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
|
Command Command
|
||||||
// ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]).
|
// ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]).
|
||||||
ModelFlag Param
|
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.
|
// 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"`
|
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.
|
// Availability represents the result of detecting an agent's installation.
|
||||||
type Availability struct {
|
type Availability struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -49,77 +35,50 @@ type Availability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Registry manages agent discovery using the agents.Agent interface.
|
// 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 {
|
type Registry struct {
|
||||||
agents []agents.Agent
|
registry *registry.Registry
|
||||||
definitions []KnownAgent
|
logger *logger.Logger
|
||||||
logger *logger.Logger
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cachedResults []Availability
|
cachedResults []Availability
|
||||||
cachedAt time.Time
|
cachedAt time.Time
|
||||||
cacheTTL time.Duration
|
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.
|
// LoadRegistry creates a new discovery registry backed by the agent registry.
|
||||||
// It iterates over all enabled agents, calls IsInstalled and ListModels
|
// The context is unused: nothing is probed here, because detection resolves the
|
||||||
// to populate the KnownAgent definitions.
|
// agent list when a sweep runs.
|
||||||
func LoadRegistry(ctx context.Context, reg *registry.Registry, log *logger.Logger) (*Registry, error) {
|
func LoadRegistry(_ context.Context, reg *registry.Registry, log *logger.Logger) (*Registry, error) {
|
||||||
enabled := reg.ListEnabled()
|
return &Registry{
|
||||||
|
registry: reg,
|
||||||
definitions := make([]KnownAgent, 0, len(enabled))
|
logger: log,
|
||||||
agentList := make([]agents.Agent, 0, len(enabled))
|
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 {
|
for _, ag := range enabled {
|
||||||
if agents.IsVirtualAgent(ag) {
|
if agents.IsVirtualAgent(ag) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Gather discovery info from the agent.
|
result = append(result, ag)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
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...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect checks whether each agent is installed by calling IsInstalled.
|
// 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
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.mu.RLock()
|
||||||
|
startedAt := r.generation
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
results := r.detectAll(ctx)
|
results := r.detectAll(ctx)
|
||||||
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.cachedResults = results
|
if r.generation == startedAt {
|
||||||
r.cachedAt = time.Now()
|
r.cachedResults = results
|
||||||
|
r.cachedAt = time.Now()
|
||||||
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
@@ -145,6 +110,7 @@ func (r *Registry) InvalidateCache() {
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.cachedResults = nil
|
r.cachedResults = nil
|
||||||
r.cachedAt = time.Time{}
|
r.cachedAt = time.Time{}
|
||||||
|
r.generation++
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +144,9 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := make(chan indexedResult, len(r.agents))
|
sweep := r.enabledAgents()
|
||||||
for i, ag := range r.agents {
|
ch := make(chan indexedResult, len(sweep))
|
||||||
|
for i, ag := range sweep {
|
||||||
go func(idx int, ag agents.Agent) {
|
go func(idx int, ag agents.Agent) {
|
||||||
result, err := ag.IsInstalled(ctx)
|
result, err := ag.IsInstalled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -213,14 +180,14 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
|
|||||||
|
|
||||||
// Collect results preserving original order.
|
// Collect results preserving original order.
|
||||||
// If the context expires before all agents respond, return partial results.
|
// If the context expires before all agents respond, return partial results.
|
||||||
slots := make([]Availability, len(r.agents))
|
slots := make([]Availability, len(sweep))
|
||||||
valid := make([]bool, len(r.agents))
|
valid := make([]bool, len(sweep))
|
||||||
for range r.agents {
|
for range sweep {
|
||||||
select {
|
select {
|
||||||
case res := <-ch:
|
case res := <-ch:
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
r.logger.Warn("discovery: detect failed for agent",
|
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),
|
zap.Error(res.err),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -234,7 +201,7 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
|
|||||||
}
|
}
|
||||||
collect:
|
collect:
|
||||||
|
|
||||||
results := make([]Availability, 0, len(r.agents))
|
results := make([]Availability, 0, len(sweep))
|
||||||
for i, v := range valid {
|
for i, v := range valid {
|
||||||
if v {
|
if v {
|
||||||
results = append(results, slots[i])
|
results = append(results, slots[i])
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package discovery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kandev/kandev/internal/agent/agents"
|
"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/agent/usage"
|
||||||
"github.com/kandev/kandev/internal/common/logger"
|
"github.com/kandev/kandev/internal/common/logger"
|
||||||
)
|
)
|
||||||
@@ -56,6 +58,20 @@ func (a *discoveryTestAgent) calls() int {
|
|||||||
return a.installedCalls
|
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 {
|
func newDiscoveryTestLogger(t *testing.T) *logger.Logger {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
||||||
@@ -74,11 +90,7 @@ func TestRegistryDetectCachesResults(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
reg := &Registry{
|
reg := newSingleAgentRegistry(t, a, time.Minute)
|
||||||
agents: []agents.Agent{a},
|
|
||||||
logger: newDiscoveryTestLogger(t),
|
|
||||||
cacheTTL: time.Minute,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := reg.Detect(context.Background()); err != nil {
|
if _, err := reg.Detect(context.Background()); err != nil {
|
||||||
t.Fatalf("first detect: %v", err)
|
t.Fatalf("first detect: %v", err)
|
||||||
@@ -101,11 +113,7 @@ func TestRegistryInvalidateCacheForcesRefresh(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
reg := &Registry{
|
reg := newSingleAgentRegistry(t, a, time.Minute)
|
||||||
agents: []agents.Agent{a},
|
|
||||||
logger: newDiscoveryTestLogger(t),
|
|
||||||
cacheTTL: time.Minute,
|
|
||||||
}
|
|
||||||
|
|
||||||
first, err := reg.Detect(context.Background())
|
first, err := reg.Detect(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,11 +144,7 @@ func TestRegistryDetectCacheTTLExpiry(t *testing.T) {
|
|||||||
discovery: &agents.DiscoveryResult{Available: true},
|
discovery: &agents.DiscoveryResult{Available: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
reg := &Registry{
|
reg := newSingleAgentRegistry(t, a, 20*time.Millisecond)
|
||||||
agents: []agents.Agent{a},
|
|
||||||
logger: newDiscoveryTestLogger(t),
|
|
||||||
cacheTTL: 20 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := reg.Detect(context.Background()); err != nil {
|
if _, err := reg.Detect(context.Background()); err != nil {
|
||||||
t.Fatalf("first detect: %v", err)
|
t.Fatalf("first detect: %v", err)
|
||||||
@@ -154,3 +158,195 @@ func TestRegistryDetectCacheTTLExpiry(t *testing.T) {
|
|||||||
t.Fatalf("IsInstalled calls = %d, want 2", got)
|
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,
|
AgentID: inst.agentType,
|
||||||
Refresh: refresh,
|
Refresh: refresh,
|
||||||
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||||
Command: probeCommand.Args(),
|
Command: probeCommand.Args(),
|
||||||
ModelFlag: cfg.ModelFlag.Args(),
|
ModelFlag: cfg.ModelFlag.Args(),
|
||||||
WorkDir: inst.workDir,
|
WorkDir: inst.workDir,
|
||||||
Env: agents.RuntimeEnvFor(ia),
|
Env: agents.RuntimeEnvFor(ia),
|
||||||
StripEnv: agents.StripEnvFor(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{
|
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||||
Command: command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir,
|
Command: command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir,
|
||||||
Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: cliFlags, CommandPrefix: prefix,
|
Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: cliFlags, CommandPrefix: prefix,
|
||||||
ProviderGatewayAuth: gatewayAuth,
|
ProviderGatewayAuth: gatewayAuth, OperatorDefined: cfg.OperatorDefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
release, err := inst.acquireOperation(ctx, false)
|
release, err := inst.acquireOperation(ctx, false)
|
||||||
@@ -355,11 +355,12 @@ func (m *Manager) ExecutePromptWithMCP(
|
|||||||
Model: resolved,
|
Model: resolved,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||||
Command: command.Args(),
|
Command: command.Args(),
|
||||||
ModelFlag: cfg.ModelFlag.Args(),
|
ModelFlag: cfg.ModelFlag.Args(),
|
||||||
WorkDir: inst.workDir,
|
WorkDir: inst.workDir,
|
||||||
Env: agents.RuntimeEnvFor(ia),
|
Env: agents.RuntimeEnvFor(ia),
|
||||||
StripEnv: agents.StripEnvFor(ia),
|
StripEnv: agents.StripEnvFor(ia),
|
||||||
|
OperatorDefined: cfg.OperatorDefined,
|
||||||
},
|
},
|
||||||
MCPServers: mcpServers,
|
MCPServers: mcpServers,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,20 @@ import (
|
|||||||
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
"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.
|
// stored on the agent row's tui_config and replayed into the registry on boot.
|
||||||
type CustomTUIAgentSpec struct {
|
type CustomTUIAgentSpec struct {
|
||||||
Slug string
|
Slug string
|
||||||
@@ -18,8 +31,12 @@ type CustomTUIAgentSpec struct {
|
|||||||
Model string
|
Model string
|
||||||
CommandArgs []string
|
CommandArgs []string
|
||||||
// MCPStrategyKey selects how kandev injects MCP servers into the wrapped
|
// 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
|
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
|
// 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.
|
// upgrade. Failing loudly beats registering the agent with MCP silently off.
|
||||||
var ErrUnknownMCPStrategy = fmt.Errorf("unknown MCP strategy")
|
var ErrUnknownMCPStrategy = fmt.Errorf("unknown MCP strategy")
|
||||||
|
|
||||||
// buildCustomTUIAgent turns a user-provided spec into a TUIAgent. The command
|
// ErrUnknownCustomAgentProtocol is returned when a spec names a protocol that
|
||||||
// string is split into binary + args, and any {{model}} placeholder in the
|
// does not resolve. Falling back to terminal would silently ignore the user's
|
||||||
// command is replaced with the model value.
|
// choice and launch an ACP server into a PTY.
|
||||||
func buildCustomTUIAgent(spec CustomTUIAgentSpec) (*agents.TUIAgent, error) {
|
var ErrUnknownCustomAgentProtocol = fmt.Errorf("unknown custom agent protocol")
|
||||||
// Replace {{model}} template in the command string
|
|
||||||
|
// 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
|
resolvedCommand := spec.Command
|
||||||
if spec.Model != "" {
|
if spec.Model != "" {
|
||||||
resolvedCommand = strings.ReplaceAll(spec.Command, "{{model}}", spec.Model)
|
resolvedCommand = strings.ReplaceAll(spec.Command, "{{model}}", spec.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split command into binary + args
|
|
||||||
parts := strings.Fields(resolvedCommand)
|
parts := strings.Fields(resolvedCommand)
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return nil, fmt.Errorf("command is empty")
|
return "", nil, fmt.Errorf("command is empty")
|
||||||
}
|
}
|
||||||
binary := parts[0]
|
|
||||||
args := parts[1:]
|
args := parts[1:]
|
||||||
if len(spec.CommandArgs) > 0 {
|
if len(spec.CommandArgs) > 0 {
|
||||||
args = append(args, spec.CommandArgs...)
|
args = append(args, spec.CommandArgs...)
|
||||||
}
|
}
|
||||||
|
return parts[0], args, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
// registers it, failing if the ID is already taken.
|
||||||
func (r *Registry) RegisterCustomTUIAgent(spec CustomTUIAgentSpec) error {
|
func (r *Registry) RegisterCustomTUIAgent(spec CustomTUIAgentSpec) error {
|
||||||
tuiAgent, err := buildCustomTUIAgent(spec)
|
customAgent, err := buildCustomAgent(spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.Register(tuiAgent)
|
return r.Register(customAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceCustomTUIAgent rebuilds an already-registered custom TUI agent in
|
// ReplaceCustomTUIAgent rebuilds an already-registered custom agent in place.
|
||||||
// place. The MCP strategy is baked into the TUIAgent at construction, so
|
// The MCP strategy and the protocol are baked in at construction, so changing
|
||||||
// changing it needs a new instance rather than a mutation.
|
// either needs a new instance rather than a mutation.
|
||||||
//
|
//
|
||||||
// This uses Replace rather than Unregister + Register because the pair leaves a
|
// 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
|
// 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
|
// with "agent type not found", and a competing registration can leave the agent
|
||||||
// missing until restart.
|
// missing until restart.
|
||||||
func (r *Registry) ReplaceCustomTUIAgent(spec CustomTUIAgentSpec) error {
|
func (r *Registry) ReplaceCustomTUIAgent(spec CustomTUIAgentSpec) error {
|
||||||
tuiAgent, err := buildCustomTUIAgent(spec)
|
customAgent, err := buildCustomAgent(spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.Replace(tuiAgent)
|
return r.Replace(customAgent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package registry
|
package registry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"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) {
|
func TestRegisterCustomTUIAgent_Success(t *testing.T) {
|
||||||
@@ -123,3 +129,102 @@ func TestRegisterCustomTUIAgent_DuplicateID(t *testing.T) {
|
|||||||
t.Error("expected error for duplicate registration")
|
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,
|
AgentID: agentID,
|
||||||
Model: model,
|
Model: model,
|
||||||
InferenceConfig: &utility.InferenceConfigDTO{
|
InferenceConfig: &utility.InferenceConfigDTO{
|
||||||
Command: cfg.Command.Args(),
|
Command: cfg.Command.Args(),
|
||||||
ModelFlag: cfg.ModelFlag.Args(),
|
ModelFlag: cfg.ModelFlag.Args(),
|
||||||
WorkDir: execution.WorkspacePath,
|
WorkDir: execution.WorkspacePath,
|
||||||
StripEnv: agents.StripEnvFor(ia),
|
StripEnv: agents.StripEnvFor(ia),
|
||||||
|
OperatorDefined: cfg.OperatorDefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ func (m *Manager) ExecuteInferenceProfilePrompt(ctx context.Context, sessionID,
|
|||||||
InferenceConfig: &utility.InferenceConfigDTO{
|
InferenceConfig: &utility.InferenceConfigDTO{
|
||||||
Command: cfg.Command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: execution.WorkspacePath,
|
Command: cfg.Command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: execution.WorkspacePath,
|
||||||
Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: flags, CommandPrefix: prefix,
|
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
|
return err
|
||||||
}
|
}
|
||||||
if agent.TUIConfig != nil {
|
custom := agent.TUIConfig != nil
|
||||||
|
if custom {
|
||||||
_ = c.agentRegistry.Unregister(agent.Name)
|
_ = c.agentRegistry.Unregister(agent.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,5 +350,11 @@ func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ var (
|
|||||||
ErrRequireExactModelNeedsModel = errors.New("exact model requires a concrete model")
|
ErrRequireExactModelNeedsModel = errors.New("exact model requires a concrete model")
|
||||||
ErrRequireExactModelUnsupported = errors.New("exact model is not supported for this profile")
|
ErrRequireExactModelUnsupported = errors.New("exact model is not supported for this profile")
|
||||||
ErrUnknownMCPStrategy = errors.New("unknown MCP strategy")
|
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")
|
ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent")
|
||||||
ErrDynamicAgentRoutingDisabled = errors.New("dynamic agent routing is disabled")
|
ErrDynamicAgentRoutingDisabled = errors.New("dynamic agent routing is disabled")
|
||||||
ErrDynamicProfileCandidatesRequired = errors.New("dynamic profile candidates are required")
|
ErrDynamicProfileCandidatesRequired = errors.New("dynamic profile candidates are required")
|
||||||
@@ -312,26 +314,119 @@ func (c *Controller) SetJobBroadcaster(hub JobBroadcaster) {
|
|||||||
c.maintenance = newMaintenanceCoordinator()
|
c.maintenance = newMaintenanceCoordinator()
|
||||||
c.jobStore = NewJobStore(hub, c.logger.Zap(), func(agentName string) {
|
c.jobStore = NewJobStore(hub, c.logger.Zap(), func(agentName string) {
|
||||||
c.InvalidateDiscoveryCache()
|
c.InvalidateDiscoveryCache()
|
||||||
// Kick a fresh capability probe immediately so the UI doesn't sit on
|
c.kickCapabilityProbe(agentName)
|
||||||
// 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.logger.Info("install succeeded", zap.String("agent", agentName))
|
c.logger.Info("install succeeded", zap.String("agent", agentName))
|
||||||
}, c.maintenance)
|
}, c.maintenance)
|
||||||
c.initializeUpdateJobStore()
|
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() {
|
func (c *Controller) initializeUpdateJobStore() {
|
||||||
if c.hub == nil || c.runtimeUpdater == nil {
|
if c.hub == nil || c.runtimeUpdater == nil {
|
||||||
c.updateJobStore = nil
|
c.updateJobStore = nil
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func slugify(displayName string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCustomTUIAgentRequest is the request to create a custom TUI agent.
|
// CreateCustomTUIAgentRequest is the request to create a custom agent.
|
||||||
type CreateCustomTUIAgentRequest struct {
|
type CreateCustomTUIAgentRequest struct {
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Model string
|
Model string
|
||||||
@@ -32,11 +32,37 @@ type CreateCustomTUIAgentRequest struct {
|
|||||||
Description string
|
Description string
|
||||||
CommandArgs []string
|
CommandArgs []string
|
||||||
// MCPStrategy selects how kandev injects its per-session MCP server into
|
// 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
|
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) {
|
func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomTUIAgentRequest) (*dto.AgentDTO, error) {
|
||||||
slug := slugify(req.DisplayName)
|
slug := slugify(req.DisplayName)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
@@ -45,8 +71,9 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
|||||||
if req.Command == "" {
|
if req.Command == "" {
|
||||||
return nil, ErrCommandRequired
|
return nil, ErrCommandRequired
|
||||||
}
|
}
|
||||||
if _, ok := mcpconfig.StrategyByKey(req.MCPStrategy); !ok {
|
protocol := registry.CustomAgentProtocol(req.Protocol)
|
||||||
return nil, ErrUnknownMCPStrategy
|
if err := validateCustomAgentProtocol(protocol, req.MCPStrategy); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for conflict with existing registry entry
|
// Check for conflict with existing registry entry
|
||||||
@@ -72,11 +99,13 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
|||||||
Model: req.Model,
|
Model: req.Model,
|
||||||
CommandArgs: req.CommandArgs,
|
CommandArgs: req.CommandArgs,
|
||||||
MCPStrategyKey: req.MCPStrategy,
|
MCPStrategyKey: req.MCPStrategy,
|
||||||
|
Protocol: protocol,
|
||||||
}); regErr != nil {
|
}); regErr != nil {
|
||||||
return nil, fmt.Errorf("failed to register agent: %w", regErr)
|
return nil, fmt.Errorf("failed to register agent: %w", regErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist to DB
|
// Persist to DB
|
||||||
|
acp := protocol == registry.CustomAgentProtocolACP
|
||||||
tuiConfig := &models.TUIConfigJSON{
|
tuiConfig := &models.TUIConfigJSON{
|
||||||
Command: req.Command,
|
Command: req.Command,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
@@ -85,12 +114,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
|||||||
CommandArgs: req.CommandArgs,
|
CommandArgs: req.CommandArgs,
|
||||||
WaitForTerminal: true,
|
WaitForTerminal: true,
|
||||||
MCPStrategy: req.MCPStrategy,
|
MCPStrategy: req.MCPStrategy,
|
||||||
|
Protocol: req.Protocol,
|
||||||
}
|
}
|
||||||
agent := &models.Agent{
|
agent := &models.Agent{
|
||||||
Name: slug,
|
Name: slug,
|
||||||
// Mirrors what TUIAgent.IsInstalled derives, so the profile MCP editor
|
// Mirrors what the registered agent's IsInstalled derives, so the
|
||||||
// works immediately instead of only after the next discovery sweep.
|
// profile MCP editor works immediately instead of only after the next
|
||||||
SupportsMCP: req.MCPStrategy != mcpconfig.StrategyKeyNone,
|
// 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,
|
TUIConfig: tuiConfig,
|
||||||
}
|
}
|
||||||
if err := c.repo.CreateAgent(ctx, agent); err != nil {
|
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
|
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
|
profileName := req.Model
|
||||||
if profileName == "" {
|
if profileName == "" {
|
||||||
profileName = req.DisplayName
|
profileName = req.DisplayName
|
||||||
@@ -111,10 +145,27 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
|
|||||||
Model: "passthrough",
|
Model: "passthrough",
|
||||||
CLIPassthrough: true,
|
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 {
|
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
|
||||||
return nil, err
|
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}
|
profiles := []*models.AgentProfile{profile}
|
||||||
result := c.toAgentDTO(agent, profiles)
|
result := c.toAgentDTO(agent, profiles)
|
||||||
return &result, nil
|
return &result, nil
|
||||||
@@ -152,6 +203,10 @@ func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID,
|
|||||||
// carry a user-selected one.
|
// carry a user-selected one.
|
||||||
return nil, ErrNotCustomTUIAgent
|
return nil, ErrNotCustomTUIAgent
|
||||||
}
|
}
|
||||||
|
protocol := registry.CustomAgentProtocol(agent.TUIConfig.Protocol)
|
||||||
|
if err := validateCustomAgentProtocol(protocol, strategyKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if agent.TUIConfig.MCPStrategy == strategyKey {
|
if agent.TUIConfig.MCPStrategy == strategyKey {
|
||||||
return c.customTUIAgentDTO(ctx, agent)
|
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)
|
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)
|
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
|
// atomic: Unregister + Register would leave a window in which a launching
|
||||||
// session sees no entry for this agent ID.
|
// session sees no entry for this agent ID.
|
||||||
func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
|
func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
|
||||||
cfg := agent.TUIConfig
|
return c.agentRegistry.ReplaceCustomTUIAgent(CustomAgentSpecFromStored(agent.Name, agent.TUIConfig))
|
||||||
return c.agentRegistry.ReplaceCustomTUIAgent(registry.CustomTUIAgentSpec{
|
}
|
||||||
Slug: agent.Name,
|
|
||||||
|
// 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,
|
DisplayName: cfg.DisplayName,
|
||||||
Command: cfg.Command,
|
Command: cfg.Command,
|
||||||
Description: cfg.Description,
|
Description: cfg.Description,
|
||||||
Model: cfg.Model,
|
Model: cfg.Model,
|
||||||
CommandArgs: cfg.CommandArgs,
|
CommandArgs: cfg.CommandArgs,
|
||||||
MCPStrategyKey: cfg.MCPStrategy,
|
MCPStrategyKey: cfg.MCPStrategy,
|
||||||
})
|
Protocol: registry.CustomAgentProtocol(cfg.Protocol),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) (*dto.AgentDTO, error) {
|
func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) (*dto.AgentDTO, error) {
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kandev/kandev/internal/agent/agents"
|
"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/mcpconfig"
|
||||||
"github.com/kandev/kandev/internal/agent/registry"
|
"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/agent/settings/models"
|
||||||
"github.com/kandev/kandev/internal/common/logger"
|
"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)
|
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,
|
CommandArgs: agent.TUIConfig.CommandArgs,
|
||||||
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
|
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
|
||||||
MCPStrategy: agent.TUIConfig.MCPStrategy,
|
MCPStrategy: agent.TUIConfig.MCPStrategy,
|
||||||
|
Protocol: agent.TUIConfig.Protocol,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.agentRegistry != nil {
|
if c.agentRegistry != nil {
|
||||||
|
|||||||
@@ -293,6 +293,21 @@ func (f *fakeStore) UpdateAgentProfile(_ context.Context, p *models.AgentProfile
|
|||||||
return nil
|
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) {
|
func (f *fakeStore) UpdateAgentProfileEnabled(ctx context.Context, id string, enabled bool) (time.Time, error) {
|
||||||
profile, err := f.GetAgentProfile(ctx, id)
|
profile, err := f.GetAgentProfile(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ type TUIConfigDTO struct {
|
|||||||
WaitForTerminal bool `json:"wait_for_terminal"`
|
WaitForTerminal bool `json:"wait_for_terminal"`
|
||||||
// MCPStrategy is the selected MCP injection mechanism ("" = none).
|
// MCPStrategy is the selected MCP injection mechanism ("" = none).
|
||||||
MCPStrategy string `json:"mcp_strategy,omitempty"`
|
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 {
|
type AgentDTO struct {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type createCustomTUIAgentRequest struct {
|
|||||||
// MCPStrategy names how the wrapped CLI loads MCP servers, so kandev can
|
// 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.
|
// point it at the per-session server. Empty (the default) = no MCP tools.
|
||||||
MCPStrategy string `json:"mcp_strategy"`
|
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 {
|
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
|
||||||
@@ -33,6 +36,7 @@ func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCust
|
|||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
CommandArgs: r.CommandArgs,
|
CommandArgs: r.CommandArgs,
|
||||||
MCPStrategy: r.MCPStrategy,
|
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())
|
resp, err := h.controller.CreateCustomTUIAgent(c.Request.Context(), body.toControllerRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
case controller.ErrAgentAlreadyExists:
|
case controller.ErrAgentAlreadyExists:
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
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)
|
resp, err := h.controller.SetCustomTUIAgentMCPStrategy(c.Request.Context(), c.Param("id"), body.MCPStrategy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
case controller.ErrAgentNotFound:
|
case controller.ErrAgentNotFound:
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
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
|
// user's CLI definition, and because tui_config is JSON: adding the field
|
||||||
// needs no migration.
|
// needs no migration.
|
||||||
MCPStrategy string `json:"mcp_strategy,omitempty"`
|
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 {
|
type AgentProfile struct {
|
||||||
|
|||||||
@@ -1263,6 +1263,29 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
|
|||||||
return r.updateAgentProfile(ctx, r.db, profile)
|
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 {
|
func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profileExecer, profile *models.AgentProfile) error {
|
||||||
profile.UpdatedAt = time.Now().UTC()
|
profile.UpdatedAt = time.Now().UTC()
|
||||||
cliFlagsJSON, err := cliFlagsToJSON(profile.CLIFlags)
|
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)
|
) (*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
|
// DynamicProfileRepository is the optional extension implemented by settings
|
||||||
// stores that persist the dynamic profile document. Keeping it separate from
|
// stores that persist the dynamic profile document. Keeping it separate from
|
||||||
// Repository lets small controller fakes and plugin adapters retain the
|
// 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
|
return &PromptResponse{Success: false, Error: "work_dir is required for ACP inference"}, nil
|
||||||
}
|
}
|
||||||
model, modelConfigOptions, _ := acpcompat.MigrateCursorModel(req.AgentID, req.Model, nil)
|
model, modelConfigOptions, _ := acpcompat.MigrateCursorModel(req.AgentID, req.Model, nil)
|
||||||
resolvedCmd := resolveProbeCommand(cfg.Command[0])
|
resolvedCmd, cmdErr := resolveSpawnCommand(cfg)
|
||||||
if resolvedCmd == "" {
|
if cmdErr != "" {
|
||||||
return &PromptResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP command", cfg.Command[0])}, nil
|
return &PromptResponse{Success: false, Error: cmdErr}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -83,9 +83,6 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
|
|||||||
zap.String("model", model),
|
zap.String("model", model),
|
||||||
zap.Strings("command", args))
|
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:]
|
cmdArgs := args[1:]
|
||||||
if len(cfg.CommandPrefix) > 0 {
|
if len(cfg.CommandPrefix) > 0 {
|
||||||
args = append(append([]string{}, cfg.CommandPrefix...), args...)
|
args = append(append([]string{}, cfg.CommandPrefix...), args...)
|
||||||
@@ -96,8 +93,10 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
|
|||||||
cmdArgs = args[1:]
|
cmdArgs = args[1:]
|
||||||
}
|
}
|
||||||
cmdArgs = append(cmdArgs, cfg.CLIFlags...)
|
cmdArgs = append(cmdArgs, cfg.CLIFlags...)
|
||||||
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
|
// Use resolvedCmd (not args[0]) so the executable name the taint tracker
|
||||||
// the executable name is not derived from tainted input.
|
// 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 := exec.CommandContext(ctx, resolvedCmd, cmdArgs...)
|
||||||
cmd.Dir = workDir
|
cmd.Dir = workDir
|
||||||
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
|
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
|
||||||
@@ -476,9 +475,9 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
|
|||||||
if workDir == "" {
|
if workDir == "" {
|
||||||
return &ProbeResponse{Success: false, Error: "work_dir is required for ACP probe"}, nil
|
return &ProbeResponse{Success: false, Error: "work_dir is required for ACP probe"}, nil
|
||||||
}
|
}
|
||||||
resolvedCmd := resolveProbeCommand(cfg.Command[0])
|
resolvedCmd, cmdErr := resolveSpawnCommand(cfg)
|
||||||
if resolvedCmd == "" {
|
if cmdErr != "" {
|
||||||
return &ProbeResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP probe command", cfg.Command[0])}, nil
|
return &ProbeResponse{Success: false, Error: cmdErr}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -496,9 +495,10 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
|
|||||||
zap.String("agent_id", req.AgentID),
|
zap.String("agent_id", req.AgentID),
|
||||||
zap.Strings("command", args))
|
zap.Strings("command", args))
|
||||||
|
|
||||||
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
|
// Use resolvedCmd (not args[0]) so the allow-list literal is what reaches
|
||||||
// the executable name is not derived from tainted input.
|
// exec.Command for every built-in agent, and the one exception is the
|
||||||
//nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags
|
// 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 := exec.CommandContext(ctx, resolvedCmd, args[1:]...)
|
||||||
cmd.Dir = workDir
|
cmd.Dir = workDir
|
||||||
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
|
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
|
||||||
@@ -1269,6 +1269,32 @@ func resolveProbeCommand(name string) string {
|
|||||||
return ""
|
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
|
// resolveACPCommandPrefix validates the optional launcher that wraps an
|
||||||
// already allow-listed ACP agent command. The deployment guard is accepted
|
// already allow-listed ACP agent command. The deployment guard is accepted
|
||||||
// only at its fixed image path: accepting a matching basename from another
|
// 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
|
// (base URL + bearer key) right after the ACP initialize handshake, the
|
||||||
// same mechanism the live session adapter uses.
|
// same mechanism the live session adapter uses.
|
||||||
ProviderGatewayAuth *acpprovider.GatewayAuth `json:"provider_gateway_auth,omitempty"`
|
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.
|
// 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 {
|
if agent.TUIConfig == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cfg := agent.TUIConfig
|
spec := agentsettingscontroller.CustomAgentSpecFromStored(agent.Name, agent.TUIConfig)
|
||||||
if regErr := agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
|
if regErr := agentRegistry.RegisterCustomTUIAgent(spec); regErr != nil {
|
||||||
Slug: agent.Name,
|
|
||||||
DisplayName: cfg.DisplayName,
|
|
||||||
Command: cfg.Command,
|
|
||||||
Description: cfg.Description,
|
|
||||||
Model: cfg.Model,
|
|
||||||
CommandArgs: cfg.CommandArgs,
|
|
||||||
MCPStrategyKey: cfg.MCPStrategy,
|
|
||||||
}); regErr != nil {
|
|
||||||
log.Warn("failed to register custom TUI agent",
|
log.Warn("failed to register custom TUI agent",
|
||||||
zap.String("name", agent.Name), zap.Error(regErr))
|
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();
|
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", () => {
|
it("renders nothing for an agent that has not been saved yet", () => {
|
||||||
renderIndexComposition(undefined);
|
renderIndexComposition(undefined);
|
||||||
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
|
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ function useAgentPageState() {
|
|||||||
model?: string;
|
model?: string;
|
||||||
command: string;
|
command: string;
|
||||||
mcp_strategy?: string;
|
mcp_strategy?: string;
|
||||||
|
protocol?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await createCustomTUIAgent(data);
|
await createCustomTUIAgent(data);
|
||||||
const [discoveryResp, agentsResp, availableResp] = await Promise.all([
|
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 { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
|
||||||
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
|
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
|
||||||
|
|
||||||
|
const DISPLAY_NAME_LABEL = "Display Name";
|
||||||
|
const COMMAND_LABEL = "Command";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
function renderDialog(onSubmit = vi.fn()) {
|
function renderDialog(onSubmit = vi.fn()) {
|
||||||
@@ -31,8 +34,8 @@ describe("AddTUIAgentDialog", () => {
|
|||||||
it("keeps the {{model}} token in the command placeholder", () => {
|
it("keeps the {{model}} token in the command placeholder", () => {
|
||||||
renderDialog();
|
renderDialog();
|
||||||
|
|
||||||
expect(screen.getByLabelText("Command").getAttribute("placeholder")).toBe(
|
expect(screen.getByLabelText(COMMAND_LABEL).getAttribute("placeholder")).toBe(
|
||||||
"e.g. superclaude --yolo --model {{model}}",
|
"e.g. superagent --yolo --model {{model}}",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +53,9 @@ describe("AddTUIAgentDialog", () => {
|
|||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
renderDialog(onSubmit);
|
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"));
|
fireEvent.click(screen.getByText("Create"));
|
||||||
|
|
||||||
expect(screen.getByText("Command is required")).toBeTruthy();
|
expect(screen.getByText("Command is required")).toBeTruthy();
|
||||||
@@ -63,14 +68,69 @@ describe("AddTUIAgentDialog", () => {
|
|||||||
const onSubmit = vi.fn().mockRejectedValue("boom");
|
const onSubmit = vi.fn().mockRejectedValue("boom");
|
||||||
renderDialog(onSubmit);
|
renderDialog(onSubmit);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
|
||||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
|
target: { value: "superagent" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
|
||||||
fireEvent.click(screen.getByText("Create"));
|
fireEvent.click(screen.getByText("Create"));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
|
||||||
expect(onSubmit).toHaveBeenCalledOnce();
|
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 () => {
|
it("closes without exposing a handled stale-page error", async () => {
|
||||||
const staleError = new ApiError("interim settings interlock required", 403, {
|
const staleError = new ApiError("interim settings interlock required", 403, {
|
||||||
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
|
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
|
||||||
@@ -80,8 +140,10 @@ describe("AddTUIAgentDialog", () => {
|
|||||||
const onOpenChange = vi.fn();
|
const onOpenChange = vi.fn();
|
||||||
render(<AddTUIAgentDialog open onOpenChange={onOpenChange} onSubmit={onSubmit} />);
|
render(<AddTUIAgentDialog open onOpenChange={onOpenChange} onSubmit={onSubmit} />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
|
||||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
|
target: { value: "superagent" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
|
||||||
fireEvent.click(screen.getByText("Create"));
|
fireEvent.click(screen.getByText("Create"));
|
||||||
|
|
||||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@kandev/ui/dialog";
|
} from "@kandev/ui/dialog";
|
||||||
import { Input } from "@kandev/ui/input";
|
import { Input } from "@kandev/ui/input";
|
||||||
import { Label } from "@kandev/ui/label";
|
import { Label } from "@kandev/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||||
import { MCPStrategySelect, useMCPStrategies } from "./mcp-strategy-select";
|
import { MCPStrategySelect, useMCPStrategies } from "./mcp-strategy-select";
|
||||||
|
|
||||||
type TUIAgentFormData = {
|
type TUIAgentFormData = {
|
||||||
@@ -22,6 +23,7 @@ type TUIAgentFormData = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
command: string;
|
command: string;
|
||||||
mcp_strategy?: string;
|
mcp_strategy?: string;
|
||||||
|
protocol?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The placeholder kandev substitutes into the TUI command. The user types it
|
// 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.
|
// catalog. Same for the example command and model names.
|
||||||
const MODEL_TOKEN = "{{model}}";
|
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 = {
|
type AddTUIAgentDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -40,6 +51,7 @@ type DialogHandlersParams = {
|
|||||||
model: string;
|
model: string;
|
||||||
command: string;
|
command: string;
|
||||||
mcpStrategy: string;
|
mcpStrategy: string;
|
||||||
|
protocol: string;
|
||||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
onSubmit: (data: TUIAgentFormData) => Promise<void>;
|
onSubmit: (data: TUIAgentFormData) => Promise<void>;
|
||||||
@@ -52,6 +64,7 @@ function useDialogHandlers({
|
|||||||
model,
|
model,
|
||||||
command,
|
command,
|
||||||
mcpStrategy,
|
mcpStrategy,
|
||||||
|
protocol,
|
||||||
setError,
|
setError,
|
||||||
setLoading,
|
setLoading,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -69,12 +82,17 @@ function useDialogHandlers({
|
|||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
const acp = protocol === PROTOCOL_ACP;
|
||||||
try {
|
try {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
display_name: displayName.trim(),
|
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(),
|
command: command.trim(),
|
||||||
mcp_strategy: mcpStrategy || undefined,
|
mcp_strategy: acp ? undefined : mcpStrategy || undefined,
|
||||||
|
protocol: acp ? PROTOCOL_ACP : undefined,
|
||||||
});
|
});
|
||||||
reset();
|
reset();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -98,23 +116,139 @@ function useDialogHandlers({
|
|||||||
return { handleSubmit, handleOpenChange };
|
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) {
|
export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
const [mcpStrategy, setMcpStrategy] = useState("");
|
const [mcpStrategy, setMcpStrategy] = useState("");
|
||||||
|
const [protocol, setProtocol] = useState(PROTOCOL_TERMINAL);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const acp = protocol === PROTOCOL_ACP;
|
||||||
|
|
||||||
// Only fetch the strategy list while the dialog is open.
|
// Only fetch the strategy list while the dialog is open on the protocol that
|
||||||
const strategies = useMCPStrategies(open);
|
// can use one.
|
||||||
|
const strategies = useMCPStrategies(open && !acp);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setDisplayName("");
|
setDisplayName("");
|
||||||
setModel("");
|
setModel("");
|
||||||
setCommand("");
|
setCommand("");
|
||||||
setMcpStrategy("");
|
setMcpStrategy("");
|
||||||
|
setProtocol(PROTOCOL_TERMINAL);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -124,6 +258,7 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
|
|||||||
model,
|
model,
|
||||||
command,
|
command,
|
||||||
mcpStrategy,
|
mcpStrategy,
|
||||||
|
protocol,
|
||||||
setError,
|
setError,
|
||||||
setLoading,
|
setLoading,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -143,43 +278,22 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
|
|||||||
<Label htmlFor="tui-display-name">{t("agents:displayName")}</Label>
|
<Label htmlFor="tui-display-name">{t("agents:displayName")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="tui-display-name"
|
id="tui-display-name"
|
||||||
placeholder={t("agents:exampleValue", { example: "superclaude" })}
|
placeholder={t("agents:exampleValue", { example: "superagent" })}
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<AgentProtocolSelect value={protocol} onChange={setProtocol} />
|
||||||
<Label htmlFor="tui-model">{t("agents:model")}</Label>
|
{!acp && <TerminalModelField value={model} onChange={setModel} />}
|
||||||
<Input
|
<CommandField acp={acp} value={command} onChange={setCommand} />
|
||||||
id="tui-model"
|
{!acp && (
|
||||||
placeholder={t("agents:exampleValue", { example: "best" })}
|
<MCPStrategySelect
|
||||||
value={model}
|
id="tui-mcp-strategy"
|
||||||
onChange={(e) => setModel(e.target.value)}
|
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>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) {
|
|||||||
// Gated: this card is rendered once per agent on the index, and hooks run
|
// 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
|
// before the early return below, so an unconditional fetch would issue one
|
||||||
// identical request per built-in agent as well.
|
// 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 strategies = useMCPStrategies(isCustomTUIAgent);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
if (!agent?.tui_config) return null;
|
if (!isCustomTUIAgent || !agent?.tui_config) return null;
|
||||||
|
|
||||||
const handleChange = async (strategy: string) => {
|
const handleChange = async (strategy: string) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|||||||
@@ -640,6 +640,15 @@ export class ApiClient {
|
|||||||
return this.request("GET", "/api/v1/agents/available");
|
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> {
|
async deleteAgentProfile(profileId: string, force?: boolean): Promise<void> {
|
||||||
const qs = force ? "?force=true" : "";
|
const qs = force ? "?force=true" : "";
|
||||||
await this.request("DELETE", `/api/v1/agent-profiles/${profileId}${qs}`);
|
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.goto("/settings/agents");
|
||||||
await testPage.getByTestId("new-agent-button").click();
|
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();
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
const trigger = dialog.getByTestId("mcp-strategy-select");
|
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.goto("/settings/agents");
|
||||||
await testPage.getByTestId("new-agent-button").tap();
|
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();
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
const trigger = dialog.getByTestId("mcp-strategy-select");
|
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
|
// 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
|
// verbatim, all interpolated as values so the pseudo-locale cannot turn them
|
||||||
// into dead pointers: the `{prompt}` and `{{model}}` substitution tokens, the
|
// 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`,
|
// `mcpServers` JSON key, the MCP server product names (`Playwright MCP`,
|
||||||
// `Chrome DevTools MCP`, `Context7 MCP`, `GitHub MCP`) and the Kandev MCP tool
|
// `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
|
// list, and the whole of the assembled command preview. Wire values stay wire
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export async function createCustomTUIAgent(
|
|||||||
command: string;
|
command: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
mcp_strategy?: string;
|
mcp_strategy?: string;
|
||||||
|
protocol?: string;
|
||||||
},
|
},
|
||||||
options?: ApiRequestOptions,
|
options?: ApiRequestOptions,
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export type {
|
|||||||
import type { AgentProfile } from "./agent-profile";
|
import type { AgentProfile } from "./agent-profile";
|
||||||
import type { BackendMessage } from "./backend-message";
|
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 = {
|
export type TUIConfig = {
|
||||||
command: string;
|
command: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
@@ -33,6 +40,13 @@ export type TUIConfig = {
|
|||||||
* list here, or a strategy added in Go silently stops being selectable.
|
* list here, or a strategy added in Go silently stops being selectable.
|
||||||
*/
|
*/
|
||||||
mcp_strategy?: string;
|
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. */
|
/** One selectable MCP injection mechanism, served by the backend. */
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"actionCannotBeUndone": "This action cannot be undone.",
|
"actionCannotBeUndone": "This action cannot be undone.",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"addProfile": "Add profile",
|
"addProfile": "Add profile",
|
||||||
"addTuiAgent": "Add TUI Agent",
|
"addTuiAgent": "Add custom agent",
|
||||||
"addTuiAgentDescription": "Register a CLI tool as a TUI agent. It will appear in the agent list with a default passthrough profile.",
|
"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",
|
"agentCliFlags": "Agent CLI flags",
|
||||||
"agentCliFlagsDescription": "Flags passed to the agent CLI on launch. Only enabled entries are applied.",
|
"agentCliFlagsDescription": "Flags passed to the agent CLI on launch. Only enabled entries are applied.",
|
||||||
"agentInstallAlreadyInProgress": "Agent installation is already in progress.",
|
"agentInstallAlreadyInProgress": "Agent installation is already in progress.",
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
"agentNotFound": "Agent not found.",
|
"agentNotFound": "Agent not found.",
|
||||||
"agentProfileSettings": "{{name}} profile settings",
|
"agentProfileSettings": "{{name}} profile settings",
|
||||||
"agentProfilesTitle": "{{name}} Profiles",
|
"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",
|
"authRequiredOpenLoginTerminal": "Authentication required - click to open login terminal",
|
||||||
"authRequiredOpenShell": "Authentication required - click to open a shell and sign in",
|
"authRequiredOpenShell": "Authentication required - click to open a shell and sign in",
|
||||||
"authenticationRequired": "Authentication required",
|
"authenticationRequired": "Authentication required",
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
"agentNotFound": "エージェントが見つかりません。",
|
"agentNotFound": "エージェントが見つかりません。",
|
||||||
"agentProfileSettings": "{{name}} のプロファイル設定",
|
"agentProfileSettings": "{{name}} のプロファイル設定",
|
||||||
"agentProfilesTitle": "{{name}} のプロファイル",
|
"agentProfilesTitle": "{{name}} のプロファイル",
|
||||||
|
"agentProtocol": "プロトコル",
|
||||||
|
"agentProtocolAcp": "ACP",
|
||||||
|
"agentProtocolAcpHelp": "Kandev が Agent Client Protocol 経由でコマンドを制御します。構造化されたチャット、ツール呼び出し、モデル、モードを利用できます。",
|
||||||
|
"agentProtocolHelp": "コマンドが標準入出力で ACP サーバーを起動する場合にのみ ACP を選んでください。例: {{example}}。",
|
||||||
|
"agentProtocolTerminal": "ターミナル",
|
||||||
|
"agentProtocolTerminalHelp": "Kandev はコマンドをターミナルタブで実行します。構造化されたチャット、ツール呼び出し、モデル一覧はありません。",
|
||||||
"authRequiredOpenLoginTerminal": "認証が必要です。クリックしてログイン用ターミナルを開く",
|
"authRequiredOpenLoginTerminal": "認証が必要です。クリックしてログイン用ターミナルを開く",
|
||||||
"authRequiredOpenShell": "認証が必要です。クリックしてシェルを開き、サインインしてください",
|
"authRequiredOpenShell": "認証が必要です。クリックしてシェルを開き、サインインしてください",
|
||||||
"authenticationRequired": "認証が必要です",
|
"authenticationRequired": "認証が必要です",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"actionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
"actionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||||
"add": "Àďď",
|
"add": "Àďď",
|
||||||
"addProfile": "Àďď ƥŕōƒĩĺē",
|
"addProfile": "Àďď ƥŕōƒĩĺē",
|
||||||
"addTuiAgent": "Àďď ŢŨĨ Àĝēńţ",
|
"addTuiAgent": "Àďď ćũśţōḿ àĝēńţ",
|
||||||
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ţōōĺ àś à ŢŨĨ àĝēńţ. Ĩţ ŵĩĺĺ àƥƥēàŕ ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥàśśţĥŕōũĝĥ ƥŕōƒĩĺē.",
|
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ŷōũ àĺŕēàďŷ ĥàvē. Ĩţ àƥƥēàŕś ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥŕōƒĩĺē ĩń ţĥē ƥŕōţōćōĺ ŷōũ ƥĩćķ.",
|
||||||
"agentCliFlags": "Àĝēńţ ĆĹĨ ƒĺàĝś",
|
"agentCliFlags": "Àĝēńţ ĆĹĨ ƒĺàĝś",
|
||||||
"agentCliFlagsDescription": "Ƒĺàĝś ƥàśśēď ţō ţĥē àĝēńţ ĆĹĨ ōń ĺàũńćĥ. Ōńĺŷ ēńàƀĺēď ēńţŕĩēś àŕē àƥƥĺĩēď.",
|
"agentCliFlagsDescription": "Ƒĺàĝś ƥàśśēď ţō ţĥē àĝēńţ ĆĹĨ ōń ĺàũńćĥ. Ōńĺŷ ēńàƀĺēď ēńţŕĩēś àŕē àƥƥĺĩēď.",
|
||||||
"agentInstallAlreadyInProgress": "Àĝēńţ ĩńśţàĺĺàţĩōń ĩś àĺŕēàďŷ ĩń ƥŕōĝŕēśś.",
|
"agentInstallAlreadyInProgress": "Àĝēńţ ĩńśţàĺĺàţĩōń ĩś àĺŕēàďŷ ĩń ƥŕōĝŕēśś.",
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
"agentNotFound": "Àĝēńţ ńōţ ƒōũńď.",
|
"agentNotFound": "Àĝēńţ ńōţ ƒōũńď.",
|
||||||
"agentProfileSettings": "{{name}} ƥŕōƒĩĺē śēţţĩńĝś",
|
"agentProfileSettings": "{{name}} ƥŕōƒĩĺē śēţţĩńĝś",
|
||||||
"agentProfilesTitle": "{{name}} Ƥŕōƒĩĺēś",
|
"agentProfilesTitle": "{{name}} Ƥŕōƒĩĺēś",
|
||||||
|
"agentProtocol": "Ƥŕōţōćōĺ",
|
||||||
|
"agentProtocolAcp": "ÀĆƤ",
|
||||||
|
"agentProtocolAcpHelp": "Ķàńďēv ďŕĩvēś ţĥē ćōḿḿàńď ōvēŕ ţĥē Àĝēńţ Ćĺĩēńţ Ƥŕōţōćōĺ. Śţŕũćţũŕēď ćĥàţ, ţōōĺ ćàĺĺś, ḿōďēĺś, àńď ḿōďēś.",
|
||||||
|
"agentProtocolHelp": "Ćĥōōśē ÀĆƤ ōńĺŷ ŵĥēń ţĥē ćōḿḿàńď śţàŕţś àń ÀĆƤ śēŕvēŕ ōń śţàńďàŕď ĩńƥũţ àńď ōũţƥũţ, ƒōŕ ēxàḿƥĺē {{example}}.",
|
||||||
|
"agentProtocolTerminal": "Ţēŕḿĩńàĺ",
|
||||||
|
"agentProtocolTerminalHelp": "Ķàńďēv ŕũńś ţĥē ćōḿḿàńď ĩń à ţēŕḿĩńàĺ ţàƀ. Ńō śţŕũćţũŕēď ćĥàţ, ţōōĺ ćàĺĺś, ōŕ ḿōďēĺ ĺĩśţ.",
|
||||||
"authRequiredOpenLoginTerminal": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń ĺōĝĩń ţēŕḿĩńàĺ",
|
"authRequiredOpenLoginTerminal": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń ĺōĝĩń ţēŕḿĩńàĺ",
|
||||||
"authRequiredOpenShell": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń à śĥēĺĺ àńď śĩĝń ĩń",
|
"authRequiredOpenShell": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń à śĥēĺĺ àńď śĩĝń ĩń",
|
||||||
"authenticationRequired": "Àũţĥēńţĩćàţĩōń ŕē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.",
|
"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.",
|
"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.",
|
"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.",
|
"actionCannotBeUndone": "Esta ação não pode ser anulada.",
|
||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"addProfile": "Adicionar perfil",
|
"addProfile": "Adicionar perfil",
|
||||||
"addTuiAgent": "Adicionar agente TUI",
|
"addTuiAgent": "Adicionar agente personalizado",
|
||||||
"addTuiAgentDescription": "Registe uma ferramenta de CLI como agente TUI. Aparecerá na lista de agentes com um perfil de passagem direta pré-definido.",
|
"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",
|
"agentCliFlags": "Flags da CLI do agente",
|
||||||
"agentCliFlagsDescription": "Flags passadas à CLI do agente no arranque. Só as entradas ativadas são aplicadas.",
|
"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.",
|
"agentInstallAlreadyInProgress": "Já está em curso uma instalação do agente.",
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
"agentNotFound": "Agente não encontrado.",
|
"agentNotFound": "Agente não encontrado.",
|
||||||
"agentProfileSettings": "Definições do perfil {{name}}",
|
"agentProfileSettings": "Definições do perfil {{name}}",
|
||||||
"agentProfilesTitle": "Perfis de {{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",
|
"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",
|
"authRequiredOpenShell": "É necessária autenticação - clique para abrir uma shell e iniciar sessão",
|
||||||
"authenticationRequired": "É necessária autenticação",
|
"authenticationRequired": "É necessária autenticação",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"actionCannotBeUndone": "此操作无法撤销。",
|
"actionCannotBeUndone": "此操作无法撤销。",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"addProfile": "添加配置",
|
"addProfile": "添加配置",
|
||||||
"addTuiAgent": "添加 TUI 智能体",
|
"addTuiAgent": "添加自定义代理",
|
||||||
"addTuiAgentDescription": "将 CLI 工具注册为 TUI 智能体。它会以默认透传配置出现在智能体列表中。",
|
"addTuiAgentDescription": "注册你已安装的 CLI。它会出现在代理列表中,并带有一个使用你所选协议的默认配置。",
|
||||||
"agentCliFlags": "智能体 CLI 标志",
|
"agentCliFlags": "智能体 CLI 标志",
|
||||||
"agentCliFlagsDescription": "启动时智能体 CLI 接收的标志。仅应用已启用的项。",
|
"agentCliFlagsDescription": "启动时智能体 CLI 接收的标志。仅应用已启用的项。",
|
||||||
"agentInstallAlreadyInProgress": "智能体安装已在进行中。",
|
"agentInstallAlreadyInProgress": "智能体安装已在进行中。",
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
"agentNotFound": "未找到智能体。",
|
"agentNotFound": "未找到智能体。",
|
||||||
"agentProfileSettings": "{{name}} 智能体配置",
|
"agentProfileSettings": "{{name}} 智能体配置",
|
||||||
"agentProfilesTitle": "{{name}} 配置",
|
"agentProfilesTitle": "{{name}} 配置",
|
||||||
|
"agentProtocol": "协议",
|
||||||
|
"agentProtocolAcp": "ACP",
|
||||||
|
"agentProtocolAcpHelp": "Kandev 通过 Agent Client Protocol 驱动该命令。支持结构化对话、工具调用、模型和模式。",
|
||||||
|
"agentProtocolHelp": "仅当该命令在标准输入输出上启动 ACP 服务器时才选择 ACP,例如 {{example}}。",
|
||||||
|
"agentProtocolTerminal": "终端",
|
||||||
|
"agentProtocolTerminalHelp": "Kandev 在终端标签页中运行该命令。没有结构化对话、工具调用或模型列表。",
|
||||||
"authRequiredOpenLoginTerminal": "需要身份验证;点击打开登录终端",
|
"authRequiredOpenLoginTerminal": "需要身份验证;点击打开登录终端",
|
||||||
"authRequiredOpenShell": "需要身份验证;点击打开 shell 并登录",
|
"authRequiredOpenShell": "需要身份验证;点击打开 shell 并登录",
|
||||||
"authenticationRequired": "需要身份验证",
|
"authenticationRequired": "需要身份验证",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"actionCannotBeUndone": "此操作無法撤銷。",
|
"actionCannotBeUndone": "此操作無法撤銷。",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"addProfile": "新增設定",
|
"addProfile": "新增設定",
|
||||||
"addTuiAgent": "新增 TUI 代理程式",
|
"addTuiAgent": "新增自訂代理",
|
||||||
"addTuiAgentDescription": "將 CLI 工具註冊為 TUI 代理程式。它會以預設透傳設定出現在代理程式列表中。",
|
"addTuiAgentDescription": "註冊你已經安裝嘅 CLI。佢會喺代理清單入面出現,並帶有一個使用你所揀協定嘅預設設定檔。",
|
||||||
"agentCliFlags": "代理程式 CLI 旗標",
|
"agentCliFlags": "代理程式 CLI 旗標",
|
||||||
"agentCliFlagsDescription": "啓動時代理程式 CLI 接收的旗標。僅應用已啓用的項。",
|
"agentCliFlagsDescription": "啓動時代理程式 CLI 接收的旗標。僅應用已啓用的項。",
|
||||||
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
|
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
"agentNotFound": "未找到代理程式。",
|
"agentNotFound": "未找到代理程式。",
|
||||||
"agentProfileSettings": "{{name}} 代理程式設定檔",
|
"agentProfileSettings": "{{name}} 代理程式設定檔",
|
||||||
"agentProfilesTitle": "{{name}} 設定",
|
"agentProfilesTitle": "{{name}} 設定",
|
||||||
|
"agentProtocol": "協定",
|
||||||
|
"agentProtocolAcp": "ACP",
|
||||||
|
"agentProtocolAcpHelp": "Kandev 會透過 Agent Client Protocol 驅動該指令。有結構化對話、工具呼叫、模型同模式。",
|
||||||
|
"agentProtocolHelp": "只有當該指令喺標準輸入輸出上啟動 ACP 伺服器時先揀 ACP,例如 {{example}}。",
|
||||||
|
"agentProtocolTerminal": "終端機",
|
||||||
|
"agentProtocolTerminalHelp": "Kandev 會喺終端機分頁執行該指令。冇結構化對話、工具呼叫或模型清單。",
|
||||||
"authRequiredOpenLoginTerminal": "需要身份驗證;點擊開啟登入終端",
|
"authRequiredOpenLoginTerminal": "需要身份驗證;點擊開啟登入終端",
|
||||||
"authRequiredOpenShell": "需要身份驗證;點擊開啟 shell 並登入",
|
"authRequiredOpenShell": "需要身份驗證;點擊開啟 shell 並登入",
|
||||||
"authenticationRequired": "需要身份驗證",
|
"authenticationRequired": "需要身份驗證",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"actionCannotBeUndone": "此操作無法撤銷。",
|
"actionCannotBeUndone": "此操作無法撤銷。",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"addProfile": "新增設定",
|
"addProfile": "新增設定",
|
||||||
"addTuiAgent": "新增 TUI 代理程式",
|
"addTuiAgent": "新增自訂代理程式",
|
||||||
"addTuiAgentDescription": "將 CLI 工具註冊為 TUI 代理程式。它會以預設透傳設定出現在代理程式清單中。",
|
"addTuiAgentDescription": "註冊你已安裝的 CLI。它會出現在代理程式清單中,並帶有一個使用你所選通訊協定的預設設定檔。",
|
||||||
"agentCliFlags": "代理程式 CLI 旗標",
|
"agentCliFlags": "代理程式 CLI 旗標",
|
||||||
"agentCliFlagsDescription": "啟動時代理程式 CLI 接收的旗標。僅應用已啟用的項。",
|
"agentCliFlagsDescription": "啟動時代理程式 CLI 接收的旗標。僅應用已啟用的項。",
|
||||||
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
|
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
"agentNotFound": "未找到代理程式。",
|
"agentNotFound": "未找到代理程式。",
|
||||||
"agentProfileSettings": "{{name}} 代理程式設定檔",
|
"agentProfileSettings": "{{name}} 代理程式設定檔",
|
||||||
"agentProfilesTitle": "{{name}} 設定",
|
"agentProfilesTitle": "{{name}} 設定",
|
||||||
|
"agentProtocol": "通訊協定",
|
||||||
|
"agentProtocolAcp": "ACP",
|
||||||
|
"agentProtocolAcpHelp": "Kandev 會透過 Agent Client Protocol 驅動該指令。具備結構化對話、工具呼叫、模型與模式。",
|
||||||
|
"agentProtocolHelp": "只有當該指令會在標準輸入輸出上啟動 ACP 伺服器時才選擇 ACP,例如 {{example}}。",
|
||||||
|
"agentProtocolTerminal": "終端機",
|
||||||
|
"agentProtocolTerminalHelp": "Kandev 會在終端機分頁執行該指令。沒有結構化對話、工具呼叫或模型清單。",
|
||||||
"authRequiredOpenLoginTerminal": "需要身分驗證;點選開啟登入終端",
|
"authRequiredOpenLoginTerminal": "需要身分驗證;點選開啟登入終端",
|
||||||
"authRequiredOpenShell": "需要身分驗證;點選開啟 shell 並登入",
|
"authRequiredOpenShell": "需要身分驗證;點選開啟 shell 並登入",
|
||||||
"authenticationRequired": "需要身分驗證",
|
"authenticationRequired": "需要身分驗證",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-22
|
||||||
|
status: in_progress
|
||||||
|
requirements:
|
||||||
|
- REQ-AGENTS-CUSTOM-ACP-001
|
||||||
|
system_design:
|
||||||
|
- ../../specs/agents/system-design/custom-acp-agents.md
|
||||||
|
legacy_specs: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation Plan: Operator-Registered ACP Agents
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Let an operator register a CLI that speaks ACP and have Kandev drive it as a structured agent, and stop
|
||||||
|
the discovery sweep from reporting an agent list captured at boot. One work order owns both, because
|
||||||
|
the protocol change is not observable without the discovery change: a newly created ACP agent would
|
||||||
|
stay absent from Installed Agents until the backend restarted.
|
||||||
|
|
||||||
|
## Work orders
|
||||||
|
|
||||||
|
| Wave | Work order | Owns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | [Task 01: Operator-registered ACP agents](task-01-operator-registered-acp-agents.md) | Live discovery, the protocol field, `CustomACPAgent`, the probe exception, and the dialog |
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
A single sequential work order. Splitting the discovery fix into its own wave was considered and
|
||||||
|
rejected: it would leave the ACP wave unable to verify its own acceptance criteria, since every
|
||||||
|
create/delete assertion depends on the live sweep.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Built-in agent declaration, detection, and launch are untouched.
|
||||||
|
- Renaming `tui_config` or `/api/v1/agents/tui`. The stored column and the route keep their names; a
|
||||||
|
migration and an API break to correct a name no operator sees is not a trade this plan makes.
|
||||||
|
- A login command for an operator-registered agent. A CLI whose ACP mode needs an interactive sign-in
|
||||||
|
must be signed in from a terminal; built-ins declare `LoginCommand()` and custom definitions have no
|
||||||
|
field for it. Recorded here because the gap is real and adjacent, not because this plan closes it.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
id: "01-operator-registered-acp-agents"
|
||||||
|
title: "Let an operator-registered agent declare and run ACP"
|
||||||
|
status: in_progress
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
plan: "plan.md"
|
||||||
|
requirements:
|
||||||
|
- REQ-AGENTS-CUSTOM-ACP-001
|
||||||
|
acceptance_criteria:
|
||||||
|
- AC-AGENTS-CUSTOM-ACP-001.1
|
||||||
|
- AC-AGENTS-CUSTOM-ACP-001.2
|
||||||
|
- AC-AGENTS-CUSTOM-ACP-001.3
|
||||||
|
- AC-AGENTS-CUSTOM-ACP-001.4
|
||||||
|
- AC-AGENTS-CUSTOM-ACP-001.5
|
||||||
|
- AC-AGENTS-CUSTOM-ACP-001.6
|
||||||
|
- AC-AGENTS-CUSTOM-ACP-001.7
|
||||||
|
system_design:
|
||||||
|
- ../../specs/agents/system-design/custom-acp-agents.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 01: Operator-registered ACP agents
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make the discovery sweep read the live agent registry, add a protocol to the operator-registered
|
||||||
|
definition, and let the capability probe spawn the command that definition names.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- `internal/agent/discovery`: resolve agents per sweep, generation-fence the result cache, drop the
|
||||||
|
dead `KnownAgent`/`Definitions()` capture.
|
||||||
|
- `internal/agent/settings/controller`: invalidate after each registry mutation; persist the protocol;
|
||||||
|
seed an ACP profile as non-passthrough and probe it; share one spec builder.
|
||||||
|
- `internal/agent/agents`: add `CustomACPAgent`; mark its command operator-defined.
|
||||||
|
- `internal/agent/registry`: build the agent the protocol names; reject unknown protocols and an
|
||||||
|
ACP-plus-strategy pair.
|
||||||
|
- `internal/agentctl/server/utility`: `resolveSpawnCommand` accepts an operator-defined command.
|
||||||
|
- `apps/web`: protocol selector; hide the model and MCP fields for ACP; stop calling the dialog a TUI
|
||||||
|
agent.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.1** Creating an agent makes it appear in Installed Agents and deleting it
|
||||||
|
makes it disappear, with no backend restart and without Rescan re-detecting the deleted binary.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.2** A membership change is visible before the cache TTL, and a sweep that
|
||||||
|
started before the invalidation does not publish its results.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.3** A definition with no protocol still registers a `*TUIAgent` and is
|
||||||
|
still classified passthrough-only.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.4** An ACP definition registers an agent with `Runtime().Protocol = ACP`
|
||||||
|
that implements `InferenceAgent`, is not passthrough-only, and offers no passthrough config.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.5** Creating an ACP agent seeds one profile with `CLIPassthrough = false`
|
||||||
|
and an empty model, and starts a capability probe for that agent only.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.6** An unknown protocol and an ACP-plus-MCP-strategy pair are both
|
||||||
|
refused, at creation and on the MCP-strategy route, with no row written.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.7** The probe spawns an operator-defined command and still refuses an
|
||||||
|
unlisted command that is not operator-defined.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- RED/GREEN Go: `internal/agent/discovery`, `internal/agent/registry`, `internal/agent/agents`,
|
||||||
|
`internal/agent/settings/controller`, `internal/agentctl/server/utility`.
|
||||||
|
- Web: `add-tui-agent-dialog.test.tsx`, `custom-tui-mcp-mount.test.tsx`.
|
||||||
|
- E2E: `e2e/tests/settings/custom-acp-agent.spec.ts`.
|
||||||
|
- `make lint-backend`, web `typecheck`, web `i18n:check`.
|
||||||
|
|
||||||
|
## Not verified
|
||||||
|
|
||||||
|
No real ACP session has completed end to end. The CLI used during development fails `session/new`
|
||||||
|
against its own model-catalogue service in every mode, reproduced outside Kandev with `acpdbg` and with
|
||||||
|
a hand-written JSON-RPC client, so the spawn, `initialize`, `authenticate`, and `session/new` path is
|
||||||
|
confirmed correct but the last mile waits on that service.
|
||||||
@@ -9,7 +9,7 @@ Choose the smallest integration that matches the CLI:
|
|||||||
|
|
||||||
| Need | Path |
|
| Need | Path |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Use a local interactive CLI without changing Kandev source | Add a custom TUI agent in Settings |
|
| Use a local CLI without changing Kandev source | Add a custom agent in Settings |
|
||||||
| Ship a passthrough-only CLI as a built-in | Add a declarative `TUIAgent` and registry entry |
|
| Ship a passthrough-only CLI as a built-in | Add a declarative `TUIAgent` and registry entry |
|
||||||
| Show structured chat, tool calls, modes, and resume | Implement a built-in ACP agent |
|
| Show structured chat, tool calls, modes, and resume | Implement a built-in ACP agent |
|
||||||
|
|
||||||
@@ -17,26 +17,29 @@ ACP, REST, and MCP are different boundaries. ACP is the only structured agent pr
|
|||||||
|
|
||||||
## Quick path
|
## Quick path
|
||||||
|
|
||||||
1. Use **Add TUI Agent** for a local terminal-only CLI.
|
1. Use **Add custom agent** for a local CLI, and pick its protocol there.
|
||||||
2. Add a built-in `TUIAgent` only when every Kandev install needs it.
|
2. Add a built-in `TUIAgent` only when every Kandev install needs it.
|
||||||
3. Use a full ACP integration for structured chat, tools, models, modes, or resume.
|
3. Use a full ACP integration for structured chat, tools, models, modes, or resume.
|
||||||
4. Validate the path you chose:
|
4. Validate the path you chose:
|
||||||
- local TUI: installation discovery and exact command-token construction;
|
- local TUI: installation discovery and exact command-token construction;
|
||||||
- built-in or ACP: declared permissions, credentials, resume, and MCP delivery.
|
- built-in or ACP: declared permissions, credentials, resume, and MCP delivery.
|
||||||
|
|
||||||
## Register a local TUI agent
|
## Register a local agent
|
||||||
|
|
||||||
Open **Settings → Agents**, choose **Add TUI Agent**, and provide:
|
Open **Settings → Agents**, choose **Add custom agent**, and provide:
|
||||||
|
|
||||||
- a display name;
|
- a display name;
|
||||||
- an optional model/profile label;
|
- a protocol: **Terminal** or **ACP**;
|
||||||
- a command available on the executor's `PATH`.
|
- a command available on the executor's `PATH`;
|
||||||
|
- for Terminal only, an optional model/profile label and an MCP strategy.
|
||||||
|
|
||||||
Use `{{model}}` in the command to insert the optional model value. Kandev derives a stable slug from the display name, persists the definition, registers it at startup, and creates a default passthrough profile.
|
Kandev derives a stable slug from the display name, persists the definition, registers it at startup, and creates a default profile in the selected protocol's mode. The command is split into an executable and arguments on whitespace; it is not evaluated by a shell, and shell quoting/expansion is not supported.
|
||||||
|
|
||||||
This path is terminal passthrough only. It does not provide structured chat messages, tool-call visibility, ACP capability probing, or Kandev MCP injection. The command is split into an executable and arguments on whitespace; it is not evaluated by a shell, and shell quoting/expansion is not supported.
|
**Terminal** runs the command as a raw CLI under a PTY. Use `{{model}}` in the command to insert the optional model value. There are no structured chat messages, no tool-call visibility, and no capability probing; Kandev's MCP server reaches the CLI only through the selected MCP strategy, which writes the config file shape that CLI reads.
|
||||||
|
|
||||||
Use a source integration when the agent must ship for every installation, needs logos/discovery/auth/permissions, needs safe structured argument building, or supports ACP.
|
**ACP** drives the command over the Agent Client Protocol on standard input and output, so the agent gets structured chat, tool calls, and the models and modes learned by the host-utility capability probe. Give the command that starts the ACP server, typically behind a flag (`my-agent --acp`). Resolved MCP servers travel in `session/new`, so no MCP strategy applies and none is offered. The protocol is not probed from the command: a CLI's ACP server command is not the command that renders its terminal, so choosing wrong would launch a JSON-RPC server into a terminal tab.
|
||||||
|
|
||||||
|
Use a source integration when the agent must ship for every installation, needs logos/auth/permissions/install scripts, needs safe structured argument building, or needs a bridge because the CLI has no ACP server of its own.
|
||||||
|
|
||||||
## Ship a built-in passthrough agent
|
## Ship a built-in passthrough agent
|
||||||
|
|
||||||
|
|||||||
@@ -187,9 +187,13 @@ uses the expected npm installation and configured registry. Run `npm config get
|
|||||||
|
|
||||||
### Add a custom terminal agent
|
### Add a custom terminal agent
|
||||||
|
|
||||||
Use **Settings > Agents > Add TUI Agent** for a CLI that Kandev does not register. Enter a display name, command, and optional model label. `{{model}}` in the command is replaced by the selected model value, then the entire command is split on whitespace with Go's `strings.Fields`.
|
Use **Settings > Agents > Add custom agent** for a CLI that Kandev does not register. Enter a display name, a protocol, and a command. The entire command is split on whitespace with Go's `strings.Fields`.
|
||||||
|
|
||||||
That parser is not a shell and is not quote-aware: quotes and backslashes do not preserve a path or model containing spaces as one argument. Custom TUI agents always use terminal passthrough. They do not gain ACP features such as structured permission prompts, model discovery, modes, or session configuration merely by being added. Test the exact resulting argument split before assigning it to work.
|
That parser is not a shell and is not quote-aware: quotes and backslashes do not preserve a path or model containing spaces as one argument. Test the exact resulting argument split before assigning it to work.
|
||||||
|
|
||||||
|
A **Terminal** agent runs as passthrough. It also takes an optional model label, and `{{model}}` in the command is replaced by that value. It gains no ACP features: no structured permission prompts, no model discovery, no modes, no session configuration.
|
||||||
|
|
||||||
|
An **ACP** agent is driven over the Agent Client Protocol instead, so it does get structured chat, tool calls, permission prompts, and probed models and modes. Enter the command that starts the CLI's ACP server, usually behind a flag such as `--acp`. Its model comes from the capability probe rather than the command, and its MCP servers travel in `session/new`, so neither the model label nor the MCP strategy is offered.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -445,7 +449,7 @@ Workspace automation selectors do not offer passthrough agent profiles. Local ex
|
|||||||
|
|
||||||
ACP sessions can expose typed messages, tool updates, permission requests, models, modes, dynamic configuration, todos, usage, and resume metadata. Each capability depends on the agent's actual ACP implementation. ACP-only profile settings, including command prefixes and structured configuration, do not add those capabilities to a terminal-passthrough CLI.
|
ACP sessions can expose typed messages, tool updates, permission requests, models, modes, dynamic configuration, todos, usage, and resume metadata. Each capability depends on the agent's actual ACP implementation. ACP-only profile settings, including command prefixes and structured configuration, do not add those capabilities to a terminal-passthrough CLI.
|
||||||
|
|
||||||
Passthrough preserves the CLI's native PTY interface. It is useful when the native terminal has features that ACP does not expose, but Kandev cannot manufacture structured capabilities that are absent. Custom TUI profiles are locked to passthrough. Profile-specific MCP injection also varies by CLI; verify the command preview and the MCP section before depending on it.
|
Terminal custom profiles use passthrough and preserve the CLI's native PTY interface. This is useful when the native terminal has features that ACP does not expose, but Kandev cannot manufacture structured capabilities that are absent. Custom ACP profiles use structured ACP sessions. Profile-specific MCP injection also varies by CLI; verify the command preview and the MCP section before depending on it.
|
||||||
|
|
||||||
> **MCP credential exposure:** MCP headers and environment values are stored in profile configuration. Codex may place them in process arguments, and Cursor or Pi may leave them in project files after teardown. Use short-lived, narrowly scoped credentials and review persisted files.
|
> **MCP credential exposure:** MCP headers and environment values are stored in profile configuration. Codex may place them in process arguments, and Cursor or Pi may leave them in project files after teardown. Use short-lived, narrowly scoped credentials and review persisted files.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Use the smallest seam that owns the behavior, then follow the completion checkli
|
|||||||
|
|
||||||
## Add an agent
|
## Add an agent
|
||||||
|
|
||||||
For a local passthrough-only CLI, users can choose **Settings → Agents → Add TUI Agent**. That path persists a custom definition and default profile; no source patch is required.
|
For a local CLI, users can choose **Settings → Agents → Add custom agent** and pick Terminal or ACP. That path persists a custom definition and default profile; no source patch is required.
|
||||||
|
|
||||||
To ship a built-in integration, add an `agents.Agent` implementation under `apps/backend/internal/agent/agents/` and register it in `internal/agent/registry/registry.go`. Structured agents currently need an ACP-speaking process. Optional interfaces add inference, CLI passthrough, native-binary preference, or interactive login.
|
To ship a built-in integration, add an `agents.Agent` implementation under `apps/backend/internal/agent/agents/` and register it in `internal/agent/registry/registry.go`. Structured agents currently need an ACP-speaking process. Optional interfaces add inference, CLI passthrough, native-binary preference, or interactive login.
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ Remote repository and issue/PR URLs are not added from this settings page. Use t
|
|||||||
4. Complete the provider's authentication flow. Some agents expose a dedicated login terminal; others use their own CLI outside Kandev.
|
4. Complete the provider's authentication flow. Some agents expose a dedicated login terminal; others use their own CLI outside Kandev.
|
||||||
5. Open the agent and its profile. Verify the selected model and mode, permission switches, CLI flags, environment variables, MCP configuration, and CLI-passthrough setting. Save changes.
|
5. Open the agent and its profile. Verify the selected model and mode, permission switches, CLI flags, environment variables, MCP configuration, and CLI-passthrough setting. Save changes.
|
||||||
|
|
||||||
Detected capabilities come from the installed CLI and can change after an agent upgrade or login. A model or mode shown in documentation is not guaranteed for every account. If no built-in adapter fits, **Add TUI Agent** creates a passthrough integration; passthrough has a different resume, usage, and MCP contract from an ACP-capable agent.
|
Detected capabilities come from the installed CLI and can change after an agent upgrade or login. A model or mode shown in documentation is not guaranteed for every account. If no built-in adapter fits, **Add custom agent** registers the CLI as a Terminal or an ACP agent; Terminal passthrough has a different resume, usage, and MCP contract from an ACP-capable agent.
|
||||||
|
|
||||||
For the first task on an existing repository, keep the seeded **Worktree** executor profile. It creates a separate Git checkout so concurrent Kandev tasks do not edit the same working tree. A worktree is Git isolation, not operating-system isolation. Choose **Local** only when direct edits in the selected checkout are intentional. A repository initialized from **New Task** has one empty initial commit and no project files. Creation selects a direct Local profile for a single-row task when one is available and preserves the selected executor for multiple rows. Without a direct Local profile, single-row creation remains disabled. See [Agents and profiles](agents-and-profiles.md) and [Executors](executors.md) before using Docker, SSH, Sprites, custom scripts, or shared infrastructure.
|
For the first task on an existing repository, keep the seeded **Worktree** executor profile. It creates a separate Git checkout so concurrent Kandev tasks do not edit the same working tree. A worktree is Git isolation, not operating-system isolation. Choose **Local** only when direct edits in the selected checkout are intentional. A repository initialized from **New Task** has one empty initial commit and no project files. Creation selects a direct Local profile for a single-row task when one is available and preserves the selected executor for multiple rows. Without a direct Local profile, single-row creation remains disabled. See [Agents and profiles](agents-and-profiles.md) and [Executors](executors.md) before using Docker, SSH, Sprites, custom scripts, or shared infrastructure.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
system: agents
|
||||||
|
created: 2026-09-22
|
||||||
|
updated: 2026-09-22
|
||||||
|
owners:
|
||||||
|
- jnmanso
|
||||||
|
---
|
||||||
|
# Operator-Registered Agent Requirements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
An operator can register a CLI as an agent from Settings without changing Kandev source. That
|
||||||
|
definition names the protocol Kandev drives the CLI with, and the registry, the discovery sweep, and
|
||||||
|
the capability probe all honour it for the lifetime of the backend process rather than only at boot.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### REQ-AGENTS-CUSTOM-ACP-001: Operator-Registered Agents Run In Their Declared Protocol
|
||||||
|
|
||||||
|
**Intent:** Let an operator register a CLI that speaks the Agent Client Protocol and have Kandev drive
|
||||||
|
it as a structured agent, while keeping every existing terminal definition working untouched and
|
||||||
|
keeping registry changes visible without a restart.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.1:** When an agent is registered, replaced, or unregistered after
|
||||||
|
startup, the discovery sweep shall resolve the agent list from the agent registry rather than from a
|
||||||
|
list captured at boot, so a created agent appears and a deleted agent disappears without restarting
|
||||||
|
the backend.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.2:** When a registry membership change is committed, the cached sweep
|
||||||
|
results shall be dropped so the change is observable before the cache TTL expires, and a sweep that
|
||||||
|
began before that invalidation shall not publish its superseded results.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.3:** When an operator-registered definition carries no protocol, the
|
||||||
|
system shall continue to run it as terminal passthrough, so definitions stored before the field
|
||||||
|
existed keep their behaviour with no migration.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.4:** When a definition declares the ACP protocol, the system shall
|
||||||
|
register an agent whose runtime protocol is ACP, which advertises inference so the capability probe
|
||||||
|
covers it, and which is not classified as passthrough-only.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.5:** When an ACP definition is created, the system shall seed its default
|
||||||
|
profile as non-passthrough with no model and shall start a capability probe, so the profile editor
|
||||||
|
offers the agent's probed models without a manual refresh.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.6:** When a definition names an unknown protocol, or pairs the ACP
|
||||||
|
protocol with a passthrough MCP strategy, the system shall reject it with a client error rather than
|
||||||
|
registering a downgraded agent.
|
||||||
|
- **AC-AGENTS-CUSTOM-ACP-001.7:** When the capability probe spawns an operator-registered command, it
|
||||||
|
shall accept that command even though no compiled-in literal can cover it, while a command that
|
||||||
|
claims to be built-in shall still resolve against the allow-list.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
system: agents
|
||||||
|
requirements:
|
||||||
|
- REQ-AGENTS-CUSTOM-ACP-001
|
||||||
|
created: 2026-09-22
|
||||||
|
updated: 2026-09-22
|
||||||
|
owners:
|
||||||
|
- jnmanso
|
||||||
|
---
|
||||||
|
# Operator-Registered Agent System Design
|
||||||
|
|
||||||
|
## Purpose and boundaries
|
||||||
|
|
||||||
|
This design owns the technical contract for REQ-AGENTS-CUSTOM-ACP-001: the stored shape of an
|
||||||
|
operator-registered definition, which agent type each protocol builds, and the three places that
|
||||||
|
previously assumed every such definition was terminal passthrough.
|
||||||
|
|
||||||
|
It does not change how built-in agents are declared, detected, or launched.
|
||||||
|
|
||||||
|
## Requirement mapping
|
||||||
|
|
||||||
|
| Acceptance criterion | Owner |
|
||||||
|
| --- | --- |
|
||||||
|
| AC-AGENTS-CUSTOM-ACP-001.1 | `internal/agent/discovery` |
|
||||||
|
| AC-AGENTS-CUSTOM-ACP-001.2 | `internal/agent/discovery`, `internal/agent/settings/controller` |
|
||||||
|
| AC-AGENTS-CUSTOM-ACP-001.3 | `internal/agent/registry`, `internal/agent/settings/models` |
|
||||||
|
| AC-AGENTS-CUSTOM-ACP-001.4 | `internal/agent/agents` |
|
||||||
|
| AC-AGENTS-CUSTOM-ACP-001.5 | `internal/agent/settings/controller` |
|
||||||
|
| AC-AGENTS-CUSTOM-ACP-001.6 | `internal/agent/registry`, `internal/agent/settings/handlers` |
|
||||||
|
| AC-AGENTS-CUSTOM-ACP-001.7 | `internal/agentctl/server/utility` |
|
||||||
|
|
||||||
|
## Discovery reads the registry per sweep
|
||||||
|
|
||||||
|
`discovery.Registry` holds `*registry.Registry` and resolves enabled, non-virtual agents inside
|
||||||
|
`detectAll`. The boot-time capture is removed, along with the `KnownAgent`/`Definitions()` pair that
|
||||||
|
carried it and the `IsInstalled` pass it ran at startup.
|
||||||
|
|
||||||
|
Detection results stay cached for `defaultCacheTTL`. Because a sweep can outlive an invalidation, the
|
||||||
|
cache is generation-fenced: `InvalidateCache` bumps a counter, `Detect` captures it before sweeping,
|
||||||
|
and the write at the end is skipped when the captured generation is stale. Without the fence a sweep
|
||||||
|
that started before a delete would publish the deleted agent back into the cache.
|
||||||
|
|
||||||
|
`CreateCustomTUIAgent`, `DeleteAgent`, and `SetCustomTUIAgentMCPStrategy` invalidate after their
|
||||||
|
registry mutation commits.
|
||||||
|
|
||||||
|
## Protocol lives in the stored definition
|
||||||
|
|
||||||
|
`models.TUIConfigJSON.protocol` carries `registry.CustomAgentProtocol`. The zero value is
|
||||||
|
`CustomAgentProtocolTerminal`, which is what every row written before the field existed decodes to, so
|
||||||
|
no migration runs. `CustomAgentProtocolACP` is the only other accepted value.
|
||||||
|
|
||||||
|
`registry.buildCustomAgent` switches on it:
|
||||||
|
|
||||||
|
- terminal builds an `agents.TUIAgent` with the resolved MCP strategy, exactly as before;
|
||||||
|
- ACP builds an `agents.CustomACPAgent` and rejects a non-empty MCP strategy;
|
||||||
|
- anything else returns `ErrUnknownCustomAgentProtocol`.
|
||||||
|
|
||||||
|
`controller.CustomAgentSpecFromStored` is the single spec builder shared by the MCP-strategy change and
|
||||||
|
the boot replay, so a field added to the stored config cannot reach one path and miss the other.
|
||||||
|
|
||||||
|
## CustomACPAgent is deliberately not a TUIAgent
|
||||||
|
|
||||||
|
`agents.IsPassthroughOnly` is a type assertion on `*TUIAgent`, and the host-utility probe covers only
|
||||||
|
`InferenceAgent` implementers. `CustomACPAgent` therefore implements `Agent` plus `InferenceAgent`,
|
||||||
|
reports `agent.ProtocolACP` from `Runtime()`, and deliberately does not implement `PassthroughAgent`: a
|
||||||
|
definition carries one command, and the command that starts an ACP server is not the command that
|
||||||
|
renders an interactive terminal.
|
||||||
|
|
||||||
|
`IsInstalled` reports `SupportsMCP = true` because resolved servers travel in ACP `session/new` rather
|
||||||
|
than through a config-file strategy.
|
||||||
|
|
||||||
|
## The probe accepts an operator-registered command
|
||||||
|
|
||||||
|
`acp_executor.go` resolved every spawn against `allowedProbeCommands`, a map of compiled-in literals
|
||||||
|
that exists so static analysis can follow a literal to `exec.Command`. An operator-registered command
|
||||||
|
cannot be in that map.
|
||||||
|
|
||||||
|
`resolveSpawnCommand` keeps that path for built-ins and returns the command unchanged when the agent
|
||||||
|
marked it operator-defined (`InferenceConfig.OperatorDefined`, carried to agentctl on
|
||||||
|
`InferenceConfigDTO`). The exception is bounded to a command the install operator typed in Settings:
|
||||||
|
the session path already spawns that exact string, and agentctl's piped runner already accepts an
|
||||||
|
arbitrary command from its request, so the allow-list denied the probe without denying execution.
|
||||||
Reference in New Issue
Block a user