package controller import ( "context" "errors" "os" "path/filepath" "slices" "strings" "testing" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/modelfetcher" "github.com/kandev/kandev/internal/agent/usage" "github.com/kandev/kandev/internal/common/logger" ) // testAgent is a minimal implementation of agents.Agent for testing purposes. // Embeds StandardPassthrough to optionally satisfy agents.PassthroughAgent. type testAgent struct { agents.StandardPassthrough id string name string displayName string description string enabled bool runtime *agents.RuntimeConfig permissionSettings map[string]agents.PermissionSetting logoData []byte // supportsMCP feeds the DiscoveryResult returned by IsInstalled so the // E2E-mock discovery bypass can verify capability propagation. Most // tests don't care and the zero value matches the legacy behaviour. supportsMCP bool mcpConfigPaths []string } func (a *testAgent) ID() string { return a.id } func (a *testAgent) Name() string { return a.name } func (a *testAgent) DisplayName() string { return a.displayName } func (a *testAgent) Description() string { return a.description } func (a *testAgent) Enabled() bool { return a.enabled } func (a *testAgent) DisplayOrder() int { return 0 } func (a *testAgent) Logo(v agents.LogoVariant) []byte { return a.logoData } func (a *testAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) { return &agents.DiscoveryResult{ Available: false, SupportsMCP: a.supportsMCP, MCPConfigPaths: a.mcpConfigPaths, }, nil } // BuildCommand builds a command using runtime config and permission flags. // Model/mode are applied through ACP session configuration at session start, not via CLI. func (a *testAgent) BuildCommand(opts agents.CommandOptions) agents.Command { rt := a.Runtime() if rt == nil { return agents.Command{} } cmd := make([]string, len(rt.Cmd.Args())) copy(cmd, rt.Cmd.Args()) cmd = applyTestPermissionFlags(cmd, a.permissionSettings, opts.PermissionValues) return agents.NewCommand(cmd...) } func applyTestPermissionFlags(cmd []string, permSettings map[string]agents.PermissionSetting, values map[string]bool) []string { if permSettings == nil || values == nil { return cmd } for name, setting := range permSettings { if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { continue } v, ok := values[name] if !ok || !v { continue } if setting.CLIFlagValue != "" { cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) } else { parts := strings.Fields(setting.CLIFlag) cmd = append(cmd, parts...) } } return cmd } func (a *testAgent) PermissionSettings() map[string]agents.PermissionSetting { return a.permissionSettings } func (a *testAgent) Runtime() *agents.RuntimeConfig { return a.runtime } func (a *testAgent) BillingType() usage.BillingType { return usage.BillingTypeAPIKey } func (a *testAgent) RemoteAuth() *agents.RemoteAuth { return nil } func (a *testAgent) InstallScript() string { return "" } func newTestController(agentList map[string]agents.Agent) *Controller { log, _ := logger.NewLogger(logger.LoggingConfig{ Level: "error", Format: "json", }) reg := registry.NewRegistry(log) for _, ag := range agentList { _ = reg.Register(ag) } return &Controller{ agentRegistry: reg, modelCache: modelfetcher.NewCache(), logger: log, } } func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { agentList := map[string]agents.Agent{ "test-agent": &testAgent{ id: "test-agent", name: "test-agent", enabled: true, runtime: &agents.RuntimeConfig{ Cmd: agents.NewCommand("test-cli", "--verbose"), ModelFlag: agents.NewParam("--model", "{model}"), }, permissionSettings: map[string]agents.PermissionSetting{ "auto_approve": { Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes", }, }, }, } controller := newTestController(agentList) req := CommandPreviewRequest{ Model: "gpt-4", PermissionSettings: map[string]bool{"auto_approve": true}, CLIPassthrough: false, } result, err := controller.PreviewAgentCommand(context.Background(), "test-agent", req) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } if !result.Supported { t.Error("PreviewAgentCommand() Supported = false, want true") } // --model is no longer emitted: model is applied through ACP session configuration. expectedParts := []string{"test-cli", "--verbose", "--yes"} for _, part := range expectedParts { found := false for _, cmdPart := range result.Command { if cmdPart == part { found = true break } } if !found { t.Errorf("PreviewAgentCommand() command missing %q, got %v", part, result.Command) } } for _, cmdPart := range result.Command { if cmdPart == "--model" { t.Errorf("PreviewAgentCommand() should not emit --model, got %v", result.Command) } } } func TestController_PreviewAgentCommand_RejectsInvalidCommandPrefix(t *testing.T) { controller := newTestController(map[string]agents.Agent{ "test-agent": &testAgent{ id: "test-agent", name: "test-agent", enabled: true, runtime: &agents.RuntimeConfig{Cmd: agents.NewCommand("test-cli")}, }, }) for _, prefix := range []string{`greywall "unterminated`, `'' --arg`, `--not-a-launcher`} { _, err := controller.PreviewAgentCommand(context.Background(), "test-agent", CommandPreviewRequest{ CommandPrefix: prefix, }) if !errors.Is(err, ErrInvalidCommandPrefix) { t.Errorf("prefix %q: expected ErrInvalidCommandPrefix, got %v", prefix, err) } } } func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) { agentList := map[string]agents.Agent{ "claude-code": &testAgent{ id: "claude-code", name: "claude-code", enabled: true, runtime: &agents.RuntimeConfig{ Cmd: agents.NewCommand("claude"), ModelFlag: agents.NewParam("--model", "{model}"), }, StandardPassthrough: agents.StandardPassthrough{ Cfg: agents.PassthroughConfig{ Supported: true, PassthroughCmd: agents.NewCommand("npx", "-y", "@anthropic-ai/claude-code"), ModelFlag: agents.NewParam("--model", "{model}"), PromptFlag: agents.NewParam("--prompt", "{prompt}"), }, PermSettings: map[string]agents.PermissionSetting{ "dangerously_skip_permissions": { Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions", }, }, }, permissionSettings: map[string]agents.PermissionSetting{ "dangerously_skip_permissions": { Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions", }, }, }, } controller := newTestController(agentList) req := CommandPreviewRequest{ Model: "claude-sonnet-4-20250514", PermissionSettings: map[string]bool{"dangerously_skip_permissions": true}, CLIPassthrough: true, } result, err := controller.PreviewAgentCommand(context.Background(), "claude-code", req) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } // Verify it uses passthrough command if len(result.Command) < 3 || result.Command[0] != "npx" { t.Errorf("PreviewAgentCommand() should use passthrough command, got %v", result.Command) } // Verify model flag is present hasModel := false for i, part := range result.Command { if part == "--model" && i+1 < len(result.Command) && result.Command[i+1] == "claude-sonnet-4-20250514" { hasModel = true break } } if !hasModel { t.Errorf("PreviewAgentCommand() missing model flag, got %v", result.Command) } // Verify permission flag is present hasPermFlag := false for _, part := range result.Command { if part == "--dangerously-skip-permissions" { hasPermFlag = true break } } if !hasPermFlag { t.Errorf("PreviewAgentCommand() missing permission flag, got %v", result.Command) } // Verify prompt placeholder is NOT present in preview (prompt is injected at runtime, not in preview) for _, part := range result.Command { if part == "--prompt" || part == "{prompt}" { t.Errorf("PreviewAgentCommand() should not include prompt placeholder in passthrough preview, got %v", result.Command) break } } } func TestController_PreviewAgentCommand_AgentNotFound(t *testing.T) { controller := newTestController(map[string]agents.Agent{}) _, err := controller.PreviewAgentCommand(context.Background(), "nonexistent", CommandPreviewRequest{}) if err == nil { t.Error("PreviewAgentCommand() should return error for unknown agent") } } func TestController_PreviewAgentCommand_PassthroughDisabled(t *testing.T) { agentList := map[string]agents.Agent{ "test-agent": &testAgent{ id: "test-agent", name: "test-agent", enabled: true, runtime: &agents.RuntimeConfig{ Cmd: agents.NewCommand("test-cli"), }, StandardPassthrough: agents.StandardPassthrough{ Cfg: agents.PassthroughConfig{ Supported: true, PassthroughCmd: agents.NewCommand("passthrough-cli"), }, }, }, } controller := newTestController(agentList) // CLIPassthrough is false, so should use standard command req := CommandPreviewRequest{ CLIPassthrough: false, } result, err := controller.PreviewAgentCommand(context.Background(), "test-agent", req) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } if result.Command[0] != "test-cli" { t.Errorf("PreviewAgentCommand() should use standard command when passthrough disabled, got %v", result.Command) } } // TestController_PreviewAgentCommand_PassthroughDoesNotDuplicateCLIFlag regresses // the Auggie case where enabling CLI passthrough caused --allow-indexing to be // emitted twice: once by BuildPassthroughCommand via Settings() and again by an // unconditional CLIFlagTokens append in the preview. The launch path only adds // it via Settings(), so the preview must match. func TestController_PreviewAgentCommand_PassthroughDoesNotDuplicateCLIFlag(t *testing.T) { permSettings := map[string]agents.PermissionSetting{ "allow_indexing": { Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--allow-indexing", }, } agentList := map[string]agents.Agent{ "auggie": &testAgent{ id: "auggie", name: "auggie", enabled: true, runtime: &agents.RuntimeConfig{ Cmd: agents.NewCommand("auggie"), }, StandardPassthrough: agents.StandardPassthrough{ Cfg: agents.PassthroughConfig{ Supported: true, PassthroughCmd: agents.NewCommand("npx", "-y", "@augmentcode/auggie"), }, PermSettings: permSettings, }, permissionSettings: permSettings, }, } controller := newTestController(agentList) // Simulate the post-backfill state: allow_indexing toggle ON and the legacy // backfill has seeded --allow-indexing into CLIFlags. Both sources would // previously emit the flag. req := CommandPreviewRequest{ PermissionSettings: map[string]bool{"allow_indexing": true}, CLIPassthrough: true, CLIFlags: []dto.CLIFlagDTO{ {Flag: "--allow-indexing", Enabled: true}, }, } result, err := controller.PreviewAgentCommand(context.Background(), "auggie", req) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } count := 0 for _, part := range result.Command { if part == "--allow-indexing" { count++ } } if count != 1 { t.Errorf("PreviewAgentCommand() emitted --allow-indexing %d times, want 1; got %v", count, result.Command) } } // TestController_PreviewAgentCommand_ACPAppendsCLIFlagsOnce locks in that the // non-passthrough (ACP) branch still appends user-configured CLI flags exactly // once. Auggie's real BuildCommand intentionally does not emit --allow-indexing; // the flag must come through CLIFlagTokens. func TestController_PreviewAgentCommand_ACPAppendsCLIFlagsOnce(t *testing.T) { agentList := map[string]agents.Agent{ "auggie": &testAgent{ id: "auggie", name: "auggie", enabled: true, runtime: &agents.RuntimeConfig{ Cmd: agents.NewCommand("auggie", "--acp"), }, // No permissionSettings: mirrors Auggie's real BuildCommand which // does not emit --allow-indexing — it flows through CLIFlags only. }, } controller := newTestController(agentList) req := CommandPreviewRequest{ CLIPassthrough: false, CLIFlags: []dto.CLIFlagDTO{ {Flag: "--allow-indexing", Enabled: true}, }, } result, err := controller.PreviewAgentCommand(context.Background(), "auggie", req) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } count := 0 for _, part := range result.Command { if part == "--allow-indexing" { count++ } } if count != 1 { t.Errorf("PreviewAgentCommand() emitted --allow-indexing %d times, want 1; got %v", count, result.Command) } } func TestBuildCommandString(t *testing.T) { tests := []struct { name string cmd []string expected string }{ { name: "simple command", cmd: []string{"echo", "hello"}, expected: "echo hello", }, { name: "command with spaces", cmd: []string{"echo", "hello world"}, expected: `echo "hello world"`, }, { name: "command with quotes", cmd: []string{"echo", `say "hi"`}, expected: `echo "say \"hi\""`, }, { name: "command with special chars", cmd: []string{"bash", "-c", "echo $HOME"}, expected: `bash -c "echo $HOME"`, }, { name: "empty command", cmd: []string{}, expected: "", }, { name: "empty argument", cmd: []string{"node", "-e", ""}, expected: `node -e ""`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := buildCommandString(tt.cmd) if result != tt.expected { t.Errorf("buildCommandString(%v) = %q, want %q", tt.cmd, result, tt.expected) } }) } } func TestCommandPreviewResponse_DTO(t *testing.T) { resp := dto.CommandPreviewResponse{ Supported: true, Command: []string{"npx", "claude-code", "--model", "gpt-4"}, CommandString: `npx claude-code --model gpt-4`, } if !resp.Supported { t.Error("CommandPreviewResponse.Supported should be true") } if len(resp.Command) != 4 { t.Errorf("CommandPreviewResponse.Command length = %d, want 4", len(resp.Command)) } if resp.CommandString == "" { t.Error("CommandPreviewResponse.CommandString should not be empty") } } func TestController_GetAgentLogo_Success(t *testing.T) { logoBytes := []byte("test") agentList := map[string]agents.Agent{ "test-agent": &testAgent{ id: "test-agent", name: "test-agent", enabled: true, logoData: logoBytes, }, } ctrl := newTestController(agentList) data, err := ctrl.GetAgentLogo(context.Background(), "test-agent", agents.LogoLight) if err != nil { t.Fatalf("GetAgentLogo() error = %v", err) } if string(data) != string(logoBytes) { t.Errorf("GetAgentLogo() = %q, want %q", data, logoBytes) } } func TestController_GetAgentLogo_AgentNotFound(t *testing.T) { ctrl := newTestController(map[string]agents.Agent{}) _, err := ctrl.GetAgentLogo(context.Background(), "nonexistent", agents.LogoLight) if err != ErrAgentNotFound { t.Errorf("GetAgentLogo() error = %v, want ErrAgentNotFound", err) } } func TestController_GetAgentLogo_NoLogoData(t *testing.T) { agentList := map[string]agents.Agent{ "test-agent": &testAgent{ id: "test-agent", name: "test-agent", enabled: true, // logoData is nil }, } ctrl := newTestController(agentList) _, err := ctrl.GetAgentLogo(context.Background(), "test-agent", agents.LogoLight) if err != ErrLogoNotAvailable { t.Errorf("GetAgentLogo() error = %v, want ErrLogoNotAvailable", err) } } // TestSyncAgentFromDiscovery_UnknownAgentSkipped verifies that // syncAgentFromDiscovery returns nil (not an error) when the discovered agent // is not in the registry. This is the expected behaviour when // KANDEV_MOCK_AGENT=only suppresses all real agents: the filesystem scanner may // still detect installed CLIs, but they should be silently skipped rather than // aborting the entire profile-sync loop. func TestSyncAgentFromDiscovery_UnknownAgentSkipped(t *testing.T) { ctrl := newTestController(map[string]agents.Agent{}) // empty registry result := discovery.Availability{ Name: "some-unregistered-agent", Available: true, } if err := ctrl.syncAgentFromDiscovery(context.Background(), result); err != nil { t.Errorf("expected nil error for unknown agent, got: %v", err) } } // TestDetectAgents_E2EMockBypassesFilesystem verifies that when // KANDEV_E2E_MOCK=true, detectAgents returns results synthesised from the // registry without calling c.discovery.Detect (which would panic here // because c.discovery is nil). Every enabled agent must appear as Available. func TestDetectAgents_E2EMockBypassesFilesystem(t *testing.T) { t.Setenv("KANDEV_E2E_MOCK", "true") ctrl := newTestController(map[string]agents.Agent{ "mock-agent": &testAgent{id: "mock-agent", name: "mock-agent", enabled: true}, "other-mock": &testAgent{id: "other-mock", name: "other-mock", enabled: true}, }) // c.discovery is nil — if detectAgents called it the test would panic. results, err := ctrl.detectAgents(context.Background()) if err != nil { t.Fatalf("detectAgents() error = %v", err) } if len(results) != 2 { t.Fatalf("expected 2 results, got %d", len(results)) } for _, r := range results { if !r.Available { t.Errorf("agent %q: Available = false, want true in E2E mock mode", r.Name) } } } // TestDetectAgents_E2EMockPropagatesSupportsMCP pins the regression // that broke plan mode in every E2E test: synthAvailabilityFromRegistry // was synthesising Availability records without calling IsInstalled, so // SupportsMCP defaulted to false. The frontend's useSessionMcp then // treated every agent as MCP-incapable, the plan-mode toggle took the // layout-only path, and `togglePlanMode()` tests timed out waiting for // `Continue working on the plan...`. func TestDetectAgents_E2EMockPropagatesSupportsMCP(t *testing.T) { t.Setenv("KANDEV_E2E_MOCK", "true") ctrl := newTestController(map[string]agents.Agent{ "mock-mcp": &testAgent{ id: "mock-mcp", name: "mock-mcp", enabled: true, supportsMCP: true, mcpConfigPaths: []string{"/some/path/.mock-mcp.json"}, }, "mock-no-mcp": &testAgent{ id: "mock-no-mcp", name: "mock-no-mcp", enabled: true, supportsMCP: false, }, }) results, err := ctrl.detectAgents(context.Background()) if err != nil { t.Fatalf("detectAgents() error = %v", err) } byID := make(map[string]struct { supportsMCP bool mcpConfigPath string }, len(results)) for _, r := range results { byID[r.Name] = struct { supportsMCP bool mcpConfigPath string }{r.SupportsMCP, r.MCPConfigPath} } if got := byID["mock-mcp"]; !got.supportsMCP { t.Error("mock-mcp: SupportsMCP = false, want true (IsInstalled said yes)") } else if got.mcpConfigPath != "/some/path/.mock-mcp.json" { t.Errorf("mock-mcp: MCPConfigPath = %q, want first entry from IsInstalled", got.mcpConfigPath) } if got := byID["mock-no-mcp"]; got.supportsMCP { t.Error("mock-no-mcp: SupportsMCP = true, want false (IsInstalled said no)") } } // TestController_PreviewAgentCommand_CopilotKeepsManagedPackage verifies that // a globally installed Copilot CLI does not make the command preview bypass // the managed npm runtime. func TestController_PreviewAgentCommand_CopilotKeepsManagedPackage(t *testing.T) { controller := newTestController(map[string]agents.Agent{ "copilot-acp": agents.NewCopilotACP(), }) // A copilot binary on PATH must not change the previewed launch command. dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "copilot"), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write fake copilot: %v", err) } t.Setenv("PATH", dir) res, err := controller.PreviewAgentCommand(context.Background(), "copilot-acp", CommandPreviewRequest{}) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } want := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} if got := res.Command; !slices.Equal(got, want) { t.Errorf("preview with copilot on PATH = %v, want %v", got, want) } }