package cli import ( "context" "errors" "fmt" "os" "strings" "testing" "time" "iop/apps/node/internal/adapters/cli/status" "iop/apps/node/internal/runtime" "iop/packages/go/config" ) // --- driveJSONLines tests --- type testSink struct { events []runtime.RuntimeEvent } func (s *testSink) Emit(_ context.Context, e runtime.RuntimeEvent) error { s.events = append(s.events, e) return nil } type mockLineEmitter struct { name string emitFn func(line string) ([]runtime.RuntimeEvent, error) } func (m *mockLineEmitter) Name() string { return m.name } func (m *mockLineEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) { if m.emitFn != nil { return m.emitFn(line) } return nil, nil } func TestDriveJSONLines_DispatchesEmitterEvents(t *testing.T) { input := `{"type":"message","role":"assistant","content":"hello"} not-json {"type":"message","role":"assistant","content":"world"}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(input + "\n") mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { return []runtime.RuntimeEvent{ {Type: runtime.EventTypeDelta, Delta: "a:" + line}, {Type: runtime.EventTypeDelta, Delta: "b:" + line}, }, nil }, } outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-1", outBuf, mockEmitter, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } if got := len(sink.events); got != 4 { t.Fatalf("expected 4 events, got %d", got) } for i, ev := range sink.events { if ev.RunID != "run-1" { t.Errorf("event %d: RunID = %q, want %q", i, ev.RunID, "run-1") } if ev.Timestamp.IsZero() { t.Errorf("event %d: Timestamp is zero", i) } if ev.Type == runtime.EventTypeDelta { outputTokens += len(strings.Fields(ev.Delta)) } } raw := outBuf.String() if !strings.Contains(raw, `{"type":"message"`) { t.Fatalf("outBuf missing JSON line: %q", raw) } } func TestDriveJSONLines_StopsOnEmitterError(t *testing.T) { input := `{"type":"text"} {"type":"error"}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(input + "\n") callCount := 0 mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { callCount++ if callCount == 2 { return nil, errors.New("emitter failure") } return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: "ok"}}, nil }, } _, err := driveJSONLines(context.Background(), outReader, sink, "run-2", outBuf, mockEmitter, 4*1024*1024) if err == nil { t.Fatal("expected error, got nil") } if err.Error() != "emitter failure" { t.Fatalf("unexpected error: %v", err) } if callCount != 2 { t.Fatalf("expected emitter called 2 times, got %d", callCount) } } func TestDriveJSONLines_AccumulatesRawOutput(t *testing.T) { lines := `{"type":"text","part":{"type":"text","text":"a"}} {"type":"text","part":{"type":"text","text":"b"}}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(lines + "\n") outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-int", outBuf, opencodeJSONEmitter{}, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } raw := outBuf.String() if !strings.Contains(raw, `{"type":"text"`) { t.Fatalf("outBuf missing expected line: %q", raw) } if newlineCount := strings.Count(raw, "\n"); newlineCount != 2 { t.Fatalf("expected 2 newlines in outBuf, got %d", newlineCount) } if outputTokens != 2 { t.Fatalf("expected 2 outputTokens, got %d", outputTokens) } } func TestDriveJSONLines_ScannerBufferMax(t *testing.T) { longLine := strings.Repeat("x", 100) + `{"type":"text"}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(longLine + "\n") outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-buf", outBuf, &mockLineEmitter{name: "buf"}, 50) if err != nil { t.Fatalf("driveJSONLines: %v", err) } if outputTokens != 0 { t.Fatalf("expected 0 outputTokens for line too long for buffer, got %d", outputTokens) } } func TestDriveJSONLines_SkipsEmptyAndNonJSONLines(t *testing.T) { input := "\n\nnot json at all\n \n{\"type\":\"text\"}\n" outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(input) callCount := 0 mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { callCount++ return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: line}}, nil }, } _, err := driveJSONLines(context.Background(), outReader, sink, "run-skip", outBuf, mockEmitter, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } if callCount != 1 { t.Fatalf("expected emitter called 1 time, got %d", callCount) } } func TestDriveJSONLines_OutputTokensCountedForDeltaOnly(t *testing.T) { outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(`{"type":"error"} {"type":"delta"}` + "\n") mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { if strings.Contains(line, "error") { return []runtime.RuntimeEvent{{Type: runtime.EventTypeError, Error: "bad"}}, nil } return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: "one two three"}}, nil }, } outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-tokens", outBuf, mockEmitter, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } if outputTokens != 3 { t.Fatalf("expected 3 outputTokens, got %d", outputTokens) } } // --- emitter edge case tests (not covered by blackbox tests) --- func TestStreamJSONEmitter_SkipsNonAssistantRoles(t *testing.T) { e := streamJSONEmitter{} events, _ := e.Emit(`{"type":"message","role":"user","content":"hi"}`) if len(events) != 0 { t.Fatalf("expected 0 events for user role, got %d", len(events)) } } func TestStreamJSONEmitter_ErrorEvent(t *testing.T) { e := streamJSONEmitter{} events, _ := e.Emit(`{"type":"error","error":"something broke"}`) if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "something broke" { t.Fatalf("unexpected events: %+v", events) } } func TestStreamJSONEmitter_ToolUseBecomesVisibleDelta(t *testing.T) { e := streamJSONEmitter{} events, _ := e.Emit(`{"type":"tool_use","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"go test ./..."}}`) if len(events) != 1 || events[0].Type != runtime.EventTypeDelta { t.Fatalf("unexpected events: %+v", events) } if events[0].Delta != "[tool] run_shell_command: go test ./...\n" { t.Fatalf("unexpected delta: %q", events[0].Delta) } if events[0].Metadata["kind"] != "tool_use" || events[0].Metadata["tool_name"] != "run_shell_command" { t.Fatalf("unexpected metadata: %+v", events[0].Metadata) } } func TestStreamJSONEmitter_ToolResultBecomesVisibleDelta(t *testing.T) { e := streamJSONEmitter{} events, _ := e.Emit(`{"type":"tool_result","tool_id":"tool-1","status":"success","output":"ok\n"}`) if len(events) != 1 || events[0].Type != runtime.EventTypeDelta { t.Fatalf("unexpected events: %+v", events) } if events[0].Delta != "[tool-result] success: ok\n" { t.Fatalf("unexpected delta: %q", events[0].Delta) } if events[0].Metadata["kind"] != "tool_result" { t.Fatalf("unexpected metadata: %+v", events[0].Metadata) } } func TestStreamJSONEmitter_ResultError(t *testing.T) { e := streamJSONEmitter{} events, _ := e.Emit(`{"type":"result","status":"error","error":{"message":"quota exceeded"}}`) if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "quota exceeded" { t.Fatalf("unexpected events: %+v", events) } } func TestStreamJSONEmitter_EmptyContentSkipped(t *testing.T) { e := streamJSONEmitter{} events, _ := e.Emit(`{"type":"message","role":"assistant","content":""}`) if len(events) != 0 { t.Fatalf("expected 0 events for empty content, got %d", len(events)) } } func TestClaudeJSONEmitter_ErrorResult(t *testing.T) { e := claudeJSONEmitter{} events, _ := e.Emit(`{"type":"result","is_error":true,"result":"API timeout"}`) if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "API timeout" { t.Fatalf("unexpected events: %+v", events) } } func TestClaudeJSONEmitter_NonTextDeltaSkipped(t *testing.T) { e := claudeJSONEmitter{} events, _ := e.Emit(`{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"image_delta","data":"base64"}}}`) if len(events) != 0 { t.Fatalf("expected 0 events for non-text delta, got %d", len(events)) } } func TestCodexJSONEmitter_ItemDeltaBecomesDelta(t *testing.T) { e := codexJSONEmitter{} events, _ := e.Emit(`{"type":"item.delta","item":{"type":"agent_message","delta":"Doing."}}`) if len(events) != 1 || events[0].Delta != "Doing." { t.Fatalf("unexpected events: %+v", events) } } func TestCodexJSONEmitter_OutputTextDeltaBecomesDelta(t *testing.T) { e := codexJSONEmitter{} events, _ := e.Emit(`{"type":"response.output_text.delta","delta":"chunk"}`) if len(events) != 1 || events[0].Delta != "chunk" { t.Fatalf("unexpected events: %+v", events) } } func TestCodexJSONEmitter_ContentOutputTextFallsBackToDelta(t *testing.T) { e := codexJSONEmitter{} events, _ := e.Emit(`{"type":"item.updated","item":{"type":"agent_message","message":{"content":[{"type":"output_text","text":"partial text"}]}}}`) if len(events) != 1 || events[0].Delta != "partial text" { t.Fatalf("unexpected events: %+v", events) } } func TestCodexJSONEmitter_TurnFailedBecomesError(t *testing.T) { e := codexJSONEmitter{} events, _ := e.Emit(`{"type":"turn.failed","error":{"message":"quota exceeded"}}`) if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "quota exceeded" { t.Fatalf("unexpected events: %+v", events) } } func TestCodexJSONEmitter_StandardErrorEvent(t *testing.T) { e := codexJSONEmitter{} events, _ := e.Emit(`{"type":"error","message":"network timeout"}`) if len(events) != 1 || events[0].Error != "network timeout" { t.Fatalf("unexpected events: %+v", events) } } func TestCodexJSONEmitter_NonAgentMessageSkipped(t *testing.T) { e := codexJSONEmitter{} events, _ := e.Emit(`{"type":"item.completed","item":{"type":"tool_call","text":"ls -la"}}`) if len(events) != 0 { t.Fatalf("expected 0 events for non-agent_message item, got %d", len(events)) } } func TestClineJSONEmitter_CompletionError(t *testing.T) { e := clineJSONEmitter{} events, _ := e.Emit(`{"type":"completion","status":"error","error":"task failed"}`) if len(events) != 1 || events[0].Error != "task failed" { t.Fatalf("unexpected events: %+v", events) } } func TestClineJSONEmitter_SayErrorWithFallback(t *testing.T) { e := clineJSONEmitter{} events, _ := e.Emit(`{"type":"say","say":"error","message":"fallback msg"}`) if len(events) != 1 || events[0].Error != "fallback msg" { t.Fatalf("unexpected events: %+v", events) } } func TestClineJSONEmitter_CompletionErrorWithoutErrorField(t *testing.T) { e := clineJSONEmitter{} events, _ := e.Emit(`{"type":"completion","status":"error"}`) if len(events) != 1 || events[0].Error != "cline task failed" { t.Fatalf("unexpected events: %+v", events) } } // --- emitter registry tests --- func TestEmitters_HaveDistinctNames(t *testing.T) { testCases := []struct { emitter lineEmitter want string }{ {streamJSONEmitter{}, "stream-json"}, {claudeJSONEmitter{}, "claude-json"}, {codexJSONEmitter{}, "codex-json"}, {opencodeJSONEmitter{}, "opencode-json"}, {clineJSONEmitter{}, "cline-json"}, } for _, tc := range testCases { if got := tc.emitter.Name(); got != tc.want { t.Errorf("%T.Name() = %q, want %q", tc.emitter, got, tc.want) } } } func TestExecutorForMode(t *testing.T) { c := New(config.CLIConf{}, nil) testCases := []struct { mode string persistent bool wantType string }{ {mode: "codex-exec", wantType: "codex"}, {mode: "codex-app-server", wantType: "codex-app-server"}, {mode: "antigravity-print", wantType: "antigravity"}, {mode: "opencode-sse", wantType: "opencode"}, {mode: "persistent-lazy", persistent: true, wantType: "persistent"}, {mode: "", persistent: true, wantType: "persistent"}, {mode: "", persistent: false, wantType: "oneshot"}, {mode: "unknown-mode", persistent: false, wantType: "oneshot"}, } for _, tc := range testCases { profile := config.CLIProfileConf{ Mode: tc.mode, Persistent: tc.persistent, } exec := c.executorFor(profile) var ok bool switch tc.wantType { case "codex": _, ok = exec.(*codexExecutor) case "codex-app-server": _, ok = exec.(*codexAppServerExecutor) case "antigravity": _, ok = exec.(*antigravityExecutor) case "opencode": _, ok = exec.(*opencodeExecutor) case "persistent": _, ok = exec.(*persistentExecutor) case "oneshot": _, ok = exec.(*oneshotExecutor) } if !ok { t.Errorf("executorFor(mode=%q, persistent=%t) got type %T, want %s", tc.mode, tc.persistent, exec, tc.wantType) } } } func TestCLIStartSkipsCodexAppServerPersistentStartup(t *testing.T) { // persistent=true + mode=codex-app-server must NOT be autostarted via Start(). c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "codex-as": { Mode: modeCodexAppServer, Persistent: true, Command: "false", // would fail if actually started }, }, }, nil) // shouldAutostartPersistentProfile must return false. profile := c.profiles["codex-as"] if shouldAutostartPersistentProfile(profile) { t.Fatal("shouldAutostartPersistentProfile should return false for codex-app-server mode") } // Start() must not attempt to launch the process (no error from "false"). if err := c.Start(context.Background()); err != nil { t.Fatalf("Start() returned error for codex-app-server profile: %v", err) } if len(c.persistentExecutor.sessions) != 0 { t.Fatalf("expected no persistent sessions, got %d", len(c.persistentExecutor.sessions)) } } func TestHandleSessionList_PopulatedSnapshot(t *testing.T) { c := New(config.CLIConf{}, nil) c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{} c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "alt"}] = &profileSession{} c.codexExecutor.sessions[sessionKey{target: "codex", sessionID: "default"}] = &codexExecSession{} c.codexAppServerExecutor.sessions[sessionKey{target: "codex-as", sessionID: "default"}] = &codexAppServerSession{} c.antigravityExecutor.sessions[sessionKey{target: "antigravity", sessionID: "main"}] = &antigravitySession{} c.opencodeExecutor.sessions[sessionKey{target: "opencode", sessionID: "main"}] = &opencodeSSESession{} resp := c.handleSessionList(runtime.CommandRequest{ RequestID: "req-list", Type: runtime.CommandTypeSessionList, Adapter: "cli", }) if resp.Result["count"] != "6" { t.Fatalf("count: got %q want %q", resp.Result["count"], "6") } want := "antigravity-print:antigravity/main,codex-app-server:codex-as/default,codex-exec:codex/default,opencode-sse:opencode/main,persistent:claude/alt,persistent:claude/default" if got := resp.Result["sessions"]; got != want { t.Fatalf("sessions: got %q want %q", got, want) } if resp.RequestID != "req-list" { t.Fatalf("request id not echoed: %+v", resp) } // Sorted order: antigravity-print:antigravity/main, codex-app-server:codex-as/default, // codex-exec:codex/default, opencode-sse:opencode/main, persistent:claude/alt, persistent:claude/default wantSessions := []struct{ mode, target, sessionID, label string }{ {"antigravity-print", "antigravity", "main", "antigravity-print:antigravity/main"}, {"codex-app-server", "codex-as", "default", "codex-app-server:codex-as/default"}, {"codex-exec", "codex", "default", "codex-exec:codex/default"}, {"opencode-sse", "opencode", "main", "opencode-sse:opencode/main"}, {"persistent", "claude", "alt", "persistent:claude/alt"}, {"persistent", "claude", "default", "persistent:claude/default"}, } for i, ws := range wantSessions { prefix := fmt.Sprintf("session.%d.", i) if got := resp.Result[prefix+"label"]; got != ws.label { t.Fatalf("%slabel: got %q want %q", prefix, got, ws.label) } if got := resp.Result[prefix+"mode"]; got != ws.mode { t.Fatalf("%smode: got %q want %q", prefix, got, ws.mode) } if got := resp.Result[prefix+"target"]; got != ws.target { t.Fatalf("%starget: got %q want %q", prefix, got, ws.target) } if got := resp.Result[prefix+"session_id"]; got != ws.sessionID { t.Fatalf("%ssession_id: got %q want %q", prefix, got, ws.sessionID) } } } func TestHandleSessionList_WorkspaceVariantsDistinguished(t *testing.T) { c := New(config.CLIConf{}, nil) // Same target/sessionID, two different workspaces, plus a no-workspace session. c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{} c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default", workspace: "/ws/a"}] = &profileSession{} c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default", workspace: "/ws/b"}] = &profileSession{} resp := c.handleSessionList(runtime.CommandRequest{ RequestID: "req-ws", Type: runtime.CommandTypeSessionList, }) if resp.Result["count"] != "3" { t.Fatalf("count: got %q want %q", resp.Result["count"], "3") } // Labels must be distinct so the three variants do not collide. want := "persistent:claude/default,persistent:claude/default#/ws/a,persistent:claude/default#/ws/b" if got := resp.Result["sessions"]; got != want { t.Fatalf("sessions: got %q want %q", got, want) } // Sorted by label: no-workspace first, then #/ws/a, then #/ws/b. wantEntries := []struct{ label, workspace string }{ {"persistent:claude/default", ""}, {"persistent:claude/default#/ws/a", "/ws/a"}, {"persistent:claude/default#/ws/b", "/ws/b"}, } for i, we := range wantEntries { prefix := fmt.Sprintf("session.%d.", i) if got := resp.Result[prefix+"label"]; got != we.label { t.Errorf("%slabel: got %q want %q", prefix, got, we.label) } if got := resp.Result[prefix+"workspace"]; got != we.workspace { t.Errorf("%sworkspace: got %q want %q", prefix, got, we.workspace) } if got := resp.Result[prefix+"target"]; got != "claude" { t.Errorf("%starget: got %q want %q", prefix, got, "claude") } if got := resp.Result[prefix+"session_id"]; got != "default" { t.Errorf("%ssession_id: got %q want %q", prefix, got, "default") } } } func TestHandleSessionList_NoWorkspaceHasEmptyWorkspaceField(t *testing.T) { c := New(config.CLIConf{}, nil) c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{} resp := c.handleSessionList(runtime.CommandRequest{Type: runtime.CommandTypeSessionList}) if got := resp.Result["session.0.label"]; got != "persistent:claude/default" { t.Fatalf("label: got %q want %q", got, "persistent:claude/default") } if got, ok := resp.Result["session.0.workspace"]; !ok || got != "" { t.Fatalf("workspace: got %q present=%v want empty string", got, ok) } } func TestSessionKey_NormalizesWorkspaceAndSessionID(t *testing.T) { key := newSessionKey(runtime.ExecutionSpec{ Target: "claude", Workspace: " /ws/a ", }) if key.target != "claude" { t.Errorf("target: got %q want %q", key.target, "claude") } if key.sessionID != runtime.DefaultSessionID { t.Errorf("sessionID: got %q want default %q", key.sessionID, runtime.DefaultSessionID) } if key.workspace != "/ws/a" { t.Errorf("workspace: got %q want trimmed %q", key.workspace, "/ws/a") } empty := newSessionKey(runtime.ExecutionSpec{Target: "claude", Workspace: " "}) if empty.workspace != "" { t.Errorf("blank workspace should normalize to empty, got %q", empty.workspace) } } func TestHandleSessionList_SlashInSessionID(t *testing.T) { c := New(config.CLIConf{}, nil) c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "team/a/b"}] = &profileSession{} resp := c.handleSessionList(runtime.CommandRequest{ RequestID: "req-slash", Type: runtime.CommandTypeSessionList, }) if resp.Result["count"] != "1" { t.Fatalf("count: got %q want %q", resp.Result["count"], "1") } if got := resp.Result["session.0.label"]; got != "persistent:claude/team/a/b" { t.Fatalf("session.0.label: got %q want %q", got, "persistent:claude/team/a/b") } if got := resp.Result["session.0.mode"]; got != "persistent" { t.Fatalf("session.0.mode: got %q want %q", got, "persistent") } if got := resp.Result["session.0.target"]; got != "claude" { t.Fatalf("session.0.target: got %q want %q", got, "claude") } if got := resp.Result["session.0.session_id"]; got != "team/a/b" { t.Fatalf("session.0.session_id: got %q want %q", got, "team/a/b") } } func TestJsonEmitters_RegistryMatchesImpls(t *testing.T) { expectedKeys := []string{"stream-json", "claude-json", "codex-json", "opencode-json", "cline-json"} for _, key := range expectedKeys { reg, ok := jsonEmitters[key] if !ok { t.Fatalf("jsonEmitters[%q] not found in registry", key) } if reg.emitter.Name() != key { t.Errorf("jsonEmitters[%q].emitter.Name() = %q, want %q", key, reg.emitter.Name(), key) } if reg.scanBufMax < 1024 { t.Errorf("jsonEmitters[%q].scanBufMax = %d, expected at least 1024", key, reg.scanBufMax) } } if len(jsonEmitters) != len(expectedKeys) { t.Errorf("expected %d registered emitters, got %d", len(expectedKeys), len(jsonEmitters)) } } func TestHandleUsageStatus_EnvelopeAndParseMetadata(t *testing.T) { t.Run("raw-only parse_status", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "claude": {}, }, }, nil) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return &status.UsageStatus{RawOutput: "some raw text"}, nil } resp, err := c.HandleCommand(context.Background(), runtime.CommandRequest{ RequestID: "req-usage-1", Type: runtime.CommandTypeUsageStatus, Adapter: "cli", Target: "claude", SessionID: "sess-1", }) if err != nil { t.Fatalf("HandleCommand: %v", err) } if resp.RequestID != "req-usage-1" { t.Fatalf("RequestID: got %q want %q", resp.RequestID, "req-usage-1") } if resp.Type != runtime.CommandTypeUsageStatus { t.Fatalf("Type: got %q want %q", resp.Type, runtime.CommandTypeUsageStatus) } if resp.Adapter != "cli" { t.Fatalf("Adapter: got %q want %q", resp.Adapter, "cli") } if resp.Target != "claude" { t.Fatalf("Target: got %q want %q", resp.Target, "claude") } if resp.SessionID != "sess-1" { t.Fatalf("SessionID: got %q want %q", resp.SessionID, "sess-1") } if resp.UsageStatus == nil { t.Fatal("UsageStatus is nil") } if got := resp.UsageStatus.Metadata["parse_status"]; got != "raw_only" { t.Fatalf("parse_status: got %q want %q", got, "raw_only") } }) t.Run("metadata-only no synthetic parse_status", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "claude": {}, }, }, nil) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return &status.UsageStatus{ Metadata: map[string]string{"source": "cli"}, }, nil } resp, err := c.HandleCommand(context.Background(), runtime.CommandRequest{ RequestID: "req-usage-2", Type: runtime.CommandTypeUsageStatus, Adapter: "cli", Target: "claude", SessionID: "sess-2", }) if err != nil { t.Fatalf("HandleCommand: %v", err) } if resp.UsageStatus == nil { t.Fatal("UsageStatus is nil") } if _, ok := resp.UsageStatus.Metadata["parse_status"]; ok { t.Fatalf("parse_status should not be set for metadata-only result, got %q", resp.UsageStatus.Metadata["parse_status"]) } }) } func TestHandleSessionList_CodexAppServerVisible(t *testing.T) { c := New(config.CLIConf{}, nil) c.codexAppServerExecutor.sessions[sessionKey{target: "codex-as", sessionID: "sess-1"}] = &codexAppServerSession{ threadID: "thread-abc", } resp := c.handleSessionList(runtime.CommandRequest{ RequestID: "req-cas", Type: runtime.CommandTypeSessionList, Adapter: "cli", }) if resp.Result["count"] != "1" { t.Fatalf("count: got %q want %q", resp.Result["count"], "1") } if got := resp.Result["session.0.mode"]; got != modeCodexAppServer { t.Errorf("mode: got %q want %q", got, modeCodexAppServer) } if got := resp.Result["session.0.target"]; got != "codex-as" { t.Errorf("target: got %q want %q", got, "codex-as") } if got := resp.Result["session.0.session_id"]; got != "sess-1" { t.Errorf("session_id: got %q want %q", got, "sess-1") } } func TestTerminateSession_CodexAppServerRemovesFromList(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "codex-as": {Mode: modeCodexAppServer}, }, }, nil) key := sessionKey{target: "codex-as", sessionID: "sess-1"} c.codexAppServerExecutor.sessions[key] = &codexAppServerSession{key: key} if err := c.TerminateSession(context.Background(), "codex-as", "sess-1"); err != nil { t.Fatalf("TerminateSession: %v", err) } resp := c.handleSessionList(runtime.CommandRequest{Type: runtime.CommandTypeSessionList}) if resp.Result["count"] != "0" { t.Fatalf("expected session removed, count = %q", resp.Result["count"]) } } func TestTerminateSession_RemovesAllWorkspaceVariants(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "codex": {Mode: modeCodexExec}, }, }, nil) // Same target/sessionID across no-workspace and two workspaces. for _, ws := range []string{"", "/ws/a", "/ws/b"} { key := sessionKey{target: "codex", sessionID: "sess-1", workspace: ws} c.codexExecutor.sessions[key] = &codexExecSession{key: key} } // A different sessionID must survive termination. other := sessionKey{target: "codex", sessionID: "sess-2", workspace: "/ws/a"} c.codexExecutor.sessions[other] = &codexExecSession{key: other} if err := c.TerminateSession(context.Background(), "codex", "sess-1"); err != nil { t.Fatalf("TerminateSession: %v", err) } if len(c.codexExecutor.sessions) != 1 { t.Fatalf("expected only sess-2 to remain, got %d sessions", len(c.codexExecutor.sessions)) } if _, ok := c.codexExecutor.sessions[other]; !ok { t.Fatal("sess-2 should not have been terminated") } // Terminating a target/sessionID with no sessions must report not-found. err := c.TerminateSession(context.Background(), "codex", "missing") if err == nil { t.Fatal("expected error terminating unknown session") } } func TestCloseProfileSession_PipeFallbackIdempotent(t *testing.T) { var mockCloseCalled int mockClose := func() error { mockCloseCalled++ return os.ErrClosed } sess := &profileSession{ closeFn: mockClose, } err := closeProfileSession(context.Background(), sess) if err != nil { t.Fatalf("expected nil error on idempotent close of already closed session, got: %v", err) } if mockCloseCalled != 1 { t.Errorf("expected mockClose to be called 1 time, got %d", mockCloseCalled) } } func TestCLIOneshotExecutorCwd(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "oneshot-pwd": { Command: "pwd", }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "oneshot-pwd", Workspace: tmpDir, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } var output string for _, ev := range sink.events { if ev.Type == runtime.EventTypeDelta { output += ev.Delta } } resolved, err := os.Readlink(tmpDir) if err != nil { resolved = tmpDir } if !strings.Contains(output, tmpDir) && !strings.Contains(output, resolved) { t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output) } } func TestCLIPersistentNonTerminalCwd(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "persistent-pwd": { Command: "sh", Persistent: true, }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "persistent-pwd", Workspace: tmpDir, SessionID: "sess-cwd-nt", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "pwd\n"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } defer func() { _ = c.Stop(context.Background()) }() var output string success := false resolved, err := os.Readlink(tmpDir) if err != nil { resolved = tmpDir } // Wait up to 2 seconds for output for i := 0; i < 20; i++ { output = "" for _, ev := range sink.events { if ev.Type == runtime.EventTypeDelta { output += ev.Delta } } if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) { success = true break } time.Sleep(100 * time.Millisecond) } if !success { t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output) } } func TestCLIPersistentTerminalCwd(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "persistent-term-pwd": { Command: "sh", Persistent: true, Terminal: true, }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "persistent-term-pwd", Workspace: tmpDir, SessionID: "sess-cwd-term", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "pwd"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } defer func() { _ = c.Stop(context.Background()) }() var output string success := false resolved, err := os.Readlink(tmpDir) if err != nil { resolved = tmpDir } // Wait up to 2 seconds for output for i := 0; i < 20; i++ { output = "" for _, ev := range sink.events { if ev.Type == runtime.EventTypeDelta { output += ev.Delta } } if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) { success = true break } time.Sleep(100 * time.Millisecond) } if !success { t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output) } } func TestCLIWorkspacePreflightFailures(t *testing.T) { // 1. prepareWorkspaceDir helper tests t.Run("Helper", func(t *testing.T) { // Blank workspace dir, err := prepareWorkspaceDir("") if err != nil || dir != "" { t.Errorf("expected empty dir and nil error for blank workspace, got dir=%q, err=%v", dir, err) } dir, err = prepareWorkspaceDir(" ") if err != nil || dir != "" { t.Errorf("expected empty dir and nil error for whitespace workspace, got dir=%q, err=%v", dir, err) } // Not found directory nonExistentPath := "/nonexistent/path/for/workspace/test" dir, err = prepareWorkspaceDir(nonExistentPath) if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got dir=%q, err=%v", dir, err) } // File is not a directory tmpFile, err := os.CreateTemp("", "test-workspace-file-*") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) _ = tmpFile.Close() dir, err = prepareWorkspaceDir(tmpFile.Name()) if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace is not a directory") { t.Errorf("expected 'workspace is not a directory' error, got dir=%q, err=%v", dir, err) } // Inaccessible directory if os.Geteuid() != 0 { // Create a directory and make it inaccessible inaccessibleDir := t.TempDir() + "/inaccessible" if err := os.Mkdir(inaccessibleDir, 0000); err != nil { t.Fatalf("failed to create inaccessible dir: %v", err) } dir, err = prepareWorkspaceDir(inaccessibleDir) if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace inaccessible") { t.Errorf("expected 'workspace inaccessible' error, got dir=%q, err=%v", dir, err) } // Restore permission for cleanup _ = os.Chmod(inaccessibleDir, 0700) } else { t.Log("running as root; skipping inaccessible workspace helper test") } }) // 2. Execute path preflight error propagation t.Run("ExecuteOneshot", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "oneshot-pwd": { Command: "pwd", }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "oneshot-run-id", Target: "oneshot-pwd", Workspace: "/nonexistent/path/for/workspace/test", } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) t.Run("ExecutePersistent", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "persistent-pwd": { Command: "sh", Persistent: true, }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "persistent-run-id", Target: "persistent-pwd", Workspace: "/nonexistent/path/for/workspace/test", SessionID: "sess-preflight-fail", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "pwd\n"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) t.Run("ExecuteCodexAppServer", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "codex-app": { Mode: modeCodexAppServer, Command: "codex", Persistent: true, Args: []string{"--app-server"}, }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "codex-run-id", Target: "codex-app", Workspace: "/nonexistent/path/for/workspace/test", SessionID: "sess-codex-preflight-fail", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "some prompt"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: start codex app-server") || !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'start codex app-server' and 'workspace not found' errors, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: start codex app-server") || !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'start codex app-server' and 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) t.Run("ExecuteOpencodeSSE", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "opencode-sse-target": { Mode: modeOpencodeSSE, Command: "opencode", Persistent: true, Args: []string{"serve"}, // triggers opencode-sse mode }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "opencode-run-id", Target: "opencode-sse-target", Workspace: "/nonexistent/path/for/workspace/test", SessionID: "sess-opencode-preflight-fail", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "some prompt"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: start opencode server") || !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'start opencode server' and 'workspace not found' errors, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: start opencode server") || !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'start opencode server' and 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) } func TestCLIProcessExitFailureRemainsDistinct(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "fail-oneshot": { Command: "sh", Args: []string{"-c", "exit 7"}, }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "fail-oneshot", Workspace: tmpDir, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected command execution to fail") } errStr := err.Error() // Command failure should NOT look like a workspace error. if strings.Contains(errStr, "workspace not found") || strings.Contains(errStr, "workspace is not a directory") || strings.Contains(errStr, "workspace inaccessible") { t.Errorf("process exit failure should be distinct from workspace preflight error, got: %q", errStr) } if !strings.Contains(errStr, "exit status 7") && !strings.Contains(errStr, "exit code 7") { t.Errorf("expected error to mention exit status/code 7, got: %q", errStr) } }