From 37f4992ed59c9f442e664e030318d181d5dbfb68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Thu, 13 Aug 2026 17:25:00 +0100 Subject: [PATCH] fix(agents): expose kandev MCP tools to custom TUI agents (#2603) Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 5 (1M context) --- apps/backend/internal/agent/agents/agent.go | 25 ++- .../internal/agent/agents/tui_agent.go | 27 ++- .../internal/agent/agents/tui_agent_test.go | 75 +++++++ .../agent/mcpconfig/strategy_registry.go | 81 +++++++ .../agent/mcpconfig/strategy_registry_test.go | 68 ++++++ .../internal/agent/registry/custom_tui.go | 82 +++++-- .../agent/registry/custom_tui_mcp_test.go | 135 ++++++++++++ .../agent/registry/custom_tui_test.go | 14 +- .../internal/agent/registry/registry_test.go | 2 +- .../agent/runtime/lifecycle/container.go | 2 +- .../runtime/lifecycle/executor_docker.go | 2 +- .../runtime/lifecycle/executor_standalone.go | 2 +- .../agent/settings/controller/controller.go | 2 + .../agent/settings/controller/custom_tui.go | 112 +++++++++- .../settings/controller/custom_tui_test.go | 201 ++++++++++++++++++ .../agent/settings/controller/profile_crud.go | 1 + .../settings/controller/reconciler_test.go | 54 ++++- .../internal/agent/settings/dto/dto.go | 2 + .../settings/handlers/custom_tui_handlers.go | 65 +++++- .../agent/settings/handlers/handlers.go | 2 + .../internal/agent/settings/models/models.go | 7 + .../internal/agent/settings/store/sqlite.go | 41 +++- .../settings/store/sqlite_tui_update_test.go | 127 +++++++++++ apps/backend/internal/backendapp/services.go | 12 +- ...t_handlers_workflow_session_config_test.go | 8 +- apps/backend/pkg/websocket/actions.go | 6 + .../app/settings/agents/[agentId]/page.tsx | 18 +- .../agents/custom-tui-mcp-mount.test.tsx | 109 ++++++++++ apps/web/app/settings/agents/page.tsx | 3 + .../settings/add-tui-agent-dialog.tsx | 23 +- .../settings/custom-tui-mcp-card.tsx | 78 +++++++ .../settings/mcp-strategy-select.test.tsx | 31 +++ .../settings/mcp-strategy-select.tsx | 114 ++++++++++ apps/web/eslint.i18n.options.mjs | 1 + apps/web/lib/api/domains/settings-api.ts | 44 +++- apps/web/lib/types/backend.ts | 11 + apps/web/lib/types/http-agents.ts | 14 ++ .../handlers/agents-settings-updated.test.ts | 111 ++++++++++ apps/web/lib/ws/handlers/agents.ts | 21 ++ apps/web/src/locales/en/agents.json | 4 + apps/web/src/locales/pseudo/agents.json | 4 + apps/web/src/locales/pt-pt/agents.json | 22 +- apps/web/src/locales/zh-cn/agents.json | 4 + apps/web/src/locales/zh-hk/agents.json | 44 ++-- apps/web/src/locales/zh-tw/agents.json | 44 ++-- 45 files changed, 1742 insertions(+), 113 deletions(-) create mode 100644 apps/backend/internal/agent/mcpconfig/strategy_registry.go create mode 100644 apps/backend/internal/agent/mcpconfig/strategy_registry_test.go create mode 100644 apps/backend/internal/agent/registry/custom_tui_mcp_test.go create mode 100644 apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go create mode 100644 apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx create mode 100644 apps/web/components/settings/custom-tui-mcp-card.tsx create mode 100644 apps/web/components/settings/mcp-strategy-select.test.tsx create mode 100644 apps/web/components/settings/mcp-strategy-select.tsx create mode 100644 apps/web/lib/ws/handlers/agents-settings-updated.test.ts diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 5ca39f685c..902a021c59 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -117,12 +117,35 @@ type LoginAgent interface { } // IsPassthroughOnly returns true if the agent only supports passthrough mode -// and should not have interactive MCP tools (e.g. ask_user_question) registered. +// (a raw CLI under a PTY, no ACP protocol mode). It governs execution mode — +// notably seeding the agent's default profile as CLI passthrough — and is true +// for every TUI agent regardless of MCP configuration. +// +// For "should interactive MCP tools be registered?", use +// SupportsInteractiveMCPTools instead. The two questions used to share this one +// predicate, which is why a custom TUI agent could never get ask_user_question. func IsPassthroughOnly(a Agent) bool { _, ok := a.(*TUIAgent) return ok } +// SupportsInteractiveMCPTools reports whether interactive MCP tools (notably +// ask_user_question) should be registered for the agent's session. +// +// A TUI agent with no MCP injection strategy is a plain terminal tool that +// never speaks MCP: registering a tool it cannot call just means a question +// that hangs forever. A TUI agent *with* a strategy wraps a real MCP-capable +// CLI that kandev points at the per-session server, so it should get the same +// tools as the built-in claude-acp agent running in passthrough mode. Every +// non-TUI agent supports them. +func SupportsInteractiveMCPTools(a Agent) bool { + tui, ok := a.(*TUIAgent) + if !ok { + return true + } + return tui.MCPStrategy() != nil +} + // LogoVariant selects light or dark logo. type LogoVariant int diff --git a/apps/backend/internal/agent/agents/tui_agent.go b/apps/backend/internal/agent/agents/tui_agent.go index 58133b6bff..d29e0ea47d 100644 --- a/apps/backend/internal/agent/agents/tui_agent.go +++ b/apps/backend/internal/agent/agents/tui_agent.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/usage" "github.com/kandev/kandev/pkg/agent" ) @@ -29,6 +30,12 @@ type TUIAgentConfig struct { ModelFlag Param // e.g. NewParam("--model", "{model}") — lets users pick a model in profile settings CommandArgs []string // extra args after Command DetectOpts []DetectOption // defaults to WithCommand(Command) + // MCPStrategy materializes kandev's per-session MCP server (and the + // profile's configured servers) into the wrapped CLI's own shape. Nil — the + // default — means no injection, which is correct for a TUI tool that is not + // an MCP client at all. Resolved from the user's stored strategy key; see + // mcpconfig.StrategyByKey for why it is chosen rather than inferred. + MCPStrategy mcpconfig.PassthroughMCPStrategy } // TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools. @@ -75,6 +82,7 @@ func NewTUIAgent(cfg TUIAgentConfig) *TUIAgent { IdleTimeout: cfg.IdleTimeout, BufferMaxBytes: cfg.BufferMax, WaitForTerminal: cfg.WaitForTerm, + MCPStrategy: cfg.MCPStrategy, // Ink-based TUIs (Claude Code and similar) coalesce multi-byte // stdin reads into a paste burst, absorbing a trailing "\r" into // the pasted content instead of dispatching Enter, and enable @@ -113,7 +121,24 @@ func (a *TUIAgent) Logo(v LogoVariant) []byte { } func (a *TUIAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - return Detect(ctx, a.cfg.DetectOpts...) + result, err := Detect(ctx, a.cfg.DetectOpts...) + if err != nil { + return result, err + } + // Derive MCP support from the configured strategy rather than storing it as + // an independent flag. Discovery writes this value back over agents.supports_mcp + // on every sweep (see controller.upsertAgent), so anything set separately at + // creation time is reverted within a boot. Deriving makes that write the + // mechanism that keeps the flag correct instead of the thing fighting it, + // and makes "supports MCP but has no way to inject it" unrepresentable. + result.SupportsMCP = a.cfg.MCPStrategy != nil + return result, nil +} + +// MCPStrategy returns the configured passthrough MCP injection strategy, or nil +// when the agent does no injection. +func (a *TUIAgent) MCPStrategy() mcpconfig.PassthroughMCPStrategy { + return a.cfg.MCPStrategy } func (a *TUIAgent) BuildCommand(_ CommandOptions) Command { diff --git a/apps/backend/internal/agent/agents/tui_agent_test.go b/apps/backend/internal/agent/agents/tui_agent_test.go index 7da4f35bd1..0be583c013 100644 --- a/apps/backend/internal/agent/agents/tui_agent_test.go +++ b/apps/backend/internal/agent/agents/tui_agent_test.go @@ -1,8 +1,11 @@ package agents import ( + "context" "testing" "time" + + "github.com/kandev/kandev/internal/agent/mcpconfig" ) // Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude @@ -31,3 +34,75 @@ func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) { t.Errorf("effective submit sequence = %q, want %q", got, "\r") } } + +// TestTUIAgentDerivesSupportsMCPFromStrategy pins the mechanism that makes the +// flag survive. Discovery writes IsInstalled's SupportsMCP back over the +// agents.supports_mcp column on every sweep (controller.upsertAgent), so a flag +// set independently at creation is reverted within a boot. Deriving it here +// makes that write keep the flag correct instead of clobbering it. +func TestTUIAgentDerivesSupportsMCPFromStrategy(t *testing.T) { + withStrategy := NewTUIAgent(TUIAgentConfig{ + AgentID: "with", AgentName: "with", Command: "echo", + MCPStrategy: mcpconfig.ClaudeStrategy{}, + }) + result, err := withStrategy.IsInstalled(context.Background()) + if err != nil { + t.Fatalf("IsInstalled: %v", err) + } + if !result.SupportsMCP { + t.Error("SupportsMCP = false for an agent with a strategy, want true") + } + + without := NewTUIAgent(TUIAgentConfig{AgentID: "without", AgentName: "without", Command: "echo"}) + result, err = without.IsInstalled(context.Background()) + if err != nil { + t.Fatalf("IsInstalled: %v", err) + } + if result.SupportsMCP { + t.Error("SupportsMCP = true for an agent with no strategy, want false") + } +} + +// TestTUIAgentCarriesStrategyIntoPassthroughConfig pins the field the launch +// path actually reads: applyPassthroughMCP returns immediately when +// PassthroughConfig.MCPStrategy is nil, so a strategy that does not land here +// injects nothing no matter what else is configured. +func TestTUIAgentCarriesStrategyIntoPassthroughConfig(t *testing.T) { + a := NewTUIAgent(TUIAgentConfig{ + AgentID: "x", AgentName: "x", Command: "echo", + MCPStrategy: mcpconfig.CodexStrategy{}, + }) + if a.PassthroughConfig().MCPStrategy == nil { + t.Fatal("PassthroughConfig.MCPStrategy = nil, want the configured strategy") + } +} + +// TestInteractiveMCPToolsSplitFromPassthroughOnly pins the distinction between +// two questions that used to share one predicate. IsPassthroughOnly governs +// execution mode (it seeds the default profile as CLI passthrough), so it must +// stay true for every TUI agent. Interactive-tool registration is separate: a +// TUI agent wrapping a real MCP-capable CLI should get ask_user_question, which +// it never could while both answers came from the same type assertion. +func TestInteractiveMCPToolsSplitFromPassthroughOnly(t *testing.T) { + withStrategy := NewTUIAgent(TUIAgentConfig{ + AgentID: "with", AgentName: "with", Command: "echo", + MCPStrategy: mcpconfig.ClaudeStrategy{}, + }) + without := NewTUIAgent(TUIAgentConfig{AgentID: "without", AgentName: "without", Command: "echo"}) + + // Both are passthrough-only: neither has an ACP protocol mode. Weakening + // this would seed them as ACP agents and break them outright. + if !IsPassthroughOnly(withStrategy) { + t.Error("IsPassthroughOnly = false for a TUI agent with a strategy, want true") + } + if !IsPassthroughOnly(without) { + t.Error("IsPassthroughOnly = false for a plain TUI agent, want true") + } + + if !SupportsInteractiveMCPTools(withStrategy) { + t.Error("SupportsInteractiveMCPTools = false for a TUI agent with a strategy, want true") + } + if SupportsInteractiveMCPTools(without) { + t.Error("SupportsInteractiveMCPTools = true for a plain TUI agent, want false") + } +} diff --git a/apps/backend/internal/agent/mcpconfig/strategy_registry.go b/apps/backend/internal/agent/mcpconfig/strategy_registry.go new file mode 100644 index 0000000000..299d0bbbd1 --- /dev/null +++ b/apps/backend/internal/agent/mcpconfig/strategy_registry.go @@ -0,0 +1,81 @@ +package mcpconfig + +// Strategy keys are the stable identifiers used to persist a passthrough MCP +// strategy choice (custom TUI agents store one on their tui_config) and to +// carry it over the settings API. Built-in agents declare their strategy in Go +// and never round-trip through a key. +// +// A custom TUI agent wraps an arbitrary command — `zsh -ic "fuelclaude --model +// opus"` is a real example — so kandev cannot infer which CLI is underneath or +// how that CLI loads MCP servers. Guessing wrong is the worst outcome +// available: the config file gets written, the CLI ignores it, and nothing +// anywhere reports an error. The user picks the key instead. +const ( + // StrategyKeyNone means the agent does not get MCP injection. It is the + // default for custom TUI agents and the stored value for every agent + // created before this field existed. + StrategyKeyNone = "" + StrategyKeyClaude = "claude" + StrategyKeyCodex = "codex" + StrategyKeyCursor = "cursor" + StrategyKeyOpenCode = "opencode" + StrategyKeyPi = "pi" +) + +// StrategyOption describes one selectable strategy for the settings UI. +type StrategyOption struct { + Key string `json:"key"` + // Description is the strategy's own Describe() text, so the UI explains the + // actual mechanism ("an MCP config file passed via the --mcp-config flag") + // rather than restating the key. + Description string `json:"description"` +} + +// strategyByKey is the single source of truth for key → implementation. +// StrategyOptions and StrategyByKey both read it, so a new strategy becomes +// selectable in the UI and resolvable at launch from one edit. +var strategyByKey = map[string]PassthroughMCPStrategy{ + StrategyKeyClaude: ClaudeStrategy{}, + StrategyKeyCodex: CodexStrategy{}, + StrategyKeyCursor: CursorStrategy{}, + StrategyKeyOpenCode: OpenCodeStrategy{}, + StrategyKeyPi: PiStrategy{}, +} + +// strategyKeyOrder fixes the presentation order of StrategyOptions. Map +// iteration order is random, and a dropdown that reshuffles on every request is +// unusable. +var strategyKeyOrder = []string{ + StrategyKeyClaude, + StrategyKeyCodex, + StrategyKeyCursor, + StrategyKeyOpenCode, + StrategyKeyPi, +} + +// StrategyByKey resolves a persisted strategy key to its implementation. +// +// The empty key resolves to (nil, true): "this agent does no MCP injection" is +// a valid stored value, not a failure. An unknown key returns ok=false so the +// API layer can reject it up front — resolving it to nil instead would persist +// a typo that silently disables MCP forever. +func StrategyByKey(key string) (PassthroughMCPStrategy, bool) { + if key == StrategyKeyNone { + return nil, true + } + strategy, ok := strategyByKey[key] + return strategy, ok +} + +// StrategyOptions returns the selectable strategies in stable order, each with +// the mechanism description the UI shows next to it. +func StrategyOptions() []StrategyOption { + options := make([]StrategyOption, 0, len(strategyKeyOrder)) + for _, key := range strategyKeyOrder { + options = append(options, StrategyOption{ + Key: key, + Description: strategyByKey[key].Describe(), + }) + } + return options +} diff --git a/apps/backend/internal/agent/mcpconfig/strategy_registry_test.go b/apps/backend/internal/agent/mcpconfig/strategy_registry_test.go new file mode 100644 index 0000000000..4301d00bb8 --- /dev/null +++ b/apps/backend/internal/agent/mcpconfig/strategy_registry_test.go @@ -0,0 +1,68 @@ +package mcpconfig + +import "testing" + +func TestStrategyByKeyResolvesEveryOfferedOption(t *testing.T) { + // Every key the UI can offer must resolve, or a user could pick an option + // that is rejected on save. + for _, opt := range StrategyOptions() { + strategy, ok := StrategyByKey(opt.Key) + if !ok { + t.Errorf("StrategyByKey(%q) not ok, but it is an offered option", opt.Key) + continue + } + if strategy == nil { + t.Errorf("StrategyByKey(%q) = nil, want an implementation", opt.Key) + } + if opt.Description == "" { + t.Errorf("option %q has an empty description", opt.Key) + } + } +} + +func TestStrategyByKeyEmptyIsValidAndNil(t *testing.T) { + // "No MCP injection" is a legitimate stored value — the default for custom + // TUI agents and for every row written before the field existed. + strategy, ok := StrategyByKey(StrategyKeyNone) + if !ok { + t.Fatal("StrategyByKey(\"\") not ok, want ok (no-injection is valid)") + } + if strategy != nil { + t.Fatalf("StrategyByKey(\"\") = %T, want nil", strategy) + } +} + +func TestStrategyByKeyRejectsUnknown(t *testing.T) { + // A typo must fail loudly at the API boundary. Resolving it to nil would + // persist a value that silently disables MCP with no error anywhere. + if _, ok := StrategyByKey("cluade"); ok { + t.Fatal("StrategyByKey(\"cluade\") ok = true, want false") + } +} + +func TestStrategyOptionsAreStablyOrdered(t *testing.T) { + // Map iteration is randomised; a dropdown that reshuffles per request is + // unusable. Compare several passes rather than pinning exact contents. + first := StrategyOptions() + for range 20 { + next := StrategyOptions() + if len(next) != len(first) { + t.Fatalf("option count changed: %d then %d", len(first), len(next)) + } + for i := range first { + if next[i].Key != first[i].Key { + t.Fatalf("option order changed at %d: %q then %q", i, first[i].Key, next[i].Key) + } + } + } +} + +func TestStrategyOptionsExcludeNone(t *testing.T) { + // The empty key is represented by the UI's own "off" choice, not as a + // selectable strategy with a mechanism description. + for _, opt := range StrategyOptions() { + if opt.Key == StrategyKeyNone { + t.Fatal("StrategyOptions includes the empty key") + } + } +} diff --git a/apps/backend/internal/agent/registry/custom_tui.go b/apps/backend/internal/agent/registry/custom_tui.go index 732054b9dc..b443af810d 100644 --- a/apps/backend/internal/agent/registry/custom_tui.go +++ b/apps/backend/internal/agent/registry/custom_tui.go @@ -5,38 +5,88 @@ import ( "strings" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/mcpconfig" ) -// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and registers it. -// The command string is split into binary + args. Any {{model}} placeholder in the command -// is replaced with the model value. -func (r *Registry) RegisterCustomTUIAgent(slug, displayName, command, description, model string, commandArgs []string) error { +// CustomTUIAgentSpec is the user-provided definition of a custom TUI agent, as +// stored on the agent row's tui_config and replayed into the registry on boot. +type CustomTUIAgentSpec struct { + Slug string + DisplayName string + Command string + Description string + Model string + CommandArgs []string + // MCPStrategyKey selects how kandev injects MCP servers into the wrapped + // CLI (see mcpconfig.StrategyByKey). Empty means no injection. + MCPStrategyKey string +} + +// ErrUnknownMCPStrategy is returned when a spec names a strategy key that no +// longer resolves — a typo from an older client, or a key removed by an +// upgrade. Failing loudly beats registering the agent with MCP silently off. +var ErrUnknownMCPStrategy = fmt.Errorf("unknown MCP strategy") + +// buildCustomTUIAgent turns a user-provided spec into a TUIAgent. The command +// string is split into binary + args, and any {{model}} placeholder in the +// command is replaced with the model value. +func buildCustomTUIAgent(spec CustomTUIAgentSpec) (*agents.TUIAgent, error) { // Replace {{model}} template in the command string - resolvedCommand := command - if model != "" { - resolvedCommand = strings.ReplaceAll(command, "{{model}}", model) + resolvedCommand := spec.Command + if spec.Model != "" { + resolvedCommand = strings.ReplaceAll(spec.Command, "{{model}}", spec.Model) } // Split command into binary + args parts := strings.Fields(resolvedCommand) if len(parts) == 0 { - return fmt.Errorf("command is empty") + return nil, fmt.Errorf("command is empty") } binary := parts[0] args := parts[1:] - if len(commandArgs) > 0 { - args = append(args, commandArgs...) + if len(spec.CommandArgs) > 0 { + args = append(args, spec.CommandArgs...) } - cfg := agents.TUIAgentConfig{ - AgentID: slug, - AgentName: slug, + strategy, ok := mcpconfig.StrategyByKey(spec.MCPStrategyKey) + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey) + } + + return agents.NewTUIAgent(agents.TUIAgentConfig{ + AgentID: spec.Slug, + AgentName: spec.Slug, Command: binary, - Desc: description, - Display: displayName, + Desc: spec.Description, + Display: spec.DisplayName, WaitForTerm: true, CommandArgs: args, + MCPStrategy: strategy, + }), nil +} + +// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and +// registers it, failing if the ID is already taken. +func (r *Registry) RegisterCustomTUIAgent(spec CustomTUIAgentSpec) error { + tuiAgent, err := buildCustomTUIAgent(spec) + if err != nil { + return err } - tuiAgent := agents.NewTUIAgent(cfg) return r.Register(tuiAgent) } + +// ReplaceCustomTUIAgent rebuilds an already-registered custom TUI agent in +// place. The MCP strategy is baked into the TUIAgent at construction, so +// changing it needs a new instance rather than a mutation. +// +// This uses Replace rather than Unregister + Register because the pair leaves a +// window with no entry for the ID: a session launching in that window fails +// with "agent type not found", and a competing registration can leave the agent +// missing until restart. +func (r *Registry) ReplaceCustomTUIAgent(spec CustomTUIAgentSpec) error { + tuiAgent, err := buildCustomTUIAgent(spec) + if err != nil { + return err + } + return r.Replace(tuiAgent) +} diff --git a/apps/backend/internal/agent/registry/custom_tui_mcp_test.go b/apps/backend/internal/agent/registry/custom_tui_mcp_test.go new file mode 100644 index 0000000000..e74079c8db --- /dev/null +++ b/apps/backend/internal/agent/registry/custom_tui_mcp_test.go @@ -0,0 +1,135 @@ +package registry + +import ( + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestRegistry(t *testing.T) *Registry { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + return NewRegistry(log) +} + +func registeredStrategy(t *testing.T, reg *Registry, slug string) mcpconfig.PassthroughMCPStrategy { + t.Helper() + ag, ok := reg.Get(slug) + if !ok { + t.Fatalf("agent %q not registered", slug) + } + pt, ok := ag.(agents.PassthroughAgent) + if !ok { + t.Fatalf("agent %q does not implement PassthroughAgent", slug) + } + return pt.PassthroughConfig().MCPStrategy +} + +// TestRegisterCustomTUIAgent_StrategyKeyReachesAgent covers the contract the +// registration tests previously left untested: every case passed the zero-value +// key, so a regression that dropped the strategy on the way to the agent would +// not have failed anything here. +func TestRegisterCustomTUIAgent_StrategyKeyReachesAgent(t *testing.T) { + reg := newTestRegistry(t) + + if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{ + Slug: "with-strategy", DisplayName: "With", Command: "cli", + MCPStrategyKey: mcpconfig.StrategyKeyClaude, + }); err != nil { + t.Fatalf("RegisterCustomTUIAgent: %v", err) + } + if registeredStrategy(t, reg, "with-strategy") == nil { + t.Error("MCPStrategy = nil, want the Claude strategy") + } +} + +func TestRegisterCustomTUIAgent_NoKeyLeavesStrategyNil(t *testing.T) { + reg := newTestRegistry(t) + + if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{ + Slug: "no-strategy", DisplayName: "None", Command: "k9s", + }); err != nil { + t.Fatalf("RegisterCustomTUIAgent: %v", err) + } + if registeredStrategy(t, reg, "no-strategy") != nil { + t.Error("MCPStrategy set, want nil") + } +} + +// TestRegisterCustomTUIAgent_UnknownKeyRejectedWithoutRegistering pins that a +// bad key fails loudly instead of registering an agent whose MCP is silently off. +func TestRegisterCustomTUIAgent_UnknownKeyRejectedWithoutRegistering(t *testing.T) { + reg := newTestRegistry(t) + + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{ + Slug: "bad-key", DisplayName: "Bad", Command: "cli", MCPStrategyKey: "cluade", + }) + if !errors.Is(err, ErrUnknownMCPStrategy) { + t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err) + } + if _, ok := reg.Get("bad-key"); ok { + t.Error("agent was registered despite the rejected strategy key") + } +} + +// TestReplaceCustomTUIAgent_SwapsStrategyWithoutRemovingEntry is the reason +// Replace is used instead of Unregister + Register: the pair leaves a window in +// which the ID resolves to nothing, and a session launching then fails with +// "agent type not found". The entry must be continuously resolvable. +func TestReplaceCustomTUIAgent_SwapsStrategyWithoutRemovingEntry(t *testing.T) { + reg := newTestRegistry(t) + spec := CustomTUIAgentSpec{Slug: "swap-me", DisplayName: "Swap", Command: "cli --flag"} + if err := reg.RegisterCustomTUIAgent(spec); err != nil { + t.Fatalf("RegisterCustomTUIAgent: %v", err) + } + + spec.MCPStrategyKey = mcpconfig.StrategyKeyCodex + if err := reg.ReplaceCustomTUIAgent(spec); err != nil { + t.Fatalf("ReplaceCustomTUIAgent: %v", err) + } + + if registeredStrategy(t, reg, "swap-me") == nil { + t.Error("MCPStrategy = nil after replace") + } + // Replace must not duplicate or drop the entry, and the rest of the + // definition has to survive the rebuild. + ag, _ := reg.Get("swap-me") + want := []string{"cli", "--flag"} + got := ag.Runtime().Cmd.Args() + if len(got) != len(want) { + t.Fatalf("argv = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("argv = %#v, want %#v", got, want) + } + } +} + +// TestReplaceCustomTUIAgent_RejectsUnknownKeyLeavingEntryIntact pins that a bad +// key on an edit does not take the existing agent down with it. +func TestReplaceCustomTUIAgent_RejectsUnknownKeyLeavingEntryIntact(t *testing.T) { + reg := newTestRegistry(t) + spec := CustomTUIAgentSpec{ + Slug: "keep-me", DisplayName: "Keep", Command: "cli", + MCPStrategyKey: mcpconfig.StrategyKeyClaude, + } + if err := reg.RegisterCustomTUIAgent(spec); err != nil { + t.Fatalf("RegisterCustomTUIAgent: %v", err) + } + + bad := spec + bad.MCPStrategyKey = "nope" + if err := reg.ReplaceCustomTUIAgent(bad); !errors.Is(err, ErrUnknownMCPStrategy) { + t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err) + } + if registeredStrategy(t, reg, "keep-me") == nil { + t.Error("existing agent lost its strategy after a rejected replace") + } +} diff --git a/apps/backend/internal/agent/registry/custom_tui_test.go b/apps/backend/internal/agent/registry/custom_tui_test.go index 4584aec39c..5c38ed66a4 100644 --- a/apps/backend/internal/agent/registry/custom_tui_test.go +++ b/apps/backend/internal/agent/registry/custom_tui_test.go @@ -8,7 +8,7 @@ func TestRegisterCustomTUIAgent_Success(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - err := reg.RegisterCustomTUIAgent("my-agent", "My Agent", "my-agent --verbose", "A test agent", "", nil) + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "my-agent", DisplayName: "My Agent", Command: "my-agent --verbose", Description: "A test agent", Model: ""}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -41,7 +41,7 @@ func TestRegisterCustomTUIAgent_ModelTemplate(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - err := reg.RegisterCustomTUIAgent("tmpl-agent", "Template", "my-cli --model {{model}}", "", "best", nil) + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "tmpl-agent", DisplayName: "Template", Command: "my-cli --model {{model}}", Description: "", Model: "best"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -69,7 +69,7 @@ func TestRegisterCustomTUIAgent_ModelTemplateNotReplacedWhenEmpty(t *testing.T) log := newTestLogger() reg := NewRegistry(log) - err := reg.RegisterCustomTUIAgent("no-model", "No Model", "cli --model {{model}}", "", "", nil) + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "no-model", DisplayName: "No Model", Command: "cli --model {{model}}", Description: "", Model: ""}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -91,7 +91,7 @@ func TestRegisterCustomTUIAgent_CommandArgs(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - err := reg.RegisterCustomTUIAgent("extra-args", "Extra", "my-cli", "", "", []string{"--extra", "--flag"}) + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "extra-args", DisplayName: "Extra", Command: "my-cli", Description: "", Model: "", CommandArgs: []string{"--extra", "--flag"}}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -107,7 +107,7 @@ func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - err := reg.RegisterCustomTUIAgent("empty-cmd", "Empty", "", "", "", nil) + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "empty-cmd", DisplayName: "Empty", Command: "", Description: "", Model: ""}) if err == nil { t.Error("expected error for empty command") } @@ -117,8 +117,8 @@ func TestRegisterCustomTUIAgent_DuplicateID(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - _ = reg.RegisterCustomTUIAgent("dup-agent", "First", "first-cli", "", "", nil) - err := reg.RegisterCustomTUIAgent("dup-agent", "Second", "second-cli", "", "", nil) + _ = reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "dup-agent", DisplayName: "First", Command: "first-cli", Description: "", Model: ""}) + err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "dup-agent", DisplayName: "Second", Command: "second-cli", Description: "", Model: ""}) if err == nil { t.Error("expected error for duplicate registration") } diff --git a/apps/backend/internal/agent/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go index ea15a5a54e..d7a2572ea9 100644 --- a/apps/backend/internal/agent/registry/registry_test.go +++ b/apps/backend/internal/agent/registry/registry_test.go @@ -406,7 +406,7 @@ func TestResolveFamilyIDsReportsEveryAmbiguousCandidate(t *testing.T) { t.Run(tc.name, func(t *testing.T) { reg := NewRegistry(newTestLogger()) reg.LoadDefaults() - if err := reg.RegisterCustomTUIAgent(tc.slug, tc.displayName, "claude-tui", "", "", nil); err != nil { + if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: tc.slug, DisplayName: tc.displayName, Command: "claude-tui", Description: "", Model: ""}); err != nil { t.Fatalf("register custom agent: %v", err) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index 4c9f82e0e5..805a71492f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -272,7 +272,7 @@ func (cm *ContainerManager) createInstanceAndClient( if config.AgentConfig != nil { agentType = config.AgentConfig.ID() } - disableAskQuestion := agents.IsPassthroughOnly(config.AgentConfig) + disableAskQuestion := !agents.SupportsInteractiveMCPTools(config.AgentConfig) assumeMcpSse := false assumeMcpHttp := false requiresProcessKill := false diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index 121d6cdd34..c8eff5559b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -543,7 +543,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID var stripEnv []string if req.AgentConfig != nil { agentType = req.AgentConfig.ID() - disableAskQuestion = agents.IsPassthroughOnly(req.AgentConfig) + disableAskQuestion = !agents.SupportsInteractiveMCPTools(req.AgentConfig) if rt := req.AgentConfig.Runtime(); rt != nil { assumeMcpSse = rt.AssumeMcpSse assumeMcpHttp = rt.AssumeMcpHttp diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go index c509ee99a3..5169cc9baf 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go @@ -137,7 +137,7 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr if req.AgentConfig != nil { agentType = req.AgentConfig.ID() } - disableAskQuestion := agents.IsPassthroughOnly(req.AgentConfig) + disableAskQuestion := !agents.SupportsInteractiveMCPTools(req.AgentConfig) assumeMcpSse := false assumeMcpHttp := false requiresProcessKill := false diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 37561078d4..5c8ed20925 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -49,6 +49,8 @@ var ( ErrCommandRequired = errors.New("command is required") ErrInvalidProfileEnvVars = errors.New("invalid profile env vars") ErrInvalidCommandPrefix = errors.New("invalid command prefix") + ErrUnknownMCPStrategy = errors.New("unknown MCP strategy") + ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent") ) type Controller struct { diff --git a/apps/backend/internal/agent/settings/controller/custom_tui.go b/apps/backend/internal/agent/settings/controller/custom_tui.go index cf6f19961b..3d73c7bf87 100644 --- a/apps/backend/internal/agent/settings/controller/custom_tui.go +++ b/apps/backend/internal/agent/settings/controller/custom_tui.go @@ -8,6 +8,8 @@ import ( "regexp" "strings" + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/models" ) @@ -29,6 +31,9 @@ type CreateCustomTUIAgentRequest struct { Command string Description string CommandArgs []string + // MCPStrategy selects how kandev injects its per-session MCP server into + // the wrapped CLI. Empty means no MCP tools, which is the default. + MCPStrategy string } // CreateCustomTUIAgent registers a new custom TUI agent and persists it to the database. @@ -40,6 +45,9 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT if req.Command == "" { return nil, ErrCommandRequired } + if _, ok := mcpconfig.StrategyByKey(req.MCPStrategy); !ok { + return nil, ErrUnknownMCPStrategy + } // Check for conflict with existing registry entry if c.agentRegistry.Exists(slug) { @@ -56,9 +64,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT } // Register in the in-memory registry - if regErr := c.agentRegistry.RegisterCustomTUIAgent( - slug, req.DisplayName, req.Command, req.Description, req.Model, req.CommandArgs, - ); regErr != nil { + if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{ + Slug: slug, + DisplayName: req.DisplayName, + Command: req.Command, + Description: req.Description, + Model: req.Model, + CommandArgs: req.CommandArgs, + MCPStrategyKey: req.MCPStrategy, + }); regErr != nil { return nil, fmt.Errorf("failed to register agent: %w", regErr) } @@ -70,10 +84,14 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT Description: req.Description, CommandArgs: req.CommandArgs, WaitForTerminal: true, + MCPStrategy: req.MCPStrategy, } agent := &models.Agent{ - Name: slug, - TUIConfig: tuiConfig, + Name: slug, + // Mirrors what TUIAgent.IsInstalled derives, so the profile MCP editor + // works immediately instead of only after the next discovery sweep. + SupportsMCP: req.MCPStrategy != mcpconfig.StrategyKeyNone, + TUIConfig: tuiConfig, } if err := c.repo.CreateAgent(ctx, agent); err != nil { // Rollback registry on DB failure @@ -101,3 +119,87 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT result := toAgentDTO(agent, profiles) return &result, nil } + +// SetCustomTUIAgentMCPStrategy changes an existing custom TUI agent's MCP +// injection strategy. +// +// Everything else about a tui_config is immutable — there is no edit route for +// the command — but the strategy has to be mutable or every agent created +// before this feature existed could never get MCP tools without being deleted +// and rebuilt, losing its profiles and any session history pointing at it. +// +// The in-memory registry entry is rebuilt because the strategy is baked into +// the TUIAgent at construction; the DB row is updated to match. Sessions +// already running keep the strategy they launched with until they restart, +// since the passthrough command is built once at launch. +func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID, strategyKey string) (*dto.AgentDTO, error) { + if _, ok := mcpconfig.StrategyByKey(strategyKey); !ok { + return nil, ErrUnknownMCPStrategy + } + + agent, err := c.repo.GetAgent(ctx, agentID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentNotFound + } + return nil, err + } + if agent == nil { + return nil, ErrAgentNotFound + } + if agent.TUIConfig == nil { + // Built-in agents declare their strategy in Go; only custom TUI agents + // carry a user-selected one. + return nil, ErrNotCustomTUIAgent + } + if agent.TUIConfig.MCPStrategy == strategyKey { + return c.customTUIAgentDTO(ctx, agent) + } + + previous := agent.TUIConfig.MCPStrategy + agent.TUIConfig.MCPStrategy = strategyKey + agent.SupportsMCP = strategyKey != mcpconfig.StrategyKeyNone + if err := c.repo.UpdateAgent(ctx, agent); err != nil { + return nil, err + } + + if err := c.reregisterCustomTUIAgent(agent); err != nil { + // Roll the row back so the DB cannot claim a strategy the running + // registry does not have — that mismatch would persist until restart. + agent.TUIConfig.MCPStrategy = previous + agent.SupportsMCP = previous != mcpconfig.StrategyKeyNone + if rollbackErr := c.repo.UpdateAgent(ctx, agent); rollbackErr != nil { + return nil, fmt.Errorf("re-register agent: %w (rollback also failed: %v)", err, rollbackErr) + } + return nil, fmt.Errorf("failed to re-register agent: %w", err) + } + + return c.customTUIAgentDTO(ctx, agent) +} + +// reregisterCustomTUIAgent rebuilds the registry entry from the agent's stored +// tui_config. The strategy is baked into the TUIAgent at construction, so a +// changed strategy needs a new instance rather than a mutation. The swap is +// atomic: Unregister + Register would leave a window in which a launching +// session sees no entry for this agent ID. +func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error { + cfg := agent.TUIConfig + return c.agentRegistry.ReplaceCustomTUIAgent(registry.CustomTUIAgentSpec{ + Slug: agent.Name, + DisplayName: cfg.DisplayName, + Command: cfg.Command, + Description: cfg.Description, + Model: cfg.Model, + CommandArgs: cfg.CommandArgs, + MCPStrategyKey: cfg.MCPStrategy, + }) +} + +func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) (*dto.AgentDTO, error) { + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + result := toAgentDTO(agent, filterGlobalProfiles(profiles)) + return &result, nil +} diff --git a/apps/backend/internal/agent/settings/controller/custom_tui_test.go b/apps/backend/internal/agent/settings/controller/custom_tui_test.go index b6d5a68357..4bac2cfe75 100644 --- a/apps/backend/internal/agent/settings/controller/custom_tui_test.go +++ b/apps/backend/internal/agent/settings/controller/custom_tui_test.go @@ -2,10 +2,14 @@ package controller import ( "context" + "errors" "slices" "testing" + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/common/logger" ) @@ -99,3 +103,200 @@ func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) { t.Errorf("stored CommandArgs = %#v, want empty", got) } } + +// --- MCP strategy ------------------------------------------------------------ + +// TestCreateCustomTUIAgent_MCPStrategyEnablesInjection is the test that +// distinguishes the fix from the bug in issue #2474. Before the fix a custom +// TUI agent was registered with no MCP strategy and supports_mcp=0, so +// applyPassthroughMCP was a no-op and the mcpconfig service refused the profile +// — kandev's own session tools were unreachable with no way to turn them on. +func TestCreateCustomTUIAgent_MCPStrategyEnablesInjection(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{ + DisplayName: "Fuel Claude", + Command: "fuelclaude", + MCPStrategy: mcpconfig.StrategyKeyClaude, + }); err != nil { + t.Fatalf("CreateCustomTUIAgent: %v", err) + } + + ag, ok := c.agentRegistry.Get("fuel-claude") + if !ok { + t.Fatal("agent not registered") + } + pt, ok := ag.(agents.PassthroughAgent) + if !ok { + t.Fatal("agent does not implement PassthroughAgent") + } + // The strategy is what applyPassthroughMCP keys off; nil means the CLI is + // never told the per-session MCP server exists. + if pt.PassthroughConfig().MCPStrategy == nil { + t.Error("PassthroughConfig.MCPStrategy = nil, want the Claude strategy") + } + // supports_mcp gates the profile MCP editor and the mcpconfig service. + if stored := st.byName["fuel-claude"]; !stored.SupportsMCP { + t.Error("stored SupportsMCP = false, want true") + } + if got := st.byName["fuel-claude"].TUIConfig.MCPStrategy; got != mcpconfig.StrategyKeyClaude { + t.Errorf("stored MCPStrategy = %q, want %q", got, mcpconfig.StrategyKeyClaude) + } +} + +// TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff pins that the default is +// unchanged: a plain TUI tool that never speaks MCP must not advertise support. +func TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{ + DisplayName: "Plain Tool", + Command: "k9s", + }); err != nil { + t.Fatalf("CreateCustomTUIAgent: %v", err) + } + + ag, _ := c.agentRegistry.Get("plain-tool") + if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil { + t.Error("MCPStrategy set, want nil for an agent created without one") + } + if st.byName["plain-tool"].SupportsMCP { + t.Error("stored SupportsMCP = true, want false") + } +} + +// TestCreateCustomTUIAgent_RejectsUnknownStrategy pins that a bad key fails at +// creation. Accepting it would persist an agent whose MCP is silently off +// forever, with no error surfaced anywhere. +func TestCreateCustomTUIAgent_RejectsUnknownStrategy(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{ + DisplayName: "Typo Agent", + Command: "some-cli", + MCPStrategy: "cluade", + }) + if !errors.Is(err, ErrUnknownMCPStrategy) { + t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err) + } + if _, registered := c.agentRegistry.Get("typo-agent"); registered { + t.Error("agent was registered despite the rejected strategy") + } + if _, stored := st.byName["typo-agent"]; stored { + t.Error("agent was persisted despite the rejected strategy") + } +} + +// TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent covers the upgrade path: +// every custom TUI agent created before this feature has an empty strategy, and +// there is no route to edit a tui_config. Without this they could only get MCP +// by being deleted and rebuilt, losing their profiles. +func TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{ + DisplayName: "Legacy Agent", + Command: "legacy-cli --flag", + }) + if err != nil { + t.Fatalf("CreateCustomTUIAgent: %v", err) + } + + got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyCodex) + if err != nil { + t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err) + } + if !got.SupportsMCP { + t.Error("returned SupportsMCP = false, want true") + } + + ag, ok := c.agentRegistry.Get("legacy-agent") + if !ok { + t.Fatal("agent missing from registry after re-register") + } + pt, ok := ag.(agents.PassthroughAgent) + if !ok { + t.Fatal("agent does not implement PassthroughAgent") + } + if pt.PassthroughConfig().MCPStrategy == nil { + t.Error("MCPStrategy = nil after opting in") + } + // The rest of the definition must survive the rebuild — the re-register + // reconstructs the agent from stored config, so a dropped field here would + // silently change the command the user launches. + want := []string{"legacy-cli", "--flag"} + if argv := ag.Runtime().Cmd.Args(); !slices.Equal(argv, want) { + t.Errorf("argv = %#v, want %#v", argv, want) + } + if stored := st.byName["legacy-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyCodex { + t.Errorf("stored strategy = %q, want %q", stored.TUIConfig.MCPStrategy, mcpconfig.StrategyKeyCodex) + } +} + +// TestSetCustomTUIAgentMCPStrategy_TurnsOff pins the reverse direction: clearing +// the strategy must also clear supports_mcp, or the profile MCP editor would +// stay visible for an agent that can no longer receive any servers. +func TestSetCustomTUIAgentMCPStrategy_TurnsOff(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{ + DisplayName: "Toggle Agent", + Command: "toggle-cli", + MCPStrategy: mcpconfig.StrategyKeyClaude, + }) + if err != nil { + t.Fatalf("CreateCustomTUIAgent: %v", err) + } + + got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyNone) + if err != nil { + t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err) + } + if got.SupportsMCP { + t.Error("returned SupportsMCP = true, want false after clearing the strategy") + } + ag, _ := c.agentRegistry.Get("toggle-agent") + if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil { + t.Error("MCPStrategy still set after clearing") + } +} + +func TestSetCustomTUIAgentMCPStrategy_RejectsNonTUIAgent(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + builtin := &models.Agent{Name: "claude-acp"} + if err := st.CreateAgent(context.Background(), builtin); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + + _, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), builtin.ID, mcpconfig.StrategyKeyClaude) + if !errors.Is(err, ErrNotCustomTUIAgent) { + t.Fatalf("err = %v, want ErrNotCustomTUIAgent", err) + } +} + +func TestSetCustomTUIAgentMCPStrategy_RejectsUnknownStrategy(t *testing.T) { + st := newFakeStore() + c := newCustomTUIController(t, st) + + created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{ + DisplayName: "Guard Agent", + Command: "guard-cli", + }) + if err != nil { + t.Fatalf("CreateCustomTUIAgent: %v", err) + } + + if _, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, "nope"); !errors.Is(err, ErrUnknownMCPStrategy) { + t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err) + } + if stored := st.byName["guard-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyNone { + t.Errorf("stored strategy = %q, want unchanged", stored.TUIConfig.MCPStrategy) + } +} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 1ee348fa41..b8cbd16faa 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -783,6 +783,7 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD Description: agent.TUIConfig.Description, CommandArgs: agent.TUIConfig.CommandArgs, WaitForTerminal: agent.TUIConfig.WaitForTerminal, + MCPStrategy: agent.TUIConfig.MCPStrategy, } } return result diff --git a/apps/backend/internal/agent/settings/controller/reconciler_test.go b/apps/backend/internal/agent/settings/controller/reconciler_test.go index 88021351db..06aa3fc4c0 100644 --- a/apps/backend/internal/agent/settings/controller/reconciler_test.go +++ b/apps/backend/internal/agent/settings/controller/reconciler_test.go @@ -3,7 +3,9 @@ package controller import ( "context" "database/sql" + "encoding/json" "errors" + "fmt" "strconv" "testing" "time" @@ -62,16 +64,41 @@ func newFakeStore() *fakeStore { } } +// copyAgent deep-copies an agent so the fake store behaves like a real one: +// callers get a snapshot, not a handle on stored state. +// +// This matters more than it looks. When Get returned the stored pointer and +// UpdateAgent was a no-op, a controller could mutate a nested field, never +// persist it, and every assertion still passed — the test was reading the same +// struct it had just written through. That masked UpdateAgent silently dropping +// the tui_config column. Round-tripping through JSON forces writes to go through +// UpdateAgent to be observable, the same as SQL. +func copyAgent(a *models.Agent) *models.Agent { + if a == nil { + return nil + } + data, err := json.Marshal(a) + if err != nil { + panic("fakeStore: marshal agent: " + err.Error()) + } + var out models.Agent + if err := json.Unmarshal(data, &out); err != nil { + panic("fakeStore: unmarshal agent: " + err.Error()) + } + return &out +} + func (f *fakeStore) CreateAgent(_ context.Context, a *models.Agent) error { f.nextAgentID++ a.ID = "agent-" + strconv.Itoa(f.nextAgentID) - f.agents[a.ID] = a - f.byName[a.Name] = a + stored := copyAgent(a) + f.agents[a.ID] = stored + f.byName[a.Name] = stored return nil } func (f *fakeStore) GetAgent(_ context.Context, id string) (*models.Agent, error) { - return f.agents[id], nil + return copyAgent(f.agents[id]), nil } func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agent, error) { @@ -79,13 +106,28 @@ func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agen return nil, f.getByNameErr } if a, ok := f.byName[name]; ok { - return a, nil + return copyAgent(a), nil } return nil, sql.ErrNoRows } -func (f *fakeStore) UpdateAgent(context.Context, *models.Agent) error { return nil } -func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil } +func (f *fakeStore) UpdateAgent(_ context.Context, a *models.Agent) error { + existing, ok := f.agents[a.ID] + if !ok { + return fmt.Errorf("agent not found: %s", a.ID) + } + stored := copyAgent(a) + // Mirror the real store's column list so the fake cannot accept a write the + // database would reject or ignore. + existing.WorkspaceID = stored.WorkspaceID + existing.SupportsMCP = stored.SupportsMCP + existing.MCPConfigPath = stored.MCPConfigPath + existing.TUIConfig = stored.TUIConfig + f.byName[existing.Name] = existing + return nil +} + +func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil } func (f *fakeStore) ListAgents(_ context.Context) ([]*models.Agent, error) { if f.listAgentsErr != nil { diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index eff69305d0..5f3f3282f4 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -79,6 +79,8 @@ type TUIConfigDTO struct { Description string `json:"description,omitempty"` CommandArgs []string `json:"command_args,omitempty"` WaitForTerminal bool `json:"wait_for_terminal"` + // MCPStrategy is the selected MCP injection mechanism ("" = none). + MCPStrategy string `json:"mcp_strategy,omitempty"` } type AgentDTO struct { diff --git a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go index 9cb977ecbe..9d5849068b 100644 --- a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go +++ b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/settings/controller" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -19,6 +20,9 @@ type createCustomTUIAgentRequest struct { // which the registry splits on whitespace — each entry is one argv element, // so an argument containing a space can only be expressed here. CommandArgs []string `json:"command_args"` + // MCPStrategy names how the wrapped CLI loads MCP servers, so kandev can + // point it at the per-session server. Empty (the default) = no MCP tools. + MCPStrategy string `json:"mcp_strategy"` } func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest { @@ -28,6 +32,7 @@ func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCust Command: r.Command, Description: r.Description, CommandArgs: r.CommandArgs, + MCPStrategy: r.MCPStrategy, } } @@ -49,7 +54,7 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) { resp, err := h.controller.CreateCustomTUIAgent(c.Request.Context(), body.toControllerRequest()) if err != nil { switch err { - case controller.ErrInvalidSlug, controller.ErrCommandRequired: + case controller.ErrInvalidSlug, controller.ErrCommandRequired, controller.ErrUnknownMCPStrategy: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) case controller.ErrAgentAlreadyExists: c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) @@ -82,3 +87,61 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) { c.JSON(http.StatusOK, resp) } + +type updateCustomTUIAgentMCPRequest struct { + MCPStrategy string `json:"mcp_strategy"` +} + +// httpListMCPStrategies serves the selectable MCP injection strategies. The +// list is served rather than hardcoded in the client so a strategy added in Go +// becomes selectable without a matching frontend edit, and so the UI can show +// each strategy's own description of the mechanism it uses. +func (h *Handlers) httpListMCPStrategies(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"strategies": mcpconfig.StrategyOptions()}) +} + +// httpUpdateCustomTUIAgentMCP changes an existing custom TUI agent's MCP +// injection strategy. The rest of a tui_config stays immutable; this exists so +// agents created before the field existed can opt in without being deleted and +// rebuilt. +func (h *Handlers) httpUpdateCustomTUIAgentMCP(c *gin.Context) { + var body updateCustomTUIAgentMCPRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + resp, err := h.controller.SetCustomTUIAgentMCPStrategy(c.Request.Context(), c.Param("id"), body.MCPStrategy) + if err != nil { + switch err { + case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case controller.ErrAgentNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + default: + h.logger.Error("failed to update custom TUI agent MCP strategy", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + + // The agent's supports_mcp and tui_config both changed; refresh the agent + // list so open settings tabs show the MCP editor appearing or disappearing + // without a reload. + // + // ActionAgentSettingsUpdated, not ActionAgentUpdated: the latter is a + // runtime status ping shaped {agentId, status} feeding a different store + // slice, so sending an agent record on it inserts {id: undefined} into the + // runtime list instead of updating settings. + // + //ws:global agent settings are not workspace-scoped — the agents list is a + // global settings surface, so every connected client's copy is stale after + // this change. Matches the sibling agent.profile.* and agent.available.updated + // broadcasts in this package. + if h.hub != nil { + notification, _ := ws.NewNotification(ws.ActionAgentSettingsUpdated, gin.H{"agent": resp}) + h.hub.Broadcast(notification) + } + + c.JSON(http.StatusOK, resp) +} diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 3cac96ea57..bb6e17385f 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -58,6 +58,8 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.GET("/agents", h.httpListAgents) api.POST("/agents", h.interlock, h.httpCreateAgent) api.POST("/agents/tui", h.interlock, h.httpCreateCustomTUIAgent) + api.GET("/agents/tui/mcp-strategies", h.httpListMCPStrategies) + api.PATCH("/agents/tui/:id/mcp", h.interlock, h.httpUpdateCustomTUIAgentMCP) api.GET("/agents/:id", h.httpGetAgent) api.PATCH("/agents/:id", h.interlock, h.httpUpdateAgent) api.DELETE("/agents/:id", h.interlock, h.httpDeleteAgent) diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 639cb18f72..0c9c970a6d 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -29,6 +29,13 @@ type TUIConfigJSON struct { Description string `json:"description,omitempty"` CommandArgs []string `json:"command_args,omitempty"` WaitForTerminal bool `json:"wait_for_terminal"` + // MCPStrategy selects how kandev injects its per-session MCP server into + // the wrapped CLI (mcpconfig.StrategyKey*). Empty — the value every row + // written before this field existed decodes to — means no injection. + // Stored here rather than as an agent column because it is part of the + // user's CLI definition, and because tui_config is JSON: adding the field + // needs no migration. + MCPStrategy string `json:"mcp_strategy,omitempty"` } type AgentProfile struct { diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index cdee34d97c..0613cf8588 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -351,6 +351,22 @@ func (r *sqliteRepository) Close() error { return r.db.Close() } +// marshalTUIConfig renders a TUI config for the nullable tui_config column. +// Shared by CreateAgent and UpdateAgent so the two writers cannot drift — the +// column being absent from one of them is exactly how an editable field was +// lost on save. +func marshalTUIConfig(cfg *models.TUIConfigJSON) (*string, error) { + if cfg == nil { + return nil, nil + } + data, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("failed to marshal tui_config: %w", err) + } + s := string(data) + return &s, nil +} + func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent) error { if agent.ID == "" { agent.ID = uuid.New().String() @@ -358,16 +374,11 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent) now := time.Now().UTC() agent.CreatedAt = now agent.UpdatedAt = now - var tuiConfigJSON *string - if agent.TUIConfig != nil { - data, err := json.Marshal(agent.TUIConfig) - if err != nil { - return fmt.Errorf("failed to marshal tui_config: %w", err) - } - s := string(data) - tuiConfigJSON = &s + tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig) + if err != nil { + return err } - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.CreatedAt, agent.UpdatedAt) @@ -392,10 +403,18 @@ func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*mo func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error { agent.UpdatedAt = time.Now().UTC() + // tui_config is written here as well as in CreateAgent: a custom TUI agent's + // MCP strategy is editable after creation, and omitting the column silently + // discarded that edit — the row kept supports_mcp while the strategy it was + // derived from reverted on the next boot. + tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig) + if err != nil { + return err + } result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ? + UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, tui_config = ?, updated_at = ? WHERE id = ? - `), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID) + `), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.UpdatedAt, agent.ID) if err != nil { return err } diff --git a/apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go b/apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go new file mode 100644 index 0000000000..c5bf8e17f7 --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go @@ -0,0 +1,127 @@ +package store + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +// TestUpdateAgent_PersistsTUIConfig pins the column that UpdateAgent used to +// omit. A custom TUI agent's MCP strategy is editable after creation, and the +// UPDATE statement listed only workspace_id/supports_mcp/mcp_config_path — so +// the edit was accepted, supports_mcp was written, and the strategy it is +// derived from silently reverted on the next read. Round-tripping through the +// real store is the only thing that catches this: a fake repository that +// returns the same pointer it was handed reports success either way. +func TestUpdateAgent_PersistsTUIConfig(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + agent := &models.Agent{ + Name: "fuel-claude", + TUIConfig: &models.TUIConfigJSON{ + Command: "fuelclaude", + DisplayName: "Fuel Claude", + WaitForTerminal: true, + }, + } + if err := repo.CreateAgent(ctx, agent); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + + agent.TUIConfig.MCPStrategy = "claude" + agent.SupportsMCP = true + if err := repo.UpdateAgent(ctx, agent); err != nil { + t.Fatalf("UpdateAgent: %v", err) + } + + // Re-read from the database rather than inspecting the struct we just + // mutated — that distinction is the entire point of this test. + stored, err := repo.GetAgent(ctx, agent.ID) + if err != nil { + t.Fatalf("GetAgent: %v", err) + } + if stored.TUIConfig == nil { + t.Fatal("TUIConfig = nil after update") + } + if stored.TUIConfig.MCPStrategy != "claude" { + t.Errorf("MCPStrategy = %q, want %q", stored.TUIConfig.MCPStrategy, "claude") + } + if !stored.SupportsMCP { + t.Error("SupportsMCP = false, want true") + } + // The rest of the config must survive the write: UpdateAgent rewrites the + // whole JSON blob, so a dropped field here would quietly change the command + // the user launches. + if stored.TUIConfig.Command != "fuelclaude" { + t.Errorf("Command = %q, want %q", stored.TUIConfig.Command, "fuelclaude") + } + if stored.TUIConfig.DisplayName != "Fuel Claude" { + t.Errorf("DisplayName = %q, want %q", stored.TUIConfig.DisplayName, "Fuel Claude") + } + if !stored.TUIConfig.WaitForTerminal { + t.Error("WaitForTerminal = false, want true") + } +} + +// TestUpdateAgent_ClearsTUIStrategy pins the off direction, which shares the +// same write path. +func TestUpdateAgent_ClearsTUIStrategy(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + agent := &models.Agent{ + Name: "toggle-agent", + SupportsMCP: true, + TUIConfig: &models.TUIConfigJSON{ + Command: "toggle-cli", + DisplayName: "Toggle", + MCPStrategy: "codex", + }, + } + if err := repo.CreateAgent(ctx, agent); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + + agent.TUIConfig.MCPStrategy = "" + agent.SupportsMCP = false + if err := repo.UpdateAgent(ctx, agent); err != nil { + t.Fatalf("UpdateAgent: %v", err) + } + + stored, err := repo.GetAgent(ctx, agent.ID) + if err != nil { + t.Fatalf("GetAgent: %v", err) + } + if stored.TUIConfig.MCPStrategy != "" { + t.Errorf("MCPStrategy = %q, want empty", stored.TUIConfig.MCPStrategy) + } + if stored.SupportsMCP { + t.Error("SupportsMCP = true, want false") + } +} + +// TestUpdateAgent_NonTUIAgentKeepsNullConfig guards the nil path: a built-in +// agent has no tui_config and must not gain one. +func TestUpdateAgent_NonTUIAgentKeepsNullConfig(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + agent := &models.Agent{Name: "claude-acp"} + if err := repo.CreateAgent(ctx, agent); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + agent.SupportsMCP = true + if err := repo.UpdateAgent(ctx, agent); err != nil { + t.Fatalf("UpdateAgent: %v", err) + } + + stored, err := repo.GetAgent(ctx, agent.ID) + if err != nil { + t.Fatalf("GetAgent: %v", err) + } + if stored.TUIConfig != nil { + t.Errorf("TUIConfig = %+v, want nil", stored.TUIConfig) + } +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 3d90c275cd..4eefd8d7f1 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -392,9 +392,15 @@ func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry continue } cfg := agent.TUIConfig - if regErr := agentRegistry.RegisterCustomTUIAgent( - agent.Name, cfg.DisplayName, cfg.Command, cfg.Description, cfg.Model, cfg.CommandArgs, - ); regErr != nil { + if regErr := agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{ + Slug: agent.Name, + DisplayName: cfg.DisplayName, + Command: cfg.Command, + Description: cfg.Description, + Model: cfg.Model, + CommandArgs: cfg.CommandArgs, + MCPStrategyKey: cfg.MCPStrategy, + }); regErr != nil { log.Warn("failed to register custom TUI agent", zap.String("name", agent.Name), zap.Error(regErr)) } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go index c54c8846b8..3768d42a10 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go @@ -455,7 +455,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousAgentFamilyWarns(t *testing.T) { shadowed := registry.NewRegistry(testLogger()) shadowed.LoadDefaults() - if err := shadowed.RegisterCustomTUIAgent(tc.slug, tc.displayName, "claude-tui", "", "", nil); err != nil { + if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: tc.slug, DisplayName: tc.displayName, Command: "claude-tui"}); err != nil { t.Fatalf("register custom agent: %v", err) } svc.agentFamilyResolver = shadowed @@ -495,7 +495,7 @@ func TestProcessOnEnterConfigureSessionUnrelatedAmbiguousRuleStillApplies(t *tes shadowed := registry.NewRegistry(testLogger()) shadowed.LoadDefaults() // "Claude" now names both claude-acp and this agent — and neither is codex-acp. - if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil { + if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil { t.Fatalf("register custom agent: %v", err) } svc.agentFamilyResolver = shadowed @@ -561,7 +561,7 @@ func TestProcessOnEnterConfigureSessionRelevantAmbiguityRefusesDespiteExactRule( shadowed := registry.NewRegistry(testLogger()) shadowed.LoadDefaults() - if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil { + if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil { t.Fatalf("register custom agent: %v", err) } svc.agentFamilyResolver = shadowed @@ -603,7 +603,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousSessionFamilyRefuses(t *testing. shadowed := registry.NewRegistry(testLogger()) shadowed.LoadDefaults() // "Claude" now answers for both claude-acp and this agent. - if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil { + if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil { t.Fatalf("register custom agent: %v", err) } svc.agentFamilyResolver = shadowed diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index db96aad098..d9e2ae6dcd 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -253,6 +253,12 @@ const ( ActionAgentProfileCreated = "agent.profile.created" ActionAgentProfileUpdated = "agent.profile.updated" + // ActionAgentSettingsUpdated carries a full agent settings record + // (dto.AgentDTO) after a settings-side change such as a custom TUI agent's + // MCP strategy. Distinct from ActionAgentUpdated, which is a runtime status + // ping keyed by {agentId, status} and feeds a different store slice. + ActionAgentSettingsUpdated = "agent.settings.updated" + // Permission request actions (agent -> user -> agent) ActionPermissionRequested = "permission.requested" // Agent requesting permission ActionPermissionRespond = "permission.respond" // User responding to permission request diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 9dd9edd6b4..9849d98a59 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -363,6 +363,20 @@ function useAgentSaveRevision(agent: DraftAgent) { return { revision, saved, setSaved }; } +/** + * Explains why the shared Save control is blocked, or undefined when it is not. + * Extracted so AgentSetupForm stays within the file's function-length limit. + */ +function resolveSaveInvalidReason( + t: (key: string) => string, + profilesValid: boolean, + hasInvalidMcpConfig: boolean, +): string | undefined { + if (!profilesValid) return t("agents:everyProfileNeedsNameAndModel"); + if (hasInvalidMcpConfig) return t("agents:fixInvalidMcpConfig"); + return undefined; +} + function AgentSetupForm({ initialAgent, savedAgent, @@ -420,9 +434,7 @@ function AgentSetupForm({ if (savedDraft) saveRevision.setSaved(JSON.stringify(savedDraft)); }; const profilesValid = areAgentProfilesValid(draftAgent); - let saveInvalidReason: string | undefined; - if (!profilesValid) saveInvalidReason = t("agents:everyProfileNeedsNameAndModel"); - else if (hasInvalidMcpConfig) saveInvalidReason = t("agents:fixInvalidMcpConfig"); + const saveInvalidReason = resolveSaveInvalidReason(t, profilesValid, hasInvalidMcpConfig); useSettingsSaveContributor({ id: `agent:${draftAgent.id}`, revision: saveRevision.revision, diff --git a/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx b/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx new file mode 100644 index 0000000000..9a70e36cd5 --- /dev/null +++ b/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx @@ -0,0 +1,109 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import type { Agent } from "@/lib/types/http-agents"; + +// The strategy list is fetched on mount; the mount assertion below is about +// whether the control is reachable at all, not about the option list. +vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({ + ...(await importOriginal>()), + listMCPStrategies: vi.fn(async () => [{ key: "claude", description: "a config file" }]), + updateCustomTUIAgentMCPStrategy: vi.fn(), +})); + +const { CustomTUIMcpCard } = await import("@/components/settings/custom-tui-mcp-card"); +const { InstalledAgentCard } = await import("@/components/settings/installed-agent-card"); + +const TIMESTAMP = "2026-08-13T10:00:00Z"; +const STRATEGY_SELECT = "mcp-strategy-select"; + +function tuiAgent(overrides: Partial = {}): Agent { + return { + id: "agent-1", + name: "fuel-claude", + supports_mcp: false, + tui_config: { + command: "fuelclaude", + display_name: "Fuel Claude", + wait_for_terminal: true, + }, + profiles: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + } as Agent; +} + +function discovery(name: string) { + return { + name, + supports_mcp: false, + mcp_config_path: null, + installation_paths: [], + available: true, + matched_path: "/usr/bin/fuelclaude", + }; +} + +/** + * Composes the card exactly as the agents index page does: inside + * InstalledAgentCard, keyed off the saved agent record. + * + * The point of asserting through this composition rather than rendering + * CustomTUIMcpCard alone is that the control was previously mounted on the + * agent detail route, which redirects saved agents back to the index before the + * form mounts. Testing the leaf in isolation passed while the control was + * reachable from nowhere. + */ +function renderIndexComposition(saved: Agent | undefined, name = "fuel-claude") { + return render( + + + + + + + , + ); +} + +describe("custom TUI MCP control on the agents index", () => { + afterEach(cleanup); + + it("renders for a saved custom TUI agent", async () => { + renderIndexComposition(tuiAgent()); + expect(await screen.findByTestId("agent-mcp-strategy-fuel-claude")).toBeTruthy(); + expect(screen.getByTestId(STRATEGY_SELECT)).toBeTruthy(); + }); + + it("shows the agent's stored strategy as the current value", async () => { + renderIndexComposition( + tuiAgent({ + supports_mcp: true, + tui_config: { + command: "fuelclaude", + display_name: "Fuel Claude", + wait_for_terminal: true, + mcp_strategy: "claude", + }, + }), + ); + const trigger = await screen.findByTestId(STRATEGY_SELECT); + expect(trigger.textContent).toContain("claude"); + }); + + it("renders nothing for a built-in (non-TUI) agent", () => { + renderIndexComposition(tuiAgent({ tui_config: null }), "claude-acp"); + expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull(); + }); + + it("renders nothing for an agent that has not been saved yet", () => { + renderIndexComposition(undefined); + expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull(); + }); +}); diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 44c2ced89c..dab6f63681 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -28,6 +28,7 @@ import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agent import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog"; import { AgentProfilesSubList } from "@/components/settings/agents/agent-profiles-section"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; +import { CustomTUIMcpCard } from "@/components/settings/custom-tui-mcp-card"; import { InstalledAgentCard } from "@/components/settings/installed-agent-card"; import { AGENTS_BROWSE_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents"; import { @@ -242,6 +243,7 @@ function InstalledAgentsSection({ savedAgent={savedAgentsByName.get(agent.name)} agentName={agent.name} /> + ))} @@ -296,6 +298,7 @@ function useAgentPageState() { display_name: string; model?: string; command: string; + mcp_strategy?: string; }) => { await createCustomTUIAgent(data); const [discoveryResp, agentsResp, availableResp] = await Promise.all([ diff --git a/apps/web/components/settings/add-tui-agent-dialog.tsx b/apps/web/components/settings/add-tui-agent-dialog.tsx index 56e74ce810..0d69a7a4a1 100644 --- a/apps/web/components/settings/add-tui-agent-dialog.tsx +++ b/apps/web/components/settings/add-tui-agent-dialog.tsx @@ -14,8 +14,14 @@ import { } from "@kandev/ui/dialog"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; +import { MCPStrategySelect, useMCPStrategies } from "./mcp-strategy-select"; -type TUIAgentFormData = { display_name: string; model?: string; command: string }; +type TUIAgentFormData = { + display_name: string; + model?: string; + command: string; + mcp_strategy?: string; +}; // The placeholder kandev substitutes into the TUI command. The user types it // verbatim, so it is interpolated as a value rather than written into the @@ -32,6 +38,7 @@ type DialogHandlersParams = { displayName: string; model: string; command: string; + mcpStrategy: string; setError: React.Dispatch>; setLoading: React.Dispatch>; onSubmit: (data: TUIAgentFormData) => Promise; @@ -43,6 +50,7 @@ function useDialogHandlers({ displayName, model, command, + mcpStrategy, setError, setLoading, onSubmit, @@ -65,6 +73,7 @@ function useDialogHandlers({ display_name: displayName.trim(), model: model.trim() || undefined, command: command.trim(), + mcp_strategy: mcpStrategy || undefined, }); reset(); onOpenChange(false); @@ -88,13 +97,18 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD const [displayName, setDisplayName] = useState(""); const [model, setModel] = useState(""); const [command, setCommand] = useState(""); + const [mcpStrategy, setMcpStrategy] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Only fetch the strategy list while the dialog is open. + const strategies = useMCPStrategies(open); + const reset = () => { setDisplayName(""); setModel(""); setCommand(""); + setMcpStrategy(""); setError(null); setLoading(false); }; @@ -103,6 +117,7 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD displayName, model, command, + mcpStrategy, setError, setLoading, onSubmit, @@ -153,6 +168,12 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD

+ {error &&

{error}

} diff --git a/apps/web/components/settings/custom-tui-mcp-card.tsx b/apps/web/components/settings/custom-tui-mcp-card.tsx new file mode 100644 index 0000000000..526829c5fe --- /dev/null +++ b/apps/web/components/settings/custom-tui-mcp-card.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { MCPStrategySelect, useMCPStrategies } from "@/components/settings/mcp-strategy-select"; +import { updateCustomTUIAgentMCPStrategy } from "@/lib/api/domains/settings-api"; +import type { Agent } from "@/lib/types/http-agents"; + +type CustomTUIMcpCardProps = { + /** Renders nothing unless this is a saved custom TUI agent. */ + agent: Agent | null | undefined; +}; + +/** + * Lets the user change a custom TUI agent's MCP injection strategy. + * + * Self-contained (own store write, own toast) so it can mount wherever the + * agent is actually shown. It first lived on the agent detail route, which + * redirects saved agents straight back to the index — so it rendered nowhere a + * user would find it. + * + * Saves immediately rather than registering with the shared settings-save + * coordinator, matching the profile-enabled toggle: the strategy is agent-level + * state outside the profile draft, the backend rebuilds the registry entry as + * part of the write, and turning it on is what makes the per-profile MCP editor + * appear — deferring that behind a separate Save would be confusing. + */ +export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const savedAgents = useAppStore((state) => state.settingsAgents.items); + const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); + // Gated: this card is rendered once per agent on the index, and hooks run + // before the early return below, so an unconditional fetch would issue one + // identical request per built-in agent as well. + const isCustomTUIAgent = Boolean(agent?.tui_config); + const strategies = useMCPStrategies(isCustomTUIAgent); + const [saving, setSaving] = useState(false); + + if (!agent?.tui_config) return null; + + const handleChange = async (strategy: string) => { + setSaving(true); + try { + const updated = await updateCustomTUIAgentMCPStrategy(agent.id, strategy); + // Write through the store rather than waiting on the WS echo so the + // control reflects the save immediately. Profiles are preserved: this + // endpoint only changes agent-level settings. + setSettingsAgents( + savedAgents.map((item) => + item.id === updated.id ? { ...item, ...updated, profiles: item.profiles } : item, + ), + ); + } catch (error) { + toast({ + title: t("agents:failedToSaveAgent"), + description: error instanceof Error ? error.message : t("agents:requestFailed"), + variant: "error", + }); + } finally { + setSaving(false); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/web/components/settings/mcp-strategy-select.test.tsx b/apps/web/components/settings/mcp-strategy-select.test.tsx new file mode 100644 index 0000000000..1c279f0999 --- /dev/null +++ b/apps/web/components/settings/mcp-strategy-select.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { fromStrategyValue, MCP_STRATEGY_NONE, toStrategyValue } from "./mcp-strategy-select"; + +// Radix Select cannot hold an empty-string value, so "no MCP injection" needs a +// sentinel in the control and "" on the wire. If the mapping leaks, the backend +// receives "__none__", rejects it as an unknown strategy, and the user cannot +// turn MCP off again. +describe("MCP strategy value mapping", () => { + it("maps an unset strategy to the sentinel", () => { + expect(toStrategyValue(undefined)).toBe(MCP_STRATEGY_NONE); + expect(toStrategyValue("")).toBe(MCP_STRATEGY_NONE); + }); + + it("passes a real strategy key through unchanged", () => { + expect(toStrategyValue("claude")).toBe("claude"); + }); + + it("maps the sentinel back to an empty key for the API", () => { + expect(fromStrategyValue(MCP_STRATEGY_NONE)).toBe(""); + }); + + it("passes a real strategy key back unchanged", () => { + expect(fromStrategyValue("codex")).toBe("codex"); + }); + + it("round-trips every value the control can hold", () => { + for (const key of ["", "claude", "codex", "cursor", "opencode", "pi"]) { + expect(fromStrategyValue(toStrategyValue(key))).toBe(key); + } + }); +}); diff --git a/apps/web/components/settings/mcp-strategy-select.tsx b/apps/web/components/settings/mcp-strategy-select.tsx new file mode 100644 index 0000000000..46c661a6d7 --- /dev/null +++ b/apps/web/components/settings/mcp-strategy-select.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { listMCPStrategies } from "@/lib/api/domains/settings-api"; +import type { MCPStrategyOption } from "@/lib/types/http-agents"; + +/** + * Radix Select cannot hold an empty-string value, so the "off" choice needs a + * sentinel. Callers convert between it and "" at the API boundary via + * {@link toStrategyValue} / {@link fromStrategyValue}. It is a control value, + * never displayed and never translated. + */ +export const MCP_STRATEGY_NONE = "__none__"; + +/** Maps a stored strategy key ("" = off) to the Select's value. */ +export function toStrategyValue(strategy: string | undefined): string { + return strategy ? strategy : MCP_STRATEGY_NONE; +} + +/** Maps the Select's value back to a stored strategy key ("" = off). */ +export function fromStrategyValue(value: string): string { + return value === MCP_STRATEGY_NONE ? "" : value; +} + +/** + * Loads the selectable MCP strategies from the backend. + * + * The list is fetched rather than hardcoded so a strategy added in Go becomes + * selectable without a matching frontend edit — and so an option can never be + * offered that the backend would reject on save. + */ +export function useMCPStrategies(enabled: boolean) { + const [strategies, setStrategies] = useState([]); + + useEffect(() => { + if (!enabled) return; + let active = true; + listMCPStrategies() + .then((options) => { + if (active) setStrategies(options); + }) + .catch(() => { + // Non-fatal: the picker falls back to the "off" choice only. Creating + // an agent without MCP still works, which is the pre-existing behavior. + if (active) setStrategies([]); + }); + return () => { + active = false; + }; + }, [enabled]); + + return strategies; +} + +type MCPStrategySelectProps = { + id: string; + /** Stored strategy key; "" or undefined means no injection. */ + value: string | undefined; + /** Receives the stored strategy key ("" when the user picks "off"). */ + onChange: (strategy: string) => void; + strategies: MCPStrategyOption[]; + disabled?: boolean; +}; + +/** + * Picks how kandev injects its per-session MCP server into a custom TUI agent's + * wrapped CLI. + * + * This is an explicit choice rather than something inferred from the command, + * because the command is arbitrary (`zsh -ic "fuelclaude --model opus"` is a + * real example) and a wrong guess fails silently: kandev writes a config file, + * the CLI ignores it, and no error surfaces anywhere. + */ +export function MCPStrategySelect({ + id, + value, + onChange, + strategies, + disabled, +}: MCPStrategySelectProps) { + const { t } = useTranslation(); + + return ( +
+ + +

{t("agents:mcpStrategyHelp")}

+
+ ); +} diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index c4056edb4f..db4f39815f 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -537,6 +537,7 @@ export const i18nGuardFiles = [ "components/settings/command-prefix-field.tsx", "components/settings/install-agent-card.tsx", "components/settings/installed-agent-card.tsx", + "components/settings/mcp-strategy-select.tsx", "components/settings/mode-combobox.tsx", "components/settings/profile-capability-helpers.tsx", "components/settings/profile-form-fields.tsx", diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 4a9939d018..7c7c30c2bf 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -22,6 +22,7 @@ import type { AgentModelConfigResponse, ResolveAgentModelConfigRequest, } from "@/lib/types/http"; +import type { MCPStrategyOption } from "@/lib/types/http-agents"; import type { MessageQueueSettingsPatch, MessageQueueSettingsResponse, @@ -332,7 +333,13 @@ export async function getInstallJob( } export async function createCustomTUIAgent( - payload: { display_name: string; model?: string; command: string; description?: string }, + payload: { + display_name: string; + model?: string; + command: string; + description?: string; + mcp_strategy?: string; + }, options?: ApiRequestOptions, ): Promise { const res = await fetchJson("/api/v1/agents/tui", { @@ -342,6 +349,41 @@ export async function createCustomTUIAgent( return normalizeAgentResponse(res); } +/** + * Lists the MCP injection mechanisms a custom TUI agent can use. Served by the + * backend so the option list cannot drift from the strategies that actually + * resolve at launch. + */ +export async function listMCPStrategies(options?: ApiRequestOptions): Promise { + const res = await fetchJson<{ strategies?: MCPStrategyOption[] }>( + "/api/v1/agents/tui/mcp-strategies", + options, + ); + return res.strategies ?? []; +} + +/** + * Changes an existing custom TUI agent's MCP injection strategy. Pass an empty + * string to turn injection off. This is the only mutable part of a tui_config — + * it exists so agents created before the field existed can opt in without being + * deleted and rebuilt. + */ +export async function updateCustomTUIAgentMCPStrategy( + agentId: string, + mcpStrategy: string, + options?: ApiRequestOptions, +): Promise { + const res = await fetchJson(`/api/v1/agents/tui/${agentId}/mcp`, { + ...options, + init: { + method: "PATCH", + body: JSON.stringify({ mcp_strategy: mcpStrategy }), + ...(options?.init ?? {}), + }, + }); + return normalizeAgentResponse(res); +} + export async function fetchDynamicModels( agentName: string, options?: ApiRequestOptions & { refresh?: boolean }, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 715e9f8b20..ef76ea4740 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -15,6 +15,7 @@ import type { RunEventAppendedPayload } from "./run-events"; export type { RunEventAppendedPayload } from "./run-events"; import type { + Agent, AvailableAgent, ForegroundActivity, TaskPendingAction, @@ -120,6 +121,15 @@ export type AgentUpdatePayload = { message?: string; }; +/** + * A full agent settings record after an agent-level settings change (e.g. a + * custom TUI agent's MCP strategy). Distinct from AgentUpdatePayload, which is + * the runtime status ping. + */ +export type AgentSettingsUpdatedPayload = { + agent: Agent; +}; + export type AgentAvailableUpdatedPayload = { agents: AvailableAgent[]; tools?: ToolStatus[]; @@ -345,6 +355,7 @@ export type BackendMessageMap = SessionBackendMessageMap & >; "task.plan.reverted": BackendMessage<"task.plan.reverted", TaskPlanRevisionEventPayload>; "agent.updated": BackendMessage<"agent.updated", AgentUpdatePayload>; + "agent.settings.updated": BackendMessage<"agent.settings.updated", AgentSettingsUpdatedPayload>; "agent.available.updated": BackendMessage< "agent.available.updated", AgentAvailableUpdatedPayload diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 88f53c7d61..0aff09ae00 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -26,6 +26,20 @@ export type TUIConfig = { description?: string; command_args?: string[]; wait_for_terminal: boolean; + /** + * How kandev injects its per-session MCP server into the wrapped CLI. + * Empty/absent means no injection — the agent gets no kandev task tools. + * Keys come from `GET /api/v1/agents/tui/mcp-strategies`; never hardcode a + * list here, or a strategy added in Go silently stops being selectable. + */ + mcp_strategy?: string; +}; + +/** One selectable MCP injection mechanism, served by the backend. */ +export type MCPStrategyOption = { + key: string; + /** The strategy's own description of the mechanism it uses. */ + description: string; }; export type Agent = { diff --git a/apps/web/lib/ws/handlers/agents-settings-updated.test.ts b/apps/web/lib/ws/handlers/agents-settings-updated.test.ts new file mode 100644 index 0000000000..fdbf508b18 --- /dev/null +++ b/apps/web/lib/ws/handlers/agents-settings-updated.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { create, type StoreApi } from "zustand"; +import { immer } from "zustand/middleware/immer"; + +import { createSettingsSlice } from "@/lib/state/slices/settings/settings-slice"; +import type { SettingsSlice } from "@/lib/state/slices/settings/types"; +import type { AppState } from "@/lib/state/store"; +import { registerAgentsHandlers } from "./agents"; + +function makeStore() { + return create()( + immer((set, get, store) => createSettingsSlice(set, get, store)), + ) as unknown as StoreApi; +} + +const TIMESTAMP = "2026-08-13T10:00:00Z"; +const SETTINGS_UPDATED = "agent.settings.updated"; + +function message(action: string, payload: unknown) { + return { id: `message-${action}`, type: "notification", action, timestamp: TIMESTAMP, payload }; +} + +function savedAgent(overrides: Record = {}) { + return { + id: "agent-1", + name: "fuel-claude", + supports_mcp: false, + tui_config: { + command: "fuelclaude", + display_name: "Fuel Claude", + wait_for_terminal: true, + }, + profiles: [{ id: "p1", name: "Default" }], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + }; +} + +/** + * These pin the split between two actions that were briefly conflated. The + * settings record was first broadcast on `agent.updated`, whose handler expects + * `{agentId, status}` — it read `undefined` and pushed `{id: undefined}` into + * the runtime-status list instead of updating settings. + */ +describe("agent.settings.updated handler", () => { + it("replaces the agent's settings record in settingsAgents", () => { + const store = makeStore(); + store.getState().setSettingsAgents([savedAgent()] as never); + const handlers = registerAgentsHandlers(store); + + handlers[SETTINGS_UPDATED]?.( + message(SETTINGS_UPDATED, { + agent: savedAgent({ + supports_mcp: true, + tui_config: { + command: "fuelclaude", + display_name: "Fuel Claude", + wait_for_terminal: true, + mcp_strategy: "claude", + }, + }), + }) as never, + ); + + const [updated] = store.getState().settingsAgents.items; + expect(updated.supports_mcp).toBe(true); + expect(updated.tui_config?.mcp_strategy).toBe("claude"); + }); + + it("preserves locally normalized profiles", () => { + const store = makeStore(); + store.getState().setSettingsAgents([savedAgent()] as never); + const handlers = registerAgentsHandlers(store); + + // The event carries agent-level settings; profiles have their own events, + // so an agent record arriving without them must not wipe the list. + handlers[SETTINGS_UPDATED]?.( + message(SETTINGS_UPDATED, { + agent: savedAgent({ supports_mcp: true, profiles: [] }), + }) as never, + ); + + expect(store.getState().settingsAgents.items[0].profiles).toHaveLength(1); + }); + + it("ignores an agent it does not already track", () => { + const store = makeStore(); + store.getState().setSettingsAgents([savedAgent()] as never); + const handlers = registerAgentsHandlers(store); + + handlers[SETTINGS_UPDATED]?.( + message(SETTINGS_UPDATED, { agent: savedAgent({ id: "agent-other" }) }) as never, + ); + + expect(store.getState().settingsAgents.items).toHaveLength(1); + expect(store.getState().settingsAgents.items[0].id).toBe("agent-1"); + }); + + it("ignores a payload with no agent rather than corrupting the list", () => { + const store = makeStore(); + store.getState().setSettingsAgents([savedAgent()] as never); + const handlers = registerAgentsHandlers(store); + + handlers[SETTINGS_UPDATED]?.(message(SETTINGS_UPDATED, {}) as never); + handlers[SETTINGS_UPDATED]?.(message(SETTINGS_UPDATED, { agent: {} }) as never); + + expect(store.getState().settingsAgents.items).toHaveLength(1); + expect(store.getState().settingsAgents.items[0].id).toBe("agent-1"); + }); +}); diff --git a/apps/web/lib/ws/handlers/agents.ts b/apps/web/lib/ws/handlers/agents.ts index 20b6d7f442..43c200faa9 100644 --- a/apps/web/lib/ws/handlers/agents.ts +++ b/apps/web/lib/ws/handlers/agents.ts @@ -230,6 +230,27 @@ export function registerAgentsHandlers(store: StoreApi): WsHandlers { }, })); }, + // Full settings record for one agent (e.g. a custom TUI agent's MCP + // strategy changed). Distinct from "agent.updated", which is a runtime + // status ping keyed by {agentId, status} and feeds state.agents. + "agent.settings.updated": (message) => { + const incoming = message.payload?.agent; + if (!incoming?.id) return; + store.setState((state) => { + const exists = state.settingsAgents.items.some((a) => a.id === incoming.id); + if (!exists) return state; + return { + ...state, + settingsAgents: { + items: state.settingsAgents.items.map((a) => + // Preserve the locally normalized profiles: this event carries + // agent-level settings, and the profile list has its own events. + a.id === incoming.id ? { ...a, ...incoming, profiles: a.profiles } : a, + ), + }, + }; + }); + }, "agent.profile.created": (message) => { store.setState((state) => ({ ...state, diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index f942eca790..48366f03cc 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -135,6 +135,10 @@ "mcpSaveProfileHint": "Save this profile to configure MCP servers.", "mcpServersHelp": "MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.", "mcpServersJson": "MCP servers (JSON)", + "mcpStrategy": "Kandev MCP tools", + "mcpStrategyHelp": "Kandev can hand this agent its own tools (set task title, ask the user a question, update the plan, mark a step complete). To do that it must write MCP config in the format your CLI reads, so pick the one that matches. Choosing the wrong format fails silently: the config is written, the CLI ignores it, and no error is shown. Leave this off if the command is not an MCP-capable coding agent.", + "mcpStrategyNone": "Off: no Kandev tools", + "mcpStrategyOption": "{{name}}: {{mechanism}}", "model": "Model", "modelHeading": "Model", "modelRequiredForAllProfiles": "Model is required for all profiles.", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index 463ccff48d..e9b6c13f8c 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -135,6 +135,10 @@ "mcpSaveProfileHint": "Śàvē ţĥĩś ƥŕōƒĩĺē ţō ćōńƒĩĝũŕē ḾĆƤ śēŕvēŕś.", "mcpServersHelp": "ḾĆƤ ďēƒĩńĩţĩōńś àŕē śţōŕēď ĩń ţĥē ďàţàƀàśē àńď ŕēśōĺvēď ƥēŕ ēxēćũţōŕ àţ ŕũńţĩḿē. Ţĥĩś ďōēś ńōţ ōvēŕŕĩďē ŷōũŕ ĺōćàĺ àĝēńţ ćōńƒĩĝ.", "mcpServersJson": "ḾĆƤ śēŕvēŕś (ĴŚŌŃ)", + "mcpStrategy": "Ķàńďēv ḾĆƤ ţōōĺś", + "mcpStrategyHelp": "Ķàńďēv ćàń ĥàńď ţĥĩś àĝēńţ ĩţś ōŵń ţōōĺś (śēţ ţàśķ ţĩţĺē, àśķ ţĥē ũśēŕ à qũēśţĩōń, ũƥďàţē ţĥē ƥĺàń, ḿàŕķ à śţēƥ ćōḿƥĺēţē). Ţō ďō ţĥàţ ĩţ ḿũśţ ŵŕĩţē ḾĆƤ ćōńƒĩĝ ĩń ţĥē ƒōŕḿàţ ŷōũŕ ĆĹĨ ŕēàďś, śō ƥĩćķ ţĥē ōńē ţĥàţ ḿàţćĥēś. Ćĥōōśĩńĝ ţĥē ŵŕōńĝ ƒōŕḿàţ ƒàĩĺś śĩĺēńţĺŷ: ţĥē ćōńƒĩĝ ĩś ŵŕĩţţēń, ţĥē ĆĹĨ ĩĝńōŕēś ĩţ, àńď ńō ēŕŕōŕ ĩś śĥōŵń. Ĺēàvē ţĥĩś ōƒƒ ĩƒ ţĥē ćōḿḿàńď ĩś ńōţ àń ḾĆƤ-ćàƥàƀĺē ćōďĩńĝ àĝēńţ.", + "mcpStrategyNone": "Ōƒƒ: ńō Ķàńďēv ţōōĺś", + "mcpStrategyOption": "{{name}}: {{mechanism}}", "model": "Ḿōďēĺ", "modelHeading": "Ḿōďēĺ", "modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index 0db455b8e5..f3c99ec548 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -50,10 +50,8 @@ "conflictTasksTitle": "Tarefas:", "conflictTierMappingRow": "Escalão <0>{{tier}} em {{workspace}} para {{provider}}", "conflictTierMappingsTitle": "Mapeamentos de escalão da área de trabalho:", - "conflictWatchersTitle": "Vigilâncias (serão desativadas):", "conflictUtilityAgentsTitle": "Agentes utilitários (manterão ligações obsoletas):", - "disableProfileUtilityWarning": "Agentes utilitários usam este perfil. Desativá-lo impede novas chamadas até reparar cada ligação.", - "disableProfileAnyway": "Desativar perfil", + "conflictWatchersTitle": "Vigilâncias (serão desativadas):", "copied": "Copiado", "copyInstallCommand": "Copiar o comando de instalação", "copyToClipboard": "Copiar para a área de transferência", @@ -75,6 +73,8 @@ "details": "detalhes", "detectedAt": "Detetado em {{path}}", "disableProfile": "Desativar perfil", + "disableProfileAnyway": "Desativar perfil", + "disableProfileUtilityWarning": "Agentes utilitários usam este perfil. Desativá-lo impede novas chamadas até reparar cada ligação.", "disableSetting": "Desativar {{name}}", "displayName": "Nome a apresentar", "displayNameRequired": "O nome a apresentar é obrigatório", @@ -84,18 +84,13 @@ "enableProfile": "Ativar perfil", "enableSetting": "Ativar {{name}}", "enabled": "Ativado", - "enabledProfileInfo": "Mais informações sobre a disponibilidade do perfil", "enabledProfileHelper": "Os perfis desativados continuam a servir as sessões existentes, mas não são oferecidos ao criar novas tarefas ou agentes.", + "enabledProfileInfo": "Mais informações sobre a disponibilidade do perfil", "everyProfileNeedsNameAndModel": "Todos os perfis precisam de um nome e de um modelo.", "exampleValue": "ex.: {{example}}", "failedToCreateAgent": "Não foi possível criar o agente", "failedToDeleteProfile": "Não foi possível eliminar o perfil", "failedToFetchCapabilities": "Não foi possível obter as capacidades", - "modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.", - "modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.", - "modelSettings": "Definições do modelo", - "resolvingModelOptions": "A carregar as opções do modelo…", - "retryModelOptions": "Tentar novamente", "failedToLoadCommandPreview": "Não foi possível carregar a pré-visualização do comando", "failedToLoadMcpConfig": "Não foi possível carregar a configuração MCP", "failedToRefresh": "Não foi possível atualizar: {{error}}", @@ -128,9 +123,16 @@ "mcpSaveProfileHint": "Guarde este perfil para configurar servidores MCP.", "mcpServersHelp": "As definições de MCP são guardadas na base de dados e resolvidas por executor em tempo de execução. Isto não substitui a configuração local do seu agente.", "mcpServersJson": "Servidores MCP (JSON)", + "mcpStrategy": "Ferramentas MCP do Kandev", + "mcpStrategyHelp": "O Kandev pode disponibilizar a este agente as suas próprias ferramentas (definir o título da tarefa, colocar uma questão ao utilizador, atualizar o plano, marcar um passo como concluído). Para isso, tem de escrever a configuração MCP no formato que a sua CLI lê, por isso escolha o que corresponde. Escolher o formato errado falha silenciosamente: a configuração é escrita, a CLI ignora-a e não é apresentado qualquer erro. Deixe desativado se o comando não for um agente de programação compatível com MCP.", + "mcpStrategyNone": "Desativado: sem ferramentas do Kandev", + "mcpStrategyOption": "{{name}}: {{mechanism}}", "model": "Modelo", "modelHeading": "Modelo", + "modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.", + "modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.", "modelRequiredForAllProfiles": "O modelo é obrigatório em todos os perfis.", + "modelSettings": "Definições do modelo", "noAuthHintLogin": "Clique em Abrir terminal para iniciar sessão e depois atualize.", "noAuthHintNotInstalled": "Instale a CLI do agente e depois clique em Atualizar.", "noAuthHintShell": "Clique em Abrir terminal para iniciar uma shell, execute o comando de início de sessão do agente e depois atualize.", @@ -162,7 +164,9 @@ "removeThisProfile": "Remover este perfil", "requestFailed": "O pedido falhou", "rescan": "Voltar a analisar", + "resolvingModelOptions": "A carregar as opções do modelo…", "retry": "Tentar novamente", + "retryModelOptions": "Tentar novamente", "retryUpdate": "Repetir a atualização", "retryVersionCheck": "Repetir a verificação da versão", "reviewUpdateBeforeApplying": "Reveja a atualização antes de o Kandev alterar o runtime deste anfitrião.", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index 622634af7a..a9b5704fc5 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -133,6 +133,10 @@ "mcpSaveProfileHint": "保存此配置以配置 MCP 服务器。", "mcpServersHelp": "MCP 定义存储在数据库中,并在运行时按执行器解析。这不会覆盖你的本地智能体配置。", "mcpServersJson": "MCP 服务器(JSON)", + "mcpStrategy": "Kandev MCP 工具", + "mcpStrategyHelp": "Kandev 可以为该代理提供自己的工具(设置任务标题、向用户提问、更新计划、标记步骤完成)。为此,它必须以你的 CLI 能读取的格式写入 MCP 配置,因此请选择匹配的格式。选错格式会静默失败:配置已写入,但 CLI 会忽略它,并且不会显示任何错误。如果该命令不是支持 MCP 的编码代理,请保持关闭。", + "mcpStrategyNone": "关闭:不提供 Kandev 工具", + "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", "modelHeading": "模型", "modelRequiredForAllProfiles": "所有配置都需要模型。", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 6a5706af2e..80e463620e 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -31,7 +31,6 @@ "capabilityProbeFailed": "探測失敗。請查看代理程式日誌了解詳情。", "capabilityProbing": "正在檢查代理程式能力…", "changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級映射。", - "everythingInstalled": "所有可用代理程式和工具均已安裝。", "checkingAuthentication": "正在檢查身份驗證", "checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身份驗證…", "checkingLatestRuntimeVersion": "正在檢查最新執行時版本…", @@ -52,18 +51,13 @@ "conflictTasksTitle": "任務:", "conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}} 層級", "conflictTierMappingsTitle": "工作區層級映射:", - "conflictWatchersTitle": "監控(將被停用):", "conflictUtilityAgentsTitle": "輔助代理程式(將保留失效綁定):", - "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。", - "disableProfileAnyway": "停用設定", + "conflictWatchersTitle": "監控(將被停用):", "copied": "已複製", "copyInstallCommand": "複製安裝命令", "copyToClipboard": "複製到剪貼板", "create": "建立", "createAgentProfileTitle": "建立 {{name}} 設定", - "installAgents": "安裝代理程式", - "profileCount_one": "{{count}} 個設定", - "profileCount_other": "{{count}} 個設定", "createProfileIntro": "為此代理程式建立新設定。", "creating": "正在建立…", "defaultProfile": "預設代理程式設定檔", @@ -80,27 +74,30 @@ "details": "詳情", "detectedAt": "檢測路徑:{{path}}", "disableProfile": "停用代理程式設定檔", + "disableProfileAnyway": "停用設定", + "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。", "disableSetting": "停用 {{name}}", "displayName": "顯示名稱", "displayNameRequired": "顯示名稱為必填項", "done": "完成", + "duplicate": "複製", + "duplicateProfileNamed": "複製 {{name}}", + "duplicateProfileSuccess": "代理程式設定檔已複製", + "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。", "enableMcp": "啓用 MCP", "enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。", "enableProfile": "啓用代理程式設定檔", "enableSetting": "啓用 {{name}}", "enabled": "已啓用", - "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊", "enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。", + "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊", "everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。", + "everythingInstalled": "所有可用代理程式和工具均已安裝。", "exampleValue": "例如 {{example}}", "failedToCreateAgent": "建立代理程式失敗", "failedToDeleteProfile": "刪除設定失敗", + "failedToDuplicateProfile": "複製代理程式設定檔失敗", "failedToFetchCapabilities": "擷取能力失敗", - "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。", - "modelOptionsResolutionFailed": "無法載入模型選項。", - "modelSettings": "模型設定", - "resolvingModelOptions": "正在載入模型選項…", - "retryModelOptions": "重試", "failedToLoadCommandPreview": "載入命令預覽失敗", "failedToLoadMcpConfig": "載入 MCP 設定失敗", "failedToRefresh": "刷新失敗:{{error}}", @@ -115,6 +112,7 @@ "hostTerminal": "主機終端", "hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。", "install": "安裝", + "installAgents": "安裝代理程式", "installQueued": "已排隊…", "installationNotDetected": "未檢測到安裝", "installed": "已安裝", @@ -133,14 +131,21 @@ "mcpSaveProfileHint": "儲存此設定以設定 MCP 伺服器。", "mcpServersHelp": "MCP 定義存儲在數據庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。", "mcpServersJson": "MCP 伺服器(JSON)", + "mcpStrategy": "Kandev MCP 工具", + "mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。", + "mcpStrategyNone": "關閉:不提供 Kandev 工具", + "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", "modelHeading": "模型", + "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。", + "modelOptionsResolutionFailed": "無法載入模型選項。", "modelRequiredForAllProfiles": "所有設定都需要模型。", + "modelSettings": "模型設定", + "newProfile": "新設定", "noAuthHintLogin": "點擊「開啟終端」登入,然後刷新。", "noAuthHintNotInstalled": "安裝代理程式 CLI,然後點擊「刷新」。", "noAuthHintShell": "點擊「開啟終端」啓動 shell,執行代理程式的登入命令,然後刷新。", "noAuthLoginRequired": "未驗證;需要登入", - "newProfile": "新設定", "noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。", "noCommandPreview": "無可用命令預覽。", "noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點擊「重新掃描」。", @@ -154,6 +159,8 @@ "popularServers": "常用伺服器", "probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。", "profileActions": "設定操作", + "profileCount_one": "{{count}} 個設定", + "profileCount_other": "{{count}} 個設定", "profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:", "profileName": "設定名稱", "profileNameRequired": "設定名稱為必填項。", @@ -170,7 +177,9 @@ "removeThisProfile": "移除此設定", "requestFailed": "請求失敗", "rescan": "重新掃描", + "resolvingModelOptions": "正在載入模型選項…", "retry": "重試", + "retryModelOptions": "重試", "retryUpdate": "重試更新", "retryVersionCheck": "重試版本檢查", "reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。", @@ -212,10 +221,5 @@ "watcherKindGithubIssue": "GitHub 議題", "watcherKindGithubReview": "GitHub PR 審查", "watcherKindJira": "Jira", - "watcherKindLinear": "Linear", - "duplicate": "複製", - "duplicateProfileNamed": "複製 {{name}}", - "duplicateProfileSuccess": "代理程式設定檔已複製", - "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。", - "failedToDuplicateProfile": "複製代理程式設定檔失敗" + "watcherKindLinear": "Linear" } diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 912a3a920e..b180cf618d 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -31,7 +31,6 @@ "capabilityProbeFailed": "探測失敗。請檢視代理程式日誌了解詳情。", "capabilityProbing": "正在檢查代理程式能力…", "changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級對映。", - "everythingInstalled": "所有可用代理程式和工具均已安裝。", "checkingAuthentication": "正在檢查身分驗證", "checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身分驗證…", "checkingLatestRuntimeVersion": "正在檢查最新執行時版本…", @@ -52,18 +51,13 @@ "conflictTasksTitle": "任務:", "conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}} 層級", "conflictTierMappingsTitle": "工作區層級對映:", - "conflictWatchersTitle": "監控(將被停用):", "conflictUtilityAgentsTitle": "輔助代理程式(將保留失效繫結):", - "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。", - "disableProfileAnyway": "停用設定", + "conflictWatchersTitle": "監控(將被停用):", "copied": "已複製", "copyInstallCommand": "複製安裝命令", "copyToClipboard": "複製到剪貼簿", "create": "建立", "createAgentProfileTitle": "建立 {{name}} 設定", - "installAgents": "安裝代理程式", - "profileCount_one": "{{count}} 個設定", - "profileCount_other": "{{count}} 個設定", "createProfileIntro": "為此代理程式建立新設定。", "creating": "正在建立…", "defaultProfile": "預設代理程式設定檔", @@ -80,27 +74,30 @@ "details": "詳情", "detectedAt": "檢測路徑:{{path}}", "disableProfile": "停用代理程式設定檔", + "disableProfileAnyway": "停用設定", + "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。", "disableSetting": "停用 {{name}}", "displayName": "顯示名稱", "displayNameRequired": "顯示名稱為必填項", "done": "完成", + "duplicate": "複製", + "duplicateProfileNamed": "複製 {{name}}", + "duplicateProfileSuccess": "代理程式設定檔已複製", + "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。", "enableMcp": "啟用 MCP", "enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。", "enableProfile": "啟用代理程式設定檔", "enableSetting": "啟用 {{name}}", "enabled": "已啟用", - "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊", "enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。", + "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊", "everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。", + "everythingInstalled": "所有可用代理程式和工具均已安裝。", "exampleValue": "例如 {{example}}", "failedToCreateAgent": "建立代理程式失敗", "failedToDeleteProfile": "刪除設定失敗", + "failedToDuplicateProfile": "複製代理程式設定檔失敗", "failedToFetchCapabilities": "擷取能力失敗", - "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。", - "modelOptionsResolutionFailed": "無法載入模型選項。", - "modelSettings": "模型設定", - "resolvingModelOptions": "正在載入模型選項…", - "retryModelOptions": "重試", "failedToLoadCommandPreview": "載入命令預覽失敗", "failedToLoadMcpConfig": "載入 MCP 設定失敗", "failedToRefresh": "重新整理失敗:{{error}}", @@ -115,6 +112,7 @@ "hostTerminal": "主機終端", "hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。", "install": "安裝", + "installAgents": "安裝代理程式", "installQueued": "已排隊…", "installationNotDetected": "未檢測到安裝", "installed": "已安裝", @@ -133,14 +131,21 @@ "mcpSaveProfileHint": "儲存此設定以設定 MCP 伺服器。", "mcpServersHelp": "MCP 定義儲存在資料庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。", "mcpServersJson": "MCP 伺服器(JSON)", + "mcpStrategy": "Kandev MCP 工具", + "mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。", + "mcpStrategyNone": "關閉:不提供 Kandev 工具", + "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", "modelHeading": "模型", + "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。", + "modelOptionsResolutionFailed": "無法載入模型選項。", "modelRequiredForAllProfiles": "所有設定都需要模型。", + "modelSettings": "模型設定", + "newProfile": "新設定", "noAuthHintLogin": "點選「開啟終端」登入,然後重新整理。", "noAuthHintNotInstalled": "安裝代理程式 CLI,然後點選「重新整理」。", "noAuthHintShell": "點選「開啟終端」啟動 shell,執行代理程式的登入命令,然後重新整理。", "noAuthLoginRequired": "未驗證;需要登入", - "newProfile": "新設定", "noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。", "noCommandPreview": "無可用命令預覽。", "noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點選「重新掃描」。", @@ -154,6 +159,8 @@ "popularServers": "常用伺服器", "probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。", "profileActions": "設定操作", + "profileCount_one": "{{count}} 個設定", + "profileCount_other": "{{count}} 個設定", "profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:", "profileName": "設定名稱", "profileNameRequired": "設定名稱為必填項。", @@ -170,7 +177,9 @@ "removeThisProfile": "移除此設定", "requestFailed": "請求失敗", "rescan": "重新掃描", + "resolvingModelOptions": "正在載入模型選項…", "retry": "重試", + "retryModelOptions": "重試", "retryUpdate": "重試更新", "retryVersionCheck": "重試版本檢查", "reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。", @@ -212,10 +221,5 @@ "watcherKindGithubIssue": "GitHub 議題", "watcherKindGithubReview": "GitHub PR 審查", "watcherKindJira": "Jira", - "watcherKindLinear": "Linear", - "duplicate": "複製", - "duplicateProfileNamed": "複製 {{name}}", - "duplicateProfileSuccess": "代理程式設定檔已複製", - "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。", - "failedToDuplicateProfile": "複製代理程式設定檔失敗" + "watcherKindLinear": "Linear" }