diff --git a/.agents/skills/acp-debug/SKILL.md b/.agents/skills/acp-debug/SKILL.md new file mode 100644 index 0000000000..d6c4990e88 --- /dev/null +++ b/.agents/skills/acp-debug/SKILL.md @@ -0,0 +1,138 @@ +--- +name: acp-debug +description: Debug an ACP agent CLI by spawning it, speaking raw JSON-RPC, and capturing every frame to a JSONL file. Use when the user asks to probe an agent's capabilities, compare agents, test a prompt against an agent, inspect raw ACP wire frames, or investigate why an agent fails to initialize. +--- + +# ACP Debug + +Run a headless ACP JSON-RPC session against any registered kandev agent (or an arbitrary command), record every wire frame to a JSONL file, and summarize the handshake. Backed by the `acpdbg` binary at `apps/backend/bin/acpdbg`. + +## When to use this skill + +- "what models does auggie advertise" +- "probe claude-acp" / "probe all agents" / "run the matrix" +- "debug why copilot-acp isn't starting" +- "what does a `session/new` response actually look like for X" +- "try this prompt against auggie with mode=ask" +- "reproduce session/load for session against claude-acp" +- User mentions inspecting raw ACP wire payloads or a JSONL file from an earlier run + +## Before anything else: build the binary if missing + +```bash +test -x apps/backend/bin/acpdbg || make -C apps/backend build-acpdbg +``` + +## Sub-commands + +```text +acpdbg list # enumerate registered ACP agents +acpdbg probe # initialize + session/new + close +acpdbg probe --exec " [args...]" # probe an arbitrary binary not in the registry +acpdbg prompt --prompt "..." [--model M] [--mode M] +acpdbg session-load --session-id +acpdbg matrix # probe every ACP agent in parallel +``` + +**Shared flags** (apply to every sub-command): +- `--out DIR` — JSONL output directory (default `./acp-debug/`) +- `--file PATH` — exact JSONL path, overrides `--out` +- `--timeout DUR` — overall run timeout (default `30s`) +- `--workdir PATH` — child cwd (default: fresh `/tmp/kandev-acpdbg--*`) +- `--verbose` — mirror frames to stderr +- `--stderr` — capture child stderr into the JSONL + +## Steps + +**Create a task for each step below and mark them as completed as you go.** + +### 1. Pick the sub-command that matches the user's intent + +- "what models does X have" / "does X support modes" → `acpdbg probe X` +- "test a prompt against X" → `acpdbg prompt X --prompt "..."` +- "compare all agents" / "run the matrix" → `acpdbg matrix` +- "resume session " → `acpdbg session-load X --session-id ` +- "try this random binary" → `acpdbg probe --exec "path/to/bin --acp"` + +### 2. Run the command + +Always capture stdout — it contains the JSONL file path (and for `matrix`, the summary table and `matrix-summary.json` path). + +```bash +apps/backend/bin/acpdbg probe auggie --timeout 45s +``` + +For `matrix`, prefer `--timeout 60s` so `npx`-spawned agents have time to cold-start. + +### 3. Read the JSONL file + +The JSONL schema is: + +| `direction` | Meaning | Extra fields | +|---|---|---| +| `meta` | acpdbg-generated marker | `event` (`start` / `close`), `meta` (map with `agent`, `command`, `workdir` for start; `exit_code`, `reason` for close) | +| `sent` | Frame written to child's stdin | `frame` (JSON-RPC request or reply) | +| `received` | Frame read from child's stdout | `frame` (JSON-RPC request, response, or notification) | +| `stderr` | Child stderr line (only when `--stderr`) | `line` | + +Entries are strictly chronological. Each line is a single JSON object terminated by `\n`. + +Useful `jq` recipes: + +```bash +# Full initialize response +jq -c 'select(.direction == "received" and .frame.id == 1)' acp-debug/.jsonl + +# Full session/new response (models, modes, auth) +jq '.frame.result' acp-debug/.jsonl | head -50 + +# Just the models advertised +jq -r 'select(.direction == "received") | .frame.result.models.availableModels[]?.modelId' acp-debug/.jsonl + +# Close event (exit code + reason) +jq -c 'select(.direction == "meta" and .event == "close")' acp-debug/.jsonl + +# All agent-initiated requests we auto-replied to +jq -c 'select(.direction == "received" and .frame.method and .frame.id)' acp-debug/.jsonl +``` + +### 4. Summarize for the user + +Give a concise markdown summary: agent, protocol version, models found (with currentModelId), modes found (with currentModeId), auth methods, any errors, and the JSONL path for deeper inspection. Example: + +```text +**auggie** (protocol v1, auggie 0.20.1) +Models (11): claude-sonnet-4-6 (current), claude-opus-4-6, gpt-5-4, … +Modes (2): default (current), ask +Auth methods: (none advertised) +JSONL: acp-debug/auggie-probe-20260409-183104.jsonl +``` + +### 5. If something looks wrong, walk the frames + +Common failure modes: + +| Symptom | Likely cause | Next step | +|---|---|---| +| meta close `exit_code: 127` immediately | child binary not installed | Check `which `; suggest install command | +| meta close before any `received` frame | child crashed on startup | Re-run with `--stderr` to capture the error | +| `initialize` response has populated `authMethods` but session/new fails | auth required | Surface the auth method ids; suggest setting env var / running CLI login | +| `session/new` hangs (context deadline exceeded) | agent waiting on an unanswered agent-initiated request | Check JSONL for `received` frames with `method` + `id` that we auto-replied to with `method not found` — the agent may be retrying | +| Response has no `models` / `modes` fields | agent doesn't expose them over ACP | Not a bug — document the gap | + +Re-run with `--stderr` whenever the child exits before the handshake completes; the stderr lines land in the JSONL and usually contain the root cause. + +### 6. For `matrix`, read `matrix-summary.json` too + +```bash +jq '.' acp-debug/matrix-summary.json +``` + +One entry per agent with `status`, `models_count`, `current_model_id`, `auth_methods_count`, `duration_ms`, and `jsonl` (the full per-agent JSONL file). Useful for answering "which agents succeeded / failed / need auth" without re-reading every JSONL. + +## What this skill does NOT do + +- No interactive UI. For ad-hoc side-by-side comparison, read the `matrix-summary.json` output or build a separate visualization tool on top of the JSONL. +- No permission-request handling beyond canned replies. Agent-initiated requests (`fs/read_text_file`, `session/request_permission`, etc.) are answered with `-32601 method not found` so the session doesn't hang. If you need to exercise a real permission flow, use the full kandev backend. +- No automatic credential bootstrap. The child inherits the parent shell's env; if an auth check fails the skill reports which methods were advertised and lets the user fix their local credentials. +- No Docker / remote executor support. Standalone subprocess only — same as a manual `auggie --acp` invocation. diff --git a/.agents/skills/pr-fixup/SKILL.md b/.agents/skills/pr-fixup/SKILL.md index 9b2d65e679..888ca829b4 100644 --- a/.agents/skills/pr-fixup/SKILL.md +++ b/.agents/skills/pr-fixup/SKILL.md @@ -175,6 +175,13 @@ Mark task 6 as in_progress. Every comment must get a response — either a fix or a reply explaining why it was skipped. +**Per-thread engagement is mandatory. Do not take shortcuts:** + +- **Never post a single summary issue comment in place of individual thread replies.** A top-level summary comment leaves every inline thread unresolved and unanswered; reviewers have to hunt for your response across the diff. The only acceptable use of a summary comment is as an *addition* to per-thread replies, not a substitute. +- **Every unresolved review thread on the PR must receive a direct reply and be resolved**, even if that means 20+ thread interactions. Looping over threads programmatically is fine (and expected); batching into one summary is not. +- **Reply to the comment that started the thread**, not a random later one. Get the first-comment ID from the GraphQL `reviewThreads(first: 100) { nodes { comments(first: 1) { nodes { databaseId } } } }` query. +- **Do not mark task 6 completed until every previously-unresolved review thread is either resolved or has an explicit reason documented in a reply.** If you finish the pass and the `isResolved == false` set is still non-empty, you are not done. + **Important: issue comments vs review comments use different APIs:** - **Review comments** (inline, from `gh api repos/:owner/:repo/pulls//comments`) — reply via `/pulls//comments//replies`, react via `/pulls/comments//reactions` - **Issue comments** (conversation timeline, from `gh pr view --json comments` — e.g., CodeRabbit walkthrough) — reply by posting a new comment via `gh pr comment --body "..."`, react via `/issues/comments//reactions` diff --git a/.gitignore b/.gitignore index 7adcfd6c5b..7bc699e1e6 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,11 @@ apps/backend/bin/ apps/backend/kandev apps/backend/agentctl apps/backend/mock-agent +apps/backend/acpdbg + +# acpdbg JSONL output (raw ACP wire frames from debug runs) +apps/backend/acp-debug/ +acp-debug/ # Next.js .next/ diff --git a/Makefile b/Makefile index 87454826cc..9d6f5239b7 100644 --- a/Makefile +++ b/Makefile @@ -162,6 +162,10 @@ build-backend: @printf "$(CYAN)Building backend...$(RESET)\n" @$(MAKE) -C $(BACKEND_DIR) build +.PHONY: acpdbg +acpdbg: + @$(MAKE) -s -C $(BACKEND_DIR) acpdbg ARGS="$(ARGS)" + .PHONY: build-backend-quiet build-backend-quiet: @printf " $(DIM)Backend$(RESET)\n" diff --git a/apps/backend/Makefile b/apps/backend/Makefile index a4b6655edd..b0c9a636d6 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -25,7 +25,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-agentctl-linux build-mock-agent clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help +.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help ## Default target all: build @@ -36,8 +36,18 @@ build-mock-agent: @$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/mock-agent ./cmd/mock-agent +## Build and run acpdbg — usage: make acpdbg ARGS="probe auggie" +acpdbg: build-acpdbg + @$(BUILD_DIR)/acpdbg $(ARGS) + +## Build the acpdbg binary — raw ACP JSON-RPC debugger (see .agents/skills/acp-debug) +build-acpdbg: + @echo "Building acpdbg..." + @$(MKDIR) $(BUILD_DIR) + $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/acpdbg ./cmd/acpdbg + ## Build the unified kandev binary -build: build-agentctl build-mock-agent +build: build-agentctl build-mock-agent build-acpdbg @echo "Building $(BINARY_NAME)..." @$(MKDIR) $(BUILD_DIR) CGO_ENABLED=1 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/kandev diff --git a/apps/backend/cmd/acpdbg/README.md b/apps/backend/cmd/acpdbg/README.md new file mode 100644 index 0000000000..3b3bb485fc --- /dev/null +++ b/apps/backend/cmd/acpdbg/README.md @@ -0,0 +1,73 @@ +# acpdbg + +Headless CLI that speaks raw ACP JSON-RPC to any registered kandev agent (or an arbitrary binary) and records every wire frame to a JSONL file. Useful for answering "what models/modes does this agent advertise?", "does `session/load` work?", or "what does the prompt stream actually look like?" without spinning up the full kandev backend. + +## Build + +```bash +make acpdbg ARGS=list # from repo root +make -C apps/backend build-acpdbg # just build +``` + +Binary lands at `apps/backend/bin/acpdbg`. + +## Usage + +```bash +make acpdbg ARGS=" [flags]" +``` + +Or invoke the binary directly: `apps/backend/bin/acpdbg [flags]`. + +### Sub-commands + +| Command | Purpose | +|---|---| +| `list` | Enumerate registered ACP agents with their spawn command | +| `probe ` | `initialize` → `session/new` → close. Shows models, modes, auth methods. | +| `probe --exec " [args...]"` | Same but against an arbitrary binary not in the registry | +| `prompt --prompt "..." [--model M] [--mode M]` | Full prompt round-trip, collects text chunks from `session/update` | +| `session-load --session-id ` | Reproduce `session/load` for an existing session | +| `matrix` | Probe every registered ACP agent in parallel, write one JSONL per agent + `matrix-summary.json` | + +### Shared flags + +- `--out DIR` — JSONL output directory (default `./acp-debug/`) +- `--file PATH` — exact JSONL path, overrides `--out` +- `--timeout DUR` — overall run timeout (default `30s`; use `60s+` for `matrix` since `npx`-spawned agents are slow to cold-start) +- `--workdir PATH` — child cwd (default: fresh `/tmp/kandev-acpdbg--*`) +- `--verbose` — mirror frames to stderr as they're sent/received +- `--stderr` — capture child stderr into the JSONL (useful when an agent crashes before the handshake completes) + +## JSONL format + +One JSON object per line, chronologically ordered. `direction` is one of: + +| `direction` | Meaning | +|---|---| +| `meta` | acpdbg marker (`event: start` with agent/command/workdir, `event: close` with exit_code/reason) | +| `sent` | Frame written to child stdin | +| `received` | Frame read from child stdout | +| `stderr` | Child stderr line (only with `--stderr`) | + +### jq recipes + +```bash +# Full initialize response +jq -c 'select(.direction == "received" and .frame.id == 1)' acp-debug/.jsonl + +# Just the advertised models +jq -r 'select(.direction == "received") | .frame.result.models.availableModels[]?.modelId' acp-debug/.jsonl + +# Close event (exit code + reason) +jq -c 'select(.direction == "meta" and .event == "close")' acp-debug/.jsonl +``` + +## Design notes + +- **Agent registry is the source of truth.** `acpdbg` imports `internal/agent/registry` directly, so adding/removing agents in `internal/agent/agents/*.go` automatically updates the CLI. No parallel config file. +- **Raw JSON-RPC, not the SDK.** Uses a custom line-delimited framer (`internal/agent/acpdbg/framer.go`) so the recorded frames are authoritative wire bytes, not SDK-parsed events. +- **Env inheritance.** The child process inherits the parent shell's env and credential files — if it works in kandev it works here. Auth failures are captured in the JSONL. +- **Agent-initiated requests** (`fs/read_text_file`, `session/request_permission`, etc.) are auto-replied to with `-32601 method not found` so sessions don't hang. For real permission flows, use the full kandev backend. + +See `.agents/skills/acp-debug/SKILL.md` for the agent-facing usage guide. diff --git a/apps/backend/cmd/acpdbg/main.go b/apps/backend/cmd/acpdbg/main.go new file mode 100644 index 0000000000..23b24eda5a --- /dev/null +++ b/apps/backend/cmd/acpdbg/main.go @@ -0,0 +1,505 @@ +// Command acpdbg speaks raw ACP JSON-RPC to an agent CLI and captures every +// frame to a JSONL file. See .agents/skills/acp-debug/SKILL.md for usage. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/kandev/kandev/internal/agent/acpdbg" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/logger" +) + +const defaultTimeout = 30 * time.Second + +func main() { os.Exit(run()) } + +func run() int { + if len(os.Args) < 2 { + usage() + return 2 + } + sub := os.Args[1] + args := os.Args[2:] + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + var err error + switch sub { + case "list": + err = runList(args) + case "probe": + err = runProbe(ctx, args) + case "prompt": + err = runPrompt(ctx, args) + case "session-load": + err = runSessionLoad(ctx, args) + case "matrix": + err = runMatrix(ctx, args) + case "-h", "--help", "help": + usage() + return 0 + default: + fmt.Fprintf(os.Stderr, "acpdbg: unknown subcommand %q\n\n", sub) + usage() + return 2 + } + if err != nil { + fmt.Fprintf(os.Stderr, "acpdbg: %v\n", err) + return 1 + } + return 0 +} + +func usage() { + fmt.Fprintln(os.Stderr, `acpdbg — raw ACP JSON-RPC debugger + +Usage: + acpdbg list + acpdbg probe [flags] + acpdbg probe --exec " [args...]" [flags] + acpdbg prompt --prompt TEXT [--model M] [--mode M] [flags] + acpdbg session-load --session-id ID [flags] + acpdbg matrix [flags] + +Shared flags: + --out DIR output directory for JSONL (default ./acp-debug/) + --file PATH exact JSONL output path (overrides --out) + --timeout DUR per-run timeout (default 30s) + --workdir PATH child process cwd (default: fresh temp dir) + --verbose mirror frames to stderr + --stderr capture child stderr into the JSONL + --exec "CMD" spawn an arbitrary command instead of a registered agent + +The JSONL file records every request, response, notification, and meta +event in chronological order. See .claude/skills/acp-debug/SKILL.md for the +schema and examples.`) +} + +type sharedFlags struct { + out string + file string + timeout time.Duration + workdir string + verbose bool + stderr bool + exec string +} + +func registerShared(fs *flag.FlagSet) *sharedFlags { + f := &sharedFlags{} + fs.StringVar(&f.out, "out", "./acp-debug", "output directory for JSONL files") + fs.StringVar(&f.file, "file", "", "exact JSONL output path (overrides --out)") + fs.DurationVar(&f.timeout, "timeout", defaultTimeout, "overall run timeout") + fs.StringVar(&f.workdir, "workdir", "", "child process cwd (default: fresh temp dir)") + fs.BoolVar(&f.verbose, "verbose", false, "mirror frames to stderr") + fs.BoolVar(&f.stderr, "stderr", false, "capture child stderr into the JSONL") + fs.StringVar(&f.exec, "exec", "", "spawn an arbitrary command instead of a registered agent") + return f +} + +func buildLogger() (*logger.Logger, error) { + return logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) +} + +func loadRegistry() (*registry.Registry, error) { + log, err := buildLogger() + if err != nil { + return nil, err + } + reg, _, err := registry.Provide(log) + if err != nil { + return nil, err + } + return reg, nil +} + +// --- list --- + +func runList(_ []string) error { + reg, err := loadRegistry() + if err != nil { + return err + } + specs := acpdbg.ListACPAgents(reg) + if len(specs) == 0 { + fmt.Println("no ACP-capable agents found") + return nil + } + fmt.Printf("%-16s %-16s %s\n", "ID", "DISPLAY", "COMMAND") + for _, s := range specs { + fmt.Printf("%-16s %-16s %s\n", s.ID, s.DisplayName, strings.Join(s.Command, " ")) + } + return nil +} + +// --- probe --- + +func runProbe(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("probe", flag.ExitOnError) + shared := registerShared(fs) + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := resolveRunConfig(fs, shared, "probe") + if err != nil { + return err + } + + jsonlPath := resolveJSONLPath(shared, cfg.AgentID, "probe") + runCtx, cancel := context.WithTimeout(ctx, shared.timeout) + defer cancel() + + runner, err := acpdbg.NewRunner(runCtx, jsonlPath, cfg) + if err != nil { + return err + } + defer runner.Close("completed") + + res, err := acpdbg.Probe(runCtx, runner) + if err != nil { + if acpdbg.IsAuthErrorMessage(err.Error()) { + fmt.Printf("agent: %s\n", cfg.AgentID) + fmt.Printf("jsonl: %s\n", runner.Path()) + fmt.Printf("status: auth_required\n") + fmt.Printf("error: %v\n", err) + return nil + } + fmt.Fprintf(os.Stderr, "probe failed: %v\n", err) + fmt.Fprintf(os.Stderr, "jsonl: %s\n", runner.Path()) + return err + } + printProbeSummary(cfg.AgentID, runner.Path(), res) + return nil +} + +// --- prompt --- + +func runPrompt(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("prompt", flag.ExitOnError) + shared := registerShared(fs) + model := fs.String("model", "", "model to set before prompting") + mode := fs.String("mode", "", "session mode to set before prompting") + prompt := fs.String("prompt", "", "prompt text (required)") + if err := fs.Parse(args); err != nil { + return err + } + if *prompt == "" { + return errors.New("--prompt is required") + } + + cfg, err := resolveRunConfig(fs, shared, "prompt") + if err != nil { + return err + } + + jsonlPath := resolveJSONLPath(shared, cfg.AgentID, "prompt") + runCtx, cancel := context.WithTimeout(ctx, shared.timeout) + defer cancel() + + runner, err := acpdbg.NewRunner(runCtx, jsonlPath, cfg) + if err != nil { + return err + } + defer runner.Close("completed") + + res, err := acpdbg.Prompt(runCtx, runner, acpdbg.PromptOptions{ + Model: *model, + Mode: *mode, + Prompt: *prompt, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "prompt failed: %v\n", err) + fmt.Fprintf(os.Stderr, "jsonl: %s\n", runner.Path()) + return err + } + printProbeSummary(cfg.AgentID, runner.Path(), &res.ProbeResult) + fmt.Println() + fmt.Println("--- response ---") + fmt.Println(res.Text) + return nil +} + +// --- session-load --- + +func runSessionLoad(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("session-load", flag.ExitOnError) + shared := registerShared(fs) + sessionID := fs.String("session-id", "", "ACP session id to load (required)") + if err := fs.Parse(args); err != nil { + return err + } + if *sessionID == "" { + return errors.New("--session-id is required") + } + + cfg, err := resolveRunConfig(fs, shared, "session-load") + if err != nil { + return err + } + + jsonlPath := resolveJSONLPath(shared, cfg.AgentID, "session-load") + runCtx, cancel := context.WithTimeout(ctx, shared.timeout) + defer cancel() + + runner, err := acpdbg.NewRunner(runCtx, jsonlPath, cfg) + if err != nil { + return err + } + defer runner.Close("completed") + + res, err := acpdbg.SessionLoad(runCtx, runner, *sessionID) + if err != nil { + fmt.Fprintf(os.Stderr, "session-load failed: %v\n", err) + fmt.Fprintf(os.Stderr, "jsonl: %s\n", runner.Path()) + return err + } + printProbeSummary(cfg.AgentID, runner.Path(), res) + return nil +} + +// --- matrix --- + +type matrixEntry struct { + AgentID string `json:"agent_id"` + Status string `json:"status"` + JSONL string `json:"jsonl"` + AgentName string `json:"agent_name,omitempty"` + AgentVersion string `json:"agent_version,omitempty"` + ProtocolVersion int `json:"protocol_version,omitempty"` + ModelsCount int `json:"models_count"` + CurrentModelID string `json:"current_model_id,omitempty"` + ModesCount int `json:"modes_count"` + CurrentModeID string `json:"current_mode_id,omitempty"` + AuthMethodsCount int `json:"auth_methods_count"` + AuthMethods []string `json:"auth_methods,omitempty"` + Error string `json:"error,omitempty"` + DurationMs int `json:"duration_ms"` +} + +type matrixSummary struct { + TS string `json:"ts"` + Agents []matrixEntry `json:"agents"` +} + +func runMatrix(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("matrix", flag.ExitOnError) + shared := registerShared(fs) + if err := fs.Parse(args); err != nil { + return err + } + if shared.exec != "" { + return errors.New("--exec is not supported with matrix; use `probe --exec` instead") + } + + reg, err := loadRegistry() + if err != nil { + return err + } + specs := acpdbg.ListACPAgents(reg) + if len(specs) == 0 { + return errors.New("no ACP-capable agents found") + } + + if err := os.MkdirAll(shared.out, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", shared.out, err) + } + + results := make([]matrixEntry, len(specs)) + var wg sync.WaitGroup + sem := make(chan struct{}, 4) // limit parallel npx resolutions + for i, s := range specs { + wg.Add(1) + sem <- struct{}{} + go func(i int, s acpdbg.AgentSpec) { + defer wg.Done() + defer func() { <-sem }() + results[i] = probeOne(ctx, shared, s) + }(i, s) + } + wg.Wait() + + printMatrixTable(results) + + summary := matrixSummary{ + TS: time.Now().UTC().Format(time.RFC3339), + Agents: results, + } + summaryPath := filepath.Join(shared.out, "matrix-summary.json") + f, err := os.Create(summaryPath) + if err != nil { + return fmt.Errorf("create summary: %w", err) + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(summary); err != nil { + _ = f.Close() + return fmt.Errorf("encode summary: %w", err) + } + if err := f.Close(); err != nil { + return err + } + fmt.Printf("\nsummary: %s\n", summaryPath) + return nil +} + +func probeOne(ctx context.Context, shared *sharedFlags, spec acpdbg.AgentSpec) matrixEntry { + start := time.Now() + entry := matrixEntry{ + AgentID: spec.ID, + Status: "failed", + } + jsonlPath := resolveJSONLPath(shared, spec.ID, "probe") + entry.JSONL = jsonlPath + + runCtx, cancel := context.WithTimeout(ctx, shared.timeout) + defer cancel() + + runner, err := acpdbg.NewRunner(runCtx, jsonlPath, acpdbg.RunConfig{ + AgentID: spec.ID, + Command: spec.Command, + Workdir: shared.workdir, + CaptureStderr: shared.stderr, + }) + if err != nil { + entry.Error = err.Error() + entry.Status = classifyError(err.Error()) + entry.DurationMs = int(time.Since(start).Milliseconds()) + return entry + } + defer runner.Close("completed") + + res, err := acpdbg.Probe(runCtx, runner) + entry.DurationMs = int(time.Since(start).Milliseconds()) + if err != nil { + entry.Error = err.Error() + entry.Status = classifyError(err.Error()) + return entry + } + entry.Status = "ok" + entry.AgentName = res.AgentName + entry.AgentVersion = res.AgentVersion + entry.ProtocolVersion = res.ProtocolVersion + entry.ModelsCount = len(res.Models) + entry.CurrentModelID = res.CurrentModelID + entry.ModesCount = len(res.Modes) + entry.CurrentModeID = res.CurrentModeID + entry.AuthMethods = res.AuthMethods + entry.AuthMethodsCount = len(res.AuthMethods) + return entry +} + +// --- helpers --- + +func resolveRunConfig(fs *flag.FlagSet, shared *sharedFlags, _ string) (acpdbg.RunConfig, error) { + cfg := acpdbg.RunConfig{ + Workdir: shared.workdir, + CaptureStderr: shared.stderr, + } + if shared.exec != "" { + parts := splitExec(shared.exec) + if len(parts) == 0 { + return cfg, errors.New("--exec is empty") + } + cfg.AgentID = "custom-" + acpdbg.SanitizeAgentID(filepath.Base(parts[0])) + cfg.Command = parts + return cfg, nil + } + if fs.NArg() == 0 { + return cfg, errors.New("agent argument required (or use --exec)") + } + agentID := fs.Arg(0) + reg, err := loadRegistry() + if err != nil { + return cfg, err + } + spec, err := acpdbg.LookupAgent(reg, agentID) + if err != nil { + return cfg, err + } + cfg.AgentID = spec.ID + cfg.Command = spec.Command + return cfg, nil +} + +func resolveJSONLPath(shared *sharedFlags, agentID, op string) string { + if shared.file != "" { + return shared.file + } + return acpdbg.SuggestJSONLPath(shared.out, agentID, op) +} + +func printProbeSummary(agentID, jsonlPath string, r *acpdbg.ProbeResult) { + fmt.Printf("agent: %s\n", agentID) + fmt.Printf("jsonl: %s\n", jsonlPath) + fmt.Printf("agent_name: %s\n", r.AgentName) + fmt.Printf("agent_version: %s\n", r.AgentVersion) + fmt.Printf("protocol_version: %d\n", r.ProtocolVersion) + fmt.Printf("session_id: %s\n", r.SessionID) + fmt.Printf("auth_methods: %s\n", strings.Join(r.AuthMethods, ", ")) + fmt.Printf("models (%d): %s (current: %s)\n", len(r.Models), strings.Join(r.Models, ", "), r.CurrentModelID) + fmt.Printf("modes (%d): %s (current: %s)\n", len(r.Modes), strings.Join(r.Modes, ", "), r.CurrentModeID) +} + +func printMatrixTable(rows []matrixEntry) { + fmt.Printf("%-16s %-14s %-6s %-6s %-6s %s\n", "AGENT", "STATUS", "MODELS", "MODES", "AUTH", "FILE") + for _, r := range rows { + status := r.Status + if r.Error != "" && len(status) < 14 { + status += fmt.Sprintf(" (%s)", shortReason(r.Error)) + } + if len(status) > 14 { + status = status[:14] + } + fmt.Printf("%-16s %-14s %-6d %-6d %-6d %s\n", + r.AgentID, status, + r.ModelsCount, r.ModesCount, r.AuthMethodsCount, + r.JSONL) + } +} + +func shortReason(s string) string { + if len(s) > 30 { + return s[:30] + "…" + } + return s +} + +func classifyError(msg string) string { + l := strings.ToLower(msg) + switch { + case strings.Contains(l, "not found") && strings.Contains(l, "exec"): + return "not_installed" + case strings.Contains(l, "executable file not found"): + return "not_installed" + case strings.Contains(l, "auth"), strings.Contains(l, "login"), + strings.Contains(l, "credential"), strings.Contains(l, "api key"), + strings.Contains(l, "unauthorized"): + return "auth_required" + case strings.Contains(l, "context deadline exceeded"): + return "timeout" + default: + return "failed" + } +} + +// splitExec splits a command string on whitespace. No shell expansion — +// this is a simple splitter for development convenience. Quoted arguments +// are NOT supported; users needing that can pass a shell wrapper like +// `sh -c "…"` which ends up as three tokens and we exec sh. +func splitExec(s string) []string { + return strings.Fields(s) +} diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 8f2514023d..3295a7554b 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -13,8 +13,10 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + agentcapabilities "github.com/kandev/kandev/internal/agent/capabilities/handlers" "github.com/kandev/kandev/internal/agent/docker" agenthandlers "github.com/kandev/kandev/internal/agent/handlers" + "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/mcpconfig" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" @@ -427,6 +429,7 @@ type routeParams struct { analyticsRepo analyticsrepository.Repository orchestratorSvc *orchestrator.Service lifecycleMgr *lifecycle.Manager + hostUtilityMgr *hostutility.Manager eventBus bus.EventBus services *Services agentSettingsController *agentsettingscontroller.Controller @@ -552,9 +555,12 @@ func registerSecondaryRoutes( prompthandlers.RegisterRoutes(p.router, p.promptCtrl, p.log) p.log.Debug("Registered Prompts handlers (HTTP)") - utilityhandlers.RegisterRoutes(p.router, p.utilityCtrl, p.lifecycleMgr, p.services.User, p.log) + utilityhandlers.RegisterRoutes(p.router, p.utilityCtrl, p.lifecycleMgr, p.hostUtilityMgr, p.services.User, p.log) p.log.Debug("Registered Utility Agents handlers (HTTP)") + agentcapabilities.RegisterRoutes(p.router, p.hostUtilityMgr, p.log) + p.log.Debug("Registered Agent Capabilities handlers (HTTP)") + clarification.RegisterRoutes(p.router, clarificationStore, p.gateway.Hub, p.msgCreator, p.taskRepo, p.eventBus, p.log) p.log.Debug("Registered Clarification handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index f44279b731..2634602b54 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -12,6 +12,7 @@ import ( "os/signal" "path/filepath" "syscall" + "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/httpmw" @@ -29,10 +30,12 @@ import ( githubpkg "github.com/kandev/kandev/internal/github" // Agent infrastructure + "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentctlclient "github.com/kandev/kandev/internal/agentctl/client" // WebSocket gateway gateways "github.com/kandev/kandev/internal/gateway/websocket" @@ -326,7 +329,7 @@ func startAgentInfrastructure( } return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, - agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, runCleanups) + agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, addCleanup, runCleanups) } // startGatewayAndServe sets up the WebSocket gateway, HTTP routes, starts the server, @@ -343,6 +346,7 @@ func startGatewayAndServe( agentRegistry *registry.Registry, orchestratorSvc *orchestrator.Service, msgCreator *messageCreatorAdapter, + addCleanup func(func() error), runCleanups func(), ) bool { // ============================================ @@ -365,6 +369,37 @@ func startGatewayAndServe( log.Info("Session data provider configured for session subscriptions (git status from snapshots)") waitForAgentctlControlHealthy(ctx, cfg, log) + + // ============================================ + // HOST UTILITY MANAGER + // ============================================ + // Long-lived per-agent-type agentctl instances for boot-time capability + // probes, on-demand refresh via settings, and sessionless utility prompts + // (e.g. "enhance prompt" before a task/session exists). + hostControlClient := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log) + hostUtilityMgr := hostutility.NewManager(agentRegistry, cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, hostControlClient, log) + // Wire the host utility manager into the settings controller so + // /api/v1/agent-models/:agentName reads live capability data. + agentSettingsController.SetHostUtility(hostUtilityMgr) + profileReconciler := agentsettingscontroller.NewProfileReconciler(hostUtilityMgr, agentRegistry, repos.AgentSettings, log) + go func() { + if err := hostUtilityMgr.Start(ctx); err != nil { + log.Warn("host utility manager bootstrap error", zap.Error(err)) + } + // Reconcile profiles against fresh probe results — seeds defaults for + // newly probed agents, heals stale profile models/modes, cleans up + // orphans referencing removed agents. + if err := profileReconciler.Run(ctx); err != nil { + log.Warn("profile reconciler error", zap.Error(err)) + } + }() + addCleanup(func() error { + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + hostUtilityMgr.Stop(stopCtx) + return nil + }) + if err := orchestratorSvc.Start(ctx); err != nil { log.Error("Failed to start orchestrator", zap.Error(err)) return false @@ -379,7 +414,7 @@ func startGatewayAndServe( // HTTP SERVER // ============================================ server := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, - lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry) + lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr) port := cfg.Server.Port if port == 0 { @@ -416,6 +451,7 @@ func buildHTTPServer( notificationCtrl *notificationcontroller.Controller, msgCreator *messageCreatorAdapter, agentRegistry *registry.Registry, + hostUtilityMgr *hostutility.Manager, ) *http.Server { gin.SetMode(gin.ReleaseMode) router := gin.New() @@ -432,6 +468,7 @@ func buildHTTPServer( analyticsRepo: repos.Analytics, orchestratorSvc: orchestratorSvc, lifecycleMgr: lifecycleMgr, + hostUtilityMgr: hostUtilityMgr, eventBus: eventBus, services: services, agentSettingsController: agentSettingsController, diff --git a/apps/backend/internal/agent/acpdbg/framer.go b/apps/backend/internal/agent/acpdbg/framer.go new file mode 100644 index 0000000000..a2bc883615 --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/framer.go @@ -0,0 +1,147 @@ +// Package acpdbg is a headless ACP JSON-RPC debugging library used by the +// `acpdbg` CLI. It speaks raw line-delimited JSON-RPC 2.0 to an agent +// subprocess, captures every frame to a JSONL file, and never depends on the +// acp-go-sdk — so the frames recorded are authoritative wire bytes, not +// SDK-reparsed events. +package acpdbg + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "sync" +) + +// Frame is a single JSON-RPC 2.0 message — request, response, or notification. +// We keep it as a generic map because the whole point of this tool is to +// capture whatever the agent actually sends, including fields the SDK would +// drop or normalize. +type Frame map[string]any + +// ID returns the request/response id as-is (may be nil for notifications, +// float64 for numeric ids, or string for string ids — ACP uses integers). +func (f Frame) ID() any { + return f["id"] +} + +// Method returns the method name if this is a request or notification, or "". +func (f Frame) Method() string { + m, _ := f["method"].(string) + return m +} + +// IsResponse reports whether the frame has a result or error field (and +// therefore a matching request id). +func (f Frame) IsResponse() bool { + if _, ok := f["result"]; ok { + return true + } + if _, ok := f["error"]; ok { + return true + } + return false +} + +// Framer writes JSON-RPC frames to a child's stdin and reads frames from the +// child's stdout. It is NOT thread-safe for concurrent writes on the same +// stdin — callers should serialize writes (the CLI does this naturally +// because it's strictly request-response). +type Framer struct { + stdin io.Writer + stdout *bufio.Scanner + + mu sync.Mutex + nextID int +} + +// NewFramer wraps a child's stdin/stdout with a line-delimited JSON-RPC +// framer. The scanner buffer is sized for large `session/new` responses that +// can include many models and modes. +func NewFramer(stdin io.Writer, stdout io.Reader) *Framer { + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // up to 10 MiB per line + return &Framer{ + stdin: stdin, + stdout: sc, + nextID: 0, + } +} + +// NextID returns a fresh JSON-RPC request id. Monotonic starting at 1. +func (f *Framer) NextID() int { + f.mu.Lock() + defer f.mu.Unlock() + f.nextID++ + return f.nextID +} + +// Write serializes the given frame as a single JSON object followed by a +// newline and writes it to the child's stdin. +func (f *Framer) Write(frame Frame) error { + data, err := json.Marshal(frame) + if err != nil { + return fmt.Errorf("marshal frame: %w", err) + } + data = append(data, '\n') + if _, err := f.stdin.Write(data); err != nil { + return fmt.Errorf("write frame: %w", err) + } + return nil +} + +// Read returns the next frame from the child's stdout. Returns io.EOF when +// the child closes stdout. Skips blank lines between frames. +func (f *Framer) Read() (Frame, error) { + for f.stdout.Scan() { + line := f.stdout.Bytes() + if len(line) == 0 { + continue + } + var frame Frame + if err := json.Unmarshal(line, &frame); err != nil { + return nil, fmt.Errorf("parse frame %q: %w", shortLine(line), err) + } + return frame, nil + } + if err := f.stdout.Err(); err != nil { + return nil, err + } + return nil, io.EOF +} + +// NewRequest builds a JSON-RPC request frame with the next id. +func (f *Framer) NewRequest(method string, params any) (Frame, int) { + id := f.NextID() + fr := Frame{ + "jsonrpc": "2.0", + "id": id, + "method": method, + } + if params != nil { + fr["params"] = params + } + return fr, id +} + +// NewMethodNotFound builds an error response for an incoming agent-initiated +// request whose id we don't plan to service. We still reply so the agent +// doesn't hang waiting for us. +func NewMethodNotFound(id any, method string) Frame { + return Frame{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{ + "code": -32601, + "message": "method not found: " + method, + }, + } +} + +func shortLine(b []byte) string { + const max = 200 + if len(b) <= max { + return string(b) + } + return string(b[:max]) + "…" +} diff --git a/apps/backend/internal/agent/acpdbg/framer_test.go b/apps/backend/internal/agent/acpdbg/framer_test.go new file mode 100644 index 0000000000..81ee45f722 --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/framer_test.go @@ -0,0 +1,132 @@ +package acpdbg + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestFramer_WriteAppendsNewline(t *testing.T) { + var out bytes.Buffer + f := NewFramer(&out, strings.NewReader("")) + if err := f.Write(Frame{"jsonrpc": "2.0", "id": 1, "method": "initialize"}); err != nil { + t.Fatalf("write: %v", err) + } + got := out.String() + if !strings.HasSuffix(got, "\n") { + t.Errorf("expected trailing newline, got %q", got) + } + if !strings.Contains(got, `"method":"initialize"`) { + t.Errorf("expected method field in %q", got) + } +} + +func TestFramer_ReadParsesMultipleFrames(t *testing.T) { + input := `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"abc"}} +{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"nope"}} +` + f := NewFramer(io.Discard, strings.NewReader(input)) + + first, err := f.Read() + if err != nil { + t.Fatalf("read 1: %v", err) + } + if !first.IsResponse() || first.ID().(float64) != 1 { + t.Errorf("first frame should be response id=1, got %+v", first) + } + + second, err := f.Read() + if err != nil { + t.Fatalf("read 2: %v", err) + } + if second.Method() != "session/update" || second.ID() != nil { + t.Errorf("second frame should be notification, got %+v", second) + } + + third, err := f.Read() + if err != nil { + t.Fatalf("read 3: %v", err) + } + if !third.IsResponse() || third.ID().(float64) != 2 { + t.Errorf("third frame should be error response id=2, got %+v", third) + } + + if _, err := f.Read(); err != io.EOF { + t.Errorf("expected io.EOF after last frame, got %v", err) + } +} + +func TestFramer_ReadSkipsBlankLines(t *testing.T) { + input := "\n\n{\"id\":1,\"result\":{}}\n\n{\"id\":2,\"result\":{}}\n" + f := NewFramer(io.Discard, strings.NewReader(input)) + + first, err := f.Read() + if err != nil { + t.Fatalf("read 1: %v", err) + } + if first.ID().(float64) != 1 { + t.Errorf("first id = %v, want 1", first.ID()) + } + second, err := f.Read() + if err != nil { + t.Fatalf("read 2: %v", err) + } + if second.ID().(float64) != 2 { + t.Errorf("second id = %v, want 2", second.ID()) + } +} + +func TestFramer_NextIDMonotonic(t *testing.T) { + f := NewFramer(io.Discard, strings.NewReader("")) + if id := f.NextID(); id != 1 { + t.Errorf("first id = %d, want 1", id) + } + if id := f.NextID(); id != 2 { + t.Errorf("second id = %d, want 2", id) + } + req, id := f.NewRequest("initialize", map[string]any{"protocolVersion": 1}) + if id != 3 { + t.Errorf("NewRequest id = %d, want 3", id) + } + if req["method"] != "initialize" || req["jsonrpc"] != "2.0" { + t.Errorf("bad request frame: %+v", req) + } +} + +func TestFramer_ReadLargeFrame(t *testing.T) { + // A realistic session/new response can be ~50 KB when models + modes are + // populated. Ensure the scanner buffer handles it. + big := strings.Repeat("x", 200_000) + input := `{"id":1,"result":{"sessionId":"` + big + `"}}` + "\n" + f := NewFramer(io.Discard, strings.NewReader(input)) + got, err := f.Read() + if err != nil { + t.Fatalf("read: %v", err) + } + if !got.IsResponse() { + t.Errorf("expected response, got %+v", got) + } +} + +func TestFramer_ReadBadJSONReturnsError(t *testing.T) { + f := NewFramer(io.Discard, strings.NewReader("this is not json\n")) + if _, err := f.Read(); err == nil { + t.Error("expected parse error on malformed input") + } +} + +func TestNewMethodNotFound(t *testing.T) { + fr := NewMethodNotFound(42, "fs/read_text_file") + if fr["id"] != 42 { + t.Errorf("id = %v, want 42", fr["id"]) + } + errMap, ok := fr["error"].(map[string]any) + if !ok { + t.Fatalf("error field missing: %+v", fr) + } + if errMap["code"] != -32601 { + t.Errorf("code = %v, want -32601", errMap["code"]) + } +} diff --git a/apps/backend/internal/agent/acpdbg/ops.go b/apps/backend/internal/agent/acpdbg/ops.go new file mode 100644 index 0000000000..2d1e91fa71 --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/ops.go @@ -0,0 +1,351 @@ +package acpdbg + +import ( + "context" + "errors" + "fmt" + "io" + "strings" +) + +// IsAuthErrorMessage is a coarse substring heuristic mirroring +// hostutility.isAuthError. ACP auth failures bubble up as plain-text +// JSON-RPC errors without a distinct code, so we match obvious markers so +// callers can route the user to the login flow; anything unmatched stays +// as a generic failure. +func IsAuthErrorMessage(s string) bool { + lower := strings.ToLower(s) + for _, needle := range []string{"auth", "login", "unauthorized", "credential", "api key", "token"} { + if strings.Contains(lower, needle) { + return true + } + } + return false +} + +// ProbeResult is the parsed summary of a successful probe. It mirrors the +// high-level fields we extract from `initialize` and `session/new` so the +// CLI's `matrix` summary and the skill don't have to re-parse JSONL. +type ProbeResult struct { + ProtocolVersion int + AgentName string + AgentVersion string + AuthMethods []string + Models []string + CurrentModelID string + Modes []string + CurrentModeID string + SessionID string +} + +// Probe runs the minimum ACP handshake: initialize → session/new → close. +// Returns a parsed summary, or an error describing where the handshake +// broke. The runner's JSONL file captures the full wire payload regardless +// of the outcome. +func Probe(ctx context.Context, r *Runner) (*ProbeResult, error) { + initResp, err := sendInitialize(ctx, r) + if err != nil { + return nil, fmt.Errorf("initialize: %w", err) + } + + newResp, err := sendSessionNew(ctx, r) + if err != nil { + return nil, fmt.Errorf("session/new: %w", err) + } + + return buildProbeResult(initResp, newResp), nil +} + +// PromptOptions configures the Prompt operation. +type PromptOptions struct { + Model string // if non-empty, set via session/set_model (or unstable variant) + Mode string // if non-empty, set via session/set_mode + Prompt string // required — text body of the session/prompt request +} + +// PromptResult holds the text response collected from session/update +// message_chunks while session/prompt is running. +type PromptResult struct { + ProbeResult + Text string +} + +// Prompt runs the full prompt round-trip: initialize → session/new → +// [set_model] → [set_mode] → session/prompt → drain updates until the +// prompt response arrives. +func Prompt(ctx context.Context, r *Runner, opts PromptOptions) (*PromptResult, error) { + if opts.Prompt == "" { + return nil, errors.New("prompt is required") + } + probe, err := Probe(ctx, r) + if err != nil { + return nil, err + } + + if opts.Model != "" { + if err := sendSetModel(ctx, r, probe.SessionID, opts.Model); err != nil { + return nil, fmt.Errorf("session/set_model: %w", err) + } + } + if opts.Mode != "" { + if err := sendSetMode(ctx, r, probe.SessionID, opts.Mode); err != nil { + return nil, fmt.Errorf("session/set_mode: %w", err) + } + } + + text, err := sendPromptAndCollect(ctx, r, probe.SessionID, opts.Prompt) + if err != nil { + return nil, fmt.Errorf("session/prompt: %w", err) + } + return &PromptResult{ProbeResult: *probe, Text: text}, nil +} + +// SessionLoad runs initialize → session/load. +func SessionLoad(ctx context.Context, r *Runner, sessionID string) (*ProbeResult, error) { + if sessionID == "" { + return nil, errors.New("session-id is required") + } + initResp, err := sendInitialize(ctx, r) + if err != nil { + return nil, fmt.Errorf("initialize: %w", err) + } + req, _ := r.Framer().NewRequest("session/load", map[string]any{ + "sessionId": sessionID, + "mcpServers": []any{}, + }) + resp, err := r.Request(ctx, req) + if err != nil { + return nil, fmt.Errorf("session/load: %w", err) + } + if errMap, ok := resp["error"].(map[string]any); ok { + return nil, fmt.Errorf("session/load error: %v", errMap["message"]) + } + return buildProbeResult(initResp, resp), nil +} + +// --- JSON-RPC helpers --- + +func sendInitialize(ctx context.Context, r *Runner) (Frame, error) { + req, _ := r.Framer().NewRequest("initialize", map[string]any{ + "protocolVersion": 1, + "clientInfo": map[string]any{ + "name": "acpdbg", + "version": "0.1.0", + }, + "clientCapabilities": map[string]any{ + "fs": map[string]any{"readTextFile": false, "writeTextFile": false}, + "terminal": false, + }, + }) + resp, err := r.Request(ctx, req) + if err != nil { + return nil, err + } + if errMap, ok := resp["error"].(map[string]any); ok { + return nil, fmt.Errorf("%v", errMap["message"]) + } + return resp, nil +} + +func sendSessionNew(ctx context.Context, r *Runner) (Frame, error) { + req, _ := r.Framer().NewRequest("session/new", map[string]any{ + "cwd": r.cfg.Workdir, + "mcpServers": []any{}, + }) + resp, err := r.Request(ctx, req) + if err != nil { + return nil, err + } + if errMap, ok := resp["error"].(map[string]any); ok { + return nil, fmt.Errorf("%v", errMap["message"]) + } + return resp, nil +} + +func sendSetModel(ctx context.Context, r *Runner, sessionID, modelID string) error { + // Try the unstable form first (matches the acp-go-sdk fork we use). + // If that returns method-not-found, fall back to the stable name. + req, _ := r.Framer().NewRequest("session/set_model", map[string]any{ + "sessionId": sessionID, + "modelId": modelID, + }) + resp, err := r.Request(ctx, req) + if err != nil { + return err + } + if errMap, ok := resp["error"].(map[string]any); ok { + return fmt.Errorf("%v", errMap["message"]) + } + return nil +} + +func sendSetMode(ctx context.Context, r *Runner, sessionID, modeID string) error { + req, _ := r.Framer().NewRequest("session/set_mode", map[string]any{ + "sessionId": sessionID, + "modeId": modeID, + }) + resp, err := r.Request(ctx, req) + if err != nil { + return err + } + if errMap, ok := resp["error"].(map[string]any); ok { + return fmt.Errorf("%v", errMap["message"]) + } + return nil +} + +func sendPromptAndCollect(ctx context.Context, r *Runner, sessionID, prompt string) (string, error) { + req, promptID := r.Framer().NewRequest("session/prompt", map[string]any{ + "sessionId": sessionID, + "prompt": []any{ + map[string]any{"type": "text", "text": prompt}, + }, + }) + + // We can't use r.Request here because we want to drain update + // notifications while waiting for the response. Write directly and + // loop on the OOB channel + a pending response future. + if err := r.rec.Sent(req); err != nil { + return "", err + } + + respCh := make(chan Frame, 1) + r.mu.Lock() + r.pending[promptID] = respCh + r.mu.Unlock() + + if err := r.framer.Write(req); err != nil { + return "", err + } + + var text strings.Builder + for { + select { + case resp := <-respCh: + if errMap, ok := resp["error"].(map[string]any); ok { + return text.String(), fmt.Errorf("%v", errMap["message"]) + } + return text.String(), nil + case frame, ok := <-r.oob: + if !ok { + return text.String(), io.EOF + } + // Agent-initiated request during a prompt: auto-reply so it + // doesn't hang. + if frame.Method() != "" && frame.ID() != nil { + reply := NewMethodNotFound(frame.ID(), frame.Method()) + _ = r.rec.Sent(reply) + _ = r.framer.Write(reply) + continue + } + // Notification: extract text chunks from session/update if + // present. + if frame.Method() == "session/update" { + if chunk := extractTextChunk(frame); chunk != "" { + text.WriteString(chunk) + } + } + case <-ctx.Done(): + return text.String(), ctx.Err() + } + } +} + +// extractTextChunk pulls an `agentMessageChunk.content.text.text` value out +// of a session/update notification, if present. ACP's session/update is a +// tagged variant; we walk the structure defensively. +func extractTextChunk(frame Frame) string { + params, ok := frame["params"].(map[string]any) + if !ok { + return "" + } + update, ok := params["update"].(map[string]any) + if !ok { + return "" + } + // Variant 1: { "sessionUpdate": "agent_message_chunk", "content": {...} } + if kind, _ := update["sessionUpdate"].(string); kind == "agent_message_chunk" { + if content, ok := update["content"].(map[string]any); ok { + if t, ok := content["text"].(string); ok { + return t + } + } + } + // Variant 2 (older SDKs): { "agentMessageChunk": { "content": {...} } } + return extractVariant2Text(update) +} + +func extractVariant2Text(update map[string]any) string { + chunk, ok := update["agentMessageChunk"].(map[string]any) + if !ok { + return "" + } + content, ok := chunk["content"].(map[string]any) + if !ok { + return "" + } + if inner, ok := content["text"].(map[string]any); ok { + if t, ok := inner["text"].(string); ok { + return t + } + } + if t, ok := content["text"].(string); ok { + return t + } + return "" +} + +func buildProbeResult(initResp, newResp Frame) *ProbeResult { + out := &ProbeResult{} + if initResult, ok := initResp["result"].(map[string]any); ok { + parseInitResult(initResult, out) + } + if newResult, ok := newResp["result"].(map[string]any); ok { + parseSessionResult(newResult, out) + } + return out +} + +func parseInitResult(initResult map[string]any, out *ProbeResult) { + if pv, ok := initResult["protocolVersion"].(float64); ok { + out.ProtocolVersion = int(pv) + } + if info, ok := initResult["agentInfo"].(map[string]any); ok { + out.AgentName, _ = info["name"].(string) + out.AgentVersion, _ = info["version"].(string) + } + if methods, ok := initResult["authMethods"].([]any); ok { + out.AuthMethods = extractStringField(methods, "id") + } +} + +func parseSessionResult(newResult map[string]any, out *ProbeResult) { + out.SessionID, _ = newResult["sessionId"].(string) + if models, ok := newResult["models"].(map[string]any); ok { + out.CurrentModelID, _ = models["currentModelId"].(string) + if avail, ok := models["availableModels"].([]any); ok { + out.Models = extractStringField(avail, "modelId") + } + } + if modes, ok := newResult["modes"].(map[string]any); ok { + out.CurrentModeID, _ = modes["currentModeId"].(string) + if avail, ok := modes["availableModes"].([]any); ok { + out.Modes = extractStringField(avail, "id") + } + } +} + +// extractStringField pulls a string field from each map entry in the list. +func extractStringField(list []any, field string) []string { + var out []string + for _, item := range list { + mm, ok := item.(map[string]any) + if !ok { + continue + } + if v, ok := mm[field].(string); ok { + out = append(out, v) + } + } + return out +} diff --git a/apps/backend/internal/agent/acpdbg/recorder.go b/apps/backend/internal/agent/acpdbg/recorder.go new file mode 100644 index 0000000000..854ca23876 --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/recorder.go @@ -0,0 +1,132 @@ +package acpdbg + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Direction is the JSONL entry kind. +type Direction string + +const ( + DirMeta Direction = "meta" // start/close/timeout markers from acpdbg itself + DirSent Direction = "sent" // frames we wrote to the child's stdin + DirReceived Direction = "received" // frames we read from the child's stdout + DirStderr Direction = "stderr" // lines captured from the child's stderr (opt-in) +) + +// Entry is a single line in the JSONL output. Exactly one of Frame / Event / +// Line is set depending on Direction. +type Entry struct { + TS string `json:"ts"` + Direction Direction `json:"direction"` + + // Meta entries carry an event name + free-form payload. + Event string `json:"event,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + + // Sent/Received entries carry the raw JSON-RPC frame. + Frame Frame `json:"frame,omitempty"` + + // Stderr entries carry a single captured line. + Line string `json:"line,omitempty"` +} + +// Recorder writes Entries to a JSONL file, thread-safe for concurrent +// stdin/stdout/stderr goroutines. +type Recorder struct { + path string + f *os.File + + mu sync.Mutex +} + +// NewRecorder creates a JSONL file at the given path, creating parent +// directories as needed. Overwrites any existing file at the path. +func NewRecorder(path string) (*Recorder, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("create %s: %w", path, err) + } + return &Recorder{path: path, f: f}, nil +} + +// Path returns the JSONL file path. +func (r *Recorder) Path() string { return r.path } + +// Close flushes and closes the underlying file. +func (r *Recorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + if r.f == nil { + return nil + } + err := r.f.Close() + r.f = nil + return err +} + +// Meta writes a meta event with an optional payload. +func (r *Recorder) Meta(event string, payload map[string]any) error { + return r.write(Entry{ + TS: now(), + Direction: DirMeta, + Event: event, + Meta: payload, + }) +} + +// Sent writes a frame we sent to the child. +func (r *Recorder) Sent(frame Frame) error { + return r.write(Entry{ + TS: now(), + Direction: DirSent, + Frame: frame, + }) +} + +// Received writes a frame we received from the child. +func (r *Recorder) Received(frame Frame) error { + return r.write(Entry{ + TS: now(), + Direction: DirReceived, + Frame: frame, + }) +} + +// Stderr writes a single captured stderr line. +func (r *Recorder) Stderr(line string) error { + return r.write(Entry{ + TS: now(), + Direction: DirStderr, + Line: line, + }) +} + +func (r *Recorder) write(e Entry) error { + data, err := json.Marshal(e) + if err != nil { + return fmt.Errorf("marshal entry: %w", err) + } + data = append(data, '\n') + r.mu.Lock() + defer r.mu.Unlock() + if r.f == nil { + return fmt.Errorf("recorder closed") + } + if _, err := r.f.Write(data); err != nil { + return fmt.Errorf("write entry: %w", err) + } + return nil +} + +func now() string { + return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") +} diff --git a/apps/backend/internal/agent/acpdbg/recorder_test.go b/apps/backend/internal/agent/acpdbg/recorder_test.go new file mode 100644 index 0000000000..fee22dec83 --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/recorder_test.go @@ -0,0 +1,138 @@ +package acpdbg + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRecorder_WritesValidJSONLPerLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "run.jsonl") + + r, err := NewRecorder(path) + if err != nil { + t.Fatalf("new recorder: %v", err) + } + + if err := r.Meta("start", map[string]any{"agent": "claude-acp", "command": []string{"npx", "claude-agent-acp"}}); err != nil { + t.Fatalf("meta start: %v", err) + } + if err := r.Sent(Frame{"jsonrpc": "2.0", "id": 1, "method": "initialize"}); err != nil { + t.Fatalf("sent: %v", err) + } + if err := r.Received(Frame{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"protocolVersion": 1}}); err != nil { + t.Fatalf("received: %v", err) + } + if err := r.Stderr("some warning on stderr"); err != nil { + t.Fatalf("stderr: %v", err) + } + if err := r.Meta("close", map[string]any{"exit_code": 0}); err != nil { + t.Fatalf("meta close: %v", err) + } + if err := r.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + f, err := os.Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = f.Close() }() + + var entries []Entry + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + t.Errorf("unexpected blank line in JSONL") + } + var e Entry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("bad line %q: %v", line, err) + } + entries = append(entries, e) + } + + if len(entries) != 5 { + t.Fatalf("expected 5 entries, got %d", len(entries)) + } + + // Order check. + wantDir := []Direction{DirMeta, DirSent, DirReceived, DirStderr, DirMeta} + for i, e := range entries { + if e.Direction != wantDir[i] { + t.Errorf("entry %d direction = %q, want %q", i, e.Direction, wantDir[i]) + } + if e.TS == "" { + t.Errorf("entry %d has empty ts", i) + } + } + + // Timestamps must be monotonically non-decreasing. + for i := 1; i < len(entries); i++ { + if entries[i].TS < entries[i-1].TS { + t.Errorf("timestamps non-monotonic: %q < %q", entries[i].TS, entries[i-1].TS) + } + } + + // Meta start carries its payload. + if entries[0].Meta["agent"] != "claude-acp" { + t.Errorf("start meta missing agent: %+v", entries[0].Meta) + } + // Received frame retains its full payload. + res, ok := entries[2].Frame["result"].(map[string]any) + if !ok { + t.Fatalf("received frame has no result: %+v", entries[2].Frame) + } + if res["protocolVersion"].(float64) != 1 { + t.Errorf("protocolVersion = %v, want 1", res["protocolVersion"]) + } +} + +func TestRecorder_ConcurrentWritesAreSerialized(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "concurrent.jsonl") + r, err := NewRecorder(path) + if err != nil { + t.Fatalf("new: %v", err) + } + defer func() { _ = r.Close() }() + + const n = 200 + done := make(chan struct{}, 2) + go func() { + for i := range n { + _ = r.Sent(Frame{"id": i, "method": "test/sent"}) + } + done <- struct{}{} + }() + go func() { + for i := range n { + _ = r.Received(Frame{"id": i, "result": map[string]any{}}) + } + done <- struct{}{} + }() + <-done + <-done + + // Every line must parse cleanly — no interleaved writes. + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if len(lines) != 2*n { + t.Errorf("expected %d lines, got %d", 2*n, len(lines)) + } + for i, line := range lines { + var e Entry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Errorf("line %d not valid JSON: %q (%v)", i, line, err) + } + } +} diff --git a/apps/backend/internal/agent/acpdbg/registry.go b/apps/backend/internal/agent/acpdbg/registry.go new file mode 100644 index 0000000000..6d310f7a1d --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/registry.go @@ -0,0 +1,56 @@ +package acpdbg + +import ( + "fmt" + "sort" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/pkg/agent" +) + +// AgentSpec is the minimal info acpdbg needs to spawn a registered agent. +type AgentSpec struct { + ID string + DisplayName string + Command []string +} + +// ListACPAgents returns every enabled inference agent whose runtime +// protocol is ACP, sorted by ID. This is the same filter the host utility +// manager uses, so `acpdbg list` matches `/api/v1/agent-capabilities`. +func ListACPAgents(reg *registry.Registry) []AgentSpec { + var out []AgentSpec + for _, ia := range reg.ListInferenceAgents() { + ag, ok := ia.(agents.Agent) + if !ok { + continue + } + rt := ag.Runtime() + if rt == nil || rt.Protocol != agent.ProtocolACP { + continue + } + cfg := ia.InferenceConfig() + if cfg == nil || cfg.Command.IsEmpty() { + continue + } + out = append(out, AgentSpec{ + ID: ag.ID(), + DisplayName: ag.DisplayName(), + Command: cfg.Command.Args(), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// LookupAgent returns the spec for a single agent ID, or an error if the +// agent is not registered or not ACP-capable. +func LookupAgent(reg *registry.Registry, agentID string) (AgentSpec, error) { + for _, spec := range ListACPAgents(reg) { + if spec.ID == agentID { + return spec, nil + } + } + return AgentSpec{}, fmt.Errorf("agent %q not found or not ACP-capable", agentID) +} diff --git a/apps/backend/internal/agent/acpdbg/runner.go b/apps/backend/internal/agent/acpdbg/runner.go new file mode 100644 index 0000000000..361992b263 --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/runner.go @@ -0,0 +1,396 @@ +package acpdbg + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// RunConfig is passed to Run. Command is the argv to spawn; the env is +// inherited from the parent process. +type RunConfig struct { + // AgentID is a short identifier for the run, used in the JSONL meta + // start entry. For --exec runs, the caller supplies a synthetic id + // derived from the executable basename. + AgentID string + // Command is the argv to spawn. First element is the executable, rest + // are arguments. No shell expansion. + Command []string + // Workdir is the child process cwd. If empty, a fresh kandev-acpdbg-* + // temp directory is created and cleaned up on exit. + Workdir string + // CaptureStderr controls whether child stderr lines are recorded into + // the JSONL. Default: false (written to our stderr as plain lines). + CaptureStderr bool +} + +// Runner owns a recorder and a spawned child process. Use NewRunner to +// create one, then call Do with a callback that drives the protocol on the +// framer. Runner.Close always finalizes the recorder and kills any +// still-running child. +type Runner struct { + rec *Recorder + cfg RunConfig + tmpDir string // non-empty if we allocated the workdir ourselves + + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + framer *Framer + + stderrWG sync.WaitGroup + + // Frames received that aren't responses to our outstanding request. + // The protocol callback reads from this channel to process + // notifications and agent-initiated requests. + oob chan Frame + // responses[id] is closed by the read loop when a response with that + // id is recorded; the mu-protected map stores the frame itself. + mu sync.Mutex + pending map[int]chan Frame + readLoopWG sync.WaitGroup + readLoopEr error + + closeOnce sync.Once +} + +// NewRunner spawns the configured child process, wires its stdio through a +// recorder into the given JSONL file, and starts background goroutines to +// read stdout (framed JSON-RPC) and stderr (plain text). +func NewRunner(ctx context.Context, jsonlPath string, cfg RunConfig) (*Runner, error) { + if len(cfg.Command) == 0 { + return nil, errors.New("command is empty") + } + + rec, err := NewRecorder(jsonlPath) + if err != nil { + return nil, err + } + + // Allocate a tmp workdir if the caller didn't provide one. Use a + // pid-scoped prefix so we never collide with the host utility manager + // or with other concurrent acpdbg runs. + workdir := cfg.Workdir + var tmpDir string + if workdir == "" { + wd, err := os.MkdirTemp("", fmt.Sprintf("kandev-acpdbg-%d-*", os.Getpid())) + if err != nil { + _ = rec.Close() + return nil, fmt.Errorf("mktemp workdir: %w", err) + } + workdir = wd + tmpDir = wd + } + + // Record start meta before spawning so the file always has something + // useful even if exec fails. + _ = rec.Meta("start", map[string]any{ + "agent": cfg.AgentID, + "command": cfg.Command, + "workdir": workdir, + }) + + cmd, stdin, stdout, stderr, err := startChild(ctx, cfg, workdir) + if err != nil { + _ = rec.Meta("close", map[string]any{"reason": err.Error()}) + _ = rec.Close() + if tmpDir != "" { + _ = os.RemoveAll(tmpDir) + } + return nil, err + } + + r := &Runner{ + rec: rec, + cfg: cfg, + tmpDir: tmpDir, + cmd: cmd, + stdin: stdin, + stdout: stdout, + framer: NewFramer(stdin, stdout), + oob: make(chan Frame, 32), + pending: map[int]chan Frame{}, + } + + // Stderr goroutine. Always drain it to avoid blocking the child; only + // record to JSONL when CaptureStderr is set. + r.stderrWG.Add(1) + go r.drainStderr(stderr) + + // Stdout read loop: read frames, record each, route to the matching + // pending request or drop into the oob channel. + r.readLoopWG.Add(1) + go r.readLoop() + + return r, nil +} + +// startChild spawns the subprocess and returns its stdio pipes. +func startChild(ctx context.Context, cfg RunConfig, workdir string) (*exec.Cmd, io.WriteCloser, io.ReadCloser, io.ReadCloser, error) { + //nolint:gosec // the command comes from the agent registry or the user's --exec flag; both are trusted inputs + cmd := exec.CommandContext(ctx, cfg.Command[0], cfg.Command[1:]...) + cmd.Dir = workdir + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return nil, nil, nil, nil, fmt.Errorf("start %s: %w", cfg.Command[0], err) + } + return cmd, stdin, stdout, stderr, nil +} + +// Path returns the JSONL file path the runner is writing to. +func (r *Runner) Path() string { return r.rec.Path() } + +// Framer returns the framer so callers can build requests with fresh ids. +func (r *Runner) Framer() *Framer { return r.framer } + +// Request writes a request frame, records it, and waits for the matching +// response frame (or a timeout). Notifications and agent-initiated requests +// received while we wait land on the OOB channel and are handled by the +// caller via HandleOOB. +func (r *Runner) Request(ctx context.Context, frame Frame) (Frame, error) { + idNum, ok := frame["id"].(int) + if !ok { + return nil, fmt.Errorf("request frame has no integer id") + } + ch := make(chan Frame, 1) + r.mu.Lock() + r.pending[idNum] = ch + r.mu.Unlock() + + if err := r.rec.Sent(frame); err != nil { + return nil, err + } + if err := r.framer.Write(frame); err != nil { + return nil, fmt.Errorf("write request: %w", err) + } + + select { + case resp, ok := <-ch: + if !ok { + // Channel closed by readLoop on fatal error. + r.mu.Lock() + err := r.readLoopEr + r.mu.Unlock() + if err == nil { + err = io.EOF + } + return nil, fmt.Errorf("read loop exited: %w", err) + } + return resp, nil + case <-ctx.Done(): + r.mu.Lock() + delete(r.pending, idNum) + r.mu.Unlock() + return nil, ctx.Err() + } +} + +// DrainOOBUntil consumes out-of-band frames (notifications, agent-initiated +// requests) until the given condition returns true or the context is +// cancelled. The handler is called for every oob frame it receives; if the +// frame is an agent-initiated request we automatically reply with +// method-not-found so the session doesn't hang. +func (r *Runner) DrainOOBUntil(ctx context.Context, handler func(Frame) bool) error { + for { + select { + case frame, ok := <-r.oob: + if !ok { + return io.EOF + } + // If this is an agent-initiated request, auto-reply so it + // doesn't block waiting for us. + if frame.Method() != "" && frame.ID() != nil { + reply := NewMethodNotFound(frame.ID(), frame.Method()) + _ = r.rec.Sent(reply) + _ = r.framer.Write(reply) + } + if handler != nil && handler(frame) { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// NextOOB returns the next out-of-band frame, blocking until one is +// available or the context is cancelled. +func (r *Runner) NextOOB(ctx context.Context) (Frame, error) { + select { + case frame, ok := <-r.oob: + if !ok { + return nil, io.EOF + } + if frame.Method() != "" && frame.ID() != nil { + reply := NewMethodNotFound(frame.ID(), frame.Method()) + _ = r.rec.Sent(reply) + _ = r.framer.Write(reply) + } + return frame, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Close closes stdin (signalling EOF to the child), waits briefly for +// graceful exit, kills the child if needed, drains goroutines, and closes +// the recorder. Idempotent. +func (r *Runner) Close(reason string) { + r.closeOnce.Do(func() { + _ = r.stdin.Close() + done := make(chan error, 1) + go func() { done <- r.cmd.Wait() }() + + var exitCode int + var waitErr error + select { + case err := <-done: + waitErr = err + case <-time.After(5 * time.Second): + _ = r.cmd.Process.Kill() + waitErr = <-done + if reason == "" { + reason = "killed after timeout" + } + } + if exitErr, ok := waitErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else if waitErr != nil { + exitCode = -1 + } + + r.readLoopWG.Wait() + r.stderrWG.Wait() + close(r.oob) + + meta := map[string]any{"exit_code": exitCode} + if reason != "" { + meta["reason"] = reason + } + if waitErr != nil { + meta["wait_err"] = waitErr.Error() + } + if r.readLoopEr != nil && !errors.Is(r.readLoopEr, io.EOF) { + meta["read_err"] = r.readLoopEr.Error() + } + _ = r.rec.Meta("close", meta) + _ = r.rec.Close() + if r.tmpDir != "" { + _ = os.RemoveAll(r.tmpDir) + } + }) +} + +// routeResponse delivers the frame to a pending Request waiter if one +// exists. Returns true if consumed. +func (r *Runner) routeResponse(frame Frame) bool { + idf, ok := frame["id"].(float64) + if !ok { + return false + } + id := int(idf) + r.mu.Lock() + ch, found := r.pending[id] + if found { + delete(r.pending, id) + } + r.mu.Unlock() + if !found { + return false + } + ch <- frame + return true +} + +func (r *Runner) readLoop() { + defer r.readLoopWG.Done() + for { + frame, err := r.framer.Read() + if err != nil { + // Fail any outstanding request waiters so Request() returns + // rather than blocking forever when the child dies. + r.mu.Lock() + r.readLoopEr = err + for id, ch := range r.pending { + close(ch) + delete(r.pending, id) + } + r.mu.Unlock() + return + } + _ = r.rec.Received(frame) + + // Responses route to any pending Request waiter. + if frame.IsResponse() && r.routeResponse(frame) { + continue + } + + // Anything else (notifications, agent-initiated requests, + // orphaned responses) goes out of band. Block rather than drop, + // so session/update chunks and agent-initiated requests are + // never lost — this is a debug tool, correctness beats throughput. + r.oob <- frame + } +} + +func (r *Runner) drainStderr(rc io.ReadCloser) { + defer r.stderrWG.Done() + sc := bufio.NewScanner(rc) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + if r.cfg.CaptureStderr { + _ = r.rec.Stderr(line) + } else { + // Print to our stderr with a prefix so the user can tell it + // came from the child. + fmt.Fprintf(os.Stderr, "[%s stderr] %s\n", r.cfg.AgentID, line) + } + } +} + +// SanitizeAgentID produces a filesystem-safe token for use in a JSONL +// filename. Non-[A-Za-z0-9._-] characters become underscores. +func SanitizeAgentID(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + out := b.String() + if out == "" { + return "unknown" + } + return out +} + +// SuggestJSONLPath returns a default JSONL filename inside outDir based on +// the agent id, operation name, and a timestamp. +func SuggestJSONLPath(outDir, agentID, op string) string { + ts := time.Now().UTC().Format("20060102-150405") + name := fmt.Sprintf("%s-%s-%s.jsonl", SanitizeAgentID(agentID), op, ts) + return filepath.Join(outDir, name) +} diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 986abe5812..e74ebf9124 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -16,6 +16,10 @@ import ( var ErrNotSupported = errors.New("not supported by this agent") // Agent is the core interface for all coding agents. +// +// Models are no longer part of this interface — they come from the host utility +// capability cache (which probes each ACP agent at boot to learn its models and +// modes via session/new). See internal/agent/hostutility. type Agent interface { // --- Identity --- ID() string @@ -31,10 +35,6 @@ type Agent interface { // --- Discovery --- IsInstalled(ctx context.Context) (*DiscoveryResult, error) - // --- Models --- - DefaultModel() string - ListModels(ctx context.Context) (*ModelList, error) - // --- Execution --- BuildCommand(opts CommandOptions) Command @@ -55,14 +55,13 @@ type Agent interface { InstallScript() string } -// InferenceAgent is an optional capability for agents that support one-shot LLM inference. -// Agents implementing this interface can execute single prompts without a persistent session. +// InferenceAgent is an optional capability marker for agents that support +// one-shot LLM inference via the host utility manager. The actual model list +// is populated dynamically from the ACP probe — agents no longer declare a +// static model list. type InferenceAgent interface { // InferenceConfig returns the configuration for one-shot inference. InferenceConfig() *InferenceConfig - - // InferenceModels returns models available for inference (may be a subset of full models). - InferenceModels() []InferenceModel } // PassthroughAgent is an optional capability for agents that support CLI passthrough mode. @@ -86,63 +85,6 @@ const ( LogoDark ) -// Model describes a single model available for an agent. -type Model struct { - ID string `json:"id"` - ACPID string `json:"acp_id,omitempty"` // ACP protocol model ID (if different from ID) - Name string `json:"name"` - Description string `json:"description,omitempty"` - Provider string `json:"provider"` - ContextWindow int `json:"context_window"` // 0 = unspecified - IsDefault bool `json:"is_default"` - Source string `json:"source,omitempty"` // "static" or "dynamic" -} - -// ACPModelID returns the model ID for ACP SetModel. Falls back to ID if ACPID is not set. -func (m Model) ACPModelID() string { - if m.ACPID != "" { - return m.ACPID - } - return m.ID -} - -// ResolveACPModelID finds the ACP model ID for a given profile model ID. -// Searches the model list for a matching ID and returns its ACPID. -// Returns the original ID if no ACPID mapping exists. -func ResolveACPModelID(models []Model, profileModel string) string { - for _, m := range models { - if m.ID == profileModel && m.ACPID != "" { - return m.ACPID - } - } - return profileModel -} - -// ToInferenceModel converts a Model to an InferenceModel. -func (m Model) ToInferenceModel() InferenceModel { - return InferenceModel{ - ID: m.ID, - Name: m.Name, - Description: m.Description, - IsDefault: m.IsDefault, - } -} - -// ModelsToInferenceModels converts a slice of Models to InferenceModels. -func ModelsToInferenceModels(models []Model) []InferenceModel { - result := make([]InferenceModel, 0, len(models)) - for _, m := range models { - result = append(result, m.ToInferenceModel()) - } - return result -} - -// ModelList is the result of listing models for an agent. -type ModelList struct { - Models []Model - SupportsDynamic bool // true = UI shows refresh button -} - // DiscoveryResult is the result of checking if an agent is installed. type DiscoveryResult struct { Available bool diff --git a/apps/backend/internal/agent/agents/amp.go b/apps/backend/internal/agent/agents/amp.go deleted file mode 100644 index 4983ced4a7..0000000000 --- a/apps/backend/internal/agent/agents/amp.go +++ /dev/null @@ -1,137 +0,0 @@ -package agents - -import ( - "context" - _ "embed" - "time" - - "github.com/kandev/kandev/pkg/agent" -) - -//go:embed logos/amp_light.svg -var ampLogoLight []byte - -//go:embed logos/amp_dark.svg -var ampLogoDark []byte - -const ampPkg = "@sourcegraph/amp@latest" - -var ( - _ Agent = (*Amp)(nil) - _ PassthroughAgent = (*Amp)(nil) -) - -type Amp struct { - StandardPassthrough -} - -func NewAmp() *Amp { - return &Amp{ - StandardPassthrough: StandardPassthrough{ - PermSettings: ampPermSettings, - Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", ampPkg), - ModelFlag: NewParam("-m", "{model}"), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, - }, - }, - } -} - -func (a *Amp) ID() string { return "amp" } -func (a *Amp) Name() string { return "Sourcegraph Amp Agent" } -func (a *Amp) DisplayName() string { return "Amp" } -func (a *Amp) Description() string { - return "Sourcegraph Amp CLI-powered autonomous coding agent using stream-json protocol." -} -func (a *Amp) Enabled() bool { return false } -func (a *Amp) DisplayOrder() int { return 7 } - -func (a *Amp) Logo(v LogoVariant) []byte { - if v == LogoDark { - return ampLogoDark - } - return ampLogoLight -} - -func (a *Amp) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - install := OSPaths{ - Linux: []string{"~/.amp/bin"}, - MacOS: []string{"~/.amp/bin"}, - } - mcp := OSPaths{ - Linux: []string{"~/.config/amp/settings.json"}, - MacOS: []string{"~/.amp/bin"}, - } - - result, err := Detect(ctx, WithFileExists(install.Resolve()...)) - if err != nil { - return result, err - } - result.SupportsMCP = true - result.InstallationPaths = install.Expanded() - result.MCPConfigPaths = mcp.Expanded() - result.Capabilities = DiscoveryCapabilities{ - SupportsSessionResume: true, - } - return result, nil -} - -func (a *Amp) DefaultModel() string { return "smart" } - -func (a *Amp) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: ampStaticModels(), SupportsDynamic: false}, nil -} - -func (a *Amp) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", ampPkg, "--execute", "--stream-json", "--stream-json-input"). - Model(NewParam("-m", "{model}"), opts.Model). - Settings(ampPermSettings, opts.PermissionValues). - Build() -} - -func (a *Amp) Runtime() *RuntimeConfig { - canRecover := true - return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", ampPkg, "--execute", "--stream-json", "--stream-json-input").Build(), - WorkingDir: "{workspace}", - Env: map[string]string{}, - ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, - Protocol: agent.ProtocolAmp, - ModelFlag: NewParam("-m", "{model}"), - SessionConfig: SessionConfig{ - CanRecover: &canRecover, - SessionDirTemplate: "{home}/.config/amp", - ForkSessionCmd: Cmd("npx", "-y", ampPkg, "threads", "fork").Build(), - ContinueSessionCmd: Cmd("npx", "-y", ampPkg, "threads", "continue", "--execute", "--stream-json", "--stream-json-input").Build(), - }, - } -} - -func (a *Amp) RemoteAuth() *RemoteAuth { return nil } - -func (a *Amp) InstallScript() string { - return "npm install -g " + ampPkg -} - -func (a *Amp) PermissionSettings() map[string]PermissionSetting { - return ampPermSettings -} - -var ampPermSettings = map[string]PermissionSetting{ - "auto_approve": { - Supported: true, Default: true, Label: "Auto-approve (Dangerously Allow All)", Description: "Automatically approve all tool calls including shell commands", - ApplyMethod: "cli_flag", CLIFlag: "--dangerously-allow-all", - }, -} - -func ampStaticModels() []Model { - return []Model{ - {ID: "smart", Name: "Smart Mode", Description: "State-of-the-art models for maximum capability and autonomy", Provider: "amp", IsDefault: true, Source: "static"}, - {ID: "deep", Name: "Deep Mode", Description: "Deep reasoning with extended thinking on complex problems", Provider: "amp", Source: "static"}, - } -} diff --git a/apps/backend/internal/agent/agents/amp_acp.go b/apps/backend/internal/agent/agents/amp_acp.go index 6ca873d2ad..3a26a73d15 100644 --- a/apps/backend/internal/agent/agents/amp_acp.go +++ b/apps/backend/internal/agent/agents/amp_acp.go @@ -32,12 +32,12 @@ type AmpACP struct { func NewAmpACP() *AmpACP { return &AmpACP{ StandardPassthrough: StandardPassthrough{ - PermSettings: ampPermSettings, + PermSettings: emptyPermSettings, Cfg: PassthroughConfig{ Supported: true, Label: "CLI Passthrough", Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", ampPkg), + PassthroughCmd: NewCommand("npx", "-y", ampACPPkg), ModelFlag: NewParam("-m", "{model}"), IdleTimeout: 3 * time.Second, BufferMaxBytes: DefaultBufferMaxBytes, @@ -63,34 +63,19 @@ func (a *AmpACP) Logo(v LogoVariant) []byte { } func (a *AmpACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - install := OSPaths{ - Linux: []string{"~/.amp/bin"}, - MacOS: []string{"~/.amp/bin"}, - } - mcp := OSPaths{ - Linux: []string{"~/.config/amp/settings.json"}, - MacOS: []string{"~/.amp/bin"}, - } - - result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + // Check for the amp CLI on PATH. Auth state is surfaced later by the + // ACP probe, not by scanning ~/.amp. + result, err := Detect(ctx, WithCommand("amp-acp"), WithCommand("amp")) if err != nil { return result, err } result.SupportsMCP = true - result.InstallationPaths = install.Expanded() - result.MCPConfigPaths = mcp.Expanded() result.Capabilities = DiscoveryCapabilities{ SupportsSessionResume: true, } return result, nil } -func (a *AmpACP) DefaultModel() string { return "smart" } - -func (a *AmpACP) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: ampStaticModels(), SupportsDynamic: false}, nil -} - func (a *AmpACP) BuildCommand(opts CommandOptions) Command { return Cmd("npx", "-y", ampACPPkg).Build() } @@ -118,7 +103,7 @@ func (a *AmpACP) InstallScript() string { } func (a *AmpACP) PermissionSettings() map[string]PermissionSetting { - return ampPermSettings + return emptyPermSettings } // InferenceConfig returns configuration for one-shot inference using ACP. @@ -126,11 +111,5 @@ func (a *AmpACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, Command: NewCommand("npx", "-y", ampACPPkg), - ModelFlag: NewParam("-m", "{model}"), } } - -// InferenceModels returns models available for one-shot inference. -func (a *AmpACP) InferenceModels() []InferenceModel { - return ModelsToInferenceModels(ampStaticModels()) -} diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go index 94df608a69..24efd9ac39 100644 --- a/apps/backend/internal/agent/agents/auggie.go +++ b/apps/backend/internal/agent/agents/auggie.go @@ -3,9 +3,6 @@ package agents import ( "context" _ "embed" - "fmt" - "regexp" - "strings" "time" "github.com/kandev/kandev/pkg/agent" @@ -64,15 +61,13 @@ func (a *Auggie) Logo(v LogoVariant) []byte { } func (a *Auggie) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - result, err := Detect(ctx, - WithFileExists("~/.augment/.auggie.json"), - WithCommand("auggie"), - ) + // Check for the auggie CLI on PATH. Auth state is surfaced later by the + // ACP probe. + result, err := Detect(ctx, WithCommand("auggie")) if err != nil { return result, err } result.SupportsMCP = true - result.InstallationPaths = []string{expandHomePath("~/.augment/.auggie.json")} result.Capabilities = DiscoveryCapabilities{ SupportsSessionResume: true, SupportsShell: false, @@ -81,23 +76,15 @@ func (a *Auggie) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { return result, nil } -func (a *Auggie) DefaultModel() string { return "sonnet4.6" } - -func (a *Auggie) ListModels(ctx context.Context) (*ModelList, error) { - models, err := execAndParse(ctx, 30*time.Second, auggieParseModels, "auggie", "model", "list") - if err != nil { - return &ModelList{Models: auggieStaticModels(), SupportsDynamic: true}, nil - } - return &ModelList{Models: models, SupportsDynamic: true}, nil -} - func (a *Auggie) BuildCommand(opts CommandOptions) Command { - // Session resume is handled via ACP session/load, not CLI flags (NativeSessionResume: true) - return Cmd("npx", "-y", auggiePkg, "--acp"). - Model(NewParam("--model", "{model}"), opts.Model). - Permissions("--permission", auggiePermTools, opts). - Settings(auggiePermSettings, opts.PermissionValues). - Build() + // Model and mode are applied via ACP session/set_model and session/set_mode + // after session creation — no --model CLI flag. + // allow_indexing is auggie-only: apply the CLI flag if the profile enables it. + b := Cmd("npx", "-y", auggiePkg, "--acp") + if opts.PermissionValues != nil && opts.PermissionValues["allow_indexing"] { + b = b.Flag("--allow-indexing") + } + return b.Build() } func (a *Auggie) Runtime() *RuntimeConfig { @@ -114,7 +101,6 @@ func (a *Auggie) Runtime() *RuntimeConfig { }, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Protocol: agent.ProtocolACP, - ModelFlag: NewParam("--model", "{model}"), WorkspaceFlag: "--workspace-root", AssumeMcpSse: true, SessionConfig: SessionConfig{ @@ -157,84 +143,17 @@ func (a *Auggie) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, Command: NewCommand("auggie", "--acp", "--allow-indexing"), - ModelFlag: NewParam("--model", "{model}"), } } -// InferenceModels returns models available for one-shot inference. -func (a *Auggie) InferenceModels() []InferenceModel { - return ModelsToInferenceModels(auggieStaticModels()) -} - // --- Private --- -var auggiePermTools = []string{"launch-process", "save-file", "str-replace-editor", "remove-files"} - +// auggiePermSettings exposes only the auggie-specific allow_indexing flag. +// All other permission concerns are handled via ACP session modes and the +// per-tool-call permission_request UI. var auggiePermSettings = map[string]PermissionSetting{ - "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls"}, "allow_indexing": { Supported: true, Default: true, Label: "Allow indexing", Description: "Enable workspace indexing without confirmation", ApplyMethod: "cli_flag", CLIFlag: "--allow-indexing", }, } - -func auggieStaticModels() []Model { - return []Model{ - {ID: "sonnet4.6", ACPID: "claude-sonnet-4-6", Name: "Sonnet 4.6", Description: "Latest Sonnet model with improved capabilities", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, - {ID: "sonnet4.5", ACPID: "claude-sonnet-4-5", Name: "Sonnet 4.5", Description: "Great for everyday tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "opus4.6", ACPID: "claude-opus-4-6", Name: "Opus 4.6", Description: "Best for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "opus4.5", ACPID: "claude-opus-4-5", Name: "Opus 4.5", Description: "Best for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "haiku4.5", ACPID: "claude-haiku-4-5", Name: "Haiku 4.5", Description: "Fast and efficient responses", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "sonnet4", ACPID: "claude-sonnet-4", Name: "Sonnet 4", Description: "Legacy model", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "gpt5.4", ACPID: "gpt-5-4", Name: "GPT-5.4", Description: "Strong reasoning and planning (free for a limited time)", Provider: "openai", ContextWindow: 128000, Source: "static"}, - {ID: "gpt5.2", ACPID: "gpt-5-2", Name: "GPT-5.2", Description: "Smarter but slower and more expensive than GPT-5.1", Provider: "openai", ContextWindow: 128000, Source: "static"}, - {ID: "gpt5.1", ACPID: "gpt-5-1", Name: "GPT-5.1", Description: "Strong reasoning and planning", Provider: "openai", ContextWindow: 128000, Source: "static"}, - {ID: "gpt5", ACPID: "gpt-5", Name: "GPT-5", Description: "OpenAI GPT-5 legacy", Provider: "openai", ContextWindow: 128000, Source: "static"}, - } -} - -// auggieParseModels parses "auggie model list" output. -// Format: -// -// Available models: -// - Haiku 4.5 [haiku4.5] -// Fast and efficient responses -// - Claude Opus 4.5 [opus4.5] -// Best for complex tasks -var auggieModelLineRe = regexp.MustCompile(`^\s*-\s+(.+?)\s+\[(\S+)\]\s*$`) - -func auggieParseModels(output string) ([]Model, error) { - lines := strings.Split(output, "\n") - models := make([]Model, 0) - defaultModel := "sonnet4.6" - - for i := 0; i < len(lines); i++ { - m := auggieModelLineRe.FindStringSubmatch(lines[i]) - if m == nil { - continue - } - name := m[1] - id := m[2] - - var description string - if i+1 < len(lines) { - desc := strings.TrimSpace(lines[i+1]) - if desc != "" && !strings.HasPrefix(strings.TrimSpace(lines[i+1]), "-") { - description = desc - i++ // skip the description line - } - } - - models = append(models, Model{ - ID: id, - Name: name, - Description: description, - IsDefault: id == defaultModel, - Source: "dynamic", - }) - } - if len(models) == 0 && strings.TrimSpace(output) != "" { - return nil, fmt.Errorf("no models parsed from auggie output") - } - return models, nil -} diff --git a/apps/backend/internal/agent/agents/auggie_test.go b/apps/backend/internal/agent/agents/auggie_test.go deleted file mode 100644 index 5e4cfc775e..0000000000 --- a/apps/backend/internal/agent/agents/auggie_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package agents - -import ( - "testing" -) - -// assertDefaultModel verifies that exactly one model has IsDefault=true and it matches wantDef. -func assertDefaultModel(t *testing.T, models []Model, wantDef string) { - t.Helper() - found := false - for _, m := range models { - if m.ID == wantDef { - if !m.IsDefault { - t.Errorf("model %q should have IsDefault=true", wantDef) - } - found = true - } else if m.IsDefault { - t.Errorf("model %q should not have IsDefault=true", m.ID) - } - } - if !found { - t.Errorf("default model %q not found in results", wantDef) - } -} - -func TestAuggieParseModels(t *testing.T) { - tests := []struct { - name string - input string - wantLen int - wantErr bool - wantIDs []string - wantDef string // expected default model ID - }{ - { - name: "normal output with descriptions", - input: `Available models: - - Haiku 4.5 [haiku4.5] - Fast and efficient responses - - Claude Opus 4.5 [opus4.5] - Best for complex tasks - - Sonnet 4.6 [sonnet4.6] - Latest Sonnet model`, - wantLen: 3, - wantIDs: []string{"haiku4.5", "opus4.5", "sonnet4.6"}, - wantDef: "sonnet4.6", - }, - { - name: "models without descriptions", - input: `Available models: - - Haiku 4.5 [haiku4.5] - - Sonnet 4.6 [sonnet4.6]`, - wantLen: 2, - wantIDs: []string{"haiku4.5", "sonnet4.6"}, - wantDef: "sonnet4.6", - }, - { - name: "empty output returns empty slice", - input: "", - wantLen: 0, - }, - { - name: "header only returns error", - input: "Available models:\n", - wantErr: true, - }, - { - name: "non-empty garbage returns error", - input: "some unexpected output\nno models here", - wantErr: true, - }, - { - name: "non-matching lines ignored when models exist", - input: `Some random text - - Valid Model [valid-id] - A description -Not a model line -Another random line`, - wantLen: 1, - wantIDs: []string{"valid-id"}, - }, - { - name: "default model gets IsDefault true", - input: ` - Other [other-id] - Some model - - Sonnet 4.6 [sonnet4.6] - Default model`, - wantLen: 2, - wantDef: "sonnet4.6", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - models, err := auggieParseModels(tt.input) - if tt.wantErr { - if err == nil { - t.Fatal("auggieParseModels() expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("auggieParseModels() error = %v", err) - } - if len(models) != tt.wantLen { - t.Fatalf("auggieParseModels() returned %d models, want %d", len(models), tt.wantLen) - } - for i, wantID := range tt.wantIDs { - if i >= len(models) { - break - } - if models[i].ID != wantID { - t.Errorf("model[%d].ID = %q, want %q", i, models[i].ID, wantID) - } - if models[i].Source != "dynamic" { - t.Errorf("model[%d].Source = %q, want %q", i, models[i].Source, "dynamic") - } - } - if tt.wantDef != "" { - assertDefaultModel(t, models, tt.wantDef) - } - }) - } -} - -func TestAuggieParseModels_Descriptions(t *testing.T) { - input := ` - Haiku 4.5 [haiku4.5] - Fast and efficient responses - - Sonnet 4.5 [sonnet4.5]` - - models, err := auggieParseModels(input) - if err != nil { - t.Fatalf("auggieParseModels() error = %v", err) - } - if len(models) != 2 { - t.Fatalf("got %d models, want 2", len(models)) - } - if models[0].Description != "Fast and efficient responses" { - t.Errorf("model[0].Description = %q, want %q", models[0].Description, "Fast and efficient responses") - } - if models[0].Name != "Haiku 4.5" { - t.Errorf("model[0].Name = %q, want %q", models[0].Name, "Haiku 4.5") - } - if models[1].Description != "" { - t.Errorf("model[1].Description = %q, want empty", models[1].Description) - } -} diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index 66eb42559b..7f65bfaa34 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -32,7 +32,7 @@ type ClaudeACP struct { func NewClaudeACP() *ClaudeACP { return &ClaudeACP{ StandardPassthrough: StandardPassthrough{ - PermSettings: claudeCodePermSettings, + PermSettings: emptyPermSettings, Cfg: PassthroughConfig{ Supported: true, Label: "CLI Passthrough", @@ -65,27 +65,19 @@ func (a *ClaudeACP) Logo(v LogoVariant) []byte { } func (a *ClaudeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - // Use the same detection as claude-code: check for ~/.claude.json which is - // created after onboarding. Just having npx is not enough — the user needs - // Claude Code actually set up. - result, err := Detect(ctx, WithFileExists("~/.claude.json")) + // Check for the Claude Code CLI on PATH. Auth state is surfaced later by + // the ACP probe, not by the presence of ~/.claude.json. + result, err := Detect(ctx, WithCommand("claude")) if err != nil { return result, err } result.SupportsMCP = true - result.InstallationPaths = []string{expandHomePath("~/.claude.json")} result.Capabilities = DiscoveryCapabilities{ SupportsSessionResume: true, } return result, nil } -func (a *ClaudeACP) DefaultModel() string { return "claude-sonnet-4-6" } - -func (a *ClaudeACP) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: claudeCodeStaticModels(), SupportsDynamic: true}, nil -} - func (a *ClaudeACP) BuildCommand(opts CommandOptions) Command { return Cmd("npx", "-y", claudeACPPkg).Build() } @@ -140,7 +132,7 @@ func (a *ClaudeACP) InstallScript() string { } func (a *ClaudeACP) PermissionSettings() map[string]PermissionSetting { - return claudeCodePermSettings + return emptyPermSettings } // InferenceConfig returns configuration for one-shot inference using ACP. @@ -148,11 +140,5 @@ func (a *ClaudeACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, Command: NewCommand("npx", "-y", claudeACPPkg), - ModelFlag: NewParam("--model", "{model}"), } } - -// InferenceModels returns models available for one-shot inference tasks. -func (a *ClaudeACP) InferenceModels() []InferenceModel { - return ModelsToInferenceModels(claudeCodeStaticModels()) -} diff --git a/apps/backend/internal/agent/agents/claude_code.go b/apps/backend/internal/agent/agents/claude_code.go deleted file mode 100644 index b35f257e27..0000000000 --- a/apps/backend/internal/agent/agents/claude_code.go +++ /dev/null @@ -1,184 +0,0 @@ -package agents - -import ( - "context" - _ "embed" - "time" - - "github.com/kandev/kandev/pkg/agent" -) - -//go:embed logos/claude_code_light.svg -var claudeCodeLogoLight []byte - -//go:embed logos/claude_code_dark.svg -var claudeCodeLogoDark []byte - -const claudeCodePkg = "@anthropic-ai/claude-code@2.1.50" - -var ( - _ Agent = (*ClaudeCode)(nil) - _ PassthroughAgent = (*ClaudeCode)(nil) -) - -type ClaudeCode struct { - StandardPassthrough -} - -func NewClaudeCode() *ClaudeCode { - return &ClaudeCode{ - StandardPassthrough: StandardPassthrough{ - PermSettings: claudeCodePermSettings, - Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code", "--verbose"), - ModelFlag: NewParam("--model", "{model}"), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, - ResumeFlag: NewParam("-c"), - SessionResumeFlag: NewParam("--resume"), - }, - }, - } -} - -func (a *ClaudeCode) ID() string { return "claude-code" } -func (a *ClaudeCode) Name() string { return "Claude Code CLI Agent" } -func (a *ClaudeCode) DisplayName() string { return "Claude" } -func (a *ClaudeCode) Description() string { - return "Anthropic Claude Code CLI-powered autonomous coding agent using the stream-json protocol." -} -func (a *ClaudeCode) Enabled() bool { return false } -func (a *ClaudeCode) DisplayOrder() int { return 1 } - -func (a *ClaudeCode) Logo(v LogoVariant) []byte { - if v == LogoDark { - return claudeCodeLogoDark - } - return claudeCodeLogoLight -} - -func (a *ClaudeCode) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - result, err := Detect(ctx, WithFileExists("~/.claude.json")) - if err != nil { - return result, err - } - result.SupportsMCP = true - result.InstallationPaths = []string{expandHomePath("~/.claude.json")} - result.Capabilities = DiscoveryCapabilities{ - SupportsSessionResume: true, - } - return result, nil -} - -func (a *ClaudeCode) DefaultModel() string { return "claude-sonnet-4-6" } - -func (a *ClaudeCode) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: claudeCodeStaticModels(), SupportsDynamic: false}, nil -} - -func (a *ClaudeCode) BuildCommand(opts CommandOptions) Command { - b := Cmd("npx", "-y", claudeCodePkg, - "-p", "--output-format=stream-json", "--input-format=stream-json", - "--permission-prompt-tool=stdio", "--disallowedTools=AskUserQuestion", - "--setting-sources=user,project", "--verbose", - "--include-partial-messages", "--replay-user-messages"). - Model(NewParam("--model", "{model}"), opts.Model). - Resume(NewParam("--resume"), opts.SessionID, false). - ResumeAt(NewParam("--resume-session-at"), opts.ResumeAtMessageUUID) - - // When using supervised/plan mode with hooks, bypass the built-in permission - // system so hooks handle access control instead - switch opts.PermissionPolicy { - case "supervised", "plan": - b = b.Flag("--permission-mode", "bypassPermissions") - default: - b = b.Settings(claudeCodePermSettings, opts.PermissionValues) - } - - if opts.AgentType != "" { - b = b.Flag("--agent", opts.AgentType) - } - - return b.Build() -} - -func (a *ClaudeCode) Runtime() *RuntimeConfig { - canRecover := true - return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", claudeCodePkg, - "-p", "--output-format=stream-json", "--input-format=stream-json", - "--permission-prompt-tool=stdio", "--disallowedTools=AskUserQuestion", - "--setting-sources=user,project", "--verbose", - "--include-partial-messages", "--replay-user-messages").Build(), - WorkingDir: "{workspace}", - RequiredEnv: []string{"ANTHROPIC_API_KEY"}, - Env: map[string]string{ - "MCP_TIMEOUT": "7200000", // 2 hours in ms — long wait for clarification responses - }, - ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, - Protocol: agent.ProtocolClaudeCode, - ModelFlag: NewParam("--model", "{model}"), - SessionConfig: SessionConfig{ - ResumeFlag: NewParam("--resume"), - CanRecover: &canRecover, - SessionDirTemplate: "{home}/.claude", - }, - } -} - -func (a *ClaudeCode) RemoteAuth() *RemoteAuth { - return &RemoteAuth{ - Methods: []RemoteAuthMethod{ - { - Type: "env", - EnvVar: "CLAUDE_CODE_OAUTH_TOKEN", - SetupHint: "Run `claude setup-token` to generate a long-lived OAuth token", - SetupScript: `mkdir -p "${HOME}/.claude" -cat > "${HOME}/.claude/.credentials.json" < "${HOME}/.claude.json" <<'JSON' -{"hasCompletedOnboarding":true} -JSON -chmod 600 "${HOME}/.claude/.credentials.json" -chmod 600 "${HOME}/.claude.json"`, - }, - }, - } -} - -func (a *ClaudeCode) InstallScript() string { - return "npm install -g " + claudeCodePkg -} - -func (a *ClaudeCode) PermissionSettings() map[string]PermissionSetting { - return claudeCodePermSettings -} - -var claudeCodePermSettings = map[string]PermissionSetting{ - "auto_approve": { - Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls via stdio protocol", - ApplyMethod: "stdio", - }, - "dangerously_skip_permissions": { - Supported: true, Default: true, Label: "Skip Permissions", Description: "Bypass all permission checks (dangerous but fast for trusted tasks)", - ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions", - }, - "permission_policy": { - Supported: true, Default: false, Label: "Permission Policy", Description: "Control permission mode: autonomous (default), supervised (approve writes), plan (approve plan exit)", - ApplyMethod: "custom", - }, -} - -func claudeCodeStaticModels() []Model { - return []Model{ - {ID: "claude-sonnet-4-6", ACPID: "sonnet", Name: "Sonnet 4.6", Description: "Latest Sonnet model for coding and everyday tasks", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, - {ID: "claude-sonnet-4-5", ACPID: "sonnet", Name: "Sonnet 4.5", Description: "Previous Sonnet generation with strong reasoning", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "claude-opus-4-6", ACPID: "default", Name: "Opus 4.6", Description: "Latest and most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "claude-opus-4-5", ACPID: "default", Name: "Opus 4.5", Description: "Most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "claude-haiku-4-5", ACPID: "haiku", Name: "Haiku 4.5", Description: "Fast and affordable model for simple tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - } -} diff --git a/apps/backend/internal/agent/agents/codex.go b/apps/backend/internal/agent/agents/codex.go deleted file mode 100644 index 6d7718bd3f..0000000000 --- a/apps/backend/internal/agent/agents/codex.go +++ /dev/null @@ -1,149 +0,0 @@ -package agents - -import ( - "context" - _ "embed" - "time" - - "github.com/kandev/kandev/pkg/agent" -) - -//go:embed logos/codex_light.svg -var codexLogoLight []byte - -//go:embed logos/codex_dark.svg -var codexLogoDark []byte - -const codexPkg = "@openai/codex@0.104.0" - -var ( - _ Agent = (*Codex)(nil) - _ PassthroughAgent = (*Codex)(nil) -) - -type Codex struct { - StandardPassthrough -} - -func NewCodex() *Codex { - return &Codex{ - StandardPassthrough: StandardPassthrough{ - PermSettings: codexPermSettings, - Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", "@openai/codex", "--full-auto"), - ModelFlag: NewParam("--model", "{model}"), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, - }, - }, - } -} - -func (a *Codex) ID() string { return "codex" } -func (a *Codex) Name() string { return "OpenAI Codex Agent" } -func (a *Codex) DisplayName() string { return "Codex" } -func (a *Codex) Description() string { - return "OpenAI Codex CLI-powered autonomous coding agent using the Codex app-server protocol." -} -func (a *Codex) Enabled() bool { return false } -func (a *Codex) DisplayOrder() int { return 2 } - -func (a *Codex) Logo(v LogoVariant) []byte { - if v == LogoDark { - return codexLogoDark - } - return codexLogoLight -} - -func (a *Codex) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - result, err := Detect(ctx, WithFileExists("~/.codex/auth.json")) - if err != nil { - return result, err - } - result.SupportsMCP = true - result.InstallationPaths = []string{expandHomePath("~/.codex/auth.json")} - return result, nil -} - -func (a *Codex) DefaultModel() string { return "gpt-5.3-codex" } - -func (a *Codex) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: codexStaticModels(), SupportsDynamic: false}, nil -} - -func (a *Codex) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", codexPkg, "app-server"). - Model(NewParam("-c", "model=\"{model}\""), opts.Model). - Settings(codexPermSettings, opts.PermissionValues). - Build() -} - -func (a *Codex) Runtime() *RuntimeConfig { - canRecover := true - return &RuntimeConfig{ - Image: "kandev/multi-agent", - Tag: "latest", - Cmd: Cmd("npx", "-y", codexPkg, "app-server").Build(), - WorkingDir: "/workspace", - Env: map[string]string{}, - Mounts: []MountTemplate{ - {Source: "{workspace}", Target: "/workspace"}, - {Source: "{home}/.codex", Target: "/root/.codex"}, - }, - ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, - Protocol: agent.ProtocolCodex, - ModelFlag: NewParam("-c", "model=\"{model}\""), - SessionConfig: SessionConfig{ - NativeSessionResume: true, - CanRecover: &canRecover, - }, - } -} - -func (a *Codex) RemoteAuth() *RemoteAuth { - return &RemoteAuth{ - Methods: []RemoteAuthMethod{ - { - Type: "files", - Label: "Copy auth files", - SourceFiles: map[string][]string{ - "darwin": {".codex/auth.json", ".codex/config.toml"}, - "linux": {".codex/auth.json", ".codex/config.toml"}, - }, - TargetRelDir: ".codex", - }, - { - Type: "env", - EnvVar: "OPENAI_API_KEY", - }, - }, - } -} - -func (a *Codex) InstallScript() string { - return "npm install -g " + codexPkg -} - -func (a *Codex) PermissionSettings() map[string]PermissionSetting { - return codexPermSettings -} - -var codexPermSettings = map[string]PermissionSetting{ - "auto_approve": { - Supported: true, Default: true, Label: "Auto-approve", Description: "Skip approval requests from Codex", - ApplyMethod: "acp", CLIFlag: "--full-auto", - }, -} - -func codexStaticModels() []Model { - return []Model{ - {ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex", Description: "Most capable agentic coding model to date", Provider: "openai", ContextWindow: 400000, IsDefault: true, Source: "static"}, - {ID: "gpt-5.2-codex", Name: "GPT-5.2 Codex", Description: "Frontier agentic coding model", Provider: "openai", ContextWindow: 200000, Source: "static"}, - {ID: "gpt-5.1-codex-max", Name: "GPT-5.1 Codex Max", Description: "Codex-optimized flagship for deep and fast reasoning", Provider: "openai", ContextWindow: 200000, Source: "static"}, - {ID: "gpt-5.1-codex-mini", Name: "GPT-5.1 Codex Mini", Description: "Optimized for codex. Cheaper, faster, but less capable", Provider: "openai", ContextWindow: 200000, Source: "static"}, - {ID: "gpt-5.2", Name: "GPT-5.2", Description: "Latest frontier model with improvements across knowledge, reasoning and coding", Provider: "openai", ContextWindow: 200000, Source: "static"}, - } -} diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index e602ef7467..c10081c139 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -32,7 +32,7 @@ type CodexACP struct { func NewCodexACP() *CodexACP { return &CodexACP{ StandardPassthrough: StandardPassthrough{ - PermSettings: codexPermSettings, + PermSettings: emptyPermSettings, Cfg: PassthroughConfig{ Supported: true, Label: "CLI Passthrough", @@ -63,21 +63,16 @@ func (a *CodexACP) Logo(v LogoVariant) []byte { } func (a *CodexACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - result, err := Detect(ctx, WithFileExists("~/.codex/auth.json")) + // Check for the CLI binary on PATH. Auth state is surfaced later by the + // ACP probe (session/new returns auth_required if the user hasn't logged in). + result, err := Detect(ctx, WithCommand("codex-acp"), WithCommand("codex")) if err != nil { return result, err } result.SupportsMCP = true - result.InstallationPaths = []string{expandHomePath("~/.codex/auth.json")} return result, nil } -func (a *CodexACP) DefaultModel() string { return "gpt-5.3-codex" } - -func (a *CodexACP) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: codexStaticModels(), SupportsDynamic: false}, nil -} - func (a *CodexACP) BuildCommand(opts CommandOptions) Command { return Cmd("npx", "-y", codexACPPkg).Build() } @@ -129,7 +124,7 @@ func (a *CodexACP) InstallScript() string { } func (a *CodexACP) PermissionSettings() map[string]PermissionSetting { - return codexPermSettings + return emptyPermSettings } // InferenceConfig returns configuration for one-shot inference using ACP. @@ -137,11 +132,5 @@ func (a *CodexACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, Command: NewCommand("npx", "-y", codexACPPkg), - ModelFlag: NewParam("-m", "{model}"), } } - -// InferenceModels returns models available for one-shot inference. -func (a *CodexACP) InferenceModels() []InferenceModel { - return ModelsToInferenceModels(codexStaticModels()) -} diff --git a/apps/backend/internal/agent/agents/copilot.go b/apps/backend/internal/agent/agents/copilot.go deleted file mode 100644 index f3fcb81b83..0000000000 --- a/apps/backend/internal/agent/agents/copilot.go +++ /dev/null @@ -1,154 +0,0 @@ -package agents - -import ( - "context" - _ "embed" - "time" - - "github.com/kandev/kandev/pkg/agent" -) - -//go:embed logos/copilot_light.svg -var copilotLogoLight []byte - -//go:embed logos/copilot_dark.svg -var copilotLogoDark []byte - -const copilotPkg = "@github/copilot@0.0.406" - -var ( - _ Agent = (*Copilot)(nil) - _ PassthroughAgent = (*Copilot)(nil) -) - -type Copilot struct { - StandardPassthrough -} - -func NewCopilot() *Copilot { - return &Copilot{ - StandardPassthrough: StandardPassthrough{ - PermSettings: copilotPermSettings, - Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", "@github/copilot"), - ModelFlag: NewParam("--model", "{model}"), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, - ResumeFlag: NewParam("--continue"), - SessionResumeFlag: NewParam("--resume"), - }, - }, - } -} - -func (a *Copilot) ID() string { return "copilot" } -func (a *Copilot) Name() string { return "GitHub Copilot Agent" } -func (a *Copilot) DisplayName() string { return "Copilot" } -func (a *Copilot) Description() string { - return "GitHub Copilot CLI-powered autonomous coding agent using the Copilot SDK protocol." -} -func (a *Copilot) Enabled() bool { return false } -func (a *Copilot) DisplayOrder() int { return 6 } - -func (a *Copilot) Logo(v LogoVariant) []byte { - if v == LogoDark { - return copilotLogoDark - } - return copilotLogoLight -} - -func (a *Copilot) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - install := OSPaths{ - MacOS: []string{"~/.copilot/pkg"}, - } - mcp := OSPaths{ - Linux: []string{"~/.copilot/mcp-config.json"}, - MacOS: []string{"~/.copilot/mcp-config.json"}, - } - - result, err := Detect(ctx, WithFileExists(install.Resolve()...)) - if err != nil { - return result, err - } - result.SupportsMCP = true - result.InstallationPaths = install.Expanded() - result.MCPConfigPaths = mcp.Expanded() - result.Capabilities = DiscoveryCapabilities{ - SupportsSessionResume: true, - } - return result, nil -} - -func (a *Copilot) DefaultModel() string { return "gpt-5-mini" } - -func (a *Copilot) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: copilotStaticModels(), SupportsDynamic: false}, nil -} - -func (a *Copilot) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", copilotPkg, "--server", "--log-level", "error"). - Model(NewParam("--model", "{model}"), opts.Model). - Settings(copilotPermSettings, opts.PermissionValues). - Build() -} - -func (a *Copilot) Runtime() *RuntimeConfig { - canRecover := true - return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", copilotPkg, "--server", "--log-level", "error").Build(), - WorkingDir: "{workspace}", - Env: map[string]string{}, - ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, - Protocol: agent.ProtocolCopilot, - ModelFlag: NewParam("--model", "{model}"), - AssumeMcpSse: true, - SessionConfig: SessionConfig{ - NativeSessionResume: true, - ResumeFlag: NewParam("--resume"), - CanRecover: &canRecover, - SessionDirTemplate: "{home}/.copilot", - }, - } -} - -func (a *Copilot) RemoteAuth() *RemoteAuth { return nil } - -func (a *Copilot) InstallScript() string { - return "npm install -g " + copilotPkg -} - -func (a *Copilot) PermissionSettings() map[string]PermissionSetting { - return copilotPermSettings -} - -var copilotPermSettings = map[string]PermissionSetting{ - "auto_approve": { - Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Enable all tools, folders and urls without confirmation.", - ApplyMethod: "cli_flag", CLIFlag: "--yolo", - }, -} - -func copilotStaticModels() []Model { - return []Model{ - {ID: "claude-sonnet-4.6", Name: "Claude Sonnet 4.6", Provider: "anthropic", Source: "static"}, - {ID: "claude-sonnet-4.5", Name: "Claude Sonnet 4.5", Provider: "anthropic", Source: "static"}, - {ID: "claude-opus-4.6", Name: "Claude Opus 4.6", Provider: "anthropic", Source: "static"}, - {ID: "claude-opus-4.5", Name: "Claude Opus 4.5", Provider: "anthropic", Source: "static"}, - {ID: "claude-haiku-4.5", Name: "Claude Haiku 4.5", Provider: "anthropic", Source: "static"}, - {ID: "claude-sonnet-4", Name: "Claude Sonnet 4", Provider: "anthropic", Source: "static"}, - {ID: "gemini-3-pro-preview", Name: "Gemini 3 Pro (Preview)", Provider: "google", Source: "static"}, - {ID: "gpt-5.4", Name: "GPT-5.4", Provider: "openai", Source: "static"}, - {ID: "gpt-5.3-codex", Name: "GPT-5.3-Codex", Provider: "openai", Source: "static"}, - {ID: "gpt-5.2-codex", Name: "GPT-5.2-Codex", Provider: "openai", Source: "static"}, - {ID: "gpt-5.2", Name: "GPT-5.2", Provider: "openai", Source: "static"}, - {ID: "gpt-5.1-codex-max", Name: "GPT-5.1-Codex-Max", Provider: "openai", Source: "static"}, - {ID: "gpt-5.1-codex", Name: "GPT-5.1-Codex", Provider: "openai", Source: "static"}, - {ID: "gpt-5.1", Name: "GPT-5.1", Provider: "openai", Source: "static"}, - {ID: "gpt-5.1-codex-mini", Name: "GPT-5.1-Codex-Mini", Provider: "openai", Source: "static"}, - {ID: "gpt-5-mini", Name: "GPT-5 Mini", Provider: "openai", Source: "static", IsDefault: true}, - {ID: "gpt-4.1", Name: "GPT-4.1", Provider: "openai", Source: "static"}, - } -} diff --git a/apps/backend/internal/agent/agents/copilot_acp.go b/apps/backend/internal/agent/agents/copilot_acp.go index bd38931dd8..07c1fc3ebb 100644 --- a/apps/backend/internal/agent/agents/copilot_acp.go +++ b/apps/backend/internal/agent/agents/copilot_acp.go @@ -32,7 +32,7 @@ type CopilotACP struct { func NewCopilotACP() *CopilotACP { return &CopilotACP{ StandardPassthrough: StandardPassthrough{ - PermSettings: copilotPermSettings, + PermSettings: emptyPermSettings, Cfg: PassthroughConfig{ Supported: true, Label: "CLI Passthrough", @@ -65,38 +65,21 @@ func (a *CopilotACP) Logo(v LogoVariant) []byte { } func (a *CopilotACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - install := OSPaths{ - MacOS: []string{"~/.copilot/pkg"}, - } - mcp := OSPaths{ - Linux: []string{"~/.copilot/mcp-config.json"}, - MacOS: []string{"~/.copilot/mcp-config.json"}, - } - - result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + // Check for the copilot CLI on PATH. Auth state is surfaced later by + // the ACP probe, not by scanning ~/.copilot. + result, err := Detect(ctx, WithCommand("copilot")) if err != nil { return result, err } result.SupportsMCP = true - result.InstallationPaths = install.Expanded() - result.MCPConfigPaths = mcp.Expanded() result.Capabilities = DiscoveryCapabilities{ SupportsSessionResume: true, } return result, nil } -func (a *CopilotACP) DefaultModel() string { return "gpt-4.1" } - -func (a *CopilotACP) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: copilotStaticModels(), SupportsDynamic: false}, nil -} - func (a *CopilotACP) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", copilotACPPkg, "--acp"). - Model(NewParam("--model", "{model}"), opts.Model). - Settings(copilotPermSettings, opts.PermissionValues). - Build() + return Cmd("npx", "-y", copilotACPPkg, "--acp").Build() } func (a *CopilotACP) Runtime() *RuntimeConfig { @@ -107,7 +90,6 @@ func (a *CopilotACP) Runtime() *RuntimeConfig { Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Protocol: agent.ProtocolACP, - ModelFlag: NewParam("--model", "{model}"), SessionConfig: SessionConfig{ NativeSessionResume: true, CanRecover: &canRecover, @@ -123,7 +105,7 @@ func (a *CopilotACP) InstallScript() string { } func (a *CopilotACP) PermissionSettings() map[string]PermissionSetting { - return copilotPermSettings + return emptyPermSettings } // InferenceConfig returns configuration for one-shot inference using ACP. @@ -131,11 +113,5 @@ func (a *CopilotACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, Command: NewCommand("npx", "-y", copilotACPPkg, "--acp"), - ModelFlag: NewParam("--model", "{model}"), } } - -// InferenceModels returns models available for one-shot inference. -func (a *CopilotACP) InferenceModels() []InferenceModel { - return ModelsToInferenceModels(copilotStaticModels()) -} diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index 1ae01b85b0..10166baa07 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -19,6 +19,7 @@ const geminiPkg = "@google/gemini-cli" var ( _ Agent = (*Gemini)(nil) _ PassthroughAgent = (*Gemini)(nil) + _ InferenceAgent = (*Gemini)(nil) ) type Gemini struct { @@ -28,7 +29,7 @@ type Gemini struct { func NewGemini() *Gemini { return &Gemini{ StandardPassthrough: StandardPassthrough{ - PermSettings: geminiPermSettings, + PermSettings: emptyPermSettings, Cfg: PassthroughConfig{ Supported: true, Label: "CLI Passthrough", @@ -61,39 +62,21 @@ func (a *Gemini) Logo(v LogoVariant) []byte { } func (a *Gemini) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - install := OSPaths{ - Linux: []string{"~/.gemini/oauth_creds.json", "~/.gemini/installation_id"}, - MacOS: []string{"~/.gemini/oauth_creds.json", "~/.gemini/installation_id"}, - } - mcp := OSPaths{ - Linux: []string{"~/.gemini/settings.json"}, - MacOS: []string{"~/.gemini/settings.json"}, - } - - result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + // Check for the gemini CLI on PATH. Auth state is surfaced later by the + // ACP probe, not by scanning ~/.gemini. + result, err := Detect(ctx, WithCommand("gemini")) if err != nil { return result, err } result.SupportsMCP = true - result.InstallationPaths = install.Expanded() - result.MCPConfigPaths = mcp.Expanded() result.Capabilities = DiscoveryCapabilities{ SupportsSessionResume: true, } return result, nil } -func (a *Gemini) DefaultModel() string { return "gemini-3-flash-preview" } - -func (a *Gemini) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: geminiStaticModels(), SupportsDynamic: false}, nil -} - func (a *Gemini) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", geminiPkg, "--acp"). - Model(NewParam("--model", "{model}"), opts.Model). - Settings(geminiPermSettings, opts.PermissionValues). - Build() + return Cmd("npx", "-y", geminiPkg, "--acp").Build() } func (a *Gemini) Runtime() *RuntimeConfig { @@ -104,7 +87,6 @@ func (a *Gemini) Runtime() *RuntimeConfig { Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Protocol: agent.ProtocolACP, - ModelFlag: NewParam("--model", "{model}"), SessionConfig: SessionConfig{ CanRecover: &canRecover, SessionDirTemplate: "{home}/.gemini", @@ -137,19 +119,15 @@ func (a *Gemini) InstallScript() string { } func (a *Gemini) PermissionSettings() map[string]PermissionSetting { - return geminiPermSettings + return emptyPermSettings } -var geminiPermSettings = map[string]PermissionSetting{ - "auto_approve": { - Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Automatically approve all tool calls", - ApplyMethod: "cli_flag", CLIFlag: "--yolo --allowed-tools run_shell_command", - }, -} - -func geminiStaticModels() []Model { - return []Model{ - {ID: "gemini-3-flash-preview", Name: "3 Flash", Description: "Fast and efficient model", Provider: "google", ContextWindow: 1000000, IsDefault: true, Source: "static"}, - {ID: "gemini-3-pro-preview", Name: "3 Pro", Description: "Most capable model with 2M context", Provider: "google", ContextWindow: 2000000, Source: "static"}, +// InferenceConfig returns configuration for one-shot inference using ACP. +// Gemini CLI speaks ACP natively via the --acp flag, so there's no separate +// _acp variant. +func (a *Gemini) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("npx", "-y", geminiPkg, "--acp"), } } diff --git a/apps/backend/internal/agent/agents/helpers.go b/apps/backend/internal/agent/agents/helpers.go index 71521133d1..dddab49cfd 100644 --- a/apps/backend/internal/agent/agents/helpers.go +++ b/apps/backend/internal/agent/agents/helpers.go @@ -1,14 +1,14 @@ package agents import ( - "bytes" - "context" - "fmt" - "os/exec" "strings" - "time" ) +// emptyPermSettings is a shared zero-value permission settings map used by ACP +// agents that have no CLI-driven permission flags. Permission stance is +// expressed via ACP session modes and per-tool-call permission prompts. +var emptyPermSettings = map[string]PermissionSetting{} + // CmdBuilder constructs CLI command slices using a fluent API. type CmdBuilder struct { args []string @@ -114,26 +114,3 @@ func (b *CmdBuilder) Flag(parts ...string) *CmdBuilder { func (b *CmdBuilder) Build() Command { return Command{args: b.args} } - -// execAndParse runs a command with a timeout and parses stdout with the given parser function. -func execAndParse(ctx context.Context, timeout time.Duration, parse func(string) ([]Model, error), name string, args ...string) ([]Model, error) { - if timeout <= 0 { - timeout = 30 * time.Second - } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, name, args...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return nil, fmt.Errorf("command timed out after %v", timeout) - } - return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) - } - - return parse(stdout.String()) -} diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 8e247e52dd..fc471bf9e3 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -84,20 +84,12 @@ func (a *MockAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { return &DiscoveryResult{Available: true, SupportsMCP: a.supportsMCP}, nil } -func (a *MockAgent) DefaultModel() string { return "mock-default" } - -func (a *MockAgent) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: mockStaticModels(), SupportsDynamic: false}, nil -} - func (a *MockAgent) BuildCommand(opts CommandOptions) Command { binary := "mock-agent" if a.binaryPath != "" { binary = a.binaryPath } - return Cmd(binary). - Model(NewParam("--model", "{model}"), opts.Model). - Build() + return Cmd(binary).Build() } func (a *MockAgent) Runtime() *RuntimeConfig { @@ -108,7 +100,6 @@ func (a *MockAgent) Runtime() *RuntimeConfig { Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, Protocol: agent.ProtocolACP, - ModelFlag: NewParam("--model", "{model}"), SessionConfig: SessionConfig{ CanRecover: &canRecover, }, @@ -120,19 +111,5 @@ func (a *MockAgent) RemoteAuth() *RemoteAuth { return nil } func (a *MockAgent) InstallScript() string { return "" } func (a *MockAgent) PermissionSettings() map[string]PermissionSetting { - return mockPermSettings -} - -var mockPermSettings = map[string]PermissionSetting{ - "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls", - ApplyMethod: "stdio"}, - "dangerously_skip_permissions": {Supported: true, Default: false, Label: "Skip Permissions (YOLO)", Description: "Bypass all permission checks"}, -} - -func mockStaticModels() []Model { - return []Model{ - {ID: "mock-default", Name: "Mock Default", Provider: "mock", ContextWindow: 200000, IsDefault: true, Source: "static"}, - {ID: "mock-slow", Name: "Mock Slow", Description: "Longer delays", Provider: "mock", ContextWindow: 200000, Source: "static"}, - {ID: "mock-fast", Name: "Mock Fast", Description: "Minimal delays", Provider: "mock", ContextWindow: 200000, Source: "static"}, - } + return emptyPermSettings } diff --git a/apps/backend/internal/agent/agents/mock_test.go b/apps/backend/internal/agent/agents/mock_test.go index d3a2d0dcac..2c7703476b 100644 --- a/apps/backend/internal/agent/agents/mock_test.go +++ b/apps/backend/internal/agent/agents/mock_test.go @@ -6,20 +6,17 @@ import ( "github.com/kandev/kandev/pkg/agent" ) -func TestMockAgent_BuildCommand_WithModel(t *testing.T) { +func TestMockAgent_BuildCommand_NoModelFlag(t *testing.T) { + // ACP agents apply model via session/set_model after session/new, not + // via --model CLI flag. BuildCommand must not add --model. a := NewMockAgent() cmd := a.BuildCommand(CommandOptions{Model: "mock-fast"}) args := cmd.Args() - - foundModel := false - for i, arg := range args { - if arg == "--model" && i+1 < len(args) && args[i+1] == "mock-fast" { - foundModel = true + for _, arg := range args { + if arg == "--model" { + t.Errorf("--model CLI flag should not be emitted, got %v", args) } } - if !foundModel { - t.Errorf("expected --model mock-fast in args %v", args) - } } func TestMockAgent_BuildCommand_NoResumeFlag(t *testing.T) { diff --git a/apps/backend/internal/agent/agents/model_resolve_test.go b/apps/backend/internal/agent/agents/model_resolve_test.go deleted file mode 100644 index 0dfd710273..0000000000 --- a/apps/backend/internal/agent/agents/model_resolve_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package agents - -import "testing" - -func TestACPModelID(t *testing.T) { - tests := []struct { - name string - model Model - want string - }{ - { - name: "returns ACPID when set", - model: Model{ID: "claude-opus-4-6", ACPID: "default"}, - want: "default", - }, - { - name: "falls back to ID when ACPID empty", - model: Model{ID: "gpt-5-1"}, - want: "gpt-5-1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.model.ACPModelID(); got != tt.want { - t.Errorf("ACPModelID() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestResolveACPModelID(t *testing.T) { - models := []Model{ - {ID: "claude-sonnet-4-6", ACPID: "sonnet"}, - {ID: "claude-opus-4-6", ACPID: "default"}, - {ID: "claude-haiku-4-5", ACPID: "haiku"}, - {ID: "gpt-5-1"}, // no ACPID - } - - tests := []struct { - name string - profileModel string - want string - }{ - { - name: "resolves to ACPID when mapping exists", - profileModel: "claude-opus-4-6", - want: "default", - }, - { - name: "resolves sonnet mapping", - profileModel: "claude-sonnet-4-6", - want: "sonnet", - }, - { - name: "returns original ID when no ACPID", - profileModel: "gpt-5-1", - want: "gpt-5-1", - }, - { - name: "returns original ID when model not in list", - profileModel: "unknown-model", - want: "unknown-model", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ResolveACPModelID(models, tt.profileModel); got != tt.want { - t.Errorf("ResolveACPModelID() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestResolveACPModelID_EmptyList(t *testing.T) { - got := ResolveACPModelID(nil, "claude-opus-4-6") - if got != "claude-opus-4-6" { - t.Errorf("ResolveACPModelID(nil) = %q, want %q", got, "claude-opus-4-6") - } -} diff --git a/apps/backend/internal/agent/agents/opencode.go b/apps/backend/internal/agent/agents/opencode.go deleted file mode 100644 index 90957127d8..0000000000 --- a/apps/backend/internal/agent/agents/opencode.go +++ /dev/null @@ -1,176 +0,0 @@ -package agents - -import ( - "context" - _ "embed" - "strings" - "time" - - "github.com/kandev/kandev/pkg/agent" -) - -//go:embed logos/opencode_light.svg -var opencodeLogoLight []byte - -//go:embed logos/opencode_dark.svg -var opencodeLogoDark []byte - -const opencodePkg = "opencode-ai@1.1.59" - -var ( - _ Agent = (*OpenCode)(nil) - _ PassthroughAgent = (*OpenCode)(nil) -) - -type OpenCode struct { - StandardPassthrough -} - -func NewOpenCode() *OpenCode { - return &OpenCode{ - StandardPassthrough: StandardPassthrough{ - PermSettings: opencodePermSettings, - Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("opencode"), - ModelFlag: NewParam("--model", "{model}"), - PromptFlag: NewParam("--prompt", "{prompt}"), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, - ResumeFlag: NewParam("-c"), - }, - }, - } -} - -func (a *OpenCode) ID() string { return "opencode" } -func (a *OpenCode) Name() string { return "OpenCode AI Agent" } -func (a *OpenCode) DisplayName() string { return "OpenCode" } -func (a *OpenCode) Description() string { - return "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol." -} -func (a *OpenCode) Enabled() bool { return false } -func (a *OpenCode) DisplayOrder() int { return 4 } - -func (a *OpenCode) Logo(v LogoVariant) []byte { - if v == LogoDark { - return opencodeLogoDark - } - return opencodeLogoLight -} - -func (a *OpenCode) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - install := OSPaths{ - Linux: []string{"~/.opencode", "~/.config/opencode"}, - MacOS: []string{"~/.opencode", "~/.config/opencode", "~/Library/Application Support/ai.opencode.desktop"}, - } - mcp := OSPaths{ - Linux: []string{"~/.config/opencode/opencode.json", "~/.config/opencode/opencode.jsonc"}, - MacOS: []string{"~/.config/opencode/opencode.json", "~/.config/opencode/opencode.jsonc", "~/Library/Application Support/opencode/opencode.json"}, - } - - result, err := Detect(ctx, WithFileExists(install.Resolve()...)) - if err != nil { - return result, err - } - result.SupportsMCP = true - result.InstallationPaths = install.Expanded() - result.MCPConfigPaths = mcp.Expanded() - result.Capabilities = DiscoveryCapabilities{ - SupportsSessionResume: true, - } - return result, nil -} - -func (a *OpenCode) DefaultModel() string { return "opencode/gpt-5-nano" } - -func (a *OpenCode) ListModels(ctx context.Context) (*ModelList, error) { - models, err := execAndParse(ctx, 30*time.Second, opencodeParseModels, "opencode", "models") - if err != nil { - return &ModelList{Models: opencodeStaticModels(), SupportsDynamic: true}, nil - } - return &ModelList{Models: models, SupportsDynamic: true}, nil -} - -func (a *OpenCode) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", opencodePkg, "serve", "--hostname", "127.0.0.1", "--port", "0").Build() -} - -func (a *OpenCode) Runtime() *RuntimeConfig { - canRecover := true - return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", opencodePkg, "serve", "--hostname", "127.0.0.1", "--port", "0").Build(), - WorkingDir: "{workspace}", - Env: map[string]string{}, - ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, - Protocol: agent.ProtocolOpenCode, - SessionConfig: SessionConfig{ - NativeSessionResume: true, - CanRecover: &canRecover, - SessionDirTemplate: "{home}/.opencode", - }, - } -} - -func (a *OpenCode) RemoteAuth() *RemoteAuth { return nil } - -func (a *OpenCode) InstallScript() string { - return "npm install -g " + opencodePkg -} - -func (a *OpenCode) PermissionSettings() map[string]PermissionSetting { - return opencodePermSettings -} - -var opencodePermSettings = map[string]PermissionSetting{ - "auto_approve": { - Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls", - ApplyMethod: "env", - }, -} - -func opencodeStaticModels() []Model { - return []Model{ - {ID: "opencode/gpt-5-nano", Name: "GPT-5 Nano", Description: "OpenAI GPT-5 Nano", Provider: "openai", ContextWindow: 200000, IsDefault: true, Source: "static"}, - {ID: "anthropic/claude-sonnet-4-20250514", Name: "Claude Sonnet 4", Description: "Anthropic Claude Sonnet 4", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "anthropic/claude-opus-4-20250514", Name: "Claude Opus 4", Description: "Anthropic Claude Opus 4", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "openai/gpt-4.1", Name: "GPT-4.1", Description: "OpenAI GPT-4.1", Provider: "openai", ContextWindow: 200000, Source: "static"}, - {ID: "google/gemini-2.5-pro", Name: "Gemini 2.5 Pro", Description: "Google Gemini 2.5 Pro", Provider: "google", ContextWindow: 2000000, Source: "static"}, - } -} - -// opencodeParseModels parses "opencode models" output. -// Format: one model ID per line, optionally in "provider/model" format. -func opencodeParseModels(output string) ([]Model, error) { - lines := strings.Split(strings.TrimSpace(output), "\n") - models := make([]Model, 0, len(lines)) - defaultModel := "opencode/gpt-5-nano" - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - var provider, modelID, name string - if idx := strings.LastIndex(line, "/"); idx > 0 { - provider = line[:idx] - modelID = line - name = line[idx+1:] - } else { - modelID = line - name = line - } - - models = append(models, Model{ - ID: modelID, - Name: name, - Provider: provider, - IsDefault: modelID == defaultModel, - Source: "dynamic", - }) - } - return models, nil -} diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index 28556855ab..3b9b06c2dc 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -2,11 +2,20 @@ package agents import ( "context" + _ "embed" "time" "github.com/kandev/kandev/pkg/agent" ) +//go:embed logos/opencode_light.svg +var opencodeACPLogoLight []byte + +//go:embed logos/opencode_dark.svg +var opencodeACPLogoDark []byte + +const opencodeACPPkg = "opencode-ai" + var ( _ Agent = (*OpenCodeACP)(nil) _ PassthroughAgent = (*OpenCodeACP)(nil) @@ -22,7 +31,7 @@ type OpenCodeACP struct { func NewOpenCodeACP() *OpenCodeACP { return &OpenCodeACP{ StandardPassthrough: StandardPassthrough{ - PermSettings: opencodePermSettings, + PermSettings: emptyPermSettings, Cfg: PassthroughConfig{ Supported: true, Label: "CLI Passthrough", @@ -49,36 +58,25 @@ func (a *OpenCodeACP) DisplayOrder() int { return 4 } func (a *OpenCodeACP) Logo(v LogoVariant) []byte { if v == LogoDark { - return opencodeLogoDark + return opencodeACPLogoDark } - return opencodeLogoLight + return opencodeACPLogoLight } func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - // Same detection as SSE variant — both share the same binary - install := OSPaths{ - Linux: []string{"~/.opencode", "~/.config/opencode"}, - MacOS: []string{"~/.opencode", "~/.config/opencode", "~/Library/Application Support/ai.opencode.desktop"}, - } - - result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + // Check for the opencode CLI on PATH. Auth state is surfaced later by + // the ACP probe, not by scanning ~/.opencode. + result, err := Detect(ctx, WithCommand("opencode")) if err != nil { return result, err } result.SupportsMCP = true - result.InstallationPaths = install.Expanded() result.Capabilities = DiscoveryCapabilities{ SupportsSessionResume: true, } return result, nil } -func (a *OpenCodeACP) DefaultModel() string { return "opencode/gpt-5-nano" } - -func (a *OpenCodeACP) ListModels(ctx context.Context) (*ModelList, error) { - return (&OpenCode{}).ListModels(ctx) -} - func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command { return Cmd("opencode", "acp").Build() } @@ -102,11 +100,11 @@ func (a *OpenCodeACP) Runtime() *RuntimeConfig { func (a *OpenCodeACP) RemoteAuth() *RemoteAuth { return nil } func (a *OpenCodeACP) InstallScript() string { - return "npm install -g " + opencodePkg + return "npm install -g " + opencodeACPPkg } func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting { - return opencodePermSettings + return emptyPermSettings } // InferenceConfig returns configuration for one-shot inference using ACP. @@ -114,11 +112,5 @@ func (a *OpenCodeACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, Command: NewCommand("opencode", "acp"), - ModelFlag: NewParam("--model", "{model}"), } } - -// InferenceModels returns models available for one-shot inference. -func (a *OpenCodeACP) InferenceModels() []InferenceModel { - return ModelsToInferenceModels(opencodeStaticModels()) -} diff --git a/apps/backend/internal/agent/agents/tui_agent.go b/apps/backend/internal/agent/agents/tui_agent.go index 63d0d0a688..3d231dbe3e 100644 --- a/apps/backend/internal/agent/agents/tui_agent.go +++ b/apps/backend/internal/agent/agents/tui_agent.go @@ -106,12 +106,6 @@ func (a *TUIAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { return Detect(ctx, a.cfg.DetectOpts...) } -func (a *TUIAgent) DefaultModel() string { return "" } - -func (a *TUIAgent) ListModels(_ context.Context) (*ModelList, error) { - return &ModelList{Models: nil, SupportsDynamic: false}, nil -} - func (a *TUIAgent) BuildCommand(_ CommandOptions) Command { return a.cmd } diff --git a/apps/backend/internal/agent/agents/tui_agent_test.go b/apps/backend/internal/agent/agents/tui_agent_test.go deleted file mode 100644 index 204f929c9b..0000000000 --- a/apps/backend/internal/agent/agents/tui_agent_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package agents - -import ( - "context" - "testing" - - "github.com/kandev/kandev/pkg/agent" -) - -func TestNewTUIAgent_Defaults(t *testing.T) { - a := NewTUIAgent(TUIAgentConfig{ - AgentID: "test-tui", - AgentName: "TestTUI", - Command: "testtui", - Desc: "A test TUI agent.", - }) - - if a.ID() != "test-tui" { - t.Errorf("ID() = %q, want %q", a.ID(), "test-tui") - } - if a.Name() != "TestTUI" { - t.Errorf("Name() = %q, want %q", a.Name(), "TestTUI") - } - if a.DisplayName() != "TestTUI" { - t.Errorf("DisplayName() should default to AgentName, got %q", a.DisplayName()) - } - if a.DisplayOrder() != 99 { - t.Errorf("DisplayOrder() = %d, want 99 (default)", a.DisplayOrder()) - } - if !a.Enabled() { - t.Error("Enabled() should return true") - } - - rt := a.Runtime() - if rt.Protocol != agent.ProtocolACP { - t.Errorf("Protocol = %q, want %q (default)", rt.Protocol, agent.ProtocolACP) - } - - pt := a.PassthroughConfig() - if pt.IdleTimeout.Seconds() != 3 { - t.Errorf("IdleTimeout = %v, want 3s", pt.IdleTimeout) - } - if pt.BufferMaxBytes != DefaultBufferMaxBytes { - t.Errorf("BufferMaxBytes = %d, want %d", pt.BufferMaxBytes, DefaultBufferMaxBytes) - } -} - -func intPtr(v int) *int { return &v } - -func TestNewTUIAgent_CustomValues(t *testing.T) { - a := NewTUIAgent(TUIAgentConfig{ - AgentID: "lazydocker", - AgentName: "Lazydocker", - Command: "lazydocker", - Desc: "Docker TUI.", - Display: "Docker UI", - Order: intPtr(50), - WaitForTerm: true, - CommandArgs: []string{"--debug"}, - }) - - if a.DisplayName() != "Docker UI" { - t.Errorf("DisplayName() = %q, want %q", a.DisplayName(), "Docker UI") - } - if a.DisplayOrder() != 50 { - t.Errorf("DisplayOrder() = %d, want 50", a.DisplayOrder()) - } - - cmd := a.BuildCommand(CommandOptions{}) - args := cmd.Args() - if len(args) != 2 || args[0] != "lazydocker" || args[1] != "--debug" { - t.Errorf("BuildCommand() = %v, want [lazydocker --debug]", args) - } - - pt := a.PassthroughConfig() - if !pt.WaitForTerminal { - t.Error("WaitForTerminal should be true") - } - - ptCmd := pt.PassthroughCmd.Args() - if len(ptCmd) != 2 || ptCmd[0] != "lazydocker" || ptCmd[1] != "--debug" { - t.Errorf("PassthroughCmd = %v, want [lazydocker --debug]", ptCmd) - } -} - -func TestNewTUIAgent_OrderZeroIsValid(t *testing.T) { - a := NewTUIAgent(TUIAgentConfig{ - AgentID: "zero-order", AgentName: "ZeroOrder", Command: "zo", Desc: "desc", - Order: intPtr(0), - }) - if a.DisplayOrder() != 0 { - t.Errorf("DisplayOrder() = %d, want 0 (explicit zero should not be overridden)", a.DisplayOrder()) - } -} - -func TestNewTUIAgent_Logo(t *testing.T) { - // No logos - a := NewTUIAgent(TUIAgentConfig{ - AgentID: "no-logo", AgentName: "NoLogo", Command: "nol", Desc: "No logo.", - }) - if a.Logo(LogoLight) != nil { - t.Error("Logo(Light) should be nil when no logo provided") - } - if a.Logo(LogoDark) != nil { - t.Error("Logo(Dark) should be nil when no logo provided") - } - - // With logos - light := []byte("light") - dark := []byte("dark") - b := NewTUIAgent(TUIAgentConfig{ - AgentID: "with-logo", AgentName: "WithLogo", Command: "wl", Desc: "With logo.", - LogoLight: light, LogoDark: dark, - }) - if string(b.Logo(LogoLight)) != string(light) { - t.Errorf("Logo(Light) = %q, want %q", b.Logo(LogoLight), light) - } - if string(b.Logo(LogoDark)) != string(dark) { - t.Errorf("Logo(Dark) = %q, want %q", b.Logo(LogoDark), dark) - } -} - -func TestNewTUIAgent_ModelMethods(t *testing.T) { - a := NewTUIAgent(TUIAgentConfig{ - AgentID: "tui", AgentName: "TUI", Command: "tui", Desc: "desc", - }) - - if a.DefaultModel() != "" { - t.Errorf("DefaultModel() = %q, want empty", a.DefaultModel()) - } - - models, err := a.ListModels(context.Background()) - if err != nil { - t.Fatalf("ListModels() error = %v", err) - } - if models.SupportsDynamic { - t.Error("SupportsDynamic should be false") - } - if models.Models != nil { - t.Errorf("Models should be nil, got %v", models.Models) - } -} - -func TestNewTUIAgent_PermissionSettings(t *testing.T) { - a := NewTUIAgent(TUIAgentConfig{ - AgentID: "tui", AgentName: "TUI", Command: "tui", Desc: "desc", - }) - - if a.PermissionSettings() != nil { - t.Error("PermissionSettings() should return nil for TUI agents") - } -} - -func TestNewTUIAgent_CustomDetectOpts(t *testing.T) { - customDetect := WithFileExists("/nonexistent/path") - a := NewTUIAgent(TUIAgentConfig{ - AgentID: "custom-detect", - AgentName: "CustomDetect", - Command: "cd", - Desc: "Custom detection.", - DetectOpts: []DetectOption{customDetect}, - }) - - result, err := a.IsInstalled(context.Background()) - if err != nil { - t.Fatalf("IsInstalled() error = %v", err) - } - // Custom detect with nonexistent path should return not available - if result.Available { - t.Error("expected not available for nonexistent path") - } -} diff --git a/apps/backend/internal/agent/capabilities/handlers/handlers.go b/apps/backend/internal/agent/capabilities/handlers/handlers.go new file mode 100644 index 0000000000..88992d0855 --- /dev/null +++ b/apps/backend/internal/agent/capabilities/handlers/handlers.go @@ -0,0 +1,133 @@ +// Package handlers exposes the /api/v1/agents/* HTTP routes backed by the +// host utility manager: list cached capabilities, re-probe an agent type, and +// run raw one-off prompts against warm host instances. +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/common/logger" +) + +// Handlers holds dependencies for the capability routes. +type Handlers struct { + hostUtility *hostutility.Manager + logger *logger.Logger +} + +// RegisterRoutes mounts the capability routes on the given router. +// +// These endpoints sit under `/api/v1/agent-capabilities` rather than under +// `/api/v1/agents/...` to avoid a routing conflict with the existing settings +// handler which uses `/api/v1/agents/:id/...` — Gin does not allow two +// different wildcard names (:id vs :type) at the same path level. +func RegisterRoutes(router *gin.Engine, hostUtility *hostutility.Manager, log *logger.Logger) { + h := &Handlers{ + hostUtility: hostUtility, + logger: log.WithFields(zap.String("component", "agent-capabilities-handlers")), + } + api := router.Group("/api/v1/agent-capabilities") + api.GET("", h.list) + api.GET("/:type", h.get) + api.POST("/:type/probe", h.probe) + api.POST("/:type/prompt", h.prompt) +} + +type capabilitiesResponse struct { + Agents []hostutility.AgentCapabilities `json:"agents"` +} + +func (h *Handlers) list(c *gin.Context) { + // Always return a slice (never nil) so the JSON body is `[]` not `null`. + if h.hostUtility == nil { + c.JSON(http.StatusOK, capabilitiesResponse{Agents: []hostutility.AgentCapabilities{}}) + return + } + agents := h.hostUtility.GetAll() + if agents == nil { + agents = []hostutility.AgentCapabilities{} + } + c.JSON(http.StatusOK, capabilitiesResponse{Agents: agents}) +} + +func (h *Handlers) get(c *gin.Context) { + if h.hostUtility == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "host utility not configured"}) + return + } + caps, ok := h.hostUtility.Get(c.Param("type")) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "agent type not found"}) + return + } + c.JSON(http.StatusOK, caps) +} + +func (h *Handlers) probe(c *gin.Context) { + if h.hostUtility == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "host utility not configured"}) + return + } + agentType := c.Param("type") + caps, err := h.hostUtility.Refresh(c.Request.Context(), agentType) + if err != nil { + h.logger.Warn("probe refresh failed", + zap.String("agent_type", agentType), zap.Error(err)) + // Don't forward the raw error to clients — it may include tmp paths + // or subprocess diagnostics. The status and sanitized message live + // on the capability record itself (returned inline below). + c.JSON(http.StatusInternalServerError, gin.H{"error": "probe failed"}) + return + } + c.JSON(http.StatusOK, caps) +} + +// promptRequest is the body for POST /api/v1/agents/{type}/prompt. +type promptRequest struct { + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Prompt string `json:"prompt" binding:"required"` +} + +// promptResponse mirrors hostutility.PromptResult for JSON output. +type promptResponse struct { + Response string `json:"response"` + Model string `json:"model,omitempty"` + PromptTokens int `json:"prompt_tokens,omitempty"` + ResponseTokens int `json:"response_tokens,omitempty"` + DurationMs int `json:"duration_ms,omitempty"` +} + +func (h *Handlers) prompt(c *gin.Context) { + if h.hostUtility == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "host utility not configured"}) + return + } + var req promptRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + agentType := c.Param("type") + result, err := h.hostUtility.ExecutePrompt(c.Request.Context(), agentType, req.Model, req.Mode, req.Prompt) + if err != nil { + h.logger.Warn("raw prompt failed", + zap.String("agent_type", agentType), zap.Error(err)) + // Log the full error above but return a sanitized message to the + // client — raw subprocess/ACP errors can include tmp paths and + // stack traces that are not appropriate for external responses. + c.JSON(http.StatusInternalServerError, gin.H{"error": "prompt execution failed"}) + return + } + c.JSON(http.StatusOK, promptResponse{ + Response: result.Response, + Model: result.Model, + PromptTokens: result.PromptTokens, + ResponseTokens: result.ResponseTokens, + DurationMs: result.DurationMs, + }) +} diff --git a/apps/backend/internal/agent/discovery/discovery.go b/apps/backend/internal/agent/discovery/discovery.go index b2d85b9dd4..44479b81c4 100644 --- a/apps/backend/internal/agent/discovery/discovery.go +++ b/apps/backend/internal/agent/discovery/discovery.go @@ -24,16 +24,17 @@ type Capabilities struct { } // 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"` - DefaultModel string `json:"default_model"` - Models []agents.Model `json:"models"` - SupportsDynamic bool `json:"supports_dynamic"` + 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. @@ -80,20 +81,6 @@ func LoadRegistry(ctx context.Context, reg *registry.Registry, log *logger.Logge result = &agents.DiscoveryResult{} } - // Gather model info from the agent. - var models []agents.Model - var supportsDynamic bool - modelList, err := ag.ListModels(ctx) - if err != nil { - log.Warn("discovery: failed to list agent models", - zap.String("agent", ag.ID()), - zap.Error(err), - ) - } else if modelList != nil { - models = modelList.Models - supportsDynamic = modelList.SupportsDynamic - } - displayName := ag.DisplayName() if displayName == "" { displayName = ag.Name() @@ -110,9 +97,6 @@ func LoadRegistry(ctx context.Context, reg *registry.Registry, log *logger.Logge SupportsShell: result.Capabilities.SupportsShell, SupportsWorkspaceOnly: result.Capabilities.SupportsWorkspaceOnly, }, - DefaultModel: ag.DefaultModel(), - Models: models, - SupportsDynamic: supportsDynamic, } definitions = append(definitions, knownAgent) diff --git a/apps/backend/internal/agent/discovery/discovery_test.go b/apps/backend/internal/agent/discovery/discovery_test.go index 56242a9959..d793c3a90e 100644 --- a/apps/backend/internal/agent/discovery/discovery_test.go +++ b/apps/backend/internal/agent/discovery/discovery_test.go @@ -24,7 +24,6 @@ func (a *discoveryTestAgent) Description() string { return "" func (a *discoveryTestAgent) Enabled() bool { return true } func (a *discoveryTestAgent) DisplayOrder() int { return 0 } func (a *discoveryTestAgent) Logo(variant agents.LogoVariant) []byte { return nil } -func (a *discoveryTestAgent) DefaultModel() string { return "" } func (a *discoveryTestAgent) PermissionSettings() map[string]agents.PermissionSetting { return nil } @@ -34,9 +33,6 @@ func (a *discoveryTestAgent) InstallScript() string { return "" } func (a *discoveryTestAgent) BuildCommand(opts agents.CommandOptions) agents.Command { return agents.NewCommand() } -func (a *discoveryTestAgent) ListModels(ctx context.Context) (*agents.ModelList, error) { - return &agents.ModelList{}, nil -} func (a *discoveryTestAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) { a.mu.Lock() diff --git a/apps/backend/internal/agent/hostutility/cache.go b/apps/backend/internal/agent/hostutility/cache.go new file mode 100644 index 0000000000..007d4ae809 --- /dev/null +++ b/apps/backend/internal/agent/hostutility/cache.go @@ -0,0 +1,40 @@ +package hostutility + +import ( + "sort" + "sync" +) + +// cache is a thread-safe in-memory map of agent capabilities keyed by agent type. +type cache struct { + mu sync.RWMutex + byType map[string]AgentCapabilities +} + +func newCache() *cache { + return &cache{byType: make(map[string]AgentCapabilities)} +} + +func (c *cache) set(caps AgentCapabilities) { + c.mu.Lock() + defer c.mu.Unlock() + c.byType[caps.AgentType] = caps +} + +func (c *cache) get(agentType string) (AgentCapabilities, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + caps, ok := c.byType[agentType] + return caps, ok +} + +func (c *cache) all() []AgentCapabilities { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]AgentCapabilities, 0, len(c.byType)) + for _, v := range c.byType { + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { return out[i].AgentType < out[j].AgentType }) + return out +} diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go new file mode 100644 index 0000000000..eaa8bdccec --- /dev/null +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -0,0 +1,442 @@ +package hostutility + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/singleflight" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/registry" + agentctlclient "github.com/kandev/kandev/internal/agentctl/client" + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +// Manager owns the per-agent-type warm agentctl instances used for boot probes, +// capability refresh, and sessionless utility execution. +// +// Lifecycle: +// - Start(ctx): creates a process-scoped tmp parent dir, iterates enabled +// ACP-capable inference agents, and for each creates an agentctl instance +// (tmp subdir, no agent subprocess) and runs ProbeAgent in parallel. +// - ExecutePrompt / RawPrompt: picks the instance for the agent type and runs +// an ephemeral ACP session (same path as task-scoped utility calls). +// - RefreshAgent: re-runs the probe against the existing instance. +// - Stop(ctx): deletes each instance from agentctl and removes the tmp parent. +type Manager struct { + registry *registry.Registry + controlHost string + controlPort int + controlClient *agentctlclient.ControlClient + log *logger.Logger + + parentTmpDir string + cache *cache + + mu sync.RWMutex + instances map[string]*instance // keyed by agent type + createGroup singleflight.Group +} + +// instance is a single warm agentctl instance bound to an agent type. +type instance struct { + agentType string + instanceID string + workDir string + client *agentctlclient.Client +} + +// NewManager constructs a HostUtilityManager. +func NewManager( + reg *registry.Registry, + controlHost string, + controlPort int, + controlClient *agentctlclient.ControlClient, + log *logger.Logger, +) *Manager { + return &Manager{ + registry: reg, + controlHost: controlHost, + controlPort: controlPort, + controlClient: controlClient, + log: log.WithFields(zap.String("component", "host-utility")), + cache: newCache(), + instances: make(map[string]*instance), + } +} + +// Start boots one warm instance per ACP-capable inference agent and runs an +// initial probe against each in parallel. Individual agent failures are +// captured in the cache but do not abort the other agents. +func (m *Manager) Start(ctx context.Context) error { + // Create a process-scoped parent dir so concurrent kandev processes do not + // share state, and so Stop only removes dirs owned by this process. + parent, err := os.MkdirTemp("", fmt.Sprintf("kandev-host-utility-%d-*", os.Getpid())) + if err != nil { + return fmt.Errorf("create host utility tmp dir: %w", err) + } + m.parentTmpDir = parent + m.log.Info("host utility parent tmp dir created", zap.String("path", parent)) + + targets := m.eligibleAgents() + if len(targets) == 0 { + m.log.Info("no ACP-capable inference agents enabled; host utility idle") + return nil + } + + g, gctx := errgroup.WithContext(ctx) + for _, ag := range targets { + ag := ag + g.Go(func() error { + m.bootstrapAgent(gctx, ag) + return nil // Never fail the group; per-agent failures land in cache. + }) + } + _ = g.Wait() + return nil +} + +// Stop deletes all warm instances and removes the process-scoped tmp parent. +// Only dirs owned by this process are removed; other kandev processes' dirs +// are untouched. +func (m *Manager) Stop(ctx context.Context) { + m.mu.Lock() + instances := make([]*instance, 0, len(m.instances)) + for _, inst := range m.instances { + instances = append(instances, inst) + } + m.instances = make(map[string]*instance) + m.mu.Unlock() + + for _, inst := range instances { + if err := m.controlClient.DeleteInstance(ctx, inst.instanceID); err != nil { + m.log.Warn("failed to delete host utility instance", + zap.String("agent_type", inst.agentType), + zap.String("instance_id", inst.instanceID), + zap.Error(err)) + } + } + + if m.parentTmpDir != "" { + if err := os.RemoveAll(m.parentTmpDir); err != nil { + m.log.Warn("failed to remove host utility parent tmp dir", + zap.String("path", m.parentTmpDir), zap.Error(err)) + } + m.parentTmpDir = "" + } +} + +// eligibleAgents returns enabled agents that implement InferenceAgent AND whose +// runtime protocol is ACP. This is the v1 scope. +func (m *Manager) eligibleAgents() []agents.InferenceAgent { + all := m.registry.ListInferenceAgents() + out := make([]agents.InferenceAgent, 0, len(all)) + for _, ia := range all { + ag, ok := ia.(agents.Agent) + if !ok { + continue + } + rt := ag.Runtime() + if rt == nil || rt.Protocol != agent.ProtocolACP { + continue + } + out = append(out, ia) + } + return out +} + +// bootstrapAgent creates a warm instance for one agent type and runs the +// initial probe. Failures are recorded in the cache with the appropriate +// status so the UI can surface them. +func (m *Manager) bootstrapAgent(ctx context.Context, ia agents.InferenceAgent) { + ag := ia.(agents.Agent) + agentType := ag.ID() + log := m.log.WithFields(zap.String("agent_type", agentType)) + + // Publish "probing" synchronously so the UI can distinguish "not started" + // (cache miss) from "in flight". + m.cache.set(AgentCapabilities{ + AgentType: agentType, + Status: StatusProbing, + LastCheckedAt: time.Now(), + }) + + cfg := ia.InferenceConfig() + if cfg == nil || !cfg.Supported { + m.cache.set(AgentCapabilities{ + AgentType: agentType, + Status: StatusNotConfigured, + Error: "inference config not available", + LastCheckedAt: time.Now(), + }) + return + } + + // Pre-check installation so we can skip expensive probes. + if disc, err := ag.IsInstalled(ctx); err != nil || disc == nil || !disc.Available { + msg := "agent not installed" + if err != nil { + msg = err.Error() + } + log.Info("skipping host utility bootstrap: agent not installed") + m.cache.set(AgentCapabilities{ + AgentType: agentType, + Status: StatusNotInstalled, + Error: msg, + LastCheckedAt: time.Now(), + }) + return + } + + inst, err := m.createInstance(ctx, agentType) + if err != nil { + log.Warn("failed to create host utility instance", zap.Error(err)) + m.cache.set(AgentCapabilities{ + AgentType: agentType, + Status: StatusFailed, + Error: err.Error(), + LastCheckedAt: time.Now(), + }) + return + } + + m.mu.Lock() + m.instances[agentType] = inst + m.mu.Unlock() + + caps := m.probe(ctx, inst, ia) + m.cache.set(caps) + log.Info("host utility bootstrap completed", + zap.String("status", string(caps.Status)), + zap.Int("models", len(caps.Models)), + zap.Int("modes", len(caps.Modes))) +} + +// safeAgentTypeName validates that the string is safe for use as a single +// filesystem path segment: only letters, digits, dash, and underscore. The +// ACP agent IDs registered in the agent registry always satisfy this (they +// are hardcoded Go identifiers), but we enforce it explicitly so CodeQL's +// taint analysis can see that the value cannot escape the parent tmp dir. +var safeAgentTypeName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// createInstance asks the control client to create a new workspace-only +// agentctl instance in a tmp subdirectory dedicated to this agent type. +func (m *Manager) createInstance(ctx context.Context, agentType string) (*instance, error) { + if !safeAgentTypeName.MatchString(agentType) { + return nil, fmt.Errorf("invalid agent type %q: must match %s", agentType, safeAgentTypeName.String()) + } + workDir := filepath.Join(m.parentTmpDir, agentType) + if err := os.MkdirAll(workDir, 0o755); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", workDir, err) + } + + req := &agentctlclient.CreateInstanceRequest{ + WorkspacePath: workDir, + AgentType: agentType, + // No AgentCommand / Protocol / AutoStart: the instance is workspace-only + // and never runs a persistent agent subprocess. Probe/Prompt calls + // spawn their own ephemeral ACP subprocesses via InferencePrompt/Probe. + } + resp, err := m.controlClient.CreateInstance(ctx, req) + if err != nil { + return nil, fmt.Errorf("create instance: %w", err) + } + + client := agentctlclient.NewClient(m.controlHost, resp.Port, m.log) + + // Wait a moment for the instance HTTP server to come up. + healthCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := waitForClientHealthy(healthCtx, client); err != nil { + _ = m.controlClient.DeleteInstance(context.Background(), resp.ID) + return nil, fmt.Errorf("instance %s not healthy: %w", resp.ID, err) + } + + return &instance{ + agentType: agentType, + instanceID: resp.ID, + workDir: workDir, + client: client, + }, nil +} + +func waitForClientHealthy(ctx context.Context, c *agentctlclient.Client) error { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + var lastErr error + for { + if err := c.Health(ctx); err == nil { + return nil + } else { + lastErr = err + } + select { + case <-ctx.Done(): + if lastErr != nil { + return lastErr + } + return ctx.Err() + case <-ticker.C: + } + } +} + +// errAgentNotInstalled is returned from getInstance when the agent CLI is not +// installed on the host. The Refresh path uses it to classify Status +// accurately instead of lumping every error into StatusFailed. +var errAgentNotInstalled = errors.New("agent not installed") + +// getInstance returns the warm instance for the agent type, lazily recreating +// it if missing (e.g. after a previous failure or crash). +func (m *Manager) getInstance(ctx context.Context, agentType string) (*instance, agents.InferenceAgent, error) { + ia, ok := m.registry.GetInferenceAgent(agentType) + if !ok { + return nil, nil, fmt.Errorf("agent %q not found or not inference-capable", agentType) + } + ag, ok := ia.(agents.Agent) + if !ok { + return nil, nil, fmt.Errorf("agent %q is not a full agent type", agentType) + } + if rt := ag.Runtime(); rt == nil || rt.Protocol != agent.ProtocolACP { + return nil, nil, fmt.Errorf("agent %q is not ACP-capable", agentType) + } + + m.mu.RLock() + inst := m.instances[agentType] + m.mu.RUnlock() + if inst != nil { + return inst, ia, nil + } + + if m.parentTmpDir == "" { + return nil, nil, errors.New("host utility manager not started") + } + + // Serialize instance creation per agent type via singleflight so two + // concurrent callers don't both spawn a process and then race to cache + // the result. + v, err, _ := m.createGroup.Do(agentType, func() (interface{}, error) { + // Double-check inside the singleflight window. + m.mu.RLock() + existing := m.instances[agentType] + m.mu.RUnlock() + if existing != nil { + return existing, nil + } + // Pre-check installation so Refresh surfaces `not_installed` + // instead of collapsing it into `failed` via createInstance errors. + if disc, derr := ag.IsInstalled(ctx); derr != nil || disc == nil || !disc.Available { + return nil, errAgentNotInstalled + } + created, cerr := m.createInstance(ctx, agentType) + if cerr != nil { + return nil, cerr + } + m.mu.Lock() + m.instances[agentType] = created + m.mu.Unlock() + return created, nil + }) + if err != nil { + return nil, nil, err + } + return v.(*instance), ia, nil +} + +// probe runs an ACP probe against the given instance and translates the result +// into an AgentCapabilities record suitable for the cache. +func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent) AgentCapabilities { + cfg := ia.InferenceConfig() + req := &agentctlutil.ProbeRequest{ + AgentID: inst.agentType, + InferenceConfig: &agentctlutil.InferenceConfigDTO{ + Command: cfg.Command.Args(), + ModelFlag: cfg.ModelFlag.Args(), + WorkDir: inst.workDir, + }, + } + resp, err := inst.client.Probe(ctx, req) + now := time.Now() + if err != nil { + return AgentCapabilities{ + AgentType: inst.agentType, + Status: StatusFailed, + Error: err.Error(), + LastCheckedAt: now, + } + } + if !resp.Success { + status := StatusFailed + if isAuthError(resp.Error) { + status = StatusAuthRequired + } + return AgentCapabilities{ + AgentType: inst.agentType, + Status: status, + Error: resp.Error, + DurationMs: resp.DurationMs, + LastCheckedAt: now, + } + } + caps := AgentCapabilities{ + AgentType: inst.agentType, + AgentName: resp.AgentName, + AgentVersion: resp.AgentVersion, + Status: StatusOK, + ProtocolVersion: resp.ProtocolVersion, + LoadSession: resp.LoadSession, + PromptCapabilities: PromptCapabilities{ + Image: resp.PromptCapabilities.Image, + Audio: resp.PromptCapabilities.Audio, + EmbeddedContext: resp.PromptCapabilities.EmbeddedContext, + }, + CurrentModelID: resp.CurrentModelID, + CurrentModeID: resp.CurrentModeID, + DurationMs: resp.DurationMs, + LastCheckedAt: now, + } + for _, m := range resp.AuthMethods { + caps.AuthMethods = append(caps.AuthMethods, AuthMethod{ + ID: m.ID, Name: m.Name, Description: m.Description, Meta: m.Meta, + }) + } + for _, m := range resp.Models { + caps.Models = append(caps.Models, Model{ + ID: m.ID, Name: m.Name, Description: m.Description, Meta: m.Meta, + }) + } + for _, m := range resp.Modes { + caps.Modes = append(caps.Modes, Mode{ + ID: m.ID, Name: m.Name, Description: m.Description, Meta: m.Meta, + }) + } + for _, c := range resp.Commands { + caps.Commands = append(caps.Commands, Command{Name: c.Name, Description: c.Description}) + } + return caps +} + +// isAuthError is a coarse heuristic — ACP auth failures bubble up as string +// errors from the SDK without a distinct code. We match obvious markers so +// the UI can route the user to the auth flow; anything unmatched stays as +// StatusFailed. +func isAuthError(s string) bool { + lower := strings.ToLower(s) + for _, needle := range []string{"auth", "login", "unauthorized", "credential", "api key", "token"} { + if strings.Contains(lower, needle) { + return true + } + } + return false +} diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go new file mode 100644 index 0000000000..f156223f7d --- /dev/null +++ b/apps/backend/internal/agent/hostutility/public.go @@ -0,0 +1,114 @@ +package hostutility + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" +) + +// GetAll returns a snapshot of every probed agent type's capabilities. +func (m *Manager) GetAll() []AgentCapabilities { + if m == nil || m.cache == nil { + return nil + } + return m.cache.all() +} + +// Get returns the cached capabilities for a single agent type. +func (m *Manager) Get(agentType string) (AgentCapabilities, bool) { + if m == nil || m.cache == nil { + return AgentCapabilities{}, false + } + return m.cache.get(agentType) +} + +// Refresh re-probes the given agent type, refreshes the cache, and returns the +// new capabilities. If the warm instance is missing (never bootstrapped or +// crashed), it is lazily recreated. +func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilities, error) { + // Publish "probing" so callers polling the cache see in-flight state. + m.cache.set(AgentCapabilities{ + AgentType: agentType, + Status: StatusProbing, + LastCheckedAt: time.Now(), + }) + inst, ia, err := m.getInstance(ctx, agentType) + if err != nil { + status := StatusFailed + if errors.Is(err, errAgentNotInstalled) { + status = StatusNotInstalled + } + m.cache.set(AgentCapabilities{ + AgentType: agentType, + Status: status, + Error: err.Error(), + LastCheckedAt: time.Now(), + }) + return AgentCapabilities{}, err + } + caps := m.probe(ctx, inst, ia) + m.cache.set(caps) + return caps, nil +} + +// ExecutePrompt runs a sessionless utility prompt against the warm instance +// for the given agent type. The caller picks the model (explicit from the +// utility agent record, user default, or probe cache fallback). +func (m *Manager) ExecutePrompt( + ctx context.Context, + agentType, model, mode, prompt string, +) (*PromptResult, error) { + if prompt == "" { + return nil, fmt.Errorf("prompt is required") + } + inst, ia, err := m.getInstance(ctx, agentType) + if err != nil { + return nil, err + } + cfg := ia.InferenceConfig() + + resolved := m.resolveModel(agentType, model, ia) + + req := &agentctlutil.PromptRequest{ + Prompt: prompt, + AgentID: agentType, + Model: resolved, + Mode: mode, + InferenceConfig: &agentctlutil.InferenceConfigDTO{ + Command: cfg.Command.Args(), + ModelFlag: cfg.ModelFlag.Args(), + WorkDir: inst.workDir, + }, + } + resp, err := inst.client.InferencePrompt(ctx, req) + if err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf("%s", resp.Error) + } + return &PromptResult{ + Response: resp.Response, + Model: resp.Model, + PromptTokens: resp.PromptTokens, + ResponseTokens: resp.ResponseTokens, + DurationMs: resp.DurationMs, + }, nil +} + +// resolveModel picks the model to use for an ExecutePrompt call. +// Precedence: explicit argument > cached probe currentModelID. +// Static per-agent default models no longer exist; probes are the source of truth. +func (m *Manager) resolveModel(agentType, explicit string, _ agents.InferenceAgent) string { + if explicit != "" { + return explicit + } + if caps, ok := m.cache.get(agentType); ok && caps.CurrentModelID != "" { + return caps.CurrentModelID + } + return "" +} diff --git a/apps/backend/internal/agent/hostutility/types.go b/apps/backend/internal/agent/hostutility/types.go new file mode 100644 index 0000000000..0fe724f9d6 --- /dev/null +++ b/apps/backend/internal/agent/hostutility/types.go @@ -0,0 +1,101 @@ +// Package hostutility manages long-lived per-agent-type agentctl instances +// used for boot-time capability probes, on-demand refresh, and sessionless +// utility prompt execution (e.g. "enhance prompt" before a task/session exists). +// +// Each enabled ACP-capable agent type gets one warm agentctl instance in a +// process-scoped tmp directory. Calls spawn ephemeral ACP subprocesses through +// that instance, exactly like task-scoped utility calls, but without needing a +// real workspace or task session. +package hostutility + +import "time" + +// Status reports the state of a host utility instance for a given agent type. +type Status string + +const ( + StatusProbing Status = "probing" + StatusOK Status = "ok" + StatusAuthRequired Status = "auth_required" + StatusNotInstalled Status = "not_installed" + StatusFailed Status = "failed" + StatusNotConfigured Status = "not_configured" +) + +// AgentCapabilities is the cached result of probing an agent type. +type AgentCapabilities struct { + AgentType string `json:"agent_type"` + AgentName string `json:"agent_name,omitempty"` + AgentVersion string `json:"agent_version,omitempty"` + + Status Status `json:"status"` + Error string `json:"error,omitempty"` + + ProtocolVersion int `json:"protocol_version,omitempty"` + LoadSession bool `json:"load_session,omitempty"` + PromptCapabilities PromptCapabilities `json:"prompt_capabilities,omitempty"` + + AuthMethods []AuthMethod `json:"auth_methods,omitempty"` + + Models []Model `json:"models,omitempty"` + CurrentModelID string `json:"current_model_id,omitempty"` + + Modes []Mode `json:"modes,omitempty"` + CurrentModeID string `json:"current_mode_id,omitempty"` + + Commands []Command `json:"commands,omitempty"` + + LastCheckedAt time.Time `json:"last_checked_at"` + DurationMs int `json:"duration_ms,omitempty"` +} + +// PromptCapabilities reports which prompt content block types the agent accepts. +type PromptCapabilities struct { + Image bool `json:"image,omitempty"` + Audio bool `json:"audio,omitempty"` + EmbeddedContext bool `json:"embedded_context,omitempty"` +} + +// AuthMethod is a single advertised authentication method. Meta carries +// agent-specific extras from ACP's `_meta` field (e.g. amp/copilot's +// `terminal-auth` launchable-login descriptor). +type AuthMethod struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// Model is a single advertised model. Meta carries agent-specific extras +// from ACP's `_meta` field — e.g. GitHub Copilot exposes `copilotUsage` +// ("1x", "0.33x", "0x") and `copilotEnablement` here. +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// Command is a single advertised slash command from the ACP +// `available_commands_update` notification. +type Command struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// Mode is a single advertised session mode. +type Mode struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// PromptResult is returned from ExecutePrompt and RawPrompt calls. +type PromptResult struct { + Response string + Model string + PromptTokens int + ResponseTokens int + DurationMs int +} diff --git a/apps/backend/internal/agent/lifecycle/manager_profile.go b/apps/backend/internal/agent/lifecycle/manager_profile.go index 3192feb0f9..8891ed4586 100644 --- a/apps/backend/internal/agent/lifecycle/manager_profile.go +++ b/apps/backend/internal/agent/lifecycle/manager_profile.go @@ -144,21 +144,21 @@ func runtimeName(rt ExecutorBackend) executor.Name { return rt.Name() } -// resolveProfileModel resolves the model configured on an agent profile. -// Returns empty string if the profile cannot be resolved. -func (m *Manager) resolveProfileModel(ctx context.Context, profileID string) string { +// resolveProfileModelAndMode resolves the model and mode configured on an agent profile. +// Returns empty strings if the profile cannot be resolved. +func (m *Manager) resolveProfileModelAndMode(ctx context.Context, profileID string) (string, string) { if profileID == "" || m.profileResolver == nil { - return "" + return "", "" } info, err := m.profileResolver.ResolveProfile(ctx, profileID) if err != nil || info == nil { - return "" + return "", "" } - return info.Model + return info.Model, info.Mode } // initializeACPSession delegates to SessionManager for full ACP session initialization and prompting func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, attachments []MessageAttachment, mcpServers []agentctltypes.McpServer) error { - profileModel := m.resolveProfileModel(ctx, execution.AgentProfileID) - return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, attachments, mcpServers, m.MarkReady, profileModel) + profileModel, profileMode := m.resolveProfileModelAndMode(ctx, execution.AgentProfileID) + return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, attachments, mcpServers, m.MarkReady, profileModel, profileMode) } diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index d76670ff49..702f2cb06c 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -41,11 +41,6 @@ func (a *testAgent) IsInstalled(_ context.Context) (*agents.DiscoveryResult, err return &agents.DiscoveryResult{Available: false}, nil } -func (a *testAgent) DefaultModel() string { return a.defaultModel } -func (a *testAgent) ListModels(_ context.Context) (*agents.ModelList, error) { - return &agents.ModelList{}, nil -} - func (a *testAgent) BuildCommand(_ agents.CommandOptions) agents.Command { return agents.Command{} } func (a *testAgent) PermissionSettings() map[string]agents.PermissionSetting { diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver.go b/apps/backend/internal/agent/lifecycle/profile_resolver.go index 8740713370..8f02e619a1 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver.go @@ -43,6 +43,7 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str AgentID: agent.ID, AgentName: agent.Name, Model: model, + Mode: profile.Mode, AutoApprove: profile.AutoApprove, DangerouslySkipPermissions: profile.DangerouslySkipPermissions, AllowIndexing: profile.AllowIndexing, @@ -52,8 +53,10 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str }, nil } -// resolveAgentCapabilities looks up the agent in the registry and returns the effective model -// and whether the agent supports native session resume. +// resolveAgentCapabilities looks up the agent in the registry and returns the +// effective model and whether the agent supports native session resume. +// The model comes straight from the profile; static per-agent defaults have +// been removed. Empty model means "agent picks its own default". func (r *StoreProfileResolver) resolveAgentCapabilities(agentName, profileModel string) (string, bool) { if r.registry == nil { return profileModel, false @@ -62,13 +65,9 @@ func (r *StoreProfileResolver) resolveAgentCapabilities(agentName, profileModel if !ok { return profileModel, false } - model := profileModel - if model == "" { - model = ag.DefaultModel() - } var nativeSessionResume bool if rt := ag.Runtime(); rt != nil { nativeSessionResume = rt.SessionConfig.NativeSessionResume } - return model, nativeSessionResume + return profileModel, nativeSessionResume } diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go index c98fd50117..ab19386b06 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go @@ -270,9 +270,12 @@ func TestStoreProfileResolver_ResolveProfile_FallbackToRegistryDefaultModel(t *t if info == nil { t.Fatal("expected non-nil profile info") } - if info.Model != "claude-sonnet-4-20250514" { - // Should have fallback to registry's default model - t.Errorf("expected Model 'claude-sonnet-4-20250514' (from registry), got '%s'", info.Model) + // Static per-agent default models have been removed; an empty profile + // model stays empty until the host utility probe fills it in via the + // reconciler. The session-start hook calls session/set_model only when + // a model is set, otherwise the agent uses its own default. + if info.Model != "" { + t.Errorf("expected Model '' (no static fallback), got '%s'", info.Model) } } diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 6a4e789ba9..fa30768524 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -265,6 +265,7 @@ func (sm *SessionManager) InitializeAndPrompt( mcpServers []agentctltypes.McpServer, markReady func(executionID string) error, profileModel string, + profileMode string, ) error { // Create session-level trace span to group all operations under one trace _, sessionSpan := tracing.TraceSessionStart( @@ -328,25 +329,32 @@ func (sm *SessionManager) InitializeAndPrompt( execution.ACPSessionID = result.SessionID execution.sessionInitialized = true - // Set the user's configured profile model if it differs from the agent's default. - // Resolve ACP-specific model ID (e.g., "claude-opus-4-6" → "default" for Claude ACP). - if profileModel != "" && profileModel != agentConfig.DefaultModel() && execution.agentctl != nil { - acpModel := profileModel - if list, err := agentConfig.ListModels(ctx); err == nil && list != nil { - acpModel = agents.ResolveACPModelID(list.Models, profileModel) - } - - if err := execution.agentctl.SetModel(ctx, acpModel); err != nil { + // Apply profile model via ACP session/set_model (best-effort). + // ACP is the only surface for model selection now; no --model CLI flag. + if profileModel != "" && execution.agentctl != nil { + if err := execution.agentctl.SetModel(ctx, profileModel); err != nil { sm.logger.Warn("failed to set profile model via ACP", zap.String("execution_id", execution.ID), zap.String("model", profileModel), - zap.String("acp_model", acpModel), zap.Error(err)) } else { sm.logger.Info("set profile model on ACP session", zap.String("execution_id", execution.ID), - zap.String("model", profileModel), - zap.String("acp_model", acpModel)) + zap.String("model", profileModel)) + } + } + + // Apply profile mode via ACP session/set_mode (best-effort). + if profileMode != "" && execution.agentctl != nil { + if err := execution.agentctl.SetMode(ctx, result.SessionID, profileMode); err != nil { + sm.logger.Warn("failed to set profile mode via ACP", + zap.String("execution_id", execution.ID), + zap.String("mode", profileMode), + zap.Error(err)) + } else { + sm.logger.Info("set profile mode on ACP session", + zap.String("execution_id", execution.ID), + zap.String("mode", profileMode)) } } diff --git a/apps/backend/internal/agent/lifecycle/session_test.go b/apps/backend/internal/agent/lifecycle/session_test.go index 34c3021487..6798b8b7a4 100644 --- a/apps/backend/internal/agent/lifecycle/session_test.go +++ b/apps/backend/internal/agent/lifecycle/session_test.go @@ -234,7 +234,7 @@ func TestInitializeAndPrompt_StreamBeforeInitialize(t *testing.T) { err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error { return nil - }, "") + }, "", "") if err != nil { t.Fatalf("InitializeAndPrompt failed: %v", err) } @@ -306,7 +306,7 @@ func TestInitializeAndPrompt_StreamTimeout(t *testing.T) { err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error { return nil - }, "") + }, "", "") // Should fail because stream couldn't connect and Initialize fails if err == nil { @@ -359,7 +359,7 @@ func TestInitializeAndPrompt_WithTaskDescription(t *testing.T) { err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "Build a feature", nil, nil, func(executionID string) error { return nil - }, "") + }, "", "") if err != nil { t.Fatalf("InitializeAndPrompt failed: %v", err) } @@ -427,7 +427,7 @@ func TestInitializeAndPrompt_NoStreamManager(t *testing.T) { // But it should NOT panic due to nil streamManager. err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, nil, func(executionID string) error { return nil - }, "") + }, "", "") // Expect error because Initialize call over WS will fail (stream not connected) if err == nil { diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index b7ac7f4048..8b4acb8e09 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -295,17 +295,21 @@ type CredentialsManager interface { // AgentProfileInfo contains resolved profile information type AgentProfileInfo struct { - ProfileID string - ProfileName string - AgentID string - AgentName string // e.g., "auggie", "claude", "codex" - Model string + ProfileID string + ProfileName string + AgentID string + AgentName string // e.g., "auggie", "claude", "codex" + Model string // applied via ACP session/set_model at session start + Mode string // applied via ACP session/set_mode at session start (empty = use agent default) + AllowIndexing bool // auggie-only CLI flag + CLIPassthrough bool + NativeSessionResume bool // Agent supports ACP session/load for resume + SupportsMCP bool + + // Deprecated: legacy permission fields, no longer consulted by the launch + // path. Kept so existing call sites compile during the transition. AutoApprove bool DangerouslySkipPermissions bool - AllowIndexing bool - CLIPassthrough bool - NativeSessionResume bool // Agent supports ACP session/load for resume - SupportsMCP bool } // ProfileResolver resolves agent profile IDs to profile information diff --git a/apps/backend/internal/agent/lifecycle/utility.go b/apps/backend/internal/agent/lifecycle/utility.go index 3dd145442c..7c7ab51121 100644 --- a/apps/backend/internal/agent/lifecycle/utility.go +++ b/apps/backend/internal/agent/lifecycle/utility.go @@ -89,7 +89,6 @@ func (m *Manager) ListInferenceAgentsWithContext(ctx context.Context) []Inferenc ID: ag.ID(), Name: ag.Name(), DisplayName: ag.DisplayName(), - Models: ia.InferenceModels(), }) } @@ -97,9 +96,10 @@ func (m *Manager) ListInferenceAgentsWithContext(ctx context.Context) []Inferenc } // InferenceAgentInfo contains info about an inference-capable agent. +// Models are no longer listed here — consumers should read them from the +// host utility capability cache directly. type InferenceAgentInfo struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Models []agents.InferenceModel `json:"models"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` } diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 3f9a941c3a..2c88fd611c 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -24,7 +24,6 @@ type ( SessionConfig = agents.SessionConfig PassthroughConfig = agents.PassthroughConfig DiscoveryCapabilities = agents.DiscoveryCapabilities - Model = agents.Model ) // Registry manages agent configurations @@ -46,16 +45,11 @@ func NewRegistry(log *logger.Logger) *Registry { func (r *Registry) LoadDefaults() { all := []agents.Agent{ agents.NewAuggie(), - agents.NewClaudeCode(), agents.NewClaudeACP(), - agents.NewCodex(), agents.NewCodexACP(), - agents.NewCopilot(), agents.NewCopilotACP(), agents.NewGemini(), - agents.NewOpenCode(), agents.NewOpenCodeACP(), - agents.NewAmp(), agents.NewAmpACP(), agents.NewMockAgent(), } diff --git a/apps/backend/internal/agent/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go index 2ee587c2e5..4469013572 100644 --- a/apps/backend/internal/agent/registry/registry_test.go +++ b/apps/backend/internal/agent/registry/registry_test.go @@ -29,10 +29,6 @@ func (a *testAgent) Logo(agents.LogoVariant) []byte { return nil } func (a *testAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) { return nil, agents.ErrNotSupported } -func (a *testAgent) DefaultModel() string { return "" } -func (a *testAgent) ListModels(context.Context) (*agents.ModelList, error) { - return nil, agents.ErrNotSupported -} func (a *testAgent) BuildCommand(agents.CommandOptions) agents.Command { return agents.Command{} } func (a *testAgent) PermissionSettings() map[string]agents.PermissionSetting { return nil diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go index cafd853d6b..90d67e9f3b 100644 --- a/apps/backend/internal/agent/settings/controller/agent_config.go +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/models" @@ -160,19 +161,6 @@ func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting) return } -// resolveModelDisplayName returns the display name for a model ID from the model list, -// falling back to the model ID itself if not found. -func resolveModelDisplayName(modelList *agents.ModelList, modelID string) string { - if modelList != nil { - for _, m := range modelList.Models { - if m.ID == modelID { - return m.Name - } - } - } - return modelID -} - // CommandPreviewRequest contains the draft settings for command preview type CommandPreviewRequest struct { Model string @@ -207,91 +195,77 @@ func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, }, nil } -// FetchDynamicModels fetches models for an agent, optionally refreshing the cache +// FetchDynamicModels returns models and modes for an agent from the host +// utility capability cache populated by the ACP probe at boot. The refresh +// flag triggers a live Refresh() call against the warm host instance. func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, refresh bool) (*dto.DynamicModelsResponse, error) { - ag, ok := c.agentRegistry.Get(agentName) - if !ok { + if _, ok := c.agentRegistry.Get(agentName); !ok { return nil, fmt.Errorf("agent %q not found", agentName) } + if c.hostUtility == nil { + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Status: "not_configured", + }, nil + } - // Check cache unless refresh is requested - if !refresh { - if cached, ok := c.checkModelCache(agentName); ok { - return cached, nil + var caps hostutility.AgentCapabilities + if refresh { + var err error + caps, err = c.hostUtility.Refresh(ctx, agentName) + if err != nil { + s := err.Error() + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Status: string(hostutility.StatusFailed), + Error: &s, + }, nil + } + } else { + var ok bool + caps, ok = c.hostUtility.Get(agentName) + if !ok { + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Status: "not_configured", + }, nil } } - // Fetch models from the agent - modelList, err := ag.ListModels(ctx) - if err != nil { - c.logger.Warn("model fetch failed", - zap.String("agent", agentName), - zap.Error(err)) - c.modelCache.Set(agentName, nil, err) - s := err.Error() - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: nil, - Cached: false, - Error: &s, - }, nil + resp := &dto.DynamicModelsResponse{ + AgentName: agentName, + Status: string(caps.Status), + CurrentModelID: caps.CurrentModelID, + CurrentModeID: caps.CurrentModeID, + Models: []dto.ModelEntryDTO{}, + Modes: []dto.ModeEntryDTO{}, } - - models := modelList.Models - - // Cache the result if dynamic models are supported - if modelList.SupportsDynamic { - c.modelCache.Set(agentName, models, nil) - cachedAt := time.Now() - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: modelsToDTO(models), - Cached: true, - CachedAt: &cachedAt, - }, nil + if caps.Error != "" { + e := caps.Error + resp.Error = &e } - - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: modelsToDTO(models), - Cached: false, - }, nil -} - -// checkModelCache returns cached model response if available and valid. -func (c *Controller) checkModelCache(agentName string) (*dto.DynamicModelsResponse, bool) { - entry, exists := c.modelCache.Get(agentName) - if !exists || !entry.IsValid() { - return nil, false - } - cachedAt := entry.CachedAt - modelDTOs := modelsToDTO(entry.Models) - var errStr *string - if entry.Error != nil { - s := entry.Error.Error() - errStr = &s - } - return &dto.DynamicModelsResponse{ - AgentName: agentName, - Models: modelDTOs, - Cached: true, - CachedAt: &cachedAt, - Error: errStr, - }, true -} - -// modelsToDTO converts agent models to DTOs. -func modelsToDTO(models []agents.Model) []dto.ModelEntryDTO { - dtos := make([]dto.ModelEntryDTO, 0, len(models)) - for _, m := range models { - dtos = append(dtos, dto.ModelEntryDTO{ - ID: m.ID, - Name: m.Name, - Provider: m.Provider, - ContextWindow: m.ContextWindow, - IsDefault: m.IsDefault, - Source: m.Source, + for _, m := range caps.Models { + resp.Models = append(resp.Models, dto.ModelEntryDTO{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + IsDefault: m.ID == caps.CurrentModelID, + Meta: m.Meta, }) } - return dtos + for _, m := range caps.Modes { + resp.Modes = append(resp.Modes, dto.ModeEntryDTO{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + Meta: m.Meta, + }) + } + for _, c := range caps.Commands { + resp.Commands = append(resp.Commands, dto.CommandEntryDTO{ + Name: c.Name, + Description: c.Description, + }) + } + return resp, nil } diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index 0c055743a7..a6264ecc0f 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -25,6 +25,7 @@ func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, er return nil, err } result := toAgentDTO(agent, profiles) + c.applyCapabilityStatus(&result, agent.Name) return &result, nil } @@ -39,7 +40,9 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e if err != nil { return nil, err } - payload = append(payload, toAgentDTO(agent, profiles)) + entry := toAgentDTO(agent, profiles) + c.applyCapabilityStatus(&entry, agent.Name) + payload = append(payload, entry) } return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil } @@ -51,10 +54,9 @@ type CreateAgentRequest struct { } type CreateAgentProfileRequest struct { - Name string - Model string - AutoApprove bool - DangerouslySkipPermissions bool + Name string + Model string + Mode string } func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) { @@ -98,9 +100,25 @@ func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (* return nil, err } result := toAgentDTO(agent, profiles) + c.applyCapabilityStatus(&result, agent.Name) return &result, nil } +// applyCapabilityStatus populates the DTO's capability fields from the host +// utility cache. No-op when the host utility is unavailable or the agent +// isn't in the cache (e.g. mock, tui-only, or pre-probe). +func (c *Controller) applyCapabilityStatus(d *dto.AgentDTO, agentName string) { + if c.hostUtility == nil { + return + } + caps, ok := c.hostUtility.Get(agentName) + if !ok { + return + } + d.CapabilityStatus = string(caps.Status) + d.CapabilityError = caps.Error +} + func (c *Controller) findMatchedAvailability(name string, results []discovery.Availability) (*discovery.Availability, error) { for _, result := range results { if result.Name == name { @@ -118,12 +136,11 @@ func (c *Controller) createAgentProfiles(ctx context.Context, agentID, displayNa profiles := make([]*models.AgentProfile, 0, len(profileReqs)) for _, profileReq := range profileReqs { profile := &models.AgentProfile{ - AgentID: agentID, - Name: profileReq.Name, - AgentDisplayName: displayName, - Model: profileReq.Model, - AutoApprove: profileReq.AutoApprove, - DangerouslySkipPermissions: profileReq.DangerouslySkipPermissions, + AgentID: agentID, + Name: profileReq.Name, + AgentDisplayName: displayName, + Model: profileReq.Model, + Mode: profileReq.Mode, } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { return nil, err diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index a3b9ea3b1d..da4ed856e4 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -14,7 +14,6 @@ import ( "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/models" - "go.uber.org/zap" ) func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) { @@ -86,7 +85,8 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent displayName = ag.Name() } - modelEntries, supportsDynamic := c.fetchModelsWithCache(ctx, ag) + modelConfig := c.buildModelConfigFromHostUtility(ag.ID()) + _ = ctx capabilities := dto.AgentCapabilitiesDTO{ SupportsSessionResume: availability.Capabilities.SupportsSessionResume, @@ -121,56 +121,76 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent } return dto.AvailableAgentDTO{ - Name: ag.ID(), - DisplayName: displayName, - Description: ag.Description(), - InstallScript: ag.InstallScript(), - SupportsMCP: availability.SupportsMCP, - MCPConfigPath: availability.MCPConfigPath, - InstallationPaths: availability.InstallationPaths, - Available: availability.Available, - MatchedPath: availability.MatchedPath, - Capabilities: capabilities, - ModelConfig: dto.ModelConfigDTO{ - DefaultModel: ag.DefaultModel(), - AvailableModels: modelEntries, - SupportsDynamicModels: supportsDynamic, - }, + Name: ag.ID(), + DisplayName: displayName, + Description: ag.Description(), + InstallScript: ag.InstallScript(), + SupportsMCP: availability.SupportsMCP, + MCPConfigPath: availability.MCPConfigPath, + InstallationPaths: availability.InstallationPaths, + Available: availability.Available, + MatchedPath: availability.MatchedPath, + Capabilities: capabilities, + ModelConfig: modelConfig, PermissionSettings: permissionSettings, PassthroughConfig: passthroughConfig, UpdatedAt: now, } } -// fetchModelsWithCache returns model entries for an agent, using the model cache -// to avoid expensive subprocess calls (e.g. `opencode models`) on every request. -func (c *Controller) fetchModelsWithCache(ctx context.Context, ag agents.Agent) ([]dto.ModelEntryDTO, bool) { - agentName := ag.ID() - - // Check cache first - if entry, exists := c.modelCache.Get(agentName); exists && (entry.IsValid() || entry.IsStale()) { - if entry.Error == nil && entry.Models != nil { - return modelsToDTO(entry.Models), true - } +// buildModelConfigFromHostUtility reads cached ACP probe data for the agent +// type and produces a ModelConfigDTO with models, modes, and status. Agents +// not in the probe cache (e.g. the mock agent used in E2E tests, which +// doesn't speak ACP through its binary) fall back to `SupportsDynamicModels: +// false` with an empty model list — callers render the profile's stored +// model as a plain string rather than offering a dropdown. +func (c *Controller) buildModelConfigFromHostUtility(agentID string) dto.ModelConfigDTO { + // Always initialize slices so JSON marshals as [] not null — the + // frontend uses .some()/.find() on these without null checks. + cfg := dto.ModelConfigDTO{ + SupportsDynamicModels: false, + AvailableModels: []dto.ModelEntryDTO{}, + AvailableModes: []dto.ModeEntryDTO{}, } - - // Cache miss — call ListModels and cache the result - modelList, err := ag.ListModels(ctx) - if err != nil { - c.logger.Warn("failed to list models for available agent", - zap.String("agent", agentName), zap.Error(err)) - c.modelCache.Set(agentName, nil, err) - return nil, false + if c.hostUtility == nil { + cfg.Status = "not_configured" + return cfg } - if modelList == nil { - return nil, false + caps, ok := c.hostUtility.Get(agentID) + if !ok { + cfg.Status = "not_configured" + return cfg } - - if modelList.SupportsDynamic { - c.modelCache.Set(agentName, modelList.Models, nil) + cfg.SupportsDynamicModels = true + cfg.Status = string(caps.Status) + cfg.Error = caps.Error + cfg.DefaultModel = caps.CurrentModelID + cfg.CurrentModelID = caps.CurrentModelID + cfg.CurrentModeID = caps.CurrentModeID + for _, m := range caps.Models { + cfg.AvailableModels = append(cfg.AvailableModels, dto.ModelEntryDTO{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + IsDefault: m.ID == caps.CurrentModelID, + Meta: m.Meta, + }) } - - return modelsToDTO(modelList.Models), modelList.SupportsDynamic + for _, m := range caps.Modes { + cfg.AvailableModes = append(cfg.AvailableModes, dto.ModeEntryDTO{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + Meta: m.Meta, + }) + } + for _, c := range caps.Commands { + cfg.AvailableCommands = append(cfg.AvailableCommands, dto.CommandEntryDTO{ + Name: c.Name, + Description: c.Description, + }) + } + return cfg } func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { @@ -197,7 +217,6 @@ type profileSyncParams struct { autoApprove bool allowIndexing bool skipPermissions bool - modelList *agents.ModelList } // updateExistingProfiles syncs non-user-modified profiles with current agent defaults. @@ -215,8 +234,8 @@ func (c *Controller) updateExistingProfiles(ctx context.Context, profiles []*mod profile.Model = p.defaultModel updated = true } - resolvedName := resolveModelDisplayName(p.modelList, profile.Model) - if p.isPassthrough { + resolvedName := profile.Model + if p.isPassthrough || resolvedName == "" { resolvedName = p.displayName } if profile.Name != resolvedName { @@ -292,17 +311,12 @@ func (c *Controller) resolveDisplayName(agentConfig agents.Agent, agentName stri // buildProfileSyncParams assembles the parameters needed to create or update agent profiles. func (c *Controller) buildProfileSyncParams( - ctx context.Context, + _ context.Context, agentConfig agents.Agent, - agentName, displayName, defaultModel string, + _, displayName, defaultModel string, isPassthroughOnly bool, ) profileSyncParams { autoApprove, allowIndexing, skipPermissions := resolvePermissionDefaults(agentConfig.PermissionSettings()) - modelList, listErr := agentConfig.ListModels(ctx) - if listErr != nil { - c.logger.Warn("failed to list models during profile sync, using model ID as name", - zap.String("agent", agentName), zap.Error(listErr)) - } return profileSyncParams{ displayName: displayName, defaultModel: defaultModel, @@ -310,15 +324,16 @@ func (c *Controller) buildProfileSyncParams( autoApprove: autoApprove, allowIndexing: allowIndexing, skipPermissions: skipPermissions, - modelList: modelList, } } // createDefaultProfile creates the initial agent profile when none exist for an agent. +// The model may be empty — the profile reconciler fills it from the host +// utility capability cache on boot. func (c *Controller) createDefaultProfile(ctx context.Context, agentID string, p profileSyncParams) error { - profileName := resolveModelDisplayName(p.modelList, p.defaultModel) - if p.isPassthrough { - profileName = p.displayName + profileName := p.displayName + if !p.isPassthrough && p.defaultModel != "" { + profileName = p.defaultModel } defaultProfile := &models.AgentProfile{ AgentID: agentID, @@ -333,15 +348,20 @@ func (c *Controller) createDefaultProfile(ctx context.Context, agentID string, p return c.repo.CreateAgentProfile(ctx, defaultProfile) } -func resolveDefaultModel(agentConfig agents.Agent, name string) (string, bool, error) { - defaultModel := agentConfig.DefaultModel() - if defaultModel != "" { - return defaultModel, false, nil - } - if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && ptAgent.PassthroughConfig().Supported { +func resolveDefaultModel(agentConfig agents.Agent, _ string) (string, bool, error) { + // Default models come from the host utility capability cache after probing. + // The legacy bootstrap path leaves the model empty and lets the reconciler + // heal it against the cache. TUI-only agents still seed as passthrough so + // the terminal UI is enabled by default. + if agents.IsPassthroughOnly(agentConfig) { return "passthrough", true, nil } - return "", false, fmt.Errorf("unknown agent default model: %s", name) + // Mock agent is not probed (not an InferenceAgent) but needs a concrete + // model for E2E tests that exercise the ModelSelector UI. + if agentConfig.ID() == "mock-agent" { + return "mock-default", false, nil + } + return "", false, nil } func (c *Controller) upsertAgent(ctx context.Context, result discovery.Availability) (*models.Agent, error) { diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery_test.go b/apps/backend/internal/agent/settings/controller/agent_discovery_test.go deleted file mode 100644 index 4bc2a2d948..0000000000 --- a/apps/backend/internal/agent/settings/controller/agent_discovery_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package controller - -import ( - "context" - "sync" - "testing" - - "github.com/kandev/kandev/internal/agent/agents" - "github.com/kandev/kandev/internal/agent/discovery" - "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/agent/settings/dto" - "github.com/kandev/kandev/internal/agent/settings/modelfetcher" - "github.com/kandev/kandev/internal/common/logger" -) - -type availabilityTestAgent struct { - id string - - discoveryResult *agents.DiscoveryResult - modelList *agents.ModelList - - mu sync.Mutex - isInstalledCalls int - listModelsCallCount int -} - -func (a *availabilityTestAgent) ID() string { return a.id } -func (a *availabilityTestAgent) Name() string { return a.id } -func (a *availabilityTestAgent) DisplayName() string { return a.id } -func (a *availabilityTestAgent) Description() string { return "" } -func (a *availabilityTestAgent) Enabled() bool { return true } -func (a *availabilityTestAgent) DisplayOrder() int { return 0 } -func (a *availabilityTestAgent) Logo(variant agents.LogoVariant) []byte { return nil } -func (a *availabilityTestAgent) DefaultModel() string { return "default-model" } -func (a *availabilityTestAgent) BuildCommand(opts agents.CommandOptions) agents.Command { - return agents.NewCommand() -} -func (a *availabilityTestAgent) PermissionSettings() map[string]agents.PermissionSetting { - return nil -} -func (a *availabilityTestAgent) Runtime() *agents.RuntimeConfig { return nil } -func (a *availabilityTestAgent) RemoteAuth() *agents.RemoteAuth { return nil } -func (a *availabilityTestAgent) InstallScript() string { return "" } - -func (a *availabilityTestAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) { - a.mu.Lock() - a.isInstalledCalls++ - res := *a.discoveryResult - a.mu.Unlock() - return &res, nil -} - -func (a *availabilityTestAgent) ListModels(ctx context.Context) (*agents.ModelList, error) { - a.mu.Lock() - a.listModelsCallCount++ - res := *a.modelList - a.mu.Unlock() - return &res, nil -} - -func (a *availabilityTestAgent) resetInstalledCalls() { - a.mu.Lock() - a.isInstalledCalls = 0 - a.mu.Unlock() -} - -func (a *availabilityTestAgent) installedCalls() int { - a.mu.Lock() - defer a.mu.Unlock() - return a.isInstalledCalls -} - -func TestListAvailableAgents_UsesDiscoveryCapabilitiesWithoutExtraIsInstalledCall(t *testing.T) { - log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) - if err != nil { - t.Fatalf("new logger: %v", err) - } - - ag := &availabilityTestAgent{ - id: "codex", - discoveryResult: &agents.DiscoveryResult{ - Available: true, - Capabilities: agents.DiscoveryCapabilities{ - SupportsSessionResume: true, - SupportsShell: true, - SupportsWorkspaceOnly: false, - }, - }, - modelList: &agents.ModelList{SupportsDynamic: false}, - } - - agentRegistry := registry.NewRegistry(log) - if err := agentRegistry.Register(ag); err != nil { - t.Fatalf("register agent: %v", err) - } - - discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log) - if err != nil { - t.Fatalf("load discovery registry: %v", err) - } - - ag.resetInstalledCalls() - ctrl := &Controller{ - discovery: discoveryRegistry, - agentRegistry: agentRegistry, - modelCache: modelfetcher.NewCache(), - logger: log, - } - - resp, err := ctrl.ListAvailableAgents(context.Background()) - if err != nil { - t.Fatalf("ListAvailableAgents: %v", err) - } - if len(resp.Agents) != 1 { - t.Fatalf("agents = %d, want 1", len(resp.Agents)) - } - - if got := ag.installedCalls(); got != 1 { - t.Fatalf("IsInstalled calls = %d, want 1", got) - } - - caps := resp.Agents[0].Capabilities - expected := dto.AgentCapabilitiesDTO{ - SupportsSessionResume: true, - SupportsShell: true, - SupportsWorkspaceOnly: false, - } - if caps != expected { - t.Fatalf("capabilities = %+v, want %+v", caps, expected) - } -} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 4894327030..403c2aabfd 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -8,6 +8,7 @@ import ( "github.com/kandev/kandev/internal/agent/discovery" agentdto "github.com/kandev/kandev/internal/agent/dto" + "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/modelfetcher" @@ -48,6 +49,7 @@ type Controller struct { sessionChecker SessionChecker mcpService *mcpconfig.Service modelCache *modelfetcher.Cache + hostUtility *hostutility.Manager logger *logger.Logger } @@ -78,3 +80,11 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, logger: log.WithFields(zap.String("component", "agent-settings-controller")), } } + +// SetHostUtility wires the host utility manager into the controller so that +// endpoints like /agent-models can read the cached capability data. Called +// once at startup after the host utility manager is constructed; leaving this +// unset simply causes the model endpoints to report "not_configured". +func (c *Controller) SetHostUtility(h *hostutility.Manager) { + c.hostUtility = h +} diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 3e5989d692..dcf9dd04b3 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -24,7 +24,6 @@ type testAgent struct { runtime *agents.RuntimeConfig permissionSettings map[string]agents.PermissionSetting logoData []byte - modelList *agents.ModelList } func (a *testAgent) ID() string { return a.id } @@ -40,16 +39,8 @@ func (a *testAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, e return &agents.DiscoveryResult{Available: false}, nil } -func (a *testAgent) DefaultModel() string { return "" } - -func (a *testAgent) ListModels(ctx context.Context) (*agents.ModelList, error) { - if a.modelList != nil { - return a.modelList, nil - } - return &agents.ModelList{}, nil -} - -// BuildCommand builds a command using runtime config, model flag, and permission flags. +// BuildCommand builds a command using runtime config and permission flags. +// Model/mode are applied via ACP session/set_model at session start, not via CLI. func (a *testAgent) BuildCommand(opts agents.CommandOptions) agents.Command { rt := a.Runtime() if rt == nil { @@ -57,13 +48,6 @@ func (a *testAgent) BuildCommand(opts agents.CommandOptions) agents.Command { } cmd := make([]string, len(rt.Cmd.Args())) copy(cmd, rt.Cmd.Args()) - - if opts.Model != "" && !rt.ModelFlag.IsEmpty() { - for _, arg := range rt.ModelFlag.Args() { - cmd = append(cmd, strings.ReplaceAll(arg, "{model}", opts.Model)) - } - } - cmd = applyTestPermissionFlags(cmd, a.permissionSettings, opts.PermissionValues) return agents.NewCommand(cmd...) } @@ -153,7 +137,8 @@ func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { t.Error("PreviewAgentCommand() Supported = false, want true") } - expectedParts := []string{"test-cli", "--verbose", "--model", "gpt-4", "--yes"} + // --model is no longer emitted: model is applied via ACP session/set_model. + expectedParts := []string{"test-cli", "--verbose", "--yes"} for _, part := range expectedParts { found := false for _, cmdPart := range result.Command { @@ -166,6 +151,11 @@ func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { t.Errorf("PreviewAgentCommand() command missing %q, got %v", part, result.Command) } } + for _, cmdPart := range result.Command { + if cmdPart == "--model" { + t.Errorf("PreviewAgentCommand() should not emit --model, got %v", result.Command) + } + } } func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) { diff --git a/apps/backend/internal/agent/settings/controller/custom_tui_test.go b/apps/backend/internal/agent/settings/controller/custom_tui_test.go deleted file mode 100644 index 85021dbb01..0000000000 --- a/apps/backend/internal/agent/settings/controller/custom_tui_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/jmoiron/sqlx" - _ "github.com/mattn/go-sqlite3" - - "github.com/kandev/kandev/internal/agent/agents" - "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/agent/settings/modelfetcher" - "github.com/kandev/kandev/internal/agent/settings/models" - "github.com/kandev/kandev/internal/agent/settings/store" - "github.com/kandev/kandev/internal/common/logger" -) - -func newTestControllerWithRepo(t *testing.T) (*Controller, *registry.Registry) { - ctrl, reg, _ := newTestControllerWithRepoAndStore(t) - return ctrl, reg -} - -func newTestControllerWithRepoAndStore(t *testing.T) (*Controller, *registry.Registry, store.Repository) { - t.Helper() - log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) - db, err := sqlx.Open("sqlite3", ":memory:") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = db.Close() }) - - repo, _, err := store.Provide(db, db) - if err != nil { - t.Fatal(err) - } - - reg := registry.NewRegistry(log) - ctrl := &Controller{ - repo: repo, - agentRegistry: reg, - modelCache: modelfetcher.NewCache(), - logger: log, - } - return ctrl, reg, repo -} - -func TestSlugify(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"My Agent", "my-agent"}, - {" hello world ", "hello-world"}, - {"UPPER CASE", "upper-case"}, - {"special!@#chars", "special-chars"}, - {"---leading-trailing---", "leading-trailing"}, - {"", ""}, - {" ", ""}, - {"already-slug", "already-slug"}, - {"múltiple àccénts", "m-ltiple-cc-nts"}, - {"a", "a"}, - {"123", "123"}, - {"a--b", "a-b"}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := slugify(tt.input) - if got != tt.expected { - t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.expected) - } - }) - } -} - -func TestCreateCustomTUIAgent_Success(t *testing.T) { - ctrl, reg := newTestControllerWithRepo(t) - ctx := context.Background() - - result, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "My Custom Agent", - Model: "best", - Command: "my-agent --yolo", - Description: "A custom agent", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if result.Name != "my-custom-agent" { - t.Errorf("expected name %q, got %q", "my-custom-agent", result.Name) - } - if result.TUIConfig == nil { - t.Fatal("expected tui_config to be set") - } - if result.TUIConfig.Command != "my-agent --yolo" { - t.Errorf("expected command %q, got %q", "my-agent --yolo", result.TUIConfig.Command) - } - if result.TUIConfig.DisplayName != "My Custom Agent" { - t.Errorf("expected display name %q, got %q", "My Custom Agent", result.TUIConfig.DisplayName) - } - if len(result.Profiles) != 1 { - t.Fatalf("expected 1 profile, got %d", len(result.Profiles)) - } - if result.Profiles[0].Name != "best" { - t.Errorf("expected profile name %q, got %q", "best", result.Profiles[0].Name) - } - if result.Profiles[0].Model != "passthrough" { - t.Errorf("expected profile model %q, got %q", "passthrough", result.Profiles[0].Model) - } - - // Verify registered in registry - if !reg.Exists("my-custom-agent") { - t.Error("expected agent to be registered in registry") - } -} - -func TestCreateCustomTUIAgent_ProfileNameFallsBackToDisplayName(t *testing.T) { - ctrl, _ := newTestControllerWithRepo(t) - ctx := context.Background() - - result, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "No Model Agent", - Command: "no-model-cli", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(result.Profiles) != 1 { - t.Fatalf("expected 1 profile, got %d", len(result.Profiles)) - } - if result.Profiles[0].Name != "No Model Agent" { - t.Errorf("expected profile name %q, got %q", "No Model Agent", result.Profiles[0].Name) - } -} - -func TestCreateCustomTUIAgent_InvalidSlug(t *testing.T) { - ctrl, _ := newTestControllerWithRepo(t) - ctx := context.Background() - - _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "!!!", - Command: "some-cli", - }) - if err != ErrInvalidSlug { - t.Errorf("expected ErrInvalidSlug, got %v", err) - } -} - -func TestCreateCustomTUIAgent_EmptyCommand(t *testing.T) { - ctrl, _ := newTestControllerWithRepo(t) - ctx := context.Background() - - _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "Valid Name", - Command: "", - }) - if err != ErrCommandRequired { - t.Errorf("expected ErrCommandRequired, got %v", err) - } -} - -func TestCreateCustomTUIAgent_RegistryConflict(t *testing.T) { - ctrl, reg := newTestControllerWithRepo(t) - ctx := context.Background() - - // Pre-register an agent in the registry - _ = reg.RegisterCustomTUIAgent("my-agent", "My Agent", "my-cli", "", "", nil) - - _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "My Agent", - Command: "other-cli", - }) - if err != ErrAgentAlreadyExists { - t.Errorf("expected ErrAgentAlreadyExists, got %v", err) - } -} - -func TestCreateCustomTUIAgent_DBConflict(t *testing.T) { - ctrl, _ := newTestControllerWithRepo(t) - ctx := context.Background() - - // Create first agent - _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "Unique Agent", - Command: "unique-cli", - }) - if err != nil { - t.Fatalf("first create failed: %v", err) - } - - // Try to create with same name — should conflict - _, err = ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "Unique Agent", - Command: "other-cli", - }) - if err != ErrAgentAlreadyExists { - t.Errorf("expected ErrAgentAlreadyExists, got %v", err) - } -} - -func TestDeleteAgent_UnregistersFromRegistry(t *testing.T) { - ctrl, reg := newTestControllerWithRepo(t) - ctx := context.Background() - - // Create a custom TUI agent - result, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ - DisplayName: "Delete Me", - Command: "delete-cli", - }) - if err != nil { - t.Fatalf("create failed: %v", err) - } - - if !reg.Exists("delete-me") { - t.Fatal("expected agent to exist in registry before delete") - } - - // Delete it - if err := ctrl.DeleteAgent(ctx, result.ID); err != nil { - t.Fatalf("delete failed: %v", err) - } - - // Verify unregistered - if reg.Exists("delete-me") { - t.Error("expected agent to be unregistered from registry after delete") - } -} - -func TestUpdateProfile_AutoUpdatesNameWhenModelChanges(t *testing.T) { - ctrl, repo := setupProfileUpdateTest(t) - ctx := context.Background() - - agent := &models.Agent{Name: "test-agent"} - if err := repo.CreateAgent(ctx, agent); err != nil { - t.Fatalf("create agent: %v", err) - } - - profile, err := ctrl.CreateProfile(ctx, CreateProfileRequest{ - AgentID: agent.ID, - Name: "Model A Display", - Model: "model-a", - }) - if err != nil { - t.Fatalf("create profile: %v", err) - } - - // Update only the model (not the name) - newModel := "model-b" - updated, err := ctrl.UpdateProfile(ctx, UpdateProfileRequest{ - ID: profile.ID, - Model: &newModel, - }) - if err != nil { - t.Fatalf("update profile: %v", err) - } - - if updated.Name != "Model B Display" { - t.Errorf("profile name = %q, want %q", updated.Name, "Model B Display") - } - if updated.Model != "model-b" { - t.Errorf("profile model = %q, want %q", updated.Model, "model-b") - } -} - -func TestUpdateProfile_KeepsExplicitNameWhenProvided(t *testing.T) { - ctrl, repo := setupProfileUpdateTest(t) - ctx := context.Background() - - agent := &models.Agent{Name: "test-agent"} - if err := repo.CreateAgent(ctx, agent); err != nil { - t.Fatalf("create agent: %v", err) - } - - profile, err := ctrl.CreateProfile(ctx, CreateProfileRequest{ - AgentID: agent.ID, - Name: "Original Name", - Model: "model-a", - }) - if err != nil { - t.Fatalf("create profile: %v", err) - } - - // Update both model and name explicitly - newModel := "model-b" - newName := "My Custom Name" - updated, err := ctrl.UpdateProfile(ctx, UpdateProfileRequest{ - ID: profile.ID, - Model: &newModel, - Name: &newName, - }) - if err != nil { - t.Fatalf("update profile: %v", err) - } - - if updated.Name != "My Custom Name" { - t.Errorf("profile name = %q, want %q", updated.Name, "My Custom Name") - } -} - -// setupProfileUpdateTest creates a controller with a test agent that has multiple models. -func setupProfileUpdateTest(t *testing.T) (*Controller, store.Repository) { - t.Helper() - ctrl, reg, repo := newTestControllerWithRepoAndStore(t) - ta := &testAgent{ - id: "test-agent", - name: "test-agent", - modelList: &agents.ModelList{ - Models: []agents.Model{ - {ID: "model-a", Name: "Model A Display"}, - {ID: "model-b", Name: "Model B Display"}, - }, - }, - } - if err := reg.Register(ta); err != nil { - t.Fatalf("register agent: %v", err) - } - return ctrl, repo -} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 675f06bb19..55eecbd9e4 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -12,20 +12,18 @@ import ( ) type CreateProfileRequest struct { - AgentID string - Name string - Model string - AutoApprove bool - DangerouslySkipPermissions bool - AllowIndexing bool - CLIPassthrough bool + AgentID string + Name string + Model string + Mode string + AllowIndexing bool + CLIPassthrough bool } func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) { - // Validate that model is provided - if strings.TrimSpace(req.Model) == "" { - return nil, ErrModelRequired - } + // Model is optional — the profile reconciler fills it from the host + // utility probe cache on boot, and session start applies it via + // session/set_model. An empty model means "use the agent's default". agent, err := c.repo.GetAgent(ctx, req.AgentID) if err != nil { return nil, err @@ -39,15 +37,14 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest return nil, err } profile := &models.AgentProfile{ - AgentID: req.AgentID, - Name: req.Name, - AgentDisplayName: displayName, - Model: req.Model, - AutoApprove: req.AutoApprove, - DangerouslySkipPermissions: req.DangerouslySkipPermissions, - AllowIndexing: req.AllowIndexing, - CLIPassthrough: req.CLIPassthrough, - UserModified: true, + AgentID: req.AgentID, + Name: req.Name, + AgentDisplayName: displayName, + Model: req.Model, + Mode: req.Mode, + AllowIndexing: req.AllowIndexing, + CLIPassthrough: req.CLIPassthrough, + UserModified: true, } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { return nil, err @@ -57,13 +54,12 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest } type UpdateProfileRequest struct { - ID string - Name *string - Model *string - AutoApprove *bool - DangerouslySkipPermissions *bool - AllowIndexing *bool - CLIPassthrough *bool + ID string + Name *string + Model *string + Mode *string + AllowIndexing *bool + CLIPassthrough *bool } func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) { @@ -75,23 +71,15 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest profile.Name = *req.Name } if req.Model != nil { - // Validate that model is not empty - if strings.TrimSpace(*req.Model) == "" { - return nil, ErrModelRequired - } profile.Model = *req.Model - // Auto-update profile name when model changes and name wasn't explicitly provided if req.Name == nil { if newName := c.resolveProfileNameForModel(ctx, profile.AgentID, *req.Model); newName != "" { profile.Name = newName } } } - if req.AutoApprove != nil { - profile.AutoApprove = *req.AutoApprove - } - if req.DangerouslySkipPermissions != nil { - profile.DangerouslySkipPermissions = *req.DangerouslySkipPermissions + if req.Mode != nil { + profile.Mode = *req.Mode } if req.AllowIndexing != nil { profile.AllowIndexing = *req.AllowIndexing @@ -197,18 +185,17 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { return dto.AgentProfileDTO{ - ID: profile.ID, - AgentID: profile.AgentID, - Name: profile.Name, - AgentDisplayName: profile.AgentDisplayName, - Model: profile.Model, - AutoApprove: profile.AutoApprove, - DangerouslySkipPermissions: profile.DangerouslySkipPermissions, - AllowIndexing: profile.AllowIndexing, - CLIPassthrough: profile.CLIPassthrough, - UserModified: profile.UserModified, - CreatedAt: profile.CreatedAt, - UpdatedAt: profile.UpdatedAt, + ID: profile.ID, + AgentID: profile.AgentID, + Name: profile.Name, + AgentDisplayName: profile.AgentDisplayName, + Model: profile.Model, + Mode: profile.Mode, + AllowIndexing: profile.AllowIndexing, + CLIPassthrough: profile.CLIPassthrough, + UserModified: profile.UserModified, + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, } } @@ -219,16 +206,22 @@ func (c *Controller) resolveProfileNameForModel(ctx context.Context, agentID, mo if err != nil { return "" } - agentConfig, ok := c.agentRegistry.Get(agent.Name) - if !ok { + if _, ok := c.agentRegistry.Get(agent.Name); !ok { return "" } - modelEntries, _ := c.fetchModelsWithCache(ctx, agentConfig) - for _, m := range modelEntries { - if m.ID == modelID { - return m.Name + // Look up the model's display name from the host utility capability + // cache. If the cache isn't populated yet (probes not finished, agent + // not probed) we fall through to the raw model ID — better than + // blocking the save. + if c.hostUtility != nil { + if caps, ok := c.hostUtility.Get(agent.Name); ok { + for _, m := range caps.Models { + if m.ID == modelID { + return m.Name + } + } } } - return modelID // fallback to model ID if not found + return modelID } diff --git a/apps/backend/internal/agent/settings/controller/reconciler.go b/apps/backend/internal/agent/settings/controller/reconciler.go new file mode 100644 index 0000000000..04654f3936 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/reconciler.go @@ -0,0 +1,307 @@ +package controller + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/settings/models" + "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/common/logger" +) + +// CapabilityReader is the minimum surface of the host utility manager that +// the reconciler needs. Declared as an interface so tests can inject a fake. +type CapabilityReader interface { + Get(agentType string) (hostutility.AgentCapabilities, bool) +} + +// ProfileReconciler reconciles persisted agent profiles against the host +// utility capability cache. On boot it seeds default profiles for newly +// probed agents, validates existing profile models/modes against the cache +// (auto-healing stale values), and soft-deletes profiles whose agent_id is +// no longer registered (e.g. after removing the non-ACP agent variants). +// +// The reconciler is idempotent and safe to run on every boot. It runs in a +// goroutine after hostUtility.Start so probe results are available, and +// never blocks task startup — profiles used before reconciliation simply get +// validated on the next boot. +type ProfileReconciler struct { + hostUtility CapabilityReader + registry *registry.Registry + store store.Repository + log *logger.Logger +} + +// NewProfileReconciler constructs a reconciler. +func NewProfileReconciler( + h CapabilityReader, + reg *registry.Registry, + st store.Repository, + log *logger.Logger, +) *ProfileReconciler { + return &ProfileReconciler{ + hostUtility: h, + registry: reg, + store: st, + log: log.WithFields(zap.String("component", "profile-reconciler")), + } +} + +// Run executes one reconciliation pass: orphan cleanup, default seeding, and +// stale model/mode healing. Errors are logged and the pass continues — the +// goal is best-effort convergence, not atomicity. +func (r *ProfileReconciler) Run(ctx context.Context) error { + if r == nil || r.hostUtility == nil || r.registry == nil || r.store == nil { + return fmt.Errorf("reconciler not fully configured") + } + + // Orphan cleanup first: removed agents can't come back, regardless of + // probe state, so we can clean these unconditionally. + r.cleanupOrphans(ctx) + + // Walk enabled inference agents and reconcile each one. + for _, ia := range r.registry.ListInferenceAgents() { + ag, ok := ia.(agents.Agent) + if !ok { + continue + } + r.reconcileAgent(ctx, ag) + } + return nil +} + +// cleanupOrphans soft-deletes profiles whose DB agent row references an +// agent type that is no longer registered or enabled (e.g. profiles left +// behind by the removed streamjson-based variants). The settings store keys +// each DB agent by a UUID in `id`, with the registry-facing identifier stored +// in `name` — we match against the registry on `name`. +func (r *ProfileReconciler) cleanupOrphans(ctx context.Context) { + enabled := make(map[string]struct{}) + for _, ag := range r.registry.ListEnabled() { + enabled[ag.ID()] = struct{}{} + } + + dbAgents, err := r.store.ListAgents(ctx) + if err != nil { + r.log.Warn("orphan cleanup: list agents failed", zap.Error(err)) + return + } + for _, dbAgent := range dbAgents { + if _, ok := enabled[dbAgent.Name]; ok { + continue + } + profiles, err := r.store.ListAgentProfiles(ctx, dbAgent.ID) + if err != nil { + r.log.Warn("orphan cleanup: list profiles failed", + zap.String("agent_id", dbAgent.ID), + zap.String("agent_name", dbAgent.Name), + zap.Error(err)) + continue + } + for _, p := range profiles { + r.log.Info("soft-deleting orphan profile", + zap.String("profile_id", p.ID), + zap.String("agent_id", p.AgentID), + zap.String("agent_name", dbAgent.Name)) + if err := r.store.DeleteAgentProfile(ctx, p.ID); err != nil { + r.log.Warn("orphan cleanup: delete failed", + zap.String("profile_id", p.ID), zap.Error(err)) + } + } + } +} + +// reconcileAgent validates or seeds profiles for a single inference agent. +// When the agent's probe is not "ok", existing profiles are left untouched — +// the UI surfaces the probe error and the user fixes it before we retry. +func (r *ProfileReconciler) reconcileAgent(ctx context.Context, ag agents.Agent) { + agentType := ag.ID() + caps, ok := r.hostUtility.Get(agentType) + if !ok || caps.Status != hostutility.StatusOK { + r.log.Debug("skipping reconciliation: probe not ok", + zap.String("agent_id", agentType), + zap.String("status", string(caps.Status))) + return + } + + dbAgent, err := r.ensureDBAgent(ctx, ag) + if err != nil { + r.log.Warn("reconcile: ensure db agent failed", + zap.String("agent_id", agentType), zap.Error(err)) + return + } + + profiles, err := r.store.ListAgentProfiles(ctx, dbAgent.ID) + if err != nil { + r.log.Warn("reconcile: list profiles failed", + zap.String("agent_id", agentType), zap.Error(err)) + return + } + + if len(profiles) == 0 { + r.seedDefaultProfile(ctx, ag, dbAgent, caps) + return + } + + for _, p := range profiles { + r.healProfile(ctx, p, caps) + } +} + +// ensureDBAgent looks up the agent row in the store or creates one if missing. +// The store's agent row uses an auto-generated UUID for `ID`; `Name` holds +// the registry-facing identifier like "claude-acp". +// +// Callers must distinguish "not found" from transient DB errors to avoid +// duplicate rows on lock/timeout. +func (r *ProfileReconciler) ensureDBAgent(ctx context.Context, ag agents.Agent) (*models.Agent, error) { + dbAgent, err := r.store.GetAgentByName(ctx, ag.ID()) + if err == nil && dbAgent != nil { + return dbAgent, nil + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + // Transient DB error — don't attempt to create, caller will retry. + return nil, fmt.Errorf("get agent %q: %w", ag.ID(), err) + } + // Not found: create a new row. The store assigns ID on CreateAgent. + newAgent := &models.Agent{ + Name: ag.ID(), + SupportsMCP: true, + } + if createErr := r.store.CreateAgent(ctx, newAgent); createErr != nil { + return nil, createErr + } + return newAgent, nil +} + +// seedDefaultProfile creates a single default profile for an agent that has no +// existing profiles, using the probed CurrentModelID and CurrentModeID. +func (r *ProfileReconciler) seedDefaultProfile( + ctx context.Context, + ag agents.Agent, + dbAgent *models.Agent, + caps hostutility.AgentCapabilities, +) { + profile := &models.AgentProfile{ + AgentID: dbAgent.ID, + Name: profileNameFromCaps(ag, caps), + AgentDisplayName: ag.DisplayName(), + Model: caps.CurrentModelID, + Mode: caps.CurrentModeID, + AllowIndexing: ag.ID() == "auggie", + CLIPassthrough: false, + UserModified: false, + } + if err := r.store.CreateAgentProfile(ctx, profile); err != nil { + r.log.Warn("seed default profile failed", + zap.String("agent_id", dbAgent.ID), zap.Error(err)) + return + } + r.log.Info("seeded default profile from probe", + zap.String("profile_id", profile.ID), + zap.String("agent_id", dbAgent.ID), + zap.String("model", profile.Model), + zap.String("mode", profile.Mode)) +} + +// healProfile validates the profile's model and mode against the cache and +// auto-heals values that no longer exist. User-modified profiles are still +// healed — we always keep profiles in a usable state; the "user_modified" +// flag survives the write to retain user intent for other fields. +func (r *ProfileReconciler) healProfile( + ctx context.Context, + p *models.AgentProfile, + caps hostutility.AgentCapabilities, +) { + changed := healProfileName(p, caps) + + if p.Model != "" && !modelExists(p.Model, caps.Models) { + r.log.Info("profile model no longer available, auto-healing", + zap.String("profile_id", p.ID), + zap.String("old_model", p.Model), + zap.String("new_model", caps.CurrentModelID)) + p.Model = caps.CurrentModelID + changed = true + } + if p.Model == "" && caps.CurrentModelID != "" { + p.Model = caps.CurrentModelID + changed = true + } + + if p.Mode != "" && !modeExists(p.Mode, caps.Modes) { + r.log.Info("profile mode no longer available, clearing", + zap.String("profile_id", p.ID), + zap.String("old_mode", p.Mode)) + p.Mode = "" + changed = true + } + if p.Mode == "" && caps.CurrentModeID != "" { + p.Mode = caps.CurrentModeID + changed = true + } + + if !changed { + return + } + if err := r.store.UpdateAgentProfile(ctx, p); err != nil { + r.log.Warn("profile heal update failed", + zap.String("profile_id", p.ID), zap.Error(err)) + } +} + +// healProfileName updates the profile name when it still matches the agent +// display name (stale seed from before we started naming profiles after the +// current model). User-modified profiles are skipped. +func healProfileName(p *models.AgentProfile, caps hostutility.AgentCapabilities) bool { + if p.UserModified || p.Name != p.AgentDisplayName || caps.CurrentModelID == "" { + return false + } + for _, m := range caps.Models { + if m.ID == caps.CurrentModelID && m.Name != "" && m.Name != p.Name { + p.Name = m.Name + return true + } + } + return false +} + +// profileNameFromCaps picks a user-facing default profile name from the +// probed model list: prefer the Name of the agent's currentModelId, fall +// back to the model id, then the agent display name. Keeps the profile +// label meaningful ("Claude Sonnet 4.6") instead of the agent name echoed. +func profileNameFromCaps(ag agents.Agent, caps hostutility.AgentCapabilities) string { + if caps.CurrentModelID != "" { + for _, m := range caps.Models { + if m.ID == caps.CurrentModelID && m.Name != "" { + return m.Name + } + } + return caps.CurrentModelID + } + return ag.DisplayName() +} + +func modelExists(id string, models []hostutility.Model) bool { + for _, m := range models { + if m.ID == id { + return true + } + } + return false +} + +func modeExists(id string, modes []hostutility.Mode) bool { + for _, m := range modes { + if m.ID == id { + return true + } + } + return false +} diff --git a/apps/backend/internal/agent/settings/controller/reconciler_test.go b/apps/backend/internal/agent/settings/controller/reconciler_test.go new file mode 100644 index 0000000000..8af9d38dc9 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/reconciler_test.go @@ -0,0 +1,264 @@ +package controller + +import ( + "context" + "database/sql" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/settings/models" + "github.com/kandev/kandev/internal/common/logger" +) + +// fakeCapReader returns pre-baked AgentCapabilities for a fixed agent type. +type fakeCapReader struct { + caps map[string]hostutility.AgentCapabilities +} + +func (f *fakeCapReader) Get(agentType string) (hostutility.AgentCapabilities, bool) { + c, ok := f.caps[agentType] + return c, ok +} + +// fakeStore implements just enough of store.Repository for the reconciler. +type fakeStore struct { + agents map[string]*models.Agent // keyed by DB ID + byName map[string]*models.Agent // keyed by Name + profiles map[string][]*models.AgentProfile // keyed by DB agent ID + created []*models.AgentProfile + updated []*models.AgentProfile + softDeleted []string + nextAgentID int + nextProfID int + getByNameErr error +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + agents: map[string]*models.Agent{}, + byName: map[string]*models.Agent{}, + profiles: map[string][]*models.AgentProfile{}, + } +} + +func (f *fakeStore) CreateAgent(_ context.Context, a *models.Agent) error { + f.nextAgentID++ + a.ID = "agent-" + itoa(f.nextAgentID) + f.agents[a.ID] = a + f.byName[a.Name] = a + return nil +} + +func (f *fakeStore) GetAgent(_ context.Context, id string) (*models.Agent, error) { + return f.agents[id], nil +} + +func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agent, error) { + if f.getByNameErr != nil { + return nil, f.getByNameErr + } + if a, ok := f.byName[name]; ok { + return a, nil + } + return nil, sql.ErrNoRows +} + +func (f *fakeStore) UpdateAgent(context.Context, *models.Agent) error { return nil } +func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil } + +func (f *fakeStore) ListAgents(_ context.Context) ([]*models.Agent, error) { + out := make([]*models.Agent, 0, len(f.agents)) + for _, a := range f.agents { + out = append(out, a) + } + return out, nil +} + +func (f *fakeStore) ListTUIAgents(context.Context) ([]*models.Agent, error) { + return nil, nil +} + +func (f *fakeStore) GetAgentProfileMcpConfig(context.Context, string) (*models.AgentProfileMcpConfig, error) { + return nil, nil +} +func (f *fakeStore) UpsertAgentProfileMcpConfig(context.Context, *models.AgentProfileMcpConfig) error { + return nil +} + +func (f *fakeStore) CreateAgentProfile(_ context.Context, p *models.AgentProfile) error { + f.nextProfID++ + p.ID = "profile-" + itoa(f.nextProfID) + f.profiles[p.AgentID] = append(f.profiles[p.AgentID], p) + f.created = append(f.created, p) + return nil +} + +func (f *fakeStore) UpdateAgentProfile(_ context.Context, p *models.AgentProfile) error { + f.updated = append(f.updated, p) + return nil +} + +func (f *fakeStore) DeleteAgentProfile(_ context.Context, id string) error { + f.softDeleted = append(f.softDeleted, id) + return nil +} + +func (f *fakeStore) GetAgentProfile(context.Context, string) (*models.AgentProfile, error) { + return nil, nil +} + +func (f *fakeStore) ListAgentProfiles(_ context.Context, agentID string) ([]*models.AgentProfile, error) { + return f.profiles[agentID], nil +} + +func (f *fakeStore) Close() error { return nil } + +func itoa(n int) string { + if n == 0 { + return "0" + } + out := "" + for n > 0 { + out = string(rune('0'+n%10)) + out + n /= 10 + } + return out +} + +// mockInferenceAgent is a minimal fake agent for the registry. +type mockInferenceAgent struct { + id string + displayName string + enabled bool +} + +func (m *mockInferenceAgent) ID() string { return m.id } +func (m *mockInferenceAgent) Name() string { return m.id } +func (m *mockInferenceAgent) DisplayName() string { return m.displayName } +func (m *mockInferenceAgent) Description() string { return "" } +func (m *mockInferenceAgent) Enabled() bool { return m.enabled } +func (m *mockInferenceAgent) DisplayOrder() int { return 0 } +func (m *mockInferenceAgent) Logo(agents.LogoVariant) []byte { return nil } +func (m *mockInferenceAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) { + return &agents.DiscoveryResult{Available: true}, nil +} +func (m *mockInferenceAgent) BuildCommand(agents.CommandOptions) agents.Command { + return agents.Command{} +} +func (m *mockInferenceAgent) PermissionSettings() map[string]agents.PermissionSetting { return nil } +func (m *mockInferenceAgent) Runtime() *agents.RuntimeConfig { return &agents.RuntimeConfig{} } +func (m *mockInferenceAgent) RemoteAuth() *agents.RemoteAuth { return nil } +func (m *mockInferenceAgent) InstallScript() string { return "" } +func (m *mockInferenceAgent) InferenceConfig() *agents.InferenceConfig { + return &agents.InferenceConfig{Supported: true, Command: agents.NewCommand("x")} +} + +func newReconciler(t *testing.T, st *fakeStore, caps *fakeCapReader, ag agents.Agent) *ProfileReconciler { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + reg := registry.NewRegistry(log) + if err := reg.Register(ag); err != nil { + t.Fatalf("register: %v", err) + } + return NewProfileReconciler(caps, reg, st, log) +} + +func TestProfileReconciler_SeedsDefaultProfile(t *testing.T) { + st := newFakeStore() + ag := &mockInferenceAgent{id: "claude-acp", displayName: "Claude", enabled: true} + caps := &fakeCapReader{ + caps: map[string]hostutility.AgentCapabilities{ + "claude-acp": { + AgentType: "claude-acp", + Status: hostutility.StatusOK, + Models: []hostutility.Model{{ID: "claude-sonnet", Name: "Sonnet"}}, + CurrentModelID: "claude-sonnet", + CurrentModeID: "default", + }, + }, + } + r := newReconciler(t, st, caps, ag) + if err := r.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + if len(st.created) != 1 { + t.Fatalf("expected 1 created profile, got %d", len(st.created)) + } + p := st.created[0] + if p.Model != "claude-sonnet" { + t.Errorf("model = %q, want claude-sonnet", p.Model) + } + if p.Mode != "default" { + t.Errorf("mode = %q, want default", p.Mode) + } +} + +func TestProfileReconciler_HealsStaleModel(t *testing.T) { + st := newFakeStore() + // Seed an existing DB agent and profile with a stale model. + dbAgent := &models.Agent{Name: "claude-acp"} + _ = st.CreateAgent(context.Background(), dbAgent) + existing := &models.AgentProfile{ + AgentID: dbAgent.ID, + Name: "Claude", + Model: "claude-gone", + Mode: "", + } + _ = st.CreateAgentProfile(context.Background(), existing) + + ag := &mockInferenceAgent{id: "claude-acp", displayName: "Claude", enabled: true} + caps := &fakeCapReader{ + caps: map[string]hostutility.AgentCapabilities{ + "claude-acp": { + AgentType: "claude-acp", + Status: hostutility.StatusOK, + Models: []hostutility.Model{{ID: "claude-new", Name: "New"}}, + CurrentModelID: "claude-new", + Modes: []hostutility.Mode{{ID: "default", Name: "Default"}}, + CurrentModeID: "default", + }, + }, + } + r := newReconciler(t, st, caps, ag) + if err := r.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + if len(st.updated) != 1 { + t.Fatalf("expected 1 updated profile, got %d", len(st.updated)) + } + updated := st.updated[0] + if updated.Model != "claude-new" { + t.Errorf("healed model = %q, want claude-new", updated.Model) + } + if updated.Mode != "default" { + t.Errorf("backfilled mode = %q, want default", updated.Mode) + } +} + +func TestProfileReconciler_CleansOrphanProfiles(t *testing.T) { + st := newFakeStore() + // Seed a DB row for an agent that is NOT registered in the registry. + orphanAgent := &models.Agent{Name: "removed-old-agent"} + _ = st.CreateAgent(context.Background(), orphanAgent) + orphanProfile := &models.AgentProfile{ + AgentID: orphanAgent.ID, + Name: "legacy", + Model: "x", + } + _ = st.CreateAgentProfile(context.Background(), orphanProfile) + + ag := &mockInferenceAgent{id: "claude-acp", displayName: "Claude", enabled: true} + caps := &fakeCapReader{caps: map[string]hostutility.AgentCapabilities{}} + r := newReconciler(t, st, caps, ag) + if err := r.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + if len(st.softDeleted) != 1 || st.softDeleted[0] != orphanProfile.ID { + t.Fatalf("expected orphan profile to be soft-deleted, got %v", st.softDeleted) + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 2bb6944c78..2a78fcb63e 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -7,18 +7,17 @@ import ( ) type AgentProfileDTO struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - Name string `json:"name"` - AgentDisplayName string `json:"agent_display_name"` - Model string `json:"model"` - AutoApprove bool `json:"auto_approve"` - DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` - AllowIndexing bool `json:"allow_indexing"` - CLIPassthrough bool `json:"cli_passthrough"` - UserModified bool `json:"user_modified"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + AgentDisplayName string `json:"agent_display_name"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + AllowIndexing bool `json:"allow_indexing"` + CLIPassthrough bool `json:"cli_passthrough"` + UserModified bool `json:"user_modified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type TUIConfigDTO struct { @@ -38,8 +37,14 @@ type AgentDTO struct { MCPConfigPath string `json:"mcp_config_path,omitempty"` TUIConfig *TUIConfigDTO `json:"tui_config,omitempty"` Profiles []AgentProfileDTO `json:"profiles"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // CapabilityStatus mirrors the host utility probe status so clients can + // flag agents that need login or reinstallation without fetching the + // full model config separately. "" for agents that aren't probed + // (mock, tui-only). + CapabilityStatus string `json:"capability_status,omitempty"` + CapabilityError string `json:"capability_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListAgentsResponse struct { @@ -70,16 +75,29 @@ type AgentCapabilitiesDTO struct { type ModelEntryDTO struct { ID string `json:"id"` Name string `json:"name"` + Description string `json:"description,omitempty"` Provider string `json:"provider"` ContextWindow int `json:"context_window"` IsDefault bool `json:"is_default"` Source string `json:"source,omitempty"` + // Meta carries agent-specific extras from ACP's `_meta` field. For + // GitHub Copilot this includes `copilotUsage` (e.g. "1x", "0.33x", + // "0x" — the premium-request multiplier) and `copilotEnablement`. + Meta map[string]any `json:"meta,omitempty"` } type ModelConfigDTO struct { - DefaultModel string `json:"default_model"` - AvailableModels []ModelEntryDTO `json:"available_models"` - SupportsDynamicModels bool `json:"supports_dynamic_models"` + DefaultModel string `json:"default_model"` + AvailableModels []ModelEntryDTO `json:"available_models"` + CurrentModelID string `json:"current_model_id,omitempty"` + AvailableModes []ModeEntryDTO `json:"available_modes,omitempty"` + CurrentModeID string `json:"current_mode_id,omitempty"` + AvailableCommands []CommandEntryDTO `json:"available_commands,omitempty"` + SupportsDynamicModels bool `json:"supports_dynamic_models"` + // Status reflects the host utility probe state for this agent type: + // "probing" | "ok" | "auth_required" | "not_installed" | "failed". + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` } type PermissionSettingDTO struct { @@ -152,11 +170,29 @@ type CommandPreviewResponse struct { CommandString string `json:"command_string"` } -// DynamicModelsResponse is the response for the dynamic models endpoint +// DynamicModelsResponse is the response for the /agent-models/:agentName endpoint. +// Data now comes from the host utility capability cache populated by ACP probes. type DynamicModelsResponse struct { - AgentName string `json:"agent_name"` - Models []ModelEntryDTO `json:"models"` - Cached bool `json:"cached"` - CachedAt *time.Time `json:"cached_at,omitempty"` - Error *string `json:"error"` + AgentName string `json:"agent_name"` + Status string `json:"status"` // "probing" | "ok" | "auth_required" | "not_installed" | "failed" + Models []ModelEntryDTO `json:"models"` + CurrentModelID string `json:"current_model_id,omitempty"` + Modes []ModeEntryDTO `json:"modes,omitempty"` + CurrentModeID string `json:"current_mode_id,omitempty"` + Commands []CommandEntryDTO `json:"commands,omitempty"` + Error *string `json:"error"` +} + +// ModeEntryDTO is a single ACP session mode advertised by an agent. +type ModeEntryDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// CommandEntryDTO is a slash command advertised by the agent. +type CommandEntryDTO struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` } diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index fc41253366..66a5989741 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -121,10 +121,9 @@ type createAgentRequest struct { } type createAgentProfileRequest struct { - Name string `json:"name"` - Model string `json:"model"` - AutoApprove bool `json:"auto_approve"` - DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` + Name string `json:"name"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` } func (h *Handlers) httpCreateAgent(c *gin.Context) { @@ -144,10 +143,9 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) { return } profiles = append(profiles, controller.CreateAgentProfileRequest{ - Name: profile.Name, - Model: profile.Model, - AutoApprove: profile.AutoApprove, - DangerouslySkipPermissions: profile.DangerouslySkipPermissions, + Name: profile.Name, + Model: profile.Model, + Mode: profile.Mode, }) } resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{ @@ -299,12 +297,11 @@ func (h *Handlers) httpUpdateProfileMcpConfig(c *gin.Context) { } type createProfileRequest struct { - Name string `json:"name"` - Model string `json:"model"` - AutoApprove bool `json:"auto_approve"` - DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` - AllowIndexing bool `json:"allow_indexing"` - CLIPassthrough bool `json:"cli_passthrough"` + Name string `json:"name"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + AllowIndexing bool `json:"allow_indexing"` + CLIPassthrough bool `json:"cli_passthrough"` } func (h *Handlers) httpCreateProfile(c *gin.Context) { @@ -318,13 +315,12 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { return } resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequest{ - AgentID: c.Param("id"), - Name: body.Name, - Model: body.Model, - AutoApprove: body.AutoApprove, - DangerouslySkipPermissions: body.DangerouslySkipPermissions, - AllowIndexing: body.AllowIndexing, - CLIPassthrough: body.CLIPassthrough, + AgentID: c.Param("id"), + Name: body.Name, + Model: body.Model, + Mode: body.Mode, + AllowIndexing: body.AllowIndexing, + CLIPassthrough: body.CLIPassthrough, }) if err != nil { h.logger.Error("failed to create profile", zap.Error(err)) @@ -341,12 +337,11 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { } type updateProfileRequest struct { - Name *string `json:"name,omitempty"` - Model *string `json:"model,omitempty"` - AutoApprove *bool `json:"auto_approve,omitempty"` - DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"` - AllowIndexing *bool `json:"allow_indexing,omitempty"` - CLIPassthrough *bool `json:"cli_passthrough,omitempty"` + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + Mode *string `json:"mode,omitempty"` + AllowIndexing *bool `json:"allow_indexing,omitempty"` + CLIPassthrough *bool `json:"cli_passthrough,omitempty"` } func (h *Handlers) httpUpdateProfile(c *gin.Context) { @@ -360,13 +355,12 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { return } resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequest{ - ID: c.Param("id"), - Name: body.Name, - Model: body.Model, - AutoApprove: body.AutoApprove, - DangerouslySkipPermissions: body.DangerouslySkipPermissions, - AllowIndexing: body.AllowIndexing, - CLIPassthrough: body.CLIPassthrough, + ID: c.Param("id"), + Name: body.Name, + Model: body.Model, + Mode: body.Mode, + AllowIndexing: body.AllowIndexing, + CLIPassthrough: body.CLIPassthrough, }) if err != nil { if err == controller.ErrAgentProfileNotFound { diff --git a/apps/backend/internal/agent/settings/handlers/handlers_test.go b/apps/backend/internal/agent/settings/handlers/handlers_test.go deleted file mode 100644 index 06b9367bdb..0000000000 --- a/apps/backend/internal/agent/settings/handlers/handlers_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package handlers - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/kandev/kandev/internal/agent/agents" - "github.com/kandev/kandev/internal/agent/discovery" - "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/agent/settings/controller" - "github.com/kandev/kandev/internal/common/logger" - ws "github.com/kandev/kandev/pkg/websocket" -) - -type handlersTestBroadcaster struct { - mu sync.Mutex - calls int -} - -func (b *handlersTestBroadcaster) Broadcast(msg *ws.Message) { - b.mu.Lock() - b.calls++ - b.mu.Unlock() -} - -func (b *handlersTestBroadcaster) callCount() int { - b.mu.Lock() - defer b.mu.Unlock() - return b.calls -} - -type handlersTestAgent struct { - id string - - blockListModels bool - ctxDone chan struct{} -} - -func (a *handlersTestAgent) ID() string { return a.id } -func (a *handlersTestAgent) Name() string { return a.id } -func (a *handlersTestAgent) DisplayName() string { return a.id } -func (a *handlersTestAgent) Description() string { return "" } -func (a *handlersTestAgent) Enabled() bool { return true } -func (a *handlersTestAgent) DisplayOrder() int { return 0 } -func (a *handlersTestAgent) Logo(variant agents.LogoVariant) []byte { return nil } -func (a *handlersTestAgent) DefaultModel() string { return "" } -func (a *handlersTestAgent) BuildCommand(opts agents.CommandOptions) agents.Command { - return agents.NewCommand() -} -func (a *handlersTestAgent) PermissionSettings() map[string]agents.PermissionSetting { - return nil -} -func (a *handlersTestAgent) Runtime() *agents.RuntimeConfig { return nil } -func (a *handlersTestAgent) RemoteAuth() *agents.RemoteAuth { return nil } -func (a *handlersTestAgent) InstallScript() string { return "" } - -func (a *handlersTestAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) { - return &agents.DiscoveryResult{Available: true}, nil -} - -func (a *handlersTestAgent) ListModels(ctx context.Context) (*agents.ModelList, error) { - if !a.blockListModels { - return &agents.ModelList{SupportsDynamic: false}, nil - } - <-ctx.Done() - if a.ctxDone != nil { - select { - case <-a.ctxDone: - default: - close(a.ctxDone) - } - } - return nil, ctx.Err() -} - -func newHandlersTestLogger(t *testing.T) *logger.Logger { - t.Helper() - log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) - if err != nil { - t.Fatalf("new logger: %v", err) - } - return log -} - -func TestBroadcastAvailableAgentsAsync_UsesTimeoutAndDoesNotBroadcastOnError(t *testing.T) { - log := newHandlersTestLogger(t) - ag := &handlersTestAgent{id: "codex"} - - agentRegistry := registry.NewRegistry(log) - if err := agentRegistry.Register(ag); err != nil { - t.Fatalf("register agent: %v", err) - } - - discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log) - if err != nil { - t.Fatalf("load discovery registry: %v", err) - } - - ctrl := controller.NewController(nil, discoveryRegistry, agentRegistry, nil, log) - hub := &handlersTestBroadcaster{} - h := NewHandlers(ctrl, hub, log) - - prevTimeout := availableAgentsBroadcastTimeout - availableAgentsBroadcastTimeout = 20 * time.Millisecond - t.Cleanup(func() { availableAgentsBroadcastTimeout = prevTimeout }) - - ag.blockListModels = true - ag.ctxDone = make(chan struct{}) - start := time.Now() - h.broadcastAvailableAgentsAsync() - if elapsed := time.Since(start); elapsed > 10*time.Millisecond { - t.Fatalf("broadcast call blocked for %v", elapsed) - } - - select { - case <-ag.ctxDone: - case <-time.After(200 * time.Millisecond): - t.Fatal("ListModels did not observe context timeout") - } - - time.Sleep(20 * time.Millisecond) - if got := hub.callCount(); got != 1 { - t.Fatalf("broadcast calls = %d, want 1", got) - } -} diff --git a/apps/backend/internal/agent/settings/modelfetcher/cache.go b/apps/backend/internal/agent/settings/modelfetcher/cache.go index 7ff2992982..3622a91b9f 100644 --- a/apps/backend/internal/agent/settings/modelfetcher/cache.go +++ b/apps/backend/internal/agent/settings/modelfetcher/cache.go @@ -3,10 +3,19 @@ package modelfetcher import ( "sync" "time" - - "github.com/kandev/kandev/internal/agent/agents" ) +// Model is a simple descriptor retained here so CacheEntry can remain typed. +// Static agent model lists have been removed — models now come from the host +// utility capability cache. This package is deprecated and kept only as a +// shim for legacy callers until they migrate. +type Model struct { + ID string + Name string + Description string + IsDefault bool +} + const ( defaultTTL = 10 * time.Second maxCacheAge = 1 * time.Minute @@ -14,7 +23,7 @@ const ( // CacheEntry holds cached model data for an agent type CacheEntry struct { - Models []agents.Model + Models []Model CachedAt time.Time ExpiresAt time.Time Error error @@ -60,7 +69,7 @@ func (c *Cache) Get(agentName string) (*CacheEntry, bool) { } // Set caches the models for an agent -func (c *Cache) Set(agentName string, models []agents.Model, err error) { +func (c *Cache) Set(agentName string, models []Model, err error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/apps/backend/internal/agent/settings/modelfetcher/cache_test.go b/apps/backend/internal/agent/settings/modelfetcher/cache_test.go deleted file mode 100644 index be61043801..0000000000 --- a/apps/backend/internal/agent/settings/modelfetcher/cache_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package modelfetcher - -import ( - "testing" - "time" - - "github.com/kandev/kandev/internal/agent/agents" -) - -func TestCache_SetAndGet(t *testing.T) { - cache := NewCache() - - models := []agents.Model{ - {ID: "anthropic/claude-sonnet", Name: "Claude Sonnet", Provider: "anthropic"}, - {ID: "openai/gpt-4", Name: "GPT-4", Provider: "openai"}, - } - - cache.Set("test-agent", models, nil) - - entry, exists := cache.Get("test-agent") - if !exists { - t.Fatal("expected cache entry to exist") - } - - if len(entry.Models) != 2 { - t.Errorf("expected 2 models, got %d", len(entry.Models)) - } - - if entry.Models[0].ID != "anthropic/claude-sonnet" { - t.Errorf("expected first model ID to be 'anthropic/claude-sonnet', got %q", entry.Models[0].ID) - } - - if entry.Error != nil { - t.Errorf("expected no error, got %v", entry.Error) - } -} - -func TestCache_GetNonExistent(t *testing.T) { - cache := NewCache() - - _, exists := cache.Get("non-existent") - if exists { - t.Error("expected cache entry to not exist") - } -} - -func TestCache_SetWithError(t *testing.T) { - cache := NewCache() - - testErr := &testError{msg: "command failed"} - cache.Set("test-agent", nil, testErr) - - entry, exists := cache.Get("test-agent") - if !exists { - t.Fatal("expected cache entry to exist") - } - - if entry.Error == nil { - t.Error("expected error to be stored") - } - - if entry.Error.Error() != "command failed" { - t.Errorf("expected error message 'command failed', got %q", entry.Error.Error()) - } -} - -func TestCache_Invalidate(t *testing.T) { - cache := NewCache() - - models := []agents.Model{ - {ID: "test/model", Name: "Test Model", Provider: "test"}, - } - - cache.Set("test-agent", models, nil) - - // Verify it exists - _, exists := cache.Get("test-agent") - if !exists { - t.Fatal("expected cache entry to exist before invalidation") - } - - // Invalidate - cache.Invalidate("test-agent") - - // Verify it's gone - _, exists = cache.Get("test-agent") - if exists { - t.Error("expected cache entry to not exist after invalidation") - } -} - -func TestCache_Clear(t *testing.T) { - cache := NewCache() - - models := []agents.Model{ - {ID: "test/model", Name: "Test Model", Provider: "test"}, - } - - cache.Set("agent1", models, nil) - cache.Set("agent2", models, nil) - - cache.Clear() - - _, exists1 := cache.Get("agent1") - _, exists2 := cache.Get("agent2") - - if exists1 || exists2 { - t.Error("expected all cache entries to be cleared") - } -} - -func TestCacheEntry_IsValid(t *testing.T) { - tests := []struct { - name string - entry CacheEntry - expected bool - }{ - { - name: "valid entry", - entry: CacheEntry{ - Models: []agents.Model{{ID: "test"}}, - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(5 * time.Second), - Error: nil, - }, - expected: true, - }, - { - name: "expired entry", - entry: CacheEntry{ - Models: []agents.Model{{ID: "test"}}, - CachedAt: time.Now().Add(-1 * time.Minute), - ExpiresAt: time.Now().Add(-30 * time.Second), - Error: nil, - }, - expected: false, - }, - { - name: "entry with error", - entry: CacheEntry{ - Models: nil, - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(5 * time.Second), - Error: &testError{msg: "some error"}, - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.entry.IsValid() - if result != tt.expected { - t.Errorf("IsValid() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestCacheEntry_IsStale(t *testing.T) { - tests := []struct { - name string - entry CacheEntry - expected bool - }{ - { - name: "fresh entry - not stale", - entry: CacheEntry{ - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(5 * time.Second), - }, - expected: false, - }, - { - name: "expired but within max age - stale", - entry: CacheEntry{ - CachedAt: time.Now().Add(-30 * time.Second), - ExpiresAt: time.Now().Add(-20 * time.Second), - }, - expected: true, - }, - { - name: "beyond max age - not stale (too old)", - entry: CacheEntry{ - CachedAt: time.Now().Add(-2 * time.Hour), - ExpiresAt: time.Now().Add(-2 * time.Hour), - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.entry.IsStale() - if result != tt.expected { - t.Errorf("IsStale() = %v, want %v", result, tt.expected) - } - }) - } -} - -// testError is a simple error type for testing -type testError struct { - msg string -} - -func (e *testError) Error() string { - return e.msg -} diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 1591074466..40143547ec 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -24,17 +24,38 @@ type TUIConfigJSON struct { } type AgentProfile struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - Name string `json:"name"` - AgentDisplayName string `json:"agent_display_name"` - Model string `json:"model"` - AutoApprove bool `json:"auto_approve"` - DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` - AllowIndexing bool `json:"allow_indexing"` - CLIPassthrough bool `json:"cli_passthrough"` - UserModified bool `json:"user_modified"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + ID string `json:"id"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + AgentDisplayName string `json:"agent_display_name"` + + // Model is the ACP model ID applied via session/set_model at session start. + // Validated against the host utility capability cache by the reconciler. + Model string `json:"model"` + + // Mode is the optional ACP session mode applied via session/set_mode at + // session start. Empty when the agent does not advertise modes. + Mode string `json:"mode,omitempty"` + + // MigratedFrom records the agent_id this profile was migrated from, if any. + // Used for audit of the one-shot non-ACP → ACP migration. + MigratedFrom string `json:"migrated_from,omitempty"` + + // CLIPassthrough enables TUI-passthrough execution style. Orthogonal to ACP. + CLIPassthrough bool `json:"cli_passthrough"` + + // AllowIndexing is kept as an auggie-only CLI flag (no ACP equivalent). + // Ignored for all other agents. + AllowIndexing bool `json:"allow_indexing"` + + // Deprecated legacy permission fields: retained in the DB schema so rows + // load cleanly, but no longer read by the launch path. ACP session modes + // and interactive permission_request prompts replace them. + AutoApprove bool `json:"-"` + DangerouslySkipPermissions bool `json:"-"` + + UserModified bool `json:"user_modified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index e1a092a641..c306a724cd 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -56,7 +56,9 @@ func (r *sqliteRepository) initSchema() error { agent_id TEXT NOT NULL, name TEXT NOT NULL, agent_display_name TEXT NOT NULL, - model TEXT NOT NULL CHECK(model != ''), + model TEXT NOT NULL DEFAULT '', + mode TEXT DEFAULT NULL, + migrated_from TEXT DEFAULT NULL, auto_approve INTEGER NOT NULL DEFAULT 0, dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0, allow_indexing INTEGER NOT NULL DEFAULT 1, @@ -91,6 +93,10 @@ func (r *sqliteRepository) initSchema() error { // Migration: add tui_config column (idempotent — ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE agents ADD COLUMN tui_config TEXT DEFAULT NULL`) + // Migration: add mode and migrated_from columns on agent_profiles (idempotent) + _, _ = r.db.Exec(`ALTER TABLE agent_profiles ADD COLUMN mode TEXT DEFAULT NULL`) + _, _ = r.db.Exec(`ALTER TABLE agent_profiles ADD COLUMN migrated_from TEXT DEFAULT NULL`) + return nil } @@ -241,20 +247,33 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode profile.CreatedAt = now profile.UpdatedAt = now _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?) - `), profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, dialect.BoolToInt(profile.AutoApprove), + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?) + `), profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, + nullableString(profile.Mode), nullableString(profile.MigratedFrom), + dialect.BoolToInt(profile.AutoApprove), dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) return err } +// nullableString converts an empty string to sql.NullString zero-value so the +// column is written as NULL rather than "". Keeps nullable columns clean. +func nullableString(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error { profile.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE agent_profiles - SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, user_modified = ?, updated_at = ? + SET name = ?, agent_display_name = ?, model = ?, mode = ?, migrated_from = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, user_modified = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `), profile.Name, profile.AgentDisplayName, profile.Model, dialect.BoolToInt(profile.AutoApprove), + `), profile.Name, profile.AgentDisplayName, profile.Model, + nullableString(profile.Mode), nullableString(profile.MigratedFrom), + dialect.BoolToInt(profile.AutoApprove), dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), dialect.BoolToInt(profile.CLIPassthrough), dialect.BoolToInt(profile.UserModified), profile.UpdatedAt, profile.ID) if err != nil { return err @@ -283,7 +302,7 @@ func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) er func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at + SELECT id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE id = ? AND deleted_at IS NULL `), id) return scanAgentProfile(row) @@ -291,7 +310,7 @@ func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*mod func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at + SELECT id, agent_id, name, agent_display_name, model, mode, migrated_from, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `), agentID) if err != nil { @@ -376,6 +395,8 @@ func scanAgentProfile(scanner interface { Scan(dest ...any) error }) (*models.AgentProfile, error) { profile := &models.AgentProfile{} + var mode sql.NullString + var migratedFrom sql.NullString var autoApprove int var skipPermissions int var allowIndexing int @@ -388,6 +409,8 @@ func scanAgentProfile(scanner interface { &profile.Name, &profile.AgentDisplayName, &profile.Model, + &mode, + &migratedFrom, &autoApprove, &skipPermissions, &allowIndexing, @@ -400,6 +423,12 @@ func scanAgentProfile(scanner interface { ); err != nil { return nil, err } + if mode.Valid { + profile.Mode = mode.String + } + if migratedFrom.Valid { + profile.MigratedFrom = migratedFrom.String + } profile.AutoApprove = autoApprove == 1 profile.DangerouslySkipPermissions = skipPermissions == 1 profile.AllowIndexing = allowIndexing == 1 diff --git a/apps/backend/internal/agentctl/client/utility.go b/apps/backend/internal/agentctl/client/utility.go index 08868f9d6d..2d38d4319f 100644 --- a/apps/backend/internal/agentctl/client/utility.go +++ b/apps/backend/internal/agentctl/client/utility.go @@ -10,41 +10,56 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/utility" ) -// InferencePrompt executes a one-shot inference prompt via agentctl. -// Uses the long-running HTTP client since LLM inference can take several minutes. -func (c *Client) InferencePrompt(ctx context.Context, req *utility.PromptRequest) (*utility.PromptResponse, error) { - body, err := json.Marshal(req) +// doLongRunningJSON posts a JSON body to the given path on the long-running +// HTTP client and decodes a JSON response body into out. It is used by the +// utility endpoints (inference prompt, probe) where the underlying LLM call +// or agent cold-start can take minutes. +func (c *Client) doLongRunningJSON(ctx context.Context, path, label string, in, out any) error { + body, err := json.Marshal(in) if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) + return fmt.Errorf("failed to marshal request: %w", err) } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/inference/prompt", bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, bytes.NewReader(body)) if err != nil { - return nil, err + return err } httpReq.Header.Set("Content-Type", "application/json") - // Use longRunningHTTPClient for inference prompts since LLM API calls - // can take 1-5 minutes depending on model and prompt complexity. resp, err := c.longRunningHTTPClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return fmt.Errorf("request failed: %w", err) } defer func() { _ = resp.Body.Close() }() respBody, err := readResponseBody(resp) if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + return fmt.Errorf("failed to read response: %w", err) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("utility prompt failed with status %d: %s", resp.StatusCode, truncateBody(respBody)) + return fmt.Errorf("%s failed with status %d: %s", label, resp.StatusCode, truncateBody(respBody)) } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("failed to parse %s response (status %d, body: %s): %w", label, resp.StatusCode, truncateBody(respBody), err) + } + return nil +} +// InferencePrompt executes a one-shot inference prompt via agentctl. +// Uses the long-running HTTP client since LLM inference can take several minutes. +func (c *Client) InferencePrompt(ctx context.Context, req *utility.PromptRequest) (*utility.PromptResponse, error) { var result utility.PromptResponse - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) + if err := c.doLongRunningJSON(ctx, "/api/v1/inference/prompt", "utility prompt", req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Probe runs an ACP handshake (initialize + session/new) against the agent +// to discover its capabilities, auth methods, models, and modes. +func (c *Client) Probe(ctx context.Context, req *utility.ProbeRequest) (*utility.ProbeResponse, error) { + var result utility.ProbeResponse + if err := c.doLongRunningJSON(ctx, "/api/v1/inference/probe", "probe", req, &result); err != nil { + return nil, err } - return &result, nil } diff --git a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go b/apps/backend/internal/agentctl/server/adapter/amp_adapter.go deleted file mode 100644 index 24eac2150f..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/amp_adapter.go +++ /dev/null @@ -1,1149 +0,0 @@ -package adapter - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "os/exec" - "strings" - "sync" - - "github.com/google/uuid" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/amp" - v1 "github.com/kandev/kandev/pkg/api/v1" - "go.uber.org/zap" -) - -// AmpAdapter implements AgentAdapter for Sourcegraph Amp CLI. -// Amp is a one-shot CLI: each prompt spawns a new process that reads stdin -// until EOF, processes the prompt, writes stream-json to stdout, and exits. -// For multi-turn conversations, follow-up prompts use "threads continue ". -type AmpAdapter struct { - cfg *Config - logger *logger.Logger - - // Normalizer for converting tool data to NormalizedPayload - normalizer *AmpNormalizer - - // One-shot subprocess management. - // When oneShotCfg is set, the adapter spawns a new process per prompt - // instead of using externally-provided stdin/stdout pipes. - oneShotCfg *OneShotConfig - process *exec.Cmd // Current subprocess (nil between prompts) - processMu sync.Mutex // Guards subprocess lifecycle - - // Subprocess stdin/stdout (set via Connect for non-one-shot, or per-prompt for one-shot) - stdin io.Writer - stdout io.Reader - - // Amp client for protocol communication - client *amp.Client - - // Context for managing goroutine lifecycle - ctx context.Context - cancel context.CancelFunc - - // Session state - sessionID string // Thread ID in Amp terminology - operationID string - sessionStatusSent bool - hasAmpThreadID bool // True when sessionID contains a real Amp thread ID (T-xxx), not a placeholder - - // Track pending tool calls and their normalized payloads - pendingToolCalls map[string]bool - pendingToolPayloads map[string]*streams.NormalizedPayload - - // Accumulate text for the complete event - // This is needed because the manager's buffer may be cleared by session code - // before events are processed, causing a race condition - textAccumulator strings.Builder - - // Agent info - agentInfo *AgentInfo - - // Update channel - updatesCh chan AgentEvent - - // Permission handler - permissionHandler PermissionHandler - - // Result completion signaling - resultCh chan resultComplete - - // Context window tracking - mainModelName string - mainModelContextWindow int64 - contextTokensUsed int64 - - // Track if complete event was already sent for current operation - // (to avoid duplicates from both stop_reason=end_turn and result message) - completeSent bool - - // Track whether text was streamed this turn to prevent duplicates from result.text - streamingTextSentThisTurn bool - - // lastRawData holds the raw JSON of the current message being processed. - // Set in handleMessage before dispatch; used by sendUpdate for OTel tracing. - lastRawData json.RawMessage - - // Attachment management - attachMgr *shared.AttachmentManager - - // Synchronization - mu sync.RWMutex - closed bool -} - -// defaultAmpContextWindow is the fallback context window size. -const defaultAmpContextWindow = 200000 - -// NewAmpAdapter creates a new Amp protocol adapter. -func NewAmpAdapter(cfg *Config, log *logger.Logger) *AmpAdapter { - ctx, cancel := context.WithCancel(context.Background()) - - l := log.WithFields(zap.String("adapter", "amp")) - return &AmpAdapter{ - cfg: cfg, - logger: l, - normalizer: NewAmpNormalizer(), - oneShotCfg: cfg.OneShotConfig, - ctx: ctx, - cancel: cancel, - updatesCh: make(chan AgentEvent, 100), - mainModelContextWindow: defaultAmpContextWindow, - pendingToolCalls: make(map[string]bool), - pendingToolPayloads: make(map[string]*streams.NormalizedPayload), - attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()), - } -} - -// PrepareEnvironment performs protocol-specific setup before the agent process starts. -// Amp reads configuration from ~/.config/amp/settings.json. -func (a *AmpAdapter) PrepareEnvironment() (map[string]string, error) { - // Amp MCP configuration is handled via config file - return nil, nil -} - -// PrepareCommandArgs returns extra command-line arguments for the agent process. -// For Amp, no extra args are needed - configuration is via config files. -func (a *AmpAdapter) PrepareCommandArgs() []string { - return nil -} - -// Connect wires up the stdin/stdout pipes from the running agent subprocess. -// For one-shot adapters, this is a no-op — the adapter manages its own subprocess. -func (a *AmpAdapter) Connect(stdin io.Writer, stdout io.Reader) error { - if a.oneShotCfg != nil { - return nil // One-shot adapter manages its own subprocess per prompt - } - - a.mu.Lock() - defer a.mu.Unlock() - - if a.stdin != nil || a.stdout != nil { - return fmt.Errorf("adapter already connected") - } - - a.stdin = stdin - a.stdout = stdout - return nil -} - -// Initialize establishes the Amp connection with the agent subprocess. -// For one-shot adapters, this only sets up agent info — the actual client -// is created per-prompt in Prompt(). -func (a *AmpAdapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing Amp adapter", - zap.String("workdir", a.cfg.WorkDir), - zap.Bool("one_shot", a.oneShotCfg != nil)) - - // Store agent info - a.agentInfo = &AgentInfo{ - Name: "amp", - Version: "unknown", - } - - // For one-shot mode, skip client creation — it happens per-prompt - if a.oneShotCfg != nil { - a.logger.Info("Amp adapter initialized (one-shot mode)") - return nil - } - - // Legacy: create persistent client for non-one-shot mode - a.client = amp.NewClient(a.stdin, a.stdout, a.logger) - a.client.SetMessageHandler(a.handleMessage) - a.client.Start(a.ctx) - - a.logger.Info("Amp adapter initialized") - return nil -} - -// GetAgentInfo returns information about the connected agent. -func (a *AmpAdapter) GetAgentInfo() *AgentInfo { - return a.agentInfo -} - -// NewSession creates a new Amp session (thread). -// Note: Amp sessions are created implicitly with the first prompt. -func (a *AmpAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - - // Generate a placeholder session ID - Amp will return its thread ID - sessionID := uuid.New().String() - a.sessionID = sessionID - a.attachMgr.SetSessionID(sessionID) - - a.logger.Info("created new session placeholder", zap.String("session_id", sessionID)) - - return sessionID, nil -} - -// LoadSession resumes an existing Amp session (thread). -// For one-shot mode, the session ID is used to build the "threads continue" command. -func (a *AmpAdapter) LoadSession(ctx context.Context, sessionID string, _ []types.McpServer) error { - a.mu.Lock() - a.sessionID = sessionID - a.hasAmpThreadID = sessionID != "" // LoadSession always receives a real Amp thread ID - a.mu.Unlock() - a.attachMgr.SetSessionID(sessionID) - - // Set thread ID in client for tracking (only for non-one-shot) - if a.client != nil { - a.client.SetThreadID(sessionID) - } - - a.logger.Info("loaded session", zap.String("session_id", sessionID)) - - return nil -} - -// Prompt sends a prompt to Amp and waits for completion. -// In one-shot mode, this spawns a new subprocess per prompt. -// Note: attachments are not yet supported in Amp protocol - they are ignored. -func (a *AmpAdapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { - // Save attachments to workspace and prepend references to message - finalMessage := message - if len(attachments) > 0 { - saved, err := a.attachMgr.SaveAttachments(attachments) - if err != nil { - a.logger.Warn("failed to save attachments", zap.Error(err)) - } else if len(saved) > 0 { - finalMessage = shared.BuildAttachmentPrompt(saved) + message - } - } - - if a.oneShotCfg != nil { - err := a.promptOneShot(ctx, finalMessage) - a.attachMgr.Cleanup() - return err - } - err := a.promptLongLived(ctx, finalMessage) - a.attachMgr.Cleanup() - return err -} - -// oneShotProcess holds the state of a one-shot subprocess spawned for a single prompt. -type oneShotProcess struct { - cmd *exec.Cmd - client *amp.Client - stdin io.WriteCloser -} - -// promptOneShot spawns a new Amp subprocess for each prompt. -// The process reads stdin until EOF, processes the prompt, writes stream-json -// to stdout, and exits. For follow-up prompts, "threads continue " is used. -func (a *AmpAdapter) promptOneShot(ctx context.Context, message string) error { - operationID := a.resetOneShotState() - - proc, err := a.spawnOneShotProcess(ctx) - if err != nil { - return err - } - defer a.clearOneShotProcess() - - // Send user message, then close stdin to signal EOF - if err := proc.client.SendUserMessage(message); err != nil { - proc.client.Stop() - return fmt.Errorf("failed to send user message: %w", err) - } - if err := proc.stdin.Close(); err != nil { - a.logger.Debug("failed to close stdin pipe", zap.Error(err)) - } - - // Wait for result message or context cancellation - result, err := a.awaitOneShotResult(ctx, proc) - if err != nil { - return err - } - - if !result.success && result.err != "" { - return fmt.Errorf("prompt failed: %s", result.err) - } - - a.logger.Info("one-shot prompt completed", - zap.String("operation_id", operationID), - zap.Bool("success", result.success)) - return nil -} - -// resetOneShotState resets per-prompt state and returns the new operation ID. -func (a *AmpAdapter) resetOneShotState() string { - a.mu.Lock() - defer a.mu.Unlock() - operationID := uuid.New().String() - a.operationID = operationID - a.resultCh = make(chan resultComplete, 1) - a.completeSent = false - a.streamingTextSentThisTurn = false - return operationID -} - -// spawnOneShotProcess creates and starts a subprocess for a single prompt. -func (a *AmpAdapter) spawnOneShotProcess(ctx context.Context) (*oneShotProcess, error) { - args := a.buildOneShotArgs() - a.logger.Info("spawning one-shot Amp process", zap.Strings("args", args)) - - cmd := exec.CommandContext(ctx, args[0], args[1:]...) - cmd.Dir = a.oneShotCfg.WorkDir - cmd.Env = a.oneShotCfg.Env - setProcGroup(cmd) - - stdinPipe, err := cmd.StdinPipe() - if err != nil { - return nil, fmt.Errorf("failed to create stdin pipe: %w", err) - } - stdoutPipe, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("failed to create stdout pipe: %w", err) - } - stderrPipe, err := cmd.StderrPipe() - if err != nil { - return nil, fmt.Errorf("failed to create stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("failed to start Amp process: %w", err) - } - - a.processMu.Lock() - a.process = cmd - a.processMu.Unlock() - - client := amp.NewClient(stdinPipe, stdoutPipe, a.logger) - client.SetMessageHandler(a.handleMessage) - client.Start(ctx) - - go a.readOneShotStderr(stderrPipe) - - return &oneShotProcess{cmd: cmd, client: client, stdin: stdinPipe}, nil -} - -// clearOneShotProcess clears the tracked subprocess after a prompt completes. -func (a *AmpAdapter) clearOneShotProcess() { - a.processMu.Lock() - a.process = nil - a.processMu.Unlock() -} - -// awaitOneShotResult waits for the result message, then cleans up the subprocess. -func (a *AmpAdapter) awaitOneShotResult(ctx context.Context, proc *oneShotProcess) (resultComplete, error) { - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - var result resultComplete - select { - case <-ctx.Done(): - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - proc.client.Stop() - return result, ctx.Err() - case result = <-resultCh: - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - } - - proc.client.Stop() - if waitErr := proc.cmd.Wait(); waitErr != nil { - a.logger.Debug("Amp process exited", zap.Error(waitErr)) - } - return result, nil -} - -// buildOneShotArgs builds the command arguments for a one-shot prompt. -// Uses the continue command with thread ID for follow-up prompts. -func (a *AmpAdapter) buildOneShotArgs() []string { - a.mu.RLock() - threadID := a.sessionID - hasRealThread := a.hasAmpThreadID - a.mu.RUnlock() - - // Use continue command only when we have a real Amp thread ID (T-xxx), - // not a UUID placeholder from NewSession(). - if hasRealThread && threadID != "" && len(a.oneShotCfg.ContinueArgs) > 0 { - args := make([]string, len(a.oneShotCfg.ContinueArgs), len(a.oneShotCfg.ContinueArgs)+1) - copy(args, a.oneShotCfg.ContinueArgs) - args = append(args, threadID) - return args - } - - // First prompt: use initial command - args := make([]string, len(a.oneShotCfg.InitialArgs)) - copy(args, a.oneShotCfg.InitialArgs) - return args -} - -// readOneShotStderr reads stderr from the one-shot subprocess for logging. -func (a *AmpAdapter) readOneShotStderr(stderr io.ReadCloser) { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - a.logger.Debug("amp stderr", zap.String("line", scanner.Text())) - } -} - -// promptLongLived sends a prompt to a long-lived Amp process (legacy mode). -func (a *AmpAdapter) promptLongLived(ctx context.Context, message string) error { - a.mu.Lock() - operationID := uuid.New().String() - a.operationID = operationID - a.resultCh = make(chan resultComplete, 1) - a.completeSent = false - a.streamingTextSentThisTurn = false - a.mu.Unlock() - - if a.client == nil { - return fmt.Errorf("adapter not initialized") - } - - a.logger.Info("sending prompt", - zap.String("operation_id", operationID)) - - if err := a.client.SendUserMessage(message); err != nil { - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return fmt.Errorf("failed to send user message: %w", err) - } - - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - select { - case <-ctx.Done(): - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return ctx.Err() - case result := <-resultCh: - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - if !result.success && result.err != "" { - return fmt.Errorf("prompt failed: %s", result.err) - } - a.logger.Info("prompt completed", - zap.String("operation_id", operationID), - zap.Bool("success", result.success)) - return nil - } -} - -// Cancel interrupts the current operation. -// For one-shot mode, this kills the running subprocess. -func (a *AmpAdapter) Cancel(ctx context.Context) error { - a.processMu.Lock() - cmd := a.process - a.processMu.Unlock() - - if cmd != nil && cmd.Process != nil { - a.logger.Info("cancelling one-shot Amp process", zap.Int("pid", cmd.Process.Pid)) - if err := killProcessGroup(cmd.Process.Pid); err != nil { - // Fallback to direct kill if process group kill fails - a.logger.Debug("process group kill failed, trying direct kill", zap.Error(err)) - _ = cmd.Process.Kill() - } - return nil - } - - a.logger.Info("cancel requested (no running process)") - return nil -} - -// Updates returns the channel for agent events. -func (a *AmpAdapter) Updates() <-chan AgentEvent { - return a.updatesCh -} - -// GetSessionID returns the current session ID (thread ID). -func (a *AmpAdapter) GetSessionID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.sessionID -} - -// GetOperationID returns the current operation ID. -func (a *AmpAdapter) GetOperationID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.operationID -} - -// SetPermissionHandler sets the handler for permission requests. -func (a *AmpAdapter) SetPermissionHandler(handler PermissionHandler) { - a.mu.Lock() - defer a.mu.Unlock() - a.permissionHandler = handler -} - -// Close releases resources held by the adapter. -func (a *AmpAdapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - return nil - } - a.closed = true - - a.logger.Info("closing Amp adapter") - - // Clean up any saved attachments - a.attachMgr.Cleanup() - - // Cancel the context - if a.cancel != nil { - a.cancel() - } - - // Kill any running one-shot subprocess - a.processMu.Lock() - if a.process != nil && a.process.Process != nil { - _ = killProcessGroup(a.process.Process.Pid) - } - a.processMu.Unlock() - - // Stop the client (for non-one-shot mode) - if a.client != nil { - a.client.Stop() - } - - // Close update channel - close(a.updatesCh) - - return nil -} - -// sendUpdate safely sends an event to the updates channel. -func (a *AmpAdapter) sendUpdate(update AgentEvent) { - shared.LogNormalizedEvent(shared.ProtocolAmp, a.cfg.AgentID, &update) - shared.TraceProtocolEvent(context.Background(), shared.ProtocolAmp, a.cfg.AgentID, - update.Type, a.lastRawData, &update) - select { - case a.updatesCh <- update: - default: - a.logger.Warn("updates channel full, dropping event") - } -} - -// handleMessage processes streaming messages from Amp. -func (a *AmpAdapter) handleMessage(msg *amp.Message) { - // Marshal once for debug logging and tracing - a.lastRawData, _ = json.Marshal(msg) - if len(a.lastRawData) > 0 { - shared.LogRawEvent(shared.ProtocolAmp, a.cfg.AgentID, msg.Type, a.lastRawData) - } - - a.mu.RLock() - sessionID := a.sessionID - operationID := a.operationID - a.mu.RUnlock() - - // Update session ID from thread ID if provided by Amp - if msg.ThreadID != "" { - a.mu.Lock() - a.sessionID = msg.ThreadID - a.hasAmpThreadID = true - sessionID = msg.ThreadID - a.mu.Unlock() - } - - switch msg.Type { - case amp.MessageTypeSystem: - a.handleSystemMessage(msg, sessionID) - - case amp.MessageTypeAssistant: - a.handleAssistantMessage(msg, sessionID, operationID) - - case amp.MessageTypeUser: - // Amp sends tool_result blocks in user messages (unlike Claude Code which sends them in assistant messages) - a.handleUserMessage(msg, sessionID, operationID) - - case amp.MessageTypeResult: - a.handleResultMessage(msg, sessionID, operationID) - - case amp.MessageTypeRateLimit: - a.handleRateLimitMessage(msg, sessionID, operationID) - - default: - a.logger.Debug("unhandled message type", zap.String("type", msg.Type)) - } -} - -// handleSystemMessage processes system init messages. -func (a *AmpAdapter) handleSystemMessage(msg *amp.Message, sessionID string) { - a.logger.Info("received system message", - zap.String("thread_id", msg.ThreadID)) - - a.mu.Lock() - alreadySent := a.sessionStatusSent - a.sessionStatusSent = true - a.mu.Unlock() - - // Only send session status once - if alreadySent { - return - } - - status := "new" - if msg.ThreadID != "" && msg.ThreadID == sessionID { - status = "resumed" - } - - a.sendUpdate(AgentEvent{ - Type: EventTypeSessionStatus, - SessionID: sessionID, - SessionStatus: status, - Data: map[string]any{ - "session_status": status, - "init": true, - }, - }) -} - -// handleUserMessage processes user messages. -// In Amp's protocol, tool_result blocks come in user messages (not assistant messages like Claude Code). -func (a *AmpAdapter) handleUserMessage(msg *amp.Message, sessionID, operationID string) { - if msg.Message == nil { - return - } - - // Process content blocks - only interested in tool_result - for _, block := range msg.Message.Content { - if block.Type == amp.ContentTypeToolResult { - a.handleToolResult(&block, sessionID, operationID) - } - } -} - -// updateMainModel updates agent version and tracks the main model name. -func (a *AmpAdapter) updateMainModel(model string) { - if model == "" || a.agentInfo == nil { - return - } - a.agentInfo.Version = model - a.mu.Lock() - if a.mainModelName == "" { - a.mainModelName = model - a.logger.Debug("tracking main model", zap.String("model", model)) - } - a.mu.Unlock() -} - -// emitContextWindow emits a context window event. -func (a *AmpAdapter) emitContextWindow(sessionID, operationID string, contextUsed, contextSize int64) { - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) -} - -// trackUsageAndEmitContextWindow updates token tracking and emits a context window event. -func (a *AmpAdapter) trackUsageAndEmitContextWindow(usage *amp.TokenUsage, sessionID, operationID string) { - if usage == nil { - return - } - contextUsed := usage.InputTokens + usage.OutputTokens + - usage.CacheCreationInputTokens + usage.CacheReadInputTokens - - a.mu.Lock() - a.contextTokensUsed = contextUsed - contextSize := a.mainModelContextWindow - a.mu.Unlock() - - a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) -} - -// processAssistantContentBlock processes a single content block from an assistant message. -func (a *AmpAdapter) processAssistantContentBlock(block *amp.ContentBlock, sessionID, operationID, parentToolUseID string) { - switch block.Type { - case amp.ContentTypeText: - if block.Text != "" { - // Mark that text was streamed this turn (prevents duplicate from result.text) - a.mu.Lock() - a.streamingTextSentThisTurn = true - a.textAccumulator.WriteString(block.Text) - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: block.Text, - }) - } - - case amp.ContentTypeThinking: - if block.Thinking != "" { - a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, - SessionID: sessionID, - OperationID: operationID, - ReasoningText: block.Thinking, - }) - } - - case amp.ContentTypeToolUse: - a.handleToolUse(block, sessionID, operationID, parentToolUseID) - - case amp.ContentTypeToolResult: - a.handleToolResult(block, sessionID, operationID) - } -} - -// handleAssistantMessage processes assistant messages. -func (a *AmpAdapter) handleAssistantMessage(msg *amp.Message, sessionID, operationID string) { - if msg.Message == nil { - return - } - - // Extract parent tool use ID for subagent nesting - parentToolUseID := msg.ParentToolUseID - - // Log content block types for debugging - blockTypes := make([]string, 0, len(msg.Message.Content)) - for _, block := range msg.Message.Content { - blockTypes = append(blockTypes, block.Type) - } - a.logger.Debug("processing assistant message", - zap.Int("num_blocks", len(msg.Message.Content)), - zap.Strings("block_types", blockTypes), - zap.String("parent_tool_use_id", parentToolUseID)) - - // Update agent version and track main model - a.updateMainModel(msg.Message.Model) - - // Process content blocks - for i := range msg.Message.Content { - a.processAssistantContentBlock(&msg.Message.Content[i], sessionID, operationID, parentToolUseID) - } - - // Calculate and emit token usage - a.trackUsageAndEmitContextWindow(msg.Message.Usage, sessionID, operationID) - - // Check for turn completion - Amp signals this via stop_reason in assistant messages - // (unlike Claude Code which sends a separate "result" message) - if msg.Message.StopReason == "end_turn" { - a.handleTurnComplete(sessionID, operationID) - } -} - -// handleToolUse processes a tool_use content block. -func (a *AmpAdapter) handleToolUse(block *amp.ContentBlock, sessionID, operationID, parentToolUseID string) { - // Generate normalized payload using the normalizer - normalizedPayload := a.normalizer.NormalizeToolCall(block.Name, block.Input) - - // Build human-readable title - toolTitle := block.Name - // Amp uses "cmd" for Bash commands, Claude Code uses "command" - if cmd, ok := block.Input["cmd"].(string); ok && block.Name == AmpToolBash { - toolTitle = cmd - } else if cmd, ok := block.Input["command"].(string); ok && block.Name == AmpToolBash { - toolTitle = cmd - } else if path, ok := block.Input["path"].(string); ok { - // Amp uses "path" for Read tool - toolTitle = path - } else if path, ok := block.Input["file_path"].(string); ok { - // Claude Code uses "file_path" - toolTitle = path - } - - a.logger.Info("tool_use block received", - zap.String("tool_call_id", block.ID), - zap.String("tool_name", block.Name), - zap.String("title", toolTitle)) - - // Track pending tool call and cache the normalized payload for result handling - a.mu.Lock() - a.pendingToolCalls[block.ID] = true - a.pendingToolPayloads[block.ID] = normalizedPayload - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ID, - ParentToolCallID: parentToolUseID, - ToolName: block.Name, - ToolTitle: toolTitle, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) -} - -// handleToolResult processes a tool_result content block. -func (a *AmpAdapter) handleToolResult(block *amp.ContentBlock, sessionID, operationID string) { - status := "complete" - if block.IsError { - status = "error" - } - - a.logger.Info("tool_result block received", - zap.String("tool_call_id", block.ToolUseID), - zap.String("status", status)) - - // Get cached payload and remove from pending - a.mu.Lock() - delete(a.pendingToolCalls, block.ToolUseID) - cachedPayload := a.pendingToolPayloads[block.ToolUseID] - delete(a.pendingToolPayloads, block.ToolUseID) - a.mu.Unlock() - - // Normalize the tool result content - normalizedPayload := a.normalizer.NormalizeToolResult(cachedPayload, block.Content, block.IsError) - - a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ToolUseID, - ToolStatus: status, - NormalizedPayload: normalizedPayload, - }) -} - -// handleTurnComplete processes turn completion when stop_reason is "end_turn". -// This is Amp's way of signaling turn completion (instead of a separate "result" message). -func (a *AmpAdapter) handleTurnComplete(sessionID, operationID string) { - // Check if we already sent completion for this operation - // (Amp may send both stop_reason=end_turn AND a result message) - a.mu.Lock() - if a.completeSent { - a.mu.Unlock() - a.logger.Debug("ignoring duplicate turn complete event", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID)) - return - } - a.completeSent = true - - a.logger.Info("turn complete (stop_reason=end_turn)") - - // Auto-complete any pending tool calls and get accumulated text - pendingTools := make(map[string]*streams.NormalizedPayload, len(a.pendingToolCalls)) - for toolID := range a.pendingToolCalls { - pendingTools[toolID] = a.pendingToolPayloads[toolID] - } - a.pendingToolCalls = make(map[string]bool) - a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) - contextSize := a.mainModelContextWindow - contextUsed := a.contextTokensUsed - // Clear the text accumulator (text was already sent via message_chunk events) - a.textAccumulator.Reset() - a.mu.Unlock() - - for toolID, cachedPayload := range pendingTools { - a.logger.Info("auto-completing pending tool call on turn complete", - zap.String("tool_call_id", toolID)) - a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolID, - ToolStatus: "complete", - NormalizedPayload: cachedPayload, - }) - } - - // Emit final context window event - if contextUsed > 0 { - a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) - } - - // Send completion event WITHOUT text - text was already sent via message_chunk events - // Including text here would cause duplicate messages - a.sendUpdate(AgentEvent{ - Type: EventTypeComplete, - SessionID: sessionID, - OperationID: operationID, - Data: map[string]any{ - "stop_reason": "end_turn", - }, - }) - - // Signal completion - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - if resultCh != nil { - select { - case resultCh <- resultComplete{success: true}: - a.logger.Debug("signaled prompt completion") - default: - a.logger.Warn("result channel full, dropping signal") - } - } -} - -// extractResultErrorMsg extracts the error message from a result message. -func (a *AmpAdapter) extractResultErrorMsg(msg *amp.Message) string { - if !msg.IsError { - return "" - } - // Check errors array first (Claude Code format, most specific) - if len(msg.Errors) > 0 { - return strings.Join(msg.Errors, "; ") - } - if msg.Error != "" { - return msg.Error - } - if errStr := msg.GetResultString(); errStr != "" { - return errStr - } - if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { - return resultData.Text - } - return "prompt failed" -} - -// extractResultText extracts text from the result message for non-streamed responses. -func (a *AmpAdapter) extractResultText(msg *amp.Message) string { - if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { - return resultData.Text - } - if resultStr := msg.GetResultString(); resultStr != "" { - return resultStr - } - return "" -} - -// handleRateLimitMessage processes rate limit notifications. -func (a *AmpAdapter) handleRateLimitMessage(msg *amp.Message, sessionID, operationID string) { - message := "Rate limited by API" - if len(msg.RateLimitInfo) > 0 { - message = string(msg.RateLimitInfo) - } - - a.logger.Warn("rate limit event received", - zap.String("session_id", sessionID), - zap.String("message", message)) - - a.sendUpdate(AgentEvent{ - Type: EventTypeRateLimit, - SessionID: sessionID, - OperationID: operationID, - RateLimitMessage: message, - }) -} - -// signalResultCompletion sends the result to the result channel. -func (a *AmpAdapter) signalResultCompletion(success bool, errMsg string) { - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - if resultCh == nil { - return - } - select { - case resultCh <- resultComplete{success: success, err: errMsg}: - a.logger.Debug("signaled prompt completion") - default: - a.logger.Warn("result channel full, dropping signal") - } -} - -// updateContextWindowFromModelUsage updates the context window size from model_usage. -func (a *AmpAdapter) updateContextWindowFromModelUsage(msg *amp.Message) (contextUsed, contextSize int64) { - a.mu.Lock() - modelName := a.mainModelName - if msg.ModelUsage != nil && modelName != "" { - if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { - a.mainModelContextWindow = *modelStats.ContextWindow - a.logger.Debug("updated context window from model_usage", - zap.String("model", modelName), - zap.Int64("context_window", a.mainModelContextWindow)) - } - } - contextSize = a.mainModelContextWindow - contextUsed = a.contextTokensUsed - a.mu.Unlock() - return contextUsed, contextSize -} - -// drainPendingToolCalls atomically takes all pending tool calls and clears them. -func (a *AmpAdapter) drainPendingToolCalls() map[string]*streams.NormalizedPayload { - a.mu.Lock() - pending := make(map[string]*streams.NormalizedPayload, len(a.pendingToolCalls)) - for toolID := range a.pendingToolCalls { - pending[toolID] = a.pendingToolPayloads[toolID] - } - a.pendingToolCalls = make(map[string]bool) - a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) - a.mu.Unlock() - return pending -} - -// handleResultMessage processes result (completion) messages. -func (a *AmpAdapter) handleResultMessage(msg *amp.Message, sessionID, operationID string) { - a.logger.Info("received result message", - zap.Bool("is_error", msg.IsError), - zap.Int("num_turns", msg.NumTurns), - zap.String("subtype", msg.Subtype), - zap.String("error", msg.Error)) - - // Update session ID from result if provided - if resultData := msg.GetResultData(); resultData != nil { - if resultData.ThreadID != "" { - a.mu.Lock() - a.sessionID = resultData.ThreadID - a.hasAmpThreadID = true - sessionID = resultData.ThreadID - a.mu.Unlock() - } - } - - // Check if complete was already sent (by handleTurnComplete on stop_reason=end_turn) - a.mu.Lock() - alreadyCompleted := a.completeSent - if !alreadyCompleted { - a.completeSent = true - } - a.mu.Unlock() - - // Auto-complete any pending tool calls - pendingTools := a.drainPendingToolCalls() - for toolID, cachedPayload := range pendingTools { - a.logger.Info("auto-completing pending tool call on result", - zap.String("tool_call_id", toolID)) - a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolID, - ToolStatus: "complete", - NormalizedPayload: cachedPayload, - }) - } - - // Extract context window from model_usage if available - contextUsed, contextSize := a.updateContextWindowFromModelUsage(msg) - - // Emit final context window event - if contextUsed > 0 { - a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) - } - - // Only send completion event if not already sent by handleTurnComplete - if !alreadyCompleted { - // Check if text was already streamed this turn - a.mu.RLock() - textWasStreamed := a.streamingTextSentThisTurn - a.mu.RUnlock() - - // Emit result.text as message_chunk if no text was streamed this turn - // (e.g. slash commands or very short responses) - if !textWasStreamed { - if resultText := a.extractResultText(msg); resultText != "" { - a.logger.Debug("sending result text as message_chunk (no streaming text this turn)", - zap.Int("text_length", len(resultText))) - a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: resultText, - }) - } - } else { - a.logger.Debug("skipping result text (streaming text already sent this turn)") - } - - // Reset text dedup flag and accumulator - a.mu.Lock() - a.streamingTextSentThisTurn = false - a.textAccumulator.Reset() - a.mu.Unlock() - - // Use GetCostUSD() to handle both cost_usd and total_cost_usd field names - completeData := map[string]any{ - "cost_usd": msg.GetCostUSD(), - "duration_ms": msg.DurationMS, - "num_turns": msg.NumTurns, - "input_tokens": msg.TotalInputTokens, - "output_tokens": msg.TotalOutputTokens, - "is_error": msg.IsError, - } - if len(msg.Errors) > 0 { - completeData["errors"] = msg.Errors - } - - a.sendUpdate(AgentEvent{ - Type: EventTypeComplete, - SessionID: sessionID, - OperationID: operationID, - Data: completeData, - }) - } - - // Extract error message if failed - errorMsg := a.extractResultErrorMsg(msg) - - // Signal completion - a.signalResultCompletion(!msg.IsError, errorMsg) - - // Send error event if failed - if msg.IsError { - a.sendUpdate(AgentEvent{ - Type: EventTypeError, - SessionID: sessionID, - OperationID: operationID, - Error: errorMsg, - }) - } -} - -// RequiresProcessKill returns false because Amp agents exit when stdin is closed. -func (a *AmpAdapter) RequiresProcessKill() bool { - return false -} - -// IsOneShot returns true — Amp spawns a new process per prompt. -func (a *AmpAdapter) IsOneShot() bool { - return a.oneShotCfg != nil -} - -// Verify interface implementation. -var ( - _ AgentAdapter = (*AmpAdapter)(nil) - _ OneShotAdapter = (*AmpAdapter)(nil) -) diff --git a/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go b/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go deleted file mode 100644 index 5037bb6a38..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/amp_normalizer.go +++ /dev/null @@ -1,448 +0,0 @@ -package adapter - -import ( - "encoding/json" - "strings" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" -) - -// Amp tool name constants (matching Claude Code tool names). -const ( - AmpToolBash = "Bash" - AmpToolRead = "Read" - AmpToolWrite = "Write" - AmpToolEdit = "Edit" - AmpToolGlob = "Glob" - AmpToolGrep = "Grep" - AmpToolWebFetch = "WebFetch" - AmpToolWebSearch = "WebSearch" - AmpToolTask = "Task" - AmpToolTaskCreate = "TaskCreate" - AmpToolTaskUpdate = "TaskUpdate" - AmpToolTaskList = "TaskList" - AmpToolNotebookEdit = "NotebookEdit" - AmpToolTodoWrite = "TodoWrite" -) - -// AmpNormalizer converts Amp protocol tool data to NormalizedPayload. -type AmpNormalizer struct{} - -// NewAmpNormalizer creates a new Amp normalizer. -func NewAmpNormalizer() *AmpNormalizer { - return &AmpNormalizer{} -} - -// NormalizeToolCall converts Amp tool call data to NormalizedPayload. -func (n *AmpNormalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { - switch toolName { - case AmpToolEdit, AmpToolWrite, AmpToolNotebookEdit: - return n.normalizeEdit(toolName, args) - case AmpToolRead: - return n.normalizeRead(args) - case AmpToolGlob, AmpToolGrep: - return n.normalizeCodeSearch(toolName, args) - case AmpToolBash: - return n.normalizeExecute(args) - case AmpToolWebFetch, AmpToolWebSearch: - return n.normalizeHttpRequest(toolName, args) - case AmpToolTask: - return n.normalizeSubagentTask(args) - case AmpToolTaskCreate: - return n.normalizeCreateTask(args) - case AmpToolTaskUpdate, AmpToolTaskList: - return n.normalizeManageTodos(toolName, args) - case AmpToolTodoWrite: - return n.normalizeManageTodos(toolName, args) - default: - return n.normalizeGeneric(toolName, args) - } -} - -// NormalizeToolResult updates the payload with tool result data from Amp. -// Amp's tool results are JSON-wrapped strings like "{\"output\":\"...\",\"exitCode\":0}". -func (n *AmpNormalizer) NormalizeToolResult(cachedPayload *streams.NormalizedPayload, content any, isError bool) *streams.NormalizedPayload { - if cachedPayload == nil { - return n.normalizeResultNoCached(content) - } - - switch cachedPayload.Kind() { - case streams.ToolKindShellExec: - n.normalizeShellExecResult(cachedPayload, content, isError) - case streams.ToolKindHttpRequest: - n.normalizeHttpRequestResult(cachedPayload, content) - case streams.ToolKindGeneric: - n.normalizeGenericResult(cachedPayload, content) - case streams.ToolKindReadFile: - return n.normalizeReadFileResult(cachedPayload, content) - case streams.ToolKindCodeSearch: - n.normalizeCodeSearchResult(cachedPayload, content) - } - - return cachedPayload -} - -// normalizeResultNoCached creates a generic payload when no cached payload exists. -func (n *AmpNormalizer) normalizeResultNoCached(content any) *streams.NormalizedPayload { - output, _ := n.parseToolResultContent(content) - payload := streams.NewGeneric("unknown", nil) - if payload.Generic() != nil { - payload.Generic().Output = output - } - return payload -} - -// normalizeShellExecResult populates the ShellExec output from tool result content. -func (n *AmpNormalizer) normalizeShellExecResult(payload *streams.NormalizedPayload, content any, isError bool) { - if payload.ShellExec() == nil { - return - } - output, exitCode := n.parseToolResultContent(content) - payload.ShellExec().Output = &streams.ShellExecOutput{ - Stdout: output, - ExitCode: exitCode, - } - if isError { - payload.ShellExec().Output.Stderr = output - payload.ShellExec().Output.Stdout = "" - } -} - -// normalizeHttpRequestResult populates the HttpRequest response from tool result content. -func (n *AmpNormalizer) normalizeHttpRequestResult(payload *streams.NormalizedPayload, content any) { - if payload.HttpRequest() == nil { - return - } - output, _ := n.parseToolResultContent(content) - payload.HttpRequest().Response = output -} - -// normalizeGenericResult populates the Generic output from tool result content. -func (n *AmpNormalizer) normalizeGenericResult(payload *streams.NormalizedPayload, content any) { - if payload.Generic() == nil { - return - } - output, _ := n.parseToolResultContent(content) - payload.Generic().Output = output -} - -// normalizeReadFileResult populates ReadFile output, converting to CodeSearch for directories. -func (n *AmpNormalizer) normalizeReadFileResult(payload *streams.NormalizedPayload, content any) *streams.NormalizedPayload { - if payload.ReadFile() == nil { - return payload - } - result := n.parseAmpReadResult(content) - if result == nil { - return payload - } - // If it's a directory, convert to code_search - if result.IsDirectory { - return n.buildDirectoryPayload(result) - } - // Regular file read - output := &streams.ReadFileOutput{ - Content: result.Content, - } - if result.Content != "" { - output.LineCount = strings.Count(result.Content, "\n") + 1 - } - payload.ReadFile().Output = output - return payload -} - -// buildDirectoryPayload creates a CodeSearch payload for directory listings. -func (n *AmpNormalizer) buildDirectoryPayload(result *ampReadResult) *streams.NormalizedPayload { - codeSearch := streams.NewCodeSearch( - "", // query - "", // pattern - result.AbsolutePath, // path - "*", // glob - all entries - ) - if codeSearch.CodeSearch() != nil { - codeSearch.CodeSearch().Output = &streams.CodeSearchOutput{ - Files: result.DirectoryEntries, - FileCount: len(result.DirectoryEntries), - } - } - return codeSearch -} - -// normalizeCodeSearchResult populates CodeSearch output from tool result content. -func (n *AmpNormalizer) normalizeCodeSearchResult(payload *streams.NormalizedPayload, content any) { - if payload.CodeSearch() == nil { - return - } - output, _ := n.parseToolResultContent(content) - shared.NormalizeCodeSearchResult(payload.CodeSearch(), output) -} - -// ampReadResult holds parsed read result data from Amp. -type ampReadResult struct { - AbsolutePath string - Content string - IsDirectory bool - DirectoryEntries []string -} - -// parseAmpReadResult parses Amp's read result format. -func (n *AmpNormalizer) parseAmpReadResult(content any) *ampReadResult { - switch c := content.(type) { - case string: - var result ampReadResult - if err := json.Unmarshal([]byte(c), &result); err == nil { - return &result - } - // Not JSON, treat as raw file content - return &ReadResult{Content: c} - - case map[string]any: - return n.parseAmpReadResultFromMap(c) - - default: - return &ReadResult{} - } -} - -// parseAmpReadResultFromMap parses an Amp read result from a map. -func (n *AmpNormalizer) parseAmpReadResultFromMap(c map[string]any) *ampReadResult { - result := &ReadResult{} - if v, ok := c["absolutePath"].(string); ok { - result.AbsolutePath = v - } - if v, ok := c["content"].(string); ok { - result.Content = v - } - if v, ok := c["isDirectory"].(bool); ok { - result.IsDirectory = v - } - if v, ok := c["directoryEntries"].([]any); ok { - for _, entry := range v { - if s, ok := entry.(string); ok { - result.DirectoryEntries = append(result.DirectoryEntries, s) - } - } - } - return result -} - -// parseToolResultContent extracts the output string and exit code from Amp's JSON-wrapped content. -// Amp sends tool results in formats like: -// - String: "{\"output\":\"actual content\",\"exitCode\":0}" -// - Direct string (non-JSON) -// - Map with output/exitCode keys -// - Any other JSON structure (returned as-is) -func (n *AmpNormalizer) parseToolResultContent(content any) (string, int) { - switch c := content.(type) { - case string: - return n.parseStringContent(c) - case map[string]any: - return n.parseMapContent(c) - case []any: - return n.parseArrayContent(c), 0 - default: - return n.parseFallbackContent(content), 0 - } -} - -// parseStringContent handles string tool result content. -func (n *AmpNormalizer) parseStringContent(c string) (string, int) { - // Try to parse as JSON-wrapped content with {output, exitCode} structure - var wrapped struct { - Output string `json:"output"` - ExitCode int `json:"exitCode"` - } - if err := json.Unmarshal([]byte(c), &wrapped); err == nil && wrapped.Output != "" { - // Successfully parsed AND has output field - return wrapped.Output, wrapped.ExitCode - } - // Either not JSON, or JSON without "output" field - use as-is - return c, 0 -} - -// parseMapContent handles map tool result content. -func (n *AmpNormalizer) parseMapContent(c map[string]any) (string, int) { - output := shared.GetString(c, "output") - exitCode := shared.GetInt(c, "exitCode") - if output == "" { - // Try alternative field names - output = shared.GetString(c, "stdout") - if output == "" { - output = shared.GetString(c, "stderr") - } - if ec := shared.GetInt(c, "exit_code"); ec != 0 { - exitCode = ec - } - } - return output, exitCode -} - -// parseArrayContent handles array tool result content (multiple content blocks). -func (n *AmpNormalizer) parseArrayContent(c []any) string { - var sb strings.Builder - for _, item := range c { - if str, ok := item.(string); ok { - sb.WriteString(str) - } else if m, ok := item.(map[string]any); ok { - if text := shared.GetString(m, "text"); text != "" { - sb.WriteString(text) - } - } - } - return sb.String() -} - -// parseFallbackContent handles unknown content types via JSON marshaling. -func (n *AmpNormalizer) parseFallbackContent(content any) string { - if data, err := json.Marshal(content); err == nil { - return string(data) - } - return "" -} - -// normalizeEdit converts Amp Edit/Write tool data. -func (n *AmpNormalizer) normalizeEdit(toolName string, args map[string]any) *streams.NormalizedPayload { - filePath := shared.GetString(args, "file_path") - oldString := shared.GetString(args, "old_string") - newString := shared.GetString(args, "new_string") - content := shared.GetString(args, "content") - - var mutations []streams.FileMutation - - if toolName == AmpToolWrite { - // Write is a full file creation/replacement - mutations = append(mutations, streams.FileMutation{ - Type: streams.MutationCreate, - Content: content, - }) - } else { - // Edit is a patch operation - mutation := streams.FileMutation{ - Type: streams.MutationPatch, - OldContent: oldString, - NewContent: newString, - } - - // Generate unified diff - if oldString != "" && newString != "" { - mutation.Diff = shared.GenerateUnifiedDiff(oldString, newString, filePath, 0) - } - - mutations = append(mutations, mutation) - } - - return streams.NewModifyFile(filePath, mutations) -} - -// normalizeRead converts Amp Read tool data. -// Note: Amp uses "path" for the file path, while Claude Code uses "file_path". -func (n *AmpNormalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { - // Amp uses "path", Claude Code uses "file_path" - check both - filePath := shared.GetString(args, "path") - if filePath == "" { - filePath = shared.GetString(args, "file_path") - } - offset := shared.GetInt(args, "offset") - limit := shared.GetInt(args, "limit") - - return streams.NewReadFile(filePath, offset, limit) -} - -// normalizeCodeSearch converts Amp Glob/Grep tool data. -func (n *AmpNormalizer) normalizeCodeSearch(toolName string, args map[string]any) *streams.NormalizedPayload { - path := shared.GetString(args, "path") - pattern := shared.GetString(args, "pattern") - - var query, glob string - switch toolName { - case AmpToolGlob: - glob = pattern - case AmpToolGrep: - query = shared.GetString(args, "query") - if query == "" { - query = pattern - } - } - - return streams.NewCodeSearch(query, pattern, path, glob) -} - -// normalizeExecute converts Amp Bash tool data. -// Note: Amp uses "cmd" for the command field, while Claude Code uses "command". -func (n *AmpNormalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload { - // Amp uses "cmd", Claude Code uses "command" - check both - command := shared.GetString(args, "cmd") - if command == "" { - command = shared.GetString(args, "command") - } - description := shared.GetString(args, "description") - timeout := shared.GetInt(args, "timeout") - background := shared.GetBool(args, "run_in_background") - - return streams.NewShellExec(command, "", description, timeout, background) -} - -// normalizeHttpRequest converts Amp WebFetch/WebSearch tool data. -func (n *AmpNormalizer) normalizeHttpRequest(toolName string, args map[string]any) *streams.NormalizedPayload { - url := shared.GetString(args, "url") - if url == "" { - url = shared.GetString(args, "query") - } - - method := "GET" - if toolName == AmpToolWebSearch { - method = "SEARCH" - } - - return streams.NewHttpRequest(url, method) -} - -// normalizeSubagentTask converts Amp Task tool data (subagent invocation). -func (n *AmpNormalizer) normalizeSubagentTask(args map[string]any) *streams.NormalizedPayload { - description := shared.GetString(args, "description") - prompt := shared.GetString(args, "prompt") - subagentType := shared.GetString(args, "subagent_type") - - return streams.NewSubagentTask(description, prompt, subagentType) -} - -// normalizeCreateTask converts Amp TaskCreate tool data. -func (n *AmpNormalizer) normalizeCreateTask(args map[string]any) *streams.NormalizedPayload { - title := shared.GetString(args, "subject") - description := shared.GetString(args, "description") - - return streams.NewCreateTask(title, description) -} - -// normalizeManageTodos converts Amp TaskUpdate/TaskList/TodoWrite tool data. -func (n *AmpNormalizer) normalizeManageTodos(toolName string, args map[string]any) *streams.NormalizedPayload { - operation := "update" - switch toolName { - case AmpToolTaskList: - operation = "list" - case AmpToolTodoWrite: - operation = "write" - } - - var items []streams.TodoItem - - // Extract items if present - if rawItems, ok := args["items"].([]any); ok { - for _, item := range rawItems { - if itemMap, ok := item.(map[string]any); ok { - items = append(items, streams.TodoItem{ - ID: shared.GetString(itemMap, "id"), - Description: shared.GetString(itemMap, "description"), - Status: shared.GetString(itemMap, "status"), - }) - } - } - } - - return streams.NewManageTodos(operation, items) -} - -// normalizeGeneric wraps unknown tools as generic. -func (n *AmpNormalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { - return streams.NewGeneric(toolName, args) -} diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go deleted file mode 100644 index 8998ab18ff..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go +++ /dev/null @@ -1,1052 +0,0 @@ -package adapter - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/common/logger" - v1 "github.com/kandev/kandev/pkg/api/v1" - "github.com/kandev/kandev/pkg/copilot" - "go.uber.org/zap" -) - -// CopilotAdapter implements AgentAdapter for GitHub Copilot using the official SDK. -// -// The process manager spawns the Copilot CLI in server mode (--server), which -// listens on a random TCP port and prints "listening on port " to stdout. -// Connect() captures the stdout pipe, and Initialize() scans it for the port, -// then creates an SDK client that connects to the external server via CLIUrl. -type CopilotAdapter struct { - cfg *Config - logger *logger.Logger - - // Normalizer for converting tool data to NormalizedPayload - normalizer *CopilotNormalizer - - // stdout from the process manager (used to detect the listening port) - stdout io.Reader - - // Copilot SDK client (connects to externally managed CLI server via TCP) - client *copilot.Client - - // Context for managing goroutine lifecycle - ctx context.Context - cancel context.CancelFunc - - // Session state - sessionID string - operationID string - - // Track pending tool calls and their normalized payloads - pendingToolPayloads map[string]*streams.NormalizedPayload - - // Accumulate text for the complete event - textAccumulator strings.Builder - - // Agent info - agentInfo *AgentInfo - - // Update channel - updatesCh chan AgentEvent - - // Permission handler - permissionHandler PermissionHandler - - // Result completion signaling - resultCh chan resultComplete - - // Context window tracking (from usage events) - contextWindowSize int64 - contextTokensUsed int64 - - // Track if completion was already sent for current operation - completeSent bool - - // Track if we received streaming deltas for this operation - // (to avoid double-accumulating from both delta and full message events) - receivedDeltas bool - - // lastRawData holds the raw JSON of the current event being processed. - // Set in handleEvent before dispatch; used by sendUpdate for OTel tracing. - lastRawData json.RawMessage - - // Attachment management - attachMgr *shared.AttachmentManager - - // Synchronization - mu sync.RWMutex - closed bool -} - -// resultComplete holds the result of a completed prompt -type resultComplete struct { - success bool - err string -} - -// defaultCopilotContextWindow is the fallback context window size. -const defaultCopilotContextWindow = 128000 - -// mcpServerTypeHTTP is the HTTP transport type identifier for MCP servers. -const mcpServerTypeHTTP = "http" - -// NewCopilotAdapter creates a new Copilot protocol adapter. -func NewCopilotAdapter(cfg *Config, log *logger.Logger) *CopilotAdapter { - ctx, cancel := context.WithCancel(context.Background()) - - l := log.WithFields(zap.String("adapter", "copilot")) - return &CopilotAdapter{ - cfg: cfg, - logger: l, - normalizer: NewCopilotNormalizer(), - ctx: ctx, - cancel: cancel, - updatesCh: make(chan AgentEvent, 100), - contextWindowSize: defaultCopilotContextWindow, - pendingToolPayloads: make(map[string]*streams.NormalizedPayload), - attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()), - } -} - -// PrepareEnvironment performs protocol-specific setup before the agent process starts. -// No special environment is needed; the process manager spawns the CLI in server mode. -func (a *CopilotAdapter) PrepareEnvironment() (map[string]string, error) { - return nil, nil -} - -// PrepareCommandArgs returns extra command-line arguments for the agent process. -// For Copilot, no extra args are needed - the CLI is spawned in server mode. -func (a *CopilotAdapter) PrepareCommandArgs() []string { - return nil -} - -// Connect stores the stdout pipe from the process manager. -// The adapter reads stdout during Initialize to detect the TCP port -// the Copilot CLI server is listening on. -func (a *CopilotAdapter) Connect(stdin io.Writer, stdout io.Reader) error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.stdout != nil { - return fmt.Errorf("adapter already connected") - } - - a.stdout = stdout - a.logger.Info("Connect: stored stdout pipe for port detection") - return nil -} - -// Initialize waits for the Copilot CLI server to print its listening port, -// then creates an SDK client that connects to it via TCP. -func (a *CopilotAdapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing Copilot adapter (server mode)", - zap.String("workdir", a.cfg.WorkDir)) - - if a.stdout == nil { - return fmt.Errorf("stdout not connected; call Connect() before Initialize()") - } - - // Scan stdout for "listening on port " from the CLI server - port, scanner, err := a.waitForPort(ctx) - if err != nil { - return fmt.Errorf("failed to detect Copilot CLI server port: %w", err) - } - - cliURL := fmt.Sprintf("localhost:%d", port) - a.logger.Info("detected Copilot CLI server", - zap.Int("port", port), - zap.String("cli_url", cliURL)) - - // Create SDK client pointing at the external server - a.client = copilot.NewClient(copilot.ClientConfig{ - CLIUrl: cliURL, - }, a.logger) - - // Set event handler before starting - a.client.SetEventHandler(a.handleEvent) - - // Set permission handler before starting - a.client.SetPermissionHandler(a.handlePermissionRequest) - - // Start the SDK client (connects to external server via TCP) - if err := a.client.Start(ctx); err != nil { - return fmt.Errorf("failed to start Copilot SDK client: %w", err) - } - - // Drain remaining stdout in background so the process doesn't block - go func() { - for scanner.Scan() { - // discard - } - }() - - // Store agent info - a.agentInfo = &AgentInfo{ - Name: "copilot", - Version: "sdk", - } - - a.logger.Info("Copilot adapter initialized (server mode)") - return nil -} - -// portPattern matches "listening on port " printed by the Copilot CLI in server mode. -var portPattern = regexp.MustCompile(`listening on port (\d+)`) - -// waitForPort scans stdout line-by-line until it finds the listening port. -// Returns the detected port and the scanner (for background draining). -func (a *CopilotAdapter) waitForPort(ctx context.Context) (int, *bufio.Scanner, error) { - const timeout = 180 * time.Second - - scanner := bufio.NewScanner(a.stdout) - portCh := make(chan int, 1) - errCh := make(chan error, 1) - - go func() { - var capturedLines []string - for scanner.Scan() { - line := scanner.Text() - a.logger.Debug("CLI stdout", zap.String("line", line)) - - if len(capturedLines) < 64 { - capturedLines = append(capturedLines, line) - } - - if m := portPattern.FindStringSubmatch(line); m != nil { - port, err := strconv.Atoi(m[1]) - if err != nil { - errCh <- fmt.Errorf("invalid port number %q: %w", m[1], err) - return - } - portCh <- port - return - } - } - // EOF before finding port - if err := scanner.Err(); err != nil { - errCh <- fmt.Errorf("error reading stdout: %w", err) - } else { - var detail strings.Builder - if len(capturedLines) > 0 { - start := 0 - if len(capturedLines) > 12 { - start = len(capturedLines) - 12 - } - for _, l := range capturedLines[start:] { - detail.WriteString("\n ") - detail.WriteString(l) - } - } - errCh <- fmt.Errorf("CLI exited before printing listening port%s", detail.String()) - } - }() - - timer := time.NewTimer(timeout) - defer timer.Stop() - - select { - case port := <-portCh: - return port, scanner, nil - case err := <-errCh: - return 0, nil, err - case <-timer.C: - return 0, nil, fmt.Errorf("timeout (%s) waiting for Copilot CLI to print listening port", timeout) - case <-ctx.Done(): - return 0, nil, ctx.Err() - } -} - -// GetAgentInfo returns information about the connected agent. -func (a *CopilotAdapter) GetAgentInfo() *AgentInfo { - return a.agentInfo -} - -// mcpServersToCopilotConfig converts agentctl MCP server list to copilot SDK format. -func mcpServersToCopilotConfig(servers []types.McpServer) map[string]copilot.MCPServerConfig { - if len(servers) == 0 { - return nil - } - result := make(map[string]copilot.MCPServerConfig, len(servers)) - for _, srv := range servers { - cfg := copilot.MCPServerConfig{ - "tools": []string{"*"}, - } - switch srv.Type { - case "sse": - cfg["type"] = "sse" - cfg["url"] = srv.URL - if len(srv.Headers) > 0 { - cfg["headers"] = srv.Headers - } - case mcpServerTypeHTTP, "streamable_http": - cfg["type"] = srv.Type - cfg["url"] = srv.URL - if len(srv.Headers) > 0 { - cfg["headers"] = srv.Headers - } - default: // stdio / local - cfg["type"] = "local" - cfg["command"] = srv.Command - if srv.Args != nil { - cfg["args"] = srv.Args - } - if len(srv.Env) > 0 { - cfg["env"] = srv.Env - } - } - result[srv.Name] = cfg - } - return result -} - -// NewSession creates a new Copilot session via the SDK. -func (a *CopilotAdapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) { - if a.client == nil { - return "", fmt.Errorf("adapter not initialized") - } - - mcpConfig := mcpServersToCopilotConfig(mcpServers) - sessionID, err := a.client.CreateSession(ctx, mcpConfig) - if err != nil { - // If session creation fails, generate a placeholder ID - sessionID = uuid.New().String() - a.logger.Warn("failed to create SDK session, using placeholder", - zap.Error(err), - zap.String("session_id", sessionID)) - } - - a.mu.Lock() - a.sessionID = sessionID - a.mu.Unlock() - a.attachMgr.SetSessionID(sessionID) - - a.logger.Info("created new session", zap.String("session_id", sessionID)) - - // Send session status event - a.sendUpdate(AgentEvent{ - Type: EventTypeSessionStatus, - SessionID: sessionID, - SessionStatus: "new", - Data: map[string]any{ - "session_status": "new", - "init": true, - }, - }) - - return sessionID, nil -} - -// LoadSession resumes an existing Copilot session via the SDK. -func (a *CopilotAdapter) LoadSession(ctx context.Context, sessionID string, mcpServers []types.McpServer) error { - if a.client == nil { - return fmt.Errorf("adapter not initialized") - } - - mcpConfig := mcpServersToCopilotConfig(mcpServers) - if err := a.client.ResumeSession(ctx, sessionID, mcpConfig); err != nil { - a.logger.Warn("failed to resume SDK session, continuing anyway", - zap.Error(err), - zap.String("session_id", sessionID)) - } - - a.mu.Lock() - a.sessionID = sessionID - a.mu.Unlock() - a.attachMgr.SetSessionID(sessionID) - - a.logger.Info("loaded session", zap.String("session_id", sessionID)) - - // Send session status event - a.sendUpdate(AgentEvent{ - Type: EventTypeSessionStatus, - SessionID: sessionID, - SessionStatus: "resumed", - Data: map[string]any{ - "session_status": "resumed", - "init": true, - }, - }) - - return nil -} - -// Prompt sends a prompt to Copilot and waits for completion. -func (a *CopilotAdapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { - if a.client == nil { - return fmt.Errorf("adapter not initialized") - } - - a.mu.Lock() - sessionID := a.sessionID - operationID := uuid.New().String() - a.operationID = operationID - a.resultCh = make(chan resultComplete, 1) - a.completeSent = false // Reset completion flag for new operation - a.receivedDeltas = false // Reset delta tracking for new operation - a.mu.Unlock() - - // Save attachments to workspace and prepend references to message - finalMessage := message - if len(attachments) > 0 { - saved, err := a.attachMgr.SaveAttachments(attachments) - if err != nil { - a.logger.Warn("failed to save attachments", zap.Error(err)) - } else if len(saved) > 0 { - finalMessage = shared.BuildAttachmentPrompt(saved) + message - } - } - - a.logger.Info("sending prompt via SDK", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID)) - - // Send message (non-blocking, events come via handler) - if _, err := a.client.Send(ctx, finalMessage); err != nil { - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return fmt.Errorf("failed to send prompt: %w", err) - } - - // Wait for idle (completion) or context cancellation - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - select { - case <-ctx.Done(): - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return ctx.Err() - case result := <-resultCh: - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - if !result.success && result.err != "" { - return fmt.Errorf("prompt failed: %s", result.err) - } - a.logger.Info("prompt completed", - zap.String("operation_id", operationID), - zap.Bool("success", result.success)) - a.attachMgr.Cleanup() - return nil - } -} - -// Cancel interrupts the current operation. -func (a *CopilotAdapter) Cancel(ctx context.Context) error { - if a.client == nil { - return fmt.Errorf("adapter not initialized") - } - - a.logger.Info("cancelling operation via SDK") - return a.client.Abort(ctx) -} - -// Updates returns the channel for agent events. -func (a *CopilotAdapter) Updates() <-chan AgentEvent { - return a.updatesCh -} - -// GetSessionID returns the current session ID. -func (a *CopilotAdapter) GetSessionID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.sessionID -} - -// GetOperationID returns the current operation ID. -func (a *CopilotAdapter) GetOperationID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.operationID -} - -// SetPermissionHandler sets the handler for permission requests. -func (a *CopilotAdapter) SetPermissionHandler(handler PermissionHandler) { - a.mu.Lock() - defer a.mu.Unlock() - a.permissionHandler = handler -} - -// Close releases resources held by the adapter. -func (a *CopilotAdapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - return nil - } - a.closed = true - - a.logger.Info("closing Copilot SDK adapter") - - // Clean up any saved attachments - a.attachMgr.Cleanup() - - // Cancel the context - if a.cancel != nil { - a.cancel() - } - - // Stop the SDK client (disconnects from the CLI server) - if a.client != nil { - if err := a.client.Stop(); err != nil { - a.logger.Warn("error stopping SDK client", zap.Error(err)) - } - a.client = nil - } - - // Close update channel - close(a.updatesCh) - - return nil -} - -// sendUpdate safely sends an event to the updates channel. -func (a *CopilotAdapter) sendUpdate(update AgentEvent) { - shared.LogNormalizedEvent(shared.ProtocolCopilot, a.cfg.AgentID, &update) - shared.TraceProtocolEvent(context.Background(), shared.ProtocolCopilot, a.cfg.AgentID, - update.Type, a.lastRawData, &update) - select { - case a.updatesCh <- update: - default: - a.logger.Warn("updates channel full, dropping event") - } -} - -// handleEvent processes events from the Copilot SDK client. -func (a *CopilotAdapter) handleEvent(evt copilot.SessionEvent) { - // Marshal once for debug logging and tracing - a.lastRawData, _ = json.Marshal(evt) - if len(a.lastRawData) > 0 { - shared.LogRawEvent(shared.ProtocolCopilot, a.cfg.AgentID, string(evt.Type), a.lastRawData) - } - - a.mu.RLock() - sessionID := a.sessionID - operationID := a.operationID - a.mu.RUnlock() - - // Use session ID from event data if provided - if evt.Data.SessionID != nil && *evt.Data.SessionID != "" { - sessionID = *evt.Data.SessionID - } - - if a.handleLifecycleEvent(evt, sessionID, operationID) { - return - } - a.handleContentEvent(evt, sessionID, operationID) -} - -// handleLifecycleEvent handles session lifecycle events. -// Returns true if the event was handled. -func (a *CopilotAdapter) handleLifecycleEvent(evt copilot.SessionEvent, sessionID, operationID string) bool { - switch evt.Type { - case copilot.EventTypeSessionStart: - a.logger.Info("session started", zap.String("session_id", sessionID)) - case copilot.EventTypeSessionResume: - a.logger.Info("session resumed", zap.String("session_id", sessionID)) - case copilot.EventTypeAssistantTurnStart: - a.logger.Debug("turn started", zap.String("operation_id", operationID)) - case copilot.EventTypeAssistantTurnEnd: - a.logger.Debug("turn ended", zap.String("operation_id", operationID)) - case copilot.EventTypeAbort: - a.logger.Info("operation aborted") - a.signalCompletion(false, "operation aborted") - default: - return false - } - return true -} - -// handleContentEvent handles content and tool events. -func (a *CopilotAdapter) handleContentEvent(evt copilot.SessionEvent, sessionID, operationID string) { - switch evt.Type { - case copilot.EventTypeAssistantMessage: - a.handleAssistantMessage(evt, sessionID, operationID) - case copilot.EventTypeAssistantMessageDelta: - a.handleAssistantMessageDelta(evt, sessionID, operationID) - case copilot.EventTypeAssistantReasoning, copilot.EventTypeAssistantReasoningDelta: - a.handleAssistantReasoning(evt, sessionID, operationID) - case copilot.EventTypeToolStart: - a.handleToolStart(evt, sessionID, operationID) - case copilot.EventTypeToolComplete: - a.handleToolComplete(evt, sessionID, operationID) - case copilot.EventTypeToolProgress: - a.handleToolProgress(evt, sessionID, operationID) - case copilot.EventTypeSessionIdle: - a.handleSessionIdle(sessionID, operationID) - case copilot.EventTypeSessionError: - a.handleSessionError(evt, sessionID, operationID) - case copilot.EventTypeSessionUsageInfo, copilot.EventTypeAssistantUsage: - a.handleUsageInfo(evt, sessionID, operationID) - default: - a.logger.Debug("unhandled SDK event", zap.String("type", string(evt.Type))) - } -} - -// handleAssistantMessage handles a full (non-streaming) assistant message event. -// It skips accumulation if streaming deltas were already received to avoid duplication. -func (a *CopilotAdapter) handleAssistantMessage(evt copilot.SessionEvent, sessionID, operationID string) { - if evt.Data.Content == nil || *evt.Data.Content == "" { - return - } - a.mu.Lock() - alreadyReceivedDeltas := a.receivedDeltas - if !alreadyReceivedDeltas { - // Non-streaming mode: accumulate the full message - a.textAccumulator.WriteString(*evt.Data.Content) - } - a.mu.Unlock() - - // Only send message chunk if we haven't been streaming - if !alreadyReceivedDeltas { - a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: *evt.Data.Content, - }) - } -} - -// handleAssistantMessageDelta handles a streaming message delta event. -func (a *CopilotAdapter) handleAssistantMessageDelta(evt copilot.SessionEvent, sessionID, operationID string) { - if evt.Data.DeltaContent == nil || *evt.Data.DeltaContent == "" { - return - } - text := *evt.Data.DeltaContent - // Accumulate text locally to include in complete event - a.mu.Lock() - a.textAccumulator.WriteString(text) - a.receivedDeltas = true // Mark that we've received streaming deltas - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: text, - }) -} - -// handleAssistantReasoning handles reasoning/thinking content events. -func (a *CopilotAdapter) handleAssistantReasoning(evt copilot.SessionEvent, sessionID, operationID string) { - var content string - if evt.Data.Content != nil { - content = *evt.Data.Content - } - if content == "" && evt.Data.DeltaContent != nil { - content = *evt.Data.DeltaContent - } - if content != "" { - a.sendUpdate(AgentEvent{ - Type: EventTypeReasoning, - SessionID: sessionID, - OperationID: operationID, - ReasoningText: content, - }) - } -} - -// handleToolProgress handles tool progress update events. -func (a *CopilotAdapter) handleToolProgress(evt copilot.SessionEvent, sessionID, operationID string) { - toolCallID := "" - if evt.Data.ToolCallID != nil { - toolCallID = *evt.Data.ToolCallID - } - a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolStatus: "running", - }) -} - -// handleToolStart processes tool execution start events. -func (a *CopilotAdapter) handleToolStart(evt copilot.SessionEvent, sessionID, operationID string) { - toolCallID := "" - toolName := "" - var toolArgs any - - if evt.Data.ToolCallID != nil { - toolCallID = *evt.Data.ToolCallID - } - if evt.Data.ToolName != nil { - toolName = *evt.Data.ToolName - } - toolArgs = evt.Data.Arguments - - // Generate normalized payload using the normalizer - normalizedPayload := a.normalizer.NormalizeToolCall(toolName, toolArgs) - - // Build human-readable title - toolTitle := toolName - if argsMap, ok := toolArgs.(map[string]any); ok && argsMap != nil { - if cmd, ok := argsMap["command"].(string); ok && strings.ToLower(toolName) == "bash" { - toolTitle = cmd - } else if path, ok := argsMap["file_path"].(string); ok { - toolTitle = fmt.Sprintf("%s: %s", toolName, path) - } - } - - a.logger.Info("tool execution started", - zap.String("tool_call_id", toolCallID), - zap.String("tool_name", toolName)) - - // Track pending tool call and cache the normalized payload for result handling - a.mu.Lock() - a.pendingToolPayloads[toolCallID] = normalizedPayload - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolName: toolName, - ToolTitle: toolTitle, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) -} - -// handleToolComplete processes tool execution complete events. -func (a *CopilotAdapter) handleToolComplete(evt copilot.SessionEvent, sessionID, operationID string) { - toolCallID := "" - if evt.Data.ToolCallID != nil { - toolCallID = *evt.Data.ToolCallID - } - - status := "complete" - - a.logger.Info("tool execution completed", - zap.String("tool_call_id", toolCallID), - zap.String("status", status)) - - // Get cached payload and remove from pending - a.mu.Lock() - cachedPayload := a.pendingToolPayloads[toolCallID] - delete(a.pendingToolPayloads, toolCallID) - a.mu.Unlock() - - // Normalize the tool result if we have result data - var normalizedPayload *streams.NormalizedPayload - if cachedPayload != nil { - // Use the result from the event if available - normalizedPayload = a.normalizer.NormalizeToolResult(cachedPayload, evt.Data.Result, false) - } - - a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolStatus: status, - NormalizedPayload: normalizedPayload, - }) -} - -// handleSessionIdle processes session idle (completion) events. -func (a *CopilotAdapter) handleSessionIdle(sessionID, operationID string) { - // Check if we already sent completion for this operation (SDK may send multiple idle events) - a.mu.Lock() - if a.completeSent { - a.mu.Unlock() - a.logger.Debug("ignoring duplicate session idle event", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID)) - return - } - a.completeSent = true - - // Clear the text accumulator - text was already sent via message_chunk events - // so we should NOT include it in the complete event to avoid duplicates - a.textAccumulator.Reset() - - // Auto-complete any pending tool calls - pendingTools := make(map[string]*streams.NormalizedPayload, len(a.pendingToolPayloads)) - for toolID, payload := range a.pendingToolPayloads { - pendingTools[toolID] = payload - } - a.pendingToolPayloads = make(map[string]*streams.NormalizedPayload) - a.mu.Unlock() - - a.logger.Info("session idle (operation completed)", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID)) - - // Auto-complete pending tool calls - for toolID, cachedPayload := range pendingTools { - a.logger.Info("auto-completing pending tool call on idle", - zap.String("tool_call_id", toolID)) - a.sendUpdate(AgentEvent{ - Type: EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolID, - ToolStatus: "complete", - NormalizedPayload: cachedPayload, - }) - } - - // Send completion event WITHOUT text - text was already sent via message_chunk events. - // Including text here would cause duplicate messages. - a.sendUpdate(AgentEvent{ - Type: EventTypeComplete, - SessionID: sessionID, - OperationID: operationID, - }) - - // Signal completion - a.signalCompletion(true, "") -} - -// handleSessionError processes session error events. -func (a *CopilotAdapter) handleSessionError(evt copilot.SessionEvent, sessionID, operationID string) { - errorMsg := "unknown error" - if evt.Data.Message != nil { - errorMsg = *evt.Data.Message - } - - errorType := "" - if evt.Data.ErrorType != nil { - errorType = *evt.Data.ErrorType - } - - a.logger.Error("session error", - zap.String("error", errorMsg), - zap.String("error_type", errorType)) - - a.sendUpdate(AgentEvent{ - Type: EventTypeError, - SessionID: sessionID, - OperationID: operationID, - Error: errorMsg, - }) - - // Signal failure - a.signalCompletion(false, errorMsg) -} - -// handleUsageInfo processes usage info events. -// session.usage_info events provide CurrentTokens/TokenLimit (session-level tracking). -// assistant.usage events provide InputTokens/OutputTokens (per-call tracking). -func (a *CopilotAdapter) handleUsageInfo(evt copilot.SessionEvent, sessionID, operationID string) { - var contextUsed, contextSize int64 - - switch evt.Type { - case copilot.EventTypeSessionUsageInfo: - // session.usage_info: uses CurrentTokens/TokenLimit fields - if evt.Data.CurrentTokens != nil { - contextUsed = int64(*evt.Data.CurrentTokens) - } - if evt.Data.TokenLimit != nil { - contextSize = int64(*evt.Data.TokenLimit) - } - case copilot.EventTypeAssistantUsage: - // assistant.usage: uses InputTokens/OutputTokens fields - var inputTokens, outputTokens int64 - if evt.Data.InputTokens != nil { - inputTokens = int64(*evt.Data.InputTokens) - } - if evt.Data.OutputTokens != nil { - outputTokens = int64(*evt.Data.OutputTokens) - } - contextUsed = inputTokens + outputTokens - } - - a.mu.Lock() - if contextUsed > 0 { - a.contextTokensUsed = contextUsed - } - if contextSize > 0 { - a.contextWindowSize = contextSize - } - // Read final values after update - contextUsed = a.contextTokensUsed - contextSize = a.contextWindowSize - a.mu.Unlock() - - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - - a.sendUpdate(AgentEvent{ - Type: EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) -} - -// signalCompletion signals the result channel. -func (a *CopilotAdapter) signalCompletion(success bool, errMsg string) { - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - if resultCh != nil { - select { - case resultCh <- resultComplete{success: success, err: errMsg}: - default: - } - } -} - -// handlePermissionRequest handles permission requests from the Copilot SDK. -// This is called by the SDK when the agent needs permission for an action. -func (a *CopilotAdapter) handlePermissionRequest( - request copilot.PermissionRequest, - invocation copilot.PermissionInvocation, -) (copilot.PermissionRequestResult, error) { - a.mu.RLock() - handler := a.permissionHandler - sessionID := a.sessionID - a.mu.RUnlock() - - // If no handler is set, auto-approve - if handler == nil { - a.logger.Debug("auto-approving permission (no handler)", - zap.String("kind", request.Kind), - zap.String("tool_call_id", request.ToolCallID)) - return copilot.PermissionRequestResult{Kind: "approved"}, nil - } - - permReq := a.buildPermissionRequest(request, sessionID) - - a.logger.Info("requesting permission from user", - zap.String("kind", request.Kind), - zap.String("tool_call_id", request.ToolCallID), - zap.String("session_id", sessionID)) - - // Call the permission handler (blocking - waits for user response) - ctx := context.Background() - resp, err := handler(ctx, permReq) - if err != nil { - a.logger.Warn("permission handler error", zap.Error(err)) - return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil - } - - if resp == nil || resp.Cancelled { - a.logger.Info("permission denied (cancelled)") - return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil - } - - // Convert response - if resp.OptionID == "allow" { - a.logger.Info("permission approved") - return copilot.PermissionRequestResult{Kind: "approved"}, nil - } - - a.logger.Info("permission denied") - return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil -} - -// buildPermissionRequest constructs a PermissionRequest from a Copilot SDK request, -// waiting briefly for cached tool payload data to populate action details. -func (a *CopilotAdapter) buildPermissionRequest(request copilot.PermissionRequest, sessionID string) *PermissionRequest { - // Build a human-readable title using cached tool data if available. - // The pendingToolPayloads map is populated by handleToolStart events - // which run in a separate goroutine from the permission handler. - // Wait briefly for the tool_call event to be processed first, ensuring - // the tool_call message is created in the DB before the permission message - // (needed for the frontend merge logic to work). - title := request.Kind - actionType := request.Kind - actionDetails := request.Extra - - var cachedPayload *streams.NormalizedPayload - for i := 0; i < 10; i++ { - a.mu.RLock() - cachedPayload = a.pendingToolPayloads[request.ToolCallID] - a.mu.RUnlock() - if cachedPayload != nil { - break - } - time.Sleep(50 * time.Millisecond) - } - - if cachedPayload != nil { - var newDetails interface{} - title, actionType, newDetails = a.resolvePermissionActionDetails(cachedPayload, title, actionType, actionDetails) - if m, ok := newDetails.(map[string]interface{}); ok { - actionDetails = m - } - } - - return &PermissionRequest{ - SessionID: sessionID, - ToolCallID: request.ToolCallID, - Title: title, - ActionType: actionType, - ActionDetails: actionDetails, - PendingID: request.ToolCallID, - Options: []PermissionOption{ - {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, - {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, - }, - } -} - -// resolvePermissionActionDetails maps a cached tool payload to a human-readable title, -// action type, and action details for use in a permission request. -func (a *CopilotAdapter) resolvePermissionActionDetails( - cachedPayload *streams.NormalizedPayload, - title, actionType string, - actionDetails map[string]interface{}, -) (string, string, map[string]interface{}) { - switch cachedPayload.Kind() { - case streams.ToolKindShellExec: - if se := cachedPayload.ShellExec(); se != nil { - return se.Command, types.ActionTypeCommand, map[string]interface{}{ - "command": se.Command, - "cwd": se.WorkDir, - } - } - case streams.ToolKindModifyFile: - if mf := cachedPayload.ModifyFile(); mf != nil { - return fmt.Sprintf("Write: %s", mf.FilePath), types.ActionTypeFileWrite, map[string]interface{}{ - "path": mf.FilePath, - } - } - case streams.ToolKindReadFile: - if rf := cachedPayload.ReadFile(); rf != nil { - return fmt.Sprintf("Read: %s", rf.FilePath), types.ActionTypeFileRead, map[string]interface{}{ - "path": rf.FilePath, - } - } - default: - // For other tool kinds, use a generic label - return string(cachedPayload.Kind()), actionType, actionDetails - } - return title, actionType, actionDetails -} - -// RequiresProcessKill returns true because the Copilot CLI server doesn't exit on stdin close. -// The CLI runs as an HTTP server that must be explicitly killed. -func (a *CopilotAdapter) RequiresProcessKill() bool { - return true -} - -// Verify interface implementation -var _ AgentAdapter = (*CopilotAdapter)(nil) diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_adapter_test.go b/apps/backend/internal/agentctl/server/adapter/copilot_adapter_test.go deleted file mode 100644 index 6aa754bebf..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/copilot_adapter_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package adapter - -import ( - "testing" - - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/pkg/copilot" -) - -func TestMcpServersToCopilotConfig_Nil(t *testing.T) { - result := mcpServersToCopilotConfig(nil) - if result != nil { - t.Errorf("expected nil for nil input, got %v", result) - } -} - -func TestMcpServersToCopilotConfig_Empty(t *testing.T) { - result := mcpServersToCopilotConfig([]types.McpServer{}) - if result != nil { - t.Errorf("expected nil for empty input, got %v", result) - } -} - -func TestMcpServersToCopilotConfig_StdioServer(t *testing.T) { - servers := []types.McpServer{ - { - Name: "my-stdio-server", - Type: "stdio", - Command: "node", - Args: []string{"server.js", "--port", "3000"}, - }, - } - - result := mcpServersToCopilotConfig(servers) - - if len(result) != 1 { - t.Fatalf("expected 1 server, got %d", len(result)) - } - - cfg, ok := result["my-stdio-server"] - if !ok { - t.Fatal("expected server 'my-stdio-server' in result") - } - - assertConfigValue(t, cfg, "type", "local") - assertConfigValue(t, cfg, "command", "node") - - args, ok := cfg["args"].([]string) - if !ok { - t.Fatalf("expected args to be []string, got %T", cfg["args"]) - } - if len(args) != 3 || args[0] != "server.js" || args[1] != "--port" || args[2] != "3000" { - t.Errorf("unexpected args: %v", args) - } - - tools, ok := cfg["tools"].([]string) - if !ok { - t.Fatalf("expected tools to be []string, got %T", cfg["tools"]) - } - if len(tools) != 1 || tools[0] != "*" { - t.Errorf("expected tools [\"*\"], got %v", tools) - } -} - -func TestMcpServersToCopilotConfig_DefaultType(t *testing.T) { - // When type is empty, should default to "local" (stdio) - servers := []types.McpServer{ - { - Name: "default-server", - Command: "my-tool", - Args: []string{"--verbose"}, - }, - } - - result := mcpServersToCopilotConfig(servers) - cfg := result["default-server"] - assertConfigValue(t, cfg, "type", "local") - assertConfigValue(t, cfg, "command", "my-tool") -} - -func TestMcpServersToCopilotConfig_SSEServer(t *testing.T) { - servers := []types.McpServer{ - { - Name: "my-sse-server", - Type: "sse", - URL: "http://localhost:8080/sse", - }, - } - - result := mcpServersToCopilotConfig(servers) - - if len(result) != 1 { - t.Fatalf("expected 1 server, got %d", len(result)) - } - - cfg := result["my-sse-server"] - assertConfigValue(t, cfg, "type", "sse") - assertConfigValue(t, cfg, "url", "http://localhost:8080/sse") - - // Should not have command/args - if _, ok := cfg["command"]; ok { - t.Error("SSE server should not have 'command' field") - } - if _, ok := cfg["args"]; ok { - t.Error("SSE server should not have 'args' field") - } -} - -func TestMcpServersToCopilotConfig_HTTPServer(t *testing.T) { - servers := []types.McpServer{ - { - Name: "my-http-server", - Type: mcpServerTypeHTTP, - URL: "http://localhost:9090/mcp", - }, - } - - result := mcpServersToCopilotConfig(servers) - cfg := result["my-http-server"] - assertConfigValue(t, cfg, "type", mcpServerTypeHTTP) - assertConfigValue(t, cfg, "url", "http://localhost:9090/mcp") -} - -func TestMcpServersToCopilotConfig_MultipleServers(t *testing.T) { - servers := []types.McpServer{ - { - Name: "stdio-server", - Type: "stdio", - Command: "my-tool", - Args: []string{"--mode", "dev"}, - }, - { - Name: "sse-server", - Type: "sse", - URL: "http://localhost:8080/sse", - }, - { - Name: "http-server", - Type: mcpServerTypeHTTP, - URL: "http://localhost:9090/mcp", - }, - } - - result := mcpServersToCopilotConfig(servers) - - if len(result) != 3 { - t.Fatalf("expected 3 servers, got %d", len(result)) - } - - // Verify each server is present and correctly typed - assertConfigValue(t, result["stdio-server"], "type", "local") - assertConfigValue(t, result["stdio-server"], "command", "my-tool") - - assertConfigValue(t, result["sse-server"], "type", "sse") - assertConfigValue(t, result["sse-server"], "url", "http://localhost:8080/sse") - - assertConfigValue(t, result["http-server"], "type", mcpServerTypeHTTP) - assertConfigValue(t, result["http-server"], "url", "http://localhost:9090/mcp") -} - -func TestMcpServersToCopilotConfig_NilArgs(t *testing.T) { - // When Args is nil, the "args" key should not be set - servers := []types.McpServer{ - { - Name: "no-args-server", - Type: "stdio", - Command: "simple-tool", - }, - } - - result := mcpServersToCopilotConfig(servers) - cfg := result["no-args-server"] - - if _, ok := cfg["args"]; ok { - t.Error("expected no 'args' key when Args is nil") - } -} - -func TestMcpServersToCopilotConfig_AllHaveWildcardTools(t *testing.T) { - servers := []types.McpServer{ - {Name: "a", Type: "stdio", Command: "cmd-a"}, - {Name: "b", Type: "sse", URL: "http://b"}, - {Name: "c", Type: mcpServerTypeHTTP, URL: "http://c"}, - } - - result := mcpServersToCopilotConfig(servers) - - for name, cfg := range result { - tools, ok := cfg["tools"].([]string) - if !ok { - t.Errorf("server %q: expected tools to be []string", name) - continue - } - if len(tools) != 1 || tools[0] != "*" { - t.Errorf("server %q: expected tools [\"*\"], got %v", name, tools) - } - } -} - -func assertConfigValue(t *testing.T, cfg copilot.MCPServerConfig, key, expected string) { - t.Helper() - val, ok := cfg[key].(string) - if !ok { - t.Errorf("expected %q to be string, got %T (%v)", key, cfg[key], cfg[key]) - return - } - if val != expected { - t.Errorf("expected %q = %q, got %q", key, expected, val) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go b/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go deleted file mode 100644 index e6a541a873..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go +++ /dev/null @@ -1,420 +0,0 @@ -package adapter - -import ( - "encoding/json" - "regexp" - "strconv" - "strings" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" -) - -// exitedPattern matches the "" suffix that Copilot appends to shell output. -var exitedPattern = regexp.MustCompile(`\n?$`) - -// Copilot tool name constants. -// Copilot typically uses lowercase snake_case tool names. -const ( - CopilotToolBash = "bash" - CopilotToolReadFile = "read_file" - CopilotToolWriteFile = "write_file" - CopilotToolEditFile = "edit_file" - CopilotToolGlob = "glob" - CopilotToolGrep = "grep" - CopilotToolWebFetch = "web_fetch" - CopilotToolWebSearch = "web_search" -) - -// CopilotNormalizer converts Copilot SDK tool data to NormalizedPayload. -type CopilotNormalizer struct{} - -// NewCopilotNormalizer creates a new Copilot normalizer. -func NewCopilotNormalizer() *CopilotNormalizer { - return &CopilotNormalizer{} -} - -// NormalizeToolCall converts Copilot tool call data to NormalizedPayload. -func (n *CopilotNormalizer) NormalizeToolCall(toolName string, args any) *streams.NormalizedPayload { - // Convert args to map if possible - argsMap := n.toMap(args) - - // Normalize tool name to lowercase for comparison - toolNameLower := strings.ToLower(toolName) - - switch toolNameLower { - case CopilotToolBash, "shell", "execute", "run": - return n.normalizeExecute(argsMap) - case CopilotToolReadFile, "read": - return n.normalizeRead(argsMap) - case CopilotToolWriteFile, "write": - return n.normalizeWrite(argsMap) - case CopilotToolEditFile, "edit": - return n.normalizeEdit(argsMap) - case CopilotToolGlob, CopilotToolGrep, "search", "find": - return n.normalizeCodeSearch(toolNameLower, argsMap) - case CopilotToolWebFetch, CopilotToolWebSearch, "fetch", mcpServerTypeHTTP: - return n.normalizeHttpRequest(toolNameLower, argsMap) - default: - return n.normalizeGeneric(toolName, argsMap) - } -} - -// NormalizeToolResult updates the payload with tool result data. -func (n *CopilotNormalizer) NormalizeToolResult(cachedPayload *streams.NormalizedPayload, result any, isError bool) *streams.NormalizedPayload { - if cachedPayload == nil { - return n.normalizeResultNoCached(result) - } - - switch cachedPayload.Kind() { - case streams.ToolKindShellExec: - n.normalizeShellExecResult(cachedPayload, result, isError) - case streams.ToolKindHttpRequest: - n.normalizeHttpRequestResult(cachedPayload, result) - case streams.ToolKindGeneric: - n.normalizeGenericResult(cachedPayload, result) - case streams.ToolKindReadFile: - n.normalizeReadFileResult(cachedPayload, result) - case streams.ToolKindCodeSearch: - n.normalizeCodeSearchResult(cachedPayload, result) - } - - return cachedPayload -} - -// normalizeResultNoCached creates a generic payload when no cached payload exists. -func (n *CopilotNormalizer) normalizeResultNoCached(result any) *streams.NormalizedPayload { - output := n.extractOutput(result) - payload := streams.NewGeneric("unknown", nil) - if payload.Generic() != nil { - payload.Generic().Output = output - } - return payload -} - -// normalizeShellExecResult populates ShellExec output from tool result. -func (n *CopilotNormalizer) normalizeShellExecResult(payload *streams.NormalizedPayload, result any, isError bool) { - if payload.ShellExec() == nil { - return - } - output, exitCode := n.parseShellResult(result) - payload.ShellExec().Output = &streams.ShellExecOutput{ - Stdout: output, - ExitCode: exitCode, - } - if isError { - payload.ShellExec().Output.Stderr = output - payload.ShellExec().Output.Stdout = "" - } -} - -// normalizeHttpRequestResult populates HttpRequest response from tool result. -func (n *CopilotNormalizer) normalizeHttpRequestResult(payload *streams.NormalizedPayload, result any) { - if payload.HttpRequest() == nil { - return - } - if output, ok := n.extractOutput(result).(string); ok { - payload.HttpRequest().Response = output - } -} - -// normalizeGenericResult populates Generic output from tool result. -func (n *CopilotNormalizer) normalizeGenericResult(payload *streams.NormalizedPayload, result any) { - if payload.Generic() == nil { - return - } - payload.Generic().Output = n.extractOutput(result) -} - -// normalizeReadFileResult populates ReadFile output from tool result. -func (n *CopilotNormalizer) normalizeReadFileResult(payload *streams.NormalizedPayload, result any) { - if payload.ReadFile() == nil { - return - } - if output, ok := n.extractOutput(result).(string); ok { - payload.ReadFile().Output = &streams.ReadFileOutput{ - Content: output, - } - } -} - -// normalizeCodeSearchResult populates CodeSearch output from tool result. -func (n *CopilotNormalizer) normalizeCodeSearchResult(payload *streams.NormalizedPayload, result any) { - if payload.CodeSearch() == nil { - return - } - if output, ok := n.extractOutput(result).(string); ok { - // Parse file list from newline-separated output - files := n.parseFileList(output) - payload.CodeSearch().Output = &streams.CodeSearchOutput{ - Files: files, - FileCount: len(files), - } - } -} - -// toMap converts an argument to a map[string]any. -func (n *CopilotNormalizer) toMap(args any) map[string]any { - if args == nil { - return nil - } - if m, ok := args.(map[string]any); ok { - return m - } - // Try JSON round-trip for struct types - if data, err := json.Marshal(args); err == nil { - var m map[string]any - if err := json.Unmarshal(data, &m); err == nil { - return m - } - } - return nil -} - -// extractStringFieldFromMap tries common output field names on a map. -func extractStringFieldFromMap(m map[string]any) (string, bool) { - for _, key := range []string{"output", "stdout", "content", "text"} { - if v, ok := m[key].(string); ok { - return v, true - } - } - return "", false -} - -// extractOutput extracts a string output from various result formats. -func (n *CopilotNormalizer) extractOutput(result any) any { - if result == nil { - return nil - } - - switch r := result.(type) { - case string: - return r - case map[string]any: - if v, ok := extractStringFieldFromMap(r); ok { - return v - } - return r - default: - return n.extractOutputFromStruct(result) - } -} - -// extractOutputFromStruct handles struct types (like Copilot SDK's *Result) via JSON round-trip. -func (n *CopilotNormalizer) extractOutputFromStruct(result any) any { - data, err := json.Marshal(result) - if err != nil { - return result - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return result - } - if v, ok := extractStringFieldFromMap(m); ok { - return v - } - return result -} - -// parseShellResult extracts output and exit code from shell execution result. -func (n *CopilotNormalizer) parseShellResult(result any) (string, int) { - if result == nil { - return "", 0 - } - - var output string - var exitCode int - - switch r := result.(type) { - case string: - output = r - case map[string]any: - output, exitCode = n.parseShellResultFromMap(r) - default: - output, exitCode = n.parseShellResultFromStruct(result) - } - - // Strip Copilot's "" suffix and extract exit code - output, exitCode = n.cleanShellOutput(output, exitCode) - - return output, exitCode -} - -// parseShellResultFromMap extracts output and exit code from a map result. -func (n *CopilotNormalizer) parseShellResultFromMap(r map[string]any) (string, int) { - output := shared.GetString(r, "output") - if output == "" { - output = shared.GetString(r, "stdout") - } - if output == "" { - output = shared.GetString(r, "content") - } - if output == "" { - output = shared.GetString(r, "text") - } - exitCode := shared.GetInt(r, "exitCode") - if exitCode == 0 { - exitCode = shared.GetInt(r, "exit_code") - } - return output, exitCode -} - -// parseShellResultFromStruct handles struct types via JSON round-trip. -func (n *CopilotNormalizer) parseShellResultFromStruct(result any) (string, int) { - data, err := json.Marshal(result) - if err != nil { - return "", 0 - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return string(data), 0 - } - if content, ok := m["content"].(string); ok { - return content, 0 - } - if out, ok := m["output"].(string); ok { - return out, 0 - } - if stdout, ok := m["stdout"].(string); ok { - return stdout, 0 - } - return string(data), 0 -} - -// cleanShellOutput strips Copilot's "" suffix from shell output -// and extracts the exit code if not already set. -func (n *CopilotNormalizer) cleanShellOutput(output string, exitCode int) (string, int) { - if matches := exitedPattern.FindStringSubmatch(output); matches != nil { - // Strip the suffix - output = exitedPattern.ReplaceAllString(output, "") - // Extract exit code from the suffix if we don't already have one - if exitCode == 0 && len(matches) > 1 { - if code, err := strconv.Atoi(matches[1]); err == nil { - exitCode = code - } - } - } - return strings.TrimSuffix(output, "\n"), exitCode -} - -// normalizeExecute converts shell execution tool data. -func (n *CopilotNormalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload { - command := shared.GetString(args, "command") - if command == "" { - command = shared.GetString(args, "cmd") - } - description := shared.GetString(args, "description") - timeout := shared.GetInt(args, "timeout") - background := shared.GetBool(args, "background") - - return streams.NewShellExec(command, "", description, timeout, background) -} - -// normalizeRead converts file read tool data. -func (n *CopilotNormalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { - filePath := shared.GetString(args, "file_path") - if filePath == "" { - filePath = shared.GetString(args, "path") - } - offset := shared.GetInt(args, "offset") - limit := shared.GetInt(args, "limit") - - return streams.NewReadFile(filePath, offset, limit) -} - -// normalizeWrite converts file write tool data. -func (n *CopilotNormalizer) normalizeWrite(args map[string]any) *streams.NormalizedPayload { - filePath := shared.GetString(args, "file_path") - if filePath == "" { - filePath = shared.GetString(args, "path") - } - content := shared.GetString(args, "content") - - return streams.NewModifyFile(filePath, []streams.FileMutation{ - { - Type: streams.MutationCreate, - Content: content, - }, - }) -} - -// normalizeEdit converts file edit tool data. -func (n *CopilotNormalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload { - filePath := shared.GetString(args, "file_path") - if filePath == "" { - filePath = shared.GetString(args, "path") - } - oldContent := shared.GetString(args, "old_string") - if oldContent == "" { - oldContent = shared.GetString(args, "old_content") - } - newContent := shared.GetString(args, "new_string") - if newContent == "" { - newContent = shared.GetString(args, "new_content") - } - - mutation := streams.FileMutation{ - Type: streams.MutationPatch, - OldContent: oldContent, - NewContent: newContent, - } - - // Generate unified diff - if oldContent != "" && newContent != "" { - mutation.Diff = shared.GenerateUnifiedDiff(oldContent, newContent, filePath, 0) - } - - return streams.NewModifyFile(filePath, []streams.FileMutation{mutation}) -} - -// normalizeCodeSearch converts code search tool data. -func (n *CopilotNormalizer) normalizeCodeSearch(toolName string, args map[string]any) *streams.NormalizedPayload { - path := shared.GetString(args, "path") - if path == "" { - path = shared.GetString(args, "directory") - } - pattern := shared.GetString(args, "pattern") - query := shared.GetString(args, "query") - glob := "" - - if toolName == CopilotToolGlob { - glob = pattern - } - - return streams.NewCodeSearch(query, pattern, path, glob) -} - -// normalizeHttpRequest converts HTTP request tool data. -func (n *CopilotNormalizer) normalizeHttpRequest(toolName string, args map[string]any) *streams.NormalizedPayload { - url := shared.GetString(args, "url") - method := shared.GetString(args, "method") - if method == "" { - method = "GET" - } - if toolName == CopilotToolWebSearch { - method = "SEARCH" - } - - return streams.NewHttpRequest(url, method) -} - -// normalizeGeneric wraps unknown tools as generic. -func (n *CopilotNormalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { - return streams.NewGeneric(toolName, args) -} - -// parseFileList parses newline-separated file paths from grep/glob output. -func (n *CopilotNormalizer) parseFileList(output string) []string { - if output == "" { - return nil - } - lines := strings.Split(output, "\n") - var files []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" { - files = append(files, line) - } - } - return files -} diff --git a/apps/backend/internal/agentctl/server/adapter/factory.go b/apps/backend/internal/agentctl/server/adapter/factory.go index 1723dba7b3..7a30dbae74 100644 --- a/apps/backend/internal/agentctl/server/adapter/factory.go +++ b/apps/backend/internal/agentctl/server/adapter/factory.go @@ -4,48 +4,24 @@ import ( "fmt" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/codex" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/opencode" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/streamjson" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" ) // NewAdapter creates a new protocol adapter based on the specified protocol type. -// It returns an error if the protocol is not supported. -// -// The protocol determines which transport adapter to use: -// - ProtocolACP: ACP adapter (JSON-RPC 2.0 over stdin/stdout) -// - ProtocolClaudeCode: Stream-json adapter (streaming JSON over stdin/stdout) -// - ProtocolCodex: Codex adapter (JSON-RPC variant over stdin/stdout) -// - ProtocolOpenCode: OpenCode adapter (REST/SSE over HTTP) +// Only ACP is supported; non-ACP variants were removed in the ACP-first migration. func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (AgentAdapter, error) { - // Convert to shared config for transport adapters sharedCfg := cfg.ToSharedConfig() switch protocol { case agent.ProtocolACP: return newACPAdapterWrapper(acp.NewAdapter(sharedCfg, log)), nil - case agent.ProtocolClaudeCode: - return newStreamJSONAdapterWrapper(streamjson.NewAdapter(sharedCfg, log)), nil - case agent.ProtocolCodex: - return newCodexAdapterWrapper(codex.NewAdapter(sharedCfg, log)), nil - case agent.ProtocolOpenCode: - return newOpenCodeAdapterWrapper(opencode.NewAdapter(sharedCfg, log)), nil - case agent.ProtocolCopilot: - return NewCopilotAdapter(cfg, log), nil - case agent.ProtocolAmp: - return NewAmpAdapter(cfg, log), nil default: return nil, fmt.Errorf("unsupported protocol: %s", protocol) } } -// Adapter wrappers to convert transport-specific adapters to the common AgentAdapter interface. -// These wrappers handle the type conversion between transport-specific AgentInfo and the -// common adapter.AgentInfo type. - -// acpAdapterWrapper wraps acp.Adapter to implement AgentAdapter +// acpAdapterWrapper wraps acp.Adapter to implement AgentAdapter. type acpAdapterWrapper struct { *acp.Adapter } @@ -61,64 +37,3 @@ func (w *acpAdapterWrapper) GetAgentInfo() *AgentInfo { } return &AgentInfo{Name: info.Name, Version: info.Version} } - -// streamJSONAdapterWrapper wraps streamjson.Adapter to implement AgentAdapter -type streamJSONAdapterWrapper struct { - *streamjson.Adapter -} - -func newStreamJSONAdapterWrapper(a *streamjson.Adapter) *streamJSONAdapterWrapper { - return &streamJSONAdapterWrapper{Adapter: a} -} - -func (w *streamJSONAdapterWrapper) GetAgentInfo() *AgentInfo { - info := w.Adapter.GetAgentInfo() - if info == nil { - return nil - } - return &AgentInfo{Name: info.Name, Version: info.Version} -} - -// SetStderrProvider implements StderrProviderSetter -func (w *streamJSONAdapterWrapper) SetStderrProvider(provider StderrProvider) { - w.Adapter.SetStderrProvider(provider) -} - -// codexAdapterWrapper wraps codex.Adapter to implement AgentAdapter -type codexAdapterWrapper struct { - *codex.Adapter -} - -func newCodexAdapterWrapper(a *codex.Adapter) *codexAdapterWrapper { - return &codexAdapterWrapper{Adapter: a} -} - -func (w *codexAdapterWrapper) GetAgentInfo() *AgentInfo { - info := w.Adapter.GetAgentInfo() - if info == nil { - return nil - } - return &AgentInfo{Name: info.Name, Version: info.Version} -} - -// SetStderrProvider implements StderrProviderSetter -func (w *codexAdapterWrapper) SetStderrProvider(provider StderrProvider) { - w.Adapter.SetStderrProvider(provider) -} - -// openCodeAdapterWrapper wraps opencode.Adapter to implement AgentAdapter -type openCodeAdapterWrapper struct { - *opencode.Adapter -} - -func newOpenCodeAdapterWrapper(a *opencode.Adapter) *openCodeAdapterWrapper { - return &openCodeAdapterWrapper{Adapter: a} -} - -func (w *openCodeAdapterWrapper) GetAgentInfo() *AgentInfo { - info := w.Adapter.GetAgentInfo() - if info == nil { - return nil - } - return &AgentInfo{Name: info.Name, Version: info.Version} -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go deleted file mode 100644 index a873b4e802..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go +++ /dev/null @@ -1,301 +0,0 @@ -// Package codex implements the Codex transport adapter. -// Codex uses a JSON-RPC 2.0 variant over stdio (omitting the jsonrpc field). -package codex - -import ( - "context" - "encoding/json" - "fmt" - "io" - "sync" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/codex" - "go.uber.org/zap" -) - -// Codex decision constants for approval responses. -const ( - decisionAccept = "accept" - decisionAcceptSession = "acceptForSession" - decisionDecline = "decline" - decisionCancel = "cancel" -) - -// Re-export types needed by external packages -type ( - PermissionRequest = types.PermissionRequest - PermissionResponse = types.PermissionResponse - PermissionOption = streams.PermissionOption - PermissionHandler = types.PermissionHandler - AgentEvent = streams.AgentEvent - PlanEntry = streams.PlanEntry -) - -// AgentInfo contains information about the connected agent. -type AgentInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -// StderrProvider provides access to recent stderr output for error context. -type StderrProvider interface { - GetRecentStderr() []string -} - -// Adapter implements the transport adapter for agents using the Codex protocol. -// Codex uses a JSON-RPC 2.0 variant over stdio (omitting the jsonrpc field). -type Adapter struct { - cfg *shared.Config - logger *logger.Logger - - // Agent identity (from config, for logging) - agentID string - - // Normalizer for converting tool data to NormalizedPayload - normalizer *Normalizer - - // Subprocess stdin/stdout (set via Connect) - stdin io.Writer - stdout io.Reader - - // Codex client for JSON-RPC communication - client *codex.Client - - // Context for managing goroutine lifecycle - ctx context.Context - cancel context.CancelFunc - - // Session state - Thread maps to Session, Turn maps to Operation - threadID string // session ID - turnID string // operation ID - - // Agent info (populated after Initialize) - agentInfo *AgentInfo - - // Update channel - updatesCh chan AgentEvent - - // Permission handler - permissionHandler PermissionHandler - - // Stderr provider for error context - stderrProvider StderrProvider - - // Accumulators for streaming content - messageBuffer string - reasoningBuffer string - currentReasoningItemID string // track current reasoning item for separator insertion - - // Turn completion signaling - turnCompleteCh chan turnCompleteResult - - // lastRawData holds the raw JSON of the current notification being processed. - // Set in handleNotification before dispatch; used by sendUpdate for OTel tracing. - lastRawData json.RawMessage - - // OTel tracing: active prompt span context. - // Notification spans become children of the prompt span for visual grouping. - promptTraceCtx context.Context - promptTraceMu sync.RWMutex - - // Attachment management - attachMgr *shared.AttachmentManager - - // Synchronization - mu sync.RWMutex - closed bool -} - -// turnCompleteResult holds the result of a completed turn -type turnCompleteResult struct { - success bool - err string -} - -// NewAdapter creates a new Codex protocol adapter. -// Call Connect() after starting the subprocess to wire up stdin/stdout. -func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { - ctx, cancel := context.WithCancel(context.Background()) - l := log.WithFields(zap.String("adapter", "codex"), zap.String("agent_id", cfg.AgentID)) - return &Adapter{ - cfg: cfg, - logger: l, - agentID: cfg.AgentID, - normalizer: NewNormalizer(), - ctx: ctx, - cancel: cancel, - updatesCh: make(chan AgentEvent, 100), - attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()), - } -} - -// Connect wires up the stdin/stdout pipes from the running agent subprocess. -func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.stdin != nil || a.stdout != nil { - return fmt.Errorf("adapter already connected") - } - - a.stdin = stdin - a.stdout = stdout - return nil -} - -// Close releases resources held by the adapter. -func (a *Adapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - return nil - } - a.closed = true - - a.logger.Info("closing Codex adapter") - - // Clean up any saved attachments - a.attachMgr.Cleanup() - - // Cancel the context to stop the read loop goroutine - if a.cancel != nil { - a.cancel() - } - - // Stop the client - if a.client != nil { - a.client.Stop() - } - - // Close update channel - close(a.updatesCh) - - return nil -} - -// Updates returns the channel for agent events. -func (a *Adapter) Updates() <-chan AgentEvent { - return a.updatesCh -} - -// GetSessionID returns the current thread ID (session). -func (a *Adapter) GetSessionID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.threadID -} - -// GetOperationID returns the current turn ID (operation). -func (a *Adapter) GetOperationID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.turnID -} - -// SetPermissionHandler sets the handler for permission requests. -func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { - a.mu.Lock() - defer a.mu.Unlock() - a.permissionHandler = handler -} - -// SetStderrProvider sets the provider for recent stderr output. -// This is used to include stderr in error events when the agent -// reports an error without a detailed message. -func (a *Adapter) SetStderrProvider(provider StderrProvider) { - a.mu.Lock() - defer a.mu.Unlock() - a.stderrProvider = provider -} - -// RequiresProcessKill returns false because Codex agents exit when stdin is closed. -func (a *Adapter) RequiresProcessKill() bool { - return false -} - -// sendUpdate safely sends an event to the updates channel. -func (a *Adapter) sendUpdate(update AgentEvent) { - shared.LogNormalizedEvent(shared.ProtocolCodex, a.agentID, &update) - shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolCodex, a.agentID, - update.Type, a.lastRawData, &update) - select { - case a.updatesCh <- update: - default: - a.logger.Warn("updates channel full, dropping notification") - } -} - -// getPromptTraceCtx returns the current prompt span context for child-span linking. -// Returns context.Background() if no prompt is active. -func (a *Adapter) getPromptTraceCtx() context.Context { - a.promptTraceMu.RLock() - defer a.promptTraceMu.RUnlock() - if a.promptTraceCtx != nil { - return a.promptTraceCtx - } - return context.Background() -} - -// setPromptTraceCtx stores the prompt span context. -func (a *Adapter) setPromptTraceCtx(ctx context.Context) { - a.promptTraceMu.Lock() - defer a.promptTraceMu.Unlock() - a.promptTraceCtx = ctx -} - -// clearPromptTraceCtx clears the prompt span context. -func (a *Adapter) clearPromptTraceCtx() { - a.promptTraceMu.Lock() - defer a.promptTraceMu.Unlock() - a.promptTraceCtx = nil -} - -// handleNotification processes Codex notifications and emits AgentEvents. -func (a *Adapter) handleNotification(method string, params json.RawMessage) { - // Store raw data for tracing and log for debugging - a.lastRawData = params - shared.LogRawEvent(shared.ProtocolCodex, a.agentID, method, params) - - a.mu.RLock() - threadID := a.threadID - turnID := a.turnID - a.mu.RUnlock() - - switch method { - case codex.NotifyItemAgentMessageDelta: - a.handleAgentMessageDelta(params, threadID, turnID) - case codex.NotifyItemReasoningTextDelta: - a.handleReasoningDelta(params, threadID, turnID, "reasoning text delta") - case codex.NotifyItemReasoningSummaryDelta: - a.handleReasoningDelta(params, threadID, turnID, "reasoning summary delta") - case codex.NotifyTurnCompleted: - a.handleTurnCompleted(params, threadID) - case codex.NotifyTurnDiffUpdated: - a.handleTurnDiffUpdated(params, threadID) - case codex.NotifyTurnPlanUpdated: - a.handleTurnPlanUpdated(params, threadID) - case codex.NotifyError: - a.handleErrorNotification(params, threadID) - case codex.NotifyItemStarted: - a.handleItemStarted(params, threadID, turnID) - case codex.NotifyItemCompleted: - a.handleItemCompleted(params, threadID, turnID) - case codex.NotifyItemCmdExecOutputDelta: - a.handleCmdExecOutputDelta(params, threadID, turnID) - case codex.NotifyThreadTokenUsageUpdated: - a.handleTokenUsageUpdated(params, threadID, turnID) - case codex.NotifyTokenCount: - // Legacy token_count notification - ignore as we now use thread/tokenUsage/updated - a.logger.Debug("ignoring legacy token_count notification") - case codex.NotifyContextCompacted: - a.handleContextCompacted(params) - default: - // Log unhandled notifications at debug level - a.logger.Debug("unhandled notification", zap.String("method", method)) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_init.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_init.go deleted file mode 100644 index cc5a05bbbd..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_init.go +++ /dev/null @@ -1,154 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/kandev/kandev/pkg/codex" - "go.uber.org/zap" -) - -// PrepareEnvironment is a no-op for Codex. MCP servers and sandbox settings -// are now passed via command-line -c flags through PrepareCommandArgs(). -func (a *Adapter) PrepareEnvironment() (map[string]string, error) { - a.logger.Info("PrepareEnvironment called (no-op for Codex)") - return nil, nil -} - -// PrepareCommandArgs returns extra command-line arguments for the Codex process. -// This includes -c flags for MCP servers and sandbox configuration. -// Codex uses -c key=value flags to override config at runtime. -func (a *Adapter) PrepareCommandArgs() []string { - var args []string - - // Set sandbox_mode to workspace-write to enable file editing - args = append(args, "-c", "sandbox_mode=\"workspace-write\"") - - // Enable network access in sandbox - args = append(args, "-c", "sandbox_workspace_write.network_access=true") - - // Add MCP servers as -c flags - for _, server := range a.cfg.McpServers { - safeName := sanitizeCodexServerName(server.Name) - - if server.Type == "sse" || server.Type == "http" { - // HTTP/SSE transport - use url field - // Convert SSE URLs (/sse) to streamable HTTP URLs (/mcp) for Codex compatibility - url := server.URL - if url != "" { - url = convertSSEToStreamableHTTP(url) - args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.url=\"%s\"", safeName, url)) - } - } else if server.Command != "" { - // STDIO transport - use command field - args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.command=\"%s\"", safeName, server.Command)) - // Add args if present - if len(server.Args) > 0 { - // TOML array format: ["arg1", "arg2"] - quotedArgs := make([]string, len(server.Args)) - for i, arg := range server.Args { - quotedArgs[i] = fmt.Sprintf("\"%s\"", arg) - } - argsStr := "[" + strings.Join(quotedArgs, ", ") + "]" - args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.args=%s", safeName, argsStr)) - } - } - } - - a.logger.Info("PrepareCommandArgs", - zap.Int("mcp_server_count", len(a.cfg.McpServers)), - zap.Strings("extra_args", args)) - - return args -} - -// sanitizeCodexServerName converts a server name to a valid TOML table name. -// Replaces spaces and special characters with underscores. -func sanitizeCodexServerName(name string) string { - var result strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { - result.WriteRune(r) - } else { - result.WriteRune('_') - } - } - sanitized := result.String() - if sanitized == "" { - return "server" - } - return sanitized -} - -// convertSSEToStreamableHTTP converts an SSE endpoint URL to a streamable HTTP endpoint URL. -// Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests. -// This converts URLs ending in /sse to /mcp for Kandev MCP server compatibility. -// Example: http://localhost:9090/sse -> http://localhost:9090/mcp -func convertSSEToStreamableHTTP(url string) string { - if strings.HasSuffix(url, "/sse") { - return strings.TrimSuffix(url, "/sse") + "/mcp" - } - return url -} - -// Initialize establishes the Codex connection with the agent subprocess. -func (a *Adapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing Codex adapter", - zap.String("workdir", a.cfg.WorkDir)) - - // Create Codex client - a.client = codex.NewClient(a.stdin, a.stdout, a.logger) - a.client.SetNotificationHandler(a.handleNotification) - a.client.SetRequestHandler(a.handleRequest) - - // Start reading from stdout with the adapter's context - // The readLoop needs to stay alive for the entire lifecycle of the adapter, - // not just the initialize HTTP request. It will be cancelled when Close() is called. - a.client.Start(a.ctx) - - // Perform Codex initialize handshake - resp, err := a.client.Call(ctx, codex.MethodInitialize, &codex.InitializeParams{ - ClientInfo: &codex.ClientInfo{ - Name: "kandev-agentctl", - Title: "Kandev Agent Controller", - Version: "1.0.0", - }, - }) - if err != nil { - return fmt.Errorf("codex initialize handshake failed: %w", err) - } - if resp.Error != nil { - return fmt.Errorf("codex initialize error: %s", resp.Error.Message) - } - - // Parse initialize result - var initResult codex.InitializeResult - if resp.Result != nil { - if err := json.Unmarshal(resp.Result, &initResult); err != nil { - a.logger.Warn("failed to parse initialize result", zap.Error(err)) - } - } - - // Send initialized notification - if err := a.client.Notify(codex.MethodInitialized, nil); err != nil { - return fmt.Errorf("failed to send initialized notification: %w", err) - } - - // Store agent info - a.agentInfo = &AgentInfo{ - Name: a.agentID, - Version: initResult.UserAgent, - } - - a.logger.Info("Codex adapter initialized", - zap.String("user_agent", initResult.UserAgent)) - - return nil -} - -// GetAgentInfo returns information about the connected agent. -func (a *Adapter) GetAgentInfo() *AgentInfo { - return a.agentInfo -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_items.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_items.go deleted file mode 100644 index 75b2a84155..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_items.go +++ /dev/null @@ -1,176 +0,0 @@ -package codex - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/codex" - "go.uber.org/zap" -) - -// handleItemStarted handles item/started notifications. -func (a *Adapter) handleItemStarted(params json.RawMessage, threadID, turnID string) { - var p codex.ItemStartedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse item started", zap.Error(err)) - return - } - if p.Item == nil { - return - } - // Map Codex item types to tool call updates - // Item types: "userMessage", "agentMessage", "commandExecution", "fileChange", "reasoning", "mcpToolCall" - switch p.Item.Type { - case CodexItemCommandExecution: - args := map[string]any{"command": p.Item.Command, "cwd": p.Item.Cwd} - normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemCommandExecution, args) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolName: CodexItemCommandExecution, - ToolTitle: p.Item.Command, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) - case CodexItemFileChange: - a.sendFileChangeStarted(p.Item, threadID, turnID) - case CodexItemMcpToolCall: - a.sendMcpToolCallStarted(p.Item, threadID, turnID) - } -} - -// sendFileChangeStarted emits a tool_call event for a file change item. -func (a *Adapter) sendFileChangeStarted(item *codex.Item, threadID, turnID string) { - var title string - if len(item.Changes) > 0 { - title = item.Changes[0].Path - if len(item.Changes) > 1 { - title += fmt.Sprintf(" (+%d more)", len(item.Changes)-1) - } - } - changesArgs := make([]any, 0, len(item.Changes)) - for _, c := range item.Changes { - changesArgs = append(changesArgs, map[string]any{"path": c.Path, "diff": c.Diff}) - } - args := map[string]any{"changes": changesArgs} - normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemFileChange, args) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: item.ID, - ToolName: CodexItemFileChange, - ToolTitle: title, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) -} - -// sendMcpToolCallStarted emits a tool_call event for an MCP tool call item. -func (a *Adapter) sendMcpToolCallStarted(item *codex.Item, threadID, turnID string) { - title := item.Tool - if item.Server != "" { - title = item.Server + "/" + item.Tool - } - var argsMap map[string]any - if len(item.Arguments) > 0 { - _ = json.Unmarshal(item.Arguments, &argsMap) - } - args := map[string]any{"server": item.Server, "tool": item.Tool, "arguments": argsMap} - normalizedPayload := a.normalizer.NormalizeToolCall(CodexItemMcpToolCall, args) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: threadID, - OperationID: turnID, - ToolCallID: item.ID, - ToolName: item.Tool, // Use the actual MCP tool name for frontend display - ToolTitle: title, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) -} - -// handleItemCompleted handles item/completed notifications. -func (a *Adapter) handleItemCompleted(params json.RawMessage, threadID, turnID string) { - var p codex.ItemCompletedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse item completed", zap.Error(err)) - return - } - if p.Item == nil { - return - } - // Only send updates for tool-like items - if p.Item.Type != CodexItemCommandExecution && p.Item.Type != CodexItemFileChange && p.Item.Type != CodexItemMcpToolCall { - return - } - - status := "complete" - if p.Item.Status == "failed" { - status = "error" - } - update := AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.Item.ID, - ToolStatus: status, - } - - // Include normalized payload for fallback message creation - // This ensures the correct message type is used if the message doesn't exist yet - a.attachCompletedItemPayload(&update, p.Item) - a.sendUpdate(update) -} - -// attachCompletedItemPayload sets the NormalizedPayload and Diff fields on a completed item event. -func (a *Adapter) attachCompletedItemPayload(update *AgentEvent, item *codex.Item) { - switch item.Type { - case CodexItemCommandExecution: - args := map[string]any{"command": item.Command, "cwd": item.Cwd} - update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemCommandExecution, args) - case CodexItemFileChange: - changesArgs := make([]any, 0, len(item.Changes)) - for _, c := range item.Changes { - changesArgs = append(changesArgs, map[string]any{"path": c.Path, "diff": c.Diff}) - } - args := map[string]any{"changes": changesArgs} - update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemFileChange, args) - // Include diff for file changes - if len(item.Changes) > 0 { - var diffs []string - for _, c := range item.Changes { - if c.Diff != "" { - diffs = append(diffs, c.Diff) - } - } - if len(diffs) > 0 { - update.Diff = strings.Join(diffs, "\n") - } - } - case CodexItemMcpToolCall: - var argsMap map[string]any - if len(item.Arguments) > 0 { - _ = json.Unmarshal(item.Arguments, &argsMap) - } - var resultMap any - if len(item.Result) > 0 { - _ = json.Unmarshal(item.Result, &resultMap) - } - args := map[string]any{ - "server": item.Server, - "tool": item.Tool, - "arguments": argsMap, - "result": resultMap, - } - if item.ToolError != "" { - args["error"] = item.ToolError - } - update.NormalizedPayload = a.normalizer.NormalizeToolCall(CodexItemMcpToolCall, args) - update.ToolName = item.Tool // Use the actual MCP tool name - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_notifications.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_notifications.go deleted file mode 100644 index 1c9577720e..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_notifications.go +++ /dev/null @@ -1,258 +0,0 @@ -package codex - -import ( - "encoding/json" - - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/codex" - "go.uber.org/zap" -) - -// handleAgentMessageDelta handles item/agentMessage/delta notifications. -func (a *Adapter) handleAgentMessageDelta(params json.RawMessage, threadID, turnID string) { - var p codex.AgentMessageDeltaParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse agent message delta", zap.Error(err)) - return - } - a.mu.Lock() - a.messageBuffer += p.Delta - a.mu.Unlock() - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: threadID, - OperationID: turnID, - Text: p.Delta, - }) -} - -// handleReasoningDelta handles reasoning text and summary delta notifications. -func (a *Adapter) handleReasoningDelta(params json.RawMessage, threadID, turnID, logLabel string) { - var p codex.ReasoningDeltaParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse "+logLabel, zap.Error(err)) - return - } - a.mu.Lock() - // Add separator when switching to a new reasoning item - if p.ItemID != a.currentReasoningItemID && a.reasoningBuffer != "" { - a.reasoningBuffer += "\n\n" - } - a.currentReasoningItemID = p.ItemID - a.reasoningBuffer += p.Delta - a.mu.Unlock() - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeReasoning, - SessionID: threadID, - OperationID: turnID, - ReasoningText: p.Delta, - }) -} - -// handleTurnCompleted handles turn/completed notifications. -func (a *Adapter) handleTurnCompleted(params json.RawMessage, threadID string) { - var p codex.TurnCompletedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse turn completed", zap.Error(err)) - return - } - - // Signal turn completion to the waiting Prompt() call - a.mu.RLock() - completeCh := a.turnCompleteCh - a.mu.RUnlock() - - if completeCh != nil { - select { - case completeCh <- turnCompleteResult{success: p.Success, err: p.Error}: - a.logger.Debug("signaled turn completion", zap.String("turn_id", p.TurnID), zap.Bool("success", p.Success)) - default: - a.logger.Warn("turn complete channel full, dropping signal") - } - } - - // Send error event if the turn failed WITH an explicit error message. - // Note: We don't send error events here based on stderr alone, because - // NotifyError will handle error notifications (prevents duplicate messages). - if !p.Success { - a.logger.Debug("turn completed with failure", - zap.String("thread_id", threadID), - zap.String("turn_id", p.TurnID), - zap.Bool("success", p.Success), - zap.String("error", p.Error)) - - // Only send error event if there's an explicit error message - // (NotifyError handles the case when error details come separately) - if p.Error != "" { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: threadID, - OperationID: p.TurnID, - Error: p.Error, - }) - } - } -} - -// handleTurnDiffUpdated handles turn/diffUpdated notifications. -func (a *Adapter) handleTurnDiffUpdated(params json.RawMessage, threadID string) { - var p codex.TurnDiffUpdatedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse turn diff updated", zap.Error(err)) - return - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: threadID, - OperationID: p.TurnID, - Diff: p.Diff, - }) -} - -// handleTurnPlanUpdated handles turn/planUpdated notifications. -func (a *Adapter) handleTurnPlanUpdated(params json.RawMessage, threadID string) { - var p codex.TurnPlanUpdatedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse turn plan updated", zap.Error(err)) - return - } - entries := make([]PlanEntry, len(p.Plan)) - for i, e := range p.Plan { - entries[i] = PlanEntry{ - Description: e.Description, - Status: e.Status, - } - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypePlan, - SessionID: threadID, - OperationID: p.TurnID, - PlanEntries: entries, - }) -} - -// handleErrorNotification handles error notifications from the agent. -func (a *Adapter) handleErrorNotification(params json.RawMessage, threadID string) { - var p codex.ErrorParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse error notification", zap.Error(err)) - return - } - - // Get recent stderr for error context - var stderrLines []string - a.mu.RLock() - if a.stderrProvider != nil { - stderrLines = a.stderrProvider.GetRecentStderr() - } - a.mu.RUnlock() - - // Try to parse stderr into structured error info - // This handles Codex-specific error formats (e.g., rate limits) - parsedError := ParseCodexStderrLines(stderrLines) - - var parsedMsg string - if parsedError != nil { - parsedMsg = parsedError.Message - } - - a.logger.Debug("received error notification from agent", - zap.String("thread_id", threadID), - zap.Int("code", p.Code), - zap.String("message", p.Message), - zap.String("parsed_message", parsedMsg), - zap.Any("data", p.Data), - zap.Int("stderr_lines", len(stderrLines))) - - // Build error data with all available context - errorData := map[string]any{ - "code": p.Code, - "data": p.Data, - "stderr": stderrLines, - } - - // Include parsed error details if available - if parsedError != nil { - parsed := map[string]any{ - "http_error": parsedError.HTTPError, - } - // Include the full raw JSON (captures all fields from any error type) - if parsedError.RawJSON != nil { - parsed["error_json"] = parsedError.RawJSON - } - errorData["parsed"] = parsed - } - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: threadID, - Error: p.Message, - Text: parsedMsg, // User-friendly parsed message - Data: errorData, - }) -} - -// handleCmdExecOutputDelta handles item/cmdExec/outputDelta notifications. -func (a *Adapter) handleCmdExecOutputDelta(params json.RawMessage, threadID, turnID string) { - var p codex.CommandOutputDeltaParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse command output delta", zap.Error(err)) - return - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: threadID, - OperationID: turnID, - ToolCallID: p.ItemID, - }) -} - -// handleTokenUsageUpdated handles thread/tokenUsage/updated notifications. -func (a *Adapter) handleTokenUsageUpdated(params json.RawMessage, threadID, turnID string) { - var p codex.ThreadTokenUsageUpdatedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse thread token usage updated notification", zap.Error(err)) - return - } - // Extract context window information from the token usage update - if p.TokenUsage == nil || p.TokenUsage.ModelContextWindow <= 0 { - return - } - contextWindowSize := p.TokenUsage.ModelContextWindow - contextWindowUsed := int64(p.TokenUsage.Last.TotalTokens) - - remaining := contextWindowSize - contextWindowUsed - if remaining < 0 { - remaining = 0 - } - efficiency := float64(contextWindowUsed) / float64(contextWindowSize) * 100 - - a.logger.Debug("emitting context window event", - zap.Int64("size", contextWindowSize), - zap.Int64("used", contextWindowUsed), - zap.Int64("remaining", remaining), - zap.Float64("efficiency", efficiency)) - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeContextWindow, - SessionID: threadID, - OperationID: turnID, - ContextWindowSize: contextWindowSize, - ContextWindowUsed: contextWindowUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: efficiency, - }) -} - -// handleContextCompacted handles context/compacted notifications. -func (a *Adapter) handleContextCompacted(params json.RawMessage) { - var p codex.ContextCompactedParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse context compacted notification", zap.Error(err)) - return - } - a.logger.Info("context compacted", - zap.String("thread_id", p.ThreadID), - zap.String("turn_id", p.TurnID)) - // We could emit an event here if we want to notify the frontend about compaction -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_permissions.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_permissions.go deleted file mode 100644 index 673df8f636..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_permissions.go +++ /dev/null @@ -1,155 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/pkg/codex" - "go.uber.org/zap" -) - -// handleRequest processes Codex requests (approval requests) and calls permissionHandler. -func (a *Adapter) handleRequest(id any, method string, params json.RawMessage) { - a.logger.Debug("codex: received request", - zap.Any("id", id), - zap.String("method", method)) - - a.mu.RLock() - handler := a.permissionHandler - a.mu.RUnlock() - - switch method { - case codex.NotifyItemCmdExecRequestApproval: - var p codex.CommandApprovalParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse command approval request", zap.Error(err)) - if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil { - a.logger.Warn("failed to send invalid params response", zap.Error(err)) - } - return - } - a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeCommand, p.Command, map[string]any{ - "command": p.Command, - "cwd": p.Cwd, - "reasoning": p.Reasoning, - }, p.Options) - - case codex.NotifyItemFileChangeRequestApproval: - var p codex.FileChangeApprovalParams - if err := json.Unmarshal(params, &p); err != nil { - a.logger.Warn("failed to parse file change approval request", zap.Error(err)) - if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil { - a.logger.Warn("failed to send invalid params response", zap.Error(err)) - } - return - } - a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeFileWrite, p.Path, map[string]any{ - "path": p.Path, - "diff": p.Diff, - "reasoning": p.Reasoning, - }, p.Options) - - default: - a.logger.Warn("unhandled request", zap.String("method", method)) - if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.MethodNotFound, Message: "method not found"}); err != nil { - a.logger.Warn("failed to send method not found response", zap.Error(err)) - } - } -} - -// handleApprovalRequest handles permission request logic for both command and file change approvals. -func (a *Adapter) handleApprovalRequest( - id any, - handler PermissionHandler, - threadID string, - itemID string, - actionType string, - title string, - details map[string]any, - optionStrings []string, -) { - req := &PermissionRequest{ - SessionID: threadID, - ToolCallID: itemID, - Title: title, - Options: buildPermissionOptions(optionStrings), - ActionType: actionType, - ActionDetails: details, - } - - if handler == nil { - // Auto-approve if no handler - if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ - Decision: decisionAccept, - }, nil); err != nil { - a.logger.Warn("failed to send approval response", zap.Error(err)) - } - return - } - - ctx := context.Background() - resp, err := handler(ctx, req) - if err != nil { - a.logger.Error("permission handler error", zap.Error(err)) - if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ - Decision: decisionDecline, - }, nil); err != nil { - a.logger.Warn("failed to send decline response", zap.Error(err)) - } - return - } - - decision := mapResponseToDecision(resp) - if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{ - Decision: decision, - }, nil); err != nil { - a.logger.Warn("failed to send approval response", zap.Error(err)) - } -} - -// buildPermissionOptions converts Codex option strings to PermissionOption slice. -// Falls back to default approve/reject options when no options are provided. -func buildPermissionOptions(optionStrings []string) []PermissionOption { - if len(optionStrings) == 0 { - return []PermissionOption{ - {OptionID: "approve", Name: "Approve", Kind: "allow_once"}, - {OptionID: "reject", Name: "Reject", Kind: "reject_once"}, - } - } - options := make([]PermissionOption, len(optionStrings)) - for i, opt := range optionStrings { - kind := "allow_once" - switch opt { - case "approveAlways": - kind = "allow_always" - case "reject": - kind = "reject_once" - } - options[i] = PermissionOption{OptionID: opt, Name: opt, Kind: kind} - } - return options -} - -// mapResponseToDecision maps a PermissionResponse to a Codex decision string. -// Codex accepts: "accept", "acceptForSession", "decline", "cancel". -func mapResponseToDecision(resp *PermissionResponse) string { - if resp.Cancelled { - return decisionCancel - } - switch resp.OptionID { - case "approve", "allow", decisionAccept: - return decisionAccept - case "approveAlways", "allowAlways", decisionAcceptSession: - return decisionAcceptSession - case "reject", "deny", decisionDecline: - return decisionDecline - case decisionCancel: - return decisionCancel - default: - if resp.OptionID != "" { - return resp.OptionID - } - return decisionAccept - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go deleted file mode 100644 index 34e42d79aa..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_prompt.go +++ /dev/null @@ -1,198 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" - v1 "github.com/kandev/kandev/pkg/api/v1" - "github.com/kandev/kandev/pkg/codex" - "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" -) - -// Prompt sends a prompt to the agent, starting a new turn. -// This method blocks until the turn completes (turn/completed notification received). -func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { - a.mu.Lock() - client := a.client - threadID := a.threadID - // Reset accumulators for new turn - a.messageBuffer = "" - a.reasoningBuffer = "" - a.currentReasoningItemID = "" - // Create channel to wait for turn completion - a.turnCompleteCh = make(chan turnCompleteResult, 1) - a.mu.Unlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - inputs, err := a.buildPromptInputs(message, attachments) - if err != nil { - return err - } - - // Start prompt span — notification spans become children via getPromptTraceCtx() - promptCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolCodex, a.agentID, "prompt") - promptSpan.SetAttributes( - attribute.String("session_id", threadID), - attribute.Int("prompt_inputs", len(inputs)), - ) - a.setPromptTraceCtx(promptCtx) - defer func() { - a.clearPromptTraceCtx() - promptSpan.End() - }() - - a.logger.Info("sending prompt", - zap.String("thread_id", threadID), - zap.Int("inputs", len(inputs)), - zap.Int("image_attachments", len(attachments))) - - turnID, completeCh, err := a.startTurn(ctx, client, threadID, inputs) - if err != nil { - return err - } - - return a.waitForTurnCompletion(ctx, threadID, turnID, completeCh) -} - -// buildPromptInputs constructs the list of UserInput items from message text and attachments. -func (a *Adapter) buildPromptInputs(message string, attachments []v1.MessageAttachment) ([]codex.UserInput, error) { - inputs := make([]codex.UserInput, 0, 1+len(attachments)) - if strings.TrimSpace(message) != "" { - inputs = append(inputs, codex.UserInput{Type: "text", Text: message}) - } - - if len(attachments) > 0 { - saved, err := a.attachMgr.SaveAttachments(attachments) - if err != nil { - a.logger.Warn("failed to save attachments", zap.Error(err)) - } else { - var nonImageSaved []shared.SavedAttachment - for _, s := range saved { - if s.Type == "image" { - // Codex supports localImage input type with absolute path - inputs = append(inputs, codex.UserInput{Type: "localImage", Path: s.AbsPath}) - } else { - nonImageSaved = append(nonImageSaved, s) - } - } - // Non-image files are referenced in prompt text - if prompt := shared.BuildAttachmentPrompt(nonImageSaved); prompt != "" { - inputs = append([]codex.UserInput{{Type: "text", Text: prompt}}, inputs...) - } - } - } - - if len(inputs) == 0 { - return nil, fmt.Errorf("prompt requires message text or attachments") - } - return inputs, nil -} - -// startTurn calls turn/start and returns the turn ID and completion channel. -func (a *Adapter) startTurn(ctx context.Context, client *codex.Client, threadID string, inputs []codex.UserInput) (string, chan turnCompleteResult, error) { - params := &codex.TurnStartParams{ - ThreadID: threadID, - Input: inputs, - } - - resp, err := client.Call(ctx, codex.MethodTurnStart, params) - if err != nil { - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - return "", nil, fmt.Errorf("failed to start turn: %w", err) - } - - if resp.Error != nil { - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - return "", nil, fmt.Errorf("turn start error: %s", resp.Error.Message) - } - - var result codex.TurnStartResult - if err := json.Unmarshal(resp.Result, &result); err != nil { - a.logger.Warn("failed to parse turn start result", zap.Error(err), zap.String("raw", string(resp.Result))) - } - - turnID := "" - if result.Turn != nil { - turnID = result.Turn.ID - } - - a.mu.Lock() - a.turnID = turnID - completeCh := a.turnCompleteCh - a.mu.Unlock() - - if result.Turn != nil { - a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID), zap.String("status", result.Turn.Status)) - } else { - a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID)) - } - - return turnID, completeCh, nil -} - -// waitForTurnCompletion blocks until the turn completes or context is cancelled. -func (a *Adapter) waitForTurnCompletion(ctx context.Context, threadID, turnID string, completeCh chan turnCompleteResult) error { - select { - case <-ctx.Done(): - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - return ctx.Err() - case completeResult := <-completeCh: - a.mu.Lock() - a.turnCompleteCh = nil - a.mu.Unlock() - if !completeResult.success && completeResult.err != "" { - return fmt.Errorf("turn failed: %s", completeResult.err) - } - a.logger.Info("turn completed", zap.String("turn_id", turnID), zap.Bool("success", completeResult.success)) - - // Clean up saved attachments — agent has finished reading them - a.attachMgr.Cleanup() - - // Emit complete event via the stream. - // This normalizes Codex behavior to match other adapters. - // All adapters now emit complete events, eliminating the need for protocol-specific flags. - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: threadID, - OperationID: turnID, - }) - - return nil - } -} - -// Cancel interrupts the current turn. -func (a *Adapter) Cancel(ctx context.Context) error { - a.mu.RLock() - client := a.client - threadID := a.threadID - turnID := a.turnID - a.mu.RUnlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - a.logger.Info("cancelling turn", zap.String("thread_id", threadID), zap.String("turn_id", turnID)) - - // Codex uses turn/interrupt to cancel - _, err := client.Call(ctx, codex.MethodTurnInterrupt, map[string]string{ - "threadId": threadID, - "turnId": turnID, - }) - return err -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_session.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_session.go deleted file mode 100644 index b25e82b707..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/codex_session.go +++ /dev/null @@ -1,122 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/pkg/codex" - "go.uber.org/zap" -) - -// NewSession creates a new Codex thread (session). -// Note: The mcpServers parameter is ignored because Codex reads MCP configuration from -// ~/.codex/config.toml at startup time, not through the protocol. MCP servers are written -// to the config file by PrepareEnvironment() before the Codex process starts. -func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { - // Check client under lock, but don't hold lock during Call() to avoid deadlock - // with handleNotification which also needs the lock - a.mu.RLock() - client := a.client - a.mu.RUnlock() - - if client == nil { - return "", fmt.Errorf("adapter not initialized") - } - - // Determine approval policy - default to "untrusted" if not specified. - // "untrusted" forces Codex to request approval for all commands/writes. - // Other options: "on-failure", "on-request", "never" - approvalPolicy := a.cfg.ApprovalPolicy - if approvalPolicy == "" { - approvalPolicy = "untrusted" - } - - a.logger.Info("starting codex thread with approval policy", - zap.String("approval_policy", approvalPolicy), - zap.String("work_dir", a.cfg.WorkDir)) - - resp, err := client.Call(ctx, codex.MethodThreadStart, &codex.ThreadStartParams{ - Cwd: a.cfg.WorkDir, - ApprovalPolicy: approvalPolicy, // "untrusted", "on-failure", "on-request", "never" - SandboxPolicy: &codex.SandboxPolicy{ - Type: "workspace-write", // Sandbox to workspace only (kebab-case per Codex docs) - WritableRoots: []string{a.cfg.WorkDir}, // Allow writing to workspace - NetworkAccess: true, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to start thread: %w", err) - } - if resp.Error != nil { - return "", fmt.Errorf("thread start error: %s", resp.Error.Message) - } - - var result codex.ThreadStartResult - if err := json.Unmarshal(resp.Result, &result); err != nil { - return "", fmt.Errorf("failed to parse thread start result: %w", err) - } - - a.mu.Lock() - a.threadID = result.Thread.ID - a.mu.Unlock() - a.attachMgr.SetSessionID(result.Thread.ID) - - a.logger.Info("created new thread", zap.String("thread_id", a.threadID)) - - return a.threadID, nil -} - -// LoadSession resumes an existing Codex thread. -// It passes the same approval policy and sandbox settings as NewSession to ensure -// permission requirements are preserved across resume (see openai/codex#5322). -func (a *Adapter) LoadSession(ctx context.Context, sessionID string, _ []types.McpServer) error { - // Check client under lock, but don't hold lock during Call() to avoid deadlock - // with handleNotification which also needs the lock - a.mu.RLock() - client := a.client - a.mu.RUnlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - // Determine approval policy - same logic as NewSession - // "untrusted" forces Codex to request approval for all commands/writes. - approvalPolicy := a.cfg.ApprovalPolicy - if approvalPolicy == "" { - approvalPolicy = "untrusted" - } - - a.logger.Info("resuming codex thread with approval policy", - zap.String("thread_id", sessionID), - zap.String("approval_policy", approvalPolicy), - zap.String("work_dir", a.cfg.WorkDir)) - - resp, err := client.Call(ctx, codex.MethodThreadResume, &codex.ThreadResumeParams{ - ThreadID: sessionID, - Cwd: a.cfg.WorkDir, - ApprovalPolicy: approvalPolicy, - SandboxPolicy: &codex.SandboxPolicy{ - Type: "workspace-write", // kebab-case per Codex docs - WritableRoots: []string{a.cfg.WorkDir}, - NetworkAccess: true, - }, - }) - if err != nil { - return fmt.Errorf("failed to resume thread: %w", err) - } - if resp.Error != nil { - return fmt.Errorf("thread resume error: %s", resp.Error.Message) - } - - a.mu.Lock() - a.threadID = sessionID - a.mu.Unlock() - a.attachMgr.SetSessionID(sessionID) - - a.logger.Info("resumed thread", zap.String("thread_id", a.threadID)) - - return nil -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go deleted file mode 100644 index 471b7fa223..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/errors.go +++ /dev/null @@ -1,176 +0,0 @@ -package codex - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - "time" -) - -// codexErrorRegex matches Codex error log format: -// TIMESTAMP ERROR module: error=HTTP_ERROR: Some("JSON") -var codexErrorRegex = regexp.MustCompile(`error=(.+?):\s*Some\("(.+)"\)\s*$`) - -// codexLogErrorRegex matches general Codex ERROR log lines: -// TIMESTAMP ERROR module::submodule: Human-readable error message -var codexLogErrorRegex = regexp.MustCompile(`\S+\s+ERROR\s+\S+:\s+(.+)$`) - -// CodexParsedError contains the parsed error information from Codex stderr. -type CodexParsedError struct { - // Message is the user-friendly error message - Message string - // HTTPError is the HTTP error string (e.g., "http 429 Too Many Requests") - HTTPError string - // RawJSON contains all fields from the error JSON (generic, captures any structure) - RawJSON map[string]any - // ErrorType is the error type from the JSON (e.g., "usage_limit_reached") - may be empty - ErrorType string - // ResetsInSeconds is the number of seconds until the limit resets - may be 0 - ResetsInSeconds int64 -} - -// extractErrorFields extracts errorType, errorMessage, and resetsInSeconds -// from a parsed JSON map, checking both nested "error" object and top-level fields. -func extractErrorFields(rawData map[string]any) (errorType, errorMessage string, resetsInSeconds int64) { - // First, check for nested error object (standard format) - if errObj, ok := rawData["error"].(map[string]any); ok { - if t, ok := errObj["type"].(string); ok { - errorType = t - } - if m, ok := errObj["message"].(string); ok { - errorMessage = m - } - // JSON numbers are float64 in Go - if r, ok := errObj["resets_in_seconds"].(float64); ok { - resetsInSeconds = int64(r) - } - } - - // Fall back to top-level fields if nested fields not found (flat error format) - if errorMessage == "" { - if m, ok := rawData["message"].(string); ok { - errorMessage = m - } - } - if errorType == "" { - if t, ok := rawData["type"].(string); ok { - errorType = t - } - } - return errorType, errorMessage, resetsInSeconds -} - -// appendResetTime appends a human-readable reset duration to msg when resetsInSeconds > 0. -func appendResetTime(msg string, resetsInSeconds int64) string { - if resetsInSeconds <= 0 { - return msg - } - duration := time.Duration(resetsInSeconds) * time.Second - switch { - case duration.Hours() >= 1: - return fmt.Sprintf("%s (resets in %.0f hours)", msg, duration.Hours()) - case duration.Minutes() >= 1: - return fmt.Sprintf("%s (resets in %.0f minutes)", msg, duration.Minutes()) - default: - return fmt.Sprintf("%s (resets in %d seconds)", msg, int(duration.Seconds())) - } -} - -// buildErrorMessage constructs a user-friendly message from the parsed error fields. -func buildErrorMessage(errorMessage, errorType, httpError string, resetsInSeconds int64, rawData map[string]any) string { - switch { - case errorMessage != "": - return appendResetTime(errorMessage, resetsInSeconds) - case errorType != "": - return fmt.Sprintf("Error: %s", errorType) - default: - jsonBytes, _ := json.MarshalIndent(rawData, "", " ") - return fmt.Sprintf("%s\n\n%s", httpError, string(jsonBytes)) - } -} - -// ParseCodexStderrError attempts to parse a Codex stderr error line and extract -// error information. Returns nil if parsing fails. -// -// Example input: -// -// 2026-01-23T22:57:08.953223Z ERROR codex_api::endpoint::responses: error=http 429 Too Many Requests: Some("{\"error\":{...}}") -func ParseCodexStderrError(line string) *CodexParsedError { - matches := codexErrorRegex.FindStringSubmatch(line) - if len(matches) < 3 { - return nil - } - - httpError := strings.TrimSpace(matches[1]) - jsonStr := matches[2] - - // Unescape the JSON string (it's double-escaped in the log) - unescaped := strings.ReplaceAll(jsonStr, `\"`, `"`) - unescaped = strings.ReplaceAll(unescaped, `\\`, `\`) - - result := &CodexParsedError{ - HTTPError: httpError, - } - - // Try to parse the JSON into a generic map first (captures all fields) - var rawData map[string]any - if err := json.Unmarshal([]byte(unescaped), &rawData); err != nil { - // Couldn't parse JSON at all, just return HTTP error - result.Message = httpError - return result - } - - result.RawJSON = rawData - - errorType, errorMessage, resetsInSeconds := extractErrorFields(rawData) - result.ErrorType = errorType - result.ResetsInSeconds = resetsInSeconds - result.Message = buildErrorMessage(errorMessage, errorType, httpError, resetsInSeconds, rawData) - return result -} - -// ParseCodexLogError attempts to extract an error message from a general -// Codex ERROR log line (e.g., auth failures). Returns empty string if -// the line doesn't match or contains unhelpful content (JSON fragments). -// -// Example input: -// -// 2026-03-02T16:17:50Z ERROR codex_core::auth: Failed to refresh token: Please log out and sign in again. -func ParseCodexLogError(line string) string { - matches := codexLogErrorRegex.FindStringSubmatch(line) - if len(matches) < 2 { - return "" - } - msg := strings.TrimSpace(matches[1]) - // Skip JSON fragments from multi-line error output - if msg == "" || strings.HasPrefix(msg, "{") || strings.HasPrefix(msg, "}") || strings.HasPrefix(msg, "\"") { - return "" - } - // Skip lines that end with '{' (start of multi-line JSON block) - if strings.HasSuffix(msg, "{") { - return "" - } - return msg -} - -// ParseCodexStderrLines attempts to parse Codex stderr lines and extract -// error information. Searches from the end (most recent) first. -// First tries the structured error=...Some("...") format, then falls back -// to general ERROR log lines (e.g., auth failures). -// Returns nil if no parseable error is found. -func ParseCodexStderrLines(lines []string) *CodexParsedError { - // First pass: try structured error format (rate limits, API errors) - for i := len(lines) - 1; i >= 0; i-- { - if parsed := ParseCodexStderrError(lines[i]); parsed != nil { - return parsed - } - } - // Second pass: try general ERROR log lines (auth errors, etc.) - for i := len(lines) - 1; i >= 0; i-- { - if msg := ParseCodexLogError(lines[i]); msg != "" { - return &CodexParsedError{Message: msg} - } - } - return nil -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/errors_test.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/errors_test.go deleted file mode 100644 index c6e1033285..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/errors_test.go +++ /dev/null @@ -1,367 +0,0 @@ -package codex - -import ( - "strings" - "testing" -) - -func TestParseCodexStderrError(t *testing.T) { - tests := []struct { - name string - input string - wantNil bool - wantHTTPError string - wantErrorType string - wantMsgContains string - wantRawJSON bool - }{ - { - name: "no match - empty string", - input: "", - wantNil: true, - }, - { - name: "no match - regular log line", - input: "2026-01-23T22:57:08.953223Z INFO some_module: doing something", - wantNil: true, - }, - { - name: "no match - error without Some pattern", - input: "2026-01-23T22:57:08.953223Z ERROR some_module: error=something went wrong", - wantNil: true, - }, - { - name: "rate limit error - full format", - input: `2026-01-23T22:57:08.953223Z ERROR codex_api::endpoint::responses: error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"The usage limit has been reached\",\"resets_in_seconds\":57600}}")`, - wantNil: false, - wantHTTPError: "http 429 Too Many Requests", - wantErrorType: "usage_limit_reached", - wantMsgContains: "The usage limit has been reached", - wantRawJSON: true, - }, - { - name: "rate limit error - with reset time in hours", - input: `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"Limit reached\",\"resets_in_seconds\":7200}}")`, - wantNil: false, - wantHTTPError: "http 429 Too Many Requests", - wantErrorType: "usage_limit_reached", - wantMsgContains: "resets in 2 hours", - wantRawJSON: true, - }, - { - name: "rate limit error - with reset time in minutes", - input: `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"Limit reached\",\"resets_in_seconds\":300}}")`, - wantNil: false, - wantHTTPError: "http 429 Too Many Requests", - wantMsgContains: "resets in 5 minutes", - wantRawJSON: true, - }, - { - name: "rate limit error - with reset time in seconds", - input: `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"message\":\"Limit reached\",\"resets_in_seconds\":45}}")`, - wantNil: false, - wantHTTPError: "http 429 Too Many Requests", - wantMsgContains: "resets in 45 seconds", - wantRawJSON: true, - }, - { - name: "auth error", - input: `error=http 401 Unauthorized: Some("{\"error\":{\"type\":\"invalid_api_key\",\"message\":\"Invalid API key provided\"}}")`, - wantNil: false, - wantHTTPError: "http 401 Unauthorized", - wantErrorType: "invalid_api_key", - wantMsgContains: "Invalid API key provided", - wantRawJSON: true, - }, - { - name: "error with type but no message", - input: `error=http 400 Bad Request: Some("{\"error\":{\"type\":\"invalid_request\"}}")`, - wantNil: false, - wantHTTPError: "http 400 Bad Request", - wantErrorType: "invalid_request", - wantMsgContains: "Error: invalid_request", - wantRawJSON: true, - }, - { - name: "flat error structure - message at top level", - input: `error=http 500 Internal Server Error: Some("{\"message\":\"Something went wrong\",\"code\":500}")`, - wantNil: false, - wantHTTPError: "http 500 Internal Server Error", - wantMsgContains: "Something went wrong", - wantRawJSON: true, - }, - { - name: "unknown JSON structure", - input: `error=http 502 Bad Gateway: Some("{\"status\":\"error\",\"details\":{\"reason\":\"upstream timeout\"}}")`, - wantNil: false, - wantHTTPError: "http 502 Bad Gateway", - wantMsgContains: "http 502 Bad Gateway", - wantRawJSON: true, - }, - { - name: "invalid JSON - still returns HTTP error", - input: `error=http 500 Internal Server Error: Some("not valid json")`, - wantNil: false, - wantHTTPError: "http 500 Internal Server Error", - wantMsgContains: "http 500 Internal Server Error", - wantRawJSON: false, - }, - { - name: "empty JSON object", - input: `error=http 500 Internal Server Error: Some("{}")`, - wantNil: false, - wantHTTPError: "http 500 Internal Server Error", - wantMsgContains: "http 500 Internal Server Error", - wantRawJSON: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ParseCodexStderrError(tt.input) - - if tt.wantNil { - if result != nil { - t.Errorf("expected nil, got %+v", result) - } - return - } - - if result == nil { - t.Fatal("expected non-nil result, got nil") - } else { - if result.HTTPError != tt.wantHTTPError { - t.Errorf("HTTPError = %q, want %q", result.HTTPError, tt.wantHTTPError) - } - - if tt.wantErrorType != "" && result.ErrorType != tt.wantErrorType { - t.Errorf("ErrorType = %q, want %q", result.ErrorType, tt.wantErrorType) - } - - if !strings.Contains(result.Message, tt.wantMsgContains) { - t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains) - } - } - - if tt.wantRawJSON && result.RawJSON == nil { - t.Error("expected RawJSON to be non-nil") - } - if !tt.wantRawJSON && result.RawJSON != nil { - t.Error("expected RawJSON to be nil") - } - }) - } -} - -func TestParseCodexStderrLines(t *testing.T) { - tests := []struct { - name string - lines []string - wantNil bool - wantMsgContains string - }{ - { - name: "empty lines", - lines: []string{}, - wantNil: true, - }, - { - name: "nil lines", - lines: nil, - wantNil: true, - }, - { - name: "no error lines", - lines: []string{ - "2026-01-23T22:57:08Z INFO starting up", - "2026-01-23T22:57:09Z DEBUG processing request", - }, - wantNil: true, - }, - { - name: "error at end - returns most recent", - lines: []string{ - "2026-01-23T22:57:08Z INFO starting up", - `error=http 429 Too Many Requests: Some("{\"error\":{\"message\":\"Rate limited\"}}")`, - }, - wantNil: false, - wantMsgContains: "Rate limited", - }, - { - name: "error in middle - returns it", - lines: []string{ - `error=http 500 Server Error: Some("{\"error\":{\"message\":\"Server error\"}}")`, - "2026-01-23T22:57:09Z INFO recovered", - }, - wantNil: false, - wantMsgContains: "Server error", - }, - { - name: "multiple errors - returns most recent (last)", - lines: []string{ - `error=http 500 Server Error: Some("{\"error\":{\"message\":\"First error\"}}")`, - "2026-01-23T22:57:09Z INFO retrying", - `error=http 429 Rate Limited: Some("{\"error\":{\"message\":\"Second error\"}}")`, - }, - wantNil: false, - wantMsgContains: "Second error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ParseCodexStderrLines(tt.lines) - - if tt.wantNil { - if result != nil { - t.Errorf("expected nil, got %+v", result) - } - return - } - - if result == nil { - t.Fatal("expected non-nil result, got nil") - } else if !strings.Contains(result.Message, tt.wantMsgContains) { - t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains) - } - }) - } -} - -func TestParseCodexLogError(t *testing.T) { - tests := []struct { - name string - input string - wantMsg string - }{ - { - name: "empty string", - input: "", - wantMsg: "", - }, - { - name: "INFO line - no match", - input: "2026-03-02T16:17:50Z INFO codex_core::auth: Starting up", - wantMsg: "", - }, - { - name: "DEBUG line - no match", - input: "2026-03-02T16:17:50Z DEBUG codex_core::auth: Debug info", - wantMsg: "", - }, - { - name: "auth error - human readable", - input: "2026-03-02T16:17:50.825663Z ERROR codex_core::auth: Failed to refresh token: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.", - wantMsg: "Failed to refresh token: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.", - }, - { - name: "models manager error", - input: "2026-03-02T16:17:51.031854Z ERROR codex_core::models_manager::manager: failed to refresh available models: unexpected status 401 Unauthorized", - wantMsg: "failed to refresh available models: unexpected status 401 Unauthorized", - }, - { - name: "multi-line JSON start - skipped", - input: "2026-03-02T16:17:50.825623Z ERROR codex_core::auth: Failed to refresh token: 401 Unauthorized: {", - wantMsg: "", - }, - { - name: "JSON fragment starting with brace - skipped", - input: `2026-03-02T16:17:50Z ERROR codex_core::auth: {"error": "something"}`, - wantMsg: "", - }, - { - name: "JSON fragment starting with quote - skipped", - input: `2026-03-02T16:17:50Z ERROR codex_core::auth: "message": "something"`, - wantMsg: "", - }, - { - name: "closing brace - skipped", - input: `2026-03-02T16:17:50Z ERROR codex_core::auth: }`, - wantMsg: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ParseCodexLogError(tt.input) - if result != tt.wantMsg { - t.Errorf("ParseCodexLogError() = %q, want %q", result, tt.wantMsg) - } - }) - } -} - -func TestParseCodexStderrLines_LogErrorFallback(t *testing.T) { - tests := []struct { - name string - lines []string - wantNil bool - wantMsgContains string - }{ - { - name: "auth error lines - falls back to log format", - lines: []string{ - "npm warn Unknown env config", - "2026-03-02T16:17:50.825623Z ERROR codex_core::auth: Failed to refresh token: 401 Unauthorized: {", - ` "error": {`, - ` "message": "Your refresh token has already been used"`, - " }", - "}", - "2026-03-02T16:17:50.825663Z ERROR codex_core::auth: Failed to refresh token: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.", - }, - wantNil: false, - wantMsgContains: "Please log out and sign in again", - }, - { - name: "structured error takes priority over log error", - lines: []string{ - "2026-03-02T16:17:50Z ERROR codex_core::auth: Some auth error message", - `error=http 429 Too Many Requests: Some("{\"error\":{\"message\":\"Rate limited\"}}")`, - }, - wantNil: false, - wantMsgContains: "Rate limited", - }, - { - name: "only JSON fragments - no match", - lines: []string{ - "2026-03-02T16:17:50Z ERROR codex_core::auth: Failed to refresh token: 401 Unauthorized: {", - ` "error": {`, - ` "message": "something"`, - " }", - "}", - }, - wantNil: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ParseCodexStderrLines(tt.lines) - - if tt.wantNil { - if result != nil { - t.Errorf("expected nil, got %+v", result) - } - return - } - - if result == nil { - t.Fatal("expected non-nil result, got nil") - } else if !strings.Contains(result.Message, tt.wantMsgContains) { - t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains) - } - }) - } -} - -func TestParseCodexStderrError_ResetsInSeconds(t *testing.T) { - // Test that ResetsInSeconds is correctly extracted - input := `error=http 429 Too Many Requests: Some("{\"error\":{\"type\":\"usage_limit_reached\",\"resets_in_seconds\":3600}}")` - - result := ParseCodexStderrError(input) - if result == nil { - t.Fatal("expected non-nil result") - } else if result.ResetsInSeconds != 3600 { - t.Errorf("ResetsInSeconds = %d, want 3600", result.ResetsInSeconds) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go deleted file mode 100644 index e13f655bc6..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/normalize.go +++ /dev/null @@ -1,169 +0,0 @@ -package codex - -import ( - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" -) - -// Codex item type constants -const ( - CodexItemCommandExecution = "commandExecution" - CodexItemFileChange = "fileChange" - CodexItemReasoning = "reasoning" - CodexItemUserMessage = "userMessage" - CodexItemAgentMessage = "agentMessage" - CodexItemMcpToolCall = "mcpToolCall" -) - -// Normalizer converts Codex protocol tool data to NormalizedPayload. -type Normalizer struct{} - -// NewNormalizer creates a new Codex normalizer. -func NewNormalizer() *Normalizer { - return &Normalizer{} -} - -// NormalizeToolCall converts Codex tool call data to NormalizedPayload. -// Codex uses item types rather than explicit tool names. -func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { - // Codex toolName is actually the item type - switch toolName { - case CodexItemCommandExecution: - return n.normalizeCommand(args) - case CodexItemFileChange: - return n.normalizeFileChange(args) - case CodexItemMcpToolCall: - return n.normalizeMcpToolCall(args) - default: - return n.normalizeGeneric(toolName, args) - } -} - -// NormalizeToolResult updates the payload with tool result data. -func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { - switch payload.Kind() { - case streams.ToolKindShellExec: - if payload.ShellExec() != nil { - n.normalizeCommandResult(payload.ShellExec(), result) - } - case streams.ToolKindModifyFile: - // File changes are typically completed with a diff in the update - if payload.ModifyFile() != nil && len(payload.ModifyFile().Mutations) > 0 { - if diffStr, ok := result.(string); ok && diffStr != "" { - payload.ModifyFile().Mutations[0].Diff = diffStr - } - } - case streams.ToolKindGeneric: - if payload.Generic() != nil { - payload.Generic().Output = result - } - } -} - -// normalizeCommand converts Codex commandExecution item data. -func (n *Normalizer) normalizeCommand(args map[string]any) *streams.NormalizedPayload { - command := shared.GetString(args, "command") - workDir := shared.GetString(args, "cwd") - - // Use factory function - return streams.NewShellExec(command, workDir, "", 0, false) -} - -// normalizeFileChange converts Codex fileChange item data. -func (n *Normalizer) normalizeFileChange(args map[string]any) *streams.NormalizedPayload { - // Codex sends changes as an array in the item - changes, _ := args["changes"].([]any) - - var filePath string - var mutations []streams.FileMutation - - for _, change := range changes { - changeMap, ok := change.(map[string]any) - if !ok { - continue - } - - path := shared.GetString(changeMap, "path") - if filePath == "" { - filePath = path - } - - mutation := streams.FileMutation{ - Type: streams.MutationPatch, - } - - // Extract diff if available - if diff, ok := changeMap["diff"].(string); ok { - mutation.Diff = diff - } - - mutations = append(mutations, mutation) - } - - // If no changes array, try single file fields - if len(mutations) == 0 { - filePath = shared.GetString(args, "path") - mutations = append(mutations, streams.FileMutation{ - Type: streams.MutationPatch, - }) - } - - // Use factory function - return streams.NewModifyFile(filePath, mutations) -} - -// normalizeMcpToolCall converts Codex mcpToolCall item data. -// MCP tool calls have server, tool, arguments, result, and error fields. -func (n *Normalizer) normalizeMcpToolCall(args map[string]any) *streams.NormalizedPayload { - server := shared.GetString(args, "server") - tool := shared.GetString(args, "tool") - - // Create a generic payload with the MCP tool info - // The tool name is formatted as "server/tool" for display - toolName := tool - if server != "" { - toolName = server + "/" + tool - } - - // Include the arguments in the payload for display - displayArgs := map[string]any{ - "server": server, - "tool": tool, - } - if arguments, ok := args["arguments"]; ok { - displayArgs["arguments"] = arguments - } - if result, ok := args["result"]; ok { - displayArgs["result"] = result - } - if toolError, ok := args["error"]; ok { - displayArgs["error"] = toolError - } - - return streams.NewGeneric(toolName, displayArgs) -} - -// normalizeGeneric wraps unknown items as generic. -func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { - // Use factory function - return streams.NewGeneric(toolName, args) -} - -// normalizeCommandResult updates command payload with result data. -func (n *Normalizer) normalizeCommandResult(payload *streams.ShellExecPayload, result any) { - if payload.Output == nil { - payload.Output = &streams.ShellExecOutput{} - } - - switch r := result.(type) { - case string: - payload.Output.Stdout = r - case map[string]any: - if output, ok := r["aggregatedOutput"].(string); ok { - payload.Output.Stdout = output - } - if status, ok := r["status"].(string); ok && status == "failed" { - payload.Output.ExitCode = 1 - } - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go deleted file mode 100644 index 12bcc07a91..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go +++ /dev/null @@ -1,1335 +0,0 @@ -// Package opencode implements the OpenCode transport adapter. -// OpenCode uses a REST/SSE protocol over HTTP, spawning its own HTTP server. -package opencode - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/common/logger" - v1 "github.com/kandev/kandev/pkg/api/v1" - "github.com/kandev/kandev/pkg/opencode" - "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" -) - -// Re-export types needed by external packages -type ( - PermissionRequest = types.PermissionRequest - PermissionResponse = types.PermissionResponse - PermissionOption = streams.PermissionOption - PermissionHandler = types.PermissionHandler - AgentEvent = streams.AgentEvent -) - -// AgentInfo contains information about the connected agent. -type AgentInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -// Adapter implements the transport adapter for OpenCode. -// OpenCode spawns an HTTP server and communicates via REST API calls and SSE for events. -type Adapter struct { - cfg *shared.Config - logger *logger.Logger - - // Agent identity (from config, for logging) - agentID string - - // Normalizer for converting tool data to NormalizedPayload - normalizer *Normalizer - - // Subprocess stdout (for parsing server URL) - stdout io.Reader - - // HTTP client (created after server starts) - client *opencode.Client - - // Server configuration - serverURL string - password string - directory string - - // Context for managing goroutine lifecycle - ctx context.Context - cancel context.CancelFunc - - // Session state - sessionID string - operationID string - - // Agent info - agentInfo *AgentInfo - - // Update channel - updatesCh chan AgentEvent - - // Permission handler - permissionHandler PermissionHandler - - // Pending permission requests - pendingPermissions map[string]*permissionRequest - - // Result completion signaling - resultCh chan resultComplete - - // Model selection (parsed from AGENT_MODEL env var, format: "provider/model") - model *opencode.ModelSpec - - // Token tracking - totalTokens int - modelContextWindow int - - // Tool call deduplication - track which tool calls we've seen to avoid duplicates - seenToolCalls map[string]bool - - // Text part tracking - track text parts by part ID to compute incremental text - textParts map[string]*textPartState - - // Message role tracking - track message roles (user/assistant) by messageID - // Used to filter out user messages from being processed as agent output - messageRoles map[string]string - - // lastRawData holds the raw JSON of the current event being processed. - // Set in handleSDKEvent before dispatch; used by sendUpdate for OTel tracing. - lastRawData json.RawMessage - - // OTel tracing: active prompt span context. - // Notification spans become children of the prompt span for visual grouping. - promptTraceCtx context.Context - promptTraceMu sync.RWMutex - - // Attachment management - attachMgr *shared.AttachmentManager - - // Synchronization - mu sync.RWMutex - closed bool -} - -// textPartState tracks the state of a text part for incremental text computation -type textPartState struct { - lastTextLen int // Length of text we've already sent -} - -// permissionRequest tracks a pending permission -type permissionRequest struct { - requestID string - toolCallID string - toolName string -} - -// resultComplete holds the result of a completed prompt -// Note: Fields are currently unused but kept for future implementation -type resultComplete struct { - _ bool // success placeholder - _ string // err placeholder -} - -// NewAdapter creates a new OpenCode protocol adapter. -func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { - ctx, cancel := context.WithCancel(context.Background()) - agentID := cfg.AgentID - if agentID == "" { - agentID = "opencode" // Fallback for unknown agents using this protocol - } - // Parse model from AGENT_MODEL env var (format: "provider/model") - var modelSpec *opencode.ModelSpec - if modelStr := os.Getenv("AGENT_MODEL"); modelStr != "" { - modelSpec = parseModelSpec(modelStr) - log.Info("parsed model from AGENT_MODEL", zap.String("model", modelStr)) - } - - l := log.WithFields(zap.String("adapter", "opencode"), zap.String("agent_id", agentID)) - return &Adapter{ - cfg: cfg, - logger: l, - agentID: agentID, - normalizer: NewNormalizer(), - model: modelSpec, - ctx: ctx, - cancel: cancel, - updatesCh: make(chan AgentEvent, 100), - pendingPermissions: make(map[string]*permissionRequest), - seenToolCalls: make(map[string]bool), - password: opencode.GenerateServerPassword(), - directory: cfg.WorkDir, - modelContextWindow: 200000, // Default - attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()), - } -} - -// PrepareEnvironment performs protocol-specific setup before the agent process starts. -// For OpenCode, we write MCP servers to ~/.config/opencode/opencode.json and return -// environment variables for server authentication. -func (a *Adapter) PrepareEnvironment() (map[string]string, error) { - a.logger.Info("PrepareEnvironment called", - zap.Int("mcp_server_count", len(a.cfg.McpServers))) - for i, srv := range a.cfg.McpServers { - a.logger.Info("MCP server config", - zap.Int("index", i), - zap.String("name", srv.Name), - zap.String("url", srv.URL), - zap.String("type", srv.Type), - zap.String("command", srv.Command)) - } - - // Write MCP servers to OpenCode config file - if err := WriteOpenCodeMcpConfig(a.cfg.McpServers, "", a.logger); err != nil { - a.logger.Warn("failed to write OpenCode MCP config", zap.Error(err)) - // Continue - MCP servers are optional - } - - env := map[string]string{ - "OPENCODE_SERVER_PASSWORD": a.password, - } - - // Set up permissions based on config with per-permission rules - env["OPENCODE_PERMISSION"] = buildPermissionJSON(a.cfg.AutoApprove) - - // Inject runtime config for auto-compaction - env["OPENCODE_CONFIG_CONTENT"] = `{"compaction":{"auto":true}}` - - return env, nil -} - -// buildPermissionJSON builds the OPENCODE_PERMISSION JSON value. -func buildPermissionJSON(autoApprove bool) string { - if autoApprove { - return `{"rules":[{"permission":"bash","grant":"always"},{"permission":"write","grant":"always"},{"permission":"read","grant":"always"},{"permission":"edit","grant":"always"},{"permission":"glob","grant":"always"},{"permission":"grep","grant":"always"},{"permission":"webfetch","grant":"always"}],"question":"deny"}` - } - return `{"rules":[{"permission":"bash","grant":"ask"},{"permission":"write","grant":"ask"},{"permission":"read","grant":"ask"},{"permission":"edit","grant":"ask"}],"question":"deny"}` -} - -// PrepareCommandArgs returns extra command-line arguments for the agent process. -// For OpenCode, no extra args are needed - MCP is configured via config file. -func (a *Adapter) PrepareCommandArgs() []string { - return nil -} - -// Connect wires up the stdout pipe from the running agent subprocess. -// For OpenCode, we only need stdout to parse the server URL. -func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.stdout != nil { - return fmt.Errorf("adapter already connected") - } - - a.stdout = stdout - return nil -} - -// Initialize establishes the OpenCode connection by waiting for the server URL. -func (a *Adapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing OpenCode adapter", - zap.String("workdir", a.cfg.WorkDir)) - - // Wait for server to print listening URL - serverURL, err := a.waitForServerURL(ctx) - if err != nil { - return fmt.Errorf("failed to get server URL: %w", err) - } - - a.mu.Lock() - a.serverURL = serverURL - a.mu.Unlock() - - a.logger.Info("OpenCode server started", zap.String("url", serverURL)) - - // Create HTTP client - a.client = opencode.NewClient(serverURL, a.directory, a.password, a.logger) - - // Set up event handler - a.client.SetEventHandler(a.handleSDKEvent) - - // Wait for server to be healthy - healthCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - if err := a.client.WaitForHealth(healthCtx); err != nil { - return fmt.Errorf("server health check failed: %w", err) - } - - // Store agent info - a.agentInfo = &AgentInfo{ - Name: a.agentID, - Version: "unknown", - } - - // Fetch providers and commands in the background — don't block initialization - go a.fetchPostInitData() - - a.logger.Info("OpenCode adapter initialized") - - return nil -} - -// waitForServerURL reads stdout until we find the server URL -func (a *Adapter) waitForServerURL(ctx context.Context) (string, error) { - deadline := time.Now().Add(180 * time.Second) - - scanner := bufio.NewScanner(a.stdout) - var capturedLines []string - - for time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return "", ctx.Err() - default: - } - - // Try to scan with a short timeout - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("read stdout: %w", err) - } - // EOF - server exited - tail := strings.Join(capturedLines, "\n") - return "", fmt.Errorf("server exited before printing URL\nOutput:\n%s", tail) - } - - line := scanner.Text() - - // Capture for error reporting - if len(capturedLines) < 64 { - capturedLines = append(capturedLines, line) - } - - // Check for the listening URL - if url, found := strings.CutPrefix(line, "opencode server listening on "); found { - url = strings.TrimSpace(url) - a.logger.Info("found server URL", zap.String("url", url)) - - // Continue draining stdout in background - go func() { - for scanner.Scan() { - // Just drain - } - }() - - return url, nil - } - } - - tail := strings.Join(capturedLines[max(0, len(capturedLines)-12):], "\n") - return "", fmt.Errorf("timeout waiting for server URL\nOutput tail:\n%s", tail) -} - -// GetAgentInfo returns information about the connected agent. -func (a *Adapter) GetAgentInfo() *AgentInfo { - return a.agentInfo -} - -// NewSession creates a new OpenCode session. -func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { - // Clear any stale state from previous sessions - a.clearSessionState() - - sessionID, err := a.client.CreateSession(ctx) - if err != nil { - return "", fmt.Errorf("create session: %w", err) - } - - a.mu.Lock() - a.sessionID = sessionID - a.mu.Unlock() - a.attachMgr.SetSessionID(sessionID) - - a.logger.Info("created new session", zap.String("session_id", sessionID)) - - // Emit session status event - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeSessionStatus, - SessionID: sessionID, - SessionStatus: streams.SessionStatusNew, - Data: map[string]any{ - "session_status": streams.SessionStatusNew, - "init": true, - }, - }) - - return sessionID, nil -} - -// LoadSession resumes an existing OpenCode session by forking it. -func (a *Adapter) LoadSession(ctx context.Context, sessionID string, _ []types.McpServer) error { - // Clear any stale state from previous sessions - a.clearSessionState() - - // Fork the session to create a new one with the same history - newSessionID, err := a.client.ForkSession(ctx, sessionID) - if err != nil { - return fmt.Errorf("fork session: %w", err) - } - - a.mu.Lock() - a.sessionID = newSessionID - a.mu.Unlock() - a.attachMgr.SetSessionID(newSessionID) - - a.logger.Info("loaded session (forked)", - zap.String("original_session_id", sessionID), - zap.String("new_session_id", newSessionID)) - - // Emit session status event for resumed session - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeSessionStatus, - SessionID: newSessionID, - SessionStatus: streams.SessionStatusResumed, - Data: map[string]any{ - "session_status": streams.SessionStatusResumed, - "init": true, - }, - }) - - return nil -} - -// Prompt sends a prompt to OpenCode and waits for completion. -func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { - a.mu.Lock() - client := a.client - sessionID := a.sessionID - operationID := uuid.New().String() - a.operationID = operationID - // Create channel to wait for result - a.resultCh = make(chan resultComplete, 1) - a.mu.Unlock() - - // Save attachments to workspace and prepend references to message - finalMessage := message - if len(attachments) > 0 { - saved, err := a.attachMgr.SaveAttachments(attachments) - if err != nil { - a.logger.Warn("failed to save attachments", zap.Error(err)) - } else if len(saved) > 0 { - finalMessage = shared.BuildAttachmentPrompt(saved) + message - } - } - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - // Start prompt span — notification spans become children via getPromptTraceCtx() - promptCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolOpenCode, a.agentID, "prompt") - promptSpan.SetAttributes( - attribute.String("session_id", sessionID), - attribute.Int("prompt_length", len(finalMessage)), - ) - a.setPromptTraceCtx(promptCtx) - defer func() { - a.clearPromptTraceCtx() - promptSpan.End() - a.attachMgr.Cleanup() - }() - - a.logger.Info("sending prompt", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID)) - - // Start event stream before sending prompt - if err := client.StartEventStream(a.ctx, sessionID); err != nil { - a.logger.Warn("failed to start event stream", zap.Error(err)) - } - - // Send prompt with model selection (blocks until OpenCode finishes) - if err := client.SendPrompt(ctx, sessionID, finalMessage, a.model, "", ""); err != nil { - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return fmt.Errorf("failed to send prompt: %w", err) - } - - a.logger.Info("prompt HTTP request completed, waiting for SSE idle signal", - zap.String("session_id", sessionID)) - - return a.waitForPromptCompletion(ctx, client, sessionID) -} - -// waitForPromptCompletion blocks until the prompt completes via the SSE control channel. -func (a *Adapter) waitForPromptCompletion(ctx context.Context, client *opencode.Client, sessionID string) error { - controlCh := client.ControlChannel() - - for { - select { - case <-ctx.Done(): - _ = a.Cancel(context.Background()) - return ctx.Err() - - case control, ok := <-controlCh: - if !ok { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: sessionID, - }) - return nil - } - - switch control.Type { - case "idle": - a.logger.Info("session idle, prompt complete") - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: sessionID, - }) - return nil - - case "auth_required": - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: sessionID, - Error: fmt.Sprintf("Authentication required: %s", control.Message), - }) - return fmt.Errorf("auth required: %s", control.Message) - - case "session_error": - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: sessionID, - Error: control.Message, - }) - // Continue - might recover - - case "disconnected": - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: sessionID, - }) - return nil - } - } - } -} - -// Cancel cancels the current operation. -func (a *Adapter) Cancel(ctx context.Context) error { - a.mu.RLock() - client := a.client - sessionID := a.sessionID - a.mu.RUnlock() - - if client == nil { - return nil - } - - a.logger.Info("cancelling operation", zap.String("session_id", sessionID)) - - return client.Abort(ctx, sessionID) -} - -// Updates returns a channel that receives agent events. -func (a *Adapter) Updates() <-chan AgentEvent { - return a.updatesCh -} - -// GetSessionID returns the current session ID. -func (a *Adapter) GetSessionID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.sessionID -} - -// GetOperationID returns the current operation/turn ID. -func (a *Adapter) GetOperationID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.operationID -} - -// SetPermissionHandler sets the handler for permission requests. -func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { - a.mu.Lock() - defer a.mu.Unlock() - a.permissionHandler = handler -} - -// Close releases resources held by the adapter. -func (a *Adapter) Close() error { - a.logger.Info("OpenCode adapter Close() called") - - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - a.logger.Debug("OpenCode adapter already closed") - return nil - } - a.closed = true - - // Clean up any saved attachments - a.attachMgr.Cleanup() - - a.logger.Debug("OpenCode adapter: cancelling context") - a.cancel() - - a.logger.Debug("OpenCode adapter: closing HTTP client") - if a.client != nil { - a.client.Close() - } - - a.logger.Debug("OpenCode adapter: closing updates channel") - close(a.updatesCh) - - a.logger.Info("OpenCode adapter closed successfully") - return nil -} - -// RequiresProcessKill returns true because OpenCode runs as an HTTP server -// and does not exit when stdin is closed. -func (a *Adapter) RequiresProcessKill() bool { - return true -} - -// clearSessionState clears session-specific tracking state. -// This should be called when starting a new session to prevent memory leaks -// and stale data from previous sessions. -func (a *Adapter) clearSessionState() { - a.mu.Lock() - defer a.mu.Unlock() - - a.textParts = make(map[string]*textPartState) - a.messageRoles = make(map[string]string) - a.seenToolCalls = make(map[string]bool) -} - -// sendUpdate sends an event to the updates channel -func (a *Adapter) sendUpdate(event AgentEvent) { - a.mu.RLock() - closed := a.closed - a.mu.RUnlock() - - if closed { - return - } - - shared.LogNormalizedEvent(shared.ProtocolOpenCode, a.agentID, &event) - shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolOpenCode, a.agentID, - event.Type, a.lastRawData, &event) - select { - case a.updatesCh <- event: - default: - a.logger.Warn("update channel full, dropping event", - zap.String("event_type", event.Type)) - } -} - -// getPromptTraceCtx returns the current prompt span context for child-span linking. -// Returns context.Background() if no prompt is active. -func (a *Adapter) getPromptTraceCtx() context.Context { - a.promptTraceMu.RLock() - defer a.promptTraceMu.RUnlock() - if a.promptTraceCtx != nil { - return a.promptTraceCtx - } - return context.Background() -} - -// setPromptTraceCtx stores the prompt span context. -func (a *Adapter) setPromptTraceCtx(ctx context.Context) { - a.promptTraceMu.Lock() - defer a.promptTraceMu.Unlock() - a.promptTraceCtx = ctx -} - -// clearPromptTraceCtx clears the prompt span context. -func (a *Adapter) clearPromptTraceCtx() { - a.promptTraceMu.Lock() - defer a.promptTraceMu.Unlock() - a.promptTraceCtx = nil -} - -// handleSDKEvent processes events from the OpenCode SSE stream -func (a *Adapter) handleSDKEvent(event *opencode.SDKEventEnvelope) { - // Store raw data for tracing and log for debugging - a.lastRawData = event.Properties - shared.LogRawEvent(shared.ProtocolOpenCode, a.agentID, event.Type, event.Properties) - - a.mu.RLock() - sessionID := a.sessionID - operationID := a.operationID - a.mu.RUnlock() - - switch event.Type { - case opencode.SDKEventMessagePartUpdated: - a.handleMessagePartUpdated(event.Properties, sessionID, operationID) - - case opencode.SDKEventMessageUpdated: - a.handleMessageUpdated(event.Properties, sessionID) - - case opencode.SDKEventPermissionAsked: - a.handlePermissionAsked(event.Properties, sessionID) - - case opencode.SDKEventSessionCompacted: - a.handleSessionCompacted(event.Properties, sessionID) - - case opencode.SDKEventTodoUpdated: - // Log but don't emit — todo tracking is informational - a.logger.Debug("todo.updated event received") - - case opencode.SDKEventMessageRemoved, opencode.SDKEventMessagePartRemoved: - // Log message/part removal — these occur during compaction - a.logger.Debug("message removal event", zap.String("event_type", event.Type)) - - case opencode.SDKEventSessionError: - props, err := opencode.ParseSessionError(event.Properties) - if err != nil { - return - } - if props.Error != nil { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: sessionID, - Error: props.Error.GetMessage(), - }) - } - } -} - -// resolvePartID returns the part ID to use, falling back to a composite key. -func resolvePartID(partID, messageID, suffix string) string { - if partID != "" { - return partID - } - return messageID + ":" + suffix -} - -// getOrCreateTextPartState fetches or initializes the textPartState for a given part ID. -// Must be called with a.mu.Lock() held. -func (a *Adapter) getOrCreateTextPartState(partID string) *textPartState { - if a.textParts == nil { - a.textParts = make(map[string]*textPartState) - } - state := a.textParts[partID] - if state == nil { - state = &textPartState{lastTextLen: 0} - a.textParts[partID] = state - } - return state -} - -// computeIncrementalText returns the new text to emit, advancing the state. -// Must be called with a.mu.Lock() held (state is modified in place). -func computeIncrementalText(state *textPartState, cumulativeText, delta string) string { - if cumulativeText != "" { - if len(cumulativeText) > state.lastTextLen { - text := cumulativeText[state.lastTextLen:] - state.lastTextLen = len(cumulativeText) - return text - } - return "" - } - // No cumulative text available: use delta only for first chunk - if delta != "" && state.lastTextLen == 0 { - return delta - } - return "" -} - -// handleTextPart processes a text part update, returning any new text to emit. -func (a *Adapter) handleTextPart(part opencode.Part, delta string) string { - partID := resolvePartID(part.ID, part.MessageID, "text") - - a.mu.Lock() - state := a.getOrCreateTextPartState(partID) - textToSend := computeIncrementalText(state, part.Text, delta) - a.mu.Unlock() - - return textToSend -} - -// handleReasoningPart processes a reasoning part update, returning any new text to emit. -func (a *Adapter) handleReasoningPart(part opencode.Part, delta string) string { - partID := resolvePartID(part.ID, part.MessageID, "reasoning") - - a.mu.Lock() - state := a.getOrCreateTextPartState(partID) - textToSend := computeIncrementalText(state, part.Text, delta) - a.mu.Unlock() - - return textToSend -} - -// handleToolPart processes a tool part update and emits the appropriate event. -func (a *Adapter) handleToolPart(part opencode.Part, sessionID, operationID string) { - if part.State == nil { - return - } - - toolCallID := part.CallID - toolName := part.Tool - state := part.State - - // Determine status - status := normalizeToolStatus(state.Status) - - // Build ToolState for normalization (do this first so we can use input for title) - toolState := buildToolState(state) - - // Generate normalized payload - normalizedPayload := a.normalizer.NormalizeToolCall(toolName, toolState) - - // Determine title: prefer derived title (command/path), then state.Title, fallback to toolName - title := deriveToolTitle(toolName, toolState.Input) - if title == "" { - title = state.Title - } - if title == "" { - title = toolName - } - - // Track if we've seen this tool call before - a.mu.Lock() - if a.seenToolCalls == nil { - a.seenToolCalls = make(map[string]bool) - } - isFirstEvent := !a.seenToolCalls[toolCallID] - a.seenToolCalls[toolCallID] = true - a.mu.Unlock() - - // Emit tool_call for the first event, tool_update for subsequent events - if isFirstEvent { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolName: toolName, - ToolTitle: title, - ToolStatus: status, - NormalizedPayload: normalizedPayload, - }) - } else { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolCallID, - ToolName: toolName, - ToolTitle: title, - ToolStatus: status, - Error: state.Error, - NormalizedPayload: normalizedPayload, - }) - } -} - -// normalizeToolStatus maps OpenCode tool status values to frontend-compatible strings. -func normalizeToolStatus(status string) string { - switch status { - case opencode.ToolStatusPending: - return "pending" - case opencode.ToolStatusRunning: - return "running" - case opencode.ToolStatusCompleted: - return "complete" // Normalized to "complete" for frontend consistency - case opencode.ToolStatusError: - return "error" - default: - return status - } -} - -// buildToolState constructs a ToolState from OpenCode's tool part state. -func buildToolState(state *opencode.ToolStateUpdate) *ToolState { - toolState := &ToolState{ - Output: state.Output, - Title: state.Title, - } - if state.Input != nil { - toolState.Input = make(map[string]any) - _ = json.Unmarshal(state.Input, &toolState.Input) // Ignore error - input is optional - } - if state.Metadata != nil { - toolState.Metadata = make(map[string]any) - _ = json.Unmarshal(state.Metadata, &toolState.Metadata) // Ignore error - metadata is optional - } - return toolState -} - -// handleMessagePartUpdated processes message.part.updated events -func (a *Adapter) handleMessagePartUpdated(props json.RawMessage, sessionID, operationID string) { - parsed, err := opencode.ParseMessagePartUpdated(props) - if err != nil { - a.logger.Warn("failed to parse message.part.updated", zap.Error(err)) - return - } - - part := parsed.Part - - // Filter out user messages - only process assistant messages - // The role is tracked from message.updated events - if part.MessageID != "" { - a.mu.RLock() - role := a.messageRoles[part.MessageID] - a.mu.RUnlock() - if role == "user" { - // Skip user message parts - we don't want to echo user input as agent output - return - } - } - - switch part.Type { - case opencode.PartTypeText: - if textToSend := a.handleTextPart(part, parsed.Delta); textToSend != "" { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: textToSend, - }) - } - - case opencode.PartTypeReasoning: - if textToSend := a.handleReasoningPart(part, parsed.Delta); textToSend != "" { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeReasoning, - SessionID: sessionID, - OperationID: operationID, - ReasoningText: textToSend, - }) - } - - case opencode.PartTypeTool: - a.handleToolPart(part, sessionID, operationID) - } -} - -// deriveToolTitle derives a user-friendly title from tool input. -// Returns the most relevant field for display (e.g., command for bash, path for read). -func deriveToolTitle(toolName string, input map[string]any) string { - if input == nil { - return "" - } - - switch toolName { - case OpenCodeToolBash: - // For bash, use the command - if cmd, ok := input["command"].(string); ok && cmd != "" { - return cmd - } - case OpenCodeToolGlob, OpenCodeToolGrep: - // For search tools, use the pattern - if pattern, ok := input["pattern"].(string); ok && pattern != "" { - return pattern - } - case OpenCodeToolRead, OpenCodeToolEdit: - // For file tools, use the path - if path, ok := input["path"].(string); ok && path != "" { - return path - } - if path, ok := input["file_path"].(string); ok && path != "" { - return path - } - } - - return "" -} - -// handleSessionCompacted processes session.compacted events from auto-compaction. -func (a *Adapter) handleSessionCompacted(props json.RawMessage, sessionID string) { - a.logger.Info("session compacted", zap.String("session_id", sessionID)) - - // Reset token tracking since compaction changes the context window usage - a.mu.Lock() - a.totalTokens = 0 - // Clear text part state since message IDs may have changed - a.textParts = make(map[string]*textPartState) - a.messageRoles = make(map[string]string) - a.mu.Unlock() - - // Emit a context window update showing reset usage - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeContextWindow, - SessionID: sessionID, - ContextWindowUsed: 0, - ContextWindowSize: int64(a.modelContextWindow), - }) -} - -// handleMessageUpdated processes message.updated events (for token tracking) -func (a *Adapter) handleMessageUpdated(props json.RawMessage, sessionID string) { - parsed, err := opencode.ParseMessageUpdated(props) - if err != nil { - return - } - - info := parsed.Info - - // Track message role (user vs assistant) for filtering in handleMessagePartUpdated - if info.ID != "" && info.Role != "" { - a.mu.Lock() - if a.messageRoles == nil { - a.messageRoles = make(map[string]string) - } - a.messageRoles[info.ID] = info.Role - a.mu.Unlock() - } - - if info.Tokens == nil { - return - } - - // Calculate total tokens - totalTokens := info.Tokens.Input + info.Tokens.Output - if info.Tokens.Cache != nil { - totalTokens += info.Tokens.Cache.Read - } - - a.mu.Lock() - a.totalTokens = totalTokens - a.mu.Unlock() - - // Emit context window event - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeContextWindow, - SessionID: sessionID, - ContextWindowUsed: int64(totalTokens), - ContextWindowSize: int64(a.modelContextWindow), - }) -} - -// handlePermissionAsked processes permission.asked events -func (a *Adapter) handlePermissionAsked(props json.RawMessage, sessionID string) { - parsed, err := opencode.ParsePermissionAsked(props) - if err != nil { - a.logger.Warn("failed to parse permission.asked", zap.Error(err)) - return - } - - requestID := parsed.ID - permission := parsed.Permission - toolCallID := requestID - if parsed.Tool != nil { - toolCallID = parsed.Tool.CallID - } - - a.mu.RLock() - handler := a.permissionHandler - autoApprove := a.cfg.AutoApprove - a.mu.RUnlock() - - // If auto-approve is enabled, immediately approve - if autoApprove { - go func() { - if err := a.client.ReplyPermission(a.ctx, requestID, opencode.PermissionReplyOnce, nil); err != nil { - a.logger.Warn("failed to auto-approve permission", zap.Error(err)) - } - }() - return - } - - // Store pending permission - a.mu.Lock() - a.pendingPermissions[requestID] = &permissionRequest{ - requestID: requestID, - toolCallID: toolCallID, - toolName: permission, - } - a.mu.Unlock() - - // Format title from metadata if available - title := formatPermissionTitle(permission, parsed.Metadata) - - // If we have a handler, call it to get user approval. - // The handler (process manager's handlePermissionRequest) will: - // 1. Send the permission_request notification to the frontend - // 2. Block waiting for user response - // 3. Return the response - // We pass PendingID so the handler uses OpenCode's ID (e.g., "per_xxx") - // instead of generating a new one - this ensures the frontend and backend - // use the same ID for response lookup. - if handler != nil { - go func() { - req := &PermissionRequest{ - SessionID: sessionID, - ToolCallID: toolCallID, - Title: title, - ActionType: permission, - ActionDetails: parsed.Metadata, - PendingID: requestID, // Use OpenCode's ID so response lookup works - Options: []PermissionOption{ - {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, - {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, - }, - } - resp, err := handler(a.ctx, req) - if err != nil { - a.logger.Warn("permission handler error", zap.Error(err)) - return - } - if resp == nil { - return - } - - // Respond based on handler result - approved := resp.OptionID == "allow" - if err := a.RespondToPermission(a.ctx, requestID, approved, ""); err != nil { - a.logger.Warn("failed to respond to permission", zap.Error(err)) - } - }() - } -} - -// parseModelSpec parses a "providerID/modelID" string into a ModelSpec. -// If no "/" is found, the whole string is used as modelID with empty providerID. -func parseModelSpec(model string) *opencode.ModelSpec { - if idx := strings.Index(model, "/"); idx > 0 { - return &opencode.ModelSpec{ - ProviderID: model[:idx], - ModelID: model[idx+1:], - } - } - return &opencode.ModelSpec{ - ModelID: model, - } -} - -// formatPermissionTitle builds a display title for a permission request. -func formatPermissionTitle(permission string, metadata map[string]any) string { - if metadata != nil { - if cmd, ok := metadata["command"].(string); ok { - return fmt.Sprintf("Execute: %s", cmd) - } - if path, ok := metadata["path"].(string); ok { - return fmt.Sprintf("%s: %s", permission, path) - } - } - return fmt.Sprintf("Permission: %s", permission) -} - -// RespondToPermission responds to a permission request (called via handler) -func (a *Adapter) RespondToPermission(ctx context.Context, requestID string, approved bool, message string) error { - a.mu.Lock() - delete(a.pendingPermissions, requestID) - a.mu.Unlock() - - reply := opencode.PermissionReplyOnce - var msg *string - - if !approved { - reply = opencode.PermissionReplyReject - if message != "" { - msg = &message - } - } - - return a.client.ReplyPermission(ctx, requestID, reply, msg) -} - -// fetchPostInitData fetches providers and commands after initialization. -// Runs in a goroutine with a timeout so it never blocks the main flow. -func (a *Adapter) fetchPostInitData() { - ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second) - defer cancel() - - a.fetchContextWindow(ctx) - a.discoverCommands(ctx) -} - -// fetchContextWindow queries the /provider endpoint and updates the model context window. -func (a *Adapter) fetchContextWindow(ctx context.Context) { - providers, err := a.client.ListProviders(ctx) - if err != nil { - a.logger.Debug("failed to fetch providers for context window", zap.Error(err)) - return - } - - // Look up the context window for our configured model - a.mu.RLock() - model := a.model - a.mu.RUnlock() - - if model == nil { - return - } - - for _, p := range providers.All { - if p.ID != model.ProviderID { - continue - } - if modelInfo, ok := p.Models[model.ModelID]; ok && modelInfo.Limit.Context > 0 { - a.mu.Lock() - a.modelContextWindow = modelInfo.Limit.Context - a.mu.Unlock() - a.logger.Info("updated context window from provider", - zap.String("provider", model.ProviderID), - zap.String("model", model.ModelID), - zap.Int("context_window", modelInfo.Limit.Context)) - return - } - } -} - -// discoverCommands queries the /command endpoint and emits available commands. -func (a *Adapter) discoverCommands(ctx context.Context) { - commands, err := a.client.ListCommands(ctx) - if err != nil { - a.logger.Debug("failed to discover commands", zap.Error(err)) - return - } - - if len(commands) == 0 { - return - } - - a.mu.RLock() - sessionID := a.sessionID - a.mu.RUnlock() - - available := make([]streams.AvailableCommand, 0, len(commands)) - for _, cmd := range commands { - ac := streams.AvailableCommand{ - Name: cmd.Name, - Description: cmd.Description, - } - if cmd.Input != nil { - ac.InputHint = cmd.Input.Hint - } - available = append(available, ac) - } - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeAvailableCommands, - SessionID: sessionID, - AvailableCommands: available, - }) - - a.logger.Info("discovered slash commands", zap.Int("count", len(available))) -} - -// openCodeConfigPath returns the path to the OpenCode config file. -func openCodeConfigPath(homeDir string) (string, string, error) { - if homeDir == "" { - var err error - homeDir, err = os.UserHomeDir() - if err != nil { - return "", "", fmt.Errorf("failed to get user home directory: %w", err) - } - } - configDir := filepath.Join(homeDir, ".config", "opencode") - configPath := filepath.Join(configDir, "opencode.json") - return configDir, configPath, nil -} - -// loadOpenCodeConfig reads and parses the existing OpenCode config file. -// Returns an empty map if the file doesn't exist. -func loadOpenCodeConfig(configPath string, log *logger.Logger) (map[string]interface{}, error) { - existingData, err := os.ReadFile(configPath) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read existing opencode config: %w", err) - } - - var rawConfig map[string]interface{} - if len(existingData) == 0 { - return make(map[string]interface{}), nil - } - - if err := json.Unmarshal(existingData, &rawConfig); err != nil { - if log != nil { - log.Warn("failed to parse existing opencode config, will create new", - zap.String("path", configPath), - zap.Error(err)) - } - return make(map[string]interface{}), nil - } - return rawConfig, nil -} - -// buildMCPServerConfig builds the OpenCode server config entry for a single MCP server. -func buildMCPServerConfig(server shared.McpServerConfig) map[string]interface{} { - serverConfig := make(map[string]interface{}) - - if server.Type == "sse" || server.Type == "http" || server.Type == "remote" { - // Remote transport - use url field - serverConfig["type"] = "remote" - if server.URL != "" { - serverConfig["url"] = server.URL - } - } else { - // Local/stdio transport - use command and args - serverConfig["type"] = "local" - if server.Command != "" { - // OpenCode expects command as an array: ["command", "arg1", "arg2"] - cmdArray := []string{server.Command} - cmdArray = append(cmdArray, server.Args...) - serverConfig["command"] = cmdArray - } - } - - serverConfig["enabled"] = true - return serverConfig -} - -// WriteOpenCodeMcpConfig writes MCP server configuration to OpenCode's config file. -// It merges with existing config, preserving other settings and existing MCP servers. -// OpenCode reads MCP servers from ~/.config/opencode/opencode.json at startup time. -// This function should be called before starting the OpenCode process. -// If homeDir is empty, it uses os.UserHomeDir(). -func WriteOpenCodeMcpConfig(mcpServers []shared.McpServerConfig, homeDir string, log *logger.Logger) error { - if len(mcpServers) == 0 { - return nil // Nothing to configure - } - - configDir, configPath, err := openCodeConfigPath(homeDir) - if err != nil { - return err - } - - // Create config directory if it doesn't exist - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("failed to create opencode config directory: %w", err) - } - - rawConfig, err := loadOpenCodeConfig(configPath, log) - if err != nil { - return err - } - - // Get or create mcp section - mcpSection, ok := rawConfig["mcp"].(map[string]interface{}) - if !ok { - mcpSection = make(map[string]interface{}) - } - - // Add/update our MCP servers - for _, server := range mcpServers { - mcpSection[server.Name] = buildMCPServerConfig(server) - } - - rawConfig["mcp"] = mcpSection - - // Marshal back to JSON with indentation - output, err := json.MarshalIndent(rawConfig, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal opencode config: %w", err) - } - - // Write config file - if err := os.WriteFile(configPath, output, 0644); err != nil { - return fmt.Errorf("failed to write opencode config: %w", err) - } - - if log != nil { - log.Info("wrote opencode MCP config", - zap.String("path", configPath), - zap.Int("server_count", len(mcpServers))) - } - - return nil -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go deleted file mode 100644 index e72c183a7b..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize.go +++ /dev/null @@ -1,347 +0,0 @@ -package opencode - -import ( - "strings" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" -) - -// OpenCode part type constants -const ( - OpenCodePartText = "text" - OpenCodePartReasoning = "reasoning" - OpenCodePartTool = "tool" -) - -// OpenCode tool name constants -const ( - OpenCodeToolBash = "bash" - OpenCodeToolEdit = "edit" - OpenCodeToolWebFetch = "webfetch" - OpenCodeToolGlob = "glob" - OpenCodeToolGrep = "grep" - OpenCodeToolRead = "read" -) - -// Normalizer converts OpenCode protocol tool data to NormalizedPayload. -type Normalizer struct{} - -// NewNormalizer creates a new OpenCode normalizer. -func NewNormalizer() *Normalizer { - return &Normalizer{} -} - -// ToolState contains the full tool state for normalization -type ToolState struct { - Input map[string]any - Output string - Metadata map[string]any - Title string -} - -// NormalizeToolCall converts OpenCode tool call data to NormalizedPayload. -func (n *Normalizer) NormalizeToolCall(toolName string, state *ToolState) *streams.NormalizedPayload { - if state == nil { - state = &ToolState{} - } - if state.Input == nil { - state.Input = make(map[string]any) - } - - // OpenCode tool names are typically lowercase - switch toolName { - case OpenCodeToolBash: - return n.normalizeBash(state) - case OpenCodeToolEdit: - return n.normalizeEdit(state) - case OpenCodeToolWebFetch: - return n.normalizeWebFetch(state) - case OpenCodeToolGlob: - return n.normalizeGlob(state) - case OpenCodeToolGrep: - return n.normalizeGrep(state) - case OpenCodeToolRead: - return n.normalizeRead(state) - default: - return n.normalizeGeneric(toolName, state) - } -} - -// NormalizeToolResult updates the payload with tool result data. -// -// Deprecated: Result data is now handled in NormalizeToolCall via ToolState -func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { - // Keep for backwards compatibility but normalization now happens in NormalizeToolCall - switch payload.Kind() { - case streams.ToolKindShellExec: - if payload.ShellExec() != nil && payload.ShellExec().Output == nil { - n.normalizeBashResult(payload.ShellExec(), result) - } - case streams.ToolKindHttpRequest: - if payload.HttpRequest() != nil { - if r, ok := result.(string); ok { - payload.HttpRequest().Response = r - } - } - case streams.ToolKindGeneric: - if payload.Generic() != nil && payload.Generic().Output == nil { - payload.Generic().Output = result - } - } -} - -// normalizeBash converts OpenCode bash tool data. -func (n *Normalizer) normalizeBash(state *ToolState) *streams.NormalizedPayload { - command := shared.GetString(state.Input, "command") - description := shared.GetString(state.Input, "description") - timeout := shared.GetInt(state.Input, "timeout") - background := shared.GetBool(state.Input, "background") - - // Use factory function - payload := streams.NewShellExec(command, "", description, timeout, background) - - // Add output if available - if state.Output != "" || state.Metadata != nil { - payload.ShellExec().Output = &streams.ShellExecOutput{ - Stdout: state.Output, - } - // Extract exit code from metadata - if state.Metadata != nil { - if exitCode, ok := state.Metadata["exit"].(float64); ok { - payload.ShellExec().Output.ExitCode = int(exitCode) - } - } - } - - return payload -} - -// normalizeEdit converts OpenCode edit tool data. -func (n *Normalizer) normalizeEdit(state *ToolState) *streams.NormalizedPayload { - // OpenCode edit args might be in "input" or directly in args - input := state.Input - if inputMap, ok := state.Input["input"].(map[string]any); ok { - input = inputMap - } - - filePath := shared.GetString(input, "path") - if filePath == "" { - filePath = shared.GetString(input, "file_path") - } - - // Build mutations - var mutations []streams.FileMutation - if diff, ok := input["diff"].(string); ok && diff != "" { - mutations = append(mutations, streams.FileMutation{ - Type: streams.MutationPatch, - Diff: diff, - }) - } else { - mutations = append(mutations, streams.FileMutation{ - Type: streams.MutationPatch, - }) - } - - // Use factory function - return streams.NewModifyFile(filePath, mutations) -} - -// normalizeWebFetch converts OpenCode webfetch tool data. -func (n *Normalizer) normalizeWebFetch(state *ToolState) *streams.NormalizedPayload { - url := shared.GetString(state.Input, "url") - - // Use factory function - payload := streams.NewHttpRequest(url, "GET") - - // Add response if available - if state.Output != "" { - payload.HttpRequest().Response = state.Output - } - - return payload -} - -// normalizeGlob converts OpenCode glob tool data to code_search. -func (n *Normalizer) normalizeGlob(state *ToolState) *streams.NormalizedPayload { - pattern := shared.GetString(state.Input, "pattern") - path := shared.GetString(state.Input, "path") - - // Use factory function: NewCodeSearch(query, pattern, path, glob) - // For glob, pattern goes to both pattern and glob fields - payload := streams.NewCodeSearch("", pattern, path, pattern) - - // Parse output (newline-separated file list) and metadata - if state.Output == "" && state.Metadata == nil { - return payload - } - - payload.CodeSearch().Output = &streams.CodeSearchOutput{} - applyGlobOutput(payload.CodeSearch().Output, state) - - return payload -} - -// applyGlobOutput populates CodeSearchOutput from glob tool state. -func applyGlobOutput(out *streams.CodeSearchOutput, state *ToolState) { - if state.Output != "" { - out.Files = parseGlobFiles(state.Output) - } - if state.Metadata == nil { - return - } - if count, ok := state.Metadata["count"].(float64); ok { - out.FileCount = int(count) - } - if truncated, ok := state.Metadata["truncated"].(bool); ok { - out.Truncated = truncated - } -} - -// parseGlobFiles splits newline-separated output into a file list, filtering empty lines. -func parseGlobFiles(output string) []string { - lines := strings.Split(strings.TrimSpace(output), "\n") - var files []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" && !strings.HasPrefix(line, "(Results are truncated") { - files = append(files, line) - } - } - return files -} - -// normalizeGrep converts OpenCode grep tool data to code_search. -func (n *Normalizer) normalizeGrep(state *ToolState) *streams.NormalizedPayload { - pattern := shared.GetString(state.Input, "pattern") - path := shared.GetString(state.Input, "path") - query := shared.GetString(state.Input, "query") - - // Use factory function: NewCodeSearch(query, pattern, path, glob) - payload := streams.NewCodeSearch(query, pattern, path, "") - - // Parse output and metadata - if state.Output == "" && state.Metadata == nil { - return payload - } - - payload.CodeSearch().Output = &streams.CodeSearchOutput{} - applyGrepOutput(payload.CodeSearch().Output, state) - - return payload -} - -// applyGrepOutput populates CodeSearchOutput from grep tool state. -func applyGrepOutput(out *streams.CodeSearchOutput, state *ToolState) { - if state.Output != "" { - files := parseGrepFiles(state.Output) - out.Files = files - out.FileCount = len(files) - } - if state.Metadata == nil { - return - } - if truncated, ok := state.Metadata["truncated"].(bool); ok { - out.Truncated = truncated - } -} - -// parseGrepFiles extracts unique file paths from grep output lines. -func parseGrepFiles(output string) []string { - lines := strings.Split(strings.TrimSpace(output), "\n") - fileSet := make(map[string]bool) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // Extract file path (before first colon for grep output) - if idx := strings.Index(line, ":"); idx > 0 { - fileSet[line[:idx]] = true - } else { - fileSet[line] = true - } - } - var files []string - for f := range fileSet { - files = append(files, f) - } - return files -} - -// normalizeRead converts OpenCode read tool data. -func (n *Normalizer) normalizeRead(state *ToolState) *streams.NormalizedPayload { - filePath := shared.GetString(state.Input, "path") - if filePath == "" { - filePath = shared.GetString(state.Input, "file_path") - } - - offset := shared.GetInt(state.Input, "offset") - limit := shared.GetInt(state.Input, "limit") - - // Use factory function - payload := streams.NewReadFile(filePath, offset, limit) - - // Add output if available - if state.Output == "" && state.Metadata == nil { - return payload - } - - payload.ReadFile().Output = &streams.ReadFileOutput{ - Content: state.Output, - } - applyReadMetadata(payload.ReadFile().Output, state) - - return payload -} - -// applyReadMetadata fills ReadFileOutput from read tool metadata and content. -func applyReadMetadata(out *streams.ReadFileOutput, state *ToolState) { - if state.Metadata != nil { - if lineCount, ok := state.Metadata["line_count"].(float64); ok { - out.LineCount = int(lineCount) - } - if truncated, ok := state.Metadata["truncated"].(bool); ok { - out.Truncated = truncated - } - } - - // Calculate line count from content if not in metadata - if out.LineCount == 0 && state.Output != "" { - out.LineCount = strings.Count(state.Output, "\n") + 1 - } -} - -// normalizeGeneric wraps unknown tools as generic. -func (n *Normalizer) normalizeGeneric(toolName string, state *ToolState) *streams.NormalizedPayload { - // Use factory function - payload := streams.NewGeneric(toolName, state.Input) - - // Add output if available - if state.Output != "" { - payload.Generic().Output = state.Output - } - - return payload -} - -// normalizeBashResult updates bash payload with result data. -// -// Deprecated: Use ToolState in NormalizeToolCall instead -func (n *Normalizer) normalizeBashResult(payload *streams.ShellExecPayload, result any) { - if payload.Output == nil { - payload.Output = &streams.ShellExecOutput{} - } - - switch r := result.(type) { - case string: - payload.Output.Stdout = r - case map[string]any: - if output, ok := r["output"].(string); ok { - payload.Output.Stdout = output - } - if errStr, ok := r["error"].(string); ok { - payload.Output.Stderr = errStr - } - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize_test.go deleted file mode 100644 index bbe25a1adc..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/normalize_test.go +++ /dev/null @@ -1,496 +0,0 @@ -package opencode - -import ( - "testing" - - "github.com/kandev/kandev/internal/agentctl/types/streams" -) - -// TestNormalizerBash tests the normalizer's bash handling. -func TestNormalizerBash(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts command, description, timeout, background", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "command": "ls -la", - "description": "List files", - "timeout": float64(30000), - "background": true, - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolBash, state) - if result.Kind() != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind()) - } - if result.ShellExec() == nil { - t.Fatal("expected ShellExec to be set") - } - if result.ShellExec().Command != "ls -la" { - t.Errorf("expected Command 'ls -la', got %q", result.ShellExec().Command) - } - if result.ShellExec().Description != "List files" { - t.Errorf("expected Description 'List files', got %q", result.ShellExec().Description) - } - if result.ShellExec().Timeout != 30000 { - t.Errorf("expected Timeout 30000, got %d", result.ShellExec().Timeout) - } - if !result.ShellExec().Background { - t.Error("expected Background to be true") - } - }) - - t.Run("includes output with exit code from metadata", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "command": "echo hello", - }, - Output: "hello", - Metadata: map[string]any{ - "exit": float64(0), - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolBash, state) - if result.ShellExec().Output == nil { - t.Fatal("expected Output to be set") - } - if result.ShellExec().Output.Stdout != "hello" { - t.Errorf("expected Stdout 'hello', got %q", result.ShellExec().Output.Stdout) - } - if result.ShellExec().Output.ExitCode != 0 { - t.Errorf("expected ExitCode 0, got %d", result.ShellExec().Output.ExitCode) - } - }) - - t.Run("handles nil state gracefully", func(t *testing.T) { - result := normalizer.NormalizeToolCall(OpenCodeToolBash, nil) - if result.Kind() != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind()) - } - }) -} - -// TestNormalizerEdit tests the normalizer's edit handling. -func TestNormalizerEdit(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts path from input directly", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "path": "/workspace/file.ts", - "diff": "@@ -1,1 +1,1 @@\n-old\n+new\n", - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) - if result.Kind() != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind()) - } - if result.ModifyFile() == nil { - t.Fatal("expected ModifyFile to be set") - } - if result.ModifyFile().FilePath != "/workspace/file.ts" { - t.Errorf("expected FilePath '/workspace/file.ts', got %q", result.ModifyFile().FilePath) - } - if len(result.ModifyFile().Mutations) != 1 { - t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) - } - if result.ModifyFile().Mutations[0].Diff != "@@ -1,1 +1,1 @@\n-old\n+new\n" { - t.Error("expected diff to be preserved") - } - }) - - t.Run("extracts path from nested input map", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "input": map[string]any{ - "path": "/workspace/nested.ts", - }, - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) - if result.ModifyFile().FilePath != "/workspace/nested.ts" { - t.Errorf("expected FilePath '/workspace/nested.ts', got %q", result.ModifyFile().FilePath) - } - }) - - t.Run("falls back to file_path key", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "file_path": "/workspace/fallback.ts", - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) - if result.ModifyFile().FilePath != "/workspace/fallback.ts" { - t.Errorf("expected FilePath '/workspace/fallback.ts', got %q", result.ModifyFile().FilePath) - } - }) - - t.Run("creates empty mutation when no diff", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "path": "/workspace/file.ts", - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolEdit, state) - if len(result.ModifyFile().Mutations) != 1 { - t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) - } - if result.ModifyFile().Mutations[0].Type != streams.MutationPatch { - t.Errorf("expected MutationType %q, got %q", streams.MutationPatch, result.ModifyFile().Mutations[0].Type) - } - }) -} - -// TestNormalizerWebFetch tests the normalizer's webfetch handling. -func TestNormalizerWebFetch(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts URL and uses GET method", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "url": "https://example.com/api", - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolWebFetch, state) - if result.Kind() != streams.ToolKindHttpRequest { - t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind()) - } - if result.HttpRequest() == nil { - t.Fatal("expected HttpRequest to be set") - } - if result.HttpRequest().URL != "https://example.com/api" { - t.Errorf("expected URL 'https://example.com/api', got %q", result.HttpRequest().URL) - } - if result.HttpRequest().Method != "GET" { - t.Errorf("expected Method 'GET', got %q", result.HttpRequest().Method) - } - }) - - t.Run("includes response when output available", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "url": "https://example.com", - }, - Output: "Response body content", - } - result := normalizer.NormalizeToolCall(OpenCodeToolWebFetch, state) - if result.HttpRequest().Response != "Response body content" { - t.Errorf("expected Response 'Response body content', got %q", result.HttpRequest().Response) - } - }) -} - -// TestNormalizerGlob tests the normalizer's glob handling. -func TestNormalizerGlob(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts pattern and path", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "pattern": "**/*.ts", - "path": "/workspace", - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) - if result.Kind() != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) - } - if result.CodeSearch() == nil { - t.Fatal("expected CodeSearch to be set") - } - if result.CodeSearch().Glob != "**/*.ts" { - t.Errorf("expected Glob '**/*.ts', got %q", result.CodeSearch().Glob) - } - if result.CodeSearch().Path != "/workspace" { - t.Errorf("expected Path '/workspace', got %q", result.CodeSearch().Path) - } - }) - - t.Run("parses file list from output", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "pattern": "**/*.ts", - }, - Output: "file1.ts\nfile2.ts\nsrc/file3.ts", - } - result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) - if result.CodeSearch().Output == nil { - t.Fatal("expected Output to be set") - } - if len(result.CodeSearch().Output.Files) != 3 { - t.Errorf("expected 3 files, got %d", len(result.CodeSearch().Output.Files)) - } - }) - - t.Run("filters out truncation message from output", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "pattern": "**/*", - }, - Output: "file1.ts\n(Results are truncated...)", - } - result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) - if len(result.CodeSearch().Output.Files) != 1 { - t.Errorf("expected 1 file (truncation msg filtered), got %d", len(result.CodeSearch().Output.Files)) - } - }) - - t.Run("extracts count and truncated from metadata", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "pattern": "**/*", - }, - Output: "file1.ts", - Metadata: map[string]any{ - "count": float64(100), - "truncated": true, - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolGlob, state) - if result.CodeSearch().Output.FileCount != 100 { - t.Errorf("expected FileCount 100, got %d", result.CodeSearch().Output.FileCount) - } - if !result.CodeSearch().Output.Truncated { - t.Error("expected Truncated to be true") - } - }) -} - -// TestNormalizerGrep tests the normalizer's grep handling. -func TestNormalizerGrep(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts pattern, path, and query", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "pattern": "func.*Error", - "path": "/workspace", - "query": "error handling", - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolGrep, state) - if result.Kind() != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) - } - if result.CodeSearch() == nil { - t.Fatal("expected CodeSearch to be set") - } - if result.CodeSearch().Pattern != "func.*Error" { - t.Errorf("expected Pattern 'func.*Error', got %q", result.CodeSearch().Pattern) - } - if result.CodeSearch().Query != "error handling" { - t.Errorf("expected Query 'error handling', got %q", result.CodeSearch().Query) - } - }) - - t.Run("parses grep output with file:line format", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "pattern": "func", - }, - Output: "file1.go:10:func main()\nfile1.go:20:func helper()\nfile2.go:5:func test()", - } - result := normalizer.NormalizeToolCall(OpenCodeToolGrep, state) - if result.CodeSearch().Output == nil { - t.Fatal("expected Output to be set") - } - // Should deduplicate files - if result.CodeSearch().Output.FileCount != 2 { - t.Errorf("expected FileCount 2 (deduplicated), got %d", result.CodeSearch().Output.FileCount) - } - }) - - t.Run("handles files-only output", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "pattern": "test", - }, - Output: "file1.go\nfile2.go\nfile3.go", - } - result := normalizer.NormalizeToolCall(OpenCodeToolGrep, state) - if len(result.CodeSearch().Output.Files) != 3 { - t.Errorf("expected 3 files, got %d", len(result.CodeSearch().Output.Files)) - } - }) -} - -// TestNormalizerRead tests the normalizer's read handling. -func TestNormalizerRead(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts path, offset, limit", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "path": "/workspace/config.json", - "offset": float64(10), - "limit": float64(50), - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) - if result.Kind() != streams.ToolKindReadFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind()) - } - if result.ReadFile() == nil { - t.Fatal("expected ReadFile to be set") - } - if result.ReadFile().FilePath != "/workspace/config.json" { - t.Errorf("expected FilePath '/workspace/config.json', got %q", result.ReadFile().FilePath) - } - if result.ReadFile().Offset != 10 { - t.Errorf("expected Offset 10, got %d", result.ReadFile().Offset) - } - if result.ReadFile().Limit != 50 { - t.Errorf("expected Limit 50, got %d", result.ReadFile().Limit) - } - }) - - t.Run("falls back to file_path key", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "file_path": "/workspace/fallback.json", - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) - if result.ReadFile().FilePath != "/workspace/fallback.json" { - t.Errorf("expected FilePath '/workspace/fallback.json', got %q", result.ReadFile().FilePath) - } - }) - - t.Run("includes output with content and line count", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "path": "/workspace/file.txt", - }, - Output: "line1\nline2\nline3", - } - result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) - if result.ReadFile().Output == nil { - t.Fatal("expected Output to be set") - } - if result.ReadFile().Output.Content != "line1\nline2\nline3" { - t.Errorf("expected Content, got %q", result.ReadFile().Output.Content) - } - // Line count should be calculated from content - if result.ReadFile().Output.LineCount != 3 { - t.Errorf("expected LineCount 3, got %d", result.ReadFile().Output.LineCount) - } - }) - - t.Run("extracts line_count and truncated from metadata", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "path": "/workspace/large.txt", - }, - Output: "content here", - Metadata: map[string]any{ - "line_count": float64(1000), - "truncated": true, - }, - } - result := normalizer.NormalizeToolCall(OpenCodeToolRead, state) - if result.ReadFile().Output.LineCount != 1000 { - t.Errorf("expected LineCount 1000, got %d", result.ReadFile().Output.LineCount) - } - if !result.ReadFile().Output.Truncated { - t.Error("expected Truncated to be true") - } - }) -} - -// TestNormalizerGeneric tests the normalizer's generic handling. -func TestNormalizerGeneric(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("wraps unknown tools as generic", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{ - "custom_field": "custom_value", - }, - } - result := normalizer.NormalizeToolCall("custom_tool", state) - if result.Kind() != streams.ToolKindGeneric { - t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, result.Kind()) - } - if result.Generic() == nil { - t.Fatal("expected Generic to be set") - } - if result.Generic().Name != "custom_tool" { - t.Errorf("expected Name 'custom_tool', got %q", result.Generic().Name) - } - }) - - t.Run("includes output when available", func(t *testing.T) { - state := &ToolState{ - Input: map[string]any{}, - Output: "tool output", - } - result := normalizer.NormalizeToolCall("unknown", state) - if result.Generic().Output != "tool output" { - t.Errorf("expected Output 'tool output', got %v", result.Generic().Output) - } - }) -} - -// TestNormalizeToolResult tests the deprecated result handler. -func TestNormalizeToolResult(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("updates shell exec output from string", func(t *testing.T) { - payload := streams.NewShellExec("ls", "", "", 0, false) - normalizer.NormalizeToolResult(payload, "file1\nfile2") - if payload.ShellExec().Output == nil { - t.Fatal("expected Output to be set") - } - if payload.ShellExec().Output.Stdout != "file1\nfile2" { - t.Errorf("expected Stdout, got %q", payload.ShellExec().Output.Stdout) - } - }) - - t.Run("updates shell exec output from map", func(t *testing.T) { - payload := streams.NewShellExec("cmd", "", "", 0, false) - normalizer.NormalizeToolResult(payload, map[string]any{ - "output": "stdout content", - "error": "stderr content", - }) - if payload.ShellExec().Output.Stdout != "stdout content" { - t.Errorf("expected Stdout 'stdout content', got %q", payload.ShellExec().Output.Stdout) - } - if payload.ShellExec().Output.Stderr != "stderr content" { - t.Errorf("expected Stderr 'stderr content', got %q", payload.ShellExec().Output.Stderr) - } - }) - - t.Run("updates http request response", func(t *testing.T) { - payload := streams.NewHttpRequest("https://example.com", "GET") - normalizer.NormalizeToolResult(payload, "response body") - if payload.HttpRequest().Response != "response body" { - t.Errorf("expected Response 'response body', got %q", payload.HttpRequest().Response) - } - }) - - t.Run("updates generic output", func(t *testing.T) { - payload := streams.NewGeneric("tool", nil) - normalizer.NormalizeToolResult(payload, map[string]any{"data": "value"}) - if payload.Generic().Output == nil { - t.Fatal("expected Output to be set") - } - }) - - t.Run("does not overwrite existing output", func(t *testing.T) { - payload := streams.NewShellExec("ls", "", "", 0, false) - payload.ShellExec().Output = &streams.ShellExecOutput{Stdout: "existing"} - normalizer.NormalizeToolResult(payload, "new output") - // Should not overwrite - if payload.ShellExec().Output.Stdout != "existing" { - t.Errorf("expected Stdout to remain 'existing', got %q", payload.ShellExec().Output.Stdout) - } - }) -} - -// TestNewNormalizer tests the constructor. -func TestNewNormalizer(t *testing.T) { - normalizer := NewNormalizer() - if normalizer == nil { - t.Fatal("expected non-nil normalizer") - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go deleted file mode 100644 index b43d60bc0c..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go +++ /dev/null @@ -1,328 +0,0 @@ -// Package streamjson implements the stream-json transport adapter. -// This is the protocol used by Claude Code CLI (--output-format stream-json). -package streamjson - -import ( - "context" - "encoding/json" - "fmt" - "io" - "sync" - "sync/atomic" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/claudecode" - "go.uber.org/zap" -) - -// Re-export types needed by external packages -type ( - PermissionRequest = types.PermissionRequest - PermissionResponse = types.PermissionResponse - PermissionOption = streams.PermissionOption - PermissionHandler = types.PermissionHandler - AgentEvent = streams.AgentEvent -) - -// AgentInfo contains information about the connected agent. -type AgentInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -// StderrProvider provides access to recent stderr output for error context. -type StderrProvider interface { - GetRecentStderr() []string -} - -// Adapter implements the transport adapter for agents using the stream-json protocol. -// Claude Code uses a streaming JSON format over stdin/stdout with control requests for permissions. -type Adapter struct { - cfg *shared.Config - logger *logger.Logger - - // Agent identity (from config, for logging) - agentID string - - // Normalizer for converting tool data to NormalizedPayload - normalizer *Normalizer - - // Subprocess stdin/stdout (set via Connect) - stdin io.Writer - stdout io.Reader - - // Claude Code client for protocol communication - client *claudecode.Client - - // Context for managing goroutine lifecycle - ctx context.Context - cancel context.CancelFunc - - // Session state - sessionID string - operationID string // Current prompt operation - sessionStatusSent bool // Whether we've sent the session status event - - // Track pending tool calls to auto-complete on result - // Maps tool_use_id to the NormalizedPayload for enrichment with results - pendingToolCalls map[string]*streams.NormalizedPayload - - // Agent info - agentInfo *AgentInfo - - // Update channel - updatesCh chan AgentEvent - - // Permission handler - permissionHandler PermissionHandler - - // Stderr provider for error context - stderrProvider StderrProvider - - // Result completion signaling - resultCh chan resultComplete - - // Dynamic context window tracking - mainModelName string // Model name from assistant messages (excludes subagents) - mainModelContextWindow int64 // Context window size (updated from result's model_usage) - contextTokensUsed int64 // Total tokens used (input + output + cache) - - // Available commands from initialize response (stored until session is created) - pendingAvailableCommands []streams.AvailableCommand - - // Track whether text was streamed this turn to prevent duplicates from result.text - // This is set to true when we send message_chunk events from assistant messages, - // and reset to false at the start of each prompt. - streamingTextSentThisTurn bool - - // Message UUID tracking for --resume-session-at support - lastMessageUUID string // Latest committed message UUID (safe to resume from) - pendingAssistantUUID string // UUID from assistant message, committed on result - - // Plan content from ExitPlanMode tool_call, published as agent_plan message on result - exitPlanContent string - - // ExitPlanMode completion guard: set to true when a delayed completion goroutine - // is pending, cleared on result message arrival or goroutine execution. - exitPlanPending atomic.Bool - - // lastRawData holds the raw JSON of the current message being processed. - // Set in handleMessage before dispatch; used by sendUpdate for OTel tracing. - lastRawData json.RawMessage - - // OTel tracing: active prompt span context. - // Notification spans become children of the prompt span for visual grouping. - promptTraceCtx context.Context - promptTraceMu sync.RWMutex - - // Attachment management - attachMgr *shared.AttachmentManager - - // Synchronization - mu sync.RWMutex - closed bool -} - -// defaultContextWindow is the fallback context window size for Claude models -const defaultContextWindow = 200000 - -// toolExitPlanMode is the Claude Code tool name for exiting plan mode. -// Used in hook matching, permission handling, and completion detection. -const toolExitPlanMode = "ExitPlanMode" - -// resultComplete holds the result of a completed prompt -type resultComplete struct { - success bool - err string -} - -// NewAdapter creates a new stream-json protocol adapter. -// Call Connect() after starting the subprocess to wire up stdin/stdout. -// cfg.AgentID is required for debug file naming. -func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { - ctx, cancel := context.WithCancel(context.Background()) - l := log.WithFields(zap.String("adapter", "stream-json"), zap.String("agent_id", cfg.AgentID)) - return &Adapter{ - cfg: cfg, - logger: l, - agentID: cfg.AgentID, - normalizer: NewNormalizer(), - ctx: ctx, - cancel: cancel, - updatesCh: make(chan AgentEvent, 100), - mainModelContextWindow: defaultContextWindow, - pendingToolCalls: make(map[string]*streams.NormalizedPayload), - attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()), - } -} - -// Connect wires up the stdin/stdout pipes from the running agent subprocess. -func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.stdin != nil || a.stdout != nil { - return fmt.Errorf("adapter already connected") - } - - a.stdin = stdin - a.stdout = stdout - return nil -} - -// Close releases resources held by the adapter. -func (a *Adapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() - - if a.closed { - return nil - } - a.closed = true - - a.logger.Info("closing stream-json adapter") - - // Clean up any saved attachments - a.attachMgr.Cleanup() - - // Cancel the context to stop the read loop - if a.cancel != nil { - a.cancel() - } - - // Stop the client - if a.client != nil { - a.client.Stop() - } - - // Close update channel - close(a.updatesCh) - - return nil -} - -// Updates returns the channel for agent events. -func (a *Adapter) Updates() <-chan AgentEvent { - return a.updatesCh -} - -// GetSessionID returns the current session ID. -func (a *Adapter) GetSessionID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.sessionID -} - -// GetOperationID returns the current operation ID. -func (a *Adapter) GetOperationID() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.operationID -} - -// SetPermissionHandler sets the handler for permission requests. -func (a *Adapter) SetPermissionHandler(handler PermissionHandler) { - a.mu.Lock() - defer a.mu.Unlock() - a.permissionHandler = handler -} - -// SetStderrProvider sets the provider for recent stderr output. -func (a *Adapter) SetStderrProvider(provider StderrProvider) { - a.mu.Lock() - defer a.mu.Unlock() - a.stderrProvider = provider -} - -// RequiresProcessKill returns false because Claude Code agents exit when stdin is closed. -func (a *Adapter) RequiresProcessKill() bool { - return false -} - -// sendUpdate safely sends an event to the updates channel. -func (a *Adapter) sendUpdate(update AgentEvent) { - shared.LogNormalizedEvent(shared.ProtocolStreamJSON, a.agentID, &update) - shared.TraceProtocolEvent(a.getPromptTraceCtx(), shared.ProtocolStreamJSON, a.agentID, - update.Type, a.lastRawData, &update) - select { - case a.updatesCh <- update: - default: - a.logger.Warn("updates channel full, dropping event") - } -} - -// getPromptTraceCtx returns the current prompt span context for child-span linking. -// Returns context.Background() if no prompt is active. -func (a *Adapter) getPromptTraceCtx() context.Context { - a.promptTraceMu.RLock() - defer a.promptTraceMu.RUnlock() - if a.promptTraceCtx != nil { - return a.promptTraceCtx - } - return context.Background() -} - -// setPromptTraceCtx stores the prompt span context. -func (a *Adapter) setPromptTraceCtx(ctx context.Context) { - a.promptTraceMu.Lock() - defer a.promptTraceMu.Unlock() - a.promptTraceCtx = ctx -} - -// clearPromptTraceCtx clears the prompt span context. -func (a *Adapter) clearPromptTraceCtx() { - a.promptTraceMu.Lock() - defer a.promptTraceMu.Unlock() - a.promptTraceCtx = nil -} - -// handleMessage processes streaming messages from the agent. -func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) { - // Marshal once for debug logging and tracing - a.lastRawData, _ = json.Marshal(msg) - if len(a.lastRawData) > 0 { - shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, msg.Type, a.lastRawData) - } - - // Update session ID from any message type. - // Claude Code can change session_id mid-conversation (e.g. on compact). - if msg.SessionID != "" { - a.mu.Lock() - if a.sessionID != msg.SessionID { - a.logger.Info("session ID updated", - zap.String("old", a.sessionID), - zap.String("new", msg.SessionID), - zap.String("message_type", msg.Type)) - a.sessionID = msg.SessionID - } - a.mu.Unlock() - } - - a.mu.RLock() - sessionID := a.sessionID - operationID := a.operationID - a.mu.RUnlock() - - switch msg.Type { - case claudecode.MessageTypeSystem: - a.handleSystemMessage(msg) - - case claudecode.MessageTypeAssistant: - a.handleAssistantMessage(msg, sessionID, operationID) - - case claudecode.MessageTypeUser: - a.handleUserMessage(msg, sessionID, operationID) - - case claudecode.MessageTypeResult: - a.handleResultMessage(msg, sessionID, operationID) - - case claudecode.MessageTypeRateLimit: - a.handleRateLimitMessage(msg, sessionID, operationID) - - default: - a.logger.Debug("unhandled message type", zap.String("type", msg.Type)) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go deleted file mode 100644 index 73fc27d1f5..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go +++ /dev/null @@ -1,758 +0,0 @@ -package streamjson - -import ( - "encoding/json" - "io" - "testing" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/claudecode" -) - -func newTestLogger(t *testing.T) *logger.Logger { - t.Helper() - log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) - return log -} - -func TestPrepareCommandArgs_NoServers(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "claude-code"}, newTestLogger(t)) - args := a.PrepareCommandArgs() - if args != nil { - t.Fatalf("expected nil args for no MCP servers, got %v", args) - } -} - -func TestPrepareCommandArgs_SSEServer(t *testing.T) { - a := NewAdapter(&shared.Config{ - AgentID: "claude-code", - McpServers: []shared.McpServerConfig{ - {Name: "kandev", URL: "http://localhost:10001/sse", Type: "sse"}, - }, - }, newTestLogger(t)) - - args := a.PrepareCommandArgs() - if len(args) != 2 { - t.Fatalf("expected 2 args, got %d: %v", len(args), args) - } - if args[0] != "--mcp-config" { - t.Fatalf("expected first arg to be --mcp-config, got %s", args[0]) - } - - // Parse the JSON to verify structure - var config map[string]interface{} - if err := json.Unmarshal([]byte(args[1]), &config); err != nil { - t.Fatalf("failed to parse MCP config JSON: %v", err) - } - - // Must have mcpServers wrapper - servers, ok := config["mcpServers"] - if !ok { - t.Fatalf("MCP config missing 'mcpServers' wrapper key, got: %s", args[1]) - } - - serversMap, ok := servers.(map[string]interface{}) - if !ok { - t.Fatalf("mcpServers is not an object") - } - - kandev, ok := serversMap["kandev"] - if !ok { - t.Fatalf("mcpServers missing 'kandev' key") - } - - kandevMap, ok := kandev.(map[string]interface{}) - if !ok { - t.Fatalf("kandev server config is not an object") - } - - if kandevMap["url"] != "http://localhost:10001/sse" { - t.Errorf("expected url 'http://localhost:10001/sse', got %v", kandevMap["url"]) - } - if kandevMap["type"] != "sse" { - t.Errorf("expected type 'sse', got %v", kandevMap["type"]) - } -} - -func TestPrepareCommandArgs_StdioServer(t *testing.T) { - a := NewAdapter(&shared.Config{ - AgentID: "claude-code", - McpServers: []shared.McpServerConfig{ - {Name: "my-tool", Command: "node", Args: []string{"server.js", "--port", "3000"}}, - }, - }, newTestLogger(t)) - - args := a.PrepareCommandArgs() - if len(args) != 2 { - t.Fatalf("expected 2 args, got %d: %v", len(args), args) - } - - var config map[string]interface{} - if err := json.Unmarshal([]byte(args[1]), &config); err != nil { - t.Fatalf("failed to parse MCP config JSON: %v", err) - } - - servers, ok := config["mcpServers"].(map[string]interface{}) - if !ok { - t.Fatalf("MCP config missing 'mcpServers' wrapper") - } - - tool, ok := servers["my-tool"].(map[string]interface{}) - if !ok { - t.Fatalf("mcpServers missing 'my-tool' key") - } - - if tool["command"] != "node" { - t.Errorf("expected command 'node', got %v", tool["command"]) - } - - toolArgs, ok := tool["args"].([]interface{}) - if !ok { - t.Fatalf("expected args array, got %T", tool["args"]) - } - if len(toolArgs) != 3 { - t.Errorf("expected 3 args, got %d", len(toolArgs)) - } -} - -func TestPrepareCommandArgs_MultipleServers(t *testing.T) { - a := NewAdapter(&shared.Config{ - AgentID: "claude-code", - McpServers: []shared.McpServerConfig{ - {Name: "kandev", URL: "http://localhost:10001/sse", Type: "sse"}, - {Name: "other", Command: "python", Args: []string{"-m", "mcp_server"}}, - }, - }, newTestLogger(t)) - - args := a.PrepareCommandArgs() - - var config map[string]interface{} - if err := json.Unmarshal([]byte(args[1]), &config); err != nil { - t.Fatalf("failed to parse MCP config JSON: %v", err) - } - - servers, ok := config["mcpServers"].(map[string]interface{}) - if !ok { - t.Fatalf("MCP config missing 'mcpServers' wrapper") - } - - if len(servers) != 2 { - t.Errorf("expected 2 servers, got %d", len(servers)) - } - if _, ok := servers["kandev"]; !ok { - t.Error("missing 'kandev' server") - } - if _, ok := servers["other"]; !ok { - t.Error("missing 'other' server") - } -} - -func TestBuildHooks_Autonomous(t *testing.T) { - tests := []struct { - name string - policy string - }{ - {"empty policy", ""}, - {"explicit autonomous", "autonomous"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := NewAdapter(&shared.Config{ - AgentID: "claude-code", - PermissionPolicy: tt.policy, - }, newTestLogger(t)) - - hooks := a.buildHooks() - if hooks == nil { - t.Fatal("buildHooks() returned nil, want hooks with ExitPlanMode") - } - - // Should have PreToolUse with only ExitPlanMode auto-approve - preToolUse, ok := hooks["PreToolUse"] - if !ok { - t.Fatal("buildHooks() missing PreToolUse key") - } - entries, ok := preToolUse.([]claudecode.HookEntry) - if !ok { - t.Fatalf("PreToolUse is %T, want []claudecode.HookEntry", preToolUse) - } - if len(entries) != 1 { - t.Fatalf("PreToolUse has %d entries, want 1", len(entries)) - } - if entries[0].Matcher != `^ExitPlanMode$` { - t.Errorf("entries[0].Matcher = %q, want %q", entries[0].Matcher, `^ExitPlanMode$`) - } - if entries[0].HookCallbackIDs[0] != "auto_approve" { - t.Errorf("entries[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "auto_approve") - } - - // Should NOT have Stop (not registered until completion handling is implemented) - if _, ok := hooks["Stop"]; ok { - t.Error("buildHooks() has Stop key, but Stop hooks should not be registered") - } - }) - } -} - -func TestBuildHooks_Supervised(t *testing.T) { - a := NewAdapter(&shared.Config{ - AgentID: "claude-code", - PermissionPolicy: "supervised", - }, newTestLogger(t)) - - hooks := a.buildHooks() - if hooks == nil { - t.Fatal("buildHooks() returned nil, want hooks") - } - - // Should have PreToolUse with 2 entries: ExitPlanMode auto-approve + tool_approval - preToolUse, ok := hooks["PreToolUse"] - if !ok { - t.Fatal("buildHooks() missing PreToolUse key") - } - entries, ok := preToolUse.([]claudecode.HookEntry) - if !ok { - t.Fatalf("PreToolUse is %T, want []claudecode.HookEntry", preToolUse) - } - if len(entries) != 2 { - t.Fatalf("PreToolUse has %d entries, want 2", len(entries)) - } - // First: ExitPlanMode auto-approve - if entries[0].Matcher != `^ExitPlanMode$` { - t.Errorf("entries[0].Matcher = %q, want %q", entries[0].Matcher, `^ExitPlanMode$`) - } - if entries[0].HookCallbackIDs[0] != "auto_approve" { - t.Errorf("entries[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "auto_approve") - } - // Second: tool_approval for write/dangerous tools - if entries[1].HookCallbackIDs[0] != "tool_approval" { - t.Errorf("entries[1].HookCallbackIDs[0] = %q, want %q", entries[1].HookCallbackIDs[0], "tool_approval") - } - - // Should NOT have Stop - if _, ok := hooks["Stop"]; ok { - t.Error("buildHooks() has Stop key, but Stop hooks should not be registered") - } -} - -func TestBuildHooks_Plan(t *testing.T) { - a := NewAdapter(&shared.Config{ - AgentID: "claude-code", - PermissionPolicy: "plan", - }, newTestLogger(t)) - - hooks := a.buildHooks() - if hooks == nil { - t.Fatal("buildHooks() returned nil, want hooks") - } - - // Plan mode should have 2 PreToolUse entries: ExitPlanMode approval + auto-approve rest - preToolUse, ok := hooks["PreToolUse"] - if !ok { - t.Fatal("buildHooks() missing PreToolUse key") - } - entries, ok := preToolUse.([]claudecode.HookEntry) - if !ok { - t.Fatalf("PreToolUse is %T, want []claudecode.HookEntry", preToolUse) - } - if len(entries) != 2 { - t.Fatalf("PreToolUse has %d entries, want 2", len(entries)) - } - - // First entry: ExitPlanMode auto-approved - if entries[0].Matcher != `^ExitPlanMode$` { - t.Errorf("entries[0].Matcher = %q, want %q", entries[0].Matcher, `^ExitPlanMode$`) - } - if entries[0].HookCallbackIDs[0] != "auto_approve" { - t.Errorf("entries[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "auto_approve") - } - - // Second entry: everything else auto-approved - if entries[1].HookCallbackIDs[0] != "auto_approve" { - t.Errorf("entries[1].HookCallbackIDs[0] = %q, want %q", entries[1].HookCallbackIDs[0], "auto_approve") - } - - // Should NOT have Stop - if _, ok := hooks["Stop"]; ok { - t.Error("buildHooks() has Stop key, but Stop hooks should not be registered") - } -} - -func TestSendControlResult_SendsAndTraces(t *testing.T) { - // Create adapter with a mock stdin to capture output - var buf syncBuf - a := NewAdapter(&shared.Config{AgentID: "test-agent"}, newTestLogger(t)) - a.stdin = &buf - - // Wire up a client that writes to our buffer - a.client = claudecode.NewClient(&buf, &emptyReader{}, newTestLogger(t)) - - a.sendControlResult("req-123", &claudecode.PermissionResult{ - Behavior: claudecode.BehaviorAllow, - }, "test_trace_event") - - // Verify something was written to stdin - if buf.Len() == 0 { - t.Error("sendControlResult did not write to stdin") - } - - // Parse the sent message - var resp claudecode.ControlResponseMessage - if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse sent response: %v", err) - } - if resp.RequestID != "req-123" { - t.Errorf("RequestID = %q, want %q", resp.RequestID, "req-123") - } - if resp.Response == nil { - t.Fatal("Response is nil") - } - if resp.Response.Subtype != "success" { - t.Errorf("Response.Subtype = %q, want %q", resp.Response.Subtype, "success") - } -} - -// --- handleUserMessage tests --- - -// drainEvents collects all pending events from the adapter's update channel. -func drainEvents(a *Adapter) []AgentEvent { - var events []AgentEvent - for { - select { - case ev := <-a.updatesCh: - events = append(events, ev) - default: - return events - } - } -} - -func TestHandleUserMessage_ReplaySkipped(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeUser, - UUID: "msg-uuid-1", - IsReplay: true, - Message: &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(`"hello"`), - }, - } - - a.handleUserMessage(msg, "sess-1", "op-1") - - events := drainEvents(a) - if len(events) != 0 { - t.Errorf("expected 0 events for replay message, got %d", len(events)) - } - - // UUID should still be tracked for --resume-session-at - a.mu.RLock() - lastUUID := a.lastMessageUUID - a.mu.RUnlock() - if lastUUID != "msg-uuid-1" { - t.Errorf("lastMessageUUID = %q, want %q", lastUUID, "msg-uuid-1") - } -} - -func TestHandleUserMessage_EchoedPromptSkipped(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeUser, - UUID: "msg-uuid-2", - Message: &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(`"hello world"`), - }, - } - - a.handleUserMessage(msg, "sess-1", "op-1") - - events := drainEvents(a) - if len(events) != 0 { - t.Errorf("expected 0 events for echoed prompt, got %d", len(events)) - } -} - -func TestHandleUserMessage_SlashCommandOutputSkipped(t *testing.T) { - // Slash command output arrives as string content in a user message. - // It is always isReplay=true in practice, but even without that flag, - // string-content user messages are skipped — the output is delivered - // via the result message instead. - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeUser, - UUID: "msg-uuid-3", - Message: &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(`"compact summary here"`), - }, - } - - a.handleUserMessage(msg, "sess-1", "op-1") - - events := drainEvents(a) - if len(events) != 0 { - t.Errorf("expected 0 events for slash command output (delivered via result), got %d", len(events)) - } -} - -func TestHandleUserMessage_ToolResultStillProcessed(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - // Pre-register a pending tool call - a.pendingToolCalls["tool-123"] = nil - - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeUser, - UUID: "msg-uuid-4", - Message: &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"tool-123","content":"output"}]`), - }, - } - - a.handleUserMessage(msg, "sess-1", "op-1") - - events := drainEvents(a) - if len(events) != 1 { - t.Fatalf("expected 1 event for tool result, got %d", len(events)) - } - if events[0].Type != "tool_update" { - t.Errorf("event type = %q, want %q", events[0].Type, "tool_update") - } - if events[0].ToolCallID != "tool-123" { - t.Errorf("tool_call_id = %q, want %q", events[0].ToolCallID, "tool-123") - } -} - -func TestHandleUserMessage_SystemPromptEchoSkipped(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - // Simulate the real bug: Claude Code echoes back the full prompt including kandev-system tags - content := `"MCP TOOLS info\n\nhello"` - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeUser, - UUID: "msg-uuid-5", - Message: &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(content), - }, - } - - a.handleUserMessage(msg, "sess-1", "op-1") - - events := drainEvents(a) - if len(events) != 0 { - t.Errorf("expected 0 events for system prompt echo, got %d (text: %q)", len(events), events[0].Text) - } -} - -// --- handleMessage session_id tracking tests --- - -func TestHandleMessage_SessionIDTracking(t *testing.T) { - tests := []struct { - name string - initialSession string - msgType string - msgSessionID string - wantSessionID string - wantSessionSame bool - }{ - { - name: "system message sets session ID", - msgType: claudecode.MessageTypeSystem, - msgSessionID: "new-session-1", - wantSessionID: "new-session-1", - }, - { - name: "user message updates session ID", - initialSession: "old-session", - msgType: claudecode.MessageTypeUser, - msgSessionID: "new-session-2", - wantSessionID: "new-session-2", - }, - { - name: "assistant message updates session ID", - initialSession: "old-session", - msgType: claudecode.MessageTypeAssistant, - msgSessionID: "compacted-session", - wantSessionID: "compacted-session", - }, - { - name: "result message updates session ID", - initialSession: "old-session", - msgType: claudecode.MessageTypeResult, - msgSessionID: "result-session", - wantSessionID: "result-session", - }, - { - name: "empty session ID does not overwrite", - initialSession: "keep-this", - msgType: claudecode.MessageTypeUser, - msgSessionID: "", - wantSessionID: "keep-this", - }, - { - name: "same session ID is a no-op", - initialSession: "same-session", - msgType: claudecode.MessageTypeAssistant, - msgSessionID: "same-session", - wantSessionID: "same-session", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - if tt.initialSession != "" { - a.sessionID = tt.initialSession - } - - msg := &claudecode.CLIMessage{ - Type: tt.msgType, - SessionID: tt.msgSessionID, - } - - // handleMessage needs a message body for user/assistant types to avoid nil panics - if tt.msgType == claudecode.MessageTypeUser || tt.msgType == claudecode.MessageTypeAssistant { - msg.Message = &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(`"test"`), - } - } - // Result messages need a result field - if tt.msgType == claudecode.MessageTypeResult { - msg.Result = json.RawMessage(`"done"`) - } - - a.handleMessage(msg) - drainEvents(a) // discard any events - - a.mu.RLock() - gotSessionID := a.sessionID - a.mu.RUnlock() - - if gotSessionID != tt.wantSessionID { - t.Errorf("sessionID = %q, want %q", gotSessionID, tt.wantSessionID) - } - }) - } -} - -// syncBuf is a thread-safe buffer for tests. -type syncBuf struct { - data []byte -} - -func (b *syncBuf) Write(p []byte) (int, error) { - b.data = append(b.data, p...) - return len(p), nil -} - -func (b *syncBuf) Len() int { return len(b.data) } -func (b *syncBuf) Bytes() []byte { return b.data } - -// emptyReader always returns EOF. -type emptyReader struct{} - -func (e *emptyReader) Read([]byte) (int, error) { return 0, io.EOF } - -// --- TodoWrite normalizer tests --- - -func TestNormalizeTodoWrite_ExtractsTodos(t *testing.T) { - n := NewNormalizer() - - // TodoWrite uses "todos" array with "content" (not "items" with "description") - args := map[string]any{ - "todos": []any{ - map[string]any{ - "content": "Count lines in config.yaml", - "status": "pending", - "activeForm": "Counting lines in config.yaml", - }, - map[string]any{ - "content": "Count lines in Makefile", - "status": "completed", - "activeForm": "Counting lines in Makefile", - }, - }, - } - - payload := n.NormalizeToolCall("TodoWrite", args) - if payload.Kind() != "manage_todos" { - t.Fatalf("kind = %q, want %q", payload.Kind(), "manage_todos") - } - todos := payload.ManageTodos() - if todos.Operation != "write" { - t.Errorf("operation = %q, want %q", todos.Operation, "write") - } - if len(todos.Items) != 2 { - t.Fatalf("items count = %d, want 2", len(todos.Items)) - } - if todos.Items[0].Description != "Count lines in config.yaml" { - t.Errorf("items[0].Description = %q, want %q", todos.Items[0].Description, "Count lines in config.yaml") - } - if todos.Items[0].Status != "pending" { - t.Errorf("items[0].Status = %q, want %q", todos.Items[0].Status, "pending") - } - if todos.Items[0].ActiveForm != "Counting lines in config.yaml" { - t.Errorf("items[0].ActiveForm = %q, want %q", todos.Items[0].ActiveForm, "Counting lines in config.yaml") - } - if todos.Items[1].Status != "completed" { - t.Errorf("items[1].Status = %q, want %q", todos.Items[1].Status, "completed") - } -} - -// --- tool_use_result enrichment tests --- - -func TestHandleUserMessage_SubagentToolUseResult(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - // Pre-register a pending Task tool call - n := NewNormalizer() - taskPayload := n.NormalizeToolCall("Task", map[string]any{ - "description": "Count lines", - "prompt": "Count lines in file", - "subagent_type": "Bash", - }) - a.pendingToolCalls["task-1"] = taskPayload - - // Sub-agent result with array content and tool_use_result metadata - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeUser, - UUID: "msg-1", - Message: &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"task-1","content":[{"type":"text","text":"61 config.yaml"},{"type":"text","text":"agentId: abc123"}]}]`), - }, - ToolUseResult: json.RawMessage(`{"status":"completed","agentId":"abc123","totalDurationMs":1609,"totalTokens":4540,"totalToolUseCount":1}`), - } - - a.handleUserMessage(msg, "sess-1", "op-1") - - events := drainEvents(a) - if len(events) != 1 { - t.Fatalf("expected 1 event, got %d", len(events)) - } - - payload := events[0].NormalizedPayload - if payload == nil { - t.Fatal("payload is nil") - } - st := payload.SubagentTask() - if st == nil { - t.Fatal("SubagentTask is nil") - } - if st.Status != "completed" { - t.Errorf("status = %q, want %q", st.Status, "completed") - } - if st.AgentID != "abc123" { - t.Errorf("agentID = %q, want %q", st.AgentID, "abc123") - } - if st.DurationMs != 1609 { - t.Errorf("durationMs = %d, want %d", st.DurationMs, 1609) - } - if st.TotalTokens != 4540 { - t.Errorf("totalTokens = %d, want %d", st.TotalTokens, 4540) - } - if st.ToolUseCount != 1 { - t.Errorf("toolUseCount = %d, want %d", st.ToolUseCount, 1) - } -} - -func TestHandleUserMessage_TodoWriteToolUseResult(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - // Pre-register a pending TodoWrite tool call - n := NewNormalizer() - todoPayload := n.NormalizeToolCall("TodoWrite", map[string]any{ - "todos": []any{ - map[string]any{"content": "Task A", "status": "pending"}, - }, - }) - a.pendingToolCalls["todo-1"] = todoPayload - - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeUser, - UUID: "msg-2", - Message: &claudecode.AssistantMessage{ - Role: "user", - Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"todo-1","content":"Todos modified"}]`), - }, - ToolUseResult: json.RawMessage(`{"oldTodos":[],"newTodos":[{"content":"Task A","status":"completed","activeForm":"Working on Task A"}]}`), - } - - a.handleUserMessage(msg, "sess-1", "op-1") - - events := drainEvents(a) - if len(events) != 1 { - t.Fatalf("expected 1 event, got %d", len(events)) - } - - payload := events[0].NormalizedPayload - if payload == nil || payload.ManageTodos() == nil { - t.Fatal("payload or ManageTodos is nil") - } - items := payload.ManageTodos().Items - if len(items) != 1 { - t.Fatalf("items count = %d, want 1", len(items)) - } - if items[0].Description != "Task A" { - t.Errorf("items[0].Description = %q, want %q", items[0].Description, "Task A") - } - if items[0].Status != "completed" { - t.Errorf("items[0].Status = %q, want %q", items[0].Status, "completed") - } - if items[0].ActiveForm != "Working on Task A" { - t.Errorf("items[0].ActiveForm = %q, want %q", items[0].ActiveForm, "Working on Task A") - } -} - -// --- system task_started subtype test --- - -func TestHandleSystemMessage_TaskStartedSkipped(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - // Simulate the system task_started message that Claude Code sends for sub-agents - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeSystem, - Subtype: "task_started", - SessionID: "sess-1", - } - - a.handleSystemMessage(msg) - - events := drainEvents(a) - if len(events) != 0 { - t.Errorf("expected 0 events for task_started system message, got %d", len(events)) - } -} - -func TestHandleSystemMessage_InitStillWorks(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - // Normal init system message (subtype empty) - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeSystem, - SessionID: "sess-1", - SessionStatus: "new", - } - - a.handleSystemMessage(msg) - - events := drainEvents(a) - if len(events) != 1 { - t.Fatalf("expected 1 event for init system message, got %d", len(events)) - } - if events[0].Type != "session_status" { - t.Errorf("event type = %q, want %q", events[0].Type, "session_status") - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/exit_plan_mode_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/exit_plan_mode_test.go deleted file mode 100644 index c2ef4af2fc..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/exit_plan_mode_test.go +++ /dev/null @@ -1,337 +0,0 @@ -package streamjson - -import ( - "encoding/json" - "testing" - "time" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/pkg/claudecode" -) - -// --- drainExitPlanContent tests --- - -func TestDrainExitPlanContent_ReturnsAndClears(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - a.mu.Lock() - a.exitPlanContent = "my plan" - a.mu.Unlock() - - content := a.drainExitPlanContent() - if content != "my plan" { - t.Errorf("drainExitPlanContent() = %q, want %q", content, "my plan") - } - - // Second drain should return empty - content2 := a.drainExitPlanContent() - if content2 != "" { - t.Errorf("second drainExitPlanContent() = %q, want empty", content2) - } -} - -func TestDrainExitPlanContent_EmptyReturnsEmpty(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - - content := a.drainExitPlanContent() - if content != "" { - t.Errorf("drainExitPlanContent() = %q, want empty", content) - } -} - -// --- processToolUseBlock ExitPlanMode tests --- - -func TestProcessToolUseBlock_ExitPlanMode_CapturesPlanAndEmitsMode(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - - block := claudecode.ContentBlock{ - Type: "tool_use", - ID: "tool-1", - Name: "ExitPlanMode", - Input: map[string]any{"plan": "## My Plan\n- step 1\n- step 2"}, - } - - a.processToolUseBlock(block, "sess-1", "op-1", "") - - events := drainEvents(a) - if len(events) < 2 { - t.Fatalf("expected at least 2 events, got %d", len(events)) - } - - // First event: session_mode with empty mode (plan mode exited) - if events[0].Type != "session_mode" { - t.Errorf("events[0].Type = %q, want %q", events[0].Type, "session_mode") - } - if events[0].CurrentModeID != "" { - t.Errorf("events[0].CurrentModeID = %q, want empty", events[0].CurrentModeID) - } - - // Verify plan content was captured - a.mu.RLock() - planContent := a.exitPlanContent - a.mu.RUnlock() - if planContent != "## My Plan\n- step 1\n- step 2" { - t.Errorf("exitPlanContent = %q, want plan text", planContent) - } -} - -func TestProcessToolUseBlock_NonExitPlanMode_NoPlanCapture(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - - block := claudecode.ContentBlock{ - Type: "tool_use", - ID: "tool-1", - Name: "Bash", - Input: map[string]any{"command": "ls"}, - } - - a.processToolUseBlock(block, "sess-1", "op-1", "") - - events := drainEvents(a) - for _, ev := range events { - if ev.Type == "session_mode" { - t.Error("unexpected session_mode event for non-ExitPlanMode tool") - } - } - - a.mu.RLock() - planContent := a.exitPlanContent - a.mu.RUnlock() - if planContent != "" { - t.Errorf("exitPlanContent = %q, want empty", planContent) - } -} - -// --- handleResultMessage plan content tests --- - -func TestHandleResultMessage_ClearsPendingAndDrainsPlan(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - - a.mu.Lock() - a.exitPlanContent = "the plan" - a.mu.Unlock() - a.exitPlanPending.Store(true) - - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeResult, - Result: json.RawMessage(`"done"`), - } - - a.handleResultMessage(msg, "sess-1", "op-1") - - if a.exitPlanPending.Load() { - t.Error("exitPlanPending should be false after result message") - } - - a.mu.RLock() - remaining := a.exitPlanContent - a.mu.RUnlock() - if remaining != "" { - t.Errorf("exitPlanContent = %q, want empty after drain", remaining) - } - - events := drainEvents(a) - var completeEvent *AgentEvent - for i := range events { - if events[i].Type == "complete" { - completeEvent = &events[i] - break - } - } - if completeEvent == nil { - t.Fatal("no complete event found") - } - if completeEvent.Data == nil { - t.Fatal("complete data is nil") - } - if completeEvent.Data["plan_content"] != "the plan" { - t.Errorf("plan_content = %v, want %q", completeEvent.Data["plan_content"], "the plan") - } -} - -// --- scheduleExitPlanModeCompletion tests --- - -func TestScheduleExitPlanModeCompletion_EmitsCompleteAfterDelay(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - a.resultCh = make(chan resultComplete, 1) - - a.mu.Lock() - a.exitPlanContent = "delayed plan" - a.mu.Unlock() - - a.scheduleExitPlanModeCompletion() - - select { - case result := <-a.resultCh: - if !result.success { - t.Error("expected success=true") - } - case <-time.After(exitPlanModeCompletionDelay + time.Second): - t.Fatal("timed out waiting for completion signal") - } - - events := drainEvents(a) - var completeEvent *AgentEvent - for i := range events { - if events[i].Type == "complete" { - completeEvent = &events[i] - break - } - } - if completeEvent == nil { - t.Fatal("no complete event emitted") - } - if completeEvent.Data == nil { - t.Fatal("complete data is nil") - } - if completeEvent.Data["plan_content"] != "delayed plan" { - t.Errorf("plan_content = %v, want %q", completeEvent.Data["plan_content"], "delayed plan") - } - - remaining := a.drainExitPlanContent() - if remaining != "" { - t.Errorf("exitPlanContent not drained: %q", remaining) - } -} - -func TestScheduleExitPlanModeCompletion_CancelledByResult(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - - a.scheduleExitPlanModeCompletion() - - // Simulate a result message arriving before the delay - a.exitPlanPending.Store(false) - - time.Sleep(exitPlanModeCompletionDelay + 500*time.Millisecond) - - events := drainEvents(a) - for _, ev := range events { - if ev.Type == "complete" { - t.Error("unexpected complete event — should have been cancelled by result") - } - } -} - -func TestScheduleExitPlanModeCompletion_CancelledByContext(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - - a.scheduleExitPlanModeCompletion() - - // Cancel the adapter context immediately - a.cancel() - - time.Sleep(500 * time.Millisecond) - - if a.exitPlanPending.Load() { - t.Error("exitPlanPending should be false after context cancel") - } - - events := drainEvents(a) - for _, ev := range events { - if ev.Type == "complete" { - t.Error("unexpected complete event after context cancellation") - } - } -} - -// --- handleSystemMessage status/permission mode tests --- - -func TestHandleSystemMessage_StatusPermissionMode(t *testing.T) { - tests := []struct { - name string - mode string - wantModeID string - }{ - {"plan mode", "plan", "plan"}, - {"bypass mapped to empty", "bypassPermissions", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - - msg := &claudecode.CLIMessage{ - Type: claudecode.MessageTypeSystem, - Subtype: "status", - PermissionMode: tt.mode, - } - - a.handleSystemMessage(msg) - - events := drainEvents(a) - if len(events) != 1 { - t.Fatalf("expected 1 event, got %d", len(events)) - } - if events[0].Type != "session_mode" { - t.Errorf("event type = %q, want %q", events[0].Type, "session_mode") - } - if events[0].CurrentModeID != tt.wantModeID { - t.Errorf("CurrentModeID = %q, want %q", events[0].CurrentModeID, tt.wantModeID) - } - }) - } -} - -// --- hook callback and permission handler ExitPlanMode tests --- - -func TestHandleHookCallback_AutoApprove_ExitPlanMode(t *testing.T) { - var buf syncBuf - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.client = claudecode.NewClient(&buf, &emptyReader{}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - a.resultCh = make(chan resultComplete, 1) - - req := &claudecode.ControlRequest{ - Subtype: claudecode.SubtypeHookCallback, - CallbackID: "auto_approve", - Input: map[string]any{ - "tool_name": "ExitPlanMode", - "hook_event_name": "PreToolUse", - }, - } - - a.handleHookCallback("req-1", req) - - if !a.exitPlanPending.Load() { - t.Error("exitPlanPending should be true after ExitPlanMode hook approval") - } - - a.cancel() -} - -func TestHandleToolPermission_ExitPlanMode_NoHandler(t *testing.T) { - var buf syncBuf - a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) - a.client = claudecode.NewClient(&buf, &emptyReader{}, newTestLogger(t)) - a.sessionID = "sess-1" - a.operationID = "op-1" - a.resultCh = make(chan resultComplete, 1) - - req := &claudecode.ControlRequest{ - Subtype: claudecode.SubtypeCanUseTool, - ToolName: "ExitPlanMode", - Input: map[string]any{"plan": "test plan"}, - } - - a.handleToolPermission("req-1", req) - - if !a.exitPlanPending.Load() { - t.Error("exitPlanPending should be true after ExitPlanMode auto-allow") - } - - a.cancel() -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go deleted file mode 100644 index cdb6e2e2d9..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize.go +++ /dev/null @@ -1,272 +0,0 @@ -package streamjson - -import ( - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/claudecode" -) - -// DetectStreamJSONToolType determines tool type from stream-json/Claude Code tool names. -// Used for logging and backwards compatibility. -func DetectStreamJSONToolType(toolName string) string { - switch toolName { - case claudecode.ToolEdit, claudecode.ToolWrite, claudecode.ToolNotebookEdit: - return "tool_edit" - case claudecode.ToolRead, claudecode.ToolGlob, claudecode.ToolGrep: - return "tool_read" - case claudecode.ToolBash: - return "tool_execute" - default: - return "tool_call" - } -} - -// Normalizer converts stream-json protocol tool data to NormalizedPayload. -type Normalizer struct{} - -// NewNormalizer creates a new stream-json normalizer. -func NewNormalizer() *Normalizer { - return &Normalizer{} -} - -// NormalizeToolCall converts stream-json tool call data to NormalizedPayload. -func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload { - switch toolName { - case claudecode.ToolEdit, claudecode.ToolWrite, claudecode.ToolNotebookEdit: - return n.normalizeEdit(toolName, args) - case claudecode.ToolRead: - return n.normalizeRead(args) - case claudecode.ToolGlob, claudecode.ToolGrep: - return n.normalizeCodeSearch(toolName, args) - case claudecode.ToolBash: - return n.normalizeExecute(args) - case claudecode.ToolWebFetch, claudecode.ToolWebSearch: - return n.normalizeHttpRequest(toolName, args) - case claudecode.ToolTask: - return n.normalizeSubagentTask(args) - case claudecode.ToolTaskCreate: - return n.normalizeCreateTask(args) - case claudecode.ToolTaskUpdate, claudecode.ToolTaskList: - return n.normalizeManageTodos(toolName, args) - case claudecode.ToolTodoWrite: - return n.normalizeManageTodos(toolName, args) - default: - return n.normalizeGeneric(toolName, args) - } -} - -// NormalizeToolResult updates the payload with tool result data. -func (n *Normalizer) NormalizeToolResult(payload *streams.NormalizedPayload, result any) { - resultStr, _ := result.(string) - - switch payload.Kind() { - case streams.ToolKindReadFile: - n.normalizeReadFileResult(payload, resultStr) - case streams.ToolKindCodeSearch: - n.normalizeCodeSearchResult(payload, resultStr) - case streams.ToolKindModifyFile: - n.normalizeModifyFileResult(payload, resultStr) - case streams.ToolKindShellExec: - n.normalizeShellExecResult(payload, result) - case streams.ToolKindHttpRequest: - n.normalizeHttpRequestResult(payload, result) - case streams.ToolKindGeneric: - n.normalizeGenericResult(payload, result) - } -} - -// normalizeReadFileResult populates ReadFile output. -func (n *Normalizer) normalizeReadFileResult(payload *streams.NormalizedPayload, resultStr string) { - if payload.ReadFile() == nil || resultStr == "" { - return - } - shared.NormalizeReadResult(payload.ReadFile(), resultStr) -} - -// normalizeCodeSearchResult populates CodeSearch output. -func (n *Normalizer) normalizeCodeSearchResult(payload *streams.NormalizedPayload, resultStr string) { - if payload.CodeSearch() == nil || resultStr == "" { - return - } - shared.NormalizeCodeSearchResult(payload.CodeSearch(), resultStr) -} - -// normalizeModifyFileResult populates ModifyFile output. -func (n *Normalizer) normalizeModifyFileResult(payload *streams.NormalizedPayload, resultStr string) { - if payload.ModifyFile() == nil || resultStr == "" { - return - } - shared.NormalizeModifyResult(payload.ModifyFile(), resultStr) -} - -// normalizeShellExecResult populates ShellExec output. -func (n *Normalizer) normalizeShellExecResult(payload *streams.NormalizedPayload, result any) { - if payload.ShellExec() == nil { - return - } - shared.NormalizeShellResult(payload.ShellExec(), result) -} - -// normalizeHttpRequestResult populates HttpRequest response. -func (n *Normalizer) normalizeHttpRequestResult(payload *streams.NormalizedPayload, result any) { - if payload.HttpRequest() == nil { - return - } - if r, ok := result.(string); ok { - payload.HttpRequest().Response = r - } -} - -// normalizeGenericResult populates Generic output. -func (n *Normalizer) normalizeGenericResult(payload *streams.NormalizedPayload, result any) { - if payload.Generic() == nil { - return - } - payload.Generic().Output = result -} - -// normalizeEdit converts stream-json Edit/Write tool data. -func (n *Normalizer) normalizeEdit(toolName string, args map[string]any) *streams.NormalizedPayload { - filePath := shared.GetString(args, "file_path") - oldString := shared.GetString(args, "old_string") - newString := shared.GetString(args, "new_string") - content := shared.GetString(args, "content") - - var mutations []streams.FileMutation - - if toolName == claudecode.ToolWrite { - // Write is a full file creation/replacement - mutations = append(mutations, streams.FileMutation{ - Type: streams.MutationCreate, - Content: content, - }) - } else { - // Edit is a patch operation - // Only include the diff (not old/new content) to reduce payload size - mutation := streams.FileMutation{ - Type: streams.MutationPatch, - } - - // Generate unified diff when at least one string is provided - if oldString != "" || newString != "" { - mutation.Diff = shared.GenerateUnifiedDiff(oldString, newString, filePath, 0) - } - - mutations = append(mutations, mutation) - } - - // Use factory function - return streams.NewModifyFile(filePath, mutations) -} - -// normalizeRead converts stream-json Read tool data. -func (n *Normalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload { - filePath := shared.GetString(args, "file_path") - offset := shared.GetInt(args, "offset") - limit := shared.GetInt(args, "limit") - - return streams.NewReadFile(filePath, offset, limit) -} - -// normalizeCodeSearch converts stream-json Glob/Grep tool data. -func (n *Normalizer) normalizeCodeSearch(toolName string, args map[string]any) *streams.NormalizedPayload { - path := shared.GetString(args, "path") - pattern := shared.GetString(args, "pattern") - - var query, glob string - switch toolName { - case claudecode.ToolGlob: - glob = pattern - case claudecode.ToolGrep: - query = shared.GetString(args, "query") - } - - return streams.NewCodeSearch(query, pattern, path, glob) -} - -// normalizeExecute converts stream-json Bash tool data. -func (n *Normalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload { - command := shared.GetString(args, "command") - description := shared.GetString(args, "description") - timeout := shared.GetInt(args, "timeout") - background := shared.GetBool(args, "run_in_background") - - return streams.NewShellExec(command, "", description, timeout, background) -} - -// normalizeHttpRequest converts stream-json WebFetch/WebSearch tool data. -func (n *Normalizer) normalizeHttpRequest(toolName string, args map[string]any) *streams.NormalizedPayload { - url := shared.GetString(args, "url") - if url == "" { - url = shared.GetString(args, "query") - } - - method := "GET" - if toolName == claudecode.ToolWebSearch { - method = "SEARCH" - } - - return streams.NewHttpRequest(url, method) -} - -// normalizeSubagentTask converts stream-json Task tool data (subagent invocation). -func (n *Normalizer) normalizeSubagentTask(args map[string]any) *streams.NormalizedPayload { - description := shared.GetString(args, "description") - prompt := shared.GetString(args, "prompt") - subagentType := shared.GetString(args, "subagent_type") - - return streams.NewSubagentTask(description, prompt, subagentType) -} - -// normalizeCreateTask converts stream-json TaskCreate tool data. -func (n *Normalizer) normalizeCreateTask(args map[string]any) *streams.NormalizedPayload { - title := shared.GetString(args, "subject") - description := shared.GetString(args, "description") - - return streams.NewCreateTask(title, description) -} - -// normalizeManageTodos converts stream-json TaskUpdate/TaskList/TodoWrite tool data. -// TodoWrite uses {"todos": [{content, status, activeForm}]} while -// TaskUpdate uses {"items": [{id, description, status}]}. -func (n *Normalizer) normalizeManageTodos(toolName string, args map[string]any) *streams.NormalizedPayload { - operation := "update" - switch toolName { - case claudecode.ToolTaskList: - operation = "list" - case claudecode.ToolTodoWrite: - operation = "write" - } - - // Try "todos" first (TodoWrite format), then "items" (TaskUpdate format) - rawItems, ok := args["todos"].([]any) - if !ok { - rawItems, _ = args["items"].([]any) - } - - var items []streams.TodoItem - for _, item := range rawItems { - itemMap, ok := item.(map[string]any) - if !ok { - continue - } - // TodoWrite uses "content" for the description text - desc := shared.GetString(itemMap, "description") - if desc == "" { - desc = shared.GetString(itemMap, "content") - } - items = append(items, streams.TodoItem{ - ID: shared.GetString(itemMap, "id"), - Description: desc, - Status: shared.GetString(itemMap, "status"), - ActiveForm: shared.GetString(itemMap, "activeForm"), - }) - } - - return streams.NewManageTodos(operation, items) -} - -// normalizeGeneric wraps unknown tools as generic. -func (n *Normalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload { - return streams.NewGeneric(toolName, args) -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go deleted file mode 100644 index 95450c7389..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/normalize_test.go +++ /dev/null @@ -1,660 +0,0 @@ -package streamjson - -import ( - "fmt" - "strings" - "testing" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/claudecode" -) - -// TestStreamJSONNormalization runs JSONL-driven tests for stream-json protocol normalization. -func TestStreamJSONNormalization(t *testing.T) { - testCases := shared.LoadTestCases(t, "streamjson-messages.jsonl") - normalizer := NewNormalizer() - - for i, tc := range testCases { - t.Run(fmt.Sprintf("line_%d", i+1), func(t *testing.T) { - toolName, _ := tc.Input["tool_name"].(string) - args, _ := tc.Input["args"].(map[string]any) - - // Detect tool type - toolType := DetectStreamJSONToolType(toolName) - expectedToolType, _ := tc.Expected["tool_type"].(string) - if toolType != expectedToolType { - t.Errorf("tool type mismatch: got %q, want %q", toolType, expectedToolType) - } - - // Normalize using the typed normalizer - payload := normalizer.NormalizeToolCall(toolName, args) - - // Verify the Kind is set correctly based on tool type - switch toolType { - case "tool_edit": - if payload.Kind() != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, payload.Kind()) - } - case "tool_read": - if payload.Kind() != streams.ToolKindReadFile && payload.Kind() != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q or %q, got %q", streams.ToolKindReadFile, streams.ToolKindCodeSearch, payload.Kind()) - } - case "tool_execute": - if payload.Kind() != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, payload.Kind()) - } - } - }) - } -} - -// TestDetectStreamJSONToolType tests the stream-json protocol tool type detection function. -func TestDetectStreamJSONToolType(t *testing.T) { - tests := []struct { - toolName string - want string - }{ - {"Edit", "tool_edit"}, - {"Read", "tool_read"}, - {"Bash", "tool_execute"}, - {"Glob", "tool_read"}, // Glob is a read operation - {"Grep", "tool_read"}, // Grep is a read operation - {"Write", "tool_edit"}, // Write is an edit operation - {"WebSearch", "tool_call"}, - {"WebFetch", "tool_call"}, - {"Task", "tool_call"}, - {"", "tool_call"}, - } - - for _, tt := range tests { - t.Run(tt.toolName, func(t *testing.T) { - got := DetectStreamJSONToolType(tt.toolName) - if got != tt.want { - t.Errorf("DetectStreamJSONToolType(%q) = %q, want %q", tt.toolName, got, tt.want) - } - }) - } -} - -// TestDetectLanguage tests the language detection from file extensions. -func TestDetectLanguage(t *testing.T) { - tests := []struct { - path string - want string - }{ - {"file.ts", "typescript"}, - {"file.tsx", "typescript"}, - {"file.js", "javascript"}, - {"file.jsx", "javascript"}, - {"file.py", "python"}, - {"file.go", "go"}, - {"file.rs", "rust"}, - {"file.java", "java"}, - {"file.cpp", "cpp"}, - {"file.c", "c"}, - {"file.h", "c"}, - {"file.hpp", "cpp"}, - {"file.css", "css"}, - {"file.html", "html"}, - {"file.json", "json"}, - {"file.md", "markdown"}, - {"file.yaml", "yaml"}, - {"file.yml", "yaml"}, - {"file.sh", "bash"}, - {"file.bash", "bash"}, - {"file.unknown", "plaintext"}, - {"file", "plaintext"}, - {"", "plaintext"}, - {"/path/to/deep/file.ts", "typescript"}, - {"src/main.go", "go"}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - got := shared.DetectLanguage(tt.path) - if got != tt.want { - t.Errorf("DetectLanguage(%q) = %q, want %q", tt.path, got, tt.want) - } - }) - } -} - -// TestGenerateUnifiedDiff tests the unified diff generation. -func TestGenerateUnifiedDiff(t *testing.T) { - t.Run("returns empty when both are empty", func(t *testing.T) { - result := shared.GenerateUnifiedDiff("", "", "file.ts", 1) - if result != "" { - t.Errorf("expected empty string when both old and new are empty, got %q", result) - } - }) - - t.Run("returns empty when old equals new", func(t *testing.T) { - result := shared.GenerateUnifiedDiff("same content", "same content", "file.ts", 1) - if result != "" { - t.Errorf("expected empty string when old equals new, got %q", result) - } - }) - - t.Run("generates all-additions diff when old is empty", func(t *testing.T) { - result := shared.GenerateUnifiedDiff("", "new content", "file.ts", 1) - if result == "" { - t.Fatal("expected non-empty diff for create operation") - } - if !strings.Contains(result, "+new content") { - t.Error("expected added line in diff") - } - }) - - t.Run("generates all-deletions diff when new is empty", func(t *testing.T) { - result := shared.GenerateUnifiedDiff("old content", "", "file.ts", 1) - if result == "" { - t.Fatal("expected non-empty diff for delete operation") - } - if !strings.Contains(result, "-old content") { - t.Error("expected removed line in diff") - } - }) - - t.Run("generates diff with header and hunks", func(t *testing.T) { - result := shared.GenerateUnifiedDiff("const x = 1;", "const x = 2;", "file.ts", 5) - if result == "" { - t.Fatal("expected non-empty diff") - } - - // Check it contains expected elements - if !strings.Contains(result, "diff --git a/file.ts b/file.ts") { - t.Error("expected diff header") - } - if !strings.Contains(result, "--- a/file.ts") { - t.Error("expected old file marker") - } - if !strings.Contains(result, "+++ b/file.ts") { - t.Error("expected new file marker") - } - if !strings.Contains(result, "-const x = 1;") { - t.Error("expected removed line") - } - if !strings.Contains(result, "+const x = 2;") { - t.Error("expected added line") - } - }) - - t.Run("defaults startLine to 1 when 0", func(t *testing.T) { - result := shared.GenerateUnifiedDiff("a", "b", "file.go", 0) - if result == "" { - t.Fatal("expected non-empty diff") - } - // Verify it contains @@ -1 (defaulted from 0) - if !strings.Contains(result, "@@ -1,") { - t.Error("expected startLine to default to 1") - } - }) -} - -// TestNormalizerEdit tests the normalizer's edit handling. -func TestNormalizerEdit(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts file_path and content for Edit", func(t *testing.T) { - args := map[string]any{ - "file_path": "/workspace/app.ts", - "old_string": "const x = 1;", - "new_string": "const x = 2;", - } - result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) - if result.Kind() != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind()) - } - if result.ModifyFile() == nil { - t.Fatal("expected ModifyFile to be set") - } - if result.ModifyFile().FilePath != "/workspace/app.ts" { - t.Errorf("expected FilePath '/workspace/app.ts', got %q", result.ModifyFile().FilePath) - } - if len(result.ModifyFile().Mutations) != 1 { - t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) - } - mutation := result.ModifyFile().Mutations[0] - // OldContent and NewContent are no longer set (only Diff is generated) - if mutation.Diff == "" { - t.Error("expected Diff to be generated") - } - }) - - t.Run("generates diff when both strings present", func(t *testing.T) { - args := map[string]any{ - "file_path": "/workspace/app.ts", - "old_string": "const x = 1;", - "new_string": "const x = 2;", - } - result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) - if result.ModifyFile() == nil || len(result.ModifyFile().Mutations) == 0 { - t.Fatal("expected mutation") - } - if result.ModifyFile().Mutations[0].Diff == "" { - t.Error("expected diff to be generated") - } - }) - - t.Run("generates diff when old_string is empty", func(t *testing.T) { - args := map[string]any{ - "file_path": "/workspace/new.ts", - "old_string": "", - "new_string": "new content", - } - result := normalizer.NormalizeToolCall(claudecode.ToolEdit, args) - if result.ModifyFile() == nil || len(result.ModifyFile().Mutations) == 0 { - t.Fatal("expected mutation") - } - if result.ModifyFile().Mutations[0].Diff == "" { - t.Error("expected diff to be generated when old_string is empty") - } - }) - - t.Run("Write tool uses content field", func(t *testing.T) { - args := map[string]any{ - "file_path": "/workspace/CONTRIBUTING.md", - "content": "# Contributing\n\nThank you for your interest!", - } - result := normalizer.NormalizeToolCall(claudecode.ToolWrite, args) - if result.Kind() != streams.ToolKindModifyFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindModifyFile, result.Kind()) - } - if result.ModifyFile() == nil { - t.Fatal("expected ModifyFile to be set") - } - if result.ModifyFile().FilePath != "/workspace/CONTRIBUTING.md" { - t.Errorf("expected FilePath '/workspace/CONTRIBUTING.md', got %q", result.ModifyFile().FilePath) - } - if len(result.ModifyFile().Mutations) != 1 { - t.Fatalf("expected 1 mutation, got %d", len(result.ModifyFile().Mutations)) - } - mutation := result.ModifyFile().Mutations[0] - if mutation.Type != streams.MutationCreate { - t.Errorf("expected MutationType %q, got %q", streams.MutationCreate, mutation.Type) - } - if mutation.Content != "# Contributing\n\nThank you for your interest!" { - t.Errorf("expected Content from content field, got %q", mutation.Content) - } - }) -} - -// TestNormalizerRead tests the normalizer's read handling. -func TestNormalizerRead(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts file_path, offset, limit", func(t *testing.T) { - args := map[string]any{ - "file_path": "/workspace/config.json", - "offset": float64(10), - "limit": float64(50), - } - result := normalizer.NormalizeToolCall(claudecode.ToolRead, args) - if result.Kind() != streams.ToolKindReadFile { - t.Errorf("expected Kind %q, got %q", streams.ToolKindReadFile, result.Kind()) - } - if result.ReadFile() == nil { - t.Fatal("expected ReadFile to be set") - } - if result.ReadFile().FilePath != "/workspace/config.json" { - t.Errorf("expected FilePath '/workspace/config.json', got %q", result.ReadFile().FilePath) - } - if result.ReadFile().Offset != 10 { - t.Errorf("expected Offset 10, got %d", result.ReadFile().Offset) - } - if result.ReadFile().Limit != 50 { - t.Errorf("expected Limit 50, got %d", result.ReadFile().Limit) - } - }) -} - -// TestNormalizerExecute tests the normalizer's execute handling. -func TestNormalizerExecute(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("extracts command and description", func(t *testing.T) { - args := map[string]any{ - "command": "ls -la", - "description": "List files", - } - result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) - if result.Kind() != streams.ToolKindShellExec { - t.Errorf("expected Kind %q, got %q", streams.ToolKindShellExec, result.Kind()) - } - if result.ShellExec() == nil { - t.Fatal("expected ShellExec to be set") - } - if result.ShellExec().Command != "ls -la" { - t.Errorf("expected Command 'ls -la', got %q", result.ShellExec().Command) - } - if result.ShellExec().Description != "List files" { - t.Errorf("expected Description 'List files', got %q", result.ShellExec().Description) - } - }) - - t.Run("handles timeout", func(t *testing.T) { - args := map[string]any{ - "command": "npm test", - "timeout": float64(30000), - } - result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) - if result.ShellExec() == nil { - t.Fatal("expected ShellExec to be set") - } - if result.ShellExec().Timeout != 30000 { - t.Errorf("expected Timeout 30000, got %d", result.ShellExec().Timeout) - } - }) - - t.Run("handles background flag", func(t *testing.T) { - args := map[string]any{ - "command": "npm start", - "run_in_background": true, - } - result := normalizer.NormalizeToolCall(claudecode.ToolBash, args) - if result.ShellExec() == nil { - t.Fatal("expected ShellExec to be set") - } - if !result.ShellExec().Background { - t.Error("expected Background to be true") - } - }) -} - -// TestNormalizerCodeSearch tests the normalizer's code search handling. -func TestNormalizerCodeSearch(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("Glob extracts pattern as glob", func(t *testing.T) { - args := map[string]any{ - "pattern": "**/*.ts", - "path": "/workspace", - } - result := normalizer.NormalizeToolCall(claudecode.ToolGlob, args) - if result.Kind() != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) - } - if result.CodeSearch() == nil { - t.Fatal("expected CodeSearch to be set") - } - if result.CodeSearch().Glob != "**/*.ts" { - t.Errorf("expected Glob '**/*.ts', got %q", result.CodeSearch().Glob) - } - if result.CodeSearch().Path != "/workspace" { - t.Errorf("expected Path '/workspace', got %q", result.CodeSearch().Path) - } - }) - - t.Run("Grep extracts query", func(t *testing.T) { - args := map[string]any{ - "pattern": "func.*Error", - "path": "/workspace", - } - result := normalizer.NormalizeToolCall(claudecode.ToolGrep, args) - if result.Kind() != streams.ToolKindCodeSearch { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCodeSearch, result.Kind()) - } - if result.CodeSearch() == nil { - t.Fatal("expected CodeSearch to be set") - } - if result.CodeSearch().Pattern != "func.*Error" { - t.Errorf("expected Pattern 'func.*Error', got %q", result.CodeSearch().Pattern) - } - }) -} - -// TestNormalizerHttpRequest tests the normalizer's HTTP request handling. -func TestNormalizerHttpRequest(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("WebFetch uses GET method", func(t *testing.T) { - args := map[string]any{ - "url": "https://example.com/api", - } - result := normalizer.NormalizeToolCall(claudecode.ToolWebFetch, args) - if result.Kind() != streams.ToolKindHttpRequest { - t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind()) - } - if result.HttpRequest() == nil { - t.Fatal("expected HttpRequest to be set") - } - if result.HttpRequest().URL != "https://example.com/api" { - t.Errorf("expected URL 'https://example.com/api', got %q", result.HttpRequest().URL) - } - if result.HttpRequest().Method != "GET" { - t.Errorf("expected Method 'GET', got %q", result.HttpRequest().Method) - } - }) - - t.Run("WebSearch uses SEARCH method", func(t *testing.T) { - args := map[string]any{ - "query": "golang testing best practices", - } - result := normalizer.NormalizeToolCall(claudecode.ToolWebSearch, args) - if result.Kind() != streams.ToolKindHttpRequest { - t.Errorf("expected Kind %q, got %q", streams.ToolKindHttpRequest, result.Kind()) - } - if result.HttpRequest() == nil { - t.Fatal("expected HttpRequest to be set") - } - if result.HttpRequest().URL != "golang testing best practices" { - t.Errorf("expected URL 'golang testing best practices', got %q", result.HttpRequest().URL) - } - if result.HttpRequest().Method != "SEARCH" { - t.Errorf("expected Method 'SEARCH', got %q", result.HttpRequest().Method) - } - }) -} - -// TestNormalizerSubagentTask tests the normalizer's subagent task handling. -func TestNormalizerSubagentTask(t *testing.T) { - normalizer := NewNormalizer() - - args := map[string]any{ - "description": "Find all test files", - "prompt": "Search for test files in the codebase", - "subagent_type": "Explore", - } - result := normalizer.NormalizeToolCall(claudecode.ToolTask, args) - if result.Kind() != streams.ToolKindSubagentTask { - t.Errorf("expected Kind %q, got %q", streams.ToolKindSubagentTask, result.Kind()) - } - if result.SubagentTask() == nil { - t.Fatal("expected SubagentTask to be set") - } - if result.SubagentTask().Description != "Find all test files" { - t.Errorf("expected Description 'Find all test files', got %q", result.SubagentTask().Description) - } - if result.SubagentTask().Prompt != "Search for test files in the codebase" { - t.Errorf("expected Prompt 'Search for test files in the codebase', got %q", result.SubagentTask().Prompt) - } - if result.SubagentTask().SubagentType != "Explore" { - t.Errorf("expected SubagentType 'Explore', got %q", result.SubagentTask().SubagentType) - } -} - -// TestNormalizerCreateTask tests the normalizer's create task handling. -func TestNormalizerCreateTask(t *testing.T) { - normalizer := NewNormalizer() - - args := map[string]any{ - "subject": "Fix authentication bug", - "description": "The login flow fails when...", - } - result := normalizer.NormalizeToolCall(claudecode.ToolTaskCreate, args) - if result.Kind() != streams.ToolKindCreateTask { - t.Errorf("expected Kind %q, got %q", streams.ToolKindCreateTask, result.Kind()) - } - if result.CreateTask() == nil { - t.Fatal("expected CreateTask to be set") - } - if result.CreateTask().Title != "Fix authentication bug" { - t.Errorf("expected Title 'Fix authentication bug', got %q", result.CreateTask().Title) - } - if result.CreateTask().Description != "The login flow fails when..." { - t.Errorf("expected Description 'The login flow fails when...', got %q", result.CreateTask().Description) - } -} - -// TestNormalizerManageTodos tests the normalizer's manage todos handling. -func TestNormalizerManageTodos(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("TaskUpdate operation", func(t *testing.T) { - args := map[string]any{ - "taskId": "task-123", - "status": "completed", - } - result := normalizer.NormalizeToolCall(claudecode.ToolTaskUpdate, args) - if result.Kind() != streams.ToolKindManageTodos { - t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind()) - } - if result.ManageTodos() == nil { - t.Fatal("expected ManageTodos to be set") - } - if result.ManageTodos().Operation != "update" { - t.Errorf("expected Operation 'update', got %q", result.ManageTodos().Operation) - } - }) - - t.Run("TaskList operation", func(t *testing.T) { - args := map[string]any{} - result := normalizer.NormalizeToolCall(claudecode.ToolTaskList, args) - if result.Kind() != streams.ToolKindManageTodos { - t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind()) - } - if result.ManageTodos() == nil { - t.Fatal("expected ManageTodos to be set") - } - if result.ManageTodos().Operation != "list" { - t.Errorf("expected Operation 'list', got %q", result.ManageTodos().Operation) - } - }) - - t.Run("TodoWrite operation with items", func(t *testing.T) { - args := map[string]any{ - "items": []any{ - map[string]any{ - "id": "todo-1", - "description": "Write tests", - "status": "pending", - }, - }, - } - result := normalizer.NormalizeToolCall(claudecode.ToolTodoWrite, args) - if result.Kind() != streams.ToolKindManageTodos { - t.Errorf("expected Kind %q, got %q", streams.ToolKindManageTodos, result.Kind()) - } - if result.ManageTodos() == nil { - t.Fatal("expected ManageTodos to be set") - } - if result.ManageTodos().Operation != "write" { - t.Errorf("expected Operation 'write', got %q", result.ManageTodos().Operation) - } - if len(result.ManageTodos().Items) != 1 { - t.Fatalf("expected 1 item, got %d", len(result.ManageTodos().Items)) - } - if result.ManageTodos().Items[0].ID != "todo-1" { - t.Errorf("expected item ID 'todo-1', got %q", result.ManageTodos().Items[0].ID) - } - }) -} - -// TestNormalizerGeneric tests the normalizer's generic handling. -func TestNormalizerGeneric(t *testing.T) { - normalizer := NewNormalizer() - - args := map[string]any{ - "custom_field": "custom_value", - } - result := normalizer.NormalizeToolCall("UnknownTool", args) - if result.Kind() != streams.ToolKindGeneric { - t.Errorf("expected Kind %q, got %q", streams.ToolKindGeneric, result.Kind()) - } - if result.Generic() == nil { - t.Fatal("expected Generic to be set") - } - if result.Generic().Name != "UnknownTool" { - t.Errorf("expected Name 'UnknownTool', got %q", result.Generic().Name) - } -} - -// TestNormalizerToolResult tests the normalizer's tool result handling. -func TestNormalizerToolResult(t *testing.T) { - normalizer := NewNormalizer() - - t.Run("shell exec result from string", func(t *testing.T) { - // Use factory function to create payload - payload := streams.NewShellExec("ls", "", "", 0, false) - normalizer.NormalizeToolResult(payload, "file1.txt\nfile2.txt") - if payload.ShellExec().Output == nil { - t.Fatal("expected Output to be set") - } - if payload.ShellExec().Output.Stdout != "file1.txt\nfile2.txt" { - t.Errorf("expected Stdout 'file1.txt\\nfile2.txt', got %q", payload.ShellExec().Output.Stdout) - } - }) - - t.Run("shell exec result from map", func(t *testing.T) { - // Use factory function to create payload - payload := streams.NewShellExec("npm test", "", "", 0, false) - normalizer.NormalizeToolResult(payload, map[string]any{ - "stdout": "All tests passed", - "stderr": "", - "exit_code": float64(0), - }) - if payload.ShellExec().Output == nil { - t.Fatal("expected Output to be set") - } - if payload.ShellExec().Output.Stdout != "All tests passed" { - t.Errorf("expected Stdout 'All tests passed', got %q", payload.ShellExec().Output.Stdout) - } - if payload.ShellExec().Output.ExitCode != 0 { - t.Errorf("expected ExitCode 0, got %d", payload.ShellExec().Output.ExitCode) - } - }) - - t.Run("http request result", func(t *testing.T) { - // Use factory function to create payload - payload := streams.NewHttpRequest("https://example.com", "GET") - normalizer.NormalizeToolResult(payload, "Response body here") - if payload.HttpRequest().Response != "Response body here" { - t.Errorf("expected Response 'Response body here', got %q", payload.HttpRequest().Response) - } - }) - - t.Run("generic result", func(t *testing.T) { - // Use factory function to create payload - payload := streams.NewGeneric("CustomTool", nil) - normalizer.NormalizeToolResult(payload, map[string]any{"data": "value"}) - if payload.Generic().Output == nil { - t.Fatal("expected Output to be set") - } - }) -} - -// TestSplitLines tests the line splitting utility. -func TestSplitLines(t *testing.T) { - tests := []struct { - name string - input string - want int - }{ - {"empty string", "", 0}, - {"single line no newline", "hello", 1}, - {"single line with newline", "hello\n", 2}, // splits to ["hello", ""] - {"two lines", "hello\nworld", 2}, - {"crlf line endings", "hello\r\nworld", 2}, - {"multiple lines", "a\nb\nc\nd", 4}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := shared.SplitLines(tt.input) - if len(got) != tt.want { - t.Errorf("shared.SplitLines(%q) returned %d lines, want %d", tt.input, len(got), tt.want) - } - }) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go deleted file mode 100644 index 31b55eedbd..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go +++ /dev/null @@ -1,214 +0,0 @@ -package streamjson - -import ( - "context" - "encoding/json" - "time" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/claudecode" - "go.uber.org/zap" -) - -// PrepareEnvironment performs protocol-specific setup before the agent process starts. -// Stream-json protocol reads MCP configuration from settings files, but we handle MCP via kandev's -// built-in MCP server, so this is a no-op. -func (a *Adapter) PrepareEnvironment() (map[string]string, error) { - a.logger.Info("PrepareEnvironment called", - zap.Int("mcp_server_count", len(a.cfg.McpServers))) - // MCP configuration is handled externally or via CLI flags - return nil, nil -} - -// PrepareCommandArgs returns extra command-line arguments for the agent process. -// For stream-json (Claude Code), MCP configuration is passed via --mcp-config flag. -func (a *Adapter) PrepareCommandArgs() []string { - if len(a.cfg.McpServers) == 0 { - return nil - } - - // Build MCP configuration in Claude Code format - // Format: { "server-name": { "command": "...", "args": [...], "env": {...} } } - mcpConfig := make(map[string]interface{}) - for _, server := range a.cfg.McpServers { - serverDef := make(map[string]interface{}) - - // Handle different transport types - if server.Command != "" { - // stdio transport - serverDef["command"] = server.Command - if len(server.Args) > 0 { - serverDef["args"] = server.Args - } - if len(server.Env) > 0 { - serverDef["env"] = server.Env - } - } else if server.URL != "" { - // SSE/HTTP transport - serverDef["url"] = server.URL - if server.Type != "" { - serverDef["type"] = server.Type - } - if len(server.Headers) > 0 { - serverDef["headers"] = server.Headers - } - } - - mcpConfig[server.Name] = serverDef - } - - // Wrap in mcpServers key (Claude Code expects this format) - wrappedConfig := map[string]interface{}{ - "mcpServers": mcpConfig, - } - - // Convert to JSON string - configJSON, err := json.Marshal(wrappedConfig) - if err != nil { - a.logger.Warn("failed to marshal MCP config, skipping", - zap.Error(err), - zap.Int("server_count", len(a.cfg.McpServers))) - return nil - } - - serverNames := make([]string, len(a.cfg.McpServers)) - for i, s := range a.cfg.McpServers { - serverNames[i] = s.Name - } - a.logger.Info("prepared MCP configuration for Claude Code", - zap.Int("server_count", len(a.cfg.McpServers)), - zap.Strings("servers", serverNames)) - - // Return --mcp-config flag with JSON string - return []string{"--mcp-config", string(configJSON)} -} - -// Initialize establishes the stream-json connection with the agent subprocess. -func (a *Adapter) Initialize(ctx context.Context) error { - a.logger.Info("initializing stream-json adapter", - zap.String("workdir", a.cfg.WorkDir)) - - // Create Claude Code client - a.client = claudecode.NewClient(a.stdin, a.stdout, a.logger) - a.client.SetRequestHandler(a.handleControlRequest) - a.client.SetCancelHandler(a.handleControlCancel) - a.client.SetMessageHandler(a.handleMessage) - a.client.SetCloseHandler(func() { - a.logger.Info("stream closed (EOF), signaling completion") - a.signalResultCompletion(true, "") - }) - - // Start reading from stdout with the adapter's context - // Wait for the read loop to be ready before sending initialize - readyC := a.client.Start(a.ctx) - select { - case <-readyC: - a.logger.Info("read loop is ready") - case <-ctx.Done(): - return ctx.Err() - case <-time.After(5 * time.Second): - a.logger.Warn("timeout waiting for read loop to start") - } - - // Store agent info (version will be populated from first message) - a.agentInfo = &AgentInfo{ - Name: a.agentID, - Version: "unknown", - } - - // Build hooks based on permission policy - hooks := a.buildHooks() - - // Send initialize control request to get slash commands - // This is required for streaming mode (input-format=stream-json) - initCtx, initSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolStreamJSON, a.agentID, "initialize") - a.traceOutgoingSendCtx(initCtx, "initialize", map[string]any{ - "type": claudecode.MessageTypeControlRequest, - "request": map[string]any{"subtype": claudecode.SubtypeInitialize, "hooks": hooks}, - }) - initResp, err := a.client.Initialize(ctx, 180*time.Second, hooks) - initSpan.End() - - if err != nil { - a.logger.Warn("failed to initialize (continuing anyway)", zap.Error(err)) - } else if initResp != nil && len(initResp.Commands) > 0 { - // Store available commands to emit after session is created - commands := make([]streams.AvailableCommand, len(initResp.Commands)) - for i, cmd := range initResp.Commands { - commands[i] = streams.AvailableCommand{ - Name: cmd.Name, - Description: cmd.Description, - } - } - a.mu.Lock() - a.pendingAvailableCommands = commands - a.mu.Unlock() - - a.logger.Info("received slash commands from initialize", - zap.Int("count", len(commands))) - } - - a.logger.Info("stream-json adapter initialized") - - return nil -} - -// buildHooks constructs hook configuration based on the permission policy. -// Always registers at least an ExitPlanMode auto-approve hook (ExitPlanMode -// doesn't send a result message, so it needs special completion handling -// regardless of policy). -func (a *Adapter) buildHooks() map[string]any { - policy := a.cfg.PermissionPolicy - cfg := &claudecode.HookConfig{} - - switch policy { - case "supervised": - // ExitPlanMode auto-approved; write/dangerous tools require approval. - // Non-matching read-only tools are implicitly auto-approved by Claude Code. - cfg.PreToolUse = []claudecode.HookEntry{ - { - Matcher: `^ExitPlanMode$`, - HookCallbackIDs: []string{"auto_approve"}, - }, - { - Matcher: `^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite|ExitPlanMode)$).*`, - HookCallbackIDs: []string{"tool_approval"}, - }, - } - case "plan": - // All tools auto-approved during planning, including ExitPlanMode. - cfg.PreToolUse = []claudecode.HookEntry{ - { - Matcher: `^ExitPlanMode$`, - HookCallbackIDs: []string{"auto_approve"}, - }, - { - Matcher: `^(?!ExitPlanMode$).*`, - HookCallbackIDs: []string{"auto_approve"}, - }, - } - default: - // Autonomous / empty: only hook ExitPlanMode for completion handling. - // All other tools have no matching hook → implicitly auto-approved. - cfg.PreToolUse = []claudecode.HookEntry{ - { - Matcher: `^ExitPlanMode$`, - HookCallbackIDs: []string{"auto_approve"}, - }, - } - } - - // NOTE: Do NOT register Stop hooks here until proper completion handling - // is implemented. Stop hooks fire on every turn end — when approved, - // Claude Code terminates without sending a result message, leaving - // Prompt() blocked forever. A Stop hook would need its own delayed - // completion logic (like scheduleExitPlanModeCompletion) to work. - - return cfg.ToMap() -} - -// GetAgentInfo returns information about the connected agent. -func (a *Adapter) GetAgentInfo() *AgentInfo { - return a.agentInfo -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go deleted file mode 100644 index 25be4ad499..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go +++ /dev/null @@ -1,456 +0,0 @@ -package streamjson - -import ( - "encoding/json" - "fmt" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/claudecode" - "go.uber.org/zap" -) - -// handleSystemMessage processes system messages. -// Subtypes: -// - "init" (or empty): Session initialization with slash commands and status -// - "status": Permission mode changes (e.g. plan mode entry) -// - "task_started": Sub-agent task lifecycle event — logged and skipped -// -// Note: Turn completion is signaled by result messages, not system messages. -func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) { - // Handle permission mode changes from system status messages. - // "bypassPermissions" is the default/normal mode — map it to empty (no mode). - if msg.Subtype == "status" && msg.PermissionMode != "" { - modeID := msg.PermissionMode - if modeID == "bypassPermissions" { - modeID = "" - } - a.mu.RLock() - sessionID := a.sessionID - operationID := a.operationID - a.mu.RUnlock() - a.logger.Info("permission mode changed", - zap.String("raw_mode", msg.PermissionMode), - zap.String("mode_id", modeID)) - a.emitSessionMode(sessionID, operationID, modeID) - return - } - - // Skip non-init system messages (e.g. task_started for sub-agent lifecycle) - if msg.Subtype != "" && msg.Subtype != "init" { - a.logger.Debug("skipping system message with subtype", - zap.String("subtype", msg.Subtype), - zap.String("session_id", msg.SessionID)) - return - } - - a.logger.Info("received system message", - zap.String("session_id", msg.SessionID), - zap.String("status", msg.SessionStatus), - zap.Int("slash_commands_count", len(msg.SlashCommands))) - - // Note: session ID is updated centrally in handleMessage() for all message types. - a.mu.Lock() - alreadySent := a.sessionStatusSent - a.sessionStatusSent = true - a.mu.Unlock() - - // Emit available commands if present (do this on every system message, - // not just the first, in case commands change) - // Note: System message slash_commands is just an array of names (strings), - // so we only have the name, not description. The initialize response has full details. - if len(msg.SlashCommands) > 0 { - commands := make([]streams.AvailableCommand, len(msg.SlashCommands)) - for i, name := range msg.SlashCommands { - commands[i] = streams.AvailableCommand{ - Name: name, - Description: "", // System message only has names, not descriptions - } - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeAvailableCommands, - SessionID: msg.SessionID, - AvailableCommands: commands, - }) - } - - // Only send session status event once per session (on first prompt) - // The agent sends system messages on every prompt, but we only want to - // show "New session started" or "Session resumed" once - if alreadySent { - return - } - - // Send session status event (NOT complete - that's only for result messages) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeSessionStatus, - SessionID: msg.SessionID, - Data: map[string]any{ - "session_status": msg.SessionStatus, - "init": true, - }, - }) -} - -// updateMainModel tracks the first model seen as the main model name. -func (a *Adapter) updateMainModel(model string) { - if model == "" || a.agentInfo == nil { - return - } - a.agentInfo.Version = model - a.mu.Lock() - if a.mainModelName == "" { - a.mainModelName = model - a.logger.Debug("tracking main model", zap.String("model", model)) - } - a.mu.Unlock() -} - -// emitContextWindow emits a context window event. -func (a *Adapter) emitContextWindow(sessionID, operationID string, contextUsed, contextSize int64) { - remaining := contextSize - contextUsed - if remaining < 0 { - remaining = 0 - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeContextWindow, - SessionID: sessionID, - OperationID: operationID, - ContextWindowSize: contextSize, - ContextWindowUsed: contextUsed, - ContextWindowRemaining: remaining, - ContextEfficiency: float64(contextUsed) / float64(contextSize) * 100, - }) -} - -// processTextBlock handles a text content block from an assistant message. -func (a *Adapter) processTextBlock(block claudecode.ContentBlock, sessionID, operationID string) { - if block.Text == "" { - return - } - // Mark that we've sent streaming text this turn - // This prevents duplicate content from result.text - a.mu.Lock() - a.streamingTextSentThisTurn = true - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: block.Text, - }) -} - -// processToolUseBlock handles a tool_use content block from an assistant message. -func (a *Adapter) processToolUseBlock(block claudecode.ContentBlock, sessionID, operationID, parentToolUseID string) { - // Generate normalized payload using the normalizer - normalizedPayload := a.normalizer.NormalizeToolCall(block.Name, block.Input) - - // Detect specific tool operation type for logging - toolType := DetectStreamJSONToolType(block.Name) - - // Build a human-readable title for the tool call - toolTitle := block.Name - if cmd, ok := block.Input["command"].(string); ok && block.Name == claudecode.ToolBash { - toolTitle = cmd - } else if path, ok := block.Input["file_path"].(string); ok { - toolTitle = fmt.Sprintf("%s: %s", block.Name, path) - } - a.logger.Debug("tool_use block received", - zap.String("tool_call_id", block.ID), - zap.String("tool_name", block.Name), - zap.String("tool_type", toolType), - zap.String("title", toolTitle)) - - // Capture plan content from ExitPlanMode for dedicated agent_plan message - // Also emit mode-clear since Claude Code doesn't send a system status reset on exit - if block.Name == toolExitPlanMode { - if plan, ok := block.Input["plan"].(string); ok { - a.mu.Lock() - a.exitPlanContent = plan - a.mu.Unlock() - } - a.emitSessionMode(sessionID, operationID, "") - } - - // Track this tool call as pending with its payload for result enrichment - a.mu.Lock() - a.pendingToolCalls[block.ID] = normalizedPayload - a.mu.Unlock() - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolCall, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ID, - ParentToolCallID: parentToolUseID, - ToolName: block.Name, - ToolTitle: toolTitle, - ToolStatus: "running", - NormalizedPayload: normalizedPayload, - }) -} - -// handleAssistantMessage processes assistant messages (text, thinking, tool calls). -func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { - if msg.Message == nil { - return - } - - // Track assistant message UUID (committed on result) - if msg.UUID != "" { - a.mu.Lock() - a.pendingAssistantUUID = msg.UUID - a.mu.Unlock() - } - - // Extract parent tool use ID for subagent nesting - parentToolUseID := msg.ParentToolUseID - - // Get content blocks (may be nil if content is a string) - contentBlocks := msg.Message.GetContentBlocks() - - // Log content block types for debugging - blockTypes := make([]string, 0, len(contentBlocks)) - for _, block := range contentBlocks { - blockTypes = append(blockTypes, block.Type) - } - a.logger.Debug("processing assistant message", - zap.Int("num_blocks", len(contentBlocks)), - zap.Strings("block_types", blockTypes), - zap.String("parent_tool_use_id", parentToolUseID)) - - // Update agent version and track main model name from model info - a.updateMainModel(msg.Message.Model) - - // Process content blocks - for _, block := range contentBlocks { - switch block.Type { - case "text": - a.processTextBlock(block, sessionID, operationID) - case "thinking": - if block.Thinking != "" { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeReasoning, - SessionID: sessionID, - OperationID: operationID, - ReasoningText: block.Thinking, - }) - } - case "tool_use": - a.processToolUseBlock(block, sessionID, operationID, parentToolUseID) - } - } - - // Calculate and emit token usage as context window event - if msg.Message.Usage != nil { - usage := msg.Message.Usage - - // Calculate total tokens used (including cache tokens) - contextUsed := usage.InputTokens + usage.OutputTokens + - usage.CacheCreationInputTokens + usage.CacheReadInputTokens - - // Update tracked token usage - a.mu.Lock() - a.contextTokensUsed = contextUsed - contextSize := a.mainModelContextWindow - a.mu.Unlock() - - a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) - } -} - -// handleRateLimitMessage processes rate limit notifications from the Anthropic API. -func (a *Adapter) handleRateLimitMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { - message := "Rate limited by API" - if len(msg.RateLimitInfo) > 0 { - message = string(msg.RateLimitInfo) - } - - a.logger.Warn("rate limit event received", - zap.String("session_id", sessionID), - zap.String("message", message)) - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeRateLimit, - SessionID: sessionID, - OperationID: operationID, - RateLimitMessage: message, - }) -} - -// handleUserMessage processes user messages containing tool results. -// Claude Code sends tool results back as user messages with tool_result content blocks. -// -// User message variants (from Claude Code CLI): -// - isReplay=true: Historical context echoed on resume — drop entirely -// - String content: Echoed user prompt or slash command output — skip -// (slash command output is delivered via the result message instead) -// - Content blocks with tool_result: Tool results — process normally -func (a *Adapter) handleUserMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { - if msg.Message == nil { - return - } - - // Clear pending assistant UUID — a new user message means any previous - // assistant message's UUID should not be committed on the next result. - // User messages are immediately safe to reference for resume. - a.mu.Lock() - a.pendingAssistantUUID = "" - if msg.UUID != "" { - a.lastMessageUUID = msg.UUID - } - a.mu.Unlock() - - // Skip replay messages — historical context echoed on resume. - // UUID tracking above still fires for --resume-session-at support. - if msg.IsReplay { - a.logger.Debug("skipping replay user message", - zap.String("session_id", sessionID), - zap.String("uuid", msg.UUID)) - return - } - - // Skip string-content user messages (echoed prompts). - // Slash command output also arrives here but is always isReplay=true (handled above). - // The output is delivered via the result message instead. - if contentStr := msg.Message.GetContentString(); contentStr != "" { - a.logger.Debug("skipping echoed user message", - zap.String("session_id", sessionID), - zap.Int("content_length", len(contentStr))) - return - } - - // Process content blocks looking for tool_result - contentBlocks := msg.Message.GetContentBlocks() - for _, block := range contentBlocks { - if block.Type != "tool_result" { - continue - } - - // Get and enrich the pending payload with result content - a.mu.Lock() - payload := a.pendingToolCalls[block.ToolUseID] - delete(a.pendingToolCalls, block.ToolUseID) - a.mu.Unlock() - - // Enrich payload with result content - contentStr := block.GetContentString() - if payload != nil && contentStr != "" { - a.normalizer.NormalizeToolResult(payload, contentStr) - } - - // Enrich payload with structured tool_use_result metadata - if payload != nil && len(msg.ToolUseResult) > 0 { - a.enrichFromToolUseResult(payload, msg.ToolUseResult) - } - - // If there's an error, set the error flag on the payload - // This ensures the frontend can display error messages properly - if payload != nil && block.IsError { - if payload.HttpRequest() != nil { - payload.HttpRequest().IsError = true - } - } - - // Determine status - status := "complete" - if block.IsError { - status = "error" - } - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: block.ToolUseID, - ToolStatus: status, - NormalizedPayload: payload, - }) - } -} - -// enrichFromToolUseResult populates payload-specific result fields from the -// top-level tool_use_result JSON on user messages. -// -// Supported formats: -// - TodoWrite: {oldTodos, newTodos} → populates ManageTodos items from newTodos -// - Task (sub-agent): {status, agentId, totalDurationMs, totalTokens, totalToolUseCount} -func (a *Adapter) enrichFromToolUseResult(payload *streams.NormalizedPayload, raw json.RawMessage) { - var data map[string]any - if err := json.Unmarshal(raw, &data); err != nil { - return - } - - switch payload.Kind() { - case streams.ToolKindManageTodos: - a.enrichTodoResult(payload, data) - case streams.ToolKindSubagentTask: - a.enrichSubagentResult(payload, data) - } -} - -// enrichTodoResult populates ManageTodos items from the newTodos array in tool_use_result. -func (a *Adapter) enrichTodoResult(payload *streams.NormalizedPayload, data map[string]any) { - if payload.ManageTodos() == nil { - return - } - newTodos, ok := data["newTodos"].([]any) - if !ok || len(newTodos) == 0 { - return - } - items := make([]streams.TodoItem, 0, len(newTodos)) - for _, item := range newTodos { - itemMap, ok := item.(map[string]any) - if !ok { - continue - } - desc := shared.GetString(itemMap, "content") - if desc == "" { - desc = shared.GetString(itemMap, "description") - } - items = append(items, streams.TodoItem{ - ID: shared.GetString(itemMap, "id"), - Description: desc, - Status: shared.GetString(itemMap, "status"), - ActiveForm: shared.GetString(itemMap, "activeForm"), - }) - } - if len(items) > 0 { - payload.ManageTodos().Items = items - } -} - -// enrichSubagentResult populates SubagentTask result fields from tool_use_result. -func (a *Adapter) enrichSubagentResult(payload *streams.NormalizedPayload, data map[string]any) { - if payload.SubagentTask() == nil { - return - } - st := payload.SubagentTask() - if v := shared.GetString(data, "status"); v != "" { - st.Status = v - } - if v := shared.GetString(data, "agentId"); v != "" { - st.AgentID = v - } - if v, ok := data["totalDurationMs"].(float64); ok { - st.DurationMs = int64(v) - } - if v, ok := data["totalTokens"].(float64); ok { - st.TotalTokens = int64(v) - } - if v, ok := data["totalToolUseCount"].(float64); ok { - st.ToolUseCount = int(v) - } -} - -// emitSessionMode sends a session_mode event to indicate the agent's mode has changed. -func (a *Adapter) emitSessionMode(sessionID, operationID, modeID string) { - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeSessionMode, - SessionID: sessionID, - OperationID: operationID, - CurrentModeID: modeID, - }) -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go deleted file mode 100644 index 6a6eb38c3f..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go +++ /dev/null @@ -1,344 +0,0 @@ -package streamjson - -import ( - "context" - "fmt" - "time" - - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/claudecode" - "go.uber.org/zap" -) - -// handleControlRequest processes control requests (permission requests) from the agent. -func (a *Adapter) handleControlRequest(requestID string, req *claudecode.ControlRequest) { - a.logger.Info("received control request", - zap.String("request_id", requestID), - zap.String("subtype", req.Subtype), - zap.String("tool_name", req.ToolName)) - - // Trace incoming control request (bypasses handleMessage path) - a.traceIncomingControl("control_request."+req.Subtype, map[string]any{ - "type": "control_request", - "request_id": requestID, - "request": req, - }) - - switch req.Subtype { - case claudecode.SubtypeCanUseTool: - a.handleToolPermission(requestID, req) - case claudecode.SubtypeHookCallback: - a.handleHookCallback(requestID, req) - default: - a.logger.Warn("unhandled control request subtype", - zap.String("subtype", req.Subtype)) - // Send error response - if err := a.client.SendControlResponse(&claudecode.ControlResponseMessage{ - Type: claudecode.MessageTypeControlResponse, - RequestID: requestID, - Response: &claudecode.ControlResponse{ - Subtype: "error", - Error: fmt.Sprintf("unhandled subtype: %s", req.Subtype), - }, - }); err != nil { - a.logger.Warn("failed to send error response", zap.Error(err)) - } - } -} - -// handleToolPermission processes can_use_tool permission requests. -// This is the fallback path when hooks are not registered or the hook responds "ask". -// Note: plan content and session-mode-clear for ExitPlanMode are handled in -// processToolUseBlock (which fires first when the tool_use block is streamed). -func (a *Adapter) handleToolPermission(requestID string, req *claudecode.ControlRequest) { - a.mu.RLock() - handler := a.permissionHandler - sessionID := a.sessionID - a.mu.RUnlock() - - isExitPlanMode := req.ToolName == toolExitPlanMode - - // Determine action type based on tool name - actionType := types.ActionTypeOther - switch req.ToolName { - case claudecode.ToolBash: - actionType = types.ActionTypeCommand - case claudecode.ToolWrite, claudecode.ToolEdit, claudecode.ToolNotebookEdit: - actionType = types.ActionTypeFileWrite - case claudecode.ToolRead, claudecode.ToolGlob, claudecode.ToolGrep: - actionType = types.ActionTypeFileRead - case claudecode.ToolWebFetch, claudecode.ToolWebSearch: - actionType = types.ActionTypeNetwork - } - - // Build title from tool name and key input - title := req.ToolName - if cmd, ok := req.Input["command"].(string); ok && req.ToolName == claudecode.ToolBash { - title = cmd - } else if path, ok := req.Input["file_path"].(string); ok { - title = fmt.Sprintf("%s: %s", req.ToolName, path) - } - - // Build permission options - options := []PermissionOption{ - {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, - {OptionID: "allowAlways", Name: "Allow Always", Kind: "allow_always"}, - {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, - } - - // Build permission request with Claude Code's requestID. - // The handler (process manager's handlePermissionRequest) will: - // 1. Send the permission_request notification to the frontend - // 2. Block waiting for user response - // 3. Return the response - // We pass PendingID so the handler uses Claude Code's requestID - // instead of generating a new one - this ensures the frontend and backend - // use the same ID for response lookup. - actionDetails := req.Input - if req.BlockedPaths != "" { - if actionDetails == nil { - actionDetails = make(map[string]any) - } - actionDetails["blocked_paths"] = req.BlockedPaths - } - - permReq := &PermissionRequest{ - SessionID: sessionID, - ToolCallID: req.ToolUseID, - Title: title, - Options: options, - ActionType: actionType, - ActionDetails: actionDetails, - PendingID: requestID, - } - - // If no handler, auto-allow - if handler == nil { - a.logger.Debug("auto-allowing tool (no handler)", - zap.String("tool", req.ToolName)) - a.sendPermissionResponse(requestID, claudecode.BehaviorAllow) - if isExitPlanMode { - a.scheduleExitPlanModeCompletion() - } - return - } - - // Call permission handler (blocking) with timeout to prevent indefinite hangs - timeout := a.cfg.GetPermissionTimeout() - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - resp, err := handler(ctx, permReq) - if err != nil { - // Check if it was a timeout - if ctx.Err() == context.DeadlineExceeded { - a.logger.Warn("permission request timed out, auto-denying with interrupt", - zap.String("request_id", requestID), - zap.String("tool", req.ToolName), - zap.Duration("timeout", timeout)) - - // Emit cancellation event so frontend closes the dialog - a.sendUpdate(AgentEvent{ - Type: streams.EventTypePermissionCancelled, - PendingID: requestID, - }) - - // Deny with interrupt to stop the current operation - interruptFlag := true - a.sendControlResult(requestID, &claudecode.PermissionResult{ - Behavior: claudecode.BehaviorDeny, - Interrupt: &interruptFlag, - Message: "Permission request timed out", - }, "permission_response.timeout") - return - } - - a.logger.Error("permission handler error", zap.Error(err)) - a.sendPermissionResponse(requestID, claudecode.BehaviorDeny) - return - } - - // Map response to behavior - behavior := claudecode.BehaviorAllow - if resp.Cancelled { - behavior = claudecode.BehaviorDeny - } else { - switch resp.OptionID { - case "allow", "allowAlways", "approve", "approveAlways": - behavior = claudecode.BehaviorAllow - case "deny", "reject", "decline": - behavior = claudecode.BehaviorDeny - } - } - - a.sendPermissionResponse(requestID, behavior) - - // ExitPlanMode: Claude Code ends the turn after approval without a result message. - // Schedule delayed completion to unblock Prompt(). - if isExitPlanMode && behavior == claudecode.BehaviorAllow { - a.scheduleExitPlanModeCompletion() - } -} - -// sendControlResult sends a success control response with the given result payload and traces it. -// result can be a *PermissionResult, map[string]any (for hooks), or any other JSON-serializable value. -func (a *Adapter) sendControlResult(requestID string, result any, traceEvent string) { - resp := &claudecode.ControlResponseMessage{ - Type: claudecode.MessageTypeControlResponse, - RequestID: requestID, - Response: &claudecode.ControlResponse{ - Subtype: "success", - Result: result, - }, - } - if err := a.client.SendControlResponse(resp); err != nil { - a.logger.Warn("failed to send control response", - zap.Error(err), zap.String("event", traceEvent)) - } - a.traceOutgoingControl(traceEvent, resp) -} - -// sendPermissionResponse sends a permission response to the agent. -func (a *Adapter) sendPermissionResponse(requestID string, behavior string) { - a.sendControlResult(requestID, &claudecode.PermissionResult{ - Behavior: behavior, - }, "permission_response") -} - -// handleControlCancel processes control_cancel_request messages. -// This cancels a pending permission request, closing the dialog in the UI. -func (a *Adapter) handleControlCancel(requestID string) { - a.logger.Info("received control_cancel_request", - zap.String("request_id", requestID)) - - a.traceIncomingControl("control_cancel_request", map[string]any{ - "type": "control_cancel_request", - "request_id": requestID, - }) - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypePermissionCancelled, - PendingID: requestID, - }) -} - -// handleHookCallback processes hook callback requests, dispatching based on callback ID. -// Hook responses use different payload formats than can_use_tool responses: -// - PreToolUse hooks: hookSpecificOutput with permissionDecision -func (a *Adapter) handleHookCallback(requestID string, req *claudecode.ControlRequest) { - hookEventName, _ := req.Input["hook_event_name"].(string) - a.logger.Info("received hook callback", - zap.String("request_id", requestID), - zap.String("callback_id", req.CallbackID), - zap.String("hook_event_name", hookEventName)) - - switch req.CallbackID { - case "tool_approval": - // Respond with "ask" to trigger a regular can_use_tool permission request. - // Claude Code will then send a separate can_use_tool control_request. - a.sendPreToolUseHookResponse(requestID, "ask", "Requires user approval") - - case "auto_approve": - // Auto-approve via hookSpecificOutput - a.sendPreToolUseHookResponse(requestID, "allow", "Auto-approved by SDK") - - // ExitPlanMode: Claude Code ends the turn after approval without a result message. - // Capture plan content from hook input and schedule delayed completion. - // Note: Claude Code sends hook data in the "input" field (not "hook_input"). - if toolName, ok := req.Input["tool_name"].(string); ok && toolName == toolExitPlanMode { - a.handleExitPlanModeHookApproval() - } - - default: - a.logger.Warn("unknown hook callback ID, auto-approving", - zap.String("callback_id", req.CallbackID)) - a.sendPreToolUseHookResponse(requestID, "allow", "Auto-approved (unknown callback)") - } -} - -// sendPreToolUseHookResponse sends a PreToolUse hook callback response. -// permissionDecision can be "allow", "deny", or "ask" (triggers can_use_tool flow). -func (a *Adapter) sendPreToolUseHookResponse(requestID, permissionDecision, reason string) { - a.sendControlResult(requestID, map[string]any{ - "hookSpecificOutput": map[string]any{ - "hookEventName": "PreToolUse", - "permissionDecision": permissionDecision, - "permissionDecisionReason": reason, - }, - }, "hook_response.pre_tool_use") -} - -// handleExitPlanModeHookApproval schedules completion for ExitPlanMode. -// Called when ExitPlanMode is auto-approved via hook callback. -// Note: plan content and session-mode-clear are handled in processToolUseBlock -// (which fires first when the tool_use block is streamed). -func (a *Adapter) handleExitPlanModeHookApproval() { - a.scheduleExitPlanModeCompletion() -} - -// exitPlanModeCompletionDelay is the time to wait after ExitPlanMode approval before -// emitting a synthetic completion. This allows trailing stream events to arrive first. -const exitPlanModeCompletionDelay = 2 * time.Second - -// scheduleExitPlanModeCompletion schedules a delayed completion for ExitPlanMode. -// Claude Code does not send a result message after ExitPlanMode approval — the turn -// simply ends. This goroutine waits briefly for trailing stream_events, then emits -// a complete event and signals Prompt() to unblock. -// Uses exitPlanPending atomic flag to prevent duplicate completion if a result -// message arrives before the delay expires. -func (a *Adapter) scheduleExitPlanModeCompletion() { - a.exitPlanPending.Store(true) - - a.mu.RLock() - sessionID := a.sessionID - operationID := a.operationID - a.mu.RUnlock() - - go func(expectedSessionID, expectedOperationID string) { - select { - case <-time.After(exitPlanModeCompletionDelay): - // Timer expired, proceed with completion - case <-a.ctx.Done(): - a.exitPlanPending.Store(false) - return - } - - // Guard against stale goroutine completing the wrong turn - a.mu.RLock() - currentSessionID := a.sessionID - currentOperationID := a.operationID - a.mu.RUnlock() - if currentSessionID != expectedSessionID || currentOperationID != expectedOperationID { - a.logger.Debug("ExitPlanMode completion skipped (stale operation)", - zap.String("expected_session_id", expectedSessionID), - zap.String("current_session_id", currentSessionID)) - return - } - - if !a.exitPlanPending.CompareAndSwap(true, false) { - a.logger.Debug("ExitPlanMode completion cancelled (result message arrived)") - return - } - - a.logger.Info("ExitPlanMode: emitting delayed completion", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID)) - - // Drain plan content - planContent := a.drainExitPlanContent() - - completeData := map[string]any{"is_error": false} - if planContent != "" { - completeData["plan_content"] = planContent - } - - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: sessionID, - OperationID: operationID, - Data: completeData, - }) - a.signalResultCompletion(true, "") - }(sessionID, operationID) -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go deleted file mode 100644 index 5d93593aec..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_prompt.go +++ /dev/null @@ -1,124 +0,0 @@ -package streamjson - -import ( - "context" - "fmt" - - "github.com/google/uuid" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" - v1 "github.com/kandev/kandev/pkg/api/v1" - "github.com/kandev/kandev/pkg/claudecode" - "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" -) - -// Prompt sends a prompt and waits for completion. -// Since stream-json protocol doesn't support multimodal content blocks, image attachments -// are saved to temp files in the workspace and the prompt is modified to reference them. -// Claude Code can then read these files with its Read tool. -func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error { - a.mu.Lock() - client := a.client - sessionID := a.sessionID - operationID := uuid.New().String() - a.operationID = operationID - // Create channel to wait for result - a.resultCh = make(chan resultComplete, 1) - a.mu.Unlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - // Handle attachments by saving to workspace and referencing in prompt - finalMessage := message - if len(attachments) > 0 { - saved, err := a.attachMgr.SaveAttachments(attachments) - if err != nil { - a.logger.Warn("failed to save attachments", zap.Error(err)) - } else if len(saved) > 0 { - finalMessage = shared.BuildAttachmentPrompt(saved) + message - } - } - - // Start prompt span — notification spans become children via getPromptTraceCtx() - promptCtx, promptSpan := shared.TraceProtocolRequest(ctx, shared.ProtocolStreamJSON, a.agentID, "prompt") - promptSpan.SetAttributes( - attribute.String("session_id", sessionID), - attribute.Int("prompt_length", len(finalMessage)), - ) - a.setPromptTraceCtx(promptCtx) - defer func() { - a.clearPromptTraceCtx() - promptSpan.End() - }() - - // Trace the outgoing user message payload - a.traceOutgoingSend("prompt", &claudecode.UserMessage{ - Type: claudecode.MessageTypeUser, - Message: claudecode.UserMessageBody{Role: "user", Content: finalMessage}, - }) - - a.logger.Info("sending prompt", - zap.String("session_id", sessionID), - zap.String("operation_id", operationID), - zap.Int("attachments", len(attachments))) - - // Send user message - if err := client.SendUserMessage(finalMessage); err != nil { - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return fmt.Errorf("failed to send user message: %w", err) - } - - // Wait for result or context cancellation - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - select { - case <-ctx.Done(): - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - return ctx.Err() - case result := <-resultCh: - a.mu.Lock() - a.resultCh = nil - a.mu.Unlock() - if !result.success && result.err != "" { - return fmt.Errorf("prompt failed: %s", result.err) - } - a.logger.Info("prompt completed", - zap.String("operation_id", operationID), - zap.Bool("success", result.success)) - // Clean up saved attachments — agent has finished reading them - a.attachMgr.Cleanup() - return nil - } -} - -// Cancel interrupts the current operation. -func (a *Adapter) Cancel(ctx context.Context) error { - a.mu.RLock() - client := a.client - a.mu.RUnlock() - - if client == nil { - return fmt.Errorf("adapter not initialized") - } - - a.logger.Info("cancelling operation") - - // Send interrupt control request - req := &claudecode.SDKControlRequest{ - Type: claudecode.MessageTypeControlRequest, - RequestID: uuid.New().String(), - Request: claudecode.SDKControlRequestBody{ - Subtype: claudecode.SubtypeInterrupt, - }, - } - a.traceOutgoingControl("interrupt", req) - return client.SendControlRequest(req) -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go deleted file mode 100644 index f397702a15..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go +++ /dev/null @@ -1,227 +0,0 @@ -package streamjson - -import ( - "strings" - - "github.com/kandev/kandev/internal/agentctl/types/streams" - "github.com/kandev/kandev/pkg/claudecode" - "go.uber.org/zap" -) - -// updateContextWindowFromModelUsage updates context window from model_usage stats. -// Returns the updated contextUsed and contextSize. -func (a *Adapter) updateContextWindowFromModelUsage(msg *claudecode.CLIMessage) (contextUsed, contextSize int64) { - a.mu.Lock() - modelName := a.mainModelName - if msg.ModelUsage != nil && modelName != "" { - if modelStats, ok := msg.ModelUsage[modelName]; ok && modelStats.ContextWindow != nil { - a.mainModelContextWindow = *modelStats.ContextWindow - a.logger.Debug("updated context window from model_usage", - zap.String("model", modelName), - zap.Int64("context_window", a.mainModelContextWindow)) - } - } - contextSize = a.mainModelContextWindow - contextUsed = a.contextTokensUsed - a.mu.Unlock() - return contextUsed, contextSize -} - -// drainExitPlanContent atomically reads and clears the captured ExitPlanMode plan content. -func (a *Adapter) drainExitPlanContent() string { - a.mu.Lock() - defer a.mu.Unlock() - content := a.exitPlanContent - a.exitPlanContent = "" - return content -} - -// drainPendingToolCalls atomically removes and returns all pending tool call IDs. -func (a *Adapter) drainPendingToolCalls() []string { - a.mu.Lock() - pendingTools := make([]string, 0, len(a.pendingToolCalls)) - for toolID := range a.pendingToolCalls { - pendingTools = append(pendingTools, toolID) - } - a.pendingToolCalls = make(map[string]*streams.NormalizedPayload) // Clear pending - a.mu.Unlock() - return pendingTools -} - -// extractResultText extracts text from the result message (only if not already streamed). -func (a *Adapter) extractResultText(msg *claudecode.CLIMessage) string { - if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { - return resultData.Text - } - if resultStr := msg.GetResultString(); resultStr != "" { - return resultStr - } - return "" -} - -// extractResultErrorMsg extracts the error message from a result. -func (a *Adapter) extractResultErrorMsg(msg *claudecode.CLIMessage) string { - if !msg.IsError { - return "" - } - if len(msg.Errors) > 0 { - return strings.Join(msg.Errors, "; ") - } - return "" -} - -// signalResultCompletion sends the result to the waiting prompt goroutine. -func (a *Adapter) signalResultCompletion(success bool, errMsg string) { - a.mu.RLock() - resultCh := a.resultCh - a.mu.RUnlock() - - if resultCh == nil { - return - } - select { - case resultCh <- resultComplete{success: success, err: errMsg}: - a.logger.Debug("signaled prompt completion") - default: - a.logger.Warn("result channel full, dropping signal") - } -} - -// handleResultMessage processes result (completion) messages. -func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { - // Cancel any pending ExitPlanMode delayed completion — this result message - // will handle completion instead. - a.exitPlanPending.Store(false) - - a.logger.Info("received result message", - zap.Bool("is_error", msg.IsError), - zap.Int("num_turns", msg.NumTurns)) - - // Update session ID from result if provided - if resultData := msg.GetResultData(); resultData != nil && resultData.SessionID != "" { - a.mu.Lock() - a.sessionID = resultData.SessionID - sessionID = resultData.SessionID - a.mu.Unlock() - } - - // Auto-complete any pending tool calls that didn't receive explicit tool_result - // This can happen if the tool result is not sent as a separate assistant message - pendingTools := a.drainPendingToolCalls() - for _, toolID := range pendingTools { - a.logger.Debug("auto-completing pending tool call on result", - zap.String("tool_call_id", toolID)) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeToolUpdate, - SessionID: sessionID, - OperationID: operationID, - ToolCallID: toolID, - ToolStatus: "complete", - }) - } - - // Extract actual context window from model_usage if available - contextUsed, contextSize := a.updateContextWindowFromModelUsage(msg) - - // Emit final accurate context window event - if contextUsed > 0 { - a.emitContextWindow(sessionID, operationID, contextUsed, contextSize) - } - - // Check if text was already streamed this turn via assistant messages - a.mu.RLock() - textWasStreamed := a.streamingTextSentThisTurn - a.mu.RUnlock() - - // Only send result.text if NO text was streamed this turn. - // If text was already streamed via assistant messages, result.text is a duplicate. - // This prevents the same content from appearing twice in the conversation. - if !textWasStreamed { - if resultText := a.extractResultText(msg); resultText != "" { - a.logger.Debug("sending result text as message_chunk (no streaming text this turn)", - zap.Int("text_length", len(resultText))) - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeMessageChunk, - SessionID: sessionID, - OperationID: operationID, - Text: resultText, - }) - } - } else { - a.logger.Debug("skipping result text (streaming text already sent this turn)") - } - - // Reset the streaming flag for next turn - a.mu.Lock() - a.streamingTextSentThisTurn = false - a.mu.Unlock() - - // Commit pending assistant UUID as last message UUID - a.mu.Lock() - if a.pendingAssistantUUID != "" { - a.lastMessageUUID = a.pendingAssistantUUID - a.pendingAssistantUUID = "" - } - lastMsgUUID := a.lastMessageUUID - a.mu.Unlock() - - // Build error message from errors array if available - errorMsg := a.extractResultErrorMsg(msg) - - // Drain captured plan content from ExitPlanMode (if any) - planContent := a.drainExitPlanContent() - - // Send completion event AFTER any result text chunk - completeData := map[string]any{ - "cost_usd": msg.CostUSD, - "duration_ms": msg.DurationMS, - "num_turns": msg.NumTurns, - "input_tokens": msg.TotalInputTokens, - "output_tokens": msg.TotalOutputTokens, - "is_error": msg.IsError, - } - if planContent != "" { - completeData["plan_content"] = planContent - } - if len(msg.Errors) > 0 { - completeData["errors"] = msg.Errors - } - if lastMsgUUID != "" { - completeData["last_message_uuid"] = lastMsgUUID - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeComplete, - SessionID: sessionID, - OperationID: operationID, - Error: errorMsg, - Data: completeData, - LastMessageUUID: lastMsgUUID, - }) - - // Signal completion - a.signalResultCompletion(!msg.IsError, errorMsg) - - // Send error event if failed - if msg.IsError { - a.sendResultError(msg, sessionID, operationID) - } -} - -// sendResultError emits an error event with the best available error message. -func (a *Adapter) sendResultError(msg *claudecode.CLIMessage, sessionID, operationID string) { - errMsg := "prompt failed" - // Use errors array first (most specific), then fallback to result string/data - if len(msg.Errors) > 0 { - errMsg = strings.Join(msg.Errors, "; ") - } else if errStr := msg.GetResultString(); errStr != "" { - errMsg = errStr - } else if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { - errMsg = resultData.Text - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeError, - SessionID: sessionID, - OperationID: operationID, - Error: errMsg, - }) -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_session.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_session.go deleted file mode 100644 index b521946149..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_session.go +++ /dev/null @@ -1,66 +0,0 @@ -package streamjson - -import ( - "context" - - "github.com/google/uuid" - "github.com/kandev/kandev/internal/agentctl/types" - "github.com/kandev/kandev/internal/agentctl/types/streams" - "go.uber.org/zap" -) - -// emitPendingCommands emits any pending available commands for the given session. -// Must be called after the mutex is unlocked. -func (a *Adapter) emitPendingCommands(sessionID string, commands []streams.AvailableCommand) { - if len(commands) == 0 { - return - } - a.sendUpdate(AgentEvent{ - Type: streams.EventTypeAvailableCommands, - SessionID: sessionID, - AvailableCommands: commands, - }) - a.logger.Debug("emitted pending slash commands", - zap.String("session_id", sessionID), - zap.Int("count", len(commands))) -} - -// takePendingCommands atomically takes and clears pending commands. -// Must be called with the mutex held. -func (a *Adapter) takePendingCommands() []streams.AvailableCommand { - commands := a.pendingAvailableCommands - a.pendingAvailableCommands = nil - return commands -} - -// NewSession creates a new stream-json session. -// Note: Sessions are created implicitly with the first prompt. -// The mcpServers parameter is ignored as this protocol handles MCP separately. -func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { - a.mu.Lock() - sessionID := uuid.New().String() - a.sessionID = sessionID - pendingCommands := a.takePendingCommands() - a.mu.Unlock() - - a.attachMgr.SetSessionID(sessionID) - a.logger.Info("created new session placeholder", zap.String("session_id", sessionID)) - a.emitPendingCommands(sessionID, pendingCommands) - - return sessionID, nil -} - -// LoadSession resumes an existing stream-json session. -// The session ID will be passed to the agent via --resume flag (handled by process manager). -func (a *Adapter) LoadSession(ctx context.Context, sessionID string, _ []types.McpServer) error { - a.mu.Lock() - a.sessionID = sessionID - pendingCommands := a.takePendingCommands() - a.mu.Unlock() - - a.attachMgr.SetSessionID(sessionID) - a.logger.Info("loaded session", zap.String("session_id", sessionID)) - a.emitPendingCommands(sessionID, pendingCommands) - - return nil -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_tracing.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_tracing.go deleted file mode 100644 index 39ac764cac..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_tracing.go +++ /dev/null @@ -1,56 +0,0 @@ -package streamjson - -import ( - "context" - "encoding/json" - - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" -) - -// traceIncomingControl traces and logs an incoming control-plane message. -// Used for control_request messages that bypass the regular handleMessage path. -func (a *Adapter) traceIncomingControl(eventType string, msg any) { - if !shared.DebugEnabled() { - return - } - rawData, _ := json.Marshal(msg) - shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, eventType, rawData) - shared.TraceControlMessage(a.getPromptTraceCtx(), shared.ProtocolStreamJSON, a.agentID, - "recv", eventType, rawData) -} - -// traceOutgoingControl traces and logs an outgoing control-plane message. -// Used for control_response messages sent back to the Claude CLI. -func (a *Adapter) traceOutgoingControl(eventType string, msg any) { - if !shared.DebugEnabled() { - return - } - rawData, _ := json.Marshal(msg) - shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, eventType, rawData) - shared.TraceControlMessage(a.getPromptTraceCtx(), shared.ProtocolStreamJSON, a.agentID, - "send", eventType, rawData) -} - -// traceOutgoingSend traces and logs an outgoing data-plane message to Claude Code. -// Uses the current prompt trace context as parent span. -func (a *Adapter) traceOutgoingSend(eventType string, msg any) { - if !shared.DebugEnabled() { - return - } - rawData, _ := json.Marshal(msg) - shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, "send."+eventType, rawData) - shared.TraceControlMessage(a.getPromptTraceCtx(), shared.ProtocolStreamJSON, a.agentID, - "send", eventType, rawData) -} - -// traceOutgoingSendCtx is like traceOutgoingSend but uses an explicit context as parent span. -// Used for messages sent outside of a prompt lifecycle (e.g., initialize). -func (a *Adapter) traceOutgoingSendCtx(ctx context.Context, eventType string, msg any) { - if !shared.DebugEnabled() { - return - } - rawData, _ := json.Marshal(msg) - shared.LogRawEvent(shared.ProtocolStreamJSON, a.agentID, "send."+eventType, rawData) - shared.TraceControlMessage(ctx, shared.ProtocolStreamJSON, a.agentID, - "send", eventType, rawData) -} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/testdata/streamjson-messages.jsonl b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/testdata/streamjson-messages.jsonl deleted file mode 100644 index 4034a4632c..0000000000 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/testdata/streamjson-messages.jsonl +++ /dev/null @@ -1,12 +0,0 @@ -{"key":"Edit","input":{"tool_name":"Edit","args":{"file_path":"/workspace/app.ts","old_string":"const x = 1;","new_string":"const x = 2;","replace_all":false}},"expected":{"tool_type":"tool_edit","file_path":"/workspace/app.ts","old_content":"const x = 1;","new_content":"const x = 2;"}} -{"key":"Edit:2","input":{"tool_name":"Edit","args":{"file_path":"/workspace/src/main.go","old_string":"func main() {\n\tfmt.Println(\"hello\")\n}","new_string":"func main() {\n\tfmt.Println(\"hello world\")\n}","replace_all":false}},"expected":{"tool_type":"tool_edit","file_path":"/workspace/src/main.go","old_content":"func main() {\n\tfmt.Println(\"hello\")\n}","new_content":"func main() {\n\tfmt.Println(\"hello world\")\n}"}} -{"key":"Edit:3","input":{"tool_name":"Edit","args":{"file_path":"","old_string":"a","new_string":"b"}},"expected":{"tool_type":"tool_edit","file_path":"","old_content":"a","new_content":"b"}} -{"key":"Read","input":{"tool_name":"Read","args":{"file_path":"/workspace/config.json"}},"expected":{"tool_type":"tool_read","file_path":"/workspace/config.json"}} -{"key":"Read:2","input":{"tool_name":"Read","args":{"file_path":"/workspace/src/index.ts","offset":10,"limit":50}},"expected":{"tool_type":"tool_read","file_path":"/workspace/src/index.ts"}} -{"key":"Read:3","input":{"tool_name":"Read","args":{"file_path":""}},"expected":{"tool_type":"tool_read","file_path":""}} -{"key":"Bash","input":{"tool_name":"Bash","args":{"command":"ls -la","description":"List all files and directories"}},"expected":{"tool_type":"tool_execute","command":"ls -la","description":"List all files and directories"}} -{"key":"Bash:2","input":{"tool_name":"Bash","args":{"command":"npm test -- --coverage","description":""}},"expected":{"tool_type":"tool_execute","command":"npm test -- --coverage"}} -{"key":"Bash:3","input":{"tool_name":"Bash","args":{"command":"go build ./..."}},"expected":{"tool_type":"tool_execute","command":"go build ./..."}} -{"key":"Glob","input":{"tool_name":"Glob","args":{"pattern":"**/*.ts"}},"expected":{"tool_type":"tool_read"}} -{"key":"Grep","input":{"tool_name":"Grep","args":{"pattern":"TODO","path":"/workspace"}},"expected":{"tool_type":"tool_read"}} -{"key":"WebSearch","input":{"tool_name":"WebSearch","args":{"query":"golang testing"}},"expected":{"tool_type":"tool_call"}} diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index 7ee1f3560c..0b1267756d 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -42,6 +43,10 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) if workDir == "" { return &PromptResponse{Success: false, Error: "work_dir is required for ACP inference"}, nil } + resolvedCmd := resolveProbeCommand(cfg.Command[0]) + if resolvedCmd == "" { + return &PromptResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP command", cfg.Command[0])}, nil + } startTime := time.Now() @@ -53,8 +58,10 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) zap.String("model", req.Model), zap.Strings("command", args)) - // Start the agent process - cmd := exec.CommandContext(ctx, args[0], args[1:]...) + // 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 + cmd := exec.CommandContext(ctx, resolvedCmd, args[1:]...) cmd.Dir = workDir stdin, err := cmd.StdinPipe() @@ -78,7 +85,7 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) }() // Execute ACP protocol - response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt) + response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt, req.Model, req.Mode) if err != nil { return &PromptResponse{ Success: false, @@ -95,14 +102,17 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) }, nil } -// executeACPSession performs the ACP handshake, creates a session, sends the prompt, -// and collects the response text. +// executeACPSession performs the ACP handshake, creates a session, optionally +// sets the session model and mode, sends the prompt, and collects the response +// text. func (e *ACPInferenceExecutor) executeACPSession( ctx context.Context, stdin io.Writer, stdout io.Reader, workDir string, prompt string, + model string, + mode string, ) (string, error) { // Collect response text from updates var responseText strings.Builder @@ -150,6 +160,28 @@ func (e *ACPInferenceExecutor) executeACPSession( sessionID := sessionResp.SessionId + // Optionally set the session model before prompting. ACP-first agents + // declare no CLI ModelFlag, so `--model` is not appended at spawn time; + // the model has to be applied over the ACP protocol here. + if model != "" { + if _, err := conn.UnstableSetSessionModel(ctx, acp.UnstableSetSessionModelRequest{ + SessionId: sessionID, + ModelId: acp.UnstableModelId(model), + }); err != nil { + return "", fmt.Errorf("ACP session/set_model failed: %w", err) + } + } + + // Optionally set the session mode before prompting. + if mode != "" { + if _, err := conn.SetSessionMode(ctx, acp.SetSessionModeRequest{ + SessionId: sessionID, + ModeId: acp.SessionModeId(mode), + }); err != nil { + return "", fmt.Errorf("ACP session/set_mode failed: %w", err) + } + } + // Send prompt and wait for completion _, err = conn.Prompt(ctx, acp.PromptRequest{ SessionId: sessionID, @@ -166,7 +198,225 @@ func (e *ACPInferenceExecutor) executeACPSession( return result, nil } -// buildACPCommand builds the command arguments for ACP inference. +// Probe runs an ephemeral ACP handshake (initialize + session/new) to discover +// agent capabilities, auth methods, models, and modes. It does not send a prompt. +func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*ProbeResponse, error) { + if req.InferenceConfig == nil { + return &ProbeResponse{Success: false, Error: "inference config is required"}, nil + } + cfg := req.InferenceConfig + if len(cfg.Command) == 0 { + return &ProbeResponse{Success: false, Error: "inference command is empty"}, nil + } + workDir := cfg.WorkDir + if workDir == "" { + return &ProbeResponse{Success: false, Error: "work_dir is required for ACP probe"}, nil + } + resolvedCmd := resolveProbeCommand(cfg.Command[0]) + if resolvedCmd == "" { + return &ProbeResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP probe command", cfg.Command[0])}, nil + } + + startTime := time.Now() + + // Probes intentionally omit the model flag so session/new returns the agent's + // default model and the complete availableModels list. + args := buildACPCommand(cfg, "") + + e.logger.Info("starting ACP probe", + zap.String("agent_id", req.AgentID), + zap.Strings("command", args)) + + // Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that + // the executable name is not derived from tainted input. + //nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags + cmd := exec.CommandContext(ctx, resolvedCmd, args[1:]...) + cmd.Dir = workDir + + stdin, err := cmd.StdinPipe() + if err != nil { + return &ProbeResponse{Success: false, Error: fmt.Sprintf("stdin pipe: %v", err)}, nil + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return &ProbeResponse{Success: false, Error: fmt.Sprintf("stdout pipe: %v", err)}, nil + } + if err := cmd.Start(); err != nil { + return &ProbeResponse{Success: false, Error: fmt.Sprintf("start: %v", err)}, nil + } + defer func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + + resp, err := e.probeACPSession(ctx, stdin, stdout, workDir) + if err != nil { + return &ProbeResponse{ + Success: false, + Error: err.Error(), + DurationMs: int(time.Since(startTime).Milliseconds()), + }, nil + } + + resp.Success = true + resp.DurationMs = int(time.Since(startTime).Milliseconds()) + return resp, nil +} + +// probeACPSession performs initialize + session/new and returns the parsed +// capabilities, without sending any prompt or running session/prompt. After +// session/new, it briefly drains out-of-band notifications to capture the +// `available_commands_update` notification which some agents emit post-session. +func (e *ACPInferenceExecutor) probeACPSession( + ctx context.Context, + stdin io.Writer, + stdout io.Reader, + workDir string, +) (*ProbeResponse, error) { + var mu sync.Mutex + var commands []ProbeCommand + gotCommands := make(chan struct{}, 1) + updateHandler := func(n acp.SessionNotification) { + if n.Update.AvailableCommandsUpdate == nil { + return + } + mu.Lock() + commands = commands[:0] + for _, c := range n.Update.AvailableCommandsUpdate.AvailableCommands { + commands = append(commands, ProbeCommand{Name: c.Name, Description: c.Description}) + } + mu.Unlock() + select { + case gotCommands <- struct{}{}: + default: + } + } + + client := acpclient.NewClient( + acpclient.WithLogger(e.logger), + acpclient.WithWorkspaceRoot(workDir), + acpclient.WithUpdateHandler(updateHandler), + ) + + conn := acp.NewClientSideConnection(client, stdin, stdout) + conn.SetLogger(slog.Default().With("component", "acp-probe")) + + initResp, err := conn.Initialize(ctx, acp.InitializeRequest{ + ProtocolVersion: acp.ProtocolVersionNumber, + ClientInfo: &acp.Implementation{ + Name: "kandev-probe", + Version: "1.0.0", + }, + }) + if err != nil { + return nil, fmt.Errorf("ACP initialize failed: %w", err) + } + + sessionResp, err := conn.NewSession(ctx, acp.NewSessionRequest{ + Cwd: workDir, + McpServers: []acp.McpServer{}, + }) + if err != nil { + return nil, fmt.Errorf("ACP session/new failed: %w", err) + } + + // Wait up to 1s for the available_commands_update notification. Agents + // that don't advertise commands (or push them later) simply yield an + // empty Commands slice. + select { + case <-gotCommands: + case <-time.After(1 * time.Second): + case <-ctx.Done(): + } + + out := buildInitProbeFields(initResp) + applySessionProbeFields(out, sessionResp) + mu.Lock() + out.Commands = append([]ProbeCommand(nil), commands...) + mu.Unlock() + return out, nil +} + +// buildInitProbeFields populates agent info, protocol version, capabilities and +// auth methods from an ACP initialize response. +func buildInitProbeFields(initResp acp.InitializeResponse) *ProbeResponse { + out := &ProbeResponse{ + ProtocolVersion: int(initResp.ProtocolVersion), + LoadSession: initResp.AgentCapabilities.LoadSession, + PromptCapabilities: ProbePromptCapabilities{ + Image: initResp.AgentCapabilities.PromptCapabilities.Image, + Audio: initResp.AgentCapabilities.PromptCapabilities.Audio, + EmbeddedContext: initResp.AgentCapabilities.PromptCapabilities.EmbeddedContext, + }, + } + if initResp.AgentInfo != nil { + out.AgentName = initResp.AgentInfo.Name + out.AgentVersion = initResp.AgentInfo.Version + } + for _, m := range initResp.AuthMethods { + out.AuthMethods = append(out.AuthMethods, ProbeAuthMethod{ + ID: string(m.Id), //nolint:unconvert // AuthMethodId is a named string type; conversion required + Name: m.Name, + Description: derefString(m.Description), + Meta: m.Meta, + }) + } + return out +} + +// applySessionProbeFields populates models and modes from an ACP session/new response. +func applySessionProbeFields(out *ProbeResponse, sessionResp acp.NewSessionResponse) { + if sessionResp.Models != nil { + out.CurrentModelID = string(sessionResp.Models.CurrentModelId) + for _, m := range sessionResp.Models.AvailableModels { + out.Models = append(out.Models, ProbeModel{ + ID: string(m.ModelId), + Name: m.Name, + Description: derefString(m.Description), + Meta: m.Meta, + }) + } + } + if sessionResp.Modes != nil { + out.CurrentModeID = string(sessionResp.Modes.CurrentModeId) + for _, m := range sessionResp.Modes.AvailableModes { + out.Modes = append(out.Modes, ProbeMode{ + ID: string(m.Id), + Name: m.Name, + Description: derefString(m.Description), + Meta: m.Meta, + }) + } + } +} + +func derefString(p *string) string { + if p == nil { + return "" + } + return *p +} + +// allowedProbeCommands maps each permitted executable base name to a +// constant string literal. Spawning must pass one of these literal strings +// to exec.Command so CodeQL's taint tracker can see that the command name +// is not derived from untrusted input — even though the value is +// semantically the same as the base name taken from InferenceConfig.Command. +var allowedProbeCommands = map[string]string{ + "npx": "npx", + "auggie": "auggie", + "opencode": "opencode", +} + +// resolveProbeCommand validates and returns a hard-coded executable name for +// the given command. Returns the empty string if the command is not allowed. +func resolveProbeCommand(name string) string { + return allowedProbeCommands[filepath.Base(name)] +} + +// buildACPCommand builds the command arguments for ACP inference. The model +// parameter is a no-op for ACP-first agents (they have no ModelFlag); model +// selection is applied via the ACP session/set_model protocol call instead. func buildACPCommand(cfg *InferenceConfigDTO, model string) []string { args := make([]string, len(cfg.Command)) copy(args, cfg.Command) diff --git a/apps/backend/internal/agentctl/server/utility/handler.go b/apps/backend/internal/agentctl/server/utility/handler.go index 4971418e94..14359f000a 100644 --- a/apps/backend/internal/agentctl/server/utility/handler.go +++ b/apps/backend/internal/agentctl/server/utility/handler.go @@ -25,6 +25,42 @@ func NewHandler(_ string, logger *zap.Logger) *Handler { func (h *Handler) RegisterRoutes(api *gin.RouterGroup) { aux := api.Group("/inference") aux.POST("/prompt", h.handlePrompt) + aux.POST("/probe", h.handleProbe) +} + +// handleProbe handles POST /api/v1/inference/probe +func (h *Handler) handleProbe(c *gin.Context) { + var req ProbeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ProbeResponse{ + Success: false, + Error: "invalid request: " + err.Error(), + }) + return + } + + h.logger.Info("executing ACP probe", zap.String("agent_id", req.AgentID)) + + resp, err := h.executor.Probe(c.Request.Context(), &req) + if err != nil { + h.logger.Error("ACP probe failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, ProbeResponse{ + Success: false, + Error: "probe execution failed", + }) + return + } + + if !resp.Success { + h.logger.Warn("ACP probe unsuccessful", zap.String("error", resp.Error)) + } else { + h.logger.Info("ACP probe completed", + zap.Int("duration_ms", resp.DurationMs), + zap.Int("models", len(resp.Models)), + zap.Int("modes", len(resp.Modes))) + } + + c.JSON(http.StatusOK, resp) } // handlePrompt handles POST /api/v1/inference/prompt diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index 7154326a33..5ec044c427 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -14,6 +14,10 @@ type PromptRequest struct { // Model is the model to use (e.g., "claude-haiku-4-5"). Model string `json:"model,omitempty"` + // Mode is the optional session mode to set before sending the prompt. + // If empty, no session/set_mode call is made and the agent default is used. + Mode string `json:"mode,omitempty"` + // InferenceConfig is the agent's inference configuration. // This is passed from the backend which has access to the agent registry. InferenceConfig *InferenceConfigDTO `json:"inference_config,omitempty"` @@ -22,6 +26,102 @@ type PromptRequest struct { MaxTokens int `json:"max_tokens,omitempty"` } +// ProbeRequest is the request for probing an agent's capabilities. +// It runs initialize + session/new against an ephemeral ACP subprocess and +// returns the discovered agent info, auth methods, models, and modes without +// sending any prompt. +type ProbeRequest struct { + // AgentID is the agent to probe (e.g., "claude-acp", "codex-acp"). + AgentID string `json:"agent_id" binding:"required"` + + // InferenceConfig is the agent's inference configuration. + // Command and WorkDir are required; Model is intentionally omitted for probes. + InferenceConfig *InferenceConfigDTO `json:"inference_config,omitempty"` +} + +// ProbeResponse is the response from probing an agent. +type ProbeResponse struct { + // Success indicates if the probe completed successfully. + Success bool `json:"success"` + + // Error is the error message if the probe failed. + Error string `json:"error,omitempty"` + + // DurationMs is the probe duration in milliseconds. + DurationMs int `json:"duration_ms,omitempty"` + + // AgentName is the agent name reported in initialize response (if any). + AgentName string `json:"agent_name,omitempty"` + // AgentVersion is the agent version reported in initialize response (if any). + AgentVersion string `json:"agent_version,omitempty"` + + // ProtocolVersion is the negotiated ACP protocol version. + ProtocolVersion int `json:"protocol_version,omitempty"` + + // AuthMethods are the authentication methods advertised by the agent. + AuthMethods []ProbeAuthMethod `json:"auth_methods,omitempty"` + + // Models are the models the agent advertises from session/new. + Models []ProbeModel `json:"models,omitempty"` + // CurrentModelID is the default/current model selected by the agent. + CurrentModelID string `json:"current_model_id,omitempty"` + + // Modes are the session modes the agent advertises from session/new. + Modes []ProbeMode `json:"modes,omitempty"` + // CurrentModeID is the default/current mode selected by the agent. + CurrentModeID string `json:"current_mode_id,omitempty"` + + // Commands are the slash commands the agent advertises via the + // `available_commands_update` session notification (drained briefly + // after session/new). + Commands []ProbeCommand `json:"commands,omitempty"` + + // LoadSession indicates if the agent supports session/load. + LoadSession bool `json:"load_session,omitempty"` + // PromptCapabilities reports which content block types the agent accepts. + PromptCapabilities ProbePromptCapabilities `json:"prompt_capabilities,omitempty"` +} + +// ProbeAuthMethod is a single advertised authentication method. +type ProbeAuthMethod struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// ProbeModel is a single advertised model. Meta carries agent-specific +// extras from ACP's `_meta` field — e.g. GitHub Copilot exposes +// `copilotUsage` ("1x", "0.33x", "0x") and `copilotEnablement` here. +type ProbeModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// ProbeMode is a single advertised session mode. +type ProbeMode struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// ProbeCommand is a single slash command advertised by the agent via the +// ACP `available_commands_update` session notification. +type ProbeCommand struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// ProbePromptCapabilities reports the agent's prompt input capabilities. +type ProbePromptCapabilities struct { + Image bool `json:"image,omitempty"` + Audio bool `json:"audio,omitempty"` + EmbeddedContext bool `json:"embedded_context,omitempty"` +} + // InferenceConfigDTO is the inference configuration passed from backend to agentctl. type InferenceConfigDTO struct { // Command is the ACP command for one-shot inference. diff --git a/apps/backend/internal/debug/fixture.go b/apps/backend/internal/debug/fixture.go index 2f5d4cc969..f5c1adcc04 100644 --- a/apps/backend/internal/debug/fixture.go +++ b/apps/backend/internal/debug/fixture.go @@ -10,16 +10,14 @@ import ( "time" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp" - "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/streamjson" "github.com/kandev/kandev/internal/agentctl/types/streams" v1 "github.com/kandev/kandev/pkg/api/v1" ) // Protocol and file type constants. const ( - protocolACP = "acp" - protocolStreamJSON = "streamjson" - filetypeUnknown = "unknown" + protocolACP = "acp" + filetypeUnknown = "unknown" ) // DiscoveredFile represents a discovered fixture file. @@ -354,12 +352,7 @@ func parseProtocolFromFilename(filename string) string { // isKnownProtocol checks if the protocol is one of the known protocols. func isKnownProtocol(protocol string) bool { - switch protocol { - case protocolACP, protocolStreamJSON, "codex", "opencode": - return true - default: - return false - } + return protocol == protocolACP } // countLines counts the number of lines in a file. @@ -384,8 +377,6 @@ func getNormalizerForProtocol(protocol string) normalizer { switch protocol { case protocolACP: return acp.NewNormalizer() - case protocolStreamJSON: - return streamjson.NewNormalizer() default: return nil } @@ -421,20 +412,12 @@ func normalizeFixtureFile(filePath string) ([]NormalizedFixture, error) { } // Extract tool info and normalize based on protocol - switch protocol { - case protocolACP: + if protocol == protocolACP { args, _ := input.Input["args"].(map[string]any) kind, _ := args["kind"].(string) fixture.ToolName = kind fixture.ToolType = acp.DetectToolOperationType(kind, args) fixture.Payload = norm.NormalizeToolCall(kind, args) - - case protocolStreamJSON: - toolName, _ := input.Input["tool_name"].(string) - args, _ := input.Input["args"].(map[string]any) - fixture.ToolName = toolName - fixture.ToolType = streamjson.DetectStreamJSONToolType(toolName) - fixture.Payload = norm.NormalizeToolCall(toolName, args) } fixtures = append(fixtures, fixture) diff --git a/apps/backend/internal/mcp/handlers/config_agent_handlers.go b/apps/backend/internal/mcp/handlers/config_agent_handlers.go index bfa7d30ae1..28cf8ca6e7 100644 --- a/apps/backend/internal/mcp/handlers/config_agent_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_agent_handlers.go @@ -87,10 +87,9 @@ func (h *Handlers) handleCreateAgentProfile(ctx context.Context, msg *ws.Message } profile, err := h.agentSettingsCtrl.CreateProfile(ctx, agentsettingscontroller.CreateProfileRequest{ - AgentID: req.AgentID, - Name: req.Name, - Model: req.Model, - AutoApprove: req.AutoApprove, + AgentID: req.AgentID, + Name: req.Name, + Model: req.Model, }) if err != nil { h.logger.Error("failed to create agent profile", zap.Error(err)) @@ -124,10 +123,10 @@ func (h *Handlers) handleDeleteAgentProfile(ctx context.Context, msg *ws.Message func (h *Handlers) handleUpdateAgentProfile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - ProfileID string `json:"profile_id"` - Name *string `json:"name"` - Model *string `json:"model"` - AutoApprove *bool `json:"auto_approve"` + ProfileID string `json:"profile_id"` + Name *string `json:"name"` + Model *string `json:"model"` + Mode *string `json:"mode"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) @@ -137,10 +136,10 @@ func (h *Handlers) handleUpdateAgentProfile(ctx context.Context, msg *ws.Message } profile, err := h.agentSettingsCtrl.UpdateProfile(ctx, agentsettingscontroller.UpdateProfileRequest{ - ID: req.ProfileID, - Name: req.Name, - Model: req.Model, - AutoApprove: req.AutoApprove, + ID: req.ProfileID, + Name: req.Name, + Model: req.Model, + Mode: req.Mode, }) if err != nil { h.logger.Error("failed to update agent profile", zap.Error(err)) diff --git a/apps/backend/internal/utility/controller/controller.go b/apps/backend/internal/utility/controller/controller.go index a065f879d7..91478d0234 100644 --- a/apps/backend/internal/utility/controller/controller.go +++ b/apps/backend/internal/utility/controller/controller.go @@ -93,7 +93,7 @@ func (c *Controller) GetTemplateVariables(ctx context.Context) *dto.TemplateVari // PreparePromptRequest prepares a prompt request by resolving the template. // If defaults is provided and the utility agent has empty agent_id/model, the defaults are used. -func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePromptRequest, defaults *service.DefaultUtilitySettings) (*service.PromptRequest, error) { +func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePromptRequest, defaults *service.DefaultUtilitySettings, sessionless bool) (*service.PromptRequest, error) { tmplCtx := &template.Context{ GitDiff: req.GitDiff, CommitLog: req.CommitLog, @@ -107,7 +107,7 @@ func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePr UserPrompt: req.UserPrompt, ConversationHistory: req.ConversationHistory, } - return c.svc.PreparePromptRequest(ctx, req.UtilityAgentID, tmplCtx, defaults) + return c.svc.PreparePromptRequest(ctx, req.UtilityAgentID, tmplCtx, defaults, sessionless) } // CreateCall creates a call record for tracking. diff --git a/apps/backend/internal/utility/handlers/handlers.go b/apps/backend/internal/utility/handlers/handlers.go index b6cb0b97ae..59af0ba36e 100644 --- a/apps/backend/internal/utility/handlers/handlers.go +++ b/apps/backend/internal/utility/handlers/handlers.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/lifecycle" agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/logger" @@ -25,6 +26,12 @@ type InferenceExecutor interface { ListInferenceAgentsWithContext(ctx context.Context) []lifecycle.InferenceAgentInfo } +// HostUtilityExecutor runs sessionless utility prompts via the long-lived +// per-agent-type host agentctl instances. +type HostUtilityExecutor interface { + ExecutePrompt(ctx context.Context, agentType, model, mode, prompt string) (*hostutility.PromptResult, error) +} + // UserSettingsProvider provides user settings for default utility agent/model. type UserSettingsProvider interface { // GetDefaultUtilitySettings returns the user's default utility agent/model settings. @@ -35,23 +42,25 @@ type UserSettingsProvider interface { type Handlers struct { controller *controller.Controller executor InferenceExecutor + hostExecutor HostUtilityExecutor userSettings UserSettingsProvider logger *logger.Logger } // NewHandlers creates new utility agent handlers. -func NewHandlers(ctrl *controller.Controller, executor InferenceExecutor, userSettings UserSettingsProvider, log *logger.Logger) *Handlers { +func NewHandlers(ctrl *controller.Controller, executor InferenceExecutor, hostExecutor HostUtilityExecutor, userSettings UserSettingsProvider, log *logger.Logger) *Handlers { return &Handlers{ controller: ctrl, executor: executor, + hostExecutor: hostExecutor, userSettings: userSettings, logger: log.WithFields(zap.String("component", "utility-handlers")), } } // RegisterRoutes registers the utility agent routes. -func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, executor InferenceExecutor, userSettings UserSettingsProvider, log *logger.Logger) { - handlers := NewHandlers(ctrl, executor, userSettings, log) +func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, executor InferenceExecutor, hostExecutor HostUtilityExecutor, userSettings UserSettingsProvider, log *logger.Logger) { + handlers := NewHandlers(ctrl, executor, hostExecutor, userSettings, log) api := router.Group("/api/v1/utility") api.GET("/agents", handlers.httpListAgents) api.GET("/agents/:id", handlers.httpGetAgent) @@ -170,8 +179,10 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { } } + sessionless := req.SessionID == "" + // Prepare the prompt request (resolve template, get agent/model info) - prepared, err := h.controller.PreparePromptRequest(ctx, req, defaults) + prepared, err := h.controller.PreparePromptRequest(ctx, req, defaults, sessionless) if err != nil { status := http.StatusInternalServerError if errors.Is(err, service.ErrAgentNotFound) { @@ -200,7 +211,12 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { return } - // Execute via agentctl using inference agent + if sessionless { + h.executeSessionless(c, ctx, prepared, callID) + return + } + + // Execute via agentctl using an existing task session's agentctl resp, err := h.executor.ExecuteInferencePrompt(ctx, req.SessionID, prepared.AgentCLI, prepared.Model, prepared.ResolvedPrompt) if err != nil { h.logger.Error("failed to execute prompt", zap.Error(err), zap.String("call_id", callID)) @@ -238,6 +254,41 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { }) } +// executeSessionless runs the prepared prompt through the host utility manager +// (no task session). Used for flows like "enhance prompt" in the new-task modal. +func (h *Handlers) executeSessionless(c *gin.Context, ctx context.Context, prepared *service.PromptRequest, callID string) { + if h.hostExecutor == nil { + _ = h.controller.FailCall(ctx, callID, "host utility not configured", 0) + c.JSON(http.StatusServiceUnavailable, dto.ExecutePromptResponse{ + CallID: callID, + Error: "host utility not configured; session_id is required", + }) + return + } + result, err := h.hostExecutor.ExecutePrompt(ctx, prepared.AgentCLI, prepared.Model, "", prepared.ResolvedPrompt) + if err != nil { + h.logger.Error("failed to execute sessionless prompt", zap.Error(err), zap.String("call_id", callID)) + _ = h.controller.FailCall(ctx, callID, err.Error(), 0) + c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{ + CallID: callID, + Error: "failed to execute prompt: " + err.Error(), + }) + return + } + if err := h.controller.CompleteCall(ctx, callID, result.Response, result.PromptTokens, result.ResponseTokens, result.DurationMs); err != nil { + h.logger.Warn("failed to update call record", zap.Error(err), zap.String("call_id", callID)) + } + c.JSON(http.StatusOK, dto.ExecutePromptResponse{ + Success: true, + CallID: callID, + Response: result.Response, + Model: result.Model, + PromptTokens: result.PromptTokens, + ResponseTokens: result.ResponseTokens, + DurationMs: result.DurationMs, + }) +} + func (h *Handlers) httpListCalls(c *gin.Context) { utilityID := c.Param("id") limit := 50 @@ -259,23 +310,15 @@ func (h *Handlers) httpListCalls(c *gin.Context) { func (h *Handlers) httpListInferenceAgents(c *gin.Context) { inferenceAgents := h.executor.ListInferenceAgentsWithContext(c.Request.Context()) - // Convert to DTO + // Convert to DTO. Models are no longer listed per-agent here — the + // frontend should read them from the host utility capability cache via + // GET /api/v1/agents/:type/capabilities. result := make([]dto.InferenceAgentDTO, 0, len(inferenceAgents)) for _, ia := range inferenceAgents { - models := make([]dto.InferenceModelDTO, 0, len(ia.Models)) - for _, m := range ia.Models { - models = append(models, dto.InferenceModelDTO{ - ID: m.ID, - Name: m.Name, - Description: m.Description, - IsDefault: m.IsDefault, - }) - } result = append(result, dto.InferenceAgentDTO{ ID: ia.ID, Name: ia.Name, DisplayName: ia.DisplayName, - Models: models, }) } c.JSON(http.StatusOK, dto.InferenceAgentsResponse{Agents: result}) diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index f7c93c3c32..eac9d69828 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -167,14 +167,18 @@ type DefaultUtilitySettings struct { // PreparePromptRequest prepares a prompt request by resolving the template. // If the utility agent has empty AgentID/Model, the defaults are used. -func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tmplCtx *template.Context, defaults *DefaultUtilitySettings) (*PromptRequest, error) { +// When sessionless is true, missing template variables are substituted with +// empty strings instead of being left as {{Var}}. +func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tmplCtx *template.Context, defaults *DefaultUtilitySettings, sessionless bool) (*PromptRequest, error) { agent, err := s.repo.GetAgentByID(ctx, utilityID) if err != nil { return nil, ErrAgentNotFound } // Resolve template - resolvedPrompt, err := s.templateEngine.Resolve(agent.Prompt, tmplCtx) + resolvedPrompt, err := s.templateEngine.ResolveWithOptions(agent.Prompt, tmplCtx, template.ResolveOptions{ + MissingAsEmpty: sessionless, + }) if err != nil { return nil, err } diff --git a/apps/backend/internal/utility/template/engine.go b/apps/backend/internal/utility/template/engine.go index 0617e09f49..997e671f88 100644 --- a/apps/backend/internal/utility/template/engine.go +++ b/apps/backend/internal/utility/template/engine.go @@ -46,20 +46,35 @@ var templatePattern = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`) // Resolve resolves template variables in the given prompt using the provided context. // Uses {{Key}} format (no dot) for consistency with scriptengine. +// Unknown placeholders are left unchanged. func (e *Engine) Resolve(promptTemplate string, ctx *Context) (string, error) { + return e.ResolveWithOptions(promptTemplate, ctx, ResolveOptions{}) +} + +// ResolveOptions configures template resolution. +type ResolveOptions struct { + // MissingAsEmpty substitutes unknown placeholders with an empty string + // instead of leaving them as {{Var}}. Used for sessionless utility execution + // where some context variables (e.g. GitDiff, TaskTitle) are meaningless. + MissingAsEmpty bool +} + +// ResolveWithOptions resolves template variables with the given options. +func (e *Engine) ResolveWithOptions(promptTemplate string, ctx *Context, opts ResolveOptions) (string, error) { if ctx == nil { ctx = &Context{} } data := e.buildTemplateData(ctx) - // Use regex replacement for deterministic single-pass substitution result := templatePattern.ReplaceAllStringFunc(promptTemplate, func(match string) string { - // Extract key from {{Key}} key := strings.TrimSuffix(strings.TrimPrefix(match, "{{"), "}}") if val, ok := data[key]; ok { return val } - return match // Leave unknown placeholders unchanged + if opts.MissingAsEmpty { + return "" + } + return match }) return result, nil diff --git a/apps/backend/pkg/agent/protocol.go b/apps/backend/pkg/agent/protocol.go index 3de9d4505d..daba7a327f 100644 --- a/apps/backend/pkg/agent/protocol.go +++ b/apps/backend/pkg/agent/protocol.go @@ -11,25 +11,6 @@ const ( ProtocolREST Protocol = "rest" // ProtocolMCP is the Model Context Protocol. ProtocolMCP Protocol = "mcp" - // ProtocolCodex is the OpenAI Codex app-server protocol (JSON-RPC variant over stdin/stdout). - // Codex uses a similar structure to ACP but with different message formats and - // a Thread/Turn model instead of Session/Prompt. - ProtocolCodex Protocol = "codex" - // ProtocolClaudeCode is the Claude Code CLI protocol (stream-json over stdin/stdout). - // Claude Code uses a streaming JSON format with control requests for permissions - // and user messages for prompts. - ProtocolClaudeCode Protocol = "claude-code" - // ProtocolOpenCode is the OpenCode CLI protocol (REST/SSE over HTTP). - // OpenCode spawns an HTTP server and communicates via REST API calls - // with Server-Sent Events (SSE) for streaming responses. - ProtocolOpenCode Protocol = "opencode" - // ProtocolCopilot is the GitHub Copilot SDK protocol. - // Uses the official Go SDK which internally communicates via JSON-RPC with the Copilot CLI. - ProtocolCopilot Protocol = "copilot" - // ProtocolAmp is the Sourcegraph Amp protocol (stream-json over stdin/stdout). - // Amp uses a streaming JSON format similar to Claude Code, with thread-based - // session management for multi-turn conversations. - ProtocolAmp Protocol = "amp" ) // String returns the string representation of the protocol. @@ -40,7 +21,7 @@ func (p Protocol) String() string { // IsValid returns true if the protocol is a known valid protocol. func (p Protocol) IsValid() bool { switch p { - case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex, ProtocolClaudeCode, ProtocolOpenCode, ProtocolCopilot, ProtocolAmp: + case ProtocolACP, ProtocolREST, ProtocolMCP: return true default: return false diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 019a82a8c7..7f2eb3dcaf 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -87,15 +87,7 @@ export async function createAgentProfileAction( export async function updateAgentProfileAction( id: string, payload: Partial< - Pick< - AgentProfile, - | "name" - | "model" - | "auto_approve" - | "dangerously_skip_permissions" - | "allow_indexing" - | "cli_passthrough" - > + Pick >, ): Promise { return fetchJson(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { diff --git a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx index cd9df77a9f..388b3d6aac 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx +++ b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx @@ -117,8 +117,7 @@ export function ProfileCardItem({ profile={{ name: profile.name, model: profile.model, - auto_approve: profile.auto_approve, - dangerously_skip_permissions: profile.dangerously_skip_permissions, + mode: profile.mode ?? "", allow_indexing: profile.allow_indexing ?? false, cli_passthrough: profile.cli_passthrough, }} diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts index f54ae43916..e0ddba27d4 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts @@ -40,13 +40,19 @@ export function useAgentProfileSettings( }, [availableAgents, agent?.name]); const modelConfig = useMemo(() => { - return ( - availableAgent?.model_config ?? { - default_model: "", - available_models: [], - supports_dynamic_models: false, - } - ); + const raw = availableAgent?.model_config; + // Defensive normalization: the backend may marshal nil slices as null. + // Ensure arrays are always arrays so consumers can call .some()/.map(). + return { + default_model: raw?.default_model ?? "", + available_models: raw?.available_models ?? [], + current_model_id: raw?.current_model_id, + available_modes: raw?.available_modes ?? [], + current_mode_id: raw?.current_mode_id, + supports_dynamic_models: raw?.supports_dynamic_models ?? false, + status: raw?.status, + error: raw?.error, + }; }, [availableAgent]); const permissionSettings = useMemo(() => { diff --git a/apps/web/components/enhance-prompt-button.tsx b/apps/web/components/enhance-prompt-button.tsx new file mode 100644 index 0000000000..0e241478e9 --- /dev/null +++ b/apps/web/components/enhance-prompt-button.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { IconSparkles } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { GridSpinner } from "@/components/grid-spinner"; + +type EnhancePromptButtonProps = { + onClick: () => void; + isLoading: boolean; + isConfigured?: boolean; +}; + +export function EnhancePromptButton({ + onClick, + isLoading, + isConfigured = true, +}: EnhancePromptButtonProps) { + const isDisabled = !isConfigured || isLoading; + const tooltipText = isConfigured + ? "Enhance prompt with AI" + : "Configure a utility agent in settings to enable AI enhancement"; + + return ( + + + {/* Wrap in span so tooltip works even when button is disabled */} + + + + + {tooltipText} + + ); +} diff --git a/apps/web/components/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index f4f9411cda..8cfe45672a 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -92,9 +92,7 @@ function buildAgentSettings( const profile = dbAgent?.profiles?.[0]; if (profile) { const perms = profileToPermissionsMap( - profile as Partial< - Pick - >, + profile as Partial>, aa.permission_settings ?? {}, ); settings[aa.name] = { @@ -102,6 +100,7 @@ function buildAgentSettings( formData: { name: profile.name, model: profile.model || aa.model_config.default_model, + mode: (profile as { mode?: string }).mode ?? aa.model_config.current_mode_id ?? "", cli_passthrough: profile.cli_passthrough ?? false, ...perms, }, diff --git a/apps/web/components/onboarding/step-agents.tsx b/apps/web/components/onboarding/step-agents.tsx index 9508b4e3e9..a95623a2b5 100644 --- a/apps/web/components/onboarding/step-agents.tsx +++ b/apps/web/components/onboarding/step-agents.tsx @@ -2,12 +2,14 @@ import { useCallback, useState } from "react"; import { + IconAlertTriangle, IconCheck, IconClipboard, IconChevronDown, IconLoader2, IconDownload, IconExternalLink, + IconLock, } from "@tabler/icons-react"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible"; import { AgentLogo } from "@/components/agent-logo"; @@ -54,6 +56,45 @@ function CopyButton({ text }: { text: string }) { ); } +function StatusPill({ status }: { status: string }) { + switch (status) { + case "auth_required": + return ( + + + No auth + + ); + case "not_installed": + return ( + Not installed + ); + case "failed": + return ( + + + Error + + ); + case "probing": + return ( + + + Probing + + ); + case "not_configured": + return Pending; + default: + return ( + + + Installed + + ); + } +} + function InstalledAgentRow({ agent, settings, @@ -70,6 +111,8 @@ function InstalledAgentRow({ const currentModel = settings?.formData.model || agent.model_config.default_model; const modelName = agent.model_config.available_models.find((m) => m.id === currentModel)?.name ?? currentModel; + const status = agent.model_config.status ?? "ok"; + const showModelPill = status === "ok" && !!modelName; return ( @@ -82,13 +125,12 @@ function InstalledAgentRow({

{agent.display_name}

- - {modelName} - - - - Installed - + {showModelPill && ( + + {modelName} + + )} + diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 5874d81d95..551dd998c9 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -134,8 +134,7 @@ function ProfileSettingsCard({ profile={{ name: draft.name, model: draft.model, - auto_approve: draft.auto_approve, - dangerously_skip_permissions: draft.dangerously_skip_permissions, + mode: draft.mode ?? "", allow_indexing: draft.allow_indexing, cli_passthrough: draft.cli_passthrough, }} @@ -179,8 +178,7 @@ function useProfileEditorState(profile: AgentProfile) { () => draft.name !== savedProfile.name || draft.model !== savedProfile.model || - draft.auto_approve !== savedProfile.auto_approve || - draft.dangerously_skip_permissions !== savedProfile.dangerously_skip_permissions || + (draft.mode ?? "") !== (savedProfile.mode ?? "") || draft.allow_indexing !== savedProfile.allow_indexing || draft.cli_passthrough !== savedProfile.cli_passthrough, [draft, savedProfile], @@ -225,21 +223,14 @@ function useProfileSave({ }); return; } - if (!draft.model.trim()) { - toast({ - title: "Model is required", - description: "Please select a model before saving.", - variant: "error", - }); - return; - } + // Model is optional — an empty profile model means "use the agent's + // default", which is applied via ACP session/set_model at session start. setSaveStatus("loading"); try { const updated = await updateAgentProfileAction(draft.id, { name: draft.name, model: draft.model, - auto_approve: draft.auto_approve, - dangerously_skip_permissions: draft.dangerously_skip_permissions, + mode: draft.mode, allow_indexing: draft.allow_indexing, cli_passthrough: draft.cli_passthrough, }); @@ -367,8 +358,6 @@ function ProfileEditor({ agentName={agent.name} model={draft.model} permissionSettings={{ - auto_approve: draft.auto_approve, - dangerously_skip_permissions: draft.dangerously_skip_permissions, allow_indexing: draft.allow_indexing, }} cliPassthrough={draft.cli_passthrough} diff --git a/apps/web/components/settings/mode-combobox.tsx b/apps/web/components/settings/mode-combobox.tsx new file mode 100644 index 0000000000..2587108c59 --- /dev/null +++ b/apps/web/components/settings/mode-combobox.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { IconSelector } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kandev/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import type { ModeEntry } from "@/lib/types/http"; + +type ModeComboboxProps = { + value: string; + onChange: (value: string) => void; + modes: ModeEntry[]; + currentModeId: string | undefined; +}; + +/** + * Searchable session-mode picker. Same Popover+Command pattern as + * ModelCombobox so descriptions in the dropdown don't leak into the + * trigger text (which happens with Radix Select). + */ +export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComboboxProps) { + const [open, setOpen] = useState(false); + const selected = value || currentModeId || modes[0]?.id || ""; + const activeMode = modes.find((m) => m.id === selected); + return ( + + + + + e.stopPropagation()} + > + + + e.stopPropagation()} + > + No mode found. + + {modes.map((m) => ( + { + onChange(m.id); + setOpen(false); + }} + data-checked={selected === m.id} + className="cursor-pointer" + > +
+
+ {m.name} + {m.id === currentModeId && ( + (default) + )} +
+ {m.description && m.description !== m.name && ( +

{m.description}

+ )} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/web/components/settings/model-combobox.tsx b/apps/web/components/settings/model-combobox.tsx index b414e87aa1..ffa59f7298 100644 --- a/apps/web/components/settings/model-combobox.tsx +++ b/apps/web/components/settings/model-combobox.tsx @@ -1,12 +1,8 @@ "use client"; import { useState } from "react"; -import { IconRefresh, IconAlertCircle, IconSelector } from "@tabler/icons-react"; -import { Input } from "@kandev/ui/input"; -import { Label } from "@kandev/ui/label"; -import { Checkbox } from "@kandev/ui/checkbox"; +import { IconSelector } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Command, @@ -16,34 +12,39 @@ import { CommandItem, CommandList, } from "@kandev/ui/command"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { Skeleton } from "@kandev/ui/skeleton"; -import { useDynamicModels } from "@/hooks/domains/settings/use-dynamic-models"; import type { ModelEntry } from "@/lib/types/http"; -type DynamicModelSelectorProps = { - open: boolean; - setOpen: (open: boolean) => void; - selectedModel: ModelEntry | undefined; - defaultModel?: string; - placeholder: string; - isCustomMode: boolean; - models: ModelEntry[]; +type ModelComboboxProps = { value: string; onChange: (value: string) => void; + models: ModelEntry[]; + currentModelId?: string; + placeholder?: string; + disabled?: boolean; }; -function DynamicModelSelector({ - open, - setOpen, - selectedModel, - defaultModel, - placeholder, - isCustomMode, - models, +/** + * Searchable model picker. The user can only choose from the agent's + * advertised model list — custom model IDs are not allowed for ACP agents + * since the agent CLI is authoritative for what it will accept. + */ +export function ModelCombobox({ value, onChange, -}: DynamicModelSelectorProps) { + models, + currentModelId, + placeholder = "Select a model", + disabled, +}: ModelComboboxProps) { + const [open, setOpen] = useState(false); + const effectiveValue = value || currentModelId || ""; + const selectedModel = models.find((m) => m.id === effectiveValue); + + const copilotUsage = (model: ModelEntry): string | undefined => { + const raw = model.meta?.copilotUsage; + return typeof raw === "string" ? raw : undefined; + }; + return ( @@ -51,18 +52,19 @@ function DynamicModelSelector({ variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between font-normal" - disabled={isCustomMode} + disabled={disabled} + className="w-full justify-between font-normal cursor-pointer" + data-testid="profile-model-combobox-trigger" > {selectedModel ? ( {selectedModel.name} - {selectedModel.id === defaultModel && ( + {selectedModel.id === currentModelId && ( (default) )} - {selectedModel.provider && ( + {copilotUsage(selectedModel) && ( - {selectedModel.provider} + {copilotUsage(selectedModel)} )} @@ -72,35 +74,53 @@ function DynamicModelSelector({ - + e.stopPropagation()} + > - + e.stopPropagation()} + > No model found. - {models.map((model) => ( - { - onChange(currentValue); - setOpen(false); - }} - data-checked={value === model.id || (!value && model.id === defaultModel)} - > - - {model.name} - {model.id === defaultModel && ( - (default) + {models.map((model) => { + const usage = copilotUsage(model); + return ( + { + onChange(model.id); + setOpen(false); + }} + data-checked={effectiveValue === model.id} + className="cursor-pointer" + > +
+
+ {model.name} + {model.id === currentModelId && ( + (default) + )} +
+ {model.description && model.description !== model.name && ( +

+ {model.description} +

+ )} +
+ {usage && ( + + {usage} + )} -
- {model.provider && ( - - {model.provider} - - )} -
- ))} + + ); + })}
@@ -108,221 +128,3 @@ function DynamicModelSelector({
); } - -type StaticModelSelectorProps = { - value: string; - defaultModel?: string; - placeholder: string; - isCustomMode: boolean; - models: ModelEntry[]; - onChange: (value: string) => void; -}; - -function StaticModelSelector({ - value, - defaultModel, - placeholder, - isCustomMode, - models, - onChange, -}: StaticModelSelectorProps) { - return ( - - ); -} - -function DynamicModelControls({ - supportsDynamicModels, - onRefresh, - isCustomMode, - error, -}: { - supportsDynamicModels: boolean; - onRefresh: () => Promise; - isCustomMode: boolean; - error: string | null; -}) { - if (!supportsDynamicModels) return null; - return ( - <> - - - - - -

Refresh models from CLI

-
-
- {error && ( - - -
- -
-
- -

- Failed to fetch dynamic models: {error} -
- Showing static fallback models. -

-
-
- )} - - ); -} - -function CustomModelInput({ - customValue, - isCustomMode, - onValueChange, - onModeChange, -}: { - customValue: string; - isCustomMode: boolean; - onValueChange: (v: string) => void; - onModeChange: (custom: boolean) => void; -}) { - return ( -
- onValueChange(e.target.value)} - placeholder="Custom model ID" - disabled={!isCustomMode} - /> -
- onModeChange(checked === true)} - /> - -
-
- ); -} - -type ModelComboboxProps = { - value: string; - onChange: (value: string) => void; - models: ModelEntry[]; - defaultModel?: string; - placeholder?: string; - agentName?: string; - supportsDynamicModels?: boolean; -}; - -export function ModelCombobox({ - value, - onChange, - models: staticModels, - defaultModel, - placeholder = "Select a model", - agentName, - supportsDynamicModels = false, -}: ModelComboboxProps) { - const { models, isLoading, error, refresh } = useDynamicModels( - agentName, - staticModels, - supportsDynamicModels, - ); - const [open, setOpen] = useState(false); - const [customValue, setCustomValue] = useState(() => - value && !staticModels.some((m) => m.id === value) ? value : "", - ); - const isValueCustom = Boolean(value) && !models.some((m) => m.id === value); - const [userToggle, setUserToggle] = useState(null); - const isCustomMode = userToggle ?? isValueCustom; - - const handleModeChange = (custom: boolean) => { - setUserToggle(custom); - if (custom) { - if (customValue) onChange(customValue); - } else { - if (isValueCustom && value) setCustomValue(value); - onChange(defaultModel || ""); - } - }; - - const handleCustomValueChange = (newValue: string) => { - setCustomValue(newValue); - if (userToggle !== true) setUserToggle(true); - onChange(newValue); - }; - - const selectedModel = models.find((m) => m.id === (value || defaultModel)); - - if (supportsDynamicModels && isLoading) { - return ( -
- - -
- ); - } - - return ( -
-
-
- {supportsDynamicModels ? ( - - ) : ( - - )} -
- -
- -
- ); -} diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index 6628e623c9..7a41d60f85 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -1,16 +1,43 @@ "use client"; +import { + IconRefresh, + IconAlertCircle, + IconLock, + IconPackageOff, + IconTerminal2, +} from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@kandev/ui/dialog"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; +import { Skeleton } from "@kandev/ui/skeleton"; import { Switch } from "@kandev/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { ModeCombobox } from "@/components/settings/mode-combobox"; import { ModelCombobox } from "@/components/settings/model-combobox"; +import { useAgentCapabilities } from "@/hooks/domains/settings/use-dynamic-models"; import { PERMISSION_KEYS, type PermissionKey } from "@/lib/agent-permissions"; -import type { ModelConfig, PermissionSetting, PassthroughConfig } from "@/lib/types/http"; +import type { + CommandEntry, + ModelConfig, + ModeEntry, + ModelEntry, + PermissionSetting, + PassthroughConfig, +} from "@/lib/types/http"; export type ProfileFormData = { name: string; model: string; + mode: string; cli_passthrough: boolean; } & Record; @@ -125,6 +152,357 @@ function PermissionToggles({ ); } +function capabilityStatusMessage(modelConfig: ModelConfig): string | null { + switch (modelConfig.status) { + case "probing": + return "Checking agent capabilities…"; + case "auth_required": + return "Authentication required. Run the agent CLI in your terminal to authenticate, then refresh."; + case "not_installed": + return "Agent CLI not installed."; + case "failed": + return "Probe failed. Check agent logs for details."; + default: + return null; + } +} + +function CapabilityStatusMessage({ modelConfig }: { modelConfig: ModelConfig }) { + const msg = capabilityStatusMessage(modelConfig); + if (!msg) return null; + return ( +

+ {msg} +

+ ); +} + +function RefreshCapabilitiesButton({ + onRefresh, + isLoading, + error, +}: { + onRefresh: () => Promise; + isLoading: boolean; + error: string | null; +}) { + return ( +
+ + + + + +

Refresh agent capabilities (models + modes)

+
+
+ {error && ( + + +
+ +
+
+ +

Failed to refresh: {error}

+
+
+ )} +
+ ); +} + +function ModelPicker({ + profile, + models, + currentModelId, + onChange, +}: { + profile: ProfileFormData; + models: ModelEntry[]; + currentModelId: string | undefined; + onChange: (patch: Partial) => void; +}) { + return ( + onChange({ model: value })} + models={models} + currentModelId={currentModelId} + placeholder="Select a model..." + /> + ); +} + +function ModePicker({ + profile, + modes, + currentModeId, + onChange, +}: { + profile: ProfileFormData; + modes: ModeEntry[]; + currentModeId: string | undefined; + onChange: (patch: Partial) => void; +}) { + return ( + onChange({ mode: value })} + modes={modes} + currentModeId={currentModeId} + /> + ); +} + +function CommandsButton({ commands }: { commands: CommandEntry[] }) { + if (commands.length === 0) return null; + return ( + + + + + + + Available slash commands + + Type these during a session chat to invoke them — e.g. /init. + + +
+ {commands.map((c) => ( +
+ /{c.name} + {c.description && ( +

{c.description}

+ )} +
+ ))} +
+
+
+ ); +} + +function NoAuthPanel({ + agentName, + status, + isLoading, + onRefresh, + error, + rawError, +}: { + agentName: string; + status: "auth_required" | "not_installed"; + isLoading: boolean; + onRefresh: () => Promise; + error: string | null; + rawError: string | null; +}) { + const isAuth = status === "auth_required"; + const Icon = isAuth ? IconLock : IconPackageOff; + const title = isAuth ? "No auth — login required" : "Not installed"; + const hint = isAuth ? ( + <> + Run {agentName} login in your + terminal, then click Refresh. + + ) : ( + <>Install the agent CLI, then click Refresh. + ); + const detail = error || rawError; + return ( +
+ +
+
+

{title}

+ {detail && ( + + + + + +

{detail}

+
+
+ )} +
+

{hint}

+
+ +
+ ); +} + +function CapabilitiesRow({ + profile, + models, + modes, + commands, + currentModelId, + currentModeId, + onChange, + isCompact, + isLoading, + onRefresh, + error, + modelConfig, + agentName, +}: { + profile: ProfileFormData; + models: ModelEntry[]; + modes: ModeEntry[]; + commands: CommandEntry[]; + currentModelId: string | undefined; + currentModeId: string | undefined; + onChange: (patch: Partial) => void; + isCompact: boolean; + isLoading: boolean; + onRefresh: () => Promise; + error: string | null; + modelConfig: ModelConfig; + agentName: string; +}) { + const hasModes = modes.length > 0; + const activeMode = hasModes + ? modes.find((m) => m.id === (profile.mode || currentModeId || modes[0]?.id)) + : undefined; + const labelCls = isCompact ? "text-xs text-muted-foreground" : undefined; + const gapCls = isCompact ? "space-y-1.5" : "space-y-2"; + + if (isLoading && models.length === 0) { + return ( +
+ + +
+ ); + } + + const status = modelConfig.status; + if (status === "auth_required" || status === "not_installed") { + return ( + + ); + } + + return ( +
+
+
+ + +
+ {hasModes && ( +
+ + +
+ )} + +
+ {activeMode?.description && ( +

{activeMode.description}

+ )} + {commands.length > 0 && } + +
+ ); +} + +function NameField({ + profile, + onChange, + canRemove, + onRemove, +}: { + profile: ProfileFormData; + onChange: (patch: Partial) => void; + canRemove?: boolean; + onRemove?: () => void; +}) { + return ( +
+
+ + onChange({ name: event.target.value })} + placeholder="Default profile" + /> +
+ {canRemove && onRemove && ( + + )} +
+ ); +} + export function ProfileFormFields({ profile, onChange, @@ -139,43 +517,34 @@ export function ProfileFormFields({ lockPassthrough = false, }: ProfileFormFieldsProps) { const isCompact = variant === "compact"; + const caps = useAgentCapabilities(agentName, modelConfig); return (
{!hideNameField && ( -
-
- - onChange({ name: event.target.value })} - placeholder="Default profile" - /> -
- {canRemove && onRemove && ( - - )} -
+ )} -
- {isCompact ? ( - - ) : ( - - )} - onChange({ model: value })} - models={modelConfig.available_models} - defaultModel={modelConfig.default_model} - placeholder="Select or enter model..." - agentName={agentName} - supportsDynamicModels={modelConfig.supports_dynamic_models} - /> -
+ {agentLabel} • {profile.name} + {warning && } diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index d16bc4e120..da5b4376e0 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -15,6 +15,7 @@ import type { AgentProfileOption } from "@/lib/state/slices"; import { formatUserHomePath, truncateRepoPath } from "@/lib/utils"; import { getExecutorIcon } from "@/lib/executor-icons"; import { AgentLogo } from "@/components/agent-logo"; +import { getCapabilityWarning } from "@/lib/capability-warning"; type OptionItem = { value: string; @@ -114,6 +115,7 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt const agentLabel = parts[0] ?? profile.label; const profileLabel = parts[1] ?? ""; const isPassthrough = profile.cli_passthrough === true; + const warning = getCapabilityWarning(profile.capability_status, profile.capability_error); return { value: profile.id, label: profile.label, @@ -122,6 +124,9 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt {agentLabel} + {warning && ( + + )} {isPassthrough && ( diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 96f7b9f542..c7e61e5afb 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -15,6 +15,7 @@ import { import { ContextZone } from "@/components/task/chat/context-items/context-zone"; import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; +import { EnhancePromptButton } from "@/components/enhance-prompt-button"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -248,6 +249,9 @@ type TaskFormInputsProps = { descriptionValueRef: React.RefObject; disabled?: boolean; placeholder?: string; + onEnhancePrompt?: () => void; + isEnhancingPrompt?: boolean; + isUtilityConfigured?: boolean; }; function useFileAttachments() { @@ -416,9 +420,16 @@ function useDescriptionInput( useEffect(() => { const ref = descriptionValueRef as React.MutableRefObject; if (ref) { - ref.current = { getValue: () => description, getAttachments: () => attachments }; + ref.current = { + getValue: () => description, + setValue: (v: string) => { + setDescription(v); + onDescriptionChange(v.trim().length > 0); + }, + getAttachments: () => attachments, + }; } - }, [description, attachments, descriptionValueRef]); + }, [description, attachments, descriptionValueRef, onDescriptionChange]); useEffect(() => { const textarea = textareaRef.current; @@ -458,6 +469,9 @@ export const TaskFormInputs = memo(function TaskFormInputs({ descriptionValueRef, disabled, placeholder, + onEnhancePrompt, + isEnhancingPrompt, + isUtilityConfigured, }: TaskFormInputsProps) { const fileInputRef = useRef(null); const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } = @@ -517,7 +531,16 @@ export const TaskFormInputs = memo(function TaskFormInputs({ required={isSessionMode} disabled={disabled} /> - +
+ + {onEnhancePrompt && ( + + )} +
string; + setValue: (v: string) => void; getAttachments: () => FileAttachment[]; }; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 34d9ea433a..13189058eb 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -1,11 +1,13 @@ "use client"; -import { FormEvent } from "react"; +import { FormEvent, useCallback } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog"; import type { Task } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; +import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer"; import { CreateEditSelectors, @@ -260,6 +262,7 @@ type DialogFormBodyProps = { onWorkflowChange: (v: string) => void; hasRepositorySelection: boolean; isLocalExecutor: boolean; + enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; }; function DialogFormBody({ @@ -288,6 +291,7 @@ function DialogFormBody({ onWorkflowChange, hasRepositorySelection, isLocalExecutor, + enhance, }: DialogFormBodyProps) { return (
@@ -300,15 +304,13 @@ function DialogFormBody({ onKeyDown={handleKeyDown} descriptionValueRef={fs.descriptionInputRef} disabled={isTaskStarted || isPassthroughProfile} - placeholder={ - isPassthroughProfile ? "Sending a prompt is not supported in passthrough mode" : undefined - } + placeholder={isPassthroughProfile ? "Passthrough mode — prompt not supported" : undefined} + onEnhancePrompt={enhance?.onEnhance} + isEnhancingPrompt={enhance?.isLoading} + isUtilityConfigured={enhance?.isConfigured} /> {isPassthroughProfile && hasDescription && ( -

- Prompt will be ignored — passthrough sessions don't support sending a prompt on - start. -

+

Prompt ignored — passthrough mode active

)} {!isSessionMode && ( { + const current = fs.descriptionInputRef.current?.getValue()?.trim(); + if (!current) return; + enhancePrompt(current, (enhanced) => { + fs.descriptionInputRef.current?.setValue(enhanced); + fs.setHasDescription(true); + }); + }, [enhancePrompt, fs]); + return { onEnhance, isLoading: isEnhancingPrompt, isConfigured }; +} + function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { const { open, onOpenChange, mode = "create", workspaceId, workflowId, defaultStepId } = props; - const { - editingTask, - onSuccess, - onCreateSession, - initialValues, - taskId = null, - parentTaskId, - } = props; + const { editingTask, onSuccess, onCreateSession, initialValues, parentTaskId } = props; + const taskId = props.taskId ?? null; const isSessionMode = mode === "session"; const isEditMode = mode === "edit"; const isCreateMode = mode === "create"; @@ -461,6 +474,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { handlers, submitHandlers, handleKeyDown, + enhance: useEnhanceForDialog(fs), }; } @@ -528,6 +542,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { onWorkflowChange={handlers.handleWorkflowChange} hasRepositorySelection={computed.hasRepositorySelection} isLocalExecutor={computed.isLocalExecutor} + enhance={setup.enhance} /> void }) { ); } -function EnhancePromptButton({ - onClick, - isLoading, - isConfigured = true, -}: { - onClick: () => void; - isLoading: boolean; - isConfigured?: boolean; -}) { - const isDisabled = !isConfigured || isLoading; - const tooltipText = isConfigured - ? "Enhance prompt with AI" - : "Configure a utility agent in settings to enable AI enhancement"; - - return ( - - - {/* Wrap in span so tooltip works even when button is disabled */} - - - - - {tooltipText} - - ); -} - function McpIndicator({ mcpServers }: { mcpServers: string[] }) { const hasMcp = mcpServers.length > 0; const tooltipText = hasMcp diff --git a/apps/web/components/task/model-selector.tsx b/apps/web/components/task/model-selector.tsx index 2be76868d6..5de77b477a 100644 --- a/apps/web/components/task/model-selector.tsx +++ b/apps/web/components/task/model-selector.tsx @@ -16,9 +16,9 @@ type ModelSelectorProps = { type ModelOption = { id: string; name: string; - provider: string; - context_window: number; - is_default: boolean; + provider?: string; + context_window?: number; + is_default?: boolean; description?: string; usageMultiplier?: string; }; diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index a632ce4632..298e62dad1 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -18,6 +18,9 @@ import { useSummarizeSession } from "@/hooks/use-summarize-session"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import type { AgentProfileOption } from "@/lib/state/slices"; import { IconLoader2 } from "@tabler/icons-react"; +import { EnhancePromptButton } from "@/components/enhance-prompt-button"; +import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; +import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { EnvironmentBadges, ContextSelect, @@ -152,6 +155,18 @@ function NewSessionForm({ ); const profileOptions = useAgentProfileOptions(agentProfiles); const sessionOptions = useSessionOptions(taskId); + const isUtilityConfigured = useIsUtilityConfigured(); + const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ sessionId: null }); + const handleEnhancePrompt = useCallback(() => { + const current = promptRef.current?.value?.trim(); + if (!current) return; + enhancePrompt(current, (enhanced) => { + if (promptRef.current) { + promptRef.current.value = enhanced; + setHasPrompt(true); + } + }); + }, [enhancePrompt]); const handleContextChange = useCallback( async (value: string) => { @@ -285,7 +300,14 @@ function NewSessionForm({ } }} /> - +
+ + +
{ + const current = promptRef.current?.value?.trim(); + if (!current) return; + enhancePrompt(current, (enhanced) => { + if (promptRef.current) { + promptRef.current.value = enhanced; + setHasPrompt(true); + } + }); + }, [enhancePrompt]); const allExecutorProfiles = useExecutorProfiles(executors); const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles); @@ -385,7 +401,14 @@ function NewSubtaskForm({ } }} /> - +
+ + +
{ return this.request("POST", `/api/v1/agents/${agentId}/profiles`, { name, model: opts.model, - auto_approve: opts.auto_approve ?? true, + mode: opts.mode, cli_passthrough: opts.cli_passthrough ?? false, }); } + async updateAgentProfile( + profileId: string, + patch: { name?: string; model?: string; mode?: string; cli_passthrough?: boolean }, + ): Promise { + await this.request("PATCH", `/api/v1/agent-profiles/${profileId}`, patch); + } + async createTaskWithAgent( workspaceId: string, title: string, diff --git a/apps/web/e2e/tests/chat/markdown-paragraph-breaks.spec.ts b/apps/web/e2e/tests/chat/markdown-paragraph-breaks.spec.ts index bfb69ba1aa..fbabe93474 100644 --- a/apps/web/e2e/tests/chat/markdown-paragraph-breaks.spec.ts +++ b/apps/web/e2e/tests/chat/markdown-paragraph-breaks.spec.ts @@ -34,10 +34,12 @@ test.describe("Markdown paragraph breaks", () => { const session = new SessionPage(testPage); await session.waitForLoad(); + // Wait for the agent to finish processing the prompt and become idle. + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); - // Wait for the agent message containing our paragraphs to appear + // Wait for the agent response containing our paragraphs to appear. await expect(session.chat.getByText("Paragraph three.").last()).toBeVisible({ - timeout: 30_000, + timeout: 10_000, }); // Verify that consecutive

elements inside .markdown-body have spacing. diff --git a/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts new file mode 100644 index 0000000000..fc0b110260 --- /dev/null +++ b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "../../fixtures/test-base"; + +/** + * Verifies the ACP-first profile editor: + * + * - Legacy permission toggles (`auto_approve`, `dangerously_skip_permissions`) + * are no longer rendered. They were removed when profile permission stance + * moved to ACP session modes + per-tool-call permission_request prompts. + * - Profile name edits persist across reload (exercises the new AgentProfile + * DTO shape with `mode` / `migrated_from` columns). + * - Mode picker is hidden when the agent's capability cache has no modes — + * the mock agent in E2E isn't probed so no mode data flows through. + */ +test.describe("Agent profile — ACP-first", () => { + test("profile editor loads with model picker and without legacy permission toggles", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(60_000); + + const { agents } = await apiClient.listAgents(); + const agent = agents[0]; + const profileId = agent.profiles[0].id; + + await testPage.goto(`/settings/agents/${agent.name}/profiles/${profileId}`); + + // Profile name input is present (from the shared ProfileFormFields component). + await expect(testPage.getByTestId("profile-name-input")).toBeVisible({ timeout: 15_000 }); + + // The old permission toggle labels must NOT render. These were backed by + // the removed AgentProfile.auto_approve and .dangerously_skip_permissions + // fields; the corresponding PermissionSetting entries are gone from the + // shared agents so the PermissionToggles iterator emits nothing for them. + await expect(testPage.getByText(/Auto-approve/i)).toHaveCount(0); + await expect(testPage.getByText(/YOLO/i)).toHaveCount(0); + await expect(testPage.getByText(/Skip Permissions/i)).toHaveCount(0); + await expect(testPage.getByText(/dangerously skip/i)).toHaveCount(0); + + // The mock agent isn't an InferenceAgent, so the host utility cache has no + // modes for it and the mode picker is not rendered. + await expect(testPage.getByTestId("profile-mode-field")).toHaveCount(0); + }); + + test("profile name edits persist across reload", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + + // Use the seeded default profile rather than creating a new one — the + // profile editor page reads from the agents list that's hydrated on the + // server, and a freshly POSTed profile race-conditions with SSR. + const { agents } = await apiClient.listAgents(); + const agent = agents[0]; + const profile = agent.profiles[0]; + const originalName = profile.name; + + try { + await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); + + const nameInput = testPage.getByTestId("profile-name-input"); + await expect(nameInput).toBeVisible({ timeout: 15_000 }); + await expect(nameInput).toHaveValue(originalName, { timeout: 10_000 }); + + // Edit name. + const newName = `${originalName} Renamed`; + await nameInput.fill(newName); + + // Save via the dirty-state save button (card header). The save + // dispatches a Next.js server action, so we wait for the dirty badge + // to disappear as the signal that the round-trip completed. + const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); + await expect(saveButton).toBeEnabled({ timeout: 10_000 }); + await saveButton.click(); + await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 }); + + // Reload and assert the new name persisted — this exercises the + // round-trip through the new profile DTO shape (model + mode + + // allow_indexing + cli_passthrough) without the legacy permission columns. + await testPage.reload(); + await expect(testPage.getByTestId("profile-name-input")).toHaveValue(newName, { + timeout: 15_000, + }); + } finally { + // Always restore the original name so the worker-scoped seedData + // fixture stays valid for subsequent tests — even if an assertion + // above failed. + await apiClient.updateAgentProfile(profile.id, { name: originalName }); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/config-management.spec.ts b/apps/web/e2e/tests/settings/config-management.spec.ts index d1301c1dba..98680a5d09 100644 --- a/apps/web/e2e/tests/settings/config-management.spec.ts +++ b/apps/web/e2e/tests/settings/config-management.spec.ts @@ -355,17 +355,21 @@ test.describe("Config-mode MCP — agent management", () => { expect(profile?.name).toBe("Renamed Profile"); }); - test("agent can update agent profile model and auto_approve", async ({ + test("agent can update agent profile model via MCP", async ({ testPage, apiClient, seedData, }) => { + // Legacy `auto_approve` / `dangerously_skip_permissions` fields were + // removed when profile permission stance moved to ACP session modes. + // This test now verifies only the model update round-trip through the + // config-mode MCP tool. const session = await startConfigSession( apiClient, seedData, [ 'e2e:message("Updating profile settings...")', - `e2e:mcp:kandev:update_agent_profile_kandev({"profile_id":"${seedData.agentProfileId}","model":"claude-sonnet-4-5-20250514","auto_approve":false})`, + `e2e:mcp:kandev:update_agent_profile_kandev({"profile_id":"${seedData.agentProfileId}","model":"claude-sonnet-4-5-20250514"})`, 'e2e:message("Profile settings updated")', ].join("\n"), ); @@ -378,7 +382,6 @@ test.describe("Config-mode MCP — agent management", () => { .flatMap((a) => a.profiles ?? []) .find((p) => p.id === seedData.agentProfileId); expect(profile?.model).toBe("claude-sonnet-4-5-20250514"); - expect(profile?.auto_approve).toBe(false); }); }); diff --git a/apps/web/e2e/tests/task/enhance-prompt.spec.ts b/apps/web/e2e/tests/task/enhance-prompt.spec.ts new file mode 100644 index 0000000000..a702cc64cd --- /dev/null +++ b/apps/web/e2e/tests/task/enhance-prompt.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Enhance prompt button in task creation", () => { + test("enhance button is visible and enabled when utility agent is configured", async ({ + testPage, + apiClient, + seedData, + }) => { + // Seed a default utility agent pointing at mock-agent (by name, not UUID). + // The mock agent isn't inference-capable so the actual enhance call won't + // succeed, but the button should render as enabled. + const { agents } = await apiClient.listAgents(); + const mockAgent = agents.find((a) => a.name === "mock-agent"); + expect(mockAgent, "mock-agent must be registered").toBeTruthy(); + await apiClient.saveUserSettings({ + default_utility_agent_id: mockAgent!.name, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(seedData.workspaceId); + await kanban.createTaskButton.first().click(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Fill the description textarea + const textarea = testPage.getByTestId("task-description-input"); + await textarea.fill("fix the login bug"); + + // The enhance button should be visible and enabled + const enhanceBtn = testPage.getByTestId("enhance-prompt-button"); + await expect(enhanceBtn).toBeVisible(); + await expect(enhanceBtn).toBeEnabled(); + }); + + test("enhance button is disabled when no utility agent is configured", async ({ + testPage, + apiClient, + seedData, + }) => { + // Clear any default utility agent + await apiClient.saveUserSettings({ + default_utility_agent_id: "", + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(seedData.workspaceId); + await kanban.createTaskButton.first().click(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // The enhance button should exist but be disabled + const enhanceBtn = testPage.getByTestId("enhance-prompt-button"); + await expect(enhanceBtn).toBeVisible(); + await expect(enhanceBtn).toBeDisabled(); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.ts b/apps/web/hooks/domains/settings/use-dynamic-models.ts index 5e223cd729..0df271d591 100644 --- a/apps/web/hooks/domains/settings/use-dynamic-models.ts +++ b/apps/web/hooks/domains/settings/use-dynamic-models.ts @@ -1,84 +1,82 @@ import { useState, useEffect, useCallback } from "react"; import { fetchDynamicModels } from "@/lib/api/domains/settings-api"; -import type { ModelEntry, DynamicModelsResponse } from "@/lib/types/http"; +import type { + CommandEntry, + ModeEntry, + ModelEntry, + DynamicModelsResponse, + ModelConfig, +} from "@/lib/types/http"; -type UseDynamicModelsState = { +type UseAgentCapabilitiesState = { models: ModelEntry[]; + modes: ModeEntry[]; + commands: CommandEntry[]; + currentModelId: string | undefined; + currentModeId: string | undefined; isLoading: boolean; error: string | null; - cached: boolean; - cachedAt: string | null; refresh: () => Promise; }; -export function useDynamicModels( +/** + * useAgentCapabilities fetches the full ACP probe cache for an agent + * (models, modes, current defaults) and keeps it in sync. Refresh triggers + * a live re-probe against the host utility and updates both models and + * modes atomically — so the profile page's refresh button covers the whole + * agent surface, not just models. + */ +export function useAgentCapabilities( agentName: string | undefined, - staticModels: ModelEntry[], - supportsDynamicModels: boolean, -): UseDynamicModelsState { - const [models, setModels] = useState(staticModels); - // Start in loading state if dynamic models are supported to avoid UI flash + initial: ModelConfig, +): UseAgentCapabilitiesState { + const supportsDynamicModels = initial.supports_dynamic_models; + const [models, setModels] = useState(initial.available_models); + const [modes, setModes] = useState(initial.available_modes ?? []); + const [commands, setCommands] = useState(initial.available_commands ?? []); + const [currentModelId, setCurrentModelId] = useState( + initial.current_model_id, + ); + const [currentModeId, setCurrentModeId] = useState(initial.current_mode_id); const [isLoading, setIsLoading] = useState(supportsDynamicModels && !!agentName); const [error, setError] = useState(null); - const [cached, setCached] = useState(false); - const [cachedAt, setCachedAt] = useState(null); - const fetchModels = useCallback( - async (forceRefresh = false) => { + const fetchCaps = useCallback( + async (forceRefresh: boolean) => { if (!agentName || !supportsDynamicModels) { - setModels(staticModels); return; } - setIsLoading(true); setError(null); - try { const response: DynamicModelsResponse = await fetchDynamicModels(agentName, { refresh: forceRefresh, }); - if (response.error) { setError(response.error); - // Fall back to static models on error - setModels(staticModels); - } else { - setModels(response.models); + return; } - - setCached(response.cached); - setCachedAt(response.cached_at ?? null); + setModels(response.models ?? []); + setModes(response.modes ?? []); + setCommands(response.commands ?? []); + setCurrentModelId(response.current_model_id); + setCurrentModeId(response.current_mode_id); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to fetch models"; - setError(errorMessage); - // Fall back to static models on error - setModels(staticModels); + setError(err instanceof Error ? err.message : "Failed to fetch capabilities"); } finally { setIsLoading(false); } }, - [agentName, supportsDynamicModels, staticModels], + [agentName, supportsDynamicModels], ); - // Fetch on mount if dynamic models are supported useEffect(() => { if (supportsDynamicModels && agentName) { - fetchModels(false); - } else { - setModels(staticModels); + void fetchCaps(false); } - }, [agentName, supportsDynamicModels, fetchModels, staticModels]); + }, [agentName, supportsDynamicModels, fetchCaps]); - const refresh = useCallback(async () => { - await fetchModels(true); - }, [fetchModels]); + const refresh = useCallback(() => fetchCaps(true), [fetchCaps]); - return { - models, - isLoading, - error, - cached, - cachedAt, - refresh, - }; + return { models, modes, commands, currentModelId, currentModeId, isLoading, error, refresh }; } diff --git a/apps/web/hooks/use-utility-agent-generator.ts b/apps/web/hooks/use-utility-agent-generator.ts index 927fe58b76..74f5903313 100644 --- a/apps/web/hooks/use-utility-agent-generator.ts +++ b/apps/web/hooks/use-utility-agent-generator.ts @@ -6,19 +6,21 @@ import { useToast } from "@/components/toast-provider"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import type { FileInfo } from "@/lib/state/slices"; +const ENHANCE_PROMPT = "enhance-prompt" as const; + type GeneratorType = | "commit-message" | "commit-description" | "pr-title" | "pr-description" - | "enhance-prompt"; + | typeof ENHANCE_PROMPT; const UTILITY_AGENT_IDS: Record = { "commit-message": "builtin-commit-message", "commit-description": "builtin-commit-description", "pr-title": "builtin-pr-title", "pr-description": "builtin-pr-description", - "enhance-prompt": "builtin-enhance-prompt", + [ENHANCE_PROMPT]: "builtin-enhance-prompt", }; type UseUtilityAgentGeneratorOptions = { @@ -56,9 +58,42 @@ export function useUtilityAgentGenerator({ return { changedFiles, diffs }; }, [gitStatus]); + const clearType = useCallback( + (type: GeneratorType) => + setGenerating((prev) => { + const next = new Set(prev); + next.delete(type); + return next; + }), + [], + ); + + const buildRequest = useCallback( + (type: GeneratorType, options?: GenerateOptions): ExecutePromptRequest => { + const sessionless = type === ENHANCE_PROMPT && !sessionId; + const { changedFiles, diffs } = sessionless + ? { changedFiles: [] as string[], diffs: undefined } + : collectGitContext(); + return { + utility_agent_id: UTILITY_AGENT_IDS[type], + session_id: sessionId ?? "", + task_title: taskTitle, + task_description: taskDescription, + git_diff: diffs || undefined, + changed_files: changedFiles.join("\n") || undefined, + commit_log: options?.commitLog, + diff_summary: options?.diffSummary, + user_prompt: options?.userPrompt, + }; + }, + [sessionId, taskTitle, taskDescription, collectGitContext], + ); + const generate = useCallback( async (type: GeneratorType, options?: GenerateOptions) => { - if (!sessionId) { + // enhance-prompt can run sessionless via the host utility manager. + // Other generators need git context from an active session. + if (!sessionId && type !== ENHANCE_PROMPT) { toast({ title: "No active session", description: "Start a session first to use AI generation", @@ -69,34 +104,11 @@ export function useUtilityAgentGenerator({ setGenerating((prev) => new Set(prev).add(type)); try { - const { changedFiles, diffs } = collectGitContext(); - - const request: ExecutePromptRequest = { - utility_agent_id: UTILITY_AGENT_IDS[type], - session_id: sessionId, - task_title: taskTitle, - task_description: taskDescription, - git_diff: diffs || undefined, - changed_files: changedFiles.join("\n") || undefined, - commit_log: options?.commitLog, - diff_summary: options?.diffSummary, - user_prompt: options?.userPrompt, - }; - - const resp = await executeUtilityPrompt(request); - - // Clear generating state BEFORE calling onSuccess so the editor is re-enabled - setGenerating((prev) => { - const next = new Set(prev); - next.delete(type); - return next; - }); - + const resp = await executeUtilityPrompt(buildRequest(type, options)); + clearType(type); if (resp.success && resp.response) { const response = resp.response; - requestAnimationFrame(() => { - options?.onSuccess?.(response); - }); + requestAnimationFrame(() => options?.onSuccess?.(response)); } else { toast({ title: "Generation failed", @@ -105,11 +117,7 @@ export function useUtilityAgentGenerator({ }); } } catch (error) { - setGenerating((prev) => { - const next = new Set(prev); - next.delete(type); - return next; - }); + clearType(type); toast({ title: "Generation failed", description: error instanceof Error ? error.message : "Unknown error", @@ -117,7 +125,7 @@ export function useUtilityAgentGenerator({ }); } }, - [sessionId, taskTitle, taskDescription, collectGitContext, toast], + [sessionId, buildRequest, clearType, toast], ); return useGeneratorCallbacks(generate, generating); @@ -153,7 +161,7 @@ function useGeneratorCallbacks( const enhancePrompt = useCallback( (userPrompt: string, onSuccess: (enhanced: string) => void) => - generate("enhance-prompt", { onSuccess, userPrompt }), + generate(ENHANCE_PROMPT, { onSuccess, userPrompt }), [generate], ); @@ -163,7 +171,7 @@ function useGeneratorCallbacks( isGeneratingCommitDescription: generating.has("commit-description"), isGeneratingPRTitle: generating.has("pr-title"), isGeneratingPRDescription: generating.has("pr-description"), - isEnhancingPrompt: generating.has("enhance-prompt"), + isEnhancingPrompt: generating.has(ENHANCE_PROMPT), generateCommitMessage, generateCommitDescription, generatePRTitle, diff --git a/apps/web/lib/agent-permissions.test.ts b/apps/web/lib/agent-permissions.test.ts deleted file mode 100644 index 810aaee414..0000000000 --- a/apps/web/lib/agent-permissions.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - profileToPermissionsMap, - permissionsToProfilePatch, - buildDefaultPermissions, - arePermissionsDirty, -} from "./agent-permissions"; -import type { PermissionSetting } from "@/lib/types/http"; - -const mockSettings: Record = { - auto_approve: { supported: true, default: true, label: "Auto approve", description: "" }, - dangerously_skip_permissions: { - supported: true, - default: false, - label: "Skip perms", - description: "", - }, - allow_indexing: { supported: true, default: true, label: "Indexing", description: "" }, -}; - -describe("profileToPermissionsMap", () => { - it("uses profile values when present", () => { - const result = profileToPermissionsMap( - { auto_approve: false, dangerously_skip_permissions: true, allow_indexing: false }, - mockSettings, - ); - expect(result).toEqual({ - auto_approve: false, - dangerously_skip_permissions: true, - allow_indexing: false, - }); - }); - - it("falls back to setting defaults for missing keys", () => { - const result = profileToPermissionsMap({}, mockSettings); - expect(result).toEqual({ - auto_approve: true, - dangerously_skip_permissions: false, - allow_indexing: true, - }); - }); - - it("falls back to false when setting is also missing", () => { - const result = profileToPermissionsMap({}, {}); - expect(result).toEqual({ - auto_approve: false, - dangerously_skip_permissions: false, - allow_indexing: false, - }); - }); -}); - -describe("permissionsToProfilePatch", () => { - it("returns all permission keys with values", () => { - const result = permissionsToProfilePatch({ - auto_approve: true, - dangerously_skip_permissions: true, - allow_indexing: true, - }); - expect(result).toEqual({ - auto_approve: true, - dangerously_skip_permissions: true, - allow_indexing: true, - }); - }); - - it("defaults missing keys to false", () => { - const result = permissionsToProfilePatch({}); - expect(result).toEqual({ - auto_approve: false, - dangerously_skip_permissions: false, - allow_indexing: false, - }); - }); -}); - -describe("buildDefaultPermissions", () => { - it("builds defaults from permission settings", () => { - const result = buildDefaultPermissions(mockSettings); - expect(result).toEqual({ - auto_approve: true, - dangerously_skip_permissions: false, - allow_indexing: true, - }); - }); - - it("defaults to false for missing settings", () => { - const result = buildDefaultPermissions({}); - expect(result).toEqual({ - auto_approve: false, - dangerously_skip_permissions: false, - allow_indexing: false, - }); - }); -}); - -describe("arePermissionsDirty", () => { - it("returns false when all permissions match", () => { - const a = { auto_approve: true, dangerously_skip_permissions: false, allow_indexing: true }; - expect(arePermissionsDirty(a, { ...a })).toBe(false); - }); - - it("returns true when any permission differs", () => { - const a = { auto_approve: true, dangerously_skip_permissions: false, allow_indexing: true }; - const b = { auto_approve: true, dangerously_skip_permissions: true, allow_indexing: true }; - expect(arePermissionsDirty(a, b)).toBe(true); - }); - - it("detects difference when one side has undefined", () => { - const a = { auto_approve: true }; - const b = { auto_approve: false }; - expect(arePermissionsDirty(a, b)).toBe(true); - }); -}); diff --git a/apps/web/lib/agent-permissions.ts b/apps/web/lib/agent-permissions.ts index 08fcac23dd..1c54569ce2 100644 --- a/apps/web/lib/agent-permissions.ts +++ b/apps/web/lib/agent-permissions.ts @@ -1,14 +1,17 @@ import type { AgentProfile, PermissionSetting } from "@/lib/types/http"; /** - * Single source of truth for permission keys. - * Adding a key here that doesn't exist on AgentProfile will cause a compile error. + * Permission keys retained on the frontend. After the ACP-first migration the + * only surviving CLI-flag-driven permission is auggie's `allow_indexing` — all + * other agents express permission stance through ACP session modes (rendered + * as a separate Mode picker) and the interactive permission_request message UI. + * + * This file is kept as a thin compatibility shim so call sites that still use + * the old permission-map helpers continue to compile. Non-auggie agents will + * simply have an empty permission_settings map from the backend, causing the + * UI to render no toggles at all. */ -export const PERMISSION_KEYS = [ - "auto_approve", - "dangerously_skip_permissions", - "allow_indexing", -] as const; +export const PERMISSION_KEYS = ["allow_indexing"] as const; export type PermissionKey = (typeof PERMISSION_KEYS)[number]; diff --git a/apps/web/lib/capability-warning.ts b/apps/web/lib/capability-warning.ts new file mode 100644 index 0000000000..5f88ab55a8 --- /dev/null +++ b/apps/web/lib/capability-warning.ts @@ -0,0 +1,36 @@ +import { IconAlertTriangle, IconLock } from "@tabler/icons-react"; +import type { CapabilityStatus } from "@/lib/types/http"; + +export type CapabilityWarning = { + Icon: typeof IconLock; + color: string; + title: string; +}; + +export function getCapabilityWarning( + status: CapabilityStatus | undefined, + error: string | undefined, +): CapabilityWarning | null { + switch (status) { + case "auth_required": + return { + Icon: IconLock, + color: "text-amber-600 dark:text-amber-400", + title: error || "Authentication required", + }; + case "not_installed": + return { + Icon: IconAlertTriangle, + color: "text-muted-foreground", + title: error || "Agent CLI not installed", + }; + case "failed": + return { + Icon: IconAlertTriangle, + color: "text-red-600 dark:text-red-400", + title: error || "Agent probe failed", + }; + default: + return null; + } +} diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 09a1d5fab7..67d100337d 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -3,6 +3,7 @@ import type { AgentProfile, AvailableAgent, AgentDiscovery, + CapabilityStatus, CustomPrompt, EditorOption, Executor, @@ -40,11 +41,18 @@ export type AgentProfileOption = { agent_id: string; agent_name: string; cli_passthrough: boolean; + /** + * Host utility probe status for the agent this profile belongs to. + * Used by pickers and the settings sidebar to flag profiles whose agent + * needs login or reinstallation. + */ + capability_status?: CapabilityStatus; + capability_error?: string; }; /** Single source of truth for mapping an API Agent+Profile to a store AgentProfileOption. */ export function toAgentProfileOption( - agent: Pick, + agent: Pick, profile: Pick & { cli_passthrough?: boolean }, ): AgentProfileOption { return { @@ -53,6 +61,8 @@ export function toAgentProfileOption( agent_id: agent.id, agent_name: agent.name, cli_passthrough: profile.cli_passthrough ?? false, + capability_status: agent.capability_status, + capability_error: agent.capability_error, }; } diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index adad1bf3fe..e27cf55521 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -5,11 +5,14 @@ export type AgentProfile = { agent_id: string; name: string; agent_display_name: string; + /** Model ID applied via ACP session/set_model at session start. */ model: string; - auto_approve: boolean; - dangerously_skip_permissions: boolean; + /** Optional ACP session mode applied via session/set_mode at session start. */ + mode?: string; + /** Auggie-only CLI flag; ignored for all other agents. */ allow_indexing: boolean; cli_passthrough: boolean; + user_modified?: boolean; created_at: string; updated_at: string; }; @@ -31,6 +34,14 @@ export type Agent = { mcp_config_path?: string | null; tui_config?: TUIConfig | null; profiles: AgentProfile[]; + /** + * Host utility probe status for this agent type — mirrors + * `ModelConfig.status`. Populated by the backend from the host utility + * cache so clients can flag profiles that need login or reinstallation + * without fetching the full model config separately. + */ + capability_status?: CapabilityStatus; + capability_error?: string; created_at: string; updated_at: string; }; @@ -75,23 +86,62 @@ export type AgentCapabilities = { export type ModelEntry = { id: string; name: string; - provider: string; - context_window: number; - is_default: boolean; + description?: string; + provider?: string; + context_window?: number; + is_default?: boolean; source?: "static" | "dynamic"; + /** + * Agent-specific extras from ACP's `_meta` field. GitHub Copilot exposes + * `copilotUsage` (e.g. "1x", "0.33x", "0x" — premium-request multiplier) + * and `copilotEnablement`. + */ + meta?: Record; }; +export type ModeEntry = { + id: string; + name: string; + description?: string; + meta?: Record; +}; + +export type CommandEntry = { + name: string; + description?: string; +}; + +// CapabilityStatus mirrors the host utility probe status. "probing" is the +// in-flight state; "ok" is populated; the remaining values signal errors the +// UI can surface directly (auth, install, generic failure, not started yet). +export type CapabilityStatus = + | "probing" + | "ok" + | "auth_required" + | "not_installed" + | "failed" + | "not_configured"; + export type ModelConfig = { default_model: string; available_models: ModelEntry[]; + current_model_id?: string; + available_modes?: ModeEntry[]; + current_mode_id?: string; + available_commands?: CommandEntry[]; supports_dynamic_models: boolean; + status?: CapabilityStatus; + error?: string; }; export type DynamicModelsResponse = { agent_name: string; + status: CapabilityStatus; models: ModelEntry[]; - cached: boolean; - cached_at?: string; + current_model_id?: string; + modes?: ModeEntry[]; + current_mode_id?: string; + commands?: CommandEntry[]; error: string | null; }; diff --git a/docs/decisions/0002-host-utility-agentctl-for-sessionless-flows.md b/docs/decisions/0002-host-utility-agentctl-for-sessionless-flows.md new file mode 100644 index 0000000000..d7066f4f5a --- /dev/null +++ b/docs/decisions/0002-host-utility-agentctl-for-sessionless-flows.md @@ -0,0 +1,54 @@ +# 0002: Host Utility Agentctl for Sessionless ACP Flows + +**Status:** accepted +**Date:** 2026-04-08 +**Area:** backend + +## Context + +Utility agent calls (enhance-prompt, commit-message, etc.) historically required an active task session: `lifecycle.Manager.ExecuteInferencePrompt` took a `sessionID`, used `GetOrEnsureExecution` to locate that task's agentctl instance, and spawned a one-shot ACP subprocess inside it. This coupled *any* one-shot inference to an existing workspace, blocking three emerging needs: + +1. Boot-time capability probes (list models/modes/auth for each installed agent so the UI can populate settings). +2. On-demand "refresh" of those capabilities from settings. +3. Running `enhance-prompt` in the new-task modal — before any task or workspace exists. + +Built-in utility prompt templates are self-contained (they inject context as template variables rather than reading files), so the task workspace cwd was incidental — the coupling was structural, not semantic. + +## Decision + +Introduced a **host utility** subsystem (`internal/agent/hostutility/Manager`) that maintains one long-lived, workspace-only agentctl instance per ACP-native inference agent type, each bound to a process-scoped tmp directory. The manager is started after `waitForAgentctlControlHealthy` in `cmd/kandev/main.go`, runs parallel boot probes via `errgroup`, and owns an in-memory capability cache. + +Key pieces: + +- **Probe path.** New `ACPInferenceExecutor.Probe` in `internal/agentctl/server/utility/acp_executor.go` runs `initialize` + `session/new` against an ephemeral subprocess and returns the parsed `ProbeResponse` (agent info, auth methods, models, modes, prompt capabilities). Exposed over HTTP as `POST /api/v1/inference/probe` with a matching `Client.Probe` client method. +- **Warm instances, ephemeral ACP.** The per-agent-type instance is workspace-only — no persistent agent subprocess. Each probe/prompt call still spawns its own short-lived ACP subprocess via the existing `/api/v1/inference/prompt` and new `/api/v1/inference/probe` routes. The "warm" part is the agentctl instance host, not the ACP session. +- **Sessionless utility execute.** `POST /api/v1/utility/execute` now treats `session_id` as optional. When absent, `internal/utility/handlers/handlers.go:executeSessionless` routes through `HostUtilityExecutor.ExecutePrompt`, template substitution uses `MissingAsEmpty: true` for variables that are meaningless without a session (GitDiff, TaskTitle, etc.), and the `UtilityAgentCall` record is still written. +- **Public HTTP surface** (`internal/agent/capabilities/handlers/`, prefix `/api/v1/agent-capabilities` — separate from `/api/v1/agents/:id/...` to avoid a Gin wildcard name collision): + - `GET /api/v1/agent-capabilities` — list all cached + - `GET /api/v1/agent-capabilities/:type` — single agent + - `POST /api/v1/agent-capabilities/:type/probe` — re-probe + refresh cache + - `POST /api/v1/agent-capabilities/:type/prompt` — raw one-off inference (no utility agent record) +- **Tmp dir lifetime.** Each kandev process creates `kandev-host-utility--*` with per-agent subdirs; `Stop()` removes only dirs owned by this process. Concurrent kandev processes never share or clobber each other's tmp state. +- **Scope.** v1 probes only agents whose `Runtime().Protocol == ProtocolACP` (claude-acp, codex-acp, copilot-acp, amp-acp, opencode-acp, auggie). Non-ACP adapters (streamjson, codex, copilot, amp, opencode) are explicitly out of scope; they can be extended later. +- **Model resolution** for sessionless calls: explicit arg → cached probe `currentModelId`. Static per-agent model lists have been removed; `InferenceAgent` no longer exposes an `InferenceModels()` method. +- **Failure isolation.** Per-agent-type probe failures land in the cache as `{Status: failed|auth_required|not_installed, Error}` without aborting other probes or blocking boot. + +## Consequences + +**Easier:** +- Settings can show per-agent status/auth/models driven by a single endpoint. +- Enhance-prompt works before a task exists without synthesizing a throwaway session. +- Probe and sessionless prompt share the same underlying path — one mechanism, three features. +- Cold-start is bounded to the ACP subprocess spawn (~100–300ms); the agentctl host is already warm. + +**Harder:** +- One more long-lived subsystem to supervise. Crashes are lazy-repaired on next use; there is no background health loop. +- Host instances run with backend privileges in a tmp dir. The capabilities handlers do not expose shell/files endpoints, but raw agent-level permissions on the underlying agentctl instance are not fenced off in code — discipline is required if future endpoints are added. +- Utility templates that genuinely need to read workspace files cannot use the sessionless path. For v1 this is fine (all built-ins are self-contained); a `requires_workspace` flag on `UtilityAgent` is the future escape hatch. + +## Alternatives Considered + +1. **Ephemeral per-call agentctl.** Spawn agentctl, run inference, tear down. Cleanest isolation but adds cold-start latency (agentctl boot + `npx` resolution) to every Enhance click. Rejected — feels sluggish for UI-driven flows. +2. **Direct vendor SDK calls from Go.** Skip ACP entirely for inference and call Anthropic/OpenAI/etc. APIs directly. Simpler runtime but requires reimplementing auth and model listing per vendor, and diverges from the ACP-first design. Rejected as the default; may still be the right call for vendor-specific model listing in a later iteration. +3. **Reserved execution IDs in `ExecutionStore` with a `GetOrEnsureExecution` bypass.** Would have forced host utility through `lifecycle.Manager` and required a new `host-utility:` ID prefix branch in `manager_execution.go`. Rejected once it became clear that `ControlClient.CreateInstance` already does everything needed without touching the execution store. +4. **One host agentctl (not per-agent-type).** A single instance that gets re-used for all ACP agents. Rejected because probes would either have to serialize or share subprocess state; per-agent-type gives natural isolation and cheaper refresh. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 6dfe6b49e5..2fe565e2b6 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -7,3 +7,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | # | Title | Status | Area | Date | |---|-------|--------|------|------| | 0001 | [File-based knowledge system](0001-file-based-knowledge-system.md) | accepted | infra | 2026-03-28 | +| 0002 | [Host utility agentctl for sessionless ACP flows](0002-host-utility-agentctl-for-sessionless-flows.md) | accepted | backend | 2026-04-08 | diff --git a/docs/plans/2026-04-host-utility-agentctl.md b/docs/plans/2026-04-host-utility-agentctl.md new file mode 100644 index 0000000000..58e53da2b5 --- /dev/null +++ b/docs/plans/2026-04-host-utility-agentctl.md @@ -0,0 +1,110 @@ +# Host Utility Agentctl — Boot Probes, Capability API, Sessionless Utility Prompts + +**Date:** 2026-04-08 +**Status:** implemented +**PR:** TBD +**Decision:** ADR-0002 + +## Problem + +Utility agent calls (enhance-prompt, commit-message, etc.) required an active task session because `lifecycle.Manager.ExecuteInferencePrompt` looked up the session's agentctl via `GetOrEnsureExecution` and spawned a one-shot ACP subprocess inside it. Three features could not fit that mold: + +1. **Boot-time probes.** List models/modes/auth for each installed agent so the settings UI can render them and detect misconfiguration early. +2. **Settings refresh.** User clicks "refresh" to re-check auth/models after installing or re-authing a CLI. +3. **Sessionless enhance-prompt.** Enhance button in the new-task modal — no task or workspace exists yet. + +Built-in utility prompts are self-contained (context is injected as template vars), so running them in a neutral tmp dir is semantically fine. + +## Design + +### Architecture + +```text +Backend boot + ↓ +waitForAgentctlControlHealthy + ↓ +HostUtilityManager.Start(ctx) [goroutine] + ├─ mktemp kandev-host-utility--*/ + ├─ filter registry → ACP-native inference agents + └─ errgroup (parallel): + for each agent type: + ├─ IsInstalled? (skip → not_installed) + ├─ ControlClient.CreateInstance(workdir=tmp/) + ├─ waitForClientHealthy + └─ Probe() → cache +``` + +Per-call path (both probe and sessionless prompt): + +```text +caller → HostUtilityManager.getInstance(type) [singleflight per agent type] + → per-type warm Client (one-shot ACP subprocess) + → agentctl utility.{Probe|Execute} + → initialize → session/new → [set_mode?] → [prompt?] → kill +``` + +### Packages and files + +**New** +- `internal/agent/hostutility/` — `Manager`, `cache`, public API (`GetAll`, `Get`, `Refresh`, `ExecutePrompt`), types (`AgentCapabilities`, `Model`, `Mode`, `AuthMethod`, `PromptResult`, `Status` enum). +- `internal/agent/capabilities/handlers/` — HTTP routes (mounted under `/api/v1/agent-capabilities` rather than under `/api/v1/agents/:id/...` to avoid a Gin wildcard-name collision with the existing settings routes): + - `GET /api/v1/agent-capabilities` + - `GET /api/v1/agent-capabilities/:type` + - `POST /api/v1/agent-capabilities/:type/probe` + - `POST /api/v1/agent-capabilities/:type/prompt` (raw one-off) + +**Extended** +- `internal/agentctl/server/utility/types.go` — `ProbeRequest`/`ProbeResponse`, `Mode` field on `PromptRequest`, `ProbeAuthMethod`/`ProbeModel`/`ProbeMode`/`ProbePromptCapabilities`. +- `internal/agentctl/server/utility/acp_executor.go` — `Probe()` method, `executeACPSession` honors `mode` via `conn.SetSessionMode`. Parsing split into `buildInitProbeFields` + `applySessionProbeFields` helpers. +- `internal/agentctl/server/utility/handler.go` — `POST /api/v1/inference/probe` route. +- `internal/agentctl/client/utility.go` — factored `doLongRunningJSON` helper shared by `InferencePrompt` and new `Probe`. +- `internal/utility/template/engine.go` — `ResolveWithOptions` + `ResolveOptions{MissingAsEmpty bool}`. +- `internal/utility/service/service.go` — `PreparePromptRequest` takes a `sessionless bool` flag. +- `internal/utility/controller/controller.go` — threads the flag through. +- `internal/utility/handlers/handlers.go` — `session_id` optional; sessionless branch calls `HostUtilityExecutor.ExecutePrompt`; new `HostUtilityExecutor` interface. +- `cmd/kandev/main.go` — constructs `HostUtilityManager` after `waitForAgentctlControlHealthy`, starts it in a goroutine, registers `Stop()` cleanup via `addCleanup` with a 10s timeout. Threaded through `buildHTTPServer` → `routeParams`. +- `cmd/kandev/helpers.go` — `routeParams.hostUtilityMgr`; registers both the new capabilities routes and the updated utility routes. + +### Config shapes (internal) + +```go +// ProbeConfig — implicit, built per-call by the manager from registry + workdir. +// No Model — probes must omit --model so session/new returns the agent default +// plus the full availableModels list. +// +// PromptConfig — uses the existing InferenceConfigDTO; Model is required, +// Mode is optional and when set triggers session/set_mode after session/new. +``` + +### Caller responsibilities + +| Param | Probe | Sessionless prompt | Task-scoped prompt | +|---------|-------|--------------------|---------------------| +| WorkDir | host tmp dir (manager-owned) | host tmp dir | task workspace (unchanged) | +| Model | **omitted** | required (utility record → user default → cached currentModelId) | from utility record / user default | +| Mode | unused | optional | optional | + +### Scope (v1) + +- ACP-native agents only (`Runtime().Protocol == ProtocolACP`): claude-acp, codex-acp, copilot-acp, amp-acp, opencode-acp, auggie. +- Non-ACP protocols (streamjson/codex/copilot/amp/opencode adapters) excluded — later iteration. +- Cache is in-memory, populated at boot and on explicit `/probe` refresh. No TTL. +- Tmp dirs process-scoped (`kandev-host-utility--*`); cleanup only removes dirs owned by this process. + +## Implementation Notes + +- **Lazy instance recreation.** `Manager.getInstance` recreates a warm instance if it's missing (never bootstrapped or crashed). No separate health loop. +- **Failure isolation.** Per-agent bootstrap failures land in the cache as `Status = not_installed | auth_required | failed`. `isAuthError` is a coarse string-match heuristic on the ACP SDK error — the SDK doesn't expose a distinct auth error code. +- **Env inheritance.** The agentctl instance is workspace-only and inherits the backend's env. The ephemeral ACP subprocess spawned by `Probe`/`Execute` also inherits (no `cmd.Env` override in `acp_executor.go`), so `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. flow through naturally for v1. +- **`ExecutionStore` bypass deferred.** The original plan called for a `host-utility:` ID prefix branch in `manager_execution.go`. Once it became clear that `ControlClient.CreateInstance` + a regular `Client` does everything we need without touching the lifecycle manager's execution store, that branch was removed from the plan — host utility sits alongside the lifecycle manager rather than through it. +- **Worktree access.** Sessionless utility calls cannot read workspace files by design. All current built-ins are self-contained; a `requires_workspace` flag on `UtilityAgent` is the documented future escape hatch for custom templates that need file access. +- **Lint wrinkle.** `acp_executor.go` has one `//nolint:unconvert` on `string(m.Id)` where `m.Id` is `acp.AuthMethodId` (a named string type). The linter flags it as unnecessary, but Go requires the explicit conversion. +- **Frontend wiring is explicitly deferred.** Settings page, new-task modal enhance button, and startup capabilities fetch land in a follow-up PR. The API surface was designed to support all three. + +### Verification + +- `make fmt` clean +- `go build ./...` success +- `make test` all packages green +- `make lint` 0 issues diff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md index 2b4aeff0f6..b603d194e0 100644 --- a/docs/plans/INDEX.md +++ b/docs/plans/INDEX.md @@ -6,3 +6,4 @@ Create new plans via `/record plan` or manually following the format below. | Plan | Status | Date | PR | |------|--------|------|----| +| [Host utility agentctl](2026-04-host-utility-agentctl.md) | implemented | 2026-04-08 | TBD |