package bridge import ( "context" "encoding/json" "io" "log/slog" "os/exec" "strings" "testing" "time" ) func cliOpts(path string, chatOnly bool, max int, timeout time.Duration) Options { return Options{CursorPath: path, ChatOnly: chatOnly, MaxConcurrent: max, Timeout: timeout} } func TestNewBridge(t *testing.T) { b := NewCLIBridge(cliOpts("/usr/bin/agent", false, 4, 30*time.Second)) if b == nil { t.Fatal("NewCLIBridge returned nil") } if b.cursorPath != "/usr/bin/agent" { t.Errorf("cursorPath = %q, want %q", b.cursorPath, "/usr/bin/agent") } if cap(b.semaphore) != 4 { t.Errorf("semaphore capacity = %d, want 4", cap(b.semaphore)) } if b.timeout != 30*time.Second { t.Errorf("timeout = %v, want 30s", b.timeout) } } func TestNewBridge_DefaultConcurrency(t *testing.T) { b := NewCLIBridge(cliOpts("agent", false, 0, 10*time.Second)) if cap(b.semaphore) != 1 { t.Errorf("semaphore capacity = %d, want 1 (default)", cap(b.semaphore)) } } func TestNewBridge_NegativeConcurrency(t *testing.T) { b := NewCLIBridge(cliOpts("agent", false, -5, 10*time.Second)) if cap(b.semaphore) != 1 { t.Errorf("semaphore capacity = %d, want 1 (default for negative)", cap(b.semaphore)) } } func TestNewBridge_UsesACPWhenRequested(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) b := NewBridge(Options{CursorPath: "agent", Logger: logger, UseACP: true, MaxConcurrent: 2, Timeout: 10 * time.Second}) if _, ok := b.(*ACPBridge); !ok { t.Fatalf("expected ACPBridge, got %T", b) } } func TestBuildACPCommandArgs_NoModel(t *testing.T) { got := buildACPCommandArgs("/tmp/workspace", "auto") want := []string{"--workspace", "/tmp/workspace", "acp"} if len(got) != len(want) { t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got) } for i := range want { if got[i] != want[i] { t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got) } } } func TestBuildACPCommandArgs_WithModel(t *testing.T) { got := buildACPCommandArgs("/tmp/workspace", "sonnet-4.6") want := []string{"--workspace", "/tmp/workspace", "--model", "sonnet-4.6", "acp"} if len(got) != len(want) { t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got) } for i := range want { if got[i] != want[i] { t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got) } } } func TestBuildCLICommandArgs_PlanMode(t *testing.T) { got := buildCLICommandArgs("hello", "auto", "/tmp/workspace", "plan", true, false) wantPrefix := []string{ "--print", "--mode", "plan", "--workspace", "/tmp/workspace", "--model", "auto", "--stream-partial-output", "--output-format", "stream-json", } if len(got) != len(wantPrefix)+1 { t.Fatalf("unexpected arg length: %v", got) } for i := range wantPrefix { if got[i] != wantPrefix[i] { t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], wantPrefix[i], got) } } if got[len(got)-1] != "hello" { t.Fatalf("last arg = %q, want prompt", got[len(got)-1]) } } func TestBuildCLICommandArgs_ChatOnlyAddsTrust(t *testing.T) { got := buildCLICommandArgs("hi", "", "/tmp/ws", "plan", false, true) found := false for _, a := range got { if a == "--trust" { found = true break } } if !found { t.Fatalf("expected --trust when chatOnly=true, got args: %v", got) } } func TestBuildCLICommandArgs_AgentModeOmitsModeFlagAndAddsTrust(t *testing.T) { got := buildCLICommandArgs("hi", "", "/Users/me/Desktop", "agent", false, false) for _, a := range got { if a == "--mode" { t.Fatalf("agent mode should not emit --mode flag, args: %v", got) } } hasTrust := false for _, a := range got { if a == "--trust" { hasTrust = true break } } if !hasTrust { t.Fatalf("agent mode should imply --trust, args: %v", got) } } // mockCmdBridge builds a bridge that executes a fake command for channel logic testing. // //nolint:unused func mockCmdBridge(t *testing.T) *CLIBridge { t.Helper() return NewCLIBridge(cliOpts("echo", false, 2, 5*time.Second)) } func TestExecute_ContextCancelled(t *testing.T) { b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second)) ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately outputChan, errChan := b.Execute(ctx, "test prompt", "gpt-4", "") // Should receive error due to cancelled context select { case err := <-errChan: if err == nil { t.Error("expected error from cancelled context, got nil") } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for error from cancelled context") } // outputChan should be closed select { case _, ok := <-outputChan: if ok { t.Error("expected outputChan to be closed") } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for outputChan to close") } } func TestExecute_SemaphoreBlocking(t *testing.T) { b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second)) // Fill the semaphore b.semaphore <- struct{}{} ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() _, errChan := b.Execute(ctx, "test", "model", "") // Should get error because semaphore is full and context times out select { case err := <-errChan: if err == nil { t.Error("expected error, got nil") } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for semaphore blocking error") } // Release the semaphore <-b.semaphore } func TestExecute_InvalidCommand(t *testing.T) { b := NewCLIBridge(cliOpts("/nonexistent/command", false, 1, 5*time.Second)) ctx := context.Background() outputChan, errChan := b.Execute(ctx, "test", "model", "") var outputs []string for line := range outputChan { outputs = append(outputs, line) } // Should have error from starting invalid command select { case err := <-errChan: if err == nil { t.Error("expected error for invalid command, got nil") } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for error") } } func TestExecute_ValidJSONOutput(t *testing.T) { // Use "printf" to simulate JSON line output b := NewCLIBridge(cliOpts("printf", false, 2, 5*time.Second)) ctx := context.Background() // printf with JSON lines outputChan, errChan := b.Execute(ctx, `{"type":"assistant","message":{"content":[{"text":"hello"}]}}\n{"type":"result"}`, "model", "") var outputs []string for line := range outputChan { outputs = append(outputs, line) } // Check errChan for any errors select { case err := <-errChan: if err != nil { t.Logf("error (may be expected): %v", err) } default: } if len(outputs) == 0 { t.Log("no outputs received (printf may not handle newlines as expected)") } else { t.Logf("received %d output lines", len(outputs)) } } func TestHandleACPNotification_ForwardsAgentMessageChunk(t *testing.T) { w := &acpWorker{} var got strings.Builder w.setActiveSinkLocked(func(text string) { got.WriteString(text) }) params, err := json.Marshal(map[string]interface{}{ "sessionId": "s1", "update": map[string]interface{}{ "sessionUpdate": "agent_message_chunk", "content": map[string]interface{}{ "type": "text", "text": "嗨", }, }, }) if err != nil { t.Fatalf("marshal params: %v", err) } handled := w.handleACPNotification(acpMessage{ Method: "session/update", Params: params, }) if !handled { t.Fatal("expected session/update to be handled") } if got.String() != "嗨" { t.Fatalf("sink text = %q, want %q", got.String(), "嗨") } } func TestHandleACPNotification_IgnoresNonAssistantContentUpdate(t *testing.T) { w := &acpWorker{} var got strings.Builder w.setActiveSinkLocked(func(text string) { got.WriteString(text) }) params, err := json.Marshal(map[string]interface{}{ "sessionId": "s1", "update": map[string]interface{}{ "sessionUpdate": "planner_thought_chunk", "content": map[string]interface{}{ "type": "text", "text": "Handling user greetings", }, }, }) if err != nil { t.Fatalf("marshal params: %v", err) } handled := w.handleACPNotification(acpMessage{ Method: "session/update", Params: params, }) if !handled { t.Fatal("expected session/update to be handled") } if got.String() != "" { t.Fatalf("sink text = %q, want empty", got.String()) } } func TestReadLoop_DoesNotPanicWhenReaderDoneIsNil(t *testing.T) { w := &acpWorker{ pending: make(map[int]chan acpResponse), } defer func() { if r := recover(); r != nil { t.Fatalf("readLoop should not panic when readerDone is nil, got %v", r) } }() w.readLoop(io.NopCloser(strings.NewReader(""))) } // Ensure exec is used (imported but may appear unused without integration tests) var _ = exec.Command