package cli_test import ( "context" "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" "strings" "testing" "time" ) // 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 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, 5*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, 5*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, 5*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, 5*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) } }