package cli_test import ( "context" "errors" "fmt" "os" osexec "os/exec" "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 TestCLIExecutePersistentWaitsForSlowFirstOutput(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "slow-echo": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.2; printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 50, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-1", Target: "slow-echo", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() if events[len(events)-1].Type != noderuntime.EventTypeComplete { t.Fatalf("expected last event to 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 TestCLIExecutePersistentProcessExitReturnsError(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "exit-on-input": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "before-exit\n"; exit 2; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 500, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-1", Target: "exit-on-input", Input: map[string]any{"prompt": "hello"}, }, sink) if err == nil { t.Fatal("expected non-nil error when persistent process exits") } errStr := err.Error() t.Logf("Got error: %q", errStr) if !strings.Contains(errStr, "before-exit") { t.Errorf("expected error to contain recent output 'before-exit', got %q", errStr) } if !strings.Contains(errStr, "exit status 2") { t.Errorf("expected error to contain exit status 'exit status 2', got %q", errStr) } if !strings.Contains(errStr, "target=exit-on-input") { t.Errorf("expected error to contain target=exit-on-input, got %q", errStr) } events := sink.Events() var errorEvent noderuntime.RuntimeEvent var hasError bool for _, e := range events { if e.Type == noderuntime.EventTypeError { hasError = true errorEvent = e } } if !hasError { t.Fatal("expected error event in emitted events") } if !strings.Contains(errorEvent.Error, "before-exit") { t.Errorf("expected error event to contain recent output, got %q", errorEvent.Error) } if len(events) > 0 && events[len(events)-1].Type == noderuntime.EventTypeComplete { t.Fatal("last event should not be complete when process exits unexpectedly") } } func TestCLIStartPersistentTerminalAndExecuteWritesPrompt(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo-persistent": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 300, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-1", Target: "echo-persistent", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() var started, completed bool for _, e := range events { switch e.Type { case noderuntime.EventTypeStart: started = true case noderuntime.EventTypeComplete: completed = true } } if !started { t.Fatal("expected start event") } if !completed { t.Fatal("expected complete event") } if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") { t.Fatalf("expected reply:hello in deltas, got %q", combined) } } func TestCLIExecutePersistentTerminalSendsCarriageReturn(t *testing.T) { testutil.RequirePTYSupport(t) if _, err := osexec.LookPath("stty"); err != nil { t.Skip("stty required") } cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "raw-tui": { Command: os.Args[0], Args: []string{"-test.run=TestRawTUIHelperProcess", "--"}, Env: []string{"IOP_RAW_TUI_HELPER=1"}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-raw-tui", Target: "raw-tui", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") { t.Fatalf("expected reply:hello in deltas, got %q", combined) } if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete { t.Fatalf("expected complete event, got %s", last.Type) } } func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) { testutil.RequirePTYSupport(t) if _, err := osexec.LookPath("stty"); err != nil { t.Skip("stty required") } cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-warning": { Command: os.Args[0], Args: []string{"-test.run=TestRawTUIHelperProcess", "--"}, Env: []string{"IOP_CLAUDE_WARNING_HELPER=1"}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 250, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-claude-warning", Target: "claude-warning", 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 TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning(t *testing.T) { testutil.RequirePTYSupport(t) if _, err := osexec.LookPath("stty"); err != nil { t.Skip("stty required") } cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-trust-warning": { Command: os.Args[0], Args: []string{"-test.run=TestRawTUIHelperProcess", "--"}, Env: []string{"IOP_CLAUDE_TRUST_WARNING_HELPER=1"}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 250, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-claude-trust-warning", Target: "claude-trust-warning", 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 TestCLIExecutePersistentClaudeTUICancelsSessionLimitUpgradePrompt(t *testing.T) { testutil.RequirePTYSupport(t) if _, err := osexec.LookPath("stty"); err != nil { t.Skip("stty required") } cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-tui": { Command: os.Args[0], Args: []string{"-test.run=TestRawTUIHelperProcess", "--"}, Env: []string{"IOP_CLAUDE_RATE_LIMIT_HELPER=1"}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 500, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-claude-rate-limit", Target: "claude-tui", Input: map[string]any{"prompt": "hello"}, }, sink) if !errors.Is(err, noderuntime.ErrRunCancelled) { t.Fatalf("expected run cancelled, got %v", err) } var cancelMsg string for _, ev := range sink.Events() { if ev.Type == noderuntime.EventTypeCancelled { cancelMsg = ev.Message } if ev.Type == noderuntime.EventTypeError { t.Fatalf("expected cancellation, got error event %q", ev.Error) } } if !strings.Contains(cancelMsg, "session limit") { t.Fatalf("expected cancelled event to mention session limit, got %q", cancelMsg) } } func TestCLIExecutePersistentClaudeTUIReplaysPromptAfterStartupReadyRace(t *testing.T) { testutil.RequirePTYSupport(t) if _, err := osexec.LookPath("stty"); err != nil { t.Skip("stty required") } cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-tui": { Command: os.Args[0], Args: []string{"-test.run=TestRawTUIHelperProcess", "--"}, Env: []string{"IOP_CLAUDE_READY_REPLAY_HELPER=1"}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 40, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-claude-ready-race", Target: "claude-tui", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } if combined := testutil.CollectDeltas(sink.Events()); combined != "replay:hello" { t.Fatalf("expected replayed prompt response, got %q", combined) } } func TestRawTUIHelperProcess(t *testing.T) { switch { case os.Getenv("IOP_RAW_TUI_HELPER") == "1": runRawTUIHelper() case os.Getenv("IOP_CLAUDE_WARNING_HELPER") == "1": runClaudeWarningHelper() case os.Getenv("IOP_CLAUDE_TRUST_WARNING_HELPER") == "1": runClaudeTrustAndWarningHelper() case os.Getenv("IOP_CLAUDE_RATE_LIMIT_HELPER") == "1": runClaudeRateLimitHelper() case os.Getenv("IOP_CLAUDE_READY_REPLAY_HELPER") == "1": runClaudeReadyReplayHelper() default: return } } func setRawTerminal() { cmd := osexec.Command("stty", "raw", "-echo") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "stty raw: %v\n", err) os.Exit(2) } } func runRawTUIHelper() { setRawTerminal() readPromptAndReply() } func runClaudeWarningHelper() { setRawTerminal() fmt.Fprint(os.Stdout, "WARNING: Claude Code running in BypassPermissions mode\r\n2. Yes, I accept\r\n") _ = os.Stdout.Sync() want := []byte{'\x1b', '[', 'B', '\r'} seen := make([]byte, 0, len(want)) buf := make([]byte, 1) for { n, err := os.Stdin.Read(buf) if n > 0 { seen = append(seen, buf[0]) if len(seen) > len(want) { seen = seen[len(seen)-len(want):] } if string(seen) == string(want) { fmt.Fprint(os.Stdout, "ready\r\n") _ = os.Stdout.Sync() break } } if err != nil { os.Exit(0) } } readPromptAndReply() } func runClaudeTrustAndWarningHelper() { setRawTerminal() fmt.Fprint(os.Stdout, "Quick safety check: Is this a project you created or one you trust?\r\n❯ 1. Yes, I trust this folder\r\n2. No, exit\r\n") _ = os.Stdout.Sync() buf := make([]byte, 1) for { n, err := os.Stdin.Read(buf) if n > 0 && buf[0] == '\r' { break } if err != nil { os.Exit(0) } } fmt.Fprint(os.Stdout, "WARNING: Claude Code running in BypassPermissions mode\r\n2. Yes, I accept\r\n") _ = os.Stdout.Sync() want := []byte{'\x1b', '[', 'B', '\r'} seen := make([]byte, 0, len(want)) for { n, err := os.Stdin.Read(buf) if n > 0 { seen = append(seen, buf[0]) if len(seen) > len(want) { seen = seen[len(seen)-len(want):] } if string(seen) == string(want) { fmt.Fprint(os.Stdout, "ready\r\n") _ = os.Stdout.Sync() break } } if err != nil { os.Exit(0) } } readPromptAndReply() } func runClaudeRateLimitHelper() { setRawTerminal() for { buf := make([]byte, 1) n, err := os.Stdin.Read(buf) if n > 0 && buf[0] == '\r' { fmt.Fprint(os.Stdout, "\u25cf \u23bf You've hit your session limit \u00b7 resets 12:50pm (Asia/Seoul)\r\n") fmt.Fprint(os.Stdout, "What do you want to do?\r\n❯ 1. Stop and wait for limit to reset\r\n2. Upgrade your plan\r\n") _ = os.Stdout.Sync() } if err != nil { os.Exit(0) } } } func runClaudeReadyReplayHelper() { setRawTerminal() fmt.Fprint(os.Stdout, "Quick safety check: Is this a project you created or one you trust?\r\n❯ 1. Yes, I trust this folder\r\n2. No, exit\r\n") _ = os.Stdout.Sync() _ = readRawLine() time.Sleep(150 * time.Millisecond) _ = readRawLine() fmt.Fprint(os.Stdout, "\r\n❯ ") _ = os.Stdout.Sync() prompt := readRawLine() fmt.Fprintf(os.Stdout, "\r\n\u25cf replay:%s\r\n\u276f ", prompt) _ = os.Stdout.Sync() buf := make([]byte, 1) for { if _, err := os.Stdin.Read(buf); err != nil { os.Exit(0) } } } func readRawLine() string { var line []byte buf := make([]byte, 1) for { n, err := os.Stdin.Read(buf) if n > 0 { if buf[0] == '\r' || buf[0] == '\n' { return string(line) } line = append(line, buf[0]) } if err != nil { os.Exit(0) } } } func readPromptAndReply() { var prompt []byte buf := make([]byte, 1) for { n, err := os.Stdin.Read(buf) if n > 0 { if buf[0] == '\r' { fmt.Fprintf(os.Stdout, "reply:%s\n", string(prompt)) _ = os.Stdout.Sync() prompt = prompt[:0] } else { prompt = append(prompt, buf[0]) } } if err != nil { os.Exit(0) } } } func TestCLIStartPersistentTerminalEmitsRawChunksWithoutNewline(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "raw-terminal": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "chunk:%s" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() runCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(runCtx, noderuntime.ExecutionSpec{ RunID: "run-raw", Target: "raw-terminal", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "chunk:hello") { t.Fatalf("expected raw chunk in deltas, got %q", combined) } if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete { t.Fatalf("expected complete event, got %s", last.Type) } } func TestCLIStartPersistentTerminalUsesWidePTY(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "size-terminal": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do stty size; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() runCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(runCtx, noderuntime.ExecutionSpec{ RunID: "run-size", Target: "size-terminal", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "720 1024") { t.Fatalf("expected wide PTY size in deltas, got %q", combined) } } func TestCLIExecutePersistentClaudeTUIFiltersTerminalChrome(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-tui": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '\033[2C\r\033[7A\342\227\217\033[1Cmessage:%s\r\n\302\267 Cogitating... (1s, 1 tokens)\r\n\342\235\257 ' "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() runCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(runCtx, noderuntime.ExecutionSpec{ RunID: "run-filter", Target: "claude-tui", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() combined := testutil.CollectDeltas(events) if combined != "message:hello" { t.Fatalf("expected only assistant message delta, got %q", combined) } if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" { t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message) } } func TestCLIExecutePersistentClaudeTUIStreamsLineBeforeIdle(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-tui": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf 'sud fragment\r\n\342\234\242 Dilly-dallying\342\200\246\r\n* Dilly-dallying\342\200\246\r\n'; sleep 0.05; printf '\342\227\217 sudo runs commands\r\n\342\235\257 '; sleep 0.15; printf '\r\033[2A\033[2K\342\227\217 sudo runs commands with elevated privileges.\r\n\342\235\257 '; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 300, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() runCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(runCtx, noderuntime.ExecutionSpec{ RunID: "run-growing", Target: "claude-tui", Input: map[string]any{"prompt": "explain sudo"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() combined := testutil.CollectDeltas(events) if combined != "sudo runs commands with elevated privileges." { t.Fatalf("expected full growing message, got %q", combined) } if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" { t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message) } } func TestCLIExecutePersistentClaudeTUIWaitsForPromptReadyAfterPartialOutput(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-tui": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '\342\227\217 first chunk\r\n\342\234\242 Dilly-dallying\342\200\246\r\n'; sleep 0.15; printf 'second chunk\r\n\342\235\257 '; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 60, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() runCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(runCtx, noderuntime.ExecutionSpec{ RunID: "run-delayed-tail", Target: "claude-tui", Input: map[string]any{"prompt": "write two chunks"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() combined := testutil.CollectDeltas(events) if !strings.Contains(combined, "first chunk") || !strings.Contains(combined, "second chunk") || strings.Index(combined, "first chunk") > strings.Index(combined, "second chunk") { t.Fatalf("expected full delayed message, got %q", combined) } if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" { t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message) } } func TestCLIExecutePersistentClaudeTUIDoesNotCompleteOnStatusOnlyIdle(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "claude-tui": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '3\r\n\r\nThundering\342\200\2466\r\n'; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 60, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() runCtx, cancel := context.WithTimeout(ctx, 220*time.Millisecond) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(runCtx, noderuntime.ExecutionSpec{ RunID: "run-status-only", Target: "claude-tui", Input: map[string]any{"prompt": "hello"}, }, sink) if err == nil { t.Fatal("execute completed successfully before any assistant message") } for _, event := range sink.Events() { if event.Type == noderuntime.EventTypeDelta { t.Fatalf("status-only output should not be emitted as delta: %q", event.Delta) } if event.Type == noderuntime.EventTypeComplete { t.Fatalf("status-only output should not emit complete: %+v", event) } } } // TestCLIExecutePersistentCancelDoesNotTerminateSession verifies that a // cancelled run leaves the session alive so subsequent runs can reuse it. func TestCLIExecutePersistentCancelDoesNotTerminateSession(t *testing.T) { testutil.RequirePTYSupport(t) // ResponseIdleTimeoutMS is short so the cancel-drain exits quickly, // allowing the second execute to acquire the session lock promptly. cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "slow-echo": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.05; printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 150, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() // First execute: cancel before idle timeout fires. firstCtx, firstCancel := context.WithTimeout(ctx, 100*time.Millisecond) defer firstCancel() sink1 := &testutil.FakeSink{} _ = c.Execute(firstCtx, noderuntime.ExecutionSpec{ RunID: "run-1", Target: "slow-echo", Input: map[string]any{"prompt": "first"}, }, sink1) // Give time for cancel drain to finish (drain uses idleTimeout=150ms). time.Sleep(400 * time.Millisecond) // Second execute on same session — must succeed (session still alive). execCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() sink2 := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-2", Target: "slow-echo", Input: map[string]any{"prompt": "second"}, }, sink2) if err != nil { t.Fatalf("second execute after cancel must succeed (session should be alive): %v", err) } events2 := sink2.Events() if len(events2) == 0 || events2[len(events2)-1].Type != noderuntime.EventTypeComplete { t.Fatalf("expected complete event on second run, got %v", events2) } } func TestCLIExecutePersistentConsecutiveExecutes(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "consecutive-echo": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() sink1 := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-1", Target: "consecutive-echo", Input: map[string]any{"prompt": "first"}, }, sink1) if err != nil { t.Fatalf("first execute: %v", err) } events1 := sink1.Events() if events1[len(events1)-1].Type != noderuntime.EventTypeComplete { t.Fatalf("first run: expected last event to be complete, got %q", events1[len(events1)-1].Type) } sink2 := &testutil.FakeSink{} err = c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-2", Target: "consecutive-echo", Input: map[string]any{"prompt": "second"}, }, sink2) if err != nil { t.Fatalf("second execute: %v", err) } events2 := sink2.Events() if events2[len(events2)-1].Type != noderuntime.EventTypeComplete { t.Fatalf("second run: expected last event to be complete, got %q", events2[len(events2)-1].Type) } for _, e := range events2 { if e.Type == noderuntime.EventTypeDelta && strings.Contains(e.Delta, "first") { t.Fatalf("second run received first run's delta (session contamination): %q", e.Delta) } } } func TestCLIExecutePersistentCreatesIndependentSessionsForSameProfile(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "multi-echo": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 200, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() sinkA := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-a", Target: "multi-echo", SessionID: "session-a", Input: map[string]any{"prompt": "alpha"}, }, sinkA) if err != nil { t.Fatalf("session-a execute: %v", err) } sinkB := &testutil.FakeSink{} err = c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-b", Target: "multi-echo", SessionID: "session-b", Input: map[string]any{"prompt": "beta"}, }, sinkB) if err != nil { t.Fatalf("session-b execute: %v", err) } if combined := testutil.CollectDeltas(sinkA.Events()); !strings.Contains(combined, "alpha") { t.Fatalf("session-a: expected alpha in deltas, got %q", combined) } if combined := testutil.CollectDeltas(sinkB.Events()); !strings.Contains(combined, "beta") { t.Fatalf("session-b: expected beta in deltas, got %q", combined) } if combined := testutil.CollectDeltas(sinkA.Events()); strings.Contains(combined, "beta") { t.Fatalf("session-a contaminated with session-b output: %q", combined) } } func TestCLIExecutePersistentRequireExistingSessionFailsWhenMissing(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo-req": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, ResponseIdleTimeoutMS: 200, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-1", Target: "echo-req", SessionID: "nonexistent", SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "hello"}, }, sink) if err == nil { t.Fatal("expected error for require-existing with missing session") } if strings.Contains(err.Error(), "start") { t.Fatalf("should not have started a new process, got: %v", err) } } func TestCLIExecutePersistent_UserCancelEmitsUserCancelMessage(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "slow-echo": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 10; printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 500, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithCancel(ctx) sink := &testutil.FakeSink{} go func() { time.Sleep(100 * time.Millisecond) cancel() }() err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-usercancel", Target: "slow-echo", Input: map[string]any{"prompt": "hello"}, }, sink) if err == nil { t.Fatalf("expected error, got nil") } events := sink.Events() var lastMsg string var cancelEvent bool 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 != "user-cancel" { t.Fatalf("expected cancel Message 'user-cancel', got %q", lastMsg) } } func TestCLIExecutePersistent_TimeoutEmitsTimeoutMessage(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "slow-echo": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 10; printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 500, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() sink := &testutil.FakeSink{} executeErr := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-timeout", Target: "slow-echo", Input: map[string]any{"prompt": "hello"}, }, sink) if executeErr == nil { t.Fatalf("expected error, got nil") } events := sink.Events() var lastMsg string var cancelEvent bool 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 TestCLIExecutePersistent_CompletionMarkerLineEndsRun(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "marker-line": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; printf "<>\n"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 500, StartupIdleTimeoutMS: 50, CompletionMarker: config.CompletionMarkerConf{Line: "<>"}, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-marker-line", Target: "marker-line", Input: map[string]any{"prompt": "hello"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() last := events[len(events)-1] if last.Type != noderuntime.EventTypeComplete { t.Fatalf("expected last event to be complete, got %q", last.Type) } if last.Message != "completion-marker" { t.Fatalf("expected Message 'completion-marker', got %q", last.Message) } if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") { t.Fatalf("expected reply:hello in deltas, got %q", combined) } } func TestCLIExecutePersistent_CompletionMarkerRegexEndsRun(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "marker-regex": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; printf "DONE 42\n"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 500, StartupIdleTimeoutMS: 50, CompletionMarker: config.CompletionMarkerConf{Regex: "^DONE \\d+$"}, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-marker-regex", Target: "marker-regex", Input: map[string]any{"prompt": "hi"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() last := events[len(events)-1] if last.Type != noderuntime.EventTypeComplete { t.Fatalf("expected last event to be complete, got %q", last.Type) } if last.Message != "completion-marker" { t.Fatalf("expected Message 'completion-marker', got %q", last.Message) } if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hi") { t.Fatalf("expected reply:hi in deltas, got %q", combined) } } func TestCLIExecutePersistent_NoMarkerFallsBackToIdleTimeout(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "no-marker": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.1; printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 50, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-no-marker", Target: "no-marker", Input: map[string]any{"prompt": "fallback"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() last := events[len(events)-1] if last.Type != noderuntime.EventTypeComplete { t.Fatalf("expected last event to be complete, got %q", last.Type) } if last.Message != "idle-timeout" { t.Fatalf("expected Message 'idle-timeout' when no marker is set, got %q", last.Message) } if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:fallback") { t.Fatalf("expected reply:fallback in deltas, got %q", combined) } } func TestCLIExecutePersistent_IdleTimeoutMessageReason(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "idle-reason": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.1; printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 50, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } defer func() { _ = c.Stop(ctx) }() execCtx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() sink := &testutil.FakeSink{} err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: "run-idle-reason", Target: "idle-reason", Input: map[string]any{"prompt": "test"}, }, sink) if err != nil { t.Fatalf("execute: %v", err) } events := sink.Events() var complete *noderuntime.RuntimeEvent for i := range events { if events[i].Type == noderuntime.EventTypeComplete { complete = &events[i] } } if complete == nil { t.Fatal("expected complete event") } if complete.Message != "idle-timeout" { t.Fatalf("expected Message 'idle-timeout', got %q", complete.Message) } } func TestCLIExecutePersistentMaintainsHundredLogicalSessions(t *testing.T) { if testing.Short() { t.Skip("skipping heavy session test in short mode") } testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "hundred-echo": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, ResponseIdleTimeoutMS: 300, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() const n = 20 for i := 0; i < n; i++ { sessionID := "session-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26)) sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-first-" + sessionID, Target: "hundred-echo", SessionID: sessionID, Input: map[string]any{"prompt": "ping-" + sessionID}, }, sink) if err != nil { t.Fatalf("session %q first execute: %v", sessionID, err) } } // Second round — all sessions must still be alive. for i := 0; i < n; i++ { sessionID := "session-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26)) sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-second-" + sessionID, Target: "hundred-echo", SessionID: sessionID, Input: map[string]any{"prompt": "pong-" + sessionID}, }, sink) if err != nil { t.Fatalf("session %q second execute: %v", sessionID, err) } } _ = c.Stop(ctx) }