feat(agents): let a custom agent speak ACP (#3863)
Co-authored-by: Joao Manso <X363081@bcpcorp.net> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -453,6 +453,11 @@ type InferenceConfig struct {
|
||||
Command Command
|
||||
// ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]).
|
||||
ModelFlag Param
|
||||
// OperatorDefined marks a Command that the install operator registered in
|
||||
// Settings rather than one compiled into this binary. The host utility's
|
||||
// probe allow-list is built from literals, which a command that does not
|
||||
// exist until it is typed can never join.
|
||||
OperatorDefined bool
|
||||
}
|
||||
|
||||
// InferenceModel describes a model available for inference.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/usage"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
// CustomACPAgentConfig is the declarative specification for a user-defined
|
||||
// agent whose CLI speaks ACP on stdin/stdout.
|
||||
type CustomACPAgentConfig struct {
|
||||
// Required
|
||||
AgentID string
|
||||
AgentName string
|
||||
Command string // binary name, e.g. "my-agent"
|
||||
Desc string
|
||||
|
||||
// Optional (zero values get sensible defaults)
|
||||
Display string // defaults to AgentName
|
||||
Order *int // defaults to 99 when nil; use a pointer so 0 is a valid explicit value
|
||||
CommandArgs []string // extra args after Command, e.g. "--acp"
|
||||
DetectOpts []DetectOption // defaults to WithCommand(Command)
|
||||
}
|
||||
|
||||
// CustomACPAgent implements Agent for a user-defined CLI that kandev drives
|
||||
// over ACP.
|
||||
//
|
||||
// It is deliberately not a TUIAgent. IsPassthroughOnly is a type assertion on
|
||||
// that type, so being one would seed the default profile as terminal
|
||||
// passthrough and hide the agent from the host-utility capability probe.
|
||||
//
|
||||
// It offers no passthrough mode either. A custom definition carries one
|
||||
// command, and an ACP server command is not the command that renders an
|
||||
// interactive terminal: launching `my-agent --acp` under a PTY shows the user
|
||||
// JSON-RPC frames.
|
||||
type CustomACPAgent struct {
|
||||
cfg CustomACPAgentConfig
|
||||
cmd Command // cached command built once from cfg.Command + cfg.CommandArgs
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ Agent = (*CustomACPAgent)(nil)
|
||||
_ InferenceAgent = (*CustomACPAgent)(nil)
|
||||
)
|
||||
|
||||
// NewCustomACPAgent creates a CustomACPAgent from a declarative config,
|
||||
// applying defaults.
|
||||
func NewCustomACPAgent(cfg CustomACPAgentConfig) *CustomACPAgent {
|
||||
if cfg.Display == "" {
|
||||
cfg.Display = cfg.AgentName
|
||||
}
|
||||
if cfg.Order == nil {
|
||||
defaultOrder := 99
|
||||
cfg.Order = &defaultOrder
|
||||
}
|
||||
if len(cfg.DetectOpts) == 0 {
|
||||
cfg.DetectOpts = []DetectOption{WithCommand(cfg.Command)}
|
||||
}
|
||||
return &CustomACPAgent{
|
||||
cfg: cfg,
|
||||
cmd: Cmd(cfg.Command).Flag(cfg.CommandArgs...).Build(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CustomACPAgent) ID() string { return a.cfg.AgentID }
|
||||
func (a *CustomACPAgent) Name() string { return a.cfg.AgentName }
|
||||
func (a *CustomACPAgent) DisplayName() string { return a.cfg.Display }
|
||||
func (a *CustomACPAgent) Description() string { return a.cfg.Desc }
|
||||
func (a *CustomACPAgent) Enabled() bool { return true }
|
||||
func (a *CustomACPAgent) DisplayOrder() int { return *a.cfg.Order }
|
||||
|
||||
// Logo returns nil: a user-defined agent ships no artwork, and the frontend
|
||||
// renders a placeholder.
|
||||
func (a *CustomACPAgent) Logo(LogoVariant) []byte { return nil }
|
||||
|
||||
func (a *CustomACPAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, a.cfg.DetectOpts...)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
// Resolved MCP servers travel in ACP session/new, so support does not
|
||||
// depend on a per-CLI config-file strategy the way passthrough does.
|
||||
result.SupportsMCP = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *CustomACPAgent) BuildCommand(_ CommandOptions) Command { return a.cmd }
|
||||
|
||||
func (a *CustomACPAgent) Runtime() *RuntimeConfig {
|
||||
return &RuntimeConfig{
|
||||
Cmd: a.cmd,
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: DefaultResourceLimits,
|
||||
Protocol: agent.ProtocolACP,
|
||||
}
|
||||
}
|
||||
|
||||
// InferenceConfig opts the agent into the host-utility capability probe, which
|
||||
// is where its models and modes come from.
|
||||
func (a *CustomACPAgent) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{Supported: true, Command: a.cmd, OperatorDefined: true}
|
||||
}
|
||||
|
||||
func (a *CustomACPAgent) InstallScript() string { return "" }
|
||||
func (a *CustomACPAgent) RemoteAuth() *RemoteAuth { return nil }
|
||||
func (a *CustomACPAgent) BillingType() usage.BillingType { return defaultBillingType() }
|
||||
|
||||
// PermissionSettings has no CLI-driven entries: permission stance is expressed
|
||||
// through ACP session modes and per-tool-call prompts, as for built-in ACP
|
||||
// agents.
|
||||
func (a *CustomACPAgent) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
func newTestCustomACPAgent() *CustomACPAgent {
|
||||
return NewCustomACPAgent(CustomACPAgentConfig{
|
||||
AgentID: "my-agent",
|
||||
AgentName: "my-agent",
|
||||
Display: "My Agent",
|
||||
Command: "my-agent",
|
||||
CommandArgs: []string{"--acp"},
|
||||
})
|
||||
}
|
||||
|
||||
// A custom agent registered as ACP must not be classified as passthrough-only:
|
||||
// that predicate decides whether the agent can only run as a raw CLI under a
|
||||
// PTY, and it seeds the default profile as terminal passthrough.
|
||||
func TestCustomACPAgentRunsStructured(t *testing.T) {
|
||||
a := newTestCustomACPAgent()
|
||||
|
||||
if IsPassthroughOnly(a) {
|
||||
t.Error("IsPassthroughOnly = true for a custom ACP agent")
|
||||
}
|
||||
if !SupportsInteractiveMCPTools(a) {
|
||||
t.Error("SupportsInteractiveMCPTools = false for a custom ACP agent")
|
||||
}
|
||||
if _, ok := any(a).(PassthroughAgent); ok {
|
||||
t.Error("custom ACP agent advertises passthrough; its command is an ACP server, not a TUI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomACPAgentRuntimeSpeaksACP(t *testing.T) {
|
||||
a := newTestCustomACPAgent()
|
||||
|
||||
rt := a.Runtime()
|
||||
if rt.Protocol != agent.ProtocolACP {
|
||||
t.Errorf("Runtime().Protocol = %q, want %q", rt.Protocol, agent.ProtocolACP)
|
||||
}
|
||||
want := []string{"my-agent", "--acp"}
|
||||
if got := rt.Cmd.Args(); !slices.Equal(got, want) {
|
||||
t.Errorf("Runtime().Cmd = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) {
|
||||
t.Errorf("BuildCommand = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Models and modes come from the host-utility probe, which only runs for
|
||||
// agents that advertise inference. Without it the profile has no model to
|
||||
// select and session start fails the no-silent-model-fallback policy.
|
||||
func TestCustomACPAgentIsProbedForCapabilities(t *testing.T) {
|
||||
a := newTestCustomACPAgent()
|
||||
|
||||
inference, ok := any(a).(InferenceAgent)
|
||||
if !ok {
|
||||
t.Fatal("custom ACP agent does not implement InferenceAgent")
|
||||
}
|
||||
cfg := inference.InferenceConfig()
|
||||
if cfg == nil || !cfg.Supported {
|
||||
t.Fatal("InferenceConfig is not supported")
|
||||
}
|
||||
want := []string{"my-agent", "--acp"}
|
||||
if got := cfg.Command.Args(); !slices.Equal(got, want) {
|
||||
t.Errorf("InferenceConfig().Command = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved MCP servers travel in ACP session/new, so a custom ACP agent needs
|
||||
// no per-CLI config-file strategy to get kandev's tools.
|
||||
func TestCustomACPAgentSupportsMCP(t *testing.T) {
|
||||
a := newTestCustomACPAgent()
|
||||
|
||||
result, err := a.IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled: %v", err)
|
||||
}
|
||||
if !result.SupportsMCP {
|
||||
t.Error("SupportsMCP = false for a custom ACP agent")
|
||||
}
|
||||
}
|
||||
|
||||
// The host-utility probe spawns from an allow-list of compiled-in literals. A
|
||||
// command the operator typed can never be on it, so the agent has to carry the
|
||||
// marker that lets the probe accept it.
|
||||
func TestCustomACPAgentMarksItsCommandOperatorDefined(t *testing.T) {
|
||||
cfg := newTestCustomACPAgent().InferenceConfig()
|
||||
|
||||
if !cfg.OperatorDefined {
|
||||
t.Error("OperatorDefined = false; the probe would refuse this command")
|
||||
}
|
||||
}
|
||||
|
||||
// A built-in agent must not claim the marker: its command is compiled in and
|
||||
// belongs on the allow-list, where a drift between the two is a real bug.
|
||||
func TestBuiltinACPAgentDoesNotClaimOperatorDefined(t *testing.T) {
|
||||
cfg := NewGooseACP().InferenceConfig()
|
||||
|
||||
if cfg.OperatorDefined {
|
||||
t.Error("OperatorDefined = true for a built-in agent")
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,6 @@ type Capabilities struct {
|
||||
SupportsWorkspaceOnly bool `json:"supports_workspace_only"`
|
||||
}
|
||||
|
||||
// KnownAgent represents an agent definition with discovery metadata.
|
||||
// Model data now comes from the host utility capability cache — see
|
||||
// internal/agent/hostutility. The Models/DefaultModel fields below are
|
||||
// intentionally empty; discovery only advertises *which* agent types exist
|
||||
// and whether their CLI is installed.
|
||||
type KnownAgent struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SupportsMCP bool `json:"supports_mcp"`
|
||||
MCPConfigPaths []string `json:"mcp_config_paths"`
|
||||
InstallationPaths []string `json:"installation_paths"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
// Availability represents the result of detecting an agent's installation.
|
||||
type Availability struct {
|
||||
Name string `json:"name"`
|
||||
@@ -49,77 +35,50 @@ type Availability struct {
|
||||
}
|
||||
|
||||
// Registry manages agent discovery using the agents.Agent interface.
|
||||
//
|
||||
// The agent list is read from the agent registry on every sweep rather than
|
||||
// captured once. Custom agents are registered, replaced, and unregistered
|
||||
// while the backend runs (agent creation, MCP-strategy changes, deletion), and
|
||||
// a captured list would keep reporting a deleted agent as installed — and keep
|
||||
// a replaced one's superseded discovery flags — until the next restart.
|
||||
type Registry struct {
|
||||
agents []agents.Agent
|
||||
definitions []KnownAgent
|
||||
logger *logger.Logger
|
||||
registry *registry.Registry
|
||||
logger *logger.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
cachedResults []Availability
|
||||
cachedAt time.Time
|
||||
cacheTTL time.Duration
|
||||
// generation counts invalidations. A sweep reads the registry when it
|
||||
// starts and writes its results when it finishes, so an invalidation can
|
||||
// land in between; the sweep carries the generation it began with and
|
||||
// discards its results when that no longer matches.
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// LoadRegistry creates a new discovery registry from the agent registry.
|
||||
// It iterates over all enabled agents, calls IsInstalled and ListModels
|
||||
// to populate the KnownAgent definitions.
|
||||
func LoadRegistry(ctx context.Context, reg *registry.Registry, log *logger.Logger) (*Registry, error) {
|
||||
enabled := reg.ListEnabled()
|
||||
|
||||
definitions := make([]KnownAgent, 0, len(enabled))
|
||||
agentList := make([]agents.Agent, 0, len(enabled))
|
||||
// LoadRegistry creates a new discovery registry backed by the agent registry.
|
||||
// The context is unused: nothing is probed here, because detection resolves the
|
||||
// agent list when a sweep runs.
|
||||
func LoadRegistry(_ context.Context, reg *registry.Registry, log *logger.Logger) (*Registry, error) {
|
||||
return &Registry{
|
||||
registry: reg,
|
||||
logger: log,
|
||||
cacheTTL: defaultCacheTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// enabledAgents returns the agents a sweep should probe: every enabled entry in
|
||||
// the agent registry except virtual families, which have no CLI to detect.
|
||||
func (r *Registry) enabledAgents() []agents.Agent {
|
||||
enabled := r.registry.ListEnabled()
|
||||
result := make([]agents.Agent, 0, len(enabled))
|
||||
for _, ag := range enabled {
|
||||
if agents.IsVirtualAgent(ag) {
|
||||
continue
|
||||
}
|
||||
// Gather discovery info from the agent.
|
||||
result, err := ag.IsInstalled(ctx)
|
||||
if err != nil {
|
||||
log.Warn("discovery: failed to check agent installation",
|
||||
zap.String("agent", ag.ID()),
|
||||
zap.Error(err),
|
||||
)
|
||||
// Still include the agent but with empty discovery data.
|
||||
result = &agents.DiscoveryResult{}
|
||||
}
|
||||
|
||||
displayName := ag.DisplayName()
|
||||
if displayName == "" {
|
||||
displayName = ag.Name()
|
||||
}
|
||||
|
||||
knownAgent := KnownAgent{
|
||||
Name: ag.ID(),
|
||||
DisplayName: displayName,
|
||||
SupportsMCP: result.SupportsMCP,
|
||||
MCPConfigPaths: result.MCPConfigPaths,
|
||||
InstallationPaths: result.InstallationPaths,
|
||||
Capabilities: Capabilities{
|
||||
SupportsSessionResume: result.Capabilities.SupportsSessionResume,
|
||||
SupportsShell: result.Capabilities.SupportsShell,
|
||||
SupportsWorkspaceOnly: result.Capabilities.SupportsWorkspaceOnly,
|
||||
},
|
||||
}
|
||||
|
||||
definitions = append(definitions, knownAgent)
|
||||
agentList = append(agentList, ag)
|
||||
result = append(result, ag)
|
||||
}
|
||||
|
||||
return &Registry{
|
||||
agents: agentList,
|
||||
definitions: definitions,
|
||||
logger: log,
|
||||
cacheTTL: defaultCacheTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Definitions returns a copy of all known agent definitions.
|
||||
func (r *Registry) Definitions() []KnownAgent {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]KnownAgent(nil), r.definitions...)
|
||||
return result
|
||||
}
|
||||
|
||||
// Detect checks whether each agent is installed by calling IsInstalled.
|
||||
@@ -129,11 +88,17 @@ func (r *Registry) Detect(ctx context.Context) ([]Availability, error) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
startedAt := r.generation
|
||||
r.mu.RUnlock()
|
||||
|
||||
results := r.detectAll(ctx)
|
||||
|
||||
r.mu.Lock()
|
||||
r.cachedResults = results
|
||||
r.cachedAt = time.Now()
|
||||
if r.generation == startedAt {
|
||||
r.cachedResults = results
|
||||
r.cachedAt = time.Now()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
return results, nil
|
||||
@@ -145,6 +110,7 @@ func (r *Registry) InvalidateCache() {
|
||||
r.mu.Lock()
|
||||
r.cachedResults = nil
|
||||
r.cachedAt = time.Time{}
|
||||
r.generation++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -178,8 +144,9 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
|
||||
err error
|
||||
}
|
||||
|
||||
ch := make(chan indexedResult, len(r.agents))
|
||||
for i, ag := range r.agents {
|
||||
sweep := r.enabledAgents()
|
||||
ch := make(chan indexedResult, len(sweep))
|
||||
for i, ag := range sweep {
|
||||
go func(idx int, ag agents.Agent) {
|
||||
result, err := ag.IsInstalled(ctx)
|
||||
if err != nil {
|
||||
@@ -213,14 +180,14 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
|
||||
|
||||
// Collect results preserving original order.
|
||||
// If the context expires before all agents respond, return partial results.
|
||||
slots := make([]Availability, len(r.agents))
|
||||
valid := make([]bool, len(r.agents))
|
||||
for range r.agents {
|
||||
slots := make([]Availability, len(sweep))
|
||||
valid := make([]bool, len(sweep))
|
||||
for range sweep {
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.err != nil {
|
||||
r.logger.Warn("discovery: detect failed for agent",
|
||||
zap.String("agent", r.agents[res.index].ID()),
|
||||
zap.String("agent", sweep[res.index].ID()),
|
||||
zap.Error(res.err),
|
||||
)
|
||||
continue
|
||||
@@ -234,7 +201,7 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
|
||||
}
|
||||
collect:
|
||||
|
||||
results := make([]Availability, 0, len(r.agents))
|
||||
results := make([]Availability, 0, len(sweep))
|
||||
for i, v := range valid {
|
||||
if v {
|
||||
results = append(results, slots[i])
|
||||
|
||||
@@ -2,11 +2,13 @@ package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
"github.com/kandev/kandev/internal/agent/usage"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
@@ -56,6 +58,20 @@ func (a *discoveryTestAgent) calls() int {
|
||||
return a.installedCalls
|
||||
}
|
||||
|
||||
// newSingleAgentRegistry builds a discovery registry over an agent registry
|
||||
// holding exactly one agent, with an explicit cache TTL.
|
||||
func newSingleAgentRegistry(t *testing.T, ag agents.Agent, ttl time.Duration) *Registry {
|
||||
t.Helper()
|
||||
log := newDiscoveryTestLogger(t)
|
||||
agentRegistry := registry.NewRegistry(log)
|
||||
if err := agentRegistry.Register(ag); err != nil {
|
||||
t.Fatalf("register %s: %v", ag.ID(), err)
|
||||
}
|
||||
discoveryRegistry := loadDiscoveryRegistry(t, agentRegistry, log)
|
||||
discoveryRegistry.cacheTTL = ttl
|
||||
return discoveryRegistry
|
||||
}
|
||||
|
||||
func newDiscoveryTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||
@@ -74,11 +90,7 @@ func TestRegistryDetectCachesResults(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
reg := &Registry{
|
||||
agents: []agents.Agent{a},
|
||||
logger: newDiscoveryTestLogger(t),
|
||||
cacheTTL: time.Minute,
|
||||
}
|
||||
reg := newSingleAgentRegistry(t, a, time.Minute)
|
||||
|
||||
if _, err := reg.Detect(context.Background()); err != nil {
|
||||
t.Fatalf("first detect: %v", err)
|
||||
@@ -101,11 +113,7 @@ func TestRegistryInvalidateCacheForcesRefresh(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
reg := &Registry{
|
||||
agents: []agents.Agent{a},
|
||||
logger: newDiscoveryTestLogger(t),
|
||||
cacheTTL: time.Minute,
|
||||
}
|
||||
reg := newSingleAgentRegistry(t, a, time.Minute)
|
||||
|
||||
first, err := reg.Detect(context.Background())
|
||||
if err != nil {
|
||||
@@ -136,11 +144,7 @@ func TestRegistryDetectCacheTTLExpiry(t *testing.T) {
|
||||
discovery: &agents.DiscoveryResult{Available: true},
|
||||
}
|
||||
|
||||
reg := &Registry{
|
||||
agents: []agents.Agent{a},
|
||||
logger: newDiscoveryTestLogger(t),
|
||||
cacheTTL: 20 * time.Millisecond,
|
||||
}
|
||||
reg := newSingleAgentRegistry(t, a, 20*time.Millisecond)
|
||||
|
||||
if _, err := reg.Detect(context.Background()); err != nil {
|
||||
t.Fatalf("first detect: %v", err)
|
||||
@@ -154,3 +158,195 @@ func TestRegistryDetectCacheTTLExpiry(t *testing.T) {
|
||||
t.Fatalf("IsInstalled calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
type virtualTestAgent struct {
|
||||
*discoveryTestAgent
|
||||
}
|
||||
|
||||
func (a *virtualTestAgent) IsVirtual() bool { return true }
|
||||
|
||||
func detectNames(t *testing.T, r *Registry) []string {
|
||||
t.Helper()
|
||||
results, err := r.Detect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("detect: %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(results))
|
||||
for _, result := range results {
|
||||
names = append(names, result.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func loadDiscoveryRegistry(t *testing.T, reg *registry.Registry, log *logger.Logger) *Registry {
|
||||
t.Helper()
|
||||
discoveryRegistry, err := LoadRegistry(context.Background(), reg, log)
|
||||
if err != nil {
|
||||
t.Fatalf("load registry: %v", err)
|
||||
}
|
||||
return discoveryRegistry
|
||||
}
|
||||
|
||||
// A custom agent deleted through the settings API is unregistered from the
|
||||
// agent registry, but the Installed Agents list is rendered from discovery. If
|
||||
// discovery keeps its own copy of the agent list, the deleted agent stays
|
||||
// listed (and a newly created one stays missing) until the backend restarts.
|
||||
func TestRegistryDetectFollowsRegistryMembership(t *testing.T) {
|
||||
log := newDiscoveryTestLogger(t)
|
||||
reg := registry.NewRegistry(log)
|
||||
if err := reg.Register(&discoveryTestAgent{
|
||||
id: "agent-a",
|
||||
discovery: &agents.DiscoveryResult{Available: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("register agent-a: %v", err)
|
||||
}
|
||||
|
||||
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
|
||||
if !slices.Contains(detectNames(t, discoveryRegistry), "agent-a") {
|
||||
t.Fatal("agent-a missing from the first detect")
|
||||
}
|
||||
|
||||
if err := reg.Unregister("agent-a"); err != nil {
|
||||
t.Fatalf("unregister agent-a: %v", err)
|
||||
}
|
||||
discoveryRegistry.InvalidateCache()
|
||||
if slices.Contains(detectNames(t, discoveryRegistry), "agent-a") {
|
||||
t.Error("agent-a is still detected after being unregistered")
|
||||
}
|
||||
|
||||
if err := reg.Register(&discoveryTestAgent{
|
||||
id: "agent-b",
|
||||
discovery: &agents.DiscoveryResult{Available: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("register agent-b: %v", err)
|
||||
}
|
||||
discoveryRegistry.InvalidateCache()
|
||||
if !slices.Contains(detectNames(t, discoveryRegistry), "agent-b") {
|
||||
t.Error("agent-b is not detected after being registered")
|
||||
}
|
||||
}
|
||||
|
||||
// Changing a custom agent's MCP strategy rebuilds its registry entry through
|
||||
// Replace. Discovery must report the replacement, otherwise the next sweep
|
||||
// writes the superseded SupportsMCP value back over the agent row.
|
||||
func TestRegistryDetectReflectsReplacedAgent(t *testing.T) {
|
||||
log := newDiscoveryTestLogger(t)
|
||||
reg := registry.NewRegistry(log)
|
||||
if err := reg.Register(&discoveryTestAgent{
|
||||
id: "agent-a",
|
||||
discovery: &agents.DiscoveryResult{Available: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("register agent-a: %v", err)
|
||||
}
|
||||
|
||||
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
|
||||
detectNames(t, discoveryRegistry)
|
||||
|
||||
if err := reg.Replace(&discoveryTestAgent{
|
||||
id: "agent-a",
|
||||
discovery: &agents.DiscoveryResult{Available: true, SupportsMCP: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("replace agent-a: %v", err)
|
||||
}
|
||||
discoveryRegistry.InvalidateCache()
|
||||
|
||||
results, err := discoveryRegistry.Detect(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("detect: %v", err)
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.Name != "agent-a" {
|
||||
continue
|
||||
}
|
||||
if !result.SupportsMCP {
|
||||
t.Error("SupportsMCP = false after replacing agent-a with an MCP-capable entry")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("agent-a missing after replacement")
|
||||
}
|
||||
|
||||
func TestRegistryDetectSkipsVirtualAgents(t *testing.T) {
|
||||
log := newDiscoveryTestLogger(t)
|
||||
reg := registry.NewRegistry(log)
|
||||
if err := reg.Register(&virtualTestAgent{
|
||||
discoveryTestAgent: &discoveryTestAgent{
|
||||
id: "agent-virtual",
|
||||
discovery: &agents.DiscoveryResult{Available: true},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("register agent-virtual: %v", err)
|
||||
}
|
||||
|
||||
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
|
||||
if slices.Contains(detectNames(t, discoveryRegistry), "agent-virtual") {
|
||||
t.Error("virtual agent reported as a discovery result")
|
||||
}
|
||||
}
|
||||
|
||||
// blockingDiscoveryAgent holds IsInstalled open until released, so a test can
|
||||
// invalidate the cache while a sweep is still in flight.
|
||||
type blockingDiscoveryAgent struct {
|
||||
*discoveryTestAgent
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (a *blockingDiscoveryAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) {
|
||||
a.once.Do(func() { close(a.entered) })
|
||||
<-a.release
|
||||
return a.discoveryTestAgent.IsInstalled(ctx)
|
||||
}
|
||||
|
||||
// A sweep reads the registry when it starts and writes its results when it
|
||||
// finishes, so an unregister landing in between would otherwise be undone: the
|
||||
// finishing sweep caches the agent it saw, and the deleted card comes back for
|
||||
// a whole TTL.
|
||||
func TestRegistryDetectDropsResultsInvalidatedMidSweep(t *testing.T) {
|
||||
log := newDiscoveryTestLogger(t)
|
||||
reg := registry.NewRegistry(log)
|
||||
blocking := &blockingDiscoveryAgent{
|
||||
discoveryTestAgent: &discoveryTestAgent{
|
||||
id: "agent-a",
|
||||
discovery: &agents.DiscoveryResult{Available: true},
|
||||
},
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
if err := reg.Register(blocking); err != nil {
|
||||
t.Fatalf("register agent-a: %v", err)
|
||||
}
|
||||
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
|
||||
|
||||
swept := make(chan []Availability, 1)
|
||||
go func() {
|
||||
results, err := discoveryRegistry.Detect(context.Background())
|
||||
if err != nil {
|
||||
t.Errorf("in-flight detect: %v", err)
|
||||
}
|
||||
swept <- results
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-blocking.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("sweep never reached IsInstalled")
|
||||
}
|
||||
|
||||
if err := reg.Unregister("agent-a"); err != nil {
|
||||
t.Fatalf("unregister agent-a: %v", err)
|
||||
}
|
||||
discoveryRegistry.InvalidateCache()
|
||||
close(blocking.release)
|
||||
|
||||
select {
|
||||
case <-swept:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("in-flight sweep never finished")
|
||||
}
|
||||
|
||||
if slices.Contains(detectNames(t, discoveryRegistry), "agent-a") {
|
||||
t.Error("a sweep that started before the invalidation published its stale results")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,11 +875,12 @@ func buildProbeRequest(
|
||||
AgentID: inst.agentType,
|
||||
Refresh: refresh,
|
||||
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||
Command: probeCommand.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
Env: agents.RuntimeEnvFor(ia),
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
Command: probeCommand.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
Env: agents.RuntimeEnvFor(ia),
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
OperatorDefined: cfg.OperatorDefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func (m *Manager) ExecuteProfilePrompt(ctx context.Context, profileID, prompt st
|
||||
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||
Command: command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir,
|
||||
Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: cliFlags, CommandPrefix: prefix,
|
||||
ProviderGatewayAuth: gatewayAuth,
|
||||
ProviderGatewayAuth: gatewayAuth, OperatorDefined: cfg.OperatorDefined,
|
||||
},
|
||||
}
|
||||
release, err := inst.acquireOperation(ctx, false)
|
||||
@@ -355,11 +355,12 @@ func (m *Manager) ExecutePromptWithMCP(
|
||||
Model: resolved,
|
||||
Mode: mode,
|
||||
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||
Command: command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
Env: agents.RuntimeEnvFor(ia),
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
Command: command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
Env: agents.RuntimeEnvFor(ia),
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
OperatorDefined: cfg.OperatorDefined,
|
||||
},
|
||||
MCPServers: mcpServers,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,20 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
||||
)
|
||||
|
||||
// CustomTUIAgentSpec is the user-provided definition of a custom TUI agent, as
|
||||
// CustomAgentProtocol selects how kandev drives a user-defined agent.
|
||||
type CustomAgentProtocol string
|
||||
|
||||
const (
|
||||
// CustomAgentProtocolTerminal runs the command as a raw CLI under a PTY.
|
||||
// It is the zero value, so every definition stored before this field
|
||||
// existed keeps its behaviour without a migration.
|
||||
CustomAgentProtocolTerminal CustomAgentProtocol = ""
|
||||
// CustomAgentProtocolACP drives the command over ACP on stdin/stdout,
|
||||
// giving the agent structured chat, tool calls, models, and modes.
|
||||
CustomAgentProtocolACP CustomAgentProtocol = "acp"
|
||||
)
|
||||
|
||||
// CustomTUIAgentSpec is the user-provided definition of a custom agent, as
|
||||
// stored on the agent row's tui_config and replayed into the registry on boot.
|
||||
type CustomTUIAgentSpec struct {
|
||||
Slug string
|
||||
@@ -18,8 +31,12 @@ type CustomTUIAgentSpec struct {
|
||||
Model string
|
||||
CommandArgs []string
|
||||
// MCPStrategyKey selects how kandev injects MCP servers into the wrapped
|
||||
// CLI (see mcpconfig.StrategyByKey). Empty means no injection.
|
||||
// CLI (see mcpconfig.StrategyByKey). Empty means no injection. It applies
|
||||
// to the terminal protocol only.
|
||||
MCPStrategyKey string
|
||||
// Protocol selects the runtime kandev drives the command with. Empty means
|
||||
// terminal passthrough.
|
||||
Protocol CustomAgentProtocol
|
||||
}
|
||||
|
||||
// ErrUnknownMCPStrategy is returned when a spec names a strategy key that no
|
||||
@@ -27,66 +44,99 @@ type CustomTUIAgentSpec struct {
|
||||
// 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
|
||||
// ErrUnknownCustomAgentProtocol is returned when a spec names a protocol that
|
||||
// does not resolve. Falling back to terminal would silently ignore the user's
|
||||
// choice and launch an ACP server into a PTY.
|
||||
var ErrUnknownCustomAgentProtocol = fmt.Errorf("unknown custom agent protocol")
|
||||
|
||||
// ErrMCPStrategyNotApplicable is returned when a spec pairs a passthrough MCP
|
||||
// strategy with a protocol that does not inject MCP through a config file. An
|
||||
// ACP agent receives resolved servers in session/new, so a strategy there is a
|
||||
// contradiction rather than a no-op.
|
||||
var ErrMCPStrategyNotApplicable = fmt.Errorf("MCP strategy does not apply to this protocol")
|
||||
|
||||
// resolveCustomCommand splits a spec's command string into binary + args,
|
||||
// substituting any {{model}} placeholder with the spec's model value.
|
||||
func resolveCustomCommand(spec CustomTUIAgentSpec) (string, []string, error) {
|
||||
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 nil, fmt.Errorf("command is empty")
|
||||
return "", nil, fmt.Errorf("command is empty")
|
||||
}
|
||||
binary := parts[0]
|
||||
args := parts[1:]
|
||||
if len(spec.CommandArgs) > 0 {
|
||||
args = append(args, spec.CommandArgs...)
|
||||
}
|
||||
|
||||
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: spec.Description,
|
||||
Display: spec.DisplayName,
|
||||
WaitForTerm: true,
|
||||
CommandArgs: args,
|
||||
MCPStrategy: strategy,
|
||||
}), nil
|
||||
return parts[0], args, nil
|
||||
}
|
||||
|
||||
// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and
|
||||
// buildCustomAgent turns a user-provided spec into the agent implementation
|
||||
// its protocol calls for.
|
||||
func buildCustomAgent(spec CustomTUIAgentSpec) (agents.Agent, error) {
|
||||
binary, args, err := resolveCustomCommand(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch spec.Protocol {
|
||||
case CustomAgentProtocolTerminal:
|
||||
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: spec.Description,
|
||||
Display: spec.DisplayName,
|
||||
WaitForTerm: true,
|
||||
CommandArgs: args,
|
||||
MCPStrategy: strategy,
|
||||
}), nil
|
||||
case CustomAgentProtocolACP:
|
||||
if spec.MCPStrategyKey != mcpconfig.StrategyKeyNone {
|
||||
return nil, fmt.Errorf("%w: %q", ErrMCPStrategyNotApplicable, spec.MCPStrategyKey)
|
||||
}
|
||||
return agents.NewCustomACPAgent(agents.CustomACPAgentConfig{
|
||||
AgentID: spec.Slug,
|
||||
AgentName: spec.Slug,
|
||||
Command: binary,
|
||||
Desc: spec.Description,
|
||||
Display: spec.DisplayName,
|
||||
CommandArgs: args,
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownCustomAgentProtocol, spec.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterCustomTUIAgent creates the agent the spec's protocol calls for and
|
||||
// registers it, failing if the ID is already taken.
|
||||
func (r *Registry) RegisterCustomTUIAgent(spec CustomTUIAgentSpec) error {
|
||||
tuiAgent, err := buildCustomTUIAgent(spec)
|
||||
customAgent, err := buildCustomAgent(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Register(tuiAgent)
|
||||
return r.Register(customAgent)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// ReplaceCustomTUIAgent rebuilds an already-registered custom agent in place.
|
||||
// The MCP strategy and the protocol are baked in at construction, so changing
|
||||
// either 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)
|
||||
customAgent, err := buildCustomAgent(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Replace(tuiAgent)
|
||||
return r.Replace(customAgent)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
func TestRegisterCustomTUIAgent_Success(t *testing.T) {
|
||||
@@ -123,3 +129,102 @@ func TestRegisterCustomTUIAgent_DuplicateID(t *testing.T) {
|
||||
t.Error("expected error for duplicate registration")
|
||||
}
|
||||
}
|
||||
|
||||
// A spec that names the ACP protocol must produce an agent kandev drives over
|
||||
// ACP, not a terminal passthrough one. The protocol is part of the stored
|
||||
// definition, so this is what a restart replays.
|
||||
func TestRegisterCustomTUIAgent_ACPProtocol(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
|
||||
Slug: "my-agent",
|
||||
DisplayName: "My Agent",
|
||||
Command: "my-agent --acp",
|
||||
Protocol: CustomAgentProtocolACP,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ag, ok := reg.Get("my-agent")
|
||||
if !ok {
|
||||
t.Fatal("expected agent to be registered")
|
||||
}
|
||||
if agents.IsPassthroughOnly(ag) {
|
||||
t.Error("ACP custom agent classified as passthrough-only")
|
||||
}
|
||||
if ag.Runtime().Protocol != agent.ProtocolACP {
|
||||
t.Errorf("Runtime().Protocol = %q, want %q", ag.Runtime().Protocol, agent.ProtocolACP)
|
||||
}
|
||||
want := []string{"my-agent", "--acp"}
|
||||
if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, want) {
|
||||
t.Errorf("Runtime().Cmd = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The empty protocol is what every definition stored before the field existed
|
||||
// decodes to, so it has to keep meaning terminal passthrough.
|
||||
func TestRegisterCustomTUIAgent_DefaultProtocolStaysTerminal(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
|
||||
Slug: "legacy-agent",
|
||||
DisplayName: "Legacy Agent",
|
||||
Command: "legacy-agent",
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ag, ok := reg.Get("legacy-agent")
|
||||
if !ok {
|
||||
t.Fatal("expected agent to be registered")
|
||||
}
|
||||
if !agents.IsPassthroughOnly(ag) {
|
||||
t.Error("a spec with no protocol is no longer terminal passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown protocol is a typo from an older client or a value removed by an
|
||||
// upgrade. Registering it as a terminal agent would silently ignore the user's
|
||||
// choice, so it has to fail loudly — same stance as an unknown MCP strategy.
|
||||
func TestRegisterCustomTUIAgent_UnknownProtocol(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
|
||||
Slug: "bad-agent",
|
||||
DisplayName: "Bad Agent",
|
||||
Command: "bad-agent",
|
||||
Protocol: "websocket",
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownCustomAgentProtocol) {
|
||||
t.Fatalf("error = %v, want ErrUnknownCustomAgentProtocol", err)
|
||||
}
|
||||
if reg.Exists("bad-agent") {
|
||||
t.Error("agent registered despite an unknown protocol")
|
||||
}
|
||||
}
|
||||
|
||||
// The ACP path must not silently drop the MCP strategy field: an ACP agent
|
||||
// gets kandev's tools through session/new, so a passthrough strategy is a
|
||||
// contradiction rather than a no-op.
|
||||
func TestRegisterCustomTUIAgent_ACPRejectsMCPStrategy(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
|
||||
Slug: "strategy-acp",
|
||||
DisplayName: "Strategy ACP",
|
||||
Command: "strategy-acp --acp",
|
||||
Protocol: CustomAgentProtocolACP,
|
||||
MCPStrategyKey: mcpconfig.StrategyKeyClaude,
|
||||
})
|
||||
if !errors.Is(err, ErrMCPStrategyNotApplicable) {
|
||||
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
|
||||
}
|
||||
if reg.Exists("strategy-acp") {
|
||||
t.Error("agent registered despite an inapplicable MCP strategy")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,11 @@ func (m *Manager) ExecuteInferencePrompt(ctx context.Context, sessionID, agentID
|
||||
AgentID: agentID,
|
||||
Model: model,
|
||||
InferenceConfig: &utility.InferenceConfigDTO{
|
||||
Command: cfg.Command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: execution.WorkspacePath,
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
Command: cfg.Command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: execution.WorkspacePath,
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
OperatorDefined: cfg.OperatorDefined,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -144,7 +145,7 @@ func (m *Manager) ExecuteInferenceProfilePrompt(ctx context.Context, sessionID,
|
||||
InferenceConfig: &utility.InferenceConfigDTO{
|
||||
Command: cfg.Command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: execution.WorkspacePath,
|
||||
Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: flags, CommandPrefix: prefix,
|
||||
ProviderGatewayAuth: gatewayAuth,
|
||||
ProviderGatewayAuth: gatewayAuth, OperatorDefined: cfg.OperatorDefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,7 +339,8 @@ func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if agent.TUIConfig != nil {
|
||||
custom := agent.TUIConfig != nil
|
||||
if custom {
|
||||
_ = c.agentRegistry.Unregister(agent.Name)
|
||||
}
|
||||
|
||||
@@ -349,5 +350,11 @@ func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if custom {
|
||||
// Installed Agents is rendered from the cached discovery sweep, which
|
||||
// reports the registry. Without this the deleted agent keeps its card
|
||||
// until the cache expires, and Rescan re-detects its binary.
|
||||
c.InvalidateDiscoveryCache()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ var (
|
||||
ErrRequireExactModelNeedsModel = errors.New("exact model requires a concrete model")
|
||||
ErrRequireExactModelUnsupported = errors.New("exact model is not supported for this profile")
|
||||
ErrUnknownMCPStrategy = errors.New("unknown MCP strategy")
|
||||
ErrUnknownCustomAgentProtocol = errors.New("unknown custom agent protocol")
|
||||
ErrMCPStrategyNotApplicable = errors.New("MCP strategy does not apply to this protocol")
|
||||
ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent")
|
||||
ErrDynamicAgentRoutingDisabled = errors.New("dynamic agent routing is disabled")
|
||||
ErrDynamicProfileCandidatesRequired = errors.New("dynamic profile candidates are required")
|
||||
@@ -312,26 +314,119 @@ func (c *Controller) SetJobBroadcaster(hub JobBroadcaster) {
|
||||
c.maintenance = newMaintenanceCoordinator()
|
||||
c.jobStore = NewJobStore(hub, c.logger.Zap(), func(agentName string) {
|
||||
c.InvalidateDiscoveryCache()
|
||||
// Kick a fresh capability probe immediately so the UI doesn't sit on
|
||||
// stale "not_installed" until the next periodic poll. When the probe
|
||||
// finishes, re-broadcast the updated availability so any open profile
|
||||
// page transitions out of "Probing…" without a manual refresh.
|
||||
if c.hostUtility != nil {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := c.hostUtility.Refresh(ctx, agentName); err != nil {
|
||||
c.logger.Debug("post-install capability refresh failed",
|
||||
zap.String("agent", agentName), zap.Error(err))
|
||||
}
|
||||
c.BroadcastAvailableAgents()
|
||||
}()
|
||||
}
|
||||
c.kickCapabilityProbe(agentName)
|
||||
c.logger.Info("install succeeded", zap.String("agent", agentName))
|
||||
}, c.maintenance)
|
||||
c.initializeUpdateJobStore()
|
||||
}
|
||||
|
||||
// kickCapabilityProbe refreshes one agent's capability cache off the request
|
||||
// path. The periodic poll would get there eventually, but until it does the
|
||||
// profile editor sits on a stale status — "not_installed" after an install, or
|
||||
// "not_configured" for an agent registered after boot, which is every custom
|
||||
// ACP agent. Re-broadcasting availability afterwards moves any open profile
|
||||
// page out of "Probing…" without a manual refresh.
|
||||
func (c *Controller) kickCapabilityProbe(agentName string) {
|
||||
c.probeAndAdoptModel(agentName, "")
|
||||
}
|
||||
|
||||
// probeAndAdoptModel refreshes an agent's capabilities off the request path and,
|
||||
// when profileID names a profile, copies the probed default into it if it still
|
||||
// has no model.
|
||||
//
|
||||
// ProfileReconciler already fills an empty model from the probe, but it runs
|
||||
// once during startup (see backendapp/main.go), so an agent registered
|
||||
// afterwards would keep an empty model until the next restart and its sessions
|
||||
// would silently take whatever the agent defaults to.
|
||||
func (c *Controller) probeAndAdoptModel(agentName, profileID string) {
|
||||
if c.hostUtility == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
caps, err := c.hostUtility.Refresh(ctx, agentName)
|
||||
if err != nil {
|
||||
c.logger.Debug("capability refresh failed",
|
||||
zap.String("agent", agentName), zap.Error(err))
|
||||
} else if profileID != "" {
|
||||
c.adoptProbedModel(ctx, profileID, caps)
|
||||
}
|
||||
c.BroadcastAvailableAgents()
|
||||
}()
|
||||
}
|
||||
|
||||
// adoptProbedModel fills an empty profile model from the probe. A model the
|
||||
// operator already chose is left alone: overwriting it is exactly the silent
|
||||
// fallback the reconciler refuses to make.
|
||||
func (c *Controller) adoptProbedModel(
|
||||
ctx context.Context,
|
||||
profileID string,
|
||||
caps hostutility.AgentCapabilities,
|
||||
) {
|
||||
if caps.CurrentModelID == "" {
|
||||
return
|
||||
}
|
||||
adopter, ok := c.repo.(store.AgentProfileModelAdopter)
|
||||
if !ok {
|
||||
c.logger.Debug("profile store cannot adopt a probed model",
|
||||
zap.String("profile_id", profileID))
|
||||
return
|
||||
}
|
||||
updated, err := adopter.UpdateAgentProfileModelIfEmpty(ctx, profileID, caps.CurrentModelID)
|
||||
if err != nil {
|
||||
c.logger.Debug("adopting the probed model failed",
|
||||
zap.String("profile_id", profileID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if updated {
|
||||
c.broadcastProfileUpdated(ctx, profileID)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastProfileUpdated tells settings clients that background model
|
||||
// adoption changed a profile. Custom ACP profiles are global today, but keep
|
||||
// workspace routing here so this path cannot leak a scoped profile if the
|
||||
// creation flow gains workspace ownership later.
|
||||
func (c *Controller) broadcastProfileUpdated(ctx context.Context, profileID string) {
|
||||
if c.hub == nil {
|
||||
return
|
||||
}
|
||||
profile, err := c.repo.GetAgentProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
if err != nil {
|
||||
c.logger.Debug("loading adopted profile for broadcast failed",
|
||||
zap.String("profile_id", profileID), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
profileDTO := toProfileDTO(profile)
|
||||
c.decorateProviderSupportByAgentID(ctx, &profileDTO)
|
||||
inferenceCapable := false
|
||||
if agent, agentErr := c.repo.GetAgent(ctx, profile.AgentID); agentErr == nil && agent != nil && c.agentRegistry != nil {
|
||||
_, inferenceCapable = c.agentRegistry.GetInferenceAgent(agent.Name)
|
||||
}
|
||||
notification, err := ws.NewNotification(ws.ActionAgentProfileUpdated, map[string]any{
|
||||
"profile": &profileDTO,
|
||||
"inference_capable": inferenceCapable,
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Debug("building adopted profile broadcast failed",
|
||||
zap.String("profile_id", profileID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if profile.WorkspaceID != "" {
|
||||
if workspaceHub, ok := c.hub.(interface {
|
||||
BroadcastToWorkspaceOrDrop(string, *ws.Message)
|
||||
}); ok {
|
||||
workspaceHub.BroadcastToWorkspaceOrDrop(profile.WorkspaceID, notification)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.hub.Broadcast(notification)
|
||||
}
|
||||
|
||||
func (c *Controller) initializeUpdateJobStore() {
|
||||
if c.hub == nil || c.runtimeUpdater == nil {
|
||||
c.updateJobStore = nil
|
||||
|
||||
@@ -24,7 +24,7 @@ func slugify(displayName string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateCustomTUIAgentRequest is the request to create a custom TUI agent.
|
||||
// CreateCustomTUIAgentRequest is the request to create a custom agent.
|
||||
type CreateCustomTUIAgentRequest struct {
|
||||
DisplayName string
|
||||
Model string
|
||||
@@ -32,11 +32,37 @@ type CreateCustomTUIAgentRequest struct {
|
||||
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.
|
||||
// the wrapped CLI. Empty means no MCP tools, which is the default. It
|
||||
// applies to the terminal protocol only.
|
||||
MCPStrategy string
|
||||
// Protocol is the runtime kandev drives the command with
|
||||
// (registry.CustomAgentProtocol*). Empty means terminal passthrough.
|
||||
Protocol string
|
||||
}
|
||||
|
||||
// CreateCustomTUIAgent registers a new custom TUI agent and persists it to the database.
|
||||
// validateCustomAgentProtocol rejects a protocol/strategy pair the registry
|
||||
// cannot build, so the HTTP layer answers 400 instead of surfacing a
|
||||
// registration failure as a server error.
|
||||
func validateCustomAgentProtocol(protocol registry.CustomAgentProtocol, mcpStrategy string) error {
|
||||
switch protocol {
|
||||
case registry.CustomAgentProtocolTerminal:
|
||||
if _, ok := mcpconfig.StrategyByKey(mcpStrategy); !ok {
|
||||
return ErrUnknownMCPStrategy
|
||||
}
|
||||
return nil
|
||||
case registry.CustomAgentProtocolACP:
|
||||
// An ACP agent receives resolved MCP servers in session/new, so a
|
||||
// passthrough config-file strategy has nothing to write into.
|
||||
if mcpStrategy != mcpconfig.StrategyKeyNone {
|
||||
return ErrMCPStrategyNotApplicable
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return ErrUnknownCustomAgentProtocol
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCustomTUIAgent registers a new custom agent and persists it to the database.
|
||||
func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomTUIAgentRequest) (*dto.AgentDTO, error) {
|
||||
slug := slugify(req.DisplayName)
|
||||
if slug == "" {
|
||||
@@ -45,8 +71,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
|
||||
protocol := registry.CustomAgentProtocol(req.Protocol)
|
||||
if err := validateCustomAgentProtocol(protocol, req.MCPStrategy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for conflict with existing registry entry
|
||||
@@ -72,11 +99,13 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
||||
Model: req.Model,
|
||||
CommandArgs: req.CommandArgs,
|
||||
MCPStrategyKey: req.MCPStrategy,
|
||||
Protocol: protocol,
|
||||
}); regErr != nil {
|
||||
return nil, fmt.Errorf("failed to register agent: %w", regErr)
|
||||
}
|
||||
|
||||
// Persist to DB
|
||||
acp := protocol == registry.CustomAgentProtocolACP
|
||||
tuiConfig := &models.TUIConfigJSON{
|
||||
Command: req.Command,
|
||||
DisplayName: req.DisplayName,
|
||||
@@ -85,12 +114,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
||||
CommandArgs: req.CommandArgs,
|
||||
WaitForTerminal: true,
|
||||
MCPStrategy: req.MCPStrategy,
|
||||
Protocol: req.Protocol,
|
||||
}
|
||||
agent := &models.Agent{
|
||||
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,
|
||||
// Mirrors what the registered agent's IsInstalled derives, so the
|
||||
// profile MCP editor works immediately instead of only after the next
|
||||
// discovery sweep. An ACP agent always supports MCP: resolved servers
|
||||
// travel in session/new rather than through a config-file strategy.
|
||||
SupportsMCP: acp || req.MCPStrategy != mcpconfig.StrategyKeyNone,
|
||||
TUIConfig: tuiConfig,
|
||||
}
|
||||
if err := c.repo.CreateAgent(ctx, agent); err != nil {
|
||||
@@ -99,7 +131,9 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create default passthrough profile — use model as profile name for distinct dropdown labels
|
||||
// Seed the default profile in the mode the protocol actually runs in. An
|
||||
// ACP profile carries no model: the host-utility capability probe learns
|
||||
// the agent's models and the reconciler fills one in.
|
||||
profileName := req.Model
|
||||
if profileName == "" {
|
||||
profileName = req.DisplayName
|
||||
@@ -111,10 +145,27 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
||||
Model: "passthrough",
|
||||
CLIPassthrough: true,
|
||||
}
|
||||
if acp {
|
||||
// The probe supplies a default when the operator named no model; it
|
||||
// must not be the literal "passthrough" a terminal profile carries.
|
||||
profile.Model = req.Model
|
||||
profile.CLIPassthrough = false
|
||||
}
|
||||
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A discovery sweep reports whatever the agent registry holds, and its
|
||||
// results are cached, so a membership change has to drop that cache or the
|
||||
// new agent is absent from Installed Agents until the TTL expires.
|
||||
c.InvalidateDiscoveryCache()
|
||||
if acp {
|
||||
// An ACP agent's models and modes come from the capability probe, and
|
||||
// the boot sweep is long past. A terminal agent has no ACP server to
|
||||
// probe, so kicking one there would spawn the user's CLI for nothing.
|
||||
c.probeAndAdoptModel(slug, profile.ID)
|
||||
}
|
||||
|
||||
profiles := []*models.AgentProfile{profile}
|
||||
result := c.toAgentDTO(agent, profiles)
|
||||
return &result, nil
|
||||
@@ -152,6 +203,10 @@ func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID,
|
||||
// carry a user-selected one.
|
||||
return nil, ErrNotCustomTUIAgent
|
||||
}
|
||||
protocol := registry.CustomAgentProtocol(agent.TUIConfig.Protocol)
|
||||
if err := validateCustomAgentProtocol(protocol, strategyKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agent.TUIConfig.MCPStrategy == strategyKey {
|
||||
return c.customTUIAgentDTO(ctx, agent)
|
||||
}
|
||||
@@ -173,6 +228,10 @@ func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID,
|
||||
}
|
||||
return nil, fmt.Errorf("failed to re-register agent: %w", err)
|
||||
}
|
||||
// The replacement instance reports a different SupportsMCP, and the sweep
|
||||
// writes that flag back over the agent row: a cached sweep would revert the
|
||||
// strategy change that just succeeded.
|
||||
c.InvalidateDiscoveryCache()
|
||||
|
||||
return c.customTUIAgentDTO(ctx, agent)
|
||||
}
|
||||
@@ -183,16 +242,24 @@ func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID,
|
||||
// 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,
|
||||
return c.agentRegistry.ReplaceCustomTUIAgent(CustomAgentSpecFromStored(agent.Name, agent.TUIConfig))
|
||||
}
|
||||
|
||||
// CustomAgentSpecFromStored builds the registry spec a stored custom-agent
|
||||
// definition replays into. Strategy changes and the boot replay both go
|
||||
// through it, so a field added to the stored config cannot reach one path and
|
||||
// silently miss the other.
|
||||
func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec {
|
||||
return registry.CustomTUIAgentSpec{
|
||||
Slug: name,
|
||||
DisplayName: cfg.DisplayName,
|
||||
Command: cfg.Command,
|
||||
Description: cfg.Description,
|
||||
Model: cfg.Model,
|
||||
CommandArgs: cfg.CommandArgs,
|
||||
MCPStrategyKey: cfg.MCPStrategy,
|
||||
})
|
||||
Protocol: registry.CustomAgentProtocol(cfg.Protocol),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) (*dto.AgentDTO, error) {
|
||||
|
||||
@@ -5,10 +5,14 @@ import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/discovery"
|
||||
"github.com/kandev/kandev/internal/agent/hostutility"
|
||||
"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"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
@@ -300,3 +304,525 @@ func TestSetCustomTUIAgentMCPStrategy_RejectsUnknownStrategy(t *testing.T) {
|
||||
t.Errorf("stored strategy = %q, want unchanged", stored.TUIConfig.MCPStrategy)
|
||||
}
|
||||
}
|
||||
|
||||
func newCustomTUIControllerWithDiscovery(t *testing.T, st *fakeStore) *Controller {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
agentRegistry := registry.NewRegistry(log)
|
||||
discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log)
|
||||
if err != nil {
|
||||
t.Fatalf("load discovery registry: %v", err)
|
||||
}
|
||||
return &Controller{
|
||||
agentRegistry: agentRegistry,
|
||||
discovery: discoveryRegistry,
|
||||
repo: st,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func discoveryLists(t *testing.T, c *Controller, name string) bool {
|
||||
t.Helper()
|
||||
resp, err := c.ListDiscovery(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListDiscovery: %v", err)
|
||||
}
|
||||
return slices.ContainsFunc(resp.Agents, func(a dto.AgentDiscoveryDTO) bool {
|
||||
return a.Name == name
|
||||
})
|
||||
}
|
||||
|
||||
// The Installed Agents list is rendered from the discovery sweep, so a custom
|
||||
// agent has to enter it on creation and leave it on deletion without a restart.
|
||||
// Both directions were broken while discovery kept its own copy of the agent
|
||||
// list: a deleted agent stayed listed (Rescan re-detected its binary from the
|
||||
// stale list) and a new one stayed missing.
|
||||
func TestCustomTUIAgentEntersAndLeavesDiscovery(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIControllerWithDiscovery(t, st)
|
||||
ctx := context.Background()
|
||||
|
||||
if discoveryLists(t, c, "ghost-cli") {
|
||||
t.Fatal("ghost-cli reported by discovery before it was created")
|
||||
}
|
||||
|
||||
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Ghost CLI",
|
||||
Command: "ghost-cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
if !discoveryLists(t, c, "ghost-cli") {
|
||||
t.Error("ghost-cli missing from discovery after it was created")
|
||||
}
|
||||
|
||||
if err := c.DeleteAgent(ctx, created.ID); err != nil {
|
||||
t.Fatalf("DeleteAgent: %v", err)
|
||||
}
|
||||
if discoveryLists(t, c, "ghost-cli") {
|
||||
t.Error("ghost-cli still reported by discovery after it was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery writes SupportsMCP back over the agent row on every sweep, so a
|
||||
// strategy change that discovery cannot see is reverted by the next sweep.
|
||||
func TestCustomTUIAgentMCPStrategyChangeReachesDiscovery(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIControllerWithDiscovery(t, st)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Strategy CLI",
|
||||
Command: "strategy-cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
if discoverySupportsMCP(t, c, "strategy-cli") {
|
||||
t.Fatal("SupportsMCP = true before a strategy was selected")
|
||||
}
|
||||
|
||||
if _, err := c.SetCustomTUIAgentMCPStrategy(ctx, created.ID, mcpconfig.StrategyKeyClaude); err != nil {
|
||||
t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
|
||||
}
|
||||
if !discoverySupportsMCP(t, c, "strategy-cli") {
|
||||
t.Error("SupportsMCP = false after selecting an MCP strategy")
|
||||
}
|
||||
}
|
||||
|
||||
func discoverySupportsMCP(t *testing.T, c *Controller, name string) bool {
|
||||
t.Helper()
|
||||
resp, err := c.ListDiscovery(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListDiscovery: %v", err)
|
||||
}
|
||||
for _, a := range resp.Agents {
|
||||
if a.Name == name {
|
||||
return a.SupportsMCP
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s missing from discovery", name)
|
||||
return false
|
||||
}
|
||||
|
||||
// A custom agent created as ACP must be seeded as a structured profile. A
|
||||
// passthrough profile launches the command under a PTY, which is what the
|
||||
// terminal protocol is for — with an ACP command that shows JSON-RPC frames.
|
||||
func TestCreateCustomACPAgent_SeedsStructuredProfile(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
|
||||
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "My Agent",
|
||||
Command: "my-agent --acp",
|
||||
Protocol: string(registry.CustomAgentProtocolACP),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
ag, ok := c.agentRegistry.Get("my-agent")
|
||||
if !ok {
|
||||
t.Fatal("agent not registered")
|
||||
}
|
||||
if agents.IsPassthroughOnly(ag) {
|
||||
t.Error("ACP custom agent registered as passthrough-only")
|
||||
}
|
||||
|
||||
stored, ok := st.byName["my-agent"]
|
||||
if !ok || stored.TUIConfig == nil {
|
||||
t.Fatal("agent not persisted with a TUI config")
|
||||
}
|
||||
if stored.TUIConfig.Protocol != string(registry.CustomAgentProtocolACP) {
|
||||
t.Errorf("stored protocol = %q, want %q", stored.TUIConfig.Protocol, registry.CustomAgentProtocolACP)
|
||||
}
|
||||
if !stored.SupportsMCP {
|
||||
t.Error("stored SupportsMCP = false; ACP agents receive servers in session/new")
|
||||
}
|
||||
|
||||
profiles := st.profiles[created.ID]
|
||||
if len(profiles) != 1 {
|
||||
t.Fatalf("seeded %d profiles, want 1", len(profiles))
|
||||
}
|
||||
if profiles[0].CLIPassthrough {
|
||||
t.Error("seeded profile is CLI passthrough")
|
||||
}
|
||||
if profiles[0].Model == "passthrough" {
|
||||
t.Error(`seeded profile model is "passthrough"; the capability probe fills it`)
|
||||
}
|
||||
}
|
||||
|
||||
// The terminal protocol is the default, and its seeding must not drift.
|
||||
func TestCreateCustomTUIAgent_DefaultsToTerminalProfile(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
|
||||
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Terminal CLI",
|
||||
Command: "terminal-cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
stored, ok := st.byName["terminal-cli"]
|
||||
if !ok || stored.TUIConfig == nil {
|
||||
t.Fatal("agent not persisted with a TUI config")
|
||||
}
|
||||
if stored.TUIConfig.Protocol != "" {
|
||||
t.Errorf("stored protocol = %q, want empty", stored.TUIConfig.Protocol)
|
||||
}
|
||||
|
||||
profiles := st.profiles[created.ID]
|
||||
if len(profiles) != 1 {
|
||||
t.Fatalf("seeded %d profiles, want 1", len(profiles))
|
||||
}
|
||||
if !profiles[0].CLIPassthrough {
|
||||
t.Error("seeded profile is not CLI passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCustomTUIAgent_RejectsUnknownProtocol(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
|
||||
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Bad Protocol",
|
||||
Command: "bad-protocol",
|
||||
Protocol: "websocket",
|
||||
})
|
||||
if !errors.Is(err, ErrUnknownCustomAgentProtocol) {
|
||||
t.Fatalf("error = %v, want ErrUnknownCustomAgentProtocol", err)
|
||||
}
|
||||
if c.agentRegistry.Exists("bad-protocol") {
|
||||
t.Error("agent registered despite an unknown protocol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCustomACPAgent_RejectsMCPStrategy(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
|
||||
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "ACP With Strategy",
|
||||
Command: "acp-with-strategy --acp",
|
||||
Protocol: string(registry.CustomAgentProtocolACP),
|
||||
MCPStrategy: mcpconfig.StrategyKeyClaude,
|
||||
})
|
||||
if !errors.Is(err, ErrMCPStrategyNotApplicable) {
|
||||
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
|
||||
}
|
||||
if c.agentRegistry.Exists("acp-with-strategy") {
|
||||
t.Error("agent registered despite an inapplicable MCP strategy")
|
||||
}
|
||||
}
|
||||
|
||||
// A stored ACP definition has to replay as an ACP agent after a restart;
|
||||
// creation, strategy changes, and the boot replay all build their spec here.
|
||||
func TestCustomAgentSpecFromStored_CarriesProtocol(t *testing.T) {
|
||||
spec := CustomAgentSpecFromStored("my-agent", &models.TUIConfigJSON{
|
||||
Command: "my-agent --acp",
|
||||
DisplayName: "My Agent",
|
||||
Protocol: string(registry.CustomAgentProtocolACP),
|
||||
})
|
||||
|
||||
if spec.Protocol != registry.CustomAgentProtocolACP {
|
||||
t.Errorf("spec.Protocol = %q, want %q", spec.Protocol, registry.CustomAgentProtocolACP)
|
||||
}
|
||||
if spec.Slug != "my-agent" {
|
||||
t.Errorf("spec.Slug = %q, want %q", spec.Slug, "my-agent")
|
||||
}
|
||||
}
|
||||
|
||||
type probeRecordingHostUtility struct {
|
||||
refreshed chan string
|
||||
caps hostutility.AgentCapabilities
|
||||
}
|
||||
|
||||
func (h *probeRecordingHostUtility) Get(string) (hostutility.AgentCapabilities, bool) {
|
||||
return hostutility.AgentCapabilities{}, false
|
||||
}
|
||||
|
||||
func (h *probeRecordingHostUtility) Refresh(
|
||||
_ context.Context,
|
||||
agentType string,
|
||||
) (hostutility.AgentCapabilities, error) {
|
||||
select {
|
||||
case h.refreshed <- agentType:
|
||||
default:
|
||||
}
|
||||
return h.caps, nil
|
||||
}
|
||||
|
||||
func (h *probeRecordingHostUtility) ResolveModelConfig(
|
||||
context.Context,
|
||||
string,
|
||||
hostutility.ModelConfigResolutionRequest,
|
||||
) (hostutility.ModelConfigResolution, error) {
|
||||
return hostutility.ModelConfigResolution{}, nil
|
||||
}
|
||||
|
||||
// An ACP agent's models and modes come from the capability probe, and the
|
||||
// probe only runs at boot. Without one kicked at creation, the profile editor
|
||||
// reports "not_configured" until the user finds the manual refresh.
|
||||
func TestCreateCustomACPAgent_ProbesCapabilities(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
probe := &probeRecordingHostUtility{refreshed: make(chan string, 1)}
|
||||
c.hostUtility = probe
|
||||
|
||||
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Probed Acp",
|
||||
Command: "probed-acp --acp",
|
||||
Protocol: string(registry.CustomAgentProtocolACP),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case agentType := <-probe.refreshed:
|
||||
if agentType != "probed-acp" {
|
||||
t.Errorf("probed %q, want %q", agentType, "probed-acp")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no capability probe was kicked for a new ACP agent")
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal agent is never probed, so kicking one would spawn the user's CLI
|
||||
// for nothing and park a failed capability status on the agent.
|
||||
func TestCreateCustomTUIAgent_DoesNotProbeCapabilities(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
probe := &probeRecordingHostUtility{refreshed: make(chan string, 1)}
|
||||
c.hostUtility = probe
|
||||
|
||||
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Unprobed Terminal",
|
||||
Command: "unprobed-terminal",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case agentType := <-probe.refreshed:
|
||||
t.Errorf("probed %q; terminal agents have no ACP server to probe", agentType)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// The MCP-strategy route is the one place a stored definition's protocol can be
|
||||
// paired with a new strategy. An ACP agent has no passthrough config file to
|
||||
// write, so the pair has to be refused before the row is touched rather than
|
||||
// written and rolled back.
|
||||
func TestSetCustomTUIAgentMCPStrategy_RejectedForACPAgent(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
c := newCustomTUIController(t, st)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Strategy Acp",
|
||||
Command: "strategy-acp --acp",
|
||||
Protocol: string(registry.CustomAgentProtocolACP),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
if _, err := c.SetCustomTUIAgentMCPStrategy(ctx, created.ID, mcpconfig.StrategyKeyClaude); !errors.Is(err, ErrMCPStrategyNotApplicable) {
|
||||
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
|
||||
}
|
||||
|
||||
stored, ok := st.byName["strategy-acp"]
|
||||
if !ok || stored.TUIConfig == nil {
|
||||
t.Fatal("agent not persisted with a TUI config")
|
||||
}
|
||||
if stored.TUIConfig.MCPStrategy != "" {
|
||||
t.Errorf("stored strategy = %q, want empty", stored.TUIConfig.MCPStrategy)
|
||||
}
|
||||
if !stored.SupportsMCP {
|
||||
t.Error("SupportsMCP flipped off by a rejected strategy write")
|
||||
}
|
||||
}
|
||||
|
||||
// profileUpdateSignalStore publishes each profile model write. The probe runs
|
||||
// on its own goroutine, and fakeStore is not safe to read while it writes, so
|
||||
// tests synchronise on the write instead of polling the store.
|
||||
type profileUpdateSignalStore struct {
|
||||
*fakeStore
|
||||
models chan string
|
||||
}
|
||||
|
||||
// profileAdoptionRaceStore simulates a user changing the profile after a
|
||||
// background probe read it but before the probe can write. The conditional
|
||||
// model update sees that edit and must leave it untouched. The old full-row
|
||||
// read/modify/write path overwrote it.
|
||||
type profileAdoptionRaceStore struct {
|
||||
*fakeStore
|
||||
userEditInjected bool
|
||||
fullRowWrites int
|
||||
}
|
||||
|
||||
func (s *profileAdoptionRaceStore) GetAgentProfile(
|
||||
ctx context.Context,
|
||||
profileID string,
|
||||
) (*models.AgentProfile, error) {
|
||||
profile, err := s.fakeStore.GetAgentProfile(ctx, profileID)
|
||||
if err != nil || profile == nil || s.userEditInjected {
|
||||
return profile, err
|
||||
}
|
||||
s.userEditInjected = true
|
||||
userEdit := copyProfile(profile)
|
||||
userEdit.Model = "operator-choice"
|
||||
if err := s.fakeStore.UpdateAgentProfile(ctx, userEdit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *profileAdoptionRaceStore) UpdateAgentProfile(
|
||||
ctx context.Context,
|
||||
profile *models.AgentProfile,
|
||||
) error {
|
||||
s.fullRowWrites++
|
||||
return s.fakeStore.UpdateAgentProfile(ctx, profile)
|
||||
}
|
||||
|
||||
func (s *profileAdoptionRaceStore) UpdateAgentProfileModelIfEmpty(
|
||||
ctx context.Context,
|
||||
profileID, model string,
|
||||
) (bool, error) {
|
||||
if !s.userEditInjected {
|
||||
s.userEditInjected = true
|
||||
current, err := s.fakeStore.GetAgentProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
current.Model = "operator-choice"
|
||||
if err := s.fakeStore.UpdateAgentProfile(ctx, current); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
current, err := s.fakeStore.GetAgentProfile(ctx, profileID)
|
||||
if err != nil || current == nil || current.Model != "" {
|
||||
return false, err
|
||||
}
|
||||
current.Model = model
|
||||
if err := s.fakeStore.UpdateAgentProfile(ctx, current); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *profileUpdateSignalStore) UpdateAgentProfile(ctx context.Context, p *models.AgentProfile) error {
|
||||
err := s.fakeStore.UpdateAgentProfile(ctx, p)
|
||||
select {
|
||||
case s.models <- p.Model:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *profileUpdateSignalStore) UpdateAgentProfileModelIfEmpty(
|
||||
ctx context.Context,
|
||||
profileID, model string,
|
||||
) (bool, error) {
|
||||
profile, err := s.GetAgentProfile(ctx, profileID)
|
||||
if err != nil || profile == nil || profile.Model != "" {
|
||||
return false, err
|
||||
}
|
||||
profile.Model = model
|
||||
if err := s.UpdateAgentProfile(ctx, profile); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func newProbedController(
|
||||
t *testing.T,
|
||||
caps hostutility.AgentCapabilities,
|
||||
) (*Controller, *profileUpdateSignalStore) {
|
||||
t.Helper()
|
||||
st := &profileUpdateSignalStore{fakeStore: newFakeStore(), models: make(chan string, 4)}
|
||||
c := newCustomTUIController(t, st.fakeStore)
|
||||
c.repo = st
|
||||
c.hostUtility = &probeRecordingHostUtility{refreshed: make(chan string, 1), caps: caps}
|
||||
return c, st
|
||||
}
|
||||
|
||||
// The seeded ACP profile carries no model on purpose: the probe is what learns
|
||||
// one. ProfileReconciler copies a probed default into an empty profile, but it
|
||||
// runs once during startup, so an agent registered afterwards would keep an
|
||||
// empty model until the next restart and its sessions would silently take
|
||||
// whatever the agent defaults to.
|
||||
func TestCreateCustomACPAgent_AdoptsProbedModel(t *testing.T) {
|
||||
c, st := newProbedController(t, hostutility.AgentCapabilities{CurrentModelID: "probed-model"})
|
||||
|
||||
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Adopting Acp",
|
||||
Command: "adopting-acp --acp",
|
||||
Protocol: string(registry.CustomAgentProtocolACP),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case model := <-st.models:
|
||||
if model != "probed-model" {
|
||||
t.Errorf("wrote model %q, want the probed default", model)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the probed model was never written to the seeded profile")
|
||||
}
|
||||
}
|
||||
|
||||
// A model the operator already chose is theirs. Overwriting it from the probe
|
||||
// is exactly the silent fallback the reconciler refuses to make.
|
||||
func TestCreateCustomACPAgent_KeepsAnOperatorModel(t *testing.T) {
|
||||
c, st := newProbedController(t, hostutility.AgentCapabilities{CurrentModelID: "probed-model"})
|
||||
|
||||
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
|
||||
DisplayName: "Chosen Acp",
|
||||
Command: "chosen-acp --acp",
|
||||
Protocol: string(registry.CustomAgentProtocolACP),
|
||||
Model: "operator-choice",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCustomTUIAgent: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case model := <-st.models:
|
||||
t.Errorf("overwrote the operator's model with %q", model)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptProbedModelDoesNotOverwriteConcurrentProfileEdit(t *testing.T) {
|
||||
base := newFakeStore()
|
||||
profile := &models.AgentProfile{AgentID: "agent-1", Name: "Default"}
|
||||
if err := base.CreateAgentProfile(context.Background(), profile); err != nil {
|
||||
t.Fatalf("create profile: %v", err)
|
||||
}
|
||||
st := &profileAdoptionRaceStore{fakeStore: base}
|
||||
c := newCustomTUIController(t, base)
|
||||
c.repo = st
|
||||
|
||||
c.adoptProbedModel(context.Background(), profile.ID, hostutility.AgentCapabilities{
|
||||
CurrentModelID: "probed-model",
|
||||
})
|
||||
|
||||
got, err := base.GetAgentProfile(context.Background(), profile.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get profile: %v", err)
|
||||
}
|
||||
if got.Model != "operator-choice" {
|
||||
t.Fatalf("model = %q, want concurrent operator edit to win", got.Model)
|
||||
}
|
||||
if st.fullRowWrites != 0 {
|
||||
t.Fatalf("full-row writes = %d, want no full-row adoption write", st.fullRowWrites)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1225,6 +1225,7 @@ func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentPro
|
||||
CommandArgs: agent.TUIConfig.CommandArgs,
|
||||
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
|
||||
MCPStrategy: agent.TUIConfig.MCPStrategy,
|
||||
Protocol: agent.TUIConfig.Protocol,
|
||||
}
|
||||
}
|
||||
if c.agentRegistry != nil {
|
||||
|
||||
@@ -293,6 +293,21 @@ func (f *fakeStore) UpdateAgentProfile(_ context.Context, p *models.AgentProfile
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateAgentProfileModelIfEmpty(
|
||||
ctx context.Context,
|
||||
profileID, model string,
|
||||
) (bool, error) {
|
||||
profile, err := f.GetAgentProfile(ctx, profileID)
|
||||
if err != nil || profile == nil || profile.Model != "" {
|
||||
return false, err
|
||||
}
|
||||
profile.Model = model
|
||||
if err := f.UpdateAgentProfile(ctx, profile); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateAgentProfileEnabled(ctx context.Context, id string, enabled bool) (time.Time, error) {
|
||||
profile, err := f.GetAgentProfile(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -143,6 +143,9 @@ type TUIConfigDTO struct {
|
||||
WaitForTerminal bool `json:"wait_for_terminal"`
|
||||
// MCPStrategy is the selected MCP injection mechanism ("" = none).
|
||||
MCPStrategy string `json:"mcp_strategy,omitempty"`
|
||||
// Protocol is the runtime kandev drives the command with ("" = terminal
|
||||
// passthrough, "acp" = ACP on stdin/stdout).
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
type AgentDTO struct {
|
||||
|
||||
@@ -23,6 +23,9 @@ type createCustomTUIAgentRequest struct {
|
||||
// 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"`
|
||||
// Protocol is the runtime kandev drives the command with: empty (the
|
||||
// default) for terminal passthrough, "acp" for ACP on stdin/stdout.
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
|
||||
@@ -33,6 +36,7 @@ func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCust
|
||||
Description: r.Description,
|
||||
CommandArgs: r.CommandArgs,
|
||||
MCPStrategy: r.MCPStrategy,
|
||||
Protocol: r.Protocol,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +58,8 @@ 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, controller.ErrUnknownMCPStrategy:
|
||||
case controller.ErrInvalidSlug, controller.ErrCommandRequired, controller.ErrUnknownMCPStrategy,
|
||||
controller.ErrUnknownCustomAgentProtocol, controller.ErrMCPStrategyNotApplicable:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case controller.ErrAgentAlreadyExists:
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
@@ -114,7 +119,8 @@ func (h *Handlers) httpUpdateCustomTUIAgentMCP(c *gin.Context) {
|
||||
resp, err := h.controller.SetCustomTUIAgentMCPStrategy(c.Request.Context(), c.Param("id"), body.MCPStrategy)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent:
|
||||
case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent,
|
||||
controller.ErrMCPStrategyNotApplicable, controller.ErrUnknownCustomAgentProtocol:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case controller.ErrAgentNotFound:
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -46,6 +46,10 @@ type TUIConfigJSON struct {
|
||||
// user's CLI definition, and because tui_config is JSON: adding the field
|
||||
// needs no migration.
|
||||
MCPStrategy string `json:"mcp_strategy,omitempty"`
|
||||
// Protocol selects the runtime kandev drives the command with
|
||||
// (registry.CustomAgentProtocol*). Empty — the value every row written
|
||||
// before this field existed decodes to — means terminal passthrough.
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
type AgentProfile struct {
|
||||
|
||||
@@ -1263,6 +1263,29 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
|
||||
return r.updateAgentProfile(ctx, r.db, profile)
|
||||
}
|
||||
|
||||
// UpdateAgentProfileModelIfEmpty adopts a probed model without replacing any
|
||||
// other profile fields. The model predicate makes the read/check/write one
|
||||
// atomic operation, so a concurrent profile edit wins over the background
|
||||
// probe instead of being overwritten by a full-row update.
|
||||
func (r *sqliteRepository) UpdateAgentProfileModelIfEmpty(
|
||||
ctx context.Context,
|
||||
profileID, model string,
|
||||
) (bool, error) {
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE agent_profiles
|
||||
SET model = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL AND model = ''
|
||||
`), model, time.Now().UTC(), profileID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rows == 1, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profileExecer, profile *models.AgentProfile) error {
|
||||
profile.UpdatedAt = time.Now().UTC()
|
||||
cliFlagsJSON, err := cliFlagsToJSON(profile.CLIFlags)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/settings/models"
|
||||
)
|
||||
|
||||
func TestUpdateAgentProfileModelIfEmptyIsConditional(t *testing.T) {
|
||||
repo := newFreshRepo(t)
|
||||
ctx := context.Background()
|
||||
agent := &models.Agent{Name: "custom-acp"}
|
||||
if err := repo.CreateAgent(ctx, agent); err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
profile := &models.AgentProfile{
|
||||
AgentID: agent.ID,
|
||||
Name: "default",
|
||||
AgentDisplayName: "Custom ACP",
|
||||
}
|
||||
if err := repo.CreateAgentProfile(ctx, profile); err != nil {
|
||||
t.Fatalf("create profile: %v", err)
|
||||
}
|
||||
|
||||
updated, err := repo.UpdateAgentProfileModelIfEmpty(ctx, profile.ID, "probed-model")
|
||||
if err != nil {
|
||||
t.Fatalf("adopt model: %v", err)
|
||||
}
|
||||
if !updated {
|
||||
t.Fatal("first model adoption returned updated=false")
|
||||
}
|
||||
got, err := repo.GetAgentProfile(ctx, profile.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get adopted profile: %v", err)
|
||||
}
|
||||
if got.Model != "probed-model" {
|
||||
t.Fatalf("model = %q, want probed-model", got.Model)
|
||||
}
|
||||
|
||||
updated, err = repo.UpdateAgentProfileModelIfEmpty(ctx, profile.ID, "other-model")
|
||||
if err != nil {
|
||||
t.Fatalf("second model adoption: %v", err)
|
||||
}
|
||||
if updated {
|
||||
t.Fatal("second model adoption returned updated=true for a configured model")
|
||||
}
|
||||
got, err = repo.GetAgentProfile(ctx, profile.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get preserved profile: %v", err)
|
||||
}
|
||||
if got.Model != "probed-model" {
|
||||
t.Fatalf("model = %q after second adoption, want probed-model", got.Model)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,13 @@ type AgentProfileMcpConfigPatcher interface {
|
||||
) (*models.AgentProfileMcpConfig, error)
|
||||
}
|
||||
|
||||
// AgentProfileModelAdopter fills an unset profile model without replacing the
|
||||
// rest of the profile row. The boolean is false when the profile already has a
|
||||
// model or was deleted before the background probe completed.
|
||||
type AgentProfileModelAdopter interface {
|
||||
UpdateAgentProfileModelIfEmpty(ctx context.Context, profileID, model string) (bool, error)
|
||||
}
|
||||
|
||||
// DynamicProfileRepository is the optional extension implemented by settings
|
||||
// stores that persist the dynamic profile document. Keeping it separate from
|
||||
// Repository lets small controller fakes and plugin adapters retain the
|
||||
|
||||
@@ -68,9 +68,9 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
|
||||
return &PromptResponse{Success: false, Error: "work_dir is required for ACP inference"}, nil
|
||||
}
|
||||
model, modelConfigOptions, _ := acpcompat.MigrateCursorModel(req.AgentID, req.Model, nil)
|
||||
resolvedCmd := resolveProbeCommand(cfg.Command[0])
|
||||
if resolvedCmd == "" {
|
||||
return &PromptResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP command", cfg.Command[0])}, nil
|
||||
resolvedCmd, cmdErr := resolveSpawnCommand(cfg)
|
||||
if cmdErr != "" {
|
||||
return &PromptResponse{Success: false, Error: cmdErr}, nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
@@ -83,9 +83,6 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
|
||||
zap.String("model", model),
|
||||
zap.Strings("command", args))
|
||||
|
||||
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
|
||||
// the executable name is not derived from tainted input.
|
||||
//nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags
|
||||
cmdArgs := args[1:]
|
||||
if len(cfg.CommandPrefix) > 0 {
|
||||
args = append(append([]string{}, cfg.CommandPrefix...), args...)
|
||||
@@ -96,8 +93,10 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
|
||||
cmdArgs = args[1:]
|
||||
}
|
||||
cmdArgs = append(cmdArgs, cfg.CLIFlags...)
|
||||
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
|
||||
// the executable name is not derived from tainted input.
|
||||
// Use resolvedCmd (not args[0]) so the executable name the taint tracker
|
||||
// sees is an allow-list literal, a validated command prefix, or the
|
||||
// operator-registered command resolveSpawnCommand documents.
|
||||
//nolint:gosec // resolvedCmd is an allow-list literal, a validated prefix, or an operator-registered command
|
||||
cmd := exec.CommandContext(ctx, resolvedCmd, cmdArgs...)
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
|
||||
@@ -476,9 +475,9 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
|
||||
if workDir == "" {
|
||||
return &ProbeResponse{Success: false, Error: "work_dir is required for ACP probe"}, nil
|
||||
}
|
||||
resolvedCmd := resolveProbeCommand(cfg.Command[0])
|
||||
if resolvedCmd == "" {
|
||||
return &ProbeResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP probe command", cfg.Command[0])}, nil
|
||||
resolvedCmd, cmdErr := resolveSpawnCommand(cfg)
|
||||
if cmdErr != "" {
|
||||
return &ProbeResponse{Success: false, Error: cmdErr}, nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
@@ -496,9 +495,10 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
|
||||
zap.String("agent_id", req.AgentID),
|
||||
zap.Strings("command", args))
|
||||
|
||||
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
|
||||
// the executable name is not derived from tainted input.
|
||||
//nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags
|
||||
// Use resolvedCmd (not args[0]) so the allow-list literal is what reaches
|
||||
// exec.Command for every built-in agent, and the one exception is the
|
||||
// operator-defined command resolveSpawnCommand documents.
|
||||
//nolint:gosec // resolvedCmd is an allow-list literal or an operator-registered command
|
||||
cmd := exec.CommandContext(ctx, resolvedCmd, args[1:]...)
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
|
||||
@@ -1269,6 +1269,32 @@ func resolveProbeCommand(name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveSpawnCommand returns the executable a probe or inference subprocess
|
||||
// should spawn, or a non-empty error message when the command is not permitted.
|
||||
//
|
||||
// A built-in agent's command is compiled in, so it resolves to the allow-list
|
||||
// literal and the taint tracker can follow it to exec.Command unchanged.
|
||||
//
|
||||
// A custom agent's command cannot: it does not exist until the install
|
||||
// operator types it in Settings, so no literal can cover it. Refusing it does
|
||||
// not keep that command from running — the session path spawns the same string
|
||||
// verbatim (process/manager.go, interactive_lifecycle.go), and agentctl's own
|
||||
// piped runner accepts an arbitrary command from its request. It only denies
|
||||
// the agent the capability probe, which is where its models and modes come
|
||||
// from. The operator who typed the command is the operator who could run it
|
||||
// directly on the host.
|
||||
func resolveSpawnCommand(cfg *InferenceConfigDTO) (string, string) {
|
||||
command := cfg.Command[0]
|
||||
if cfg.OperatorDefined {
|
||||
return command, ""
|
||||
}
|
||||
resolved := resolveProbeCommand(command)
|
||||
if resolved == "" {
|
||||
return "", fmt.Sprintf("command %q is not an allowed ACP probe command", command)
|
||||
}
|
||||
return resolved, ""
|
||||
}
|
||||
|
||||
// resolveACPCommandPrefix validates the optional launcher that wraps an
|
||||
// already allow-listed ACP agent command. The deployment guard is accepted
|
||||
// only at its fixed image path: accepting a matching basename from another
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package utility
|
||||
|
||||
import "testing"
|
||||
|
||||
// A built-in agent's command is compiled into the binary, so it keeps
|
||||
// resolving to the allow-list literal the taint tracker can follow.
|
||||
func TestResolveSpawnCommand_BuiltinUsesAllowlistLiteral(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, errMsg := resolveSpawnCommand(&InferenceConfigDTO{Command: []string{"goose", "acp"}})
|
||||
|
||||
if errMsg != "" {
|
||||
t.Fatalf("errMsg = %q, want empty", errMsg)
|
||||
}
|
||||
if cmd != "goose" {
|
||||
t.Errorf("cmd = %q, want %q", cmd, "goose")
|
||||
}
|
||||
}
|
||||
|
||||
// A command that is neither allow-listed nor operator-defined is still
|
||||
// refused: for a built-in that means the allow-list and the agent definition
|
||||
// have drifted apart, which is a bug rather than a configuration choice.
|
||||
func TestResolveSpawnCommand_RejectsUnlistedBuiltin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, errMsg := resolveSpawnCommand(&InferenceConfigDTO{Command: []string{"my-agent", "--acp"}})
|
||||
|
||||
if errMsg == "" {
|
||||
t.Fatal("errMsg is empty; an unlisted built-in command must be refused")
|
||||
}
|
||||
if cmd != "" {
|
||||
t.Errorf("cmd = %q, want empty", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom agent's command is typed by the install operator in Settings. The
|
||||
// same string is already spawned unrestricted when a session starts, so
|
||||
// refusing it here only denies the agent its models and modes.
|
||||
func TestResolveSpawnCommand_AllowsOperatorDefined(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, errMsg := resolveSpawnCommand(&InferenceConfigDTO{
|
||||
Command: []string{"my-agent", "--acp"},
|
||||
OperatorDefined: true,
|
||||
})
|
||||
|
||||
if errMsg != "" {
|
||||
t.Fatalf("errMsg = %q, want empty", errMsg)
|
||||
}
|
||||
if cmd != "my-agent" {
|
||||
t.Errorf("cmd = %q, want %q", cmd, "my-agent")
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,12 @@ type InferenceConfigDTO struct {
|
||||
// (base URL + bearer key) right after the ACP initialize handshake, the
|
||||
// same mechanism the live session adapter uses.
|
||||
ProviderGatewayAuth *acpprovider.GatewayAuth `json:"provider_gateway_auth,omitempty"`
|
||||
// OperatorDefined marks a command that came from a custom agent the
|
||||
// install operator registered in Settings rather than from an agent
|
||||
// definition compiled into the binary. Such a command cannot appear in
|
||||
// the probe allow-list, because it does not exist until the operator
|
||||
// types it. See resolveSpawnCommand.
|
||||
OperatorDefined bool `json:"operator_defined,omitempty"`
|
||||
}
|
||||
|
||||
// PromptResponse is the response from executing a utility prompt.
|
||||
|
||||
@@ -1222,16 +1222,8 @@ func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry
|
||||
if agent.TUIConfig == nil {
|
||||
continue
|
||||
}
|
||||
cfg := agent.TUIConfig
|
||||
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 {
|
||||
spec := agentsettingscontroller.CustomAgentSpecFromStored(agent.Name, agent.TUIConfig)
|
||||
if regErr := agentRegistry.RegisterCustomTUIAgent(spec); regErr != nil {
|
||||
log.Warn("failed to register custom TUI agent",
|
||||
zap.String("name", agent.Name), zap.Error(regErr))
|
||||
}
|
||||
|
||||
@@ -114,6 +114,23 @@ describe("custom TUI MCP control on the agents index", () => {
|
||||
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
|
||||
});
|
||||
|
||||
// An ACP custom agent receives kandev's MCP servers in session/new. Offering
|
||||
// a passthrough config-file strategy would present a choice the backend
|
||||
// rejects, because there is no passthrough command to write a config for.
|
||||
it("renders nothing for a custom agent registered as ACP", () => {
|
||||
renderIndexComposition(
|
||||
tuiAgent({
|
||||
tui_config: {
|
||||
command: "fuelclaude --acp",
|
||||
display_name: "Fuel Claude",
|
||||
wait_for_terminal: true,
|
||||
protocol: "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();
|
||||
|
||||
@@ -334,6 +334,7 @@ function useAgentPageState() {
|
||||
model?: string;
|
||||
command: string;
|
||||
mcp_strategy?: string;
|
||||
protocol?: string;
|
||||
}) => {
|
||||
await createCustomTUIAgent(data);
|
||||
const [discoveryResp, agentsResp, availableResp] = await Promise.all([
|
||||
|
||||
@@ -3,6 +3,9 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
|
||||
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
|
||||
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
|
||||
|
||||
const DISPLAY_NAME_LABEL = "Display Name";
|
||||
const COMMAND_LABEL = "Command";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderDialog(onSubmit = vi.fn()) {
|
||||
@@ -31,8 +34,8 @@ describe("AddTUIAgentDialog", () => {
|
||||
it("keeps the {{model}} token in the command placeholder", () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByLabelText("Command").getAttribute("placeholder")).toBe(
|
||||
"e.g. superclaude --yolo --model {{model}}",
|
||||
expect(screen.getByLabelText(COMMAND_LABEL).getAttribute("placeholder")).toBe(
|
||||
"e.g. superagent --yolo --model {{model}}",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,7 +53,9 @@ describe("AddTUIAgentDialog", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderDialog(onSubmit);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
||||
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
|
||||
target: { value: "superagent" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
expect(screen.getByText("Command is required")).toBeTruthy();
|
||||
@@ -63,14 +68,69 @@ describe("AddTUIAgentDialog", () => {
|
||||
const onSubmit = vi.fn().mockRejectedValue("boom");
|
||||
renderDialog(onSubmit);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
|
||||
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
|
||||
target: { value: "superagent" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// A custom agent that speaks ACP has to be created as one: the protocol is
|
||||
// part of the stored definition and decides whether kandev drives the command
|
||||
// over ACP or launches it in a terminal.
|
||||
it("submits the ACP protocol when it is selected", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
renderDialog(onSubmit);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-protocol-select"));
|
||||
fireEvent.click(screen.getByRole("option", { name: /ACP/ }));
|
||||
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
|
||||
target: { value: "My Agent" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), {
|
||||
target: { value: "superagent --acp" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
expect(onSubmit.mock.calls[0][0]).toMatchObject({
|
||||
display_name: "My Agent",
|
||||
command: "superagent --acp",
|
||||
protocol: "acp",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to the terminal protocol", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
renderDialog(onSubmit);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
|
||||
target: { value: "superagent" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
expect(onSubmit.mock.calls[0][0].protocol).toBeUndefined();
|
||||
});
|
||||
|
||||
// The passthrough MCP strategies write a config file for the wrapped CLI.
|
||||
// An ACP agent gets its servers in session/new, so the picker would offer a
|
||||
// choice the backend rejects.
|
||||
it("hides the MCP strategy picker for the ACP protocol", () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.queryByTestId("mcp-strategy-select")).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-protocol-select"));
|
||||
fireEvent.click(screen.getByRole("option", { name: /ACP/ }));
|
||||
|
||||
expect(screen.queryByTestId("mcp-strategy-select")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes without exposing a handled stale-page error", async () => {
|
||||
const staleError = new ApiError("interim settings interlock required", 403, {
|
||||
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
|
||||
@@ -80,8 +140,10 @@ describe("AddTUIAgentDialog", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(<AddTUIAgentDialog open onOpenChange={onOpenChange} onSubmit={onSubmit} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
|
||||
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
|
||||
target: { value: "superagent" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { MCPStrategySelect, useMCPStrategies } from "./mcp-strategy-select";
|
||||
|
||||
type TUIAgentFormData = {
|
||||
@@ -22,6 +23,7 @@ type TUIAgentFormData = {
|
||||
model?: string;
|
||||
command: string;
|
||||
mcp_strategy?: string;
|
||||
protocol?: string;
|
||||
};
|
||||
|
||||
// The placeholder kandev substitutes into the TUI command. The user types it
|
||||
@@ -29,6 +31,15 @@ type TUIAgentFormData = {
|
||||
// catalog. Same for the example command and model names.
|
||||
const MODEL_TOKEN = "{{model}}";
|
||||
|
||||
// Radix Select cannot hold an empty-string value, and the stored protocol for
|
||||
// terminal passthrough is exactly that, so the terminal choice needs a
|
||||
// sentinel. It is a control value, never displayed and never translated.
|
||||
const PROTOCOL_TERMINAL = "terminal";
|
||||
const PROTOCOL_ACP = "acp";
|
||||
|
||||
// i18n-exempt: example shell command, typed verbatim rather than translated.
|
||||
const ACP_COMMAND_EXAMPLE = "superagent --acp";
|
||||
|
||||
type AddTUIAgentDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -40,6 +51,7 @@ type DialogHandlersParams = {
|
||||
model: string;
|
||||
command: string;
|
||||
mcpStrategy: string;
|
||||
protocol: string;
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onSubmit: (data: TUIAgentFormData) => Promise<void>;
|
||||
@@ -52,6 +64,7 @@ function useDialogHandlers({
|
||||
model,
|
||||
command,
|
||||
mcpStrategy,
|
||||
protocol,
|
||||
setError,
|
||||
setLoading,
|
||||
onSubmit,
|
||||
@@ -69,12 +82,17 @@ function useDialogHandlers({
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const acp = protocol === PROTOCOL_ACP;
|
||||
try {
|
||||
await onSubmit({
|
||||
display_name: displayName.trim(),
|
||||
model: model.trim() || undefined,
|
||||
// An ACP profile's model comes from the capability probe, and its MCP
|
||||
// servers travel in session/new, so neither field is offered — sending
|
||||
// one anyway would persist a value nothing reads.
|
||||
model: acp ? undefined : model.trim() || undefined,
|
||||
command: command.trim(),
|
||||
mcp_strategy: mcpStrategy || undefined,
|
||||
mcp_strategy: acp ? undefined : mcpStrategy || undefined,
|
||||
protocol: acp ? PROTOCOL_ACP : undefined,
|
||||
});
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
@@ -98,23 +116,139 @@ function useDialogHandlers({
|
||||
return { handleSubmit, handleOpenChange };
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the runtime kandev drives the command with.
|
||||
*
|
||||
* This is an explicit choice rather than something probed from the command.
|
||||
* A CLI that serves ACP normally does so behind a flag (`--acp`, `acp`), and
|
||||
* the command that starts the server is not the command that renders an
|
||||
* interactive terminal — so there is nothing to detect from a single command
|
||||
* string, and guessing wrong launches a JSON-RPC server into a terminal tab.
|
||||
*/
|
||||
function AgentProtocolSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-protocol">{t("agents:agentProtocol")}</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
id="tui-protocol"
|
||||
className="w-full min-w-0 cursor-pointer [@media(pointer:coarse)]:min-h-11"
|
||||
data-testid="agent-protocol-select"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value={PROTOCOL_TERMINAL}
|
||||
description={t("agents:agentProtocolTerminalHelp")}
|
||||
className="cursor-pointer [@media(pointer:coarse)]:min-h-11"
|
||||
>
|
||||
{t("agents:agentProtocolTerminal")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={PROTOCOL_ACP}
|
||||
description={t("agents:agentProtocolAcpHelp")}
|
||||
className="cursor-pointer [@media(pointer:coarse)]:min-h-11"
|
||||
>
|
||||
{t("agents:agentProtocolAcp")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The model label and the command hint only make sense in terminal mode: an
|
||||
* ACP profile's model comes from the capability probe, not from the command. */
|
||||
function TerminalModelField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-model">{t("agents:model")}</Label>
|
||||
<Input
|
||||
id="tui-model"
|
||||
placeholder={t("agents:exampleValue", { example: "best" })}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:tuiModelHelp")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandField({
|
||||
acp,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
acp: boolean;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-command">{t("agents:command")}</Label>
|
||||
<Input
|
||||
id="tui-command"
|
||||
placeholder={t("agents:exampleValue", {
|
||||
example: acp ? ACP_COMMAND_EXAMPLE : `superagent --yolo --model ${MODEL_TOKEN}`,
|
||||
})}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{acp ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agents:agentProtocolHelp", { example: ACP_COMMAND_EXAMPLE })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans i18nKey="agents:tuiCommandHelp" values={{ token: MODEL_TOKEN }}>
|
||||
<code className="rounded bg-muted px-1 py-0.5" />
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [mcpStrategy, setMcpStrategy] = useState("");
|
||||
const [protocol, setProtocol] = useState(PROTOCOL_TERMINAL);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const acp = protocol === PROTOCOL_ACP;
|
||||
|
||||
// Only fetch the strategy list while the dialog is open.
|
||||
const strategies = useMCPStrategies(open);
|
||||
// Only fetch the strategy list while the dialog is open on the protocol that
|
||||
// can use one.
|
||||
const strategies = useMCPStrategies(open && !acp);
|
||||
|
||||
const reset = () => {
|
||||
setDisplayName("");
|
||||
setModel("");
|
||||
setCommand("");
|
||||
setMcpStrategy("");
|
||||
setProtocol(PROTOCOL_TERMINAL);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -124,6 +258,7 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
|
||||
model,
|
||||
command,
|
||||
mcpStrategy,
|
||||
protocol,
|
||||
setError,
|
||||
setLoading,
|
||||
onSubmit,
|
||||
@@ -143,43 +278,22 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
|
||||
<Label htmlFor="tui-display-name">{t("agents:displayName")}</Label>
|
||||
<Input
|
||||
id="tui-display-name"
|
||||
placeholder={t("agents:exampleValue", { example: "superclaude" })}
|
||||
placeholder={t("agents:exampleValue", { example: "superagent" })}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-model">{t("agents:model")}</Label>
|
||||
<Input
|
||||
id="tui-model"
|
||||
placeholder={t("agents:exampleValue", { example: "best" })}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
<AgentProtocolSelect value={protocol} onChange={setProtocol} />
|
||||
{!acp && <TerminalModelField value={model} onChange={setModel} />}
|
||||
<CommandField acp={acp} value={command} onChange={setCommand} />
|
||||
{!acp && (
|
||||
<MCPStrategySelect
|
||||
id="tui-mcp-strategy"
|
||||
value={mcpStrategy}
|
||||
onChange={setMcpStrategy}
|
||||
strategies={strategies}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:tuiModelHelp")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-command">{t("agents:command")}</Label>
|
||||
<Input
|
||||
id="tui-command"
|
||||
placeholder={t("agents:exampleValue", {
|
||||
example: `superclaude --yolo --model ${MODEL_TOKEN}`,
|
||||
})}
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans i18nKey="agents:tuiCommandHelp" values={{ token: MODEL_TOKEN }}>
|
||||
<code className="rounded bg-muted px-1 py-0.5" />
|
||||
</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>
|
||||
|
||||
@@ -36,11 +36,14 @@ export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) {
|
||||
// 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);
|
||||
// An ACP custom agent receives kandev's resolved MCP servers in session/new,
|
||||
// so there is no wrapped-CLI config file for a strategy to write. Offering
|
||||
// the picker would present a choice the backend rejects.
|
||||
const isCustomTUIAgent = Boolean(agent?.tui_config) && agent?.tui_config?.protocol !== "acp";
|
||||
const strategies = useMCPStrategies(isCustomTUIAgent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (!agent?.tui_config) return null;
|
||||
if (!isCustomTUIAgent || !agent?.tui_config) return null;
|
||||
|
||||
const handleChange = async (strategy: string) => {
|
||||
setSaving(true);
|
||||
|
||||
@@ -640,6 +640,15 @@ export class ApiClient {
|
||||
return this.request("GET", "/api/v1/agents/available");
|
||||
}
|
||||
|
||||
/** Removes a custom agent by slug, so a spec that creates one leaves the
|
||||
* worker's agent list as it found it. Missing is not an error. */
|
||||
async deleteCustomAgentByName(name: string): Promise<void> {
|
||||
const { agents } = await this.listAgents();
|
||||
const agent = agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) return;
|
||||
await this.request("DELETE", `/api/v1/agents/${agent.id}`);
|
||||
}
|
||||
|
||||
async deleteAgentProfile(profileId: string, force?: boolean): Promise<void> {
|
||||
const qs = force ? "?force=true" : "";
|
||||
await this.request("DELETE", `/api/v1/agent-profiles/${profileId}${qs}`);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
const AGENT_NAME = "E2E Acp Agent";
|
||||
const AGENT_SLUG = "e2e-acp-agent";
|
||||
|
||||
test.describe("Custom ACP agent", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
await apiClient.deleteCustomAgentByName(AGENT_SLUG);
|
||||
});
|
||||
|
||||
test("registers a custom agent kandev drives over ACP", async ({ testPage, apiClient }) => {
|
||||
await testPage.goto("/settings/agents");
|
||||
await testPage.getByTestId("new-agent-button").click();
|
||||
|
||||
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByTestId("mcp-strategy-select")).toBeVisible();
|
||||
|
||||
await dialog.getByTestId("agent-protocol-select").click();
|
||||
await testPage.getByRole("listbox").getByRole("option").filter({ hasText: /^ACP/ }).click();
|
||||
|
||||
// Resolved MCP servers reach an ACP agent through session/new, so the
|
||||
// passthrough strategy picker is not offered for it.
|
||||
await expect(dialog.getByTestId("mcp-strategy-select")).toHaveCount(0);
|
||||
|
||||
await dialog.getByLabel("Display Name").fill(AGENT_NAME);
|
||||
await dialog.getByLabel("Command").fill(`${AGENT_SLUG} --acp`);
|
||||
await dialog.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(testPage.getByTestId(`agent-group-${AGENT_SLUG}`)).toBeVisible();
|
||||
await expect(testPage.getByTestId(`agent-mcp-strategy-${AGENT_SLUG}`)).toHaveCount(0);
|
||||
|
||||
// The stored definition is what a restart replays, so it has to say ACP
|
||||
// rather than leaving the agent to come back as a terminal one.
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const created = agents.find((agent) => agent.name === AGENT_SLUG);
|
||||
expect(created?.tui_config?.protocol).toBe("acp");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ test.describe("Custom TUI MCP strategy selector", () => {
|
||||
await testPage.goto("/settings/agents");
|
||||
await testPage.getByTestId("new-agent-button").click();
|
||||
|
||||
const dialog = testPage.getByRole("dialog", { name: "Add TUI Agent" });
|
||||
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const trigger = dialog.getByTestId("mcp-strategy-select");
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
const AGENT_NAME = "Mobile ACP Agent";
|
||||
const AGENT_SLUG = "mobile-acp-agent";
|
||||
|
||||
test.describe("Custom ACP agent on mobile", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
await apiClient.deleteCustomAgentByName(AGENT_SLUG);
|
||||
});
|
||||
|
||||
test("selects ACP and keeps the protocol controls usable", async ({ testPage, apiClient }) => {
|
||||
await testPage.goto("/settings/agents");
|
||||
await testPage.getByTestId("new-agent-button").tap();
|
||||
|
||||
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const protocol = dialog.getByTestId("agent-protocol-select");
|
||||
await expect
|
||||
.poll(async () => (await protocol.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
|
||||
await protocol.tap();
|
||||
const listbox = testPage.getByRole("listbox");
|
||||
const acpOption = listbox.getByRole("option", { name: "ACP" });
|
||||
await expect(acpOption).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await acpOption.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
await acpOption.tap();
|
||||
|
||||
await expect(dialog.getByTestId("mcp-strategy-select")).toHaveCount(0);
|
||||
await dialog.getByLabel("Display Name").fill(AGENT_NAME);
|
||||
await dialog.getByLabel("Command").fill(`${AGENT_SLUG} --acp`);
|
||||
await dialog.getByRole("button", { name: "Create" }).tap();
|
||||
|
||||
await expect(testPage.getByTestId(`agent-group-${AGENT_SLUG}`)).toBeVisible();
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const created = agents.find((agent) => agent.name === AGENT_SLUG);
|
||||
expect(created?.tui_config?.protocol).toBe("acp");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ test.describe("Custom TUI MCP strategy selector on mobile", () => {
|
||||
await testPage.goto("/settings/agents");
|
||||
await testPage.getByTestId("new-agent-button").tap();
|
||||
|
||||
const dialog = testPage.getByRole("dialog", { name: "Add TUI Agent" });
|
||||
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const trigger = dialog.getByTestId("mcp-strategy-select");
|
||||
|
||||
@@ -524,7 +524,7 @@ export const i18nGuardFiles = [
|
||||
// Deliberately left in English, each an identifier the user must read or type
|
||||
// verbatim, all interpolated as values so the pseudo-locale cannot turn them
|
||||
// into dead pointers: the `{prompt}` and `{{model}}` substitution tokens, the
|
||||
// `--my-flag` / `greywall --` / `/init` / `superclaude` examples, the
|
||||
// `--my-flag` / `greywall --` / `/init` / `superagent` examples, the
|
||||
// `mcpServers` JSON key, the MCP server product names (`Playwright MCP`,
|
||||
// `Chrome DevTools MCP`, `Context7 MCP`, `GitHub MCP`) and the Kandev MCP tool
|
||||
// list, and the whole of the assembled command preview. Wire values stay wire
|
||||
|
||||
@@ -346,6 +346,7 @@ export async function createCustomTUIAgent(
|
||||
command: string;
|
||||
description?: string;
|
||||
mcp_strategy?: string;
|
||||
protocol?: string;
|
||||
},
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<Agent> {
|
||||
|
||||
@@ -19,6 +19,13 @@ export type {
|
||||
import type { AgentProfile } from "./agent-profile";
|
||||
import type { BackendMessage } from "./backend-message";
|
||||
|
||||
/**
|
||||
* How kandev drives a custom agent's command. Absent means terminal
|
||||
* passthrough, which is what every definition stored before the field existed
|
||||
* decodes to.
|
||||
*/
|
||||
export type CustomAgentProtocol = "acp";
|
||||
|
||||
export type TUIConfig = {
|
||||
command: string;
|
||||
display_name: string;
|
||||
@@ -33,6 +40,13 @@ export type TUIConfig = {
|
||||
* list here, or a strategy added in Go silently stops being selectable.
|
||||
*/
|
||||
mcp_strategy?: string;
|
||||
/**
|
||||
* Runtime kandev drives the command with. Absent means terminal passthrough;
|
||||
* `"acp"` means kandev speaks the Agent Client Protocol to it on standard
|
||||
* input and output, which is what gives the agent structured chat, tool
|
||||
* calls, models, and modes.
|
||||
*/
|
||||
protocol?: CustomAgentProtocol;
|
||||
};
|
||||
|
||||
/** One selectable MCP injection mechanism, served by the backend. */
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"actionCannotBeUndone": "This action cannot be undone.",
|
||||
"add": "Add",
|
||||
"addProfile": "Add profile",
|
||||
"addTuiAgent": "Add TUI Agent",
|
||||
"addTuiAgentDescription": "Register a CLI tool as a TUI agent. It will appear in the agent list with a default passthrough profile.",
|
||||
"addTuiAgent": "Add custom agent",
|
||||
"addTuiAgentDescription": "Register a CLI you already have. It appears in the agent list with a default profile in the protocol you pick.",
|
||||
"agentCliFlags": "Agent CLI flags",
|
||||
"agentCliFlagsDescription": "Flags passed to the agent CLI on launch. Only enabled entries are applied.",
|
||||
"agentInstallAlreadyInProgress": "Agent installation is already in progress.",
|
||||
@@ -11,6 +11,12 @@
|
||||
"agentNotFound": "Agent not found.",
|
||||
"agentProfileSettings": "{{name}} profile settings",
|
||||
"agentProfilesTitle": "{{name}} Profiles",
|
||||
"agentProtocol": "Protocol",
|
||||
"agentProtocolAcp": "ACP",
|
||||
"agentProtocolAcpHelp": "Kandev drives the command over the Agent Client Protocol. Structured chat, tool calls, models, and modes.",
|
||||
"agentProtocolHelp": "Choose ACP only when the command starts an ACP server on standard input and output, for example {{example}}.",
|
||||
"agentProtocolTerminal": "Terminal",
|
||||
"agentProtocolTerminalHelp": "Kandev runs the command in a terminal tab. No structured chat, tool calls, or model list.",
|
||||
"authRequiredOpenLoginTerminal": "Authentication required - click to open login terminal",
|
||||
"authRequiredOpenShell": "Authentication required - click to open a shell and sign in",
|
||||
"authenticationRequired": "Authentication required",
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
"agentNotFound": "エージェントが見つかりません。",
|
||||
"agentProfileSettings": "{{name}} のプロファイル設定",
|
||||
"agentProfilesTitle": "{{name}} のプロファイル",
|
||||
"agentProtocol": "プロトコル",
|
||||
"agentProtocolAcp": "ACP",
|
||||
"agentProtocolAcpHelp": "Kandev が Agent Client Protocol 経由でコマンドを制御します。構造化されたチャット、ツール呼び出し、モデル、モードを利用できます。",
|
||||
"agentProtocolHelp": "コマンドが標準入出力で ACP サーバーを起動する場合にのみ ACP を選んでください。例: {{example}}。",
|
||||
"agentProtocolTerminal": "ターミナル",
|
||||
"agentProtocolTerminalHelp": "Kandev はコマンドをターミナルタブで実行します。構造化されたチャット、ツール呼び出し、モデル一覧はありません。",
|
||||
"authRequiredOpenLoginTerminal": "認証が必要です。クリックしてログイン用ターミナルを開く",
|
||||
"authRequiredOpenShell": "認証が必要です。クリックしてシェルを開き、サインインしてください",
|
||||
"authenticationRequired": "認証が必要です",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"actionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"add": "Àďď",
|
||||
"addProfile": "Àďď ƥŕōƒĩĺē",
|
||||
"addTuiAgent": "Àďď ŢŨĨ Àĝēńţ",
|
||||
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ţōōĺ àś à ŢŨĨ àĝēńţ. Ĩţ ŵĩĺĺ àƥƥēàŕ ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥàśśţĥŕōũĝĥ ƥŕōƒĩĺē.",
|
||||
"addTuiAgent": "Àďď ćũśţōḿ àĝēńţ",
|
||||
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ŷōũ àĺŕēàďŷ ĥàvē. Ĩţ àƥƥēàŕś ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥŕōƒĩĺē ĩń ţĥē ƥŕōţōćōĺ ŷōũ ƥĩćķ.",
|
||||
"agentCliFlags": "Àĝēńţ ĆĹĨ ƒĺàĝś",
|
||||
"agentCliFlagsDescription": "Ƒĺàĝś ƥàśśēď ţō ţĥē àĝēńţ ĆĹĨ ōń ĺàũńćĥ. Ōńĺŷ ēńàƀĺēď ēńţŕĩēś àŕē àƥƥĺĩēď.",
|
||||
"agentInstallAlreadyInProgress": "Àĝēńţ ĩńśţàĺĺàţĩōń ĩś àĺŕēàďŷ ĩń ƥŕōĝŕēśś.",
|
||||
@@ -11,6 +11,12 @@
|
||||
"agentNotFound": "Àĝēńţ ńōţ ƒōũńď.",
|
||||
"agentProfileSettings": "{{name}} ƥŕōƒĩĺē śēţţĩńĝś",
|
||||
"agentProfilesTitle": "{{name}} Ƥŕōƒĩĺēś",
|
||||
"agentProtocol": "Ƥŕōţōćōĺ",
|
||||
"agentProtocolAcp": "ÀĆƤ",
|
||||
"agentProtocolAcpHelp": "Ķàńďēv ďŕĩvēś ţĥē ćōḿḿàńď ōvēŕ ţĥē Àĝēńţ Ćĺĩēńţ Ƥŕōţōćōĺ. Śţŕũćţũŕēď ćĥàţ, ţōōĺ ćàĺĺś, ḿōďēĺś, àńď ḿōďēś.",
|
||||
"agentProtocolHelp": "Ćĥōōśē ÀĆƤ ōńĺŷ ŵĥēń ţĥē ćōḿḿàńď śţàŕţś àń ÀĆƤ śēŕvēŕ ōń śţàńďàŕď ĩńƥũţ àńď ōũţƥũţ, ƒōŕ ēxàḿƥĺē {{example}}.",
|
||||
"agentProtocolTerminal": "Ţēŕḿĩńàĺ",
|
||||
"agentProtocolTerminalHelp": "Ķàńďēv ŕũńś ţĥē ćōḿḿàńď ĩń à ţēŕḿĩńàĺ ţàƀ. Ńō śţŕũćţũŕēď ćĥàţ, ţōōĺ ćàĺĺś, ōŕ ḿōďēĺ ĺĩśţ.",
|
||||
"authRequiredOpenLoginTerminal": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń ĺōĝĩń ţēŕḿĩńàĺ",
|
||||
"authRequiredOpenShell": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń à śĥēĺĺ àńď śĩĝń ĩń",
|
||||
"authenticationRequired": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agents:agentProtocolTerminal": "\"Terminal\" is the ordinary Portuguese word for the surface; translating it would change nothing.",
|
||||
"common:menu": "\"Menu\" is the ordinary Portuguese word; translating it would change nothing.",
|
||||
"kanban:menu": "\"Menu\" is the ordinary Portuguese word; translating it would change nothing.",
|
||||
"office:desc": "\"Desc\" is the same abbreviation for descending order in Portuguese.",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"actionCannotBeUndone": "Esta ação não pode ser anulada.",
|
||||
"add": "Adicionar",
|
||||
"addProfile": "Adicionar perfil",
|
||||
"addTuiAgent": "Adicionar agente TUI",
|
||||
"addTuiAgentDescription": "Registe uma ferramenta de CLI como agente TUI. Aparecerá na lista de agentes com um perfil de passagem direta pré-definido.",
|
||||
"addTuiAgent": "Adicionar agente personalizado",
|
||||
"addTuiAgentDescription": "Regista um CLI que já tens. Aparece na lista de agentes com um perfil predefinido no protocolo que escolheres.",
|
||||
"agentCliFlags": "Flags da CLI do agente",
|
||||
"agentCliFlagsDescription": "Flags passadas à CLI do agente no arranque. Só as entradas ativadas são aplicadas.",
|
||||
"agentInstallAlreadyInProgress": "Já está em curso uma instalação do agente.",
|
||||
@@ -11,6 +11,12 @@
|
||||
"agentNotFound": "Agente não encontrado.",
|
||||
"agentProfileSettings": "Definições do perfil {{name}}",
|
||||
"agentProfilesTitle": "Perfis de {{name}}",
|
||||
"agentProtocol": "Protocolo",
|
||||
"agentProtocolAcp": "ACP",
|
||||
"agentProtocolAcpHelp": "O Kandev conduz o comando pelo Agent Client Protocol. Conversa estruturada, chamadas de ferramentas, modelos e modos.",
|
||||
"agentProtocolHelp": "Escolhe ACP apenas quando o comando inicia um servidor ACP na entrada e saída padrão, por exemplo {{example}}.",
|
||||
"agentProtocolTerminal": "Terminal",
|
||||
"agentProtocolTerminalHelp": "O Kandev corre o comando num separador de terminal. Sem conversa estruturada, chamadas de ferramentas ou lista de modelos.",
|
||||
"authRequiredOpenLoginTerminal": "É necessária autenticação - clique para abrir o terminal de início de sessão",
|
||||
"authRequiredOpenShell": "É necessária autenticação - clique para abrir uma shell e iniciar sessão",
|
||||
"authenticationRequired": "É necessária autenticação",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"actionCannotBeUndone": "此操作无法撤销。",
|
||||
"add": "添加",
|
||||
"addProfile": "添加配置",
|
||||
"addTuiAgent": "添加 TUI 智能体",
|
||||
"addTuiAgentDescription": "将 CLI 工具注册为 TUI 智能体。它会以默认透传配置出现在智能体列表中。",
|
||||
"addTuiAgent": "添加自定义代理",
|
||||
"addTuiAgentDescription": "注册你已安装的 CLI。它会出现在代理列表中,并带有一个使用你所选协议的默认配置。",
|
||||
"agentCliFlags": "智能体 CLI 标志",
|
||||
"agentCliFlagsDescription": "启动时智能体 CLI 接收的标志。仅应用已启用的项。",
|
||||
"agentInstallAlreadyInProgress": "智能体安装已在进行中。",
|
||||
@@ -11,6 +11,12 @@
|
||||
"agentNotFound": "未找到智能体。",
|
||||
"agentProfileSettings": "{{name}} 智能体配置",
|
||||
"agentProfilesTitle": "{{name}} 配置",
|
||||
"agentProtocol": "协议",
|
||||
"agentProtocolAcp": "ACP",
|
||||
"agentProtocolAcpHelp": "Kandev 通过 Agent Client Protocol 驱动该命令。支持结构化对话、工具调用、模型和模式。",
|
||||
"agentProtocolHelp": "仅当该命令在标准输入输出上启动 ACP 服务器时才选择 ACP,例如 {{example}}。",
|
||||
"agentProtocolTerminal": "终端",
|
||||
"agentProtocolTerminalHelp": "Kandev 在终端标签页中运行该命令。没有结构化对话、工具调用或模型列表。",
|
||||
"authRequiredOpenLoginTerminal": "需要身份验证;点击打开登录终端",
|
||||
"authRequiredOpenShell": "需要身份验证;点击打开 shell 并登录",
|
||||
"authenticationRequired": "需要身份验证",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"actionCannotBeUndone": "此操作無法撤銷。",
|
||||
"add": "新增",
|
||||
"addProfile": "新增設定",
|
||||
"addTuiAgent": "新增 TUI 代理程式",
|
||||
"addTuiAgentDescription": "將 CLI 工具註冊為 TUI 代理程式。它會以預設透傳設定出現在代理程式列表中。",
|
||||
"addTuiAgent": "新增自訂代理",
|
||||
"addTuiAgentDescription": "註冊你已經安裝嘅 CLI。佢會喺代理清單入面出現,並帶有一個使用你所揀協定嘅預設設定檔。",
|
||||
"agentCliFlags": "代理程式 CLI 旗標",
|
||||
"agentCliFlagsDescription": "啓動時代理程式 CLI 接收的旗標。僅應用已啓用的項。",
|
||||
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
|
||||
@@ -11,6 +11,12 @@
|
||||
"agentNotFound": "未找到代理程式。",
|
||||
"agentProfileSettings": "{{name}} 代理程式設定檔",
|
||||
"agentProfilesTitle": "{{name}} 設定",
|
||||
"agentProtocol": "協定",
|
||||
"agentProtocolAcp": "ACP",
|
||||
"agentProtocolAcpHelp": "Kandev 會透過 Agent Client Protocol 驅動該指令。有結構化對話、工具呼叫、模型同模式。",
|
||||
"agentProtocolHelp": "只有當該指令喺標準輸入輸出上啟動 ACP 伺服器時先揀 ACP,例如 {{example}}。",
|
||||
"agentProtocolTerminal": "終端機",
|
||||
"agentProtocolTerminalHelp": "Kandev 會喺終端機分頁執行該指令。冇結構化對話、工具呼叫或模型清單。",
|
||||
"authRequiredOpenLoginTerminal": "需要身份驗證;點擊開啟登入終端",
|
||||
"authRequiredOpenShell": "需要身份驗證;點擊開啟 shell 並登入",
|
||||
"authenticationRequired": "需要身份驗證",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"actionCannotBeUndone": "此操作無法撤銷。",
|
||||
"add": "新增",
|
||||
"addProfile": "新增設定",
|
||||
"addTuiAgent": "新增 TUI 代理程式",
|
||||
"addTuiAgentDescription": "將 CLI 工具註冊為 TUI 代理程式。它會以預設透傳設定出現在代理程式清單中。",
|
||||
"addTuiAgent": "新增自訂代理程式",
|
||||
"addTuiAgentDescription": "註冊你已安裝的 CLI。它會出現在代理程式清單中,並帶有一個使用你所選通訊協定的預設設定檔。",
|
||||
"agentCliFlags": "代理程式 CLI 旗標",
|
||||
"agentCliFlagsDescription": "啟動時代理程式 CLI 接收的旗標。僅應用已啟用的項。",
|
||||
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
|
||||
@@ -11,6 +11,12 @@
|
||||
"agentNotFound": "未找到代理程式。",
|
||||
"agentProfileSettings": "{{name}} 代理程式設定檔",
|
||||
"agentProfilesTitle": "{{name}} 設定",
|
||||
"agentProtocol": "通訊協定",
|
||||
"agentProtocolAcp": "ACP",
|
||||
"agentProtocolAcpHelp": "Kandev 會透過 Agent Client Protocol 驅動該指令。具備結構化對話、工具呼叫、模型與模式。",
|
||||
"agentProtocolHelp": "只有當該指令會在標準輸入輸出上啟動 ACP 伺服器時才選擇 ACP,例如 {{example}}。",
|
||||
"agentProtocolTerminal": "終端機",
|
||||
"agentProtocolTerminalHelp": "Kandev 會在終端機分頁執行該指令。沒有結構化對話、工具呼叫或模型清單。",
|
||||
"authRequiredOpenLoginTerminal": "需要身分驗證;點選開啟登入終端",
|
||||
"authRequiredOpenShell": "需要身分驗證;點選開啟 shell 並登入",
|
||||
"authenticationRequired": "需要身分驗證",
|
||||
|
||||
Reference in New Issue
Block a user