package cli_test import ( "context" "fmt" "path/filepath" "strings" "testing" "time" "go.uber.org/zap" clipkg "iop/apps/node/internal/adapters/cli" "iop/apps/node/internal/adapters/cli/internal/testutil" "iop/apps/node/internal/adapters/cli/status" noderuntime "iop/apps/node/internal/runtime" "iop/packages/go/config" ) func TestCLIStartPersistentReturnsErrorWhenProcessExitsDuringStartup(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "exits-immediately": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 200, }, }, } c := clipkg.New(cfg, zap.NewNop()) if err := c.Start(context.Background()); err == nil { t.Fatal("expected error when persistent process exits during startup") } } func TestCLIStartCleansUpStartedProfilesWhenLaterProfileFails(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "ok-profile": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, StartupIdleTimeoutMS: 50, ResponseIdleTimeoutMS: 200, }, "fail-profile": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 200, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err == nil { t.Fatal("expected Start to fail when one persistent profile exits immediately") } _ = c.Stop(ctx) _ = c.Stop(ctx) // After rollback, the pre-started default session for ok-profile is gone. // Verify with require-existing so lazy creation is not allowed. for _, model := range []string{"ok-profile", "fail-profile"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-cleanup", Target: model, SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatalf("expected Execute for %q to fail after cleanup (require-existing), got nil", model) } } } func TestCLIStartDeterministicOrder(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "Z-alive": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, StartupIdleTimeoutMS: 50, ResponseIdleTimeoutMS: 200, }, "Z-fail": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 200, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err == nil { t.Fatal("expected Start to fail when one persistent profile exits immediately") } _ = c.Stop(ctx) _ = c.Stop(ctx) // After rollback, pre-started default sessions must be gone. for _, model := range []string{"Z-alive", "Z-fail"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-cleanup", Target: model, SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatalf("expected Execute for %q to fail after cleanup (require-existing), got nil", model) } } } func TestCLIStartSkipsPersistentLazyProfileAndExecuteCreatesSession(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "lazy-echo": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, ResponseIdleTimeoutMS: 100, StartupIdleTimeoutMS: 50, Mode: "persistent-lazy", }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start should not pre-start persistent-lazy profile: %v", err) } defer func() { _ = c.Stop(ctx) }() requireExistingSink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-require-existing", Target: "lazy-echo", SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "hello"}, }, requireExistingSink); err == nil { t.Fatal("expected require-existing execute to fail before lazy session is created") } sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-create", Target: "lazy-echo", Input: map[string]any{"prompt": "hello"}, }, sink); err != nil { t.Fatalf("execute should create lazy persistent session: %v", err) } if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "reply:hello") { t.Fatalf("expected lazy session reply in deltas, got %q", combined) } } func TestCLIConcurrentExecuteAndStop(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.05; 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) } errCh := make(chan error, 10) done := make(chan struct{}) go func() { for i := 0; i < 3; i++ { sink := &testutil.FakeSink{} execCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: fmt.Sprintf("run-conc-%d", i), Target: "slow-echo", Input: map[string]any{"prompt": fmt.Sprintf("conc-%d", i)}, }, sink) cancel() if err != nil { errCh <- fmt.Errorf("concurrent execute %d: %w", i, err) } } done <- struct{}{} }() go func() { time.Sleep(200 * time.Millisecond) _ = c.Stop(ctx) done <- struct{}{} }() go func() { time.Sleep(300 * time.Millisecond) for i := 0; i < 2; i++ { sink := &testutil.FakeSink{} execCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: fmt.Sprintf("run-post-stop-%d", i), Target: "slow-echo", Input: map[string]any{"prompt": fmt.Sprintf("post-stop-%d", i)}, }, sink) cancel() if err != nil { errCh <- fmt.Errorf("post-stop execute %d: %w", i, err) } } done <- struct{}{} }() timeout := time.After(10 * time.Second) for completed := 0; completed < 3; completed++ { select { case <-done: case <-timeout: t.Fatal("TestCLIConcurrentExecuteAndStop timed out; possible deadlock") } } close(errCh) for range errCh { } } func TestCLIStartPartialRollbackWithMarkers(t *testing.T) { testutil.RequireUnixShell(t) markerDir := t.TempDir() startMarker := "START_MARKER_A" startMarkerPath := filepath.Join(markerDir, "marker_a") profileACmd := fmt.Sprintf(`mkdir -p %s; echo %s > %s/marker_a; while IFS= read -r line; do printf "reply:%%s\n" "$line"; done`, markerDir, startMarker, markerDir) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "profile-a": { Command: "sh", Args: []string{"-c", profileACmd}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 50, ResponseIdleTimeoutMS: 200, }, "profile-b": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err == nil { t.Fatal("expected Start to fail when profile-b exits immediately") } _ = c.Stop(ctx) time.Sleep(300 * time.Millisecond) testutil.RequireEventually(t, 2*time.Second, 50*time.Millisecond, func() bool { return testutil.ReadMarker(startMarkerPath) == startMarker }) // After rollback, the pre-started default session for profile-a is gone. sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-rollback", Target: "profile-a", SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatal("expected Execute for profile-a to fail with no-session after rollback (require-existing)") } } func TestCLITerminateSessionStopsOnlyTargetSession(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo-term": { 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() // Create session-a and session-b via lazy creation. for _, sid := range []string{"session-a", "session-b"} { sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-" + sid, Target: "echo-term", SessionID: sid, Input: map[string]any{"prompt": "ping"}, }, sink) if err != nil { t.Fatalf("create %s: %v", sid, err) } } // Terminate session-a only. if err := c.TerminateSession(ctx, "echo-term", "session-a"); err != nil { t.Fatalf("TerminateSession: %v", err) } // session-b must still respond. sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-b-after", Target: "echo-term", SessionID: "session-b", Input: map[string]any{"prompt": "pong"}, }, sink) if err != nil { t.Fatalf("session-b should still be alive: %v", err) } // session-a must be gone (require-existing fails). sinkA := &testutil.FakeSink{} err = c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-a-after", Target: "echo-term", SessionID: "session-a", SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "should-fail"}, }, sinkA) if err == nil { t.Fatal("expected error for terminated session-a with require-existing mode") } _ = c.Stop(ctx) } func TestCLISameSessionDifferentWorkspaceIsolated(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "pwd-echo": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do pwd; done`}, Persistent: true, Terminal: false, ResponseIdleTimeoutMS: 200, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() defer func() { _ = c.Stop(ctx) }() wsA := t.TempDir() wsB := t.TempDir() runIn := func(ws string) string { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-" + ws, Target: "pwd-echo", SessionID: "shared", Workspace: ws, Input: map[string]any{"prompt": "go"}, }, sink); err != nil { t.Fatalf("execute in %q: %v", ws, err) } return testutil.CollectDeltas(sink.Events()) } outA := runIn(wsA) outB := runIn(wsB) if !strings.Contains(outA, wsA) { t.Errorf("session for workspace A should run in %q, got %q", wsA, outA) } if !strings.Contains(outB, wsB) { t.Errorf("session for workspace B should run in %q, got %q", wsB, outB) } // Workspace A's session must not have run in workspace B's directory. if strings.Contains(outA, wsB) { t.Errorf("workspace A session leaked into workspace B dir %q: %q", wsB, outA) } // Both workspace variants of the shared session must be present and distinct. resp, err := c.HandleCommand(ctx, noderuntime.CommandRequest{Type: noderuntime.CommandTypeSessionList}) if err != nil { t.Fatalf("session list: %v", err) } if got := resp.Result["count"]; got != "2" { t.Fatalf("expected 2 isolated sessions, count = %q", got) } } func TestCLITerminateSessionStopsAllWorkspaceVariants(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo-ws": { 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() defer func() { _ = c.Stop(ctx) }() wsA := t.TempDir() wsB := t.TempDir() // Same target/sessionID across two workspaces. for _, ws := range []string{wsA, wsB} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-create-" + ws, Target: "echo-ws", SessionID: "shared", Workspace: ws, Input: map[string]any{"prompt": "ping"}, }, sink); err != nil { t.Fatalf("create session in %q: %v", ws, err) } } // The workspace-less terminate command must close every variant. if err := c.TerminateSession(ctx, "echo-ws", "shared"); err != nil { t.Fatalf("TerminateSession: %v", err) } // Both workspace variants must now be gone (require-existing fails). for _, ws := range []string{wsA, wsB} { sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-after-" + ws, Target: "echo-ws", SessionID: "shared", Workspace: ws, SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "should-fail"}, }, sink) if err == nil { t.Fatalf("workspace %q variant should be terminated, but require-existing succeeded", ws) } } } func TestCLIStopStopsAllLogicalSessions(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo-stop": { 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() // Create three logical sessions. for _, sid := range []string{"s1", "s2", "s3"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-" + sid, Target: "echo-stop", SessionID: sid, Input: map[string]any{"prompt": "ping"}, }, sink); err != nil { t.Fatalf("create %s: %v", sid, err) } } if err := c.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } // After Stop, all sessions must be gone (require-existing must fail). for _, sid := range []string{"s1", "s2", "s3"} { sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-after-" + sid, Target: "echo-stop", SessionID: sid, SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "should-fail"}, }, sink) if err == nil { t.Fatalf("session %q should be stopped after Stop()", sid) } } } func TestCLIHandleCommandUsageStatusUsesSelectedAgent(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "codex": {Command: "/tmp/fake-codex"}, "cline-m1": {Command: "cline-m1"}, }, }, zap.NewNop()) c.StatusChecker = func(ctx context.Context, target string, profile config.CLIProfileConf) (*status.UsageStatus, error) { if target == "codex" { if profile.Command != "/tmp/fake-codex" { t.Errorf("expected command /tmp/fake-codex, got %q", profile.Command) } return &status.UsageStatus{RawOutput: "fake-codex-status"}, nil } return nil, nil } _, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ Type: noderuntime.CommandTypeUsageStatus, Target: "unknown", }) if err == nil || !strings.Contains(err.Error(), "unknown target") { t.Errorf("expected unknown target error, got %v", err) } resp, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ RequestID: "req-123", Type: noderuntime.CommandTypeUsageStatus, Target: "codex", SessionID: "sess-456", Adapter: "cli", }) if err != nil { t.Fatalf("HandleCommand failed: %v", err) } if resp.UsageStatus == nil || resp.UsageStatus.RawOutput != "fake-codex-status" { t.Errorf("unexpected usage status: %+v", resp.UsageStatus) } if resp.RequestID != "req-123" { t.Errorf("expected RequestID req-123, got %s", resp.RequestID) } if resp.SessionID != "sess-456" { t.Errorf("expected SessionID sess-456, got %s", resp.SessionID) } if meta := resp.UsageStatus.Metadata; meta == nil || meta["parse_status"] != "raw_only" { t.Errorf("expected parse_status=raw_only metadata, got %+v", meta) } _, err = c.HandleCommand(context.Background(), noderuntime.CommandRequest{ Type: noderuntime.CommandType("invalid"), Target: "codex", }) if err == nil || !strings.Contains(err.Error(), "unsupported command") { t.Errorf("expected unsupported command error, got %v", err) } } func TestCLIHandleCommandUsageStatusMetadataOnly(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "codex": {Command: "/tmp/fake-codex"}, }, }, zap.NewNop()) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return &status.UsageStatus{ Metadata: map[string]string{"custom_key": "custom_value"}, }, nil } resp, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ Type: noderuntime.CommandTypeUsageStatus, Target: "codex", }) if err != nil { t.Fatalf("HandleCommand: %v", err) } if resp.UsageStatus == nil { t.Fatal("expected UsageStatus, got nil") } if got, ok := resp.UsageStatus.Metadata["parse_status"]; ok { t.Errorf("metadata-only status must not have parse_status annotation, got %q", got) } if got := resp.UsageStatus.Metadata["custom_key"]; got != "custom_value" { t.Errorf("custom_key: got %q want %q", got, "custom_value") } } func TestCLIHandleCommandUsageStatusEmptyFallback(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "codex": {Command: "/tmp/fake-codex"}, }, }, zap.NewNop()) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return &status.UsageStatus{}, nil } resp, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ Type: noderuntime.CommandTypeUsageStatus, Target: "codex", }) if err != nil { t.Fatalf("HandleCommand: %v", err) } if resp.UsageStatus == nil { t.Fatal("expected UsageStatus, got nil") } if meta := resp.UsageStatus.Metadata; meta == nil || meta["parse_status"] != "empty" { t.Errorf("expected parse_status=empty for truly empty status, got %+v", meta) } } func TestCLIHandleCommandUsageStatusNilCheckerResult(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "codex": {Command: "/tmp/fake-codex"}, }, }, zap.NewNop()) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return nil, nil } _, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ Type: noderuntime.CommandTypeUsageStatus, Target: "codex", }) if err == nil || !strings.Contains(err.Error(), "nil result") { t.Fatalf("expected nil result error, got %v", err) } } func TestCLIHandleCommandUsageStatusRawOnlyMetadata(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "codex": {Command: "/tmp/fake-codex"}, }, }, zap.NewNop()) c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) { return &status.UsageStatus{RawOutput: "some raw usage output"}, nil } resp, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ RequestID: "req-raw", Type: noderuntime.CommandTypeUsageStatus, Target: "codex", }) if err != nil { t.Fatalf("HandleCommand: %v", err) } if resp.UsageStatus == nil { t.Fatal("expected UsageStatus, got nil") } if resp.UsageStatus.RawOutput != "some raw usage output" { t.Errorf("RawOutput: got %q want %q", resp.UsageStatus.RawOutput, "some raw usage output") } if meta := resp.UsageStatus.Metadata; meta == nil || meta["parse_status"] != "raw_only" { t.Errorf("expected parse_status=raw_only, got %+v", meta) } } func TestCLIHandleCommandSessionListReturnsEmptyResult(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "codex": {Command: "/tmp/fake-codex"}, }, }, zap.NewNop()) resp, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ RequestID: "req-list", Type: noderuntime.CommandTypeSessionList, Adapter: "cli", }) if err != nil { t.Fatalf("HandleCommand: %v", err) } if resp.Result == nil { t.Fatalf("expected result map, got nil") } if got := resp.Result["count"]; got != "0" { t.Fatalf("count: got %q want %q", got, "0") } if got, ok := resp.Result["sessions"]; !ok || got != "" { t.Fatalf("sessions: got %q present=%v want empty string", got, ok) } } func TestCapabilities_OnlyConfiguredProfiles(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "foo": {Command: "foo"}, "bar": {Command: "bar"}, }, }, zap.NewNop()) caps, err := c.Capabilities(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if caps.MaxConcurrency != 0 { t.Fatalf("expected unlimited MaxConcurrency 0, got %d", caps.MaxConcurrency) } want := []string{"bar", "foo"} if len(caps.Targets) != len(want) { t.Fatalf("expected models %v, got %v", want, caps.Targets) } for i, m := range want { if caps.Targets[i] != m { t.Errorf("Models[%d] = %q, want %q", i, caps.Targets[i], m) } } } func TestCapabilities_EmptyProfilesReturnsEmptyModels(t *testing.T) { c := clipkg.New(config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{}, }, zap.NewNop()) caps, err := c.Capabilities(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(caps.Targets) != 0 { t.Errorf("expected empty models, got %v", caps.Targets) } }