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" ) 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, 5*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 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 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) }