Skip to content

Commit e75106c

Browse files
authored
Merge pull request #2 from CarlJi/feat/enhance_basic_test
feat: Simplify API surface and improve claude streaming correctness
2 parents d9853f6 + 6a5db77 commit e75106c

13 files changed

Lines changed: 462 additions & 97 deletions

File tree

README.md

Lines changed: 24 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -195,45 +195,29 @@ agent := opencode.New(
195195

196196
```go
197197
type Executor interface {
198-
Run(ctx context.Context, req ExecRequest) (*ExecResult, error)
198+
// Exec starts a command and returns a Process handle for streaming I/O.
199+
// args[0] is the binary name; args[1:] are arguments.
200+
Exec(ctx context.Context, args []string, env map[string]string, workDir string) (*Process, error)
199201
IsHealthy(ctx context.Context) bool
200202
Close(ctx context.Context) error
201203
}
202-
```
203-
204-
### LocalExecutor
205-
206-
`xagent.NewLocalExecutor()` — the default executor. Runs CLI binaries as child processes using `os/exec`. Zero external dependencies.
207-
208-
### DockerExecutor (Demo)
209204

210-
`demo/docker_xgopilot/docker` — demo-only executor implementation that runs each CLI invocation via `docker exec` inside a long-lived container. Container lifecycle (start, stop, remove) is managed automatically.
211-
212-
```go
213-
import "github.com/goplus/xagent/demo/docker_xgopilot/docker"
214-
215-
exec, err := docker.New(
216-
docker.WithImage("goplusorg/codeagent:v0.9.6.1"),
217-
docker.WithContainerName("my-agent"),
218-
docker.WithUser("codeagent"),
219-
docker.WithWorkDir("/workspace"),
220-
docker.WithMounts([]docker.Mount{
221-
{Host: "/host/project", Container: "/workspace"},
222-
}),
223-
docker.WithPathRemap(map[string]string{"/host/project": "/workspace"}),
224-
docker.WithInit(true),
225-
docker.WithAutoRemove(true),
226-
docker.WithLogger(slog.Default()),
227-
)
205+
// Process is the handle returned by Executor.Exec.
206+
type Process struct {
207+
Stdout io.ReadCloser // subprocess stdout (NDJSON/SSE stream)
208+
Stdin io.WriteCloser // subprocess stdin; nil when not writable
209+
Wait func() (exitCode int, err error) // blocks until process exits
210+
Stderr *bytes.Buffer // captured stderr; read after Wait() returns
211+
}
228212
```
229213

230-
No external Go dependencies — requires only the `docker` CLI on `PATH`.
214+
### LocalExecutor
231215

232-
**Multi-turn with DockerExecutor:** each `docker exec` call runs in a fresh subprocess, so process-level state is not retained between invocations. Claude session continuity relies on passing `--resume <session-id>` whenever a session ID is available. This behavior is not executor-conditional, and stdin is closed after each invocation for both `LocalExecutor` and `DockerExecutor`.
216+
`xagent.NewLocalExecutor()` — the built-in default executor. Runs CLI binaries as child processes using `os/exec`. Zero external dependencies.
233217

234-
### E2BExecutor
218+
### Custom Executors
235219

236-
`github.com/goplus/xagent/executor/e2b` — runs commands inside [E2B](https://e2b.dev) cloud sandboxes. Ships as a separate Go module with its own `go.mod`.
220+
Any type implementing the `Executor` interface can be used to run agent CLI binaries in a custom environment — for example, inside a Docker container, a cloud sandbox, or over SSH. Pass your implementation via the `WithExecutor()` option when constructing an adapter. See `demo/executor_docker` for a Docker-based reference implementation.
237221

238222
## SessionManager
239223

@@ -272,16 +256,16 @@ type Event interface {
272256
}
273257
```
274258

275-
| Event type | Kind value | Description | Adapters that emit it |
276-
| ------------------- | ---------- | ----------------------------------------------------------------------------------- | ------------------------ |
277-
| `InitEvent` | 1 | Session started; carries `SessionID`, `Model`, `ToolNames`, `CLIVersion` | Claude, Gemini, OpenCode |
278-
| `TextEvent` | 2 | Incremental text delta from the model (`Delta string`) | All |
279-
| `ThinkingEvent` | 3 | Extended thinking delta (`Delta string`) | Claude |
280-
| `ToolStartEvent` | 4 | Tool invocation started; carries `ToolName`, `CallID`, `Input []byte` | Claude, Gemini, OpenCode |
281-
| `ToolEndEvent` | 5 | Tool invocation completed; carries `Output string`, `IsError bool` | Claude, Gemini, OpenCode |
282-
| `TurnCompleteEvent` | 6 | Model turn finished; carries `InputTokens`, `OutputTokens`, `CostUSD`, `StopReason` | All |
283-
| `ErrorEvent` | 7 | Backend error; `Fatal=true` means the stream is terminated | All |
284-
| `RawEvent` | 99 | Backend-specific event with no standard mapping; raw JSON in `RawJSON []byte` | All |
259+
| Event type | Kind value | Description | Adapters that emit it |
260+
| ------------------- | ---------- | -------------------------------------------------------------------------------------------- | ------------------------ |
261+
| `InitEvent` | 1 | Session started; carries `SessionID`, `Model`, `ToolNames`, `CLIVersion` | Claude, Gemini, OpenCode |
262+
| `TextEvent` | 2 | Incremental text delta from the model (`Delta string`) | All |
263+
| `ThinkingEvent` | 3 | Extended thinking delta (`Delta string`) | Claude |
264+
| `ToolStartEvent` | 4 | Tool invocation started; carries `ToolName`, `CallID`, `Input []byte`, `Source`, `MCPServer` | Claude, Gemini, OpenCode |
265+
| `ToolEndEvent` | 5 | Tool invocation completed; carries `ToolName`, `CallID`, `Output string`, `IsError bool` | Claude, Gemini, OpenCode |
266+
| `TurnCompleteEvent` | 6 | Model turn finished; carries `InputTokens`, `OutputTokens`, `CostUSD`, `StopReason` | All |
267+
| `ErrorEvent` | 7 | Backend error; `Fatal=true` means the stream is terminated | All |
268+
| `RawEvent` | 99 | Backend-specific event with no standard mapping; raw JSON in `RawJSON []byte` | All |
285269

286270
Consume events with a type switch:
287271

@@ -318,7 +302,6 @@ Helper functions `xagent.CollectText` and `xagent.CollectResult` handle the comm
318302
- OpenAI Codex CLI: https://github.com/openai/codex
319303
- Gemini CLI: https://github.com/google-gemini/gemini-cli
320304
- OpenCode: https://github.com/sst/opencode
321-
- DockerExecutor requires `docker` on `PATH` and a running Docker daemon
322305

323306
## License
324307

agent.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,4 @@ type Capabilities struct {
4141
SessionResume bool
4242
// ForkSession indicates that the agent supports forking sessions via SessionConfig.ForkSession.
4343
ForkSession bool
44-
// Daemon indicates that the agent runs as a long-lived background server process.
45-
Daemon bool
46-
// StructuredOutput indicates that the agent supports JSON output schemas.
47-
StructuredOutput bool
48-
// MultiModal indicates that the agent accepts non-text inputs (e.g. images).
49-
MultiModal bool
50-
// ExtendedThinking indicates that the agent supports explicit reasoning / thinking traces.
51-
ExtendedThinking bool
5244
}

claude/args.go

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,6 @@ func buildArgs(cfg xagent.SessionConfig, prompt string, resumeID string, fork bo
5151
args = append(args, "--max-turns", strconv.Itoa(cfg.MaxTurns))
5252
}
5353

54-
if len(cfg.OutputSchema) > 0 {
55-
args = append(args, "--output-schema", string(cfg.OutputSchema))
56-
}
57-
58-
if cfg.ReasoningEffort != "" && cfg.ReasoningEffort != xagent.ReasoningNone {
59-
args = append(args, "--reasoning-effort", string(cfg.ReasoningEffort))
60-
}
61-
6254
if cfg.MCPConfig != nil && len(cfg.MCPConfig.Servers) > 0 {
6355
mcpJSON, err := json.Marshal(buildMCPConfigJSON(cfg.MCPConfig))
6456
if err == nil {

claude/claude.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,12 @@ func (a *Agent) Name() string {
108108
return "claude"
109109
}
110110

111+
// Capabilities returns the feature flags supported by claude CLI 2.1.x.
111112
func (a *Agent) Capabilities() xagent.Capabilities {
112113
return xagent.Capabilities{
113-
Streaming: true,
114-
SessionResume: true,
115-
ForkSession: true,
116-
Daemon: false,
117-
StructuredOutput: true,
118-
ExtendedThinking: true,
114+
Streaming: true,
115+
SessionResume: true,
116+
ForkSession: true,
119117
}
120118
}
121119

claude/parser.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,18 @@ func mapEvent(obj map[string]any, raw []byte) []xagent.Event {
114114
}
115115
case "result":
116116
usage, _ := obj["usage"].(map[string]any)
117-
return []xagent.Event{xagent.TurnCompleteEvent{
117+
out := make([]xagent.Event, 0, 2)
118+
if text := toString(obj["result"]); text != "" {
119+
out = append(out, xagent.TextEvent{Delta: text, Timestamp: now})
120+
}
121+
out = append(out, xagent.TurnCompleteEvent{
118122
InputTokens: toInt(usage["input_tokens"]),
119123
OutputTokens: toInt(usage["output_tokens"]),
120124
CostUSD: toFloat(obj["total_cost_usd"]),
121125
StopReason: toString(obj["stop_reason"]),
122126
Timestamp: now,
123-
}}
127+
})
128+
return out
124129
case "tool_result":
125130
return []xagent.Event{xagent.ToolEndEvent{
126131
ToolName: toString(obj["name"]),

claude/session.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,48 @@ func (s *session) Send(ctx context.Context, prompt string) (xagent.Stream, error
6464
proc.Stdin = nil
6565
}
6666

67+
// statefulMapLine suppresses the duplicate TextEvent that claude writes into the
68+
// final 'result' line when text was already delivered via streaming 'assistant'
69+
// events (normal mode). In plan mode no assistant TextEvents are emitted, so the
70+
// 'result' line text is kept as the sole source.
71+
var assistantTextSeen bool
72+
statefulMapLine := func(line []byte) []xagent.Event {
73+
events := mapLine(line)
74+
75+
// Single pass: detect result line and text events simultaneously.
76+
var isResultLine, hasText bool
77+
for _, e := range events {
78+
switch e.(type) {
79+
case xagent.TurnCompleteEvent:
80+
isResultLine = true
81+
case xagent.TextEvent:
82+
hasText = true
83+
}
84+
}
85+
86+
if !isResultLine {
87+
if hasText {
88+
assistantTextSeen = true
89+
}
90+
return events
91+
}
92+
93+
// result line: drop its TextEvent only when assistant streaming already delivered text.
94+
if !assistantTextSeen {
95+
return events
96+
}
97+
out := make([]xagent.Event, 0, len(events))
98+
for _, e := range events {
99+
if _, ok := e.(xagent.TextEvent); ok {
100+
continue // suppress duplicate
101+
}
102+
out = append(out, e)
103+
}
104+
return out
105+
}
106+
67107
scanner := ndjson.NewScanner(proc.Stdout)
68-
return xagent.NewProcessStream(proc, scanner, mapLine,
108+
return xagent.NewProcessStream(proc, scanner, statefulMapLine,
69109
xagent.WithOnInit(func(init xagent.InitEvent) {
70110
if init.SessionID != "" {
71111
s.mu.Lock()

codex/codex.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func WithBinaryPath(path string) Option {
6666
func (a *Agent) Name() string { return "codex" }
6767

6868
func (a *Agent) Capabilities() xagent.Capabilities {
69-
return xagent.Capabilities{Streaming: true, SessionResume: true, Daemon: true, StructuredOutput: true}
69+
return xagent.Capabilities{Streaming: true, SessionResume: true}
7070
}
7171

7272
func (a *Agent) Validate(ctx context.Context) error {

0 commit comments

Comments
 (0)