fix(agents): expose kandev MCP tools to custom TUI agents (#2603)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, unknown>>()),
|
||||
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> = {}): 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(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<InstalledAgentCard
|
||||
agent={discovery(name) as never}
|
||||
savedAgent={saved}
|
||||
displayName="Fuel Claude"
|
||||
>
|
||||
<CustomTUIMcpCard agent={saved} />
|
||||
</InstalledAgentCard>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
<CustomTUIMcpCard agent={savedAgentsByName.get(agent.name)} />
|
||||
</InstalledAgentCard>
|
||||
))}
|
||||
</div>
|
||||
@@ -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([
|
||||
|
||||
@@ -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<React.SetStateAction<string | null>>;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onSubmit: (data: TUIAgentFormData) => Promise<void>;
|
||||
@@ -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<string | null>(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
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<MCPStrategySelect
|
||||
id="tui-mcp-strategy"
|
||||
value={mcpStrategy}
|
||||
onChange={setMcpStrategy}
|
||||
strategies={strategies}
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-t px-3 py-2.5" data-testid={`agent-mcp-strategy-${agent.name}`}>
|
||||
<MCPStrategySelect
|
||||
id={`agent-mcp-strategy-${agent.id}`}
|
||||
value={agent.tui_config.mcp_strategy}
|
||||
onChange={handleChange}
|
||||
strategies={strategies}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<MCPStrategyOption[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("agents:mcpStrategy")}</Label>
|
||||
<Select
|
||||
value={toStrategyValue(value)}
|
||||
onValueChange={(next) => onChange(fromStrategyValue(next))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id={id} className="cursor-pointer" data-testid="mcp-strategy-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={MCP_STRATEGY_NONE} className="cursor-pointer">
|
||||
{t("agents:mcpStrategyNone")}
|
||||
</SelectItem>
|
||||
{strategies.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key} className="cursor-pointer">
|
||||
{t("agents:mcpStrategyOption", {
|
||||
name: option.key,
|
||||
mechanism: option.description,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:mcpStrategyHelp")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Agent> {
|
||||
const res = await fetchJson<Agent>("/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<MCPStrategyOption[]> {
|
||||
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<Agent> {
|
||||
const res = await fetchJson<Agent>(`/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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<SettingsSlice>()(
|
||||
immer((set, get, store) => createSettingsSlice(set, get, store)),
|
||||
) as unknown as StoreApi<AppState>;
|
||||
}
|
||||
|
||||
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<string, unknown> = {}) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -230,6 +230,27 @@ export function registerAgentsHandlers(store: StoreApi<AppState>): 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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.",
|
||||
|
||||
@@ -50,10 +50,8 @@
|
||||
"conflictTasksTitle": "Tarefas:",
|
||||
"conflictTierMappingRow": "Escalão <0>{{tier}}</0> 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.",
|
||||
|
||||
@@ -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": "所有配置都需要模型。",
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"capabilityProbeFailed": "探測失敗。請查看代理程式日誌了解詳情。",
|
||||
"capabilityProbing": "正在檢查代理程式能力…",
|
||||
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級映射。",
|
||||
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
|
||||
"checkingAuthentication": "正在檢查身份驗證",
|
||||
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身份驗證…",
|
||||
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
|
||||
@@ -52,18 +51,13 @@
|
||||
"conflictTasksTitle": "任務:",
|
||||
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}</0> 層級",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"capabilityProbeFailed": "探測失敗。請檢視代理程式日誌了解詳情。",
|
||||
"capabilityProbing": "正在檢查代理程式能力…",
|
||||
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級對映。",
|
||||
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
|
||||
"checkingAuthentication": "正在檢查身分驗證",
|
||||
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身分驗證…",
|
||||
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
|
||||
@@ -52,18 +51,13 @@
|
||||
"conflictTasksTitle": "任務:",
|
||||
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}</0> 層級",
|
||||
"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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user