package cli_test import ( "context" "strings" "testing" "time" "go.uber.org/zap" clipkg "iop/apps/node/internal/adapters/cli" "iop/apps/node/internal/adapters/cli/internal/testutil" noderuntime "iop/apps/node/internal/runtime" "iop/packages/go/config" ) func TestCLIExecuteOneShotPassesPromptAsArg(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo": { Command: "sh", Args: []string{"-c", `printf "reply:%s\n" "$1"`, "sh"}, }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-1", Target: "echo", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() var types []string for _, e := range events { types = append(types, string(e.Type)) } if len(events) < 3 { t.Fatalf("expected at least 3 events (start/delta/complete), got %v", types) } if events[0].Type != noderuntime.EventTypeStart { t.Fatalf("first event should be start, got %q", events[0].Type) } if events[len(events)-1].Type != noderuntime.EventTypeComplete { t.Fatalf("last event should be complete, got %q", events[len(events)-1].Type) } if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") { t.Fatalf("expected reply:hello in deltas, got %q", combined) } } func TestCLIExecuteOneShotAntigravityPrintProfilePassesPrompt(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "antigravity": { Command: "sh", Args: []string{"-c", `if [ "$1" = "--dangerously-skip-permissions" ] && [ "$2" = "--print" ]; then printf "reply:%s\n" "$3"; else printf "invalid args: %s %s %s\n" "$1" "$2" "$3"; fi`, "sh", "--dangerously-skip-permissions", "--print"}, }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-agy", Target: "antigravity", Input: map[string]any{"prompt": "hello-antigravity"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() if len(events) < 3 { t.Fatalf("expected at least 3 events, got %d", len(events)) } if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello-antigravity") { t.Fatalf("expected reply:hello-antigravity in deltas, got %q", combined) } } func TestCLIExecuteOneShotStreamJSONParsesAssistantContent(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"init","timestamp":"2026-05-03T00:00:00.000Z","session_id":"abc"} {"type":"message","role":"user","content":"hi"} {"type":"message","role":"assistant","content":"Hello","delta":true} {"type":"message","role":"assistant","content":" world","delta":true} {"type":"result","status":"success"} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "streamy": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "stream-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-sj", Target: "streamy", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } combined := testutil.CollectDeltas(sink.Events()) if combined != "Hello world" { t.Fatalf("expected stream-json deltas to concat to %q, got %q", "Hello world", combined) } } func TestCLIExecuteOneShotCodexJSONParsesAgentMessage(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"thread.started","thread_id":"019d-aaa"} {"type":"turn.started"} {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Hi there."}} {"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":3}} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "codexy": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "codex-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-cj", Target: "codexy", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } combined := testutil.CollectDeltas(sink.Events()) if combined != "Hi there." { t.Fatalf("expected codex-json deltas to be %q, got %q", "Hi there.", combined) } } func TestCLIExecuteOneShotClaudeJSONParsesContentBlockDeltas(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"system","subtype":"init","session_id":"abc"} {"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_1"}}} {"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}} {"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}} {"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}} {"type":"assistant","message":{"id":"msg_1","content":[{"type":"text","text":"Hello world"}]}} {"type":"stream_event","event":{"type":"content_block_stop","index":0}} {"type":"stream_event","event":{"type":"message_stop"}} {"type":"result","subtype":"success","is_error":false,"result":"Hello world"} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claudish": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "claude-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-cl", Target: "claudish", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } combined := testutil.CollectDeltas(sink.Events()) if combined != "Hello world" { t.Fatalf("expected claude-json deltas to be %q, got %q", "Hello world", combined) } } func TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}} {"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hi from opencode."}} {"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","reason":"stop"}} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "opencode": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "opencode-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-oc", Target: "opencode", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } combined := testutil.CollectDeltas(sink.Events()) if combined != "Hi from opencode." { t.Fatalf("expected opencode-json deltas to be %q, got %q", "Hi from opencode.", combined) } } func TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}} {"type":"error","sessionID":"ses_1","error":{"name":"UnknownError","data":{"message":"Model not found: definitely-invalid-provider/does-not-exist"}}} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "opencode": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "opencode-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-oc-err", Target: "opencode", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } if combined := testutil.CollectDeltas(sink.Events()); combined != "" { t.Fatalf("expected no deltas for error-only stream, got %q", combined) } want := "Model not found: definitely-invalid-provider/does-not-exist" var got string for _, e := range sink.Events() { if e.Type == noderuntime.EventTypeError { got = e.Error break } } if got != want { t.Fatalf("expected nested error message %q, got %q", want, got) } } func TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"error","sessionID":"ses_1","error":{"name":"ProviderUnavailable"}} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "opencode": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "opencode-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-oc-err2", Target: "opencode", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } var got string for _, e := range sink.Events() { if e.Type == noderuntime.EventTypeError { got = e.Error break } } if got != "ProviderUnavailable" { t.Fatalf("expected fallback to error.name, got %q", got) } } func TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' not-json {"type":"text","sessionID":"ses_1","part":{"type":"text","text":""}} {"type":"text","sessionID":"ses_1","part":{"type":"text","text":"valid"}} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "opencode": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "opencode-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-oc2", Target: "opencode", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } combined := testutil.CollectDeltas(sink.Events()) if combined != "valid" { t.Fatalf("expected only valid delta, got %q", combined) } } func TestCLIExecuteOneShotClineJSONParsesTextEvents(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"task_started","taskId":"1777860858386"} {"ts":1777860858391,"type":"say","say":"task","text":"Hi","modelInfo":{"providerId":"ollama","modelId":"qwen3.6:35b-a3b-bf16","mode":"act"}} {"ts":1777860858965,"type":"say","say":"api_req_started","text":"{\"request\":\"Hi\"}"} {"ts":1777860864009,"type":"say","say":"text","text":"Hello from Cline.","partial":false} {"type":"completion","status":"success","timestamp":1777860865000} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "cline-dgx": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "cline-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-cline", Target: "cline-dgx", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } combined := testutil.CollectDeltas(sink.Events()) if combined != "Hello from Cline." { t.Fatalf("expected cline-json deltas to be %q, got %q", "Hello from Cline.", combined) } } func TestCLIExecuteOneShotClineJSONParsesErrorEvents(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' {"type":"task_started","taskId":"1777860858386"} {"ts":1777860858965,"type":"ask","ask":"api_req_failed","text":"model unavailable"} EOF` cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "cline-dgx": { Command: "sh", Args: []string{"-c", script, "sh"}, OutputFormat: "cline-json", }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-cline-err", Target: "cline-dgx", Input: map[string]any{"prompt": "hi"}, }, sink); err != nil { t.Fatalf("execute: %v", err) } var got string for _, e := range sink.Events() { if e.Type == noderuntime.EventTypeError { got = e.Error break } } if got != "model unavailable" { t.Fatalf("expected cline error message, got %q", got) } } func TestCLIExecuteOneShotDrainsStderrConcurrently(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "noisy": { Command: "sh", Args: []string{ "-c", `i=0; while [ "$i" -lt 20000 ]; do echo "warning line $i" >&2; i=$((i+1)); done; printf "reply:%s\n" "$1"`, "sh", }, }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-noisy", Target: "noisy", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "reply:hello") { t.Fatalf("expected reply:hello in deltas, got %q", combined) } } func TestCLIExecuteOneShot_TimeoutEmitsTimeoutMessage(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "slow": { Command: "sh", Args: []string{"-c", `sleep 10`}, }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-timeout", Target: "slow", Input: map[string]any{"prompt": "hello"}, }, sink) if err != noderuntime.ErrRunCancelled { t.Fatalf("expected ErrRunCancelled, got %v", err) } var lastMsg string var cancelEvent bool events := sink.Events() if len(events) > 0 { for i := len(events) - 1; i >= 0; i-- { if events[i].Type == noderuntime.EventTypeCancelled { cancelEvent = true lastMsg = events[i].Message } } } if !cancelEvent { t.Fatal("expected cancelled event") } if lastMsg != "timeout" { t.Fatalf("expected cancel Message 'timeout', got %q", lastMsg) } } func TestCLIExecuteOneShot_UserCancelEmitsUserCancelMessage(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ // Emit a marker before blocking so the test can cancel deterministically // once the process is running and the stdout reader is active. "slow-cancel": { Command: "sh", Args: []string{"-c", `echo running; sleep 10`}, }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-usercancel", Target: "slow-cancel", Input: map[string]any{"prompt": "hello"}, }, sink) }() // Cancel only after the first stdout chunk proves the process started and the // read loop is active, so this exercises the stdout-read/command-wait // cancellation race rather than a pre-start cancellation. testutil.RequireEventually(t, 5*time.Second, 5*time.Millisecond, func() bool { return testutil.CollectDeltas(sink.Events()) != "" }) cancel() var err error select { case err = <-done: case <-time.After(5 * time.Second): t.Fatal("execute did not return promptly after cancellation") } if err != noderuntime.ErrRunCancelled { t.Fatalf("expected ErrRunCancelled, got %v", err) } var cancelCount int var lastMsg string for _, ev := range sink.Events() { switch ev.Type { case noderuntime.EventTypeCancelled: cancelCount++ lastMsg = ev.Message case noderuntime.EventTypeError: t.Fatalf("expected no error event on cancellation, got %q", ev.Error) } } if cancelCount != 1 { t.Fatalf("expected exactly one cancelled event, got %d", cancelCount) } if lastMsg != "user-cancel" { t.Fatalf("expected cancel Message 'user-cancel', got %q", lastMsg) } } func TestCLIExecuteOneShotStreamsStdoutChunks(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "chunky": { Command: "sh", Args: []string{ "-c", `printf "first"; sleep 0.2; printf "second"`, "sh", }, }, }, } c := clipkg.New(cfg, zap.NewNop()) sink := &testutil.FakeSink{} err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ RunID: "run-chunky", Target: "chunky", Input: map[string]any{"prompt": "unused"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } var deltas []string for _, event := range sink.Events() { if event.Type == noderuntime.EventTypeDelta { deltas = append(deltas, event.Delta) } } if len(deltas) < 2 { t.Fatalf("expected stdout chunks to be emitted separately, got %v", deltas) } if got := strings.Join(deltas, ""); got != "firstsecond" { t.Fatalf("combined deltas: got %q want firstsecond", got) } }