package agent import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "time" "log/slog" ) func TestNewReturnsTraecliBackend(t *testing.T) { t.Parallel() b, err := New("traecli", Config{ExecutablePath: "/nonexistent/traecli"}) if err != nil { t.Fatalf("New(traecli) error: %v", err) } if _, ok := b.(*traecliBackend); !ok { t.Fatalf("expected *traecliBackend, got %T", b) } } // fakeTraecliACPScript impersonates the official `traecli acp serve` for unit // tests. It speaks the SAME wire format the real traecli v0.120.42 emits // (captured live): method "session/update" with an "update.sessionUpdate" // discriminator (agent_thought_chunk / agent_message_chunk), session/new // returning result.sessionId + models.availableModels [{modelId,name, // description}], and session/prompt returning {stopReason:end_turn}. func fakeTraecliACPScript() string { return `#!/bin/sh if [ -n "$TRAECLI_ARGS_FILE" ]; then for arg in "$@"; do printf '%s\n' "$arg" >> "$TRAECLI_ARGS_FILE" done fi while IFS= read -r line; do if [ -n "$TRAECLI_REQUESTS_FILE" ]; then printf '%s\n' "$line" >> "$TRAECLI_REQUESTS_FILE" fi id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') case "$line" in *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true}}}}\n' "$id" ;; *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"availableModels":[{"modelId":"Doubao-Seed-2.1-Pro","name":"Doubao-Seed-2.1-Pro","description":""},{"modelId":"GLM-5.2","name":"GLM-5.2","description":""}]}}}\n' "$id" ;; *'"method":"session/load"'*) printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_loaded","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"history replay ignored"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; *'"method":"session/set_model"'*) case "$line" in *bogus-model*) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id" exit 0 ;; *) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; esac ;; *'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thinking about it"}}}}\n' printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call","toolCallId":"tc-1","name":"Shell","status":"pending","parameters":{"command":"echo hi"}}}}\n' printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-1","status":"completed","name":"Shell","output":"hi\\n"}}}\n' printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_new","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"pong"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" exit 0 ;; esac done ` } func TestTraecliBackendStreamsAndCompletes(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) backend, err := New("traecli", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new traecli backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "say pong", ExecOptions{Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } var messages []Message done := make(chan struct{}) go func() { defer close(done) for m := range session.Messages { messages = append(messages, m) } }() result := <-session.Result <-done if result.Status != "completed" { t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error) } if !strings.Contains(result.Output, "pong") { t.Fatalf("output = %q, want it to contain the assistant message 'pong'", result.Output) } if result.SessionID != "ses_new" { t.Fatalf("session id = %q, want ses_new", result.SessionID) } // The agent_message_chunk must surface as MessageText; the tool_call must // surface as a MessageToolUse normalized to a canonical tool name. var sawText, sawToolUse bool for _, m := range messages { if m.Type == MessageText && strings.Contains(m.Content, "pong") { sawText = true } if m.Type == MessageToolUse && m.Tool == "terminal" { sawToolUse = true } } if !sawText { t.Error("expected a MessageText carrying the assistant 'pong'") } if !sawToolUse { t.Errorf("expected the Shell tool_call to normalize to 'terminal'; messages=%+v", messages) } } func TestTraecliBlockedArgsFiltering(t *testing.T) { t.Parallel() tempDir := t.TempDir() argsFile := filepath.Join(tempDir, "argv.txt") fakePath := filepath.Join(tempDir, "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) backend, err := New("traecli", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{"TRAECLI_ARGS_FILE": argsFile}, }) if err != nil { t.Fatalf("new traecli backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "task", ExecOptions{ Timeout: 5 * time.Second, // Users must not be able to strip ACP mode, switch to print mode, // override the permission mode, or duplicate the subcommand. CustomArgs: []string{"acp", "serve", "--yolo", "-p", "--output-format", "json", "--permission-mode", "default", "--add-dir", "/tmp/extra"}, }) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() <-session.Result raw, err := os.ReadFile(argsFile) if err != nil { t.Fatalf("read args file: %v", err) } lines := strings.Split(strings.TrimSpace(string(raw)), "\n") wantPrefix := []string{"acp", "serve", "--yolo"} if len(lines) < len(wantPrefix) { t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines) } for i, want := range wantPrefix { if lines[i] != want { t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines) } } // The hardcoded prefix must appear exactly once each. joined := strings.Join(lines, " ") for _, once := range []string{"acp", "serve", "--yolo"} { if c := countTokens(lines, once); c != 1 { t.Errorf("expected exactly one %q, got %d (full: %q)", once, c, joined) } } for _, blocked := range []string{"-p", "--output-format", "json", "--permission-mode", "default"} { for _, got := range lines { if got == blocked { t.Errorf("blocked custom arg %q survived filtering: %q", blocked, lines) } } } // An allowed custom arg must survive. if !strings.Contains(joined, "--add-dir /tmp/extra") { t.Errorf("expected allowed custom arg --add-dir to survive, got %q", joined) } } func countTokens(lines []string, tok string) int { n := 0 for _, l := range lines { if l == tok { n++ } } return n } func TestTraecliSetModelFailureFailsTask(t *testing.T) { t.Parallel() fakePath := filepath.Join(t.TempDir(), "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) backend, err := New("traecli", Config{ExecutablePath: fakePath, Logger: slog.Default()}) if err != nil { t.Fatalf("new traecli backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "task", ExecOptions{Model: "bogus-model", Timeout: 5 * time.Second}) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() result := <-session.Result if result.Status != "failed" { t.Fatalf("expected failed on set_model error, got %q", result.Status) } if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) { t.Errorf("expected error to name the model, got %q", result.Error) } if !strings.Contains(result.Error, "model not available") { t.Errorf("expected upstream message surfaced, got %q", result.Error) } } func TestTraecliUsesSessionLoadForResume(t *testing.T) { t.Parallel() tempDir := t.TempDir() requestsFile := filepath.Join(tempDir, "requests.jsonl") fakePath := filepath.Join(tempDir, "traecli") writeTestExecutable(t, fakePath, []byte(fakeTraecliACPScript())) backend, err := New("traecli", Config{ ExecutablePath: fakePath, Logger: slog.Default(), Env: map[string]string{"TRAECLI_REQUESTS_FILE": requestsFile}, }) if err != nil { t.Fatalf("new traecli backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := backend.Execute(ctx, "continue", ExecOptions{ ResumeSessionID: "ses_existing", Timeout: 5 * time.Second, }) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() result := <-session.Result if result.Status != "completed" { t.Fatalf("expected completed, got %q (error=%q)", result.Status, result.Error) } if result.SessionID != "ses_existing" { t.Fatalf("session id = %q, want ses_existing", result.SessionID) } raw, err := os.ReadFile(requestsFile) if err != nil { t.Fatalf("read requests: %v", err) } requests := string(raw) if !strings.Contains(requests, `"method":"session/load"`) { t.Fatalf("expected session/load on resume, got:\n%s", requests) } if strings.Contains(requests, `"method":"session/resume"`) { t.Fatalf("traecli must use session/load (loadSession:true), not session/resume:\n%s", requests) } } // TestTraecliRealACPSmoke drives the REAL official `traecli acp serve` binary // end-to-end when it is installed and logged in. It is the live counterpart to // the fake-ACP tests above: it proves the backend's initialize → session/new → // session/prompt flow works against the actual binary and the user's account. // // Skipped automatically when traecli is not on PATH or the session cannot be // created (not logged in), so CI — which has neither — stays green. Run locally // with a logged-in traecli to exercise it. func TestTraecliRealACPSmoke(t *testing.T) { if testing.Short() { t.Skip("skipping real-binary smoke test in -short mode") } path, err := exec.LookPath("traecli") if err != nil { t.Skip("traecli not on PATH; skipping real-binary smoke test") } backend, err := New("traecli", Config{ExecutablePath: path, Logger: slog.Default()}) if err != nil { t.Fatalf("new traecli backend: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() session, err := backend.Execute(ctx, "Reply with exactly one word: pong. Do not use any tools.", ExecOptions{ Cwd: t.TempDir(), Timeout: 80 * time.Second, }) if err != nil { t.Fatalf("execute: %v", err) } go func() { for range session.Messages { } }() select { case result := <-session.Result: // "session/new" panics on a NOT-logged-in traecli (no models); treat // that as a skip so the test only fails for real protocol regressions. if result.Status == "failed" && strings.Contains(result.Error, "session/new") { t.Skipf("traecli not logged in (session/new failed): %v", result.Error) } if result.Status != "completed" { t.Fatalf("real traecli run did not complete: status=%q error=%q", result.Status, result.Error) } if !strings.Contains(strings.ToLower(result.Output), "pong") { t.Fatalf("expected real traecli output to contain 'pong', got %q", result.Output) } if result.SessionID == "" { t.Error("expected a non-empty session id from real traecli") } t.Logf("real traecli smoke OK: session=%s output=%q", result.SessionID, result.Output) case <-time.After(90 * time.Second): t.Fatal("timeout waiting for real traecli result") } }