diff --git a/.gitignore b/.gitignore index 565a73f..2f60626 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,15 @@ specs/ *.png go.work go.work.sum +pkg/cli/webapp/node_modules/ +pkg/cli/webapp/*.tsbuildinfo +# goreleaser output at repo root. +/dist/ +# Keep pkg/cli/webapp/dist tracked so the webapp is embedded by //go:embed in +# serve.go (it cannot be built in CI due to the local clicky-ui link: dependency). +# These negations override a global `dist/` ignore; re-include the directory +# before its contents. +!pkg/cli/webapp/dist/ +!pkg/cli/webapp/dist/** +.grite/ +pkg/cli/webapp/dist/ diff --git a/README.md b/README.md index af76039..125060f 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,16 @@ It provides tools to: - inspect Claude Code project/session history - summarize tool usage, paths, binaries, and API cost +- list files changed by a session - install Claude Code hooks - enforce a session-specific **Definition of Done** gate - test and use AI providers from the command line +- run iterative AI agents with verifiers, worktrees, and commits - generate/build/run containerized Claude Code sandboxes - inspect and clean stored Claude project session data +- expose captain commands as an MCP server +- run a web UI for launching and chatting with AI agents +- configure default backend, model, and AI safety toggles ## What it does @@ -57,11 +62,23 @@ Captain supports a per-session Definition of Done workflow: This lets Claude continue iterating until required checks pass. -### 4. AI provider utilities +### 4. Session changes + +`captain changes` lists the files written or edited during a Claude Code or Codex session: + +- defaults to the most recent session in the current directory +- `--session-id` targets a specific session by exact or prefix match +- `--all` searches across all projects +- `--claude` / `--codex` filter by session source +- `--agents` (default: true) includes files edited by nested sub-agents +- `--plans` / `--ignored` control whether plan files and git-ignored files are shown + +### 5. AI provider utilities Captain includes provider-agnostic AI utilities under `captain ai`: - `ai prompt` — send a prompt to a selected backend/model +- `ai agent` — run an iterative agent loop with optional verifiers, a throwaway git worktree, commit, and LLM judge - `ai models` — list model information - `ai test` — verify provider connectivity - `ai fixture` — run a YAML benchmark fixture across multiple Claude configurations and capture a markdown evidence report @@ -71,9 +88,24 @@ Supported backends are inferred from code and dependencies, including: - Anthropic - Gemini / Google - OpenAI-compatible paths via the internal provider layer -- CLI/provider abstractions for local tool-backed backends +- CLI/provider abstractions for local tool-backed backends (claude, codex, gemini) + +### 6. Adapter status and configuration + +- `whoami` — lists every AI adapter (API providers and CLI agents), how each is authenticated, whether its binary is installed, and the models it exposes +- `configure` — interactive wizard to set default backend, model, reasoning effort, budget, timeout, and feature toggles (caching, MCP, hooks, skills, user/project settings, memory) saved to `~/.captain.yaml` + +### 7. Web UI and MCP server + +- `serve` — starts an HTTP API and embedded web UI for launching AI agents and opening follow-up chat sessions; supports `--dev` to proxy to the Vite dev server +- `mcp` — exposes captain commands (history, info, cost, changes, dod, etc.) as MCP tools so Claude Code can invoke them directly -### 5. Container sandbox builder +### 8. Utility commands + +- `cmux screenshot` — captures a screenshot of the active browser surface in cmux and copies the path to the clipboard +- `port kill ` — finds and kills the process listening on a TCP port + +### 9. Container sandbox builder Captain can discover Claude-related local configuration and package it into a container sandbox. @@ -103,12 +135,19 @@ The container workflow is designed to package things like: captain/ ├── cmd/captain/ # CLI entrypoint ├── pkg/ai/ # AI abstraction, provider config, models +├── pkg/ai/agent/ # Iterative agent loop, plugins (verify, worktree, judge) ├── pkg/ai/fixture/ # YAML fixture runner for Claude configuration benchmarks +├── pkg/api/ # HTTP API types and handlers (used by serve) ├── pkg/bash/ # Bash scanning, classification, rules +├── pkg/captainconfig/ # ~/.captain.yaml config load/save ├── pkg/claude/ # Claude history, sessions, parsing, formatting ├── pkg/cli/ # Cobra/clicky command implementations +├── pkg/cli/webapp/ # Embedded React web UI (served by captain serve) +├── pkg/cmux/ # Terminal multiplexer integration (screenshot) +├── pkg/collections/ # Generic collection utilities ├── pkg/container/ # Sandbox discovery, generation, build/run logic ├── pkg/dod/ # Definition of Done persistence and execution +├── pkg/git/ # Git worktree helpers ├── pkg/sandbox/ # Token/preset/sandbox helpers ├── Dockerfile # Container image for captain/Claude tooling ├── entrypoint.sh # gosu-based user switching entrypoint @@ -124,12 +163,19 @@ Top-level commands exposed by `cmd/captain/main.go`: captain history captain info captain cost +captain changes captain sandbox captain ai +captain whoami +captain configure +captain serve captain dod captain hook captain projects captain container +captain mcp +captain cmux +captain port ``` ### History @@ -140,6 +186,8 @@ captain history --summary captain history --all captain history --tool Bash --since now-7d captain history --category git --compact +captain history --session-id 019e0365-dc2a-7ad0-a5a8-78936481a928 +captain history 019e0365-dc2a-7ad0-a5a8-78936481a928 ``` Useful flags include: @@ -149,6 +197,7 @@ Useful flags include: - `--dir` - `--category` - `--approved` +- `--session-id` - `--limit` - `--since` - `--all` @@ -173,6 +222,7 @@ captain cost --group-by project captain cost --group-by model captain cost --group-by tool captain cost --group-by category +captain cost --session-id 019e0365-dc2a-7ad0-a5a8-78936481a928 captain cost --all --since now-30d ``` @@ -187,6 +237,18 @@ Supported groupings from the code: - `tool` - `category` +### Changes + +```bash +captain changes +captain changes --session-id +captain changes --all --since now-7d +captain changes --claude +captain changes --agents=false +``` + +Lists the files written or edited during a Claude Code or Codex session. Defaults to the most recent session in the current directory. + ### Hook installation Install the bash safety hook: @@ -216,6 +278,8 @@ captain dod clear --session-id ```bash captain ai prompt --model claude-sonnet-4 --prompt "Summarize this diff" +captain ai agent --prompt "Fix the failing tests" --verify "go test ./..." +captain ai agent --prompt "Refactor this module" --worktree --commit --judge "all tests pass" captain ai test --model gemini-2.0-flash captain ai models captain ai fixture --file examples/ai-fixtures/mission-control-investigate.yaml @@ -230,6 +294,20 @@ Relevant provider flags include: - `--budget` - `--debug` +#### AI agent + +`captain ai agent` runs an iterative AI agent loop with optional quality gates: + +- `--prompt` / `-p` — task prompt (required; can be piped from stdin) +- `--system` / `-s` — system prompt override +- `--verify` — shell command run after each turn; non-zero exit triggers a re-run (repeatable) +- `--max-iterations` — max verify-and-rerun iterations (default: 1) +- `--scope` — verifier scope: `changed` (only changed files) or `all` (default) +- `--worktree` — run in a throwaway git branch/worktree +- `--branch` — worktree branch name (default: `captain/agent-`) +- `--commit` — commit changes on the worktree branch (requires `--worktree`) +- `--judge` — LLM rubric; fails a turn when the judge rejects the result + #### Fixture benchmarks `captain ai fixture` runs the same prompt against multiple Claude @@ -366,6 +444,52 @@ Important generate/build flags: - `--base` - `--mode copy|mount` +### Whoami + +```bash +captain whoami +captain whoami --backend anthropic +captain whoami --models=false +``` + +Lists every AI adapter (API providers and CLI agents: `anthropic`, `openai`, `gemini`, `claude-cli`, `claude-agent`, `codex-cli`, `gemini-cli`), their authentication method, binary availability, and a live model listing. + +### Configure + +```bash +captain configure +``` + +Interactive wizard that writes `~/.captain.yaml` with defaults for backend, model, reasoning effort, budget, timeout, and feature toggles (caching, MCP, hooks, skills, user/project settings, memory). These defaults apply to `captain ai prompt`, `captain ai agent`, `captain ai test`, and other AI commands. + +### Serve + +```bash +captain serve +captain serve --port 8080 +captain serve --dev +``` + +Starts an HTTP API and embedded web UI. The UI launches `captain ai agent` operations and opens follow-up chat windows that resume the returned session. `--dev` starts the Vite dev server from `pkg/cli/webapp` and proxies `/api` back to the Go process. + +### MCP server + +```bash +captain mcp +``` + +Exposes captain commands as MCP tools. Auto-exposes all commands except `sandbox`, `projects`, `container`, `hook`, `ai`, `dod set/clear/run`. + +### Utility commands + +```bash +# Screenshot active browser surface in cmux +captain cmux screenshot + +# Kill the process on a TCP port +captain port kill 3000 +``` + ## Build and development This repo uses `task` as the main task runner. @@ -431,22 +555,11 @@ Primary stack: - **Go 1.25.8** - **Cobra** for CLI wiring - **clicky** for formatting/output/flag binding +- **charmbracelet/huh** for the interactive `configure` TUI - **sandbox-runtime** for sandbox preset handling - AI SDKs for Anthropic, OpenAI, and Gemini/Google - shell parsing via `mvdan.cc/sh/v3` -## Current state - -This README reflects the current code layout. - -At the time of generation, `go test ./...` in this checkout does **not** fully pass. Observed issues included: - -- a build failure around `pkg/claude/tools` -- failing container tests -- AI integration tests requiring valid external provider credits/config - -So treat this repository as **active/in-progress** rather than guaranteed green in the current local state. - ## Quick start ```bash @@ -454,9 +567,17 @@ cd captain make build .bin/captain info .bin/captain history --summary +.bin/captain changes .bin/captain container list ``` +Configure defaults: + +```bash +.bin/captain configure +.bin/captain whoami +``` + If you want to use hooks: ```bash @@ -464,9 +585,16 @@ If you want to use hooks: .bin/captain hook dod install --user ``` +Start the web UI: + +```bash +.bin/captain serve +``` + ## Notes - Captain is tightly focused on **Claude Code workflows**. - It is both an analysis tool and an execution/control tool. - The container/sandbox functionality is a major part of the project, not a side feature. - Many commands assume the presence of Claude local state under the user’s Claude config/projects directories. +- Configuration is persisted to `~/.captain.yaml` via `captain configure`. diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 8becbe3..512dabd 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -70,6 +70,15 @@ func main() { changesCmd.Short = "List files modified by a session" changesCmd.Long = "List the files written or edited during a Claude Code or Codex session. Pass --session-id to target a specific session; otherwise the most recent session in the current directory is used." + planCmd := clicky.AddNamedCommand("plan", rootCmd, cli.PlanOptions{}, cli.RunPlan) + planCmd.Short = "Show the exit-plan-mode plan for a session" + planCmd.Long = "Determine the plan file path and content for a Claude Code or Codex session. Pass a session ID (exact or prefix) to target a specific session; otherwise the most recent session with a plan in the current directory is used. Claude plans resolve to a ~/.claude/plans/.md file; Codex plans are inline update_plan checklists. Use --path to print only the plan file path." + + sessionsCmd := &cobra.Command{Use: "sessions", Short: "Browse Claude and Codex sessions"} + rootCmd.AddCommand(sessionsCmd) + clicky.AddNamedCommand("list", sessionsCmd, cli.SessionListOptions{}, cli.RunSessionList).Short = "List discovered sessions" + clicky.AddNamedCommand("get", sessionsCmd, cli.SessionGetOptions{}, cli.RunSessionGet).Short = "Show a session transcript" + sandboxCmd := &cobra.Command{ Use: "sandbox", Short: "Sandbox configuration tools", @@ -92,14 +101,21 @@ func main() { } rootCmd.AddCommand(aiCmd) clicky.AddNamedCommand("prompt", aiCmd, cli.AIPromptOptions{}, cli.RunAIPrompt) + clicky.AddNamedCommand("agent", aiCmd, cli.AIAgentOptions{}, cli.RunAIAgent).Short = "Run an iterative agent with verifiers, worktree, and commit" clicky.AddNamedCommand("models", aiCmd, cli.AIModelsOptions{}, cli.RunAIModels) clicky.AddNamedCommand("test", aiCmd, cli.AITestOptions{}, cli.RunAITest) clicky.AddNamedCommand("fixture", aiCmd, cli.AIFixtureOptions{}, cli.RunAIFixture).Short = "Run a YAML fixture across multiple Claude configurations" + whoamiCmd := clicky.AddNamedCommand("whoami", rootCmd, cli.WhoamiOptions{}, cli.RunWhoami) + whoamiCmd.Short = "List agent adapters, auth methods, and available models" + whoamiCmd.Long = "Show every AI agent adapter (API providers and CLI agents), how each is authenticated (API-key env var or CLI login), whether its CLI binary is installed, and the models each provider exposes via a live API call. Pass --models=false to skip the network probes, or --backend to inspect a single adapter." + configureCmd := clicky.AddNamedCommand("configure", rootCmd, cli.ConfigureOptions{}, cli.RunConfigure) configureCmd.Short = "Interactive wizard to set default model, backend, budget, and safety toggles" configureCmd.Long = "Run an interactive form to configure ~/.captain.yaml. These defaults are applied to `captain ai prompt`, `captain ai test`, and other AI commands when corresponding flags are not passed." + rootCmd.AddCommand(cli.NewServeCommand(version)) + dodCmd := &cobra.Command{ Use: "dod", Short: "Definition of Done checks", diff --git a/go.mod b/go.mod index be88796..466c551 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,12 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/firebase/genkit/go v1.8.0 github.com/flanksource/clicky v1.21.33 - github.com/flanksource/commons v1.51.3 + github.com/flanksource/clicky/aichat v1.21.34-0.20260630075958-cd55f2f4fce3 + github.com/flanksource/commons v1.53.1 github.com/flanksource/sandbox-runtime v1.0.2 github.com/google/dotprompt/go v0.0.0-20260502013637-5cd4a8405ca3 + github.com/google/uuid v1.6.0 + github.com/invopop/jsonschema v0.13.0 github.com/mattn/go-sqlite3 v1.14.38 github.com/onsi/ginkgo/v2 v2.28.0 github.com/onsi/gomega v1.39.1 @@ -103,7 +106,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.18.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -112,7 +114,6 @@ require ( github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf // indirect github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect github.com/itchyny/gojq v0.12.19 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/jeremywohl/flatten v1.0.1 // indirect @@ -125,6 +126,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.29.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -165,6 +167,7 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect diff --git a/go.sum b/go.sum index a1c93c6..436e72c 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,10 @@ github.com/firebase/genkit/go v1.8.0 h1:jIL9xS3ZxW9sTWN2SG9RyupPd0srjXmfB1749FPI github.com/firebase/genkit/go v1.8.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= github.com/flanksource/clicky v1.21.33 h1:fiZ4+sjKdtcmqGgaJiER5wM/34QSjS9pL29gQ84BKOs= github.com/flanksource/clicky v1.21.33/go.mod h1:1vjQu7ZeWqvEy98bIlZWUni6F+fy9KtWgaHCtAJs6yE= -github.com/flanksource/commons v1.51.3 h1:sgQZ2s0XJTub4qmIlzRyH+eYXJP6UXmreCataP9mE7E= -github.com/flanksource/commons v1.51.3/go.mod h1:BxXJzAsRxsw0la7Y/ShEABa8ZbtGIdRi7PCRjiHDCJE= +github.com/flanksource/clicky/aichat v1.21.34-0.20260630075958-cd55f2f4fce3 h1:mXkj1sHYNWi+9TJwo3xV8oI7soPmfWMce7AR4/7Y2NM= +github.com/flanksource/clicky/aichat v1.21.34-0.20260630075958-cd55f2f4fce3/go.mod h1:u0m4ogkrOCW9/TAc/f06D4kBgZOZaxvLQ7HCyglL9kI= +github.com/flanksource/commons v1.53.1 h1:WiMvY9XGG//L4ndYKTcmp0NjWXg4B7Wbn4hYWkITUmY= +github.com/flanksource/commons v1.53.1/go.mod h1:ZII22jIDJ3fd/Mz7l0SzLFEv8aoSUg+xwfJAAVf8up4= github.com/flanksource/gomplate/v3 v3.24.82 h1:22HOZYeNRMX40G8OF3qRAqRoR5Lkf8Vm4x3WDqugZkE= github.com/flanksource/gomplate/v3 v3.24.82/go.mod h1:NMMZkFsjbLy/8iY8Fip5N86Y0PP6lZeq+kmPwpVVIL0= github.com/flanksource/is-healthy v1.0.88 h1:ATQuKoNdp8Qfzf41/eMFajmT0qzOmZlZNG5eLK41RFo= @@ -159,6 +161,8 @@ github.com/flanksource/kubectl-neat v1.0.4 h1:t5/9CqgE84oEtB0KitgJ2+WIeLfD+RhXSx github.com/flanksource/kubectl-neat v1.0.4/go.mod h1:Un/Voyh3cmiZNKQrW/TkAl28nAA7vwnwDGVjRErKjOw= github.com/flanksource/sandbox-runtime v1.0.2 h1:dEaVyUpeaCAn3GOiWcNDrPguaey1Gx9eTsPAc27W3Eg= github.com/flanksource/sandbox-runtime v1.0.2/go.mod h1:x5Ywc1MrP3lTgmj0VUCeFJzrDZ3mBhBV4Gdc/ioV76M= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -278,6 +282,8 @@ github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIi github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.29.0 h1:sH1NBcumKskhxqYzhXfGc201D7P76TVXiT0fGVhabeI= +github.com/mark3labs/mcp-go v0.29.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -381,6 +387,8 @@ github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/pkg/ai/agent.go b/pkg/ai/agent.go index 189bec5..446c21a 100644 --- a/pkg/ai/agent.go +++ b/pkg/ai/agent.go @@ -7,6 +7,7 @@ import ( "time" "github.com/flanksource/captain/pkg/ai/pricing" + "github.com/flanksource/captain/pkg/api" ) // Agent is a convenience wrapper over a Provider that offers the named-prompt / @@ -30,6 +31,10 @@ type PromptRequest struct { SystemPrompt string `json:"system_prompt,omitempty"` Context map[string]string `json:"context,omitempty"` StructuredOutput any `json:"structured_output,omitempty"` + // Source identifies the prompt template (e.g. the .prompt filename) for + // diagnostics; forwarded to ai.Request.Source and printed by the logging + // middleware. + Source string `json:"source,omitempty"` } // PromptResponse is the result of one PromptRequest. @@ -71,13 +76,14 @@ func (a *Agent) GetBackend() Backend { return a.provider.GetBackend() } // ExecutePrompt runs one prompt and accrues its cost onto the agent. func (a *Agent) ExecutePrompt(ctx context.Context, req PromptRequest) (*PromptResponse, error) { start := time.Now() - resp, err := a.provider.Execute(ctx, Request{ - SystemPrompt: req.SystemPrompt, - Prompt: req.Prompt, - StructuredOutput: req.StructuredOutput, - }) + resp, err := a.provider.Execute(ctx, Request{Prompt: api.Prompt{ + User: req.Prompt, + System: req.SystemPrompt, + Source: req.Source, + Schema: req.StructuredOutput, + }}) if err != nil { - return &PromptResponse{Request: req, Model: a.cfg.Model, Error: err.Error(), Duration: time.Since(start)}, err + return &PromptResponse{Request: req, Model: a.cfg.Model.Name, Error: err.Error(), Duration: time.Since(start)}, err } cost := a.accrue(resp) @@ -154,21 +160,26 @@ func (a *Agent) Close() error { func (a *Agent) accrue(resp *Response) Cost { model := resp.Model if model == "" { - model = a.cfg.Model + model = a.cfg.Model.Name } cost := Cost{ - Model: model, - InputTokens: resp.Usage.InputTokens, - OutputTokens: resp.Usage.OutputTokens, - TotalTokens: resp.Usage.TotalTokens(), + Model: model, + InputTokens: resp.Usage.InputTokens, + OutputTokens: resp.Usage.OutputTokens, + ReasoningTokens: resp.Usage.ReasoningTokens, + CacheReadTokens: resp.Usage.CacheReadTokens, + CacheWriteTokens: resp.Usage.CacheWriteTokens, + TotalTokens: resp.Usage.TotalTokens(), } // The pricing registry is keyed on OpenRouter-style ids (provider/model); - // try the backend-prefixed id first, then the bare model. The split between - // input/output cost is not modelled by the registry, so the total is carried - // in InputCost (Cost.Total() stays correct for reporting). + // try the backend-prefixed id first, then the bare model. for _, id := range pricingIDs(a.provider.GetBackend(), model) { if res, err := pricing.CalculateCost(id, cost.InputTokens, cost.OutputTokens, resp.Usage.ReasoningTokens, resp.Usage.CacheReadTokens, resp.Usage.CacheWriteTokens); err == nil { - cost.InputCost = res.TotalCost + cost.InputCost = res.InputCost + cost.OutputCost = res.OutputCost + cost.ReasoningCost = res.ReasoningCost + cost.CacheReadCost = res.CacheReadCost + cost.CacheWriteCost = res.CacheWriteCost break } } diff --git a/pkg/ai/agent/runner.go b/pkg/ai/agent/runner.go index 82ac4e8..8b99231 100644 --- a/pkg/ai/agent/runner.go +++ b/pkg/ai/agent/runner.go @@ -30,6 +30,46 @@ const ( ScopeAll Scope = "all" ) +// AllScopes lists every verifier scope in canonical order. It is the single +// source of truth behind Scope.Valid, ScopeList, ParseScope, and the +// help/error/completion strings that enumerate scopes. +func AllScopes() []Scope { + return []Scope{ScopeAll, ScopeChanged} +} + +// Valid reports whether s is one of the supported scopes. +func (s Scope) Valid() bool { + for _, x := range AllScopes() { + if s == x { + return true + } + } + return false +} + +// ScopeList renders the supported scopes as a comma-separated string for +// help/error text. +func ScopeList() string { + parts := make([]string, len(AllScopes())) + for i, s := range AllScopes() { + parts[i] = string(s) + } + return strings.Join(parts, ", ") +} + +// ParseScope resolves a CLI/flag value into a Scope, defaulting empty to +// ScopeAll. It fails loud on any other value, naming the valid set. +func ParseScope(s string) (Scope, error) { + switch Scope(s) { + case "", ScopeAll: + return ScopeAll, nil + case ScopeChanged: + return ScopeChanged, nil + default: + return "", fmt.Errorf("invalid --scope %q (valid: %s)", s, ScopeList()) + } +} + // RunContext is the shared per-run state passed to every plugin. Setup plugins // may rewrite Cwd (a worktree); the Runner fills SessionID/ChangedFiles from the // event stream as the loop progresses. @@ -172,7 +212,7 @@ func (r *Runner) Run(ctx context.Context) (*RunResult, error) { feedback = v.Feedback } req := r.Build(rc, iter, prev, feedback) - req.Cwd = rc.Cwd + req.Context.Dir = rc.Cwd return req, true } diff --git a/pkg/ai/agent/runner_test.go b/pkg/ai/agent/runner_test.go index fc59bca..ce09e91 100644 --- a/pkg/ai/agent/runner_test.go +++ b/pkg/ai/agent/runner_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,7 +49,7 @@ func TestRunner_CapturesSessionAndChangedFiles(t *testing.T) { Provider: prov, Repo: "/repo", Build: func(_ *RunContext, _ int, _ *ai.LoopIteration, _ string) ai.Request { - return ai.Request{Prompt: "go"} + return ai.Request{Prompt: api.Prompt{User: "go"}} }, } res, err := r.Run(context.Background()) @@ -80,7 +81,7 @@ func TestRunner_VerifyDrivesRerunThenStops(t *testing.T) { }, Build: func(_ *RunContext, _ int, _ *ai.LoopIteration, feedback string) ai.Request { feedbackSeen = append(feedbackSeen, feedback) - return ai.Request{Prompt: "go"} + return ai.Request{Prompt: api.Prompt{User: "go"}} }, } diff --git a/pkg/ai/agent/scope_test.go b/pkg/ai/agent/scope_test.go new file mode 100644 index 0000000..31136bd --- /dev/null +++ b/pkg/ai/agent/scope_test.go @@ -0,0 +1,38 @@ +package agent + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScope(t *testing.T) { + cases := map[string]struct { + want Scope + wantErr bool + }{ + "": {ScopeAll, false}, + "all": {ScopeAll, false}, + "changed": {ScopeChanged, false}, + "sideways": {"", true}, + } + for in, exp := range cases { + got, err := ParseScope(in) + if exp.wantErr { + require.Error(t, err, "ParseScope(%q)", in) + assert.Contains(t, err.Error(), ScopeList(), "error should name the valid set") + continue + } + require.NoError(t, err, "ParseScope(%q)", in) + assert.Equal(t, exp.want, got) + } +} + +func TestScopeValidAndList(t *testing.T) { + assert.Equal(t, []Scope{ScopeAll, ScopeChanged}, AllScopes()) + assert.Equal(t, "all, changed", ScopeList()) + assert.True(t, ScopeAll.Valid()) + assert.True(t, ScopeChanged.Valid()) + assert.False(t, Scope("sideways").Valid()) +} diff --git a/pkg/ai/agent_test.go b/pkg/ai/agent_test.go index fcd42a5..bbfa969 100644 --- a/pkg/ai/agent_test.go +++ b/pkg/ai/agent_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/flanksource/captain/pkg/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,7 +25,7 @@ func (m *mockProvider) Execute(_ context.Context, req Request) (*Response, error return nil, m.err } return &Response{ - Text: m.text + ":" + req.Prompt, + Text: m.text + ":" + req.Prompt.User, Model: m.model, Backend: BackendAnthropic, Usage: Usage{InputTokens: 10, OutputTokens: 5}, @@ -32,7 +33,7 @@ func (m *mockProvider) Execute(_ context.Context, req Request) (*Response, error } func TestAgent_ExecutePromptAccruesCost(t *testing.T) { - a := NewAgentWithProvider(&mockProvider{model: "test-model", text: "out"}, Config{Model: "test-model"}) + a := NewAgentWithProvider(&mockProvider{model: "test-model", text: "out"}, Config{Model: api.Model{Name: "test-model"}}) resp, err := a.ExecutePrompt(context.Background(), PromptRequest{Name: "p1", Prompt: "hi"}) require.NoError(t, err) @@ -48,7 +49,7 @@ func TestAgent_ExecutePromptAccruesCost(t *testing.T) { } func TestAgent_ExecutePromptError(t *testing.T) { - a := NewAgentWithProvider(&mockProvider{model: "m", err: fmt.Errorf("boom")}, Config{Model: "m"}) + a := NewAgentWithProvider(&mockProvider{model: "m", err: fmt.Errorf("boom")}, Config{Model: api.Model{Name: "m"}}) resp, err := a.ExecutePrompt(context.Background(), PromptRequest{Name: "p", Prompt: "x"}) require.Error(t, err) assert.False(t, resp.IsOK()) @@ -57,7 +58,7 @@ func TestAgent_ExecutePromptError(t *testing.T) { } func TestAgent_ExecuteBatchKeyedByName(t *testing.T) { - a := NewAgentWithProvider(&mockProvider{model: "m", text: "r"}, Config{Model: "m", MaxConcurrent: 2}) + a := NewAgentWithProvider(&mockProvider{model: "m", text: "r"}, Config{Model: api.Model{Name: "m"}, MaxConcurrent: 2}) reqs := []PromptRequest{ {Name: "a", Prompt: "1"}, {Name: "b", Prompt: "2"}, @@ -74,7 +75,7 @@ func TestAgent_ExecuteBatchKeyedByName(t *testing.T) { func TestAgent_Close(t *testing.T) { mp := &mockProvider{model: "m"} - a := NewAgentWithProvider(mp, Config{Model: "m"}) + a := NewAgentWithProvider(mp, Config{Model: api.Model{Name: "m"}}) require.NoError(t, a.Close()) assert.True(t, mp.closed) } diff --git a/pkg/ai/catalog.go b/pkg/ai/catalog.go new file mode 100644 index 0000000..a97aa7c --- /dev/null +++ b/pkg/ai/catalog.go @@ -0,0 +1,206 @@ +package ai + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// Model describes one entry in the chat model menu. captain owns the catalog +// (it is keyed on Backend, which is captain data); clicky/aichat consumes it via +// type aliases. For API backends ID is the full "provider/model" id used by the +// genkit execution path (e.g. "anthropic/claude-sonnet-4-6"). For agent/CLI +// backends ID is the menu key (e.g. "claude-agent-sonnet") and AgentModel is the +// run-time slug when it differs from ID. +type Model struct { + ID string + Backend Backend + Label string // human-friendly menu label + Reasoning bool // model honours Effort + ContextWindow int // max context tokens, for a usage gauge's denominator + // AgentModel is the model slug passed to the captain backend when it differs + // from ID (e.g. menu id "codex-gpt-5-codex" → backend model "gpt-5-codex"). + // Empty means use ID. Unused for API backends. + AgentModel string + // Default marks the catalog default. Exactly one entry sets it; its ID equals + // DefaultModelID. + Default bool +} + +// IsAgent reports whether the model runs through captain's agent framework (a +// supervised local subprocess) rather than the in-process genkit API path. All +// non-API backends are agent/CLI backends. +func (m Model) IsAgent() bool { return m.Backend.Kind() == "cli" } + +// BareID strips the "provider/" prefix from an API model id (so it joins to live +// API listings and pricing keys); agent ids have no prefix and are returned +// verbatim. +func (m Model) BareID() string { + if m.IsAgent() { + return m.ID + } + if i := strings.IndexByte(m.ID, '/'); i >= 0 { + return m.ID[i+1:] + } + return m.ID +} + +// DefaultModelID is the chat backend's default (captain's NewAnthropic default). +// The catalog entry with this ID sets Default: true. +const DefaultModelID = "anthropic/claude-sonnet-4-5" + +// defaultCatalog is the v1 model menu. API entries carry the genkit +// "provider/model" id (kept byte-identical so the web menu and stored-thread +// model ids stay stable); agent entries carry the captain backend explicitly so +// codex slugs (which look like gpt-*) are not misrouted. +var defaultCatalog = []Model{ + {ID: "anthropic/claude-sonnet-4-6", Backend: BackendAnthropic, Label: "Claude Sonnet 4.6", Reasoning: true, ContextWindow: 200000}, + {ID: "anthropic/claude-opus-4-8", Backend: BackendAnthropic, Label: "Claude Opus 4.8", Reasoning: true, ContextWindow: 200000}, + {ID: "anthropic/claude-haiku-4-5", Backend: BackendAnthropic, Label: "Claude Haiku 4.5", Reasoning: true, ContextWindow: 200000}, + {ID: "anthropic/claude-sonnet-4-5", Backend: BackendAnthropic, Label: "Claude Sonnet 4.5", Reasoning: true, ContextWindow: 200000, Default: true}, + {ID: "anthropic/claude-opus-4-1", Backend: BackendAnthropic, Label: "Claude Opus 4.1", Reasoning: true, ContextWindow: 200000}, + {ID: "anthropic/claude-3-5-haiku-latest", Backend: BackendAnthropic, Label: "Claude 3.5 Haiku", Reasoning: false, ContextWindow: 200000}, + {ID: "openai/gpt-4o", Backend: BackendOpenAI, Label: "GPT-4o", Reasoning: false, ContextWindow: 128000}, + {ID: "openai/o3", Backend: BackendOpenAI, Label: "OpenAI o3", Reasoning: true, ContextWindow: 200000}, + {ID: "openai/o4-mini", Backend: BackendOpenAI, Label: "OpenAI o4-mini", Reasoning: true, ContextWindow: 200000}, + {ID: "googleai/gemini-2.5-pro", Backend: BackendGemini, Label: "Gemini 2.5 Pro", Reasoning: true, ContextWindow: 1048576}, + {ID: "googleai/gemini-2.5-flash", Backend: BackendGemini, Label: "Gemini 2.5 Flash", Reasoning: true, ContextWindow: 1048576}, + + // Agent-framework models (captain pkg/ai StreamingProvider). These run a + // supervised local subprocess that owns its own tools. + {ID: "claude-agent-sonnet", Backend: BackendClaudeAgent, Label: "Claude Agent · Sonnet", Reasoning: true, ContextWindow: 200000}, + {ID: "claude-agent-opus", Backend: BackendClaudeAgent, Label: "Claude Agent · Opus", Reasoning: true, ContextWindow: 200000}, + {ID: "claude-agent-haiku", Backend: BackendClaudeAgent, Label: "Claude Agent · Haiku", Reasoning: true, ContextWindow: 200000}, + {ID: "codex-gpt-5-codex", Backend: BackendCodexCLI, AgentModel: "gpt-5-codex", Label: "Codex · GPT-5", Reasoning: true, ContextWindow: 400000}, +} + +var ( + modelRegistryMu sync.RWMutex + catalog = append([]Model(nil), defaultCatalog...) +) + +// Catalog returns the registered model menu. +func Catalog() []Model { + modelRegistryMu.RLock() + defer modelRegistryMu.RUnlock() + + out := make([]Model, len(catalog)) + copy(out, catalog) + return out +} + +// RegisterModel adds a model to the global catalog, or replaces the existing +// entry with the same ID while preserving its position in the menu. +func RegisterModel(model Model) error { + return RegisterModels(model) +} + +// RegisterModels adds models to the global catalog. Existing IDs are updated in +// place; new IDs are appended to the menu. +func RegisterModels(models ...Model) error { + normalized, err := normalizeModels(models, false) + if err != nil { + return err + } + + modelRegistryMu.Lock() + defer modelRegistryMu.Unlock() + + for _, model := range normalized { + updated := false + for i := range catalog { + if catalog[i].ID == model.ID { + catalog[i] = model + updated = true + break + } + } + if !updated { + catalog = append(catalog, model) + } + } + return nil +} + +// SetModelCatalog replaces the global catalog. Use RegisterModel(s) to extend +// the built-in catalog instead. +func SetModelCatalog(models []Model) error { + normalized, err := normalizeModels(models, true) + if err != nil { + return err + } + + modelRegistryMu.Lock() + defer modelRegistryMu.Unlock() + catalog = append([]Model(nil), normalized...) + return nil +} + +// ResetModelCatalog restores the built-in catalog. Primarily for tests that +// temporarily install custom models. +func ResetModelCatalog() { + modelRegistryMu.Lock() + defer modelRegistryMu.Unlock() + catalog = append([]Model(nil), defaultCatalog...) +} + +func normalizeModels(models []Model, rejectDuplicateIDs bool) ([]Model, error) { + out := make([]Model, 0, len(models)) + seen := make(map[string]bool, len(models)) + for _, model := range models { + normalized, err := normalizeModel(model) + if err != nil { + return nil, err + } + if rejectDuplicateIDs && seen[normalized.ID] { + return nil, fmt.Errorf("duplicate model ID %q", normalized.ID) + } + seen[normalized.ID] = true + out = append(out, normalized) + } + return out, nil +} + +func normalizeModel(model Model) (Model, error) { + model.ID = strings.TrimSpace(model.ID) + model.Label = strings.TrimSpace(model.Label) + if model.ID == "" { + return Model{}, fmt.Errorf("model ID is required") + } + if model.Backend == "" { + return Model{}, fmt.Errorf("model %q must set Backend (one of: %s)", model.ID, BackendList()) + } + if !model.Backend.Valid() { + return Model{}, fmt.Errorf("model %q has invalid backend %q; want one of: %s", model.ID, model.Backend, BackendList()) + } + if model.Label == "" { + model.Label = model.ID + } + return model, nil +} + +// LookupModel resolves a model id against the catalog. An empty id resolves to +// the default. Returns an error listing the menu on miss — fail loud, never +// silently substitute. +func LookupModel(id string) (Model, error) { + if id == "" { + id = DefaultModelID + } + models := Catalog() + for _, m := range models { + if m.ID == id { + return m, nil + } + } + return Model{}, fmt.Errorf("unknown model %q; available: %s", id, strings.Join(modelIDsFrom(models), ", ")) +} + +func modelIDsFrom(models []Model) []string { + ids := make([]string, len(models)) + for i, m := range models { + ids[i] = m.ID + } + sort.Strings(ids) + return ids +} diff --git a/pkg/ai/catalog_info.go b/pkg/ai/catalog_info.go new file mode 100644 index 0000000..a607ed0 --- /dev/null +++ b/pkg/ai/catalog_info.go @@ -0,0 +1,81 @@ +package ai + +import ( + "os/exec" + "slices" +) + +// ModelInfo is the JSON shape served at GET /api/chat/models so a client model +// selector can be data-driven. Configured reports whether the model is +// selectable (its API provider has a key, or its agent backend is installed). +type ModelInfo struct { + ID string `json:"id"` + Provider string `json:"provider"` + Label string `json:"label"` + Reasoning bool `json:"reasoning"` + Configured bool `json:"configured"` + ContextWindow int `json:"contextWindow"` +} + +// BackendToProvider maps a backend to the provider string used in API model ids +// and the web menu's "provider" field. API backends use the genkit provider +// namespace (anthropic/openai/googleai); agent/CLI backends use their backend +// string verbatim. +func BackendToProvider(b Backend) string { + switch b { + case BackendAnthropic: + return "anthropic" + case BackendOpenAI: + return "openai" + case BackendGemini: + return "googleai" + default: + return string(b) + } +} + +// CatalogInfo returns the model menu annotated with selectability. API models +// are configured when their provider is among configuredProviders (the keys the +// caller resolved); agent/CLI models are configured when their local backend +// binary is installed. Order mirrors the catalog so the client renders a stable, +// grouped menu. +func CatalogInfo(configuredProviders []string) []ModelInfo { + models := Catalog() + out := make([]ModelInfo, len(models)) + for i, m := range models { + configured := false + if m.IsAgent() { + configured = agentBackendAvailable(m.Backend) + } else { + configured = slices.Contains(configuredProviders, BackendToProvider(m.Backend)) + } + out[i] = ModelInfo{ + ID: m.ID, + Provider: BackendToProvider(m.Backend), + Label: m.Label, + Reasoning: m.Reasoning, + Configured: configured, + ContextWindow: m.ContextWindow, + } + } + return out +} + +// agentBackendAvailable reports whether an agent backend's local binary is +// installed (best effort): codex needs the `codex` binary; claude-agent / +// claude-cli need `tsx` on PATH. A turn still fails loud if the probe is wrong. +func agentBackendAvailable(b Backend) bool { + switch b { + case BackendCodexCLI: + return binaryOnPath("codex") + case BackendClaudeAgent, BackendClaudeCLI: + return binaryOnPath("tsx") + default: + return false + } +} + +func binaryOnPath(bin string) bool { + _, err := exec.LookPath(bin) + return err == nil +} diff --git a/pkg/ai/catalog_info_test.go b/pkg/ai/catalog_info_test.go new file mode 100644 index 0000000..4da7a3d --- /dev/null +++ b/pkg/ai/catalog_info_test.go @@ -0,0 +1,50 @@ +package ai + +import "testing" + +func TestBackendToProvider(t *testing.T) { + cases := map[Backend]string{ + BackendAnthropic: "anthropic", + BackendOpenAI: "openai", + BackendGemini: "googleai", + BackendClaudeAgent: "claude-agent", + BackendCodexCLI: "codex-cli", + } + for b, want := range cases { + if got := BackendToProvider(b); got != want { + t.Fatalf("BackendToProvider(%q) = %q, want %q", b, got, want) + } + } +} + +// TestCatalogInfoMatchesCatalog asserts the web JSON mirrors the catalog 1:1 in +// order and field values, and that API-model "configured" tracks the provider +// set passed by the caller (the contract the web client + stored threads rely +// on). Agent-model configured depends on local binaries and is not asserted. +func TestCatalogInfoMatchesCatalog(t *testing.T) { + configured := []string{"anthropic"} + info := CatalogInfo(configured) + + models := Catalog() + if len(info) != len(models) { + t.Fatalf("CatalogInfo len = %d, want %d", len(info), len(models)) + } + for i, m := range models { + got := info[i] + if got.ID != m.ID { + t.Fatalf("row %d id = %q, want %q", i, got.ID, m.ID) + } + if got.Provider != BackendToProvider(m.Backend) { + t.Fatalf("row %d provider = %q, want %q", i, got.Provider, BackendToProvider(m.Backend)) + } + if got.Label != m.Label || got.Reasoning != m.Reasoning || got.ContextWindow != m.ContextWindow { + t.Fatalf("row %d field mismatch: %+v vs %+v", i, got, m) + } + if !m.IsAgent() { + want := m.Backend == BackendAnthropic + if got.Configured != want { + t.Fatalf("row %d (%s) configured = %v, want %v", i, m.ID, got.Configured, want) + } + } + } +} diff --git a/pkg/ai/catalog_resolve.go b/pkg/ai/catalog_resolve.go new file mode 100644 index 0000000..6f4ff35 --- /dev/null +++ b/pkg/ai/catalog_resolve.go @@ -0,0 +1,321 @@ +package ai + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/flanksource/captain/pkg/ai/pricing" + "github.com/flanksource/commons/logger" +) + +// catalogLog reports non-fatal resolver issues (e.g. an unwritable model cache). +var catalogLog = logger.GetLogger("ai") + +// ResolvedModel is one merged catalog/live row joined to its pricing. +type ResolvedModel struct { + Model + // Live is true when the model was confirmed by a live /v1/models listing. + Live bool + // Price is the merged OpenRouter/static pricing for the model (zero when + // unknown). + Price pricing.ModelInfo +} + +// RuntimeID is the model id a caller passes to a backend: the AgentModel slug +// for agent backends, the bare "model" (no provider/ prefix) for API backends. +func (r ResolvedModel) RuntimeID() string { + if r.IsAgent() { + if r.AgentModel != "" { + return r.AgentModel + } + return r.ID + } + return r.BareID() +} + +// Context returns the model's context window, preferring the priced value and +// falling back to the catalog's. +func (r ResolvedModel) Context() int { + if r.Price.ContextWindow > 0 { + return r.Price.ContextWindow + } + return r.ContextWindow +} + +// ResolveOptions controls a ResolveModels query. +type ResolveOptions struct { + Backend Backend // empty = all backends + Filter string // substring filter on id/label; non-empty also reveals legacy ids + UseTokens bool // when true, augment API backends (that have a key) with live /v1/models + Refresh bool // bypass the persisted cache and re-resolve +} + +// liveModelFetcher fetches a backend's live model list. It is a package var so +// tests can stub it without hitting the network. +var liveModelFetcher = func(ctx context.Context, b Backend) ([]ModelDef, error) { + return ListModels(ctx, b) +} + +// apiBackends are the direct-API backends the resolver can list live. +var apiBackends = []Backend{BackendAnthropic, BackendOpenAI, BackendGemini} + +// ResolveModels returns the merged catalog ∪ live-API view for opts, joined to +// merged OpenRouter/static pricing and persisted to ~/.config/captain/models.json. +// A fresh, fingerprint-matching cache is reused (unless opts.Refresh). +func ResolveModels(ctx context.Context, opts ResolveOptions) ([]ResolvedModel, error) { + fp := resolveFingerprint(opts) + + rows, ok := cachedRows(opts, fp) + if !ok { + var err error + rows, err = resolveFresh(ctx, opts) + if err != nil { + return nil, err + } + if err := saveModelCache(fp, rows); err != nil { + catalogLog.Warnf("failed to persist model cache: %v", err) + } + } + return filterResolved(rows, opts.Filter), nil +} + +// cachedRows returns the persisted rows when they are fresh and match the +// current fingerprint. +func cachedRows(opts ResolveOptions, fp string) ([]ResolvedModel, bool) { + if opts.Refresh { + return nil, false + } + c, err := loadModelCache() + if err != nil || c == nil || c.expired() || c.KeyFingerprint != fp { + return nil, false + } + return c.Models, true +} + +// resolveFresh seeds the catalog filtered by backend, unions live API models +// when tokens are present, and joins each row to pricing. +func resolveFresh(ctx context.Context, opts ResolveOptions) ([]ResolvedModel, error) { + rows, index := seedCatalog(opts.Backend) + + if opts.UseTokens { + if err := unionLive(ctx, opts.Backend, &rows, index); err != nil { + return nil, err + } + } + + pricing.EnsureLoaded() + for i := range rows { + if info, ok := lookupPricing(rows[i].Backend, rows[i].BareID()); ok { + rows[i].Price = info + } + } + return rows, nil +} + +// modelKey dedups a (backend, bare-id) pair across catalog and live listings. +type modelKey struct { + backend Backend + bare string +} + +func seedCatalog(backend Backend) ([]ResolvedModel, map[modelKey]int) { + rows := make([]ResolvedModel, 0) + index := map[modelKey]int{} + for _, m := range Catalog() { + if !catalogBackendMatch(backend, m.Backend) { + continue + } + index[modelKey{m.Backend, m.BareID()}] = len(rows) + rows = append(rows, ResolvedModel{Model: m}) + } + return rows, index +} + +// catalogBackendMatch reports whether a catalog model's backend satisfies the +// requested filter. claude-cli shares the claude-agent catalog entries. +func catalogBackendMatch(want, modelBackend Backend) bool { + if want == "" { + return true + } + if want == BackendClaudeCLI { + want = BackendClaudeAgent + } + return modelBackend == want +} + +// unionLive fetches live /v1/models for every API backend (matching the filter) +// that has a key set, and merges them into rows. A fetch error fails loud. +func unionLive(ctx context.Context, backend Backend, rows *[]ResolvedModel, index map[modelKey]int) error { + for _, b := range apiBackends { + if backend != "" && b != backend { + continue + } + if GetAPIKeyFromEnv(b) == "" { + continue + } + live, err := liveModelFetcher(ctx, b) + if err != nil { + return fmt.Errorf("%s: %w", b, err) + } + for _, d := range live { + key := modelKey{b, bareModelID(d.ID)} + if i, ok := index[key]; ok { + (*rows)[i].Live = true + continue + } + index[key] = len(*rows) + *rows = append(*rows, ResolvedModel{ + Model: Model{ID: d.ID, Backend: b, Label: d.Name}, + Live: true, + }) + } + } + return nil +} + +// filterResolved applies the substring filter; with no filter it also hides +// legacy/non-chat ids (an explicit filter reveals them, matching `ai models`). +func filterResolved(rows []ResolvedModel, filter string) []ResolvedModel { + fl := strings.ToLower(filter) + out := make([]ResolvedModel, 0, len(rows)) + for _, r := range rows { + if filter != "" { + if !strings.Contains(strings.ToLower(r.ID), fl) && !strings.Contains(strings.ToLower(r.Label), fl) { + continue + } + } else if isLegacyModelID(r.ID) { + continue + } + out = append(out, r) + } + return out +} + +func resolveFingerprint(opts ResolveOptions) string { + var present []string + for _, b := range apiBackends { + if GetAPIKeyFromEnv(b) != "" { + present = append(present, string(b)) + } + } + sort.Strings(present) + return fmt.Sprintf("b=%s|tok=%v|keys=%s", opts.Backend, opts.UseTokens, strings.Join(present, ",")) +} + +func bareModelID(id string) string { + if i := strings.IndexByte(id, '/'); i >= 0 { + return id[i+1:] + } + return id +} + +// AgentCatalogModels returns the model list for a CLI/agent backend from the +// catalog — the key-free source of truth shared with the chat menu and shell +// completion. The returned ID is the run-time slug the captain provider expects: +// AgentModel when set (e.g. codex's "gpt-5-codex"), otherwise the catalog ID +// (e.g. "claude-agent-sonnet"). claude-cli shares the claude-agent entries. +func AgentCatalogModels(b Backend) []ModelDef { + want := b + if want == BackendClaudeCLI { + want = BackendClaudeAgent + } + + out := []ModelDef{} + for _, m := range Catalog() { + if !m.IsAgent() || m.Backend != want { + continue + } + id := m.ID + if m.AgentModel != "" { + id = m.AgentModel + } + label := m.Label + if label == "" { + label = id + } + out = append(out, ModelDef{ID: id, Name: label, Backend: b}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// lookupPricing tries the bare model id first, then the OpenRouter +// "provider/model" key the registry uses for the major API providers. +func lookupPricing(backend Backend, id string) (pricing.ModelInfo, bool) { + if info, ok := pricing.GetModelInfo(id); ok { + return info, true + } + prefix := orPrefix(backend) + if prefix == "" { + return pricing.ModelInfo{}, false + } + if info, ok := pricing.GetModelInfo(prefix + "/" + id); ok { + return info, true + } + return pricing.ModelInfo{}, false +} + +// orPrefix is the OpenRouter id prefix for a backend. Gemini's catalog prefix is +// "googleai" but OpenRouter keys it under "google" — get this right or pricing +// silently misses. +func orPrefix(backend Backend) string { + switch backend { + case BackendOpenAI: + return "openai" + case BackendAnthropic: + return "anthropic" + case BackendGemini: + return "google" + } + return "" +} + +// legacyModelPrefixes hides model IDs that are superseded by a newer generation +// or aren't chat completions (image/audio/embedding/moderation). Shared by +// `ai models` and the `configure` picker. An explicit --filter overrides it. +var legacyModelPrefixes = []string{ + // OpenAI legacy + "gpt-3", + "gpt-4", // covers gpt-4, gpt-4o, gpt-4.1, gpt-4-turbo, ... + "gpt-5-", // hides gpt-5 variants (mini, nano, codex, pro) but keeps bare "gpt-5", "gpt-5.1/.2/.3" + "o1", + "o3-", + "codex-mini", + // OpenAI non-chat endpoints + "dall-", + "whisper", + "tts-", + "text-embedding", + "text-moderation", + "omni-moderation", + "babbage", + "davinci", + "chatgpt-", + "computer-use-preview", + // Claude legacy (3.x and earlier 4-line latests/dated) + "claude-3", + "claude-2", + "claude-instant", + "claude-sonnet-4-0", + "claude-sonnet-4-2", + "claude-opus-4-0", + "claude-opus-4-1", + // Gemini legacy + "gemini-1", + "gemini-2.0", + // Grok legacy + "grok-3", + "grok-code-fast-1", +} + +func isLegacyModelID(id string) bool { + idLower := strings.ToLower(id) + for _, p := range legacyModelPrefixes { + if strings.HasPrefix(idLower, p) { + return true + } + } + return false +} diff --git a/pkg/ai/catalog_resolve_test.go b/pkg/ai/catalog_resolve_test.go new file mode 100644 index 0000000..f2f6684 --- /dev/null +++ b/pkg/ai/catalog_resolve_test.go @@ -0,0 +1,165 @@ +package ai + +import ( + "context" + "errors" + "testing" +) + +// stubLiveFetcher installs a fake live-model fetcher for the test and restores +// the real one on cleanup. It also isolates API-key env so only the backends +// the test opts into are fetched, and points the model cache at a temp HOME. +func stubLiveFetcher(t *testing.T, fn func(b Backend) ([]ModelDef, error)) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("GEMINI_API_KEY", "") + t.Setenv("GOOGLE_API_KEY", "") + + prev := liveModelFetcher + liveModelFetcher = func(_ context.Context, b Backend) ([]ModelDef, error) { return fn(b) } + t.Cleanup(func() { liveModelFetcher = prev }) +} + +func hasModelID(rows []ResolvedModel, id string) (ResolvedModel, bool) { + for _, r := range rows { + if r.ID == id { + return r, true + } + } + return ResolvedModel{}, false +} + +func TestResolveModels_NoTokensCatalogOnly(t *testing.T) { + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + t.Fatalf("live fetch must not run without tokens (backend %q)", b) + return nil, nil + }) + + rows, err := ResolveModels(context.Background(), ResolveOptions{Backend: BackendAnthropic, UseTokens: false}) + if err != nil { + t.Fatalf("ResolveModels: %v", err) + } + if len(rows) == 0 { + t.Fatal("expected catalog anthropic models") + } + for _, r := range rows { + if r.Backend != BackendAnthropic { + t.Fatalf("backend filter leaked: %q", r.Backend) + } + if r.Live { + t.Fatalf("no row should be Live without tokens: %q", r.ID) + } + } +} + +func TestResolveModels_TokensUnionDedup(t *testing.T) { + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + if b != BackendAnthropic { + return nil, nil + } + return []ModelDef{ + {ID: "claude-sonnet-4-5", Backend: BackendAnthropic}, // dedups with catalog anthropic/claude-sonnet-4-5 + {ID: "claude-new-xyz", Backend: BackendAnthropic}, // net-new live model + }, nil + }) + t.Setenv("ANTHROPIC_API_KEY", "k") // after stub clears the key set + + rows, err := ResolveModels(context.Background(), ResolveOptions{Backend: BackendAnthropic, UseTokens: true}) + if err != nil { + t.Fatalf("ResolveModels: %v", err) + } + + catalogRow, ok := hasModelID(rows, "anthropic/claude-sonnet-4-5") + if !ok || !catalogRow.Live { + t.Fatalf("catalog row should survive and be marked Live: %+v ok=%v", catalogRow, ok) + } + liveRow, ok := hasModelID(rows, "claude-new-xyz") + if !ok || !liveRow.Live { + t.Fatalf("net-new live model missing/!Live: %+v ok=%v", liveRow, ok) + } + + // No duplicate (backend, bareID) for claude-sonnet-4-5. + bareCount := 0 + for _, r := range rows { + if r.Backend == BackendAnthropic && r.BareID() == "claude-sonnet-4-5" { + bareCount++ + } + } + if bareCount != 1 { + t.Fatalf("claude-sonnet-4-5 appears %d times, want deduped to 1", bareCount) + } +} + +func TestResolveModels_LiveErrorFailsLoud(t *testing.T) { + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + return nil, errors.New("boom") + }) + t.Setenv("ANTHROPIC_API_KEY", "k") // after stub clears the key set + + if _, err := ResolveModels(context.Background(), ResolveOptions{Backend: BackendAnthropic, UseTokens: true}); err == nil { + t.Fatal("expected live fetch error to propagate (fail loud)") + } +} + +func TestResolveModels_LegacyHiddenUnlessFiltered(t *testing.T) { + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + if b != BackendAnthropic { + return nil, nil + } + return []ModelDef{{ID: "claude-3-5-sonnet-20241022", Backend: BackendAnthropic}}, nil + }) + t.Setenv("ANTHROPIC_API_KEY", "k") // after stub clears the key set + + noFilter, err := ResolveModels(context.Background(), ResolveOptions{Backend: BackendAnthropic, UseTokens: true}) + if err != nil { + t.Fatalf("ResolveModels: %v", err) + } + if _, ok := hasModelID(noFilter, "claude-3-5-sonnet-20241022"); ok { + t.Fatal("legacy id should be hidden without a filter") + } + + withFilter, err := ResolveModels(context.Background(), ResolveOptions{Backend: BackendAnthropic, UseTokens: true, Filter: "claude-3-5", Refresh: true}) + if err != nil { + t.Fatalf("ResolveModels: %v", err) + } + if _, ok := hasModelID(withFilter, "claude-3-5-sonnet-20241022"); !ok { + t.Fatal("explicit filter should reveal the legacy id") + } +} + +func TestResolveModels_CacheWrittenAndReused(t *testing.T) { + calls := 0 + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + if b == BackendAnthropic { + calls++ + } + return nil, nil + }) + t.Setenv("ANTHROPIC_API_KEY", "k") // after stub clears the key set + + opts := ResolveOptions{Backend: BackendAnthropic, UseTokens: true} + if _, err := ResolveModels(context.Background(), opts); err != nil { + t.Fatalf("first resolve: %v", err) + } + if calls != 1 { + t.Fatalf("first resolve should fetch live once, got %d", calls) + } + + // Second identical resolve must be served from the persisted cache. + if _, err := ResolveModels(context.Background(), opts); err != nil { + t.Fatalf("second resolve: %v", err) + } + if calls != 1 { + t.Fatalf("second resolve should reuse cache (no fetch), got %d calls", calls) + } + + // Refresh re-resolves. + if _, err := ResolveModels(context.Background(), ResolveOptions{Backend: BackendAnthropic, UseTokens: true, Refresh: true}); err != nil { + t.Fatalf("refresh resolve: %v", err) + } + if calls != 2 { + t.Fatalf("refresh should re-fetch, got %d calls", calls) + } +} diff --git a/pkg/ai/catalog_test.go b/pkg/ai/catalog_test.go new file mode 100644 index 0000000..18ad525 --- /dev/null +++ b/pkg/ai/catalog_test.go @@ -0,0 +1,122 @@ +package ai + +import "testing" + +func TestLookupModelDefault(t *testing.T) { + m, err := LookupModel("") + if err != nil { + t.Fatalf("LookupModel(\"\"): %v", err) + } + if m.ID != DefaultModelID { + t.Fatalf("default = %q, want %q", m.ID, DefaultModelID) + } + if !m.Default { + t.Fatalf("default model %q must have Default=true", m.ID) + } +} + +func TestLookupModelUnknownFailsLoud(t *testing.T) { + if _, err := LookupModel("provider/does-not-exist"); err == nil { + t.Fatal("expected error for unknown model id") + } +} + +func TestExactlyOneDefaultMatchesConst(t *testing.T) { + var defaults []string + for _, m := range Catalog() { + if m.Default { + defaults = append(defaults, m.ID) + } + } + if len(defaults) != 1 { + t.Fatalf("want exactly one Default model, got %v", defaults) + } + if defaults[0] != DefaultModelID { + t.Fatalf("Default model %q != DefaultModelID %q", defaults[0], DefaultModelID) + } +} + +func TestBareID(t *testing.T) { + cases := map[string]struct { + model Model + want string + }{ + "api strips provider prefix": {Model{ID: "anthropic/claude-sonnet-4-5", Backend: BackendAnthropic}, "claude-sonnet-4-5"}, + "gemini googleai prefix": {Model{ID: "googleai/gemini-2.5-pro", Backend: BackendGemini}, "gemini-2.5-pro"}, + "agent id verbatim": {Model{ID: "claude-agent-sonnet", Backend: BackendClaudeAgent}, "claude-agent-sonnet"}, + "codex slug id verbatim": {Model{ID: "codex-gpt-5-codex", Backend: BackendCodexCLI, AgentModel: "gpt-5-codex"}, "codex-gpt-5-codex"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := tc.model.BareID(); got != tc.want { + t.Fatalf("BareID() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestIsAgent(t *testing.T) { + cases := map[Backend]bool{ + BackendAnthropic: false, + BackendOpenAI: false, + BackendGemini: false, + BackendClaudeAgent: true, + BackendCodexCLI: true, + BackendClaudeCLI: true, + } + for b, want := range cases { + if got := (Model{Backend: b}).IsAgent(); got != want { + t.Fatalf("Backend %q IsAgent() = %v, want %v", b, got, want) + } + } +} + +func TestRegisterModelAddsAndUpdates(t *testing.T) { + t.Cleanup(ResetModelCatalog) + + if err := RegisterModel(Model{ID: "anthropic/new-model", Backend: BackendAnthropic, Label: "New"}); err != nil { + t.Fatalf("RegisterModel add: %v", err) + } + m, err := LookupModel("anthropic/new-model") + if err != nil || m.Label != "New" { + t.Fatalf("added model missing/wrong: %+v err=%v", m, err) + } + + if err := RegisterModel(Model{ID: "anthropic/new-model", Backend: BackendAnthropic, Label: "Updated"}); err != nil { + t.Fatalf("RegisterModel update: %v", err) + } + m, _ = LookupModel("anthropic/new-model") + if m.Label != "Updated" { + t.Fatalf("update did not replace label: %q", m.Label) + } +} + +func TestSetModelCatalogAndReset(t *testing.T) { + t.Cleanup(ResetModelCatalog) + + if err := SetModelCatalog([]Model{{ID: "openai/only", Backend: BackendOpenAI}}); err != nil { + t.Fatalf("SetModelCatalog: %v", err) + } + if got := len(Catalog()); got != 1 { + t.Fatalf("catalog size after Set = %d, want 1", got) + } + + ResetModelCatalog() + if got := len(Catalog()); got != len(defaultCatalog) { + t.Fatalf("catalog size after Reset = %d, want %d", got, len(defaultCatalog)) + } +} + +func TestRegisterModelValidation(t *testing.T) { + t.Cleanup(ResetModelCatalog) + + if err := RegisterModel(Model{ID: "", Backend: BackendAnthropic}); err == nil { + t.Fatal("expected error for empty ID") + } + if err := RegisterModel(Model{ID: "x/y"}); err == nil { + t.Fatal("expected error for missing Backend") + } + if err := RegisterModel(Model{ID: "x/y", Backend: Backend("nonsense")}); err == nil { + t.Fatal("expected error for invalid Backend") + } +} diff --git a/pkg/ai/client.go b/pkg/ai/client.go index abfc67b..32df732 100644 --- a/pkg/ai/client.go +++ b/pkg/ai/client.go @@ -1,59 +1,27 @@ package ai import ( - "fmt" - "os" + "github.com/flanksource/captain/pkg/api" ) -type ProviderFactory func(cfg Config) (Provider, error) - -var factories = map[Backend]ProviderFactory{} +// The provider registry now lives in pkg/api (the stable runtime contract). +// ProviderFactory is re-exported as an alias; the registration/construction +// entrypoints are thin wrappers so existing call sites (and the blank-import +// self-registration in pkg/ai/provider) keep funneling into the single api +// registry unchanged. +type ProviderFactory = api.ProviderFactory +// RegisterProvider registers a factory for a backend in the shared api registry. func RegisterProvider(backend Backend, factory ProviderFactory) { - factories[backend] = factory + api.RegisterProvider(backend, factory) } +// NewProvider constructs the registered provider for cfg's backend. func NewProvider(cfg Config) (Provider, error) { - backend := cfg.Backend - - if cfg.Model == "" { - return nil, fmt.Errorf("model cannot be empty; pass --model or run `captain configure` to set a default") - } - - if backend == "" { - var err error - backend, err = InferBackend(cfg.Model) - if err != nil { - return nil, err - } - } - cfg.Backend = backend - - factory, ok := factories[backend] - if !ok { - return nil, fmt.Errorf("no provider registered for backend: %s", backend) - } - - if cfg.APIKey == "" { - cfg.APIKey = GetAPIKeyFromEnv(backend) - } - - return factory(cfg) + return api.NewProvider(cfg) } +// GetAPIKeyFromEnv returns the first non-empty value among a backend's auth env vars. func GetAPIKeyFromEnv(backend Backend) string { - envVars := map[Backend][]string{ - BackendAnthropic: {"ANTHROPIC_API_KEY"}, - BackendGemini: {"GEMINI_API_KEY", "GOOGLE_API_KEY"}, - BackendOpenAI: {"OPENAI_API_KEY"}, - BackendGeminiCLI: {}, - BackendClaudeCLI: {}, - BackendCodexCLI: {}, - } - for _, envVar := range envVars[backend] { - if key := os.Getenv(envVar); key != "" { - return key - } - } - return "" + return api.GetAPIKeyFromEnv(backend) } diff --git a/pkg/ai/effort.go b/pkg/ai/effort.go new file mode 100644 index 0000000..3353372 --- /dev/null +++ b/pkg/ai/effort.go @@ -0,0 +1,22 @@ +package ai + +import "github.com/flanksource/captain/pkg/api" + +// Effort is the per-request reasoning effort, re-exported from the canonical +// pkg/api enum so pkg/ai (and clicky/aichat through it) share a single source +// of truth. captain owns the "xhigh" tier. +type Effort = api.Effort + +const ( + EffortNone = api.EffortNone + EffortLow = api.EffortLow + EffortMedium = api.EffortMedium + EffortHigh = api.EffortHigh + EffortXHigh = api.EffortXHigh +) + +// AllEfforts lists the non-empty effort tiers in ascending order. +func AllEfforts() []Effort { return api.AllEfforts() } + +// ValidateEffort fails loud on an unknown effort tier. +func ValidateEffort(e Effort) error { return e.Validate() } diff --git a/pkg/ai/history/codex_parser.go b/pkg/ai/history/codex_parser.go index d37b090..b9faef2 100644 --- a/pkg/ai/history/codex_parser.go +++ b/pkg/ai/history/codex_parser.go @@ -40,6 +40,54 @@ type CodexSessionInfo struct { ReasoningEffort string // "low", "medium", "high" — per turn_context } +// ReadCodexSessionMeta parses only the leading session metadata needed for +// cheap history candidate filtering. It intentionally stops before scanning +// turns or response items. +func ReadCodexSessionMeta(sessionFile string) (*CodexSessionInfo, error) { + file, err := os.Open(sessionFile) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + event, err := ParseCodexLine(line) + if err != nil { + continue + } + switch event.Type { + case "session_meta": + info := &CodexSessionInfo{ + ID: event.Payload.ID, + CWD: event.Payload.CWD, + CLIVersion: event.Payload.CLIVersion, + ModelProvider: event.Payload.ModelProvider, + Originator: event.Payload.Originator, + StartedAt: event.Time(), + } + if event.Payload.Git != nil { + info.GitBranch = event.Payload.Git.Branch + info.GitCommit = event.Payload.Git.CommitHash + } + return info, nil + case "thread.started": + return &CodexSessionInfo{ID: event.ThreadID, StartedAt: event.Time()}, nil + case "turn_context", "response_item", "event_msg", "item.completed", "turn.failed", "error": + return nil, nil + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return nil, nil +} + // ReadCodexSessionInfo parses just the leading `session_meta` event from a // codex rollout file. Returns nil if the file has no session_meta header. func ReadCodexSessionInfo(sessionFile string) (*CodexSessionInfo, error) { @@ -78,6 +126,13 @@ func ReadCodexSessionInfo(sessionFile string) (*CodexSessionInfo, error) { info.GitBranch = event.Payload.Git.Branch info.GitCommit = event.Payload.Git.CommitHash } + case "thread.started": + if info == nil { + info = &CodexSessionInfo{StartedAt: event.Time()} + } + if info.ID == "" { + info.ID = event.ThreadID + } case "turn_context": if info == nil { info = &CodexSessionInfo{StartedAt: event.Time()} diff --git a/pkg/ai/history/codex_parser_test.go b/pkg/ai/history/codex_parser_test.go index 7ff1838..879bea2 100644 --- a/pkg/ai/history/codex_parser_test.go +++ b/pkg/ai/history/codex_parser_test.go @@ -140,6 +140,56 @@ func TestReadCodexSessionInfo_ModelAndEffort(t *testing.T) { } } +func TestReadCodexSessionInfo_LiveThreadStarted(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-05-07T18:44:49.553Z","type":"thread.started","thread_id":"019e0365-dc2a-7ad0-a5a8-78936481a928"}`, + `{"timestamp":"2026-05-07T18:44:49.557Z","type":"turn_context","payload":{"model":"gpt-5-codex","effort":"high"}}`, + }, "\n") + dir := t.TempDir() + path := dir + "/sess.jsonl" + if err := os.WriteFile(path, []byte(stream), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + info, err := ReadCodexSessionInfo(path) + if err != nil { + t.Fatalf("ReadCodexSessionInfo: %v", err) + } + if info == nil { + t.Fatal("info should not be nil") + } + if info.ID != "019e0365-dc2a-7ad0-a5a8-78936481a928" { + t.Errorf("ID = %q", info.ID) + } + if info.Model != "gpt-5-codex" || info.ReasoningEffort != "high" { + t.Errorf("model/effort = %q/%q, want gpt-5-codex/high", info.Model, info.ReasoningEffort) + } +} + +func TestReadCodexSessionMetaStopsAtHeader(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-05-07T18:44:49.553Z","type":"session_meta","payload":{"id":"sess-1","cwd":"/p","cli_version":"0.128","model_provider":"openai"}}`, + `{"timestamp":"2026-05-07T18:44:49.557Z","type":"turn_context","payload":{"model":"gpt-5.5","effort":"high"}}`, + }, "\n") + dir := t.TempDir() + path := dir + "/sess.jsonl" + if err := os.WriteFile(path, []byte(stream), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + info, err := ReadCodexSessionMeta(path) + if err != nil { + t.Fatalf("ReadCodexSessionMeta: %v", err) + } + if info == nil { + t.Fatal("info should not be nil") + } + if info.ID != "sess-1" || info.CWD != "/p" || info.ModelProvider != "openai" { + t.Errorf("info = %+v", info) + } + if info.Model != "" || info.ReasoningEffort != "" { + t.Errorf("meta reader should not scan turn_context, got model/effort %q/%q", info.Model, info.ReasoningEffort) + } +} + func TestUnwrapCodexErrorMessage(t *testing.T) { tests := []struct { name string diff --git a/pkg/ai/loop_test.go b/pkg/ai/loop_test.go index 36ac127..31f0a50 100644 --- a/pkg/ai/loop_test.go +++ b/pkg/ai/loop_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "testing" + + "github.com/flanksource/captain/pkg/api" ) // fakeStreamingProvider replays a scripted sequence of event slices, one per @@ -75,7 +77,7 @@ func TestRunUntil_StopsWhenConditionMetAfterFirstRun(t *testing.T) { Provider: p, BuildRequest: func(iter int, prev *LoopIteration) (Request, bool) { if iter == 0 { - return Request{Prompt: "first"}, true + return Request{Prompt: api.Prompt{User: "first"}}, true } return Request{}, false }, @@ -103,7 +105,9 @@ func TestRunUntil_HitsMaxIterations(t *testing.T) { res, err := RunUntil(context.Background(), LoopOptions{ Provider: p, MaxIterations: 2, - BuildRequest: func(iter int, prev *LoopIteration) (Request, bool) { return Request{Prompt: "loop"}, true }, + BuildRequest: func(iter int, prev *LoopIteration) (Request, bool) { + return Request{Prompt: api.Prompt{User: "loop"}}, true + }, }) if err != nil { t.Fatalf("RunUntil err: %v", err) @@ -128,7 +132,9 @@ func TestRunUntil_HitsMaxCost(t *testing.T) { Provider: p, MaxIterations: 10, MaxCostUSD: 0.07, - BuildRequest: func(iter int, prev *LoopIteration) (Request, bool) { return Request{Prompt: "loop"}, true }, + BuildRequest: func(iter int, prev *LoopIteration) (Request, bool) { + return Request{Prompt: api.Prompt{User: "loop"}}, true + }, }) if err != nil { t.Fatalf("RunUntil err: %v", err) @@ -160,7 +166,7 @@ func TestRunUntil_SessionReuse(t *testing.T) { MaxIterations: 2, SessionReuse: true, BuildRequest: func(iter int, prev *LoopIteration) (Request, bool) { - return Request{Prompt: "loop"}, true + return Request{Prompt: api.Prompt{User: "loop"}}, true }, }) if err != nil { diff --git a/pkg/ai/middleware/caching.go b/pkg/ai/middleware/caching.go index b538cfa..e5e9820 100644 --- a/pkg/ai/middleware/caching.go +++ b/pkg/ai/middleware/caching.go @@ -22,7 +22,7 @@ func (c *cachingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp return c.provider.Execute(ctx, req) } - entry, err := c.cache.Get(req.Prompt, c.provider.GetModel()) + entry, err := c.cache.Get(req.Prompt.User, c.provider.GetModel()) if err == nil && entry != nil && entry.Error == "" { log.Debugf("[%s/%s] cache hit", c.provider.GetBackend(), c.provider.GetModel()) return &ai.Response{ @@ -50,7 +50,7 @@ func (c *cachingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp cacheEntry := &cache.Entry{ Model: c.provider.GetModel(), - Prompt: req.Prompt, + Prompt: req.Prompt.User, DurationMS: duration.Milliseconds(), Provider: string(c.provider.GetBackend()), } @@ -68,8 +68,9 @@ func (c *cachingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp cacheEntry.TokensCacheRead = resp.Usage.CacheReadTokens cacheEntry.TokensCacheWrite = resp.Usage.CacheWriteTokens cacheEntry.TokensTotal = resp.Usage.TotalTokens() - cacheEntry.MaxTokens = req.MaxTokens - cacheEntry.Temperature = req.Temperature + cacheEntry.MaxTokens = req.Budget.MaxTokens + t, _ := req.Temp() + cacheEntry.Temperature = t _ = c.cache.Set(cacheEntry) return resp, nil diff --git a/pkg/ai/middleware/cost.go b/pkg/ai/middleware/cost.go index d11019e..1ed7042 100644 --- a/pkg/ai/middleware/cost.go +++ b/pkg/ai/middleware/cost.go @@ -42,12 +42,18 @@ func (c *costProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respons log.Debugf("Cost calculation failed for %s: %v", resp.Model, calcErr) } else { c.session.AddCost(ai.Cost{ - Model: resp.Model, - InputTokens: result.InputTokens, - OutputTokens: result.OutputTokens, - TotalTokens: resp.Usage.TotalTokens(), - InputCost: result.TotalCost * float64(result.InputTokens) / float64(max(result.InputTokens+result.OutputTokens, 1)), - OutputCost: result.TotalCost * float64(result.OutputTokens) / float64(max(result.InputTokens+result.OutputTokens, 1)), + Model: resp.Model, + InputTokens: result.InputTokens, + OutputTokens: result.OutputTokens, + ReasoningTokens: result.ReasoningTokens, + CacheReadTokens: result.CacheReadTokens, + CacheWriteTokens: result.CacheWriteTokens, + TotalTokens: resp.Usage.TotalTokens(), + InputCost: result.InputCost, + OutputCost: result.OutputCost, + ReasoningCost: result.ReasoningCost, + CacheReadCost: result.CacheReadCost, + CacheWriteCost: result.CacheWriteCost, }) } diff --git a/pkg/ai/middleware/logging.go b/pkg/ai/middleware/logging.go index 9ffe1c5..c398fdc 100644 --- a/pkg/ai/middleware/logging.go +++ b/pkg/ai/middleware/logging.go @@ -2,10 +2,12 @@ package middleware import ( "context" + "encoding/json" "fmt" "time" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/provider" "github.com/flanksource/clicky" "github.com/flanksource/clicky/api/icons" "github.com/flanksource/commons/logger" @@ -26,11 +28,17 @@ func (l *loggingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp start := time.Now() if log.IsDebugEnabled() { - log.Debugf("%v", clicky.Text(""). + t := clicky.Text(""). Add(icons.AI). - Append(fmt.Sprintf(" %s/%s", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium"). - NewLine(). - Append(req.Prompt, "text-gray-600 max-w-[100ch]")) + Append(fmt.Sprintf(" %s/%s", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium") + if req.Prompt.Source != "" { + t = t.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") + } + t = t.NewLine().Append(req.Prompt.User, "text-gray-600") + if s := schemaInJSON(req.Prompt.Schema); s != "" { + t = t.NewLine().Append("schema-in ", "text-gray-500").Append(s, "text-gray-600") + } + log.Debugf("%v", t) } resp, err := l.provider.Execute(ctx, req) @@ -53,11 +61,15 @@ func (l *loggingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp } if log.IsTraceEnabled() { - log.Tracef("%v", clicky.Text(""). + t := clicky.Text(""). Add(icons.ArrowDown). Append(" response", "text-gray-500"). NewLine(). - Append(resp.Text, "text-gray-600 max-w-[100ch]")) + Append(resp.Text, "text-gray-600") + if s := structuredOutJSON(resp.StructuredData); s != "" { + t = t.NewLine().Append("schema-out ", "text-gray-500").Append(s, "text-gray-600") + } + log.Tracef("%v", t) } return resp, nil @@ -75,11 +87,13 @@ func (l *loggingProvider) ExecuteStream(ctx context.Context, req ai.Request) (<- } if log.IsDebugEnabled() { - log.Debugf("%v", clicky.Text(""). + t := clicky.Text(""). Add(icons.AI). - Append(fmt.Sprintf(" %s/%s (stream)", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium"). - NewLine(). - Append(req.Prompt, "text-gray-600 max-w-[100ch]")) + Append(fmt.Sprintf(" %s/%s (stream)", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium") + if req.Prompt.Source != "" { + t = t.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") + } + log.Debugf("%v", t.NewLine().Append(req.Prompt.User, "text-gray-600")) } return streamer.ExecuteStream(ctx, req) @@ -90,3 +104,35 @@ func WithLogging() Option { return &loggingProvider{provider: p}, nil } } + +// schemaInJSON renders the JSON schema captain derives from a structured-output +// target. Returns "" for text-mode requests (nil target); a non-struct target or +// marshal failure is surfaced inline rather than swallowed, since this is +// diagnostics that must never abort the run. +func schemaInJSON(out any) string { + if out == nil { + return "" + } + schema, err := provider.GenerateJSONSchema(out) + if err != nil { + return fmt.Sprintf("", err) + } + s, err := provider.SchemaToJSON(schema) + if err != nil { + return fmt.Sprintf("", err) + } + return s +} + +// structuredOutJSON renders the structured response the provider parsed into the +// caller's target. Returns "" when the response was text-only (nil StructuredData). +func structuredOutJSON(data any) string { + if data == nil { + return "" + } + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Sprintf("", err) + } + return string(b) +} diff --git a/pkg/ai/middleware/logging_test.go b/pkg/ai/middleware/logging_test.go new file mode 100644 index 0000000..fc31237 --- /dev/null +++ b/pkg/ai/middleware/logging_test.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "bytes" + "context" + "regexp" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/commons/logger" +) + +// sampleOut is a structured-output target: Type is required (no omitempty) and +// carries a description, Note is optional — enough to exercise schema generation. +type sampleOut struct { + Type string `json:"type" description:"conventional type"` + Note string `json:"note,omitempty"` +} + +type stubProvider struct{ resp *ai.Response } + +func (s stubProvider) Execute(context.Context, ai.Request) (*ai.Response, error) { + return s.resp, nil +} +func (s stubProvider) GetModel() string { return "test-model" } +func (s stubProvider) GetBackend() ai.Backend { return ai.BackendAnthropic } + +func TestSchemaInJSON(t *testing.T) { + if got := schemaInJSON(nil); got != "" { + t.Fatalf("text-mode (nil target): want empty, got %q", got) + } + + got := schemaInJSON(&sampleOut{}) + for _, want := range []string{`"type":"object"`, `"description":"conventional type"`, `"required":["type"]`} { + if !strings.Contains(got, want) { + t.Errorf("schema-in %q missing %q", got, want) + } + } + + if got := schemaInJSON("not-a-struct"); !strings.Contains(got, "schema-in error") { + t.Errorf("non-struct target: want inline error marker, got %q", got) + } +} + +func TestStructuredOutJSON(t *testing.T) { + if got := structuredOutJSON(nil); got != "" { + t.Fatalf("no structured data: want empty, got %q", got) + } + if got := structuredOutJSON(sampleOut{Type: "feat"}); !strings.Contains(got, `"type": "feat"`) { + t.Errorf("schema-out %q missing rendered field", got) + } +} + +var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// TestLoggingProvider_EmitsSourceAndSchemas drives a request with a Source and a +// structured-output target through the logging middleware at trace level and +// asserts the captured output carries the prompt source, the rendered input, the +// schema-in, and the structured schema-out — the four things `gavel commit -vvvv` +// previously could not show. +func TestLoggingProvider_EmitsSourceAndSchemas(t *testing.T) { + prev := logger.GetOutput() + t.Cleanup(func() { logger.SetOutput(prev) }) + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.GetLogger("ai").SetLogLevel("trace") + + p, err := WithLogging()(stubProvider{resp: &ai.Response{ + Text: `{"type":"feat"}`, + StructuredData: sampleOut{Type: "feat"}, + Model: "test-model", + }}) + if err != nil { + t.Fatalf("WithLogging: %v", err) + } + + if _, err := p.Execute(context.Background(), ai.Request{ + Prompt: api.Prompt{ + User: "rendered input prompt", + Source: "ai-commit-message.prompt", + Schema: &sampleOut{}, + }, + }); err != nil { + t.Fatalf("Execute: %v", err) + } + + got := ansi.ReplaceAllString(buf.String(), "") + for _, want := range []string{ + "ai-commit-message.prompt", // prompt source + "rendered input prompt", // rendered input + "schema-in", // schema-in label + `"type":"object"`, // schema-in body + "schema-out", // schema-out label + `"type": "feat"`, // structured schema-out body + } { + if !strings.Contains(got, want) { + t.Errorf("logging middleware output missing %q\n--- output ---\n%s", want, got) + } + } +} diff --git a/pkg/ai/model_cache.go b/pkg/ai/model_cache.go new file mode 100644 index 0000000..45546c6 --- /dev/null +++ b/pkg/ai/model_cache.go @@ -0,0 +1,80 @@ +package ai + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// modelCacheTTL bounds how long a persisted resolve is reused before the +// resolver re-queries live model lists. +const modelCacheTTL = 24 * time.Hour + +// ModelCache is the persisted merged model view written to +// ~/.config/captain/models.json. KeyFingerprint records which resolve produced +// it (backend filter + token use + which API keys were present) so a changed +// environment re-resolves instead of serving a stale live view. +type ModelCache struct { + Timestamp time.Time `json:"timestamp"` + KeyFingerprint string `json:"keyFingerprint"` + Models []ResolvedModel `json:"models"` +} + +func (c *ModelCache) expired() bool { + return time.Since(c.Timestamp) >= modelCacheTTL +} + +// modelCachePath returns ~/.config/captain/models.json, creating the directory. +func modelCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".config", "captain") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return filepath.Join(dir, "models.json"), nil +} + +func loadModelCache() (*ModelCache, error) { + path, err := modelCachePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var c ModelCache + if err := json.Unmarshal(data, &c); err != nil { + _ = os.Remove(path) + return nil, err + } + return &c, nil +} + +// saveModelCache atomically writes the merged view (tmp + rename). +func saveModelCache(fingerprint string, models []ResolvedModel) error { + path, err := modelCachePath() + if err != nil { + return err + } + data, err := json.MarshalIndent(ModelCache{ + Timestamp: time.Now(), + KeyFingerprint: fingerprint, + Models: models, + }, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/pkg/ai/models_remote.go b/pkg/ai/models_remote.go index e37e97d..d9edc97 100644 --- a/pkg/ai/models_remote.go +++ b/pkg/ai/models_remote.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "sort" "strings" "time" @@ -133,14 +132,14 @@ func doModelsRequest(req *http.Request, backend Backend) ([]ModelDef, error) { return out, nil } -// ListModels fetches the live model catalogue for a backend. Live data is the -// only source of truth: there is no static fallback, so a missing API key or -// a network failure surfaces as an error to the caller. CLI backends inherit -// from their parent provider's API (claude-cli ↔ Anthropic, codex-cli ↔ -// OpenAI, gemini-cli ↔ Gemini) because the CLIs themselves don't expose a -// listing endpoint and run against those same models under the hood. +// ListModels fetches the live model catalogue for an API backend. Live data is +// the only source of truth: there is no static fallback, so a missing API key +// or a network failure surfaces as an error to the caller. CLI/agent backends +// have no live listing here — they authenticate internally and enumerate their +// models from the static catalog in pkg/cli (agentCatalogModels), so passing +// one returns an error. func ListModels(ctx context.Context, backend Backend) ([]ModelDef, error) { - fetch, apiKey, parent := remoteFetcherFor(backend) + fetch, apiKey := remoteFetcherFor(backend) if fetch == nil { return nil, fmt.Errorf("backend %s has no live model listing", backend) } @@ -153,33 +152,22 @@ func ListModels(ctx context.Context, backend Backend) ([]ModelDef, error) { return nil, err } - // CLI backends share the underlying API but want their own backend tag - // on display so the picker stays grouped by chosen UX (claude-cli rows - // are visually distinct from anthropic API rows even though the IDs - // overlap). - if parent != backend { - for i := range models { - models[i].Backend = backend - } - } - sort.SliceStable(models, func(i, j int) bool { return models[i].ID < models[j].ID }) return models, nil } -// remoteFetcherFor returns the live-list function, API key, and parent API -// backend for the given backend. CLI backends are routed to the API of the -// same provider — they call the same models under the hood. Returns -// (nil, "", "") for backends without a known listing path. -func remoteFetcherFor(backend Backend) (fetch func(context.Context, string) ([]ModelDef, error), apiKey string, parent Backend) { +// remoteFetcherFor returns the live-list function and API key for an API +// backend. Returns (nil, "") for any backend without a live listing endpoint +// (every CLI/agent backend, which lists from the static catalog instead). +func remoteFetcherFor(backend Backend) (fetch func(context.Context, string) ([]ModelDef, error), apiKey string) { switch backend { - case BackendOpenAI, BackendCodexCLI: - return FetchOpenAIModels, os.Getenv("OPENAI_API_KEY"), BackendOpenAI - case BackendAnthropic, BackendClaudeCLI: - return FetchAnthropicModels, os.Getenv("ANTHROPIC_API_KEY"), BackendAnthropic - case BackendGemini, BackendGeminiCLI: - return FetchGeminiModels, os.Getenv("GEMINI_API_KEY"), BackendGemini + case BackendOpenAI: + return FetchOpenAIModels, GetAPIKeyFromEnv(backend) + case BackendAnthropic: + return FetchAnthropicModels, GetAPIKeyFromEnv(backend) + case BackendGemini: + return FetchGeminiModels, GetAPIKeyFromEnv(backend) default: - return nil, "", "" + return nil, "" } } diff --git a/pkg/ai/models_remote_test.go b/pkg/ai/models_remote_test.go index e17a751..6070534 100644 --- a/pkg/ai/models_remote_test.go +++ b/pkg/ai/models_remote_test.go @@ -139,7 +139,7 @@ func TestListModels_ErrorsOnMissingKey(t *testing.T) { t.Setenv("ANTHROPIC_API_KEY", "") t.Setenv("GEMINI_API_KEY", "") - for _, b := range []Backend{BackendOpenAI, BackendAnthropic, BackendGemini, BackendClaudeCLI, BackendCodexCLI, BackendGeminiCLI} { + for _, b := range []Backend{BackendOpenAI, BackendAnthropic, BackendGemini} { _, err := ListModels(context.Background(), b) if err == nil { t.Errorf("backend=%s: expected error when no API key set", b) @@ -147,6 +147,28 @@ func TestListModels_ErrorsOnMissingKey(t *testing.T) { } } +// TestListModels_RejectsNonAPIBackends pins that non-API backends have no live +// listing path: they authenticate internally and enumerate from the static +// catalog (pkg/cli), so ListModels must fail loud rather than require an API key. +func TestListModels_RejectsNonAPIBackends(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "sk-test") + t.Setenv("ANTHROPIC_API_KEY", "ant-test") + t.Setenv("GEMINI_API_KEY", "g-test") + + for _, b := range []Backend{ + BackendClaudeCLI, + BackendClaudeAgent, + BackendCodexCLI, + BackendGeminiCLI, + BackendClaudeCmux, + BackendCodexCmux, + } { + if _, err := ListModels(context.Background(), b); err == nil { + t.Errorf("backend=%s: expected error (no live listing for non-API backends)", b) + } + } +} + func TestListModels_ErrorsOnHTTPFailure(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -160,33 +182,6 @@ func TestListModels_ErrorsOnHTTPFailure(t *testing.T) { } } -func TestListModels_CodexCLIRoutesThroughOpenAI(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer sk-test" { - t.Errorf("OpenAI auth header not forwarded: %q", r.Header.Get("Authorization")) - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": []map[string]any{{"id": "gpt-5"}, {"id": "gpt-5-codex"}}, - }) - })) - defer srv.Close() - withTestServer(t, srv) - - t.Setenv("OPENAI_API_KEY", "sk-test") - got, err := ListModels(context.Background(), BackendCodexCLI) - if err != nil { - t.Fatalf("ListModels: %v", err) - } - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - for _, m := range got { - if m.Backend != BackendCodexCLI { - t.Errorf("expected re-tagged Backend=codex-cli, got %q on %+v", m.Backend, m) - } - } -} - func TestListModels_SortsAlphabetically(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/pkg/ai/pricing/openrouter.go b/pkg/ai/pricing/openrouter.go index b4facd7..113f54d 100644 --- a/pkg/ai/pricing/openrouter.go +++ b/pkg/ai/pricing/openrouter.go @@ -75,6 +75,7 @@ func EnsureLoaded() { log.Debugf("Loaded OpenRouter pricing from cache (age: %s)", time.Since(cache.Timestamp)) pricingCache = cache MergeModels(cache.Models) + applyStaticClaude() return } } @@ -88,6 +89,7 @@ func EnsureLoaded() { pricingCache = &PricingCache{Timestamp: time.Now(), Models: models} MergeModels(models) + applyStaticClaude() } func fetchOpenRouterPricing() (map[string]*ModelInfo, error) { diff --git a/pkg/ai/pricing/registry.go b/pkg/ai/pricing/registry.go index 17039d0..1ca1e22 100644 --- a/pkg/ai/pricing/registry.go +++ b/pkg/ai/pricing/registry.go @@ -27,17 +27,74 @@ var ( func GetModelInfo(model string) (ModelInfo, bool) { EnsureLoaded() registryMu.RLock() - defer registryMu.RUnlock() info, ok := registry[model] - return info, ok + registryMu.RUnlock() + if ok { + return info, true + } + // Classify-on-miss: arbitrary Claude ids (e.g. catalog "claude-sonnet-4-6") + // that OpenRouter never lists are still priced from the static table. + return claudeStaticInfo(model) +} + +// MergeMode controls how MergeModel/MergeModels reconcile an incoming row with +// an existing registry entry for the same id. +type MergeMode int + +const ( + // MergeOverlay replaces the whole row (OpenRouter's authoritative refresh). + MergeOverlay MergeMode = iota + // MergeFillMissing keeps existing non-zero fields and only fills the zero + // ones from the incoming row (used to fill gaps without clobbering prices). + MergeFillMissing +) + +// MergeModel merges a single row into the registry under the given mode. +func MergeModel(id string, in ModelInfo, mode MergeMode) { + registryMu.Lock() + defer registryMu.Unlock() + mergeLocked(id, in, mode) } +// MergeModels overlays each row into the registry (full replace per id). func MergeModels(models map[string]*ModelInfo) { registryMu.Lock() defer registryMu.Unlock() for id, info := range models { - registry[id] = *info + mergeLocked(id, *info, MergeOverlay) + } +} + +// mergeLocked performs the merge; callers must hold registryMu. +func mergeLocked(id string, in ModelInfo, mode MergeMode) { + existing, ok := registry[id] + if !ok || mode == MergeOverlay { + in.ModelID = id + registry[id] = in + return + } + if existing.ModelID == "" { + existing.ModelID = id + } + if existing.MaxTokens == 0 { + existing.MaxTokens = in.MaxTokens + } + if existing.ContextWindow == 0 { + existing.ContextWindow = in.ContextWindow + } + if existing.InputPrice == 0 { + existing.InputPrice = in.InputPrice + } + if existing.OutputPrice == 0 { + existing.OutputPrice = in.OutputPrice + } + if existing.CacheReadsPrice == 0 { + existing.CacheReadsPrice = in.CacheReadsPrice + } + if existing.CacheWritesPrice == 0 { + existing.CacheWritesPrice = in.CacheWritesPrice } + registry[id] = existing } func RegistrySize() int { @@ -64,10 +121,18 @@ func ListModels(filter string) []ModelInfo { } type CostResult struct { - Model string - InputTokens int - OutputTokens int - TotalCost float64 + Model string + InputTokens int + OutputTokens int + ReasoningTokens int + CacheReadTokens int + CacheWriteTokens int + InputCost float64 + OutputCost float64 + ReasoningCost float64 + CacheReadCost float64 + CacheWriteCost float64 + TotalCost float64 } func CalculateCost(model string, inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWriteTokens int) (CostResult, error) { @@ -78,22 +143,33 @@ func CalculateCost(model string, inputTokens, outputTokens, reasoningTokens, cac model, RegistrySize(), strings.Join(suggestions, ", ")) } - cost := float64(inputTokens)*info.InputPrice/1_000_000 + - float64(outputTokens)*info.OutputPrice/1_000_000 + - float64(reasoningTokens)*info.OutputPrice/1_000_000 + inputCost := float64(inputTokens) * info.InputPrice / 1_000_000 + outputCost := float64(outputTokens) * info.OutputPrice / 1_000_000 + reasoningCost := float64(reasoningTokens) * info.OutputPrice / 1_000_000 + var cacheReadCost float64 if cacheReadTokens > 0 && info.CacheReadsPrice > 0 { - cost += float64(cacheReadTokens) * info.CacheReadsPrice / 1_000_000 + cacheReadCost = float64(cacheReadTokens) * info.CacheReadsPrice / 1_000_000 } + var cacheWriteCost float64 if cacheWriteTokens > 0 && info.CacheWritesPrice > 0 { - cost += float64(cacheWriteTokens) * info.CacheWritesPrice / 1_000_000 + cacheWriteCost = float64(cacheWriteTokens) * info.CacheWritesPrice / 1_000_000 } + cost := inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost return CostResult{ - Model: model, - InputTokens: inputTokens, - OutputTokens: outputTokens, - TotalCost: cost, + Model: model, + InputTokens: inputTokens, + OutputTokens: outputTokens, + ReasoningTokens: reasoningTokens, + CacheReadTokens: cacheReadTokens, + CacheWriteTokens: cacheWriteTokens, + InputCost: inputCost, + OutputCost: outputCost, + ReasoningCost: reasoningCost, + CacheReadCost: cacheReadCost, + CacheWriteCost: cacheWriteCost, + TotalCost: cost, }, nil } diff --git a/pkg/ai/pricing/registry_test.go b/pkg/ai/pricing/registry_test.go new file mode 100644 index 0000000..7379313 --- /dev/null +++ b/pkg/ai/pricing/registry_test.go @@ -0,0 +1,39 @@ +package pricing + +import ( + "math" + "testing" +) + +func TestCalculateCostBreaksOutBuckets(t *testing.T) { + MergeModels(map[string]*ModelInfo{ + "test/bucketed": { + ModelID: "test/bucketed", + InputPrice: 1, + OutputPrice: 2, + CacheReadsPrice: 0.25, + CacheWritesPrice: 1.5, + }, + }) + + got, err := CalculateCost("test/bucketed", 1_000_000, 2_000_000, 500_000, 4_000_000, 2_000_000) + if err != nil { + t.Fatalf("CalculateCost: %v", err) + } + assertClose(t, "input", got.InputCost, 1) + assertClose(t, "output", got.OutputCost, 4) + assertClose(t, "reasoning", got.ReasoningCost, 1) + assertClose(t, "cache read", got.CacheReadCost, 1) + assertClose(t, "cache write", got.CacheWriteCost, 3) + assertClose(t, "total", got.TotalCost, 10) + if got.InputTokens != 1_000_000 || got.OutputTokens != 2_000_000 || got.ReasoningTokens != 500_000 || got.CacheReadTokens != 4_000_000 || got.CacheWriteTokens != 2_000_000 { + t.Fatalf("tokens = %+v", got) + } +} + +func assertClose(t *testing.T, name string, got, want float64) { + t.Helper() + if math.Abs(got-want) > 1e-9 { + t.Fatalf("%s cost = %v, want %v", name, got, want) + } +} diff --git a/pkg/ai/pricing/static.go b/pkg/ai/pricing/static.go new file mode 100644 index 0000000..443eabe --- /dev/null +++ b/pkg/ai/pricing/static.go @@ -0,0 +1,42 @@ +package pricing + +import "github.com/flanksource/captain/pkg/claude" + +// claudeStaticInfo synthesizes a ModelInfo from the hand-curated static Claude +// price table for any model id that classifies to a known Claude family +// (opus/sonnet/haiku). It returns false for non-Claude ids and unknown +// families. ContextWindow/MaxTokens are left zero — the static table carries +// prices only; context is supplied separately (catalog or OpenRouter). +func claudeStaticInfo(id string) (ModelInfo, bool) { + p, ok := claude.PricingTable[claude.ClassifyModel(id)] + if !ok { + return ModelInfo{}, false + } + return ModelInfo{ + ModelID: id, + InputPrice: p.InputPerMTok, + OutputPrice: p.OutputPerMTok, + CacheReadsPrice: p.CacheReadPerMTok, + CacheWritesPrice: p.CacheWritePerMTok, + }, true +} + +// applyStaticClaude overlays the static Claude price table onto registry rows +// that classify to a known Claude family, filling only the price fields +// OpenRouter left zero (OpenRouter stays primary, per the cost-merge decision). +// It does not add new ids: an id absent from the registry is priced lazily via +// claudeStaticInfo on lookup (see GetModelInfo's classify-on-miss). +func applyStaticClaude() { + registryMu.RLock() + ids := make([]string, 0, len(registry)) + for id := range registry { + ids = append(ids, id) + } + registryMu.RUnlock() + + for _, id := range ids { + if info, ok := claudeStaticInfo(id); ok { + MergeModel(id, info, MergeFillMissing) + } + } +} diff --git a/pkg/ai/pricing/static_test.go b/pkg/ai/pricing/static_test.go new file mode 100644 index 0000000..25a1a88 --- /dev/null +++ b/pkg/ai/pricing/static_test.go @@ -0,0 +1,132 @@ +package pricing + +import ( + "errors" + "testing" + + "github.com/flanksource/captain/pkg/claude" +) + +// errTestShortCircuit makes EnsureLoaded return immediately so lookups in these +// tests never touch the network or the on-disk OpenRouter cache. +var errTestShortCircuit = errors.New("test: skip EnsureLoaded") + +// withIsolatedRegistry swaps the package-global registry (and short-circuits +// EnsureLoaded) for the duration of a test, restoring both on cleanup. +func withIsolatedRegistry(t *testing.T, seed map[string]ModelInfo) { + t.Helper() + + registryMu.Lock() + savedReg := registry + registry = map[string]ModelInfo{} + for k, v := range seed { + registry[k] = v + } + registryMu.Unlock() + + pricingCacheLock.Lock() + savedErr := pricingCacheErr + savedCache := pricingCache + pricingCacheErr = errTestShortCircuit + pricingCacheLock.Unlock() + + t.Cleanup(func() { + registryMu.Lock() + registry = savedReg + registryMu.Unlock() + + pricingCacheLock.Lock() + pricingCacheErr = savedErr + pricingCache = savedCache + pricingCacheLock.Unlock() + }) +} + +func TestMergeModelFillMissingKeepsNonZero(t *testing.T) { + withIsolatedRegistry(t, map[string]ModelInfo{ + "x/model": {ModelID: "x/model", InputPrice: 3, ContextWindow: 200000}, + }) + + // Fill-missing must keep the existing InputPrice/ContextWindow and only set + // the zero fields (OutputPrice, CacheReadsPrice). + MergeModel("x/model", ModelInfo{InputPrice: 99, OutputPrice: 15, CacheReadsPrice: 0.3, ContextWindow: 1}, MergeFillMissing) + + got, _ := GetModelInfo("x/model") + if got.InputPrice != 3 || got.ContextWindow != 200000 { + t.Fatalf("fill-missing clobbered non-zero fields: %+v", got) + } + if got.OutputPrice != 15 || got.CacheReadsPrice != 0.3 { + t.Fatalf("fill-missing did not fill zero fields: %+v", got) + } +} + +func TestMergeModelOverlayReplaces(t *testing.T) { + withIsolatedRegistry(t, map[string]ModelInfo{ + "x/model": {ModelID: "x/model", InputPrice: 3, OutputPrice: 15}, + }) + + MergeModel("x/model", ModelInfo{InputPrice: 99}, MergeOverlay) + + got, _ := GetModelInfo("x/model") + if got.InputPrice != 99 || got.OutputPrice != 0 { + t.Fatalf("overlay did not fully replace row: %+v", got) + } +} + +func TestClaudeStaticInfo(t *testing.T) { + sonnet := claude.PricingTable[claude.ModelFamilySonnet4] + got, ok := claudeStaticInfo("claude-sonnet-4-6") + if !ok { + t.Fatal("expected sonnet id to classify") + } + if got.InputPrice != sonnet.InputPerMTok || got.OutputPrice != sonnet.OutputPerMTok || + got.CacheReadsPrice != sonnet.CacheReadPerMTok || got.CacheWritesPrice != sonnet.CacheWritePerMTok { + t.Fatalf("static sonnet price mismatch: %+v", got) + } + if _, ok := claudeStaticInfo("gpt-4o"); ok { + t.Fatal("non-claude id must not classify") + } +} + +func TestApplyStaticClaudeFillsMissingKeepsOpenRouter(t *testing.T) { + // OpenRouter gave a non-zero input price but no output price for this claude + // row; a non-claude row must be left entirely untouched. + withIsolatedRegistry(t, map[string]ModelInfo{ + "anthropic/claude-sonnet-4": {ModelID: "anthropic/claude-sonnet-4", InputPrice: 2.5}, + "openai/gpt-4o": {ModelID: "openai/gpt-4o", InputPrice: 5}, + }) + + applyStaticClaude() + + claudeRow, _ := GetModelInfo("anthropic/claude-sonnet-4") + if claudeRow.InputPrice != 2.5 { + t.Fatalf("OpenRouter input price overwritten: %+v", claudeRow) + } + if claudeRow.OutputPrice != claude.PricingTable[claude.ModelFamilySonnet4].OutputPerMTok { + t.Fatalf("static output price not filled: %+v", claudeRow) + } + + openaiRow, _ := GetModelInfo("openai/gpt-4o") + if openaiRow.InputPrice != 5 || openaiRow.OutputPrice != 0 { + t.Fatalf("non-claude row mutated: %+v", openaiRow) + } +} + +func TestGetModelInfoClassifyOnMiss(t *testing.T) { + // Registry intentionally empty: a Claude id absent from OpenRouter must + // still resolve via the static table. + withIsolatedRegistry(t, nil) + + haiku := claude.PricingTable[claude.ModelFamilyHaiku4] + got, ok := GetModelInfo("claude-haiku-4-5") + if !ok { + t.Fatal("expected classify-on-miss to price a known claude family") + } + if got.InputPrice != haiku.InputPerMTok || got.OutputPrice != haiku.OutputPerMTok { + t.Fatalf("classify-on-miss price mismatch: %+v", got) + } + + if _, ok := GetModelInfo("totally-unknown-model-zzz"); ok { + t.Fatal("unknown non-claude id must remain a miss") + } +} diff --git a/pkg/ai/prompt/prompt.go b/pkg/ai/prompt/prompt.go index 7d7eaec..398c139 100644 --- a/pkg/ai/prompt/prompt.go +++ b/pkg/ai/prompt/prompt.go @@ -1,12 +1,20 @@ // Package prompt renders Google dotprompt (.prompt) templates into captain's -// ai.Request / ai.Config. A .prompt file carries YAML frontmatter (model, -// config, input/output schema) and a Handlebars body whose {{role "system"}} / -// {{role "user"}} markers split the rendered output into messages. +// ai.Request / ai.Config. A .prompt file carries YAML frontmatter and a +// Handlebars body whose {{role "system"}} / {{role "user"}} markers split the +// rendered output into messages. +// +// The frontmatter is parsed twice: once by dotprompt for its built-in keys +// (model, config.maxOutputTokens/temperature/reasoning, input/output schema), +// and a second time straight into the spec (ai.Request = api.Spec) so any +// request option can be declared in its native, nested shape — e.g. +// permissions.mode, permissions.tools.allow, memory.skipUser, budget.maxTokens, +// context.dir, effort, sessionId, maxTurns. When a file mixes both dialects the +// dotprompt config: block wins for the three knobs it owns. // // Structured output is driven by the Go target passed to Render (out), which -// becomes ai.Request.StructuredOutput — captain's providers derive the JSON +// becomes ai.Request.Prompt.Schema — captain's providers derive the JSON // schema from that Go type. The frontmatter output schema is advisory and is -// not used to populate StructuredOutput (captain cannot consume a bare schema +// not used to populate the schema target (captain cannot consume a bare schema // map), so pass a Go struct when you want structured results. package prompt @@ -17,7 +25,9 @@ import ( "strings" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" dp "github.com/google/dotprompt/go/dotprompt" + "gopkg.in/yaml.v3" ) // Template is a parsed .prompt source ready to render with runtime data. @@ -56,7 +66,7 @@ func LoadFS(fsys fs.FS, path string) (*Template, error) { // Render executes the template body with data and folds the frontmatter into an // ai.Request and ai.Config. When out is non-nil it becomes -// Request.StructuredOutput (the structured-output target). +// Request.Prompt.Schema (the structured-output target). func (t *Template) Render(data map[string]any, out any) (ai.Request, ai.Config, error) { rendered, err := t.dp.Render(t.source, &dp.DataArgument{Input: data}, nil) if err != nil { @@ -73,23 +83,56 @@ func (t *Template) Render(data map[string]any, out any) (ai.Request, ai.Config, } } - req := ai.Request{ - SystemPrompt: strings.TrimSpace(strings.Join(system, "\n")), - Prompt: strings.TrimSpace(strings.Join(user, "\n")), + // Second parse: fold the full frontmatter into the spec so any request option + // (permissions, memory, budget, context, …) can be declared in the file. + var req ai.Request + if err := decodeSpecFrontmatter(rendered.Raw, &req); err != nil { + return ai.Request{}, ai.Config{}, fmt.Errorf("decode prompt %s frontmatter into ai.Request: %w", t.name, err) + } + + // The rendered template body wins over any frontmatter prompt.user/system. + if s := strings.TrimSpace(strings.Join(system, "\n")); s != "" { + req.Prompt.System = s + } + if u := strings.TrimSpace(strings.Join(user, "\n")); u != "" { + req.Prompt.User = u + } + req.Prompt.Source = t.name + + cfg := ai.Config{Model: req.Model} + if cfg.Model.Name == "" { + cfg.Model.Name = rendered.Model } - cfg := ai.Config{Model: rendered.Model} - if rendered.Model != "" { - if b, berr := ai.InferBackend(rendered.Model); berr == nil { - cfg.Backend = b + if cfg.Model.Backend == "" && cfg.Model.Name != "" { + if b, berr := ai.InferBackend(cfg.Model.Name); berr == nil { + cfg.Model.Backend = b } } + // The dotprompt config: block stays canonical for maxOutputTokens/temperature/ + // reasoning when a file mixes both frontmatter dialects, so apply it last. applyModelConfig(rendered.Config, &req, &cfg) + cfg.Budget = req.Budget if out != nil { - req.StructuredOutput = out + req.Prompt.Schema = out } return req, cfg, nil } +// decodeSpecFrontmatter folds the raw dotprompt frontmatter map into the spec +// (ai.Request) by re-encoding it as YAML and unmarshalling into the typed spec. +// Spec-native keys land on their fields; dotprompt-only keys (config, input, +// output) have no spec field and are ignored. It is a no-op for empty frontmatter. +func decodeSpecFrontmatter(raw map[string]any, req *ai.Request) error { + if len(raw) == 0 { + return nil + } + b, err := yaml.Marshal(raw) + if err != nil { + return fmt.Errorf("re-encode frontmatter: %w", err) + } + return yaml.Unmarshal(b, req) +} + // Library renders named .prompt files from an fs.FS (typically an embed.FS), the // drop-in replacement for ad-hoc embed + gomplate prompt rendering. type Library struct{ fsys fs.FS } @@ -110,15 +153,16 @@ func (l *Library) Render(name string, data map[string]any, out any) (ai.Request, // the captain request/config. func applyModelConfig(c dp.ModelConfig, req *ai.Request, cfg *ai.Config) { if v, ok := floatOf(c["maxOutputTokens"]); ok { - req.MaxTokens = int(v) - cfg.MaxTokens = int(v) + req.Budget.MaxTokens = int(v) + cfg.Budget.MaxTokens = int(v) } if v, ok := floatOf(c["temperature"]); ok { - req.Temperature = v - cfg.Temperature = v + temp := v + req.Temperature = &temp + cfg.Model.Temperature = &temp } if s, ok := c["reasoning"].(string); ok { - req.ReasoningEffort = s + req.Effort = api.Effort(s) } } diff --git a/pkg/ai/prompt/prompt_test.go b/pkg/ai/prompt/prompt_test.go index d7710b0..bc25f39 100644 --- a/pkg/ai/prompt/prompt_test.go +++ b/pkg/ai/prompt/prompt_test.go @@ -2,14 +2,18 @@ package prompt import ( "embed" + "io/fs" + "path/filepath" + "strings" "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -//go:embed testdata/commit.prompt +//go:embed testdata var library embed.FS func TestRender_FrontmatterAndMessages(t *testing.T) { @@ -19,14 +23,47 @@ func TestRender_FrontmatterAndMessages(t *testing.T) { req, cfg, err := tmpl.Render(map[string]any{"diff": "+added a line"}, nil) require.NoError(t, err) - assert.Contains(t, req.SystemPrompt, "Conventional Commit") - assert.Contains(t, req.Prompt, "+added a line") - assert.NotContains(t, req.Prompt, "Conventional Commit", "system text must not leak into the user prompt") + assert.Contains(t, req.Prompt.System, "Conventional Commit") + assert.Contains(t, req.Prompt.User, "+added a line") + assert.NotContains(t, req.Prompt.User, "Conventional Commit", "system text must not leak into the user prompt") - assert.Equal(t, "claude-sonnet-4-6", cfg.Model) - assert.Equal(t, ai.BackendAnthropic, cfg.Backend, "model name should infer the anthropic backend") - assert.Equal(t, 1024, req.MaxTokens) - assert.InDelta(t, 0.2, req.Temperature, 1e-9) + assert.Equal(t, "claude-sonnet-4-6", cfg.Model.Name) + assert.Equal(t, ai.BackendAnthropic, cfg.Model.Backend, "model name should infer the anthropic backend") + assert.Equal(t, 1024, req.Budget.MaxTokens) + temp, ok := req.Temp() + require.True(t, ok, "temperature should be set from frontmatter") + assert.InDelta(t, 0.2, temp, 1e-9) +} + +// TestRender_SpecFrontmatter exercises the second parse: spec-native frontmatter +// (permissions/memory/budget/maxTurns) lands on the nested ai.Request groups, +// while the dotprompt config: block stays canonical for the knobs it owns. +func TestRender_SpecFrontmatter(t *testing.T) { + tmpl, err := LoadFS(library, "testdata/options.prompt") + require.NoError(t, err) + + req, _, err := tmpl.Render(map[string]any{"target": "parser.go"}, nil) + require.NoError(t, err) + + // Spec-native keys from the second parse. + assert.Equal(t, api.PermissionAcceptEdits, req.Permissions.Mode) + assert.Equal(t, []api.Preset{api.PresetEdit}, req.Permissions.Presets) + assert.Equal(t, []string{"Read", "Edit"}, req.Permissions.Tools.Allow) + assert.True(t, req.Permissions.MCP.Disabled) + assert.True(t, req.Memory.SkipUser) + assert.Equal(t, 3, req.MaxTurns) + + // The dotprompt config: block wins for the knobs it owns: config.maxOutputTokens + // (1024) overrides the spec-native budget.maxTokens (5000), and config.temperature + // sets the model temperature. + assert.Equal(t, 1024, req.Budget.MaxTokens) + temp, ok := req.Temp() + require.True(t, ok) + assert.InDelta(t, 0.2, temp, 1e-9) + + // Body still wins for the messages. + assert.Contains(t, req.Prompt.System, "refactoring assistant") + assert.Contains(t, req.Prompt.User, "parser.go") } func TestRender_StructuredOutputTarget(t *testing.T) { @@ -38,13 +75,89 @@ func TestRender_StructuredOutputTarget(t *testing.T) { req, _, err := Load("{{role \"user\"}}\nhi").Render(nil, out) require.NoError(t, err) - assert.Same(t, out, req.StructuredOutput) + assert.Same(t, out, req.Prompt.Schema) } func TestLibrary_Render(t *testing.T) { lib := NewLibrary(library) req, cfg, err := lib.Render("testdata/commit.prompt", map[string]any{"diff": "x"}, nil) require.NoError(t, err) - assert.Equal(t, "claude-sonnet-4-6", cfg.Model) - assert.Contains(t, req.Prompt, "x") + assert.Equal(t, "claude-sonnet-4-6", cfg.Model.Name) + assert.Contains(t, req.Prompt.User, "x") +} + +func TestRender_BackendFixtureExamples(t *testing.T) { + expected := map[string]struct { + backend api.Backend + model string + }{ + "testdata/fixtures/anthropic-claude-opus.prompt": {backend: api.BackendAnthropic, model: "claude-opus-4-6"}, + "testdata/fixtures/anthropic-claude-sonnet.prompt": {backend: api.BackendAnthropic, model: "claude-sonnet-4-6"}, + "testdata/fixtures/claude-agent-opus.prompt": {backend: api.BackendClaudeAgent, model: "claude-agent-opus"}, + "testdata/fixtures/claude-agent-sonnet.prompt": {backend: api.BackendClaudeAgent, model: "claude-agent-sonnet"}, + "testdata/fixtures/claude-cli-opus.prompt": {backend: api.BackendClaudeCLI, model: "claude-agent-opus"}, + "testdata/fixtures/claude-cli-sonnet.prompt": {backend: api.BackendClaudeCLI, model: "claude-agent-sonnet"}, + "testdata/fixtures/codex-cli.prompt": {backend: api.BackendCodexCLI, model: "gpt-5-codex"}, + "testdata/fixtures/gemini-api.prompt": {backend: api.BackendGemini, model: "gemini-2.5-pro"}, + "testdata/fixtures/gemini-cli.prompt": {backend: api.BackendGeminiCLI, model: "gemini-cli-pro"}, + "testdata/fixtures/openai-gpt.prompt": {backend: api.BackendOpenAI, model: "gpt-5"}, + } + + seenFiles := map[string]bool{} + seenBackends := map[api.Backend]bool{} + claudeFamilies := map[api.Backend]map[string]bool{ + api.BackendAnthropic: {}, + api.BackendClaudeAgent: {}, + api.BackendClaudeCLI: {}, + } + + err := fs.WalkDir(library, "testdata/fixtures", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".prompt" { + return nil + } + + want, ok := expected[path] + require.True(t, ok, "unexpected fixture %s", path) + seenFiles[path] = true + + tmpl, err := LoadFS(library, path) + require.NoError(t, err) + req, cfg, err := tmpl.Render(map[string]any{"task": "summarize the change and propose next steps"}, nil) + require.NoError(t, err) + require.NoError(t, req.Validate()) + + assert.Equal(t, path, req.Prompt.Source) + assert.Contains(t, req.Prompt.User, "summarize the change") + assert.Equal(t, want.model, req.Model.Name) + assert.Equal(t, want.model, cfg.Model.Name) + assert.Equal(t, want.backend, req.Model.Backend) + assert.Equal(t, want.backend, cfg.Model.Backend) + + seenBackends[want.backend] = true + if families, ok := claudeFamilies[want.backend]; ok { + switch model := strings.ToLower(want.model); { + case strings.Contains(model, "sonnet"): + families["sonnet"] = true + case strings.Contains(model, "opus"): + families["opus"] = true + } + } + return nil + }) + require.NoError(t, err) + + assert.Len(t, seenFiles, len(expected)) + for path := range expected { + assert.True(t, seenFiles[path], "missing fixture %s", path) + } + for _, backend := range api.AllBackends() { + assert.True(t, seenBackends[backend], "missing fixture for backend %s", backend) + } + for backend, families := range claudeFamilies { + assert.True(t, families["sonnet"], "missing sonnet fixture for backend %s", backend) + assert.True(t, families["opus"], "missing opus fixture for backend %s", backend) + } } diff --git a/pkg/ai/prompt/testdata/fixtures/anthropic-claude-opus.prompt b/pkg/ai/prompt/testdata/fixtures/anthropic-claude-opus.prompt new file mode 100644 index 0000000..4a3d06a --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/anthropic-claude-opus.prompt @@ -0,0 +1,16 @@ +--- +model: claude-opus-4-6 +backend: anthropic +effort: high +config: + maxOutputTokens: 8192 + temperature: 0.1 +budget: + cost: 3 +--- +{{role "system"}} +You are a senior reviewer. Prioritize correctness, hidden risks, and concrete next actions. +{{role "user"}} +Review this task: +{{task}} + diff --git a/pkg/ai/prompt/testdata/fixtures/anthropic-claude-sonnet.prompt b/pkg/ai/prompt/testdata/fixtures/anthropic-claude-sonnet.prompt new file mode 100644 index 0000000..257a73b --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/anthropic-claude-sonnet.prompt @@ -0,0 +1,15 @@ +--- +model: claude-sonnet-4-6 +backend: anthropic +effort: medium +config: + maxOutputTokens: 4096 + temperature: 0.2 +budget: + cost: 1 +--- +{{role "system"}} +You are a precise coding assistant. Keep recommendations grounded in the supplied task. +{{role "user"}} +Task: Provide a summary of recent changes +{{task}} diff --git a/pkg/ai/prompt/testdata/fixtures/claude-agent-opus.prompt b/pkg/ai/prompt/testdata/fixtures/claude-agent-opus.prompt new file mode 100644 index 0000000..e3cbe0c --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/claude-agent-opus.prompt @@ -0,0 +1,24 @@ +--- +model: claude-agent-opus +backend: claude-agent +maxTurns: 4 +context: + dir: . +permissions: + mode: plan + tools: + allow: + - Read + - Glob + - Grep + modes: + Bash: ask +memory: + skipProject: true +--- +{{role "system"}} +You are planning a sensitive code change. Do not edit files; identify risks and checks. +{{role "user"}} +Plan: +{{task}} + diff --git a/pkg/ai/prompt/testdata/fixtures/claude-agent-sonnet.prompt b/pkg/ai/prompt/testdata/fixtures/claude-agent-sonnet.prompt new file mode 100644 index 0000000..1d7f9fd --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/claude-agent-sonnet.prompt @@ -0,0 +1,24 @@ +--- +model: claude-agent-sonnet +backend: claude-agent +maxTurns: 6 +context: + dir: . +permissions: + presets: + - edit + tools: + allow: + - Read + - Edit + - Write + - Glob + - Grep +memory: + skipUser: true +--- +{{role "system"}} +You are working inside a repository. Make narrow edits and report the verification you ran. +{{role "user"}} +Summarize recent changes in the current directory +{{task}} diff --git a/pkg/ai/prompt/testdata/fixtures/claude-cli-opus.prompt b/pkg/ai/prompt/testdata/fixtures/claude-cli-opus.prompt new file mode 100644 index 0000000..9d77816 --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/claude-cli-opus.prompt @@ -0,0 +1,23 @@ +--- +model: claude-agent-opus +backend: claude-cli +maxTurns: 3 +permissions: + mode: default + tools: + allow: + - Read + - Glob + - Grep + modes: + Bash: ask +memory: + skipHooks: true + skipUser: true +--- +{{role "system"}} +You are using the Claude CLI backend for high-signal review. Be specific about evidence. +{{role "user"}} +Review: +{{task}} + diff --git a/pkg/ai/prompt/testdata/fixtures/claude-cli-sonnet.prompt b/pkg/ai/prompt/testdata/fixtures/claude-cli-sonnet.prompt new file mode 100644 index 0000000..5c478be --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/claude-cli-sonnet.prompt @@ -0,0 +1,22 @@ +--- +model: claude-agent-sonnet +backend: claude-cli +maxTurns: 3 +permissions: + mode: default + tools: + allow: + - Read + - Glob + - Grep + modes: + Bash: ask +memory: + skipHooks: true +--- +{{role "system"}} +You are using the Claude CLI backend. Keep the answer concise and actionable. +{{role "user"}} +Handle: +{{task}} + diff --git a/pkg/ai/prompt/testdata/fixtures/codex-cli.prompt b/pkg/ai/prompt/testdata/fixtures/codex-cli.prompt new file mode 100644 index 0000000..142ead5 --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/codex-cli.prompt @@ -0,0 +1,20 @@ +--- +model: gpt-5-codex +backend: codex-cli +effort: medium +context: + dir: . +permissions: + presets: + - edit + mcp: + disabled: true +memory: + bare: true +--- +{{role "system"}} +You are a Codex agent. Apply scoped edits, keep the worktree clean, and verify the change. +{{role "user"}} +Code task: +{{task}} + diff --git a/pkg/ai/prompt/testdata/fixtures/gemini-api.prompt b/pkg/ai/prompt/testdata/fixtures/gemini-api.prompt new file mode 100644 index 0000000..5302f57 --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/gemini-api.prompt @@ -0,0 +1,16 @@ +--- +model: gemini-2.5-pro +backend: gemini +effort: medium +config: + maxOutputTokens: 4096 + temperature: 0.25 +budget: + cost: 1 +--- +{{role "system"}} +You are an analysis assistant. Compare alternatives and call out assumptions. +{{role "user"}} +Analyze: +{{task}} + diff --git a/pkg/ai/prompt/testdata/fixtures/gemini-cli.prompt b/pkg/ai/prompt/testdata/fixtures/gemini-cli.prompt new file mode 100644 index 0000000..a57ea9b --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/gemini-cli.prompt @@ -0,0 +1,10 @@ +--- +model: gemini-cli-pro +backend: gemini-cli +budget: + maxTokens: 4096 +--- +{{role "user"}} +Use the Gemini CLI backend to answer: +{{task}} + diff --git a/pkg/ai/prompt/testdata/fixtures/openai-gpt.prompt b/pkg/ai/prompt/testdata/fixtures/openai-gpt.prompt new file mode 100644 index 0000000..d71b5f2 --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/openai-gpt.prompt @@ -0,0 +1,16 @@ +--- +model: gpt-5 +backend: openai +effort: high +config: + maxOutputTokens: 4096 + temperature: 0.3 +budget: + cost: 1 +--- +{{role "system"}} +You are a direct implementation planner. Return the smallest practical sequence of steps. +{{role "user"}} +Plan the work for: +{{task}} + diff --git a/pkg/ai/prompt/testdata/options.prompt b/pkg/ai/prompt/testdata/options.prompt new file mode 100644 index 0000000..04e21f0 --- /dev/null +++ b/pkg/ai/prompt/testdata/options.prompt @@ -0,0 +1,26 @@ +--- +model: claude-sonnet-4-6 +config: + maxOutputTokens: 1024 + temperature: 0.2 +maxTurns: 3 +permissions: + mode: acceptEdits + presets: + - edit + tools: + allow: + - Read + - Edit + mcp: + disabled: true +memory: + skipUser: true +budget: + maxTokens: 5000 +--- +{{role "system"}} +You are a careful refactoring assistant. +{{role "user"}} +Refactor: +{{target}} diff --git a/pkg/ai/provider.go b/pkg/ai/provider.go index f121756..c70f2f6 100644 --- a/pkg/ai/provider.go +++ b/pkg/ai/provider.go @@ -1,67 +1,42 @@ package ai import ( - "context" - "fmt" - "strings" + "github.com/flanksource/captain/pkg/api" ) -type Backend string +// Backend is an alias for the canonical api.Backend; the enum/value type and its +// helpers live in pkg/api (the leaf package) so they are the single source of +// truth and pkg/api can carry them without importing pkg/ai. The methods +// (Valid, Kind) and constants are re-exported below so existing call sites and +// clicky/aichat's captainai.Backend keep compiling unchanged. +type Backend = api.Backend const ( - BackendAnthropic Backend = "anthropic" - BackendGemini Backend = "gemini" - BackendOpenAI Backend = "openai" - BackendClaudeCLI Backend = "claude-cli" - BackendCodexCLI Backend = "codex-cli" - BackendGeminiCLI Backend = "gemini-cli" - // BackendClaudeAgent runs the Claude Agent SDK as a long-lived clicky-supervised - // TS process spoken to over JSON-RPC stdio. It replaces the one-shot claude_cli - // (`claude -p`) path for agentic, multi-turn, tool-using runs. - BackendClaudeAgent Backend = "claude-agent" + BackendAnthropic = api.BackendAnthropic + BackendGemini = api.BackendGemini + BackendOpenAI = api.BackendOpenAI + BackendClaudeCLI = api.BackendClaudeCLI + BackendCodexCLI = api.BackendCodexCLI + BackendGeminiCLI = api.BackendGeminiCLI + BackendClaudeAgent = api.BackendClaudeAgent + BackendClaudeCmux = api.BackendClaudeCmux + BackendCodexCmux = api.BackendCodexCmux ) -type Provider interface { - Execute(ctx context.Context, req Request) (*Response, error) - GetModel() string - GetBackend() Backend -} +// AllBackends lists every supported backend in canonical order. +func AllBackends() []Backend { return api.AllBackends() } -type StreamingProvider interface { - Provider - ExecuteStream(ctx context.Context, req Request) (<-chan Event, error) -} +// AuthEnvVars returns the environment variables consulted for a backend's API key. +func AuthEnvVars(b Backend) []string { return api.AuthEnvVars(b) } -func InferBackend(model string) (Backend, error) { - m := strings.ToLower(model) +// BackendList renders AllBackends as a comma-separated string for help/error text. +func BackendList() string { return api.BackendList() } - // CLI backends (check before API backends to avoid prefix conflicts) - if strings.HasPrefix(m, "claude-agent-") { - return BackendClaudeAgent, nil - } - if strings.HasPrefix(m, "claude-code-") { - return BackendClaudeCLI, nil - } - if strings.HasPrefix(m, "codex-") || strings.HasPrefix(m, "codex") { - return BackendCodexCLI, nil - } - if strings.HasPrefix(m, "gemini-cli-") { - return BackendGeminiCLI, nil - } +// Provider and StreamingProvider are the buffered/streaming execution interfaces. +// They live in pkg/api (the stable runtime contract) and are re-exported here so +// existing call sites keep compiling unchanged. +type Provider = api.Provider +type StreamingProvider = api.StreamingProvider - // API backends - if strings.HasPrefix(m, "claude-") { - return BackendAnthropic, nil - } - if strings.HasPrefix(m, "gemini-") || strings.HasPrefix(m, "models/gemini-") { - return BackendGemini, nil - } - if strings.HasPrefix(m, "grok-") { - return BackendCodexCLI, nil - } - if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o1") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") { - return BackendOpenAI, nil - } - - return "", fmt.Errorf("unable to infer backend from model name: %s (pass --backend explicitly: anthropic, gemini, openai, codex-cli, claude-cli, gemini-cli)", model) -} +// InferBackend resolves the backend from a model name prefix (delegates to pkg/api). +func InferBackend(model string) (Backend, error) { return api.InferBackend(model) } diff --git a/pkg/ai/provider/claudeagent/provider.go b/pkg/ai/provider/claudeagent/provider.go index a274ae6..2532ab2 100644 --- a/pkg/ai/provider/claudeagent/provider.go +++ b/pkg/ai/provider/claudeagent/provider.go @@ -23,6 +23,7 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/ai/provider/jsonrpc" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/clicky/exec" "github.com/flanksource/commons/logger" ) @@ -113,7 +114,7 @@ type Provider struct { // New builds a claude-agent provider. The supervised process is started lazily // on the first ExecuteStream. func New(cfg ai.Config) (*Provider, error) { - model := cfg.Model + model := cfg.Model.Name if model == "" { model = defaultModel } @@ -133,7 +134,7 @@ func (p *Provider) GetBackend() ai.Backend { return ai.BackendClaudeAgent } // Execute drains its own ExecuteStream into a buffered ai.Response. func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { - if req.StructuredOutput != nil { + if req.Prompt.Schema != nil { return nil, fmt.Errorf("claude-agent does not support StructuredOutput") } start := time.Now() @@ -200,7 +201,7 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e // events back. Turns are serialized via turnMu so the single SDK session is // never driven by two prompts at once. func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai.Event, error) { - if req.StructuredOutput != nil { + if req.Prompt.Schema != nil { return nil, fmt.Errorf("claude-agent stream mode does not support StructuredOutput") } if err := p.ensureStarted(req); err != nil { @@ -302,11 +303,12 @@ func (p *Provider) initializeParams(req ai.Request) initializeParams { // brokered means the caller wants to vet each tool over the can_use_tool // round-trip, so the SDK must consult canUseTool instead of auto-approving: // bypassPermissions / allowDangerouslySkipPermissions would skip it entirely. - brokered := req.CanUseTool != nil + // The broker callback is a runtime concern, carried on the provider's Config. + brokered := p.cfg.CanUseTool != nil - mode := req.PermissionMode - allowed := req.AllowedTools - if req.Edit { + mode := string(req.Permissions.Mode) + allowed := req.Permissions.Tools.Allow + if req.Permissions.HasPreset(api.PresetEdit) { if mode == "" { mode = "acceptEdits" } @@ -333,13 +335,13 @@ func (p *Provider) initializeParams(req ai.Request) initializeParams { } return initializeParams{ - Cwd: req.Cwd, + Cwd: req.Context.Dir, Model: aliasModel(p.model), - SystemPrompt: req.SystemPrompt, - AppendSystemPrompt: req.AppendSystemPrompt, + SystemPrompt: req.Prompt.System, + AppendSystemPrompt: req.Prompt.AppendSystem, AllowedTools: allowed, MaxTurns: req.MaxTurns, - MaxBudgetUsd: p.cfg.BudgetUSD, + MaxBudgetUsd: p.cfg.Budget.Cost, PermissionMode: mode, Resume: resume, ApprovalMode: approvalMode, diff --git a/pkg/ai/provider/claudeagent/provider_test.go b/pkg/ai/provider/claudeagent/provider_test.go index ba7a5d6..2d5eec2 100644 --- a/pkg/ai/provider/claudeagent/provider_test.go +++ b/pkg/ai/provider/claudeagent/provider_test.go @@ -5,10 +5,12 @@ import ( "context" "encoding/json" "os" + "path/filepath" "testing" "time" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/clicky/exec" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,6 +21,15 @@ import ( // without npm, tsx or a real claude binary. const fakeServerEnv = "CLAUDEAGENT_FAKE_SERVER" +// fakeModeEnv selects the fake server's turn behaviour: "" runs the default +// happy-path turn; "approval" emits a can_use_tool request and finishes only +// after the host replies; "hang" emits text then waits for an interrupt. +const fakeModeEnv = "CLAUDEAGENT_FAKE_MODE" + +// fakeMarkerEnv, when set in "hang" mode, names a file the fake creates when it +// receives an interrupt — proof the graceful control request arrived (no kill). +const fakeMarkerEnv = "CLAUDEAGENT_FAKE_MARKER" + func TestMain(m *testing.M) { if os.Getenv(fakeServerEnv) == "1" { runFakeServer() @@ -29,8 +40,13 @@ func TestMain(m *testing.M) { // runFakeServer speaks the agent.ts JSON-RPC protocol over stdio: it answers // initialize/prompt/interrupt/shutdown and pushes a scripted set of turn -// notifications so the Go provider has a realistic stream to map. +// notifications so the Go provider has a realistic stream to map. The turn shape +// is selected by fakeModeEnv so a single binary covers the happy path, the +// can_use_tool round-trip, and the interrupt-without-kill path. func runFakeServer() { + mode := os.Getenv(fakeModeEnv) + marker := os.Getenv(fakeMarkerEnv) + enc := func(obj map[string]any) { b, _ := json.Marshal(obj) _, _ = os.Stdout.Write(append(b, '\n')) @@ -41,44 +57,74 @@ func runFakeServer() { } return raw } + completed := func(resultText string) { + enc(map[string]any{"jsonrpc": "2.0", "method": "turn/completed", "params": map[string]any{ + "success": true, "session_id": "fake-sess", "cost_usd": 0.01, + "result_text": resultText, + "usage": map[string]any{"input_tokens": 10, "output_tokens": 5}, + }}) + } scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) for scanner.Scan() { - var req struct { + var frame struct { ID json.RawMessage `json:"id"` Method string `json:"method"` + Result json.RawMessage `json:"result"` } - if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { + if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil { continue } - switch req.Method { + // The host's reply to our can_use_tool request (id, result, no method) + // finishes the approval turn, echoing the decision so the test can verify + // the round-trip reached the agent. + if frame.Method == "" && len(frame.Result) > 0 { + completed("decision=" + string(frame.Result)) + continue + } + switch frame.Method { case "initialize": - enc(map[string]any{"jsonrpc": "2.0", "id": id(req.ID), "result": map[string]any{"ok": true}}) + enc(map[string]any{"jsonrpc": "2.0", "id": id(frame.ID), "result": map[string]any{"ok": true}}) enc(map[string]any{"jsonrpc": "2.0", "method": "session/init", "params": map[string]any{ "session_id": "fake-sess", "model": "claude-sonnet-4-5", "tools": []string{"Read", "Bash"}, }}) case "prompt": - enc(map[string]any{"jsonrpc": "2.0", "id": id(req.ID), "result": map[string]any{"accepted": true}}) + enc(map[string]any{"jsonrpc": "2.0", "id": id(frame.ID), "result": map[string]any{"accepted": true}}) enc(map[string]any{"jsonrpc": "2.0", "method": "message/text", "params": map[string]any{"text": "hi from fake"}}) - enc(map[string]any{"jsonrpc": "2.0", "method": "message/tool_use", "params": map[string]any{ - "tool": "Read", "id": "t1", "input": map[string]any{"file_path": "/x"}, - }}) - enc(map[string]any{"jsonrpc": "2.0", "method": "turn/completed", "params": map[string]any{ - "success": true, "session_id": "fake-sess", "cost_usd": 0.01, - "result_text": "hi from fake", - "usage": map[string]any{"input_tokens": 10, "output_tokens": 5}, - }}) + switch mode { + case "approval": + // Ask the host to vet a Bash tool use; the turn completes when the + // host replies (handled above). + enc(map[string]any{"jsonrpc": "2.0", "id": "perm-1", "method": "can_use_tool", "params": map[string]any{ + "tool": "Bash", "input": map[string]any{"command": "ls"}, "tool_use_id": "tu1", + }}) + case "hang": + // Emit nothing more; wait for the interrupt control request. + default: + enc(map[string]any{"jsonrpc": "2.0", "method": "message/tool_use", "params": map[string]any{ + "tool": "Read", "id": "t1", "input": map[string]any{"file_path": "/x"}, + }}) + completed("hi from fake") + } case "interrupt": - enc(map[string]any{"jsonrpc": "2.0", "id": id(req.ID), "result": map[string]any{}}) + if marker != "" { + _ = os.WriteFile(marker, []byte("interrupted"), 0o644) + } + enc(map[string]any{"jsonrpc": "2.0", "id": id(frame.ID), "result": map[string]any{}}) case "shutdown": - enc(map[string]any{"jsonrpc": "2.0", "id": id(req.ID), "result": map[string]any{}}) + enc(map[string]any{"jsonrpc": "2.0", "id": id(frame.ID), "result": map[string]any{}}) os.Exit(0) } } } func withFakeAgentProcess(t *testing.T) { + t.Helper() + withFakeAgentProcessEnv(t, map[string]string{fakeServerEnv: "1"}) +} + +func withFakeAgentProcessEnv(t *testing.T, env map[string]string) { t.Helper() self, err := os.Executable() require.NoError(t, err) @@ -87,7 +133,7 @@ func withFakeAgentProcess(t *testing.T) { newAgentProcess = func(*Provider) (*exec.Process, error) { return exec.NewExec(self). WithStdioPipe(). - WithEnv(map[string]string{fakeServerEnv: "1"}), nil + WithEnv(env), nil } t.Cleanup(func() { newAgentProcess = orig }) } @@ -95,7 +141,7 @@ func withFakeAgentProcess(t *testing.T) { func TestProvider_StreamLifecycle(t *testing.T) { withFakeAgentProcess(t) - p, err := New(ai.Config{Model: "claude-agent-sonnet"}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) @@ -105,7 +151,7 @@ func TestProvider_StreamLifecycle(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - events, err := p.ExecuteStream(ctx, ai.Request{Prompt: "hello"}) + events, err := p.ExecuteStream(ctx, ai.Request{Prompt: api.Prompt{User: "hello"}}) require.NoError(t, err) var kinds []ai.EventKind @@ -131,14 +177,14 @@ func TestProvider_StreamLifecycle(t *testing.T) { func TestProvider_ExecuteCoalesce(t *testing.T) { withFakeAgentProcess(t) - p, err := New(ai.Config{Model: "claude-agent-sonnet"}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - resp, err := p.Execute(ctx, ai.Request{Prompt: "hello"}) + resp, err := p.Execute(ctx, ai.Request{Prompt: api.Prompt{User: "hello"}}) require.NoError(t, err) assert.Equal(t, "hi from fake", resp.Text) assert.Equal(t, ai.BackendClaudeAgent, resp.Backend) @@ -148,7 +194,7 @@ func TestProvider_ExecuteCoalesce(t *testing.T) { func TestProvider_MultiTurnSerialized(t *testing.T) { withFakeAgentProcess(t) - p, err := New(ai.Config{Model: "claude-agent-sonnet"}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) @@ -157,7 +203,7 @@ func TestProvider_MultiTurnSerialized(t *testing.T) { // Two sequential turns over the same supervised session must both complete. for turn := 0; turn < 2; turn++ { - events, err := p.ExecuteStream(ctx, ai.Request{Prompt: "go"}) + events, err := p.ExecuteStream(ctx, ai.Request{Prompt: api.Prompt{User: "go"}}) require.NoError(t, err) var sawResult bool for ev := range events { @@ -169,3 +215,105 @@ func TestProvider_MultiTurnSerialized(t *testing.T) { assert.Truef(t, sawResult, "turn %d should produce a result", turn) } } + +// TestProvider_CanUseTool drives the can_use_tool control round-trip end to end: +// the fake agent emits a can_use_tool request, the provider routes it to the +// request's CanUseTool callback, surfaces an EventPermission, and the decision +// round-trips back to the agent (which echoes it into the result). +func TestProvider_CanUseTool(t *testing.T) { + withFakeAgentProcessEnv(t, map[string]string{fakeServerEnv: "1", fakeModeEnv: "approval"}) + + var gotReq ai.PermissionRequest + called := make(chan struct{}, 1) + canUseTool := func(_ context.Context, r ai.PermissionRequest) (ai.PermissionDecision, error) { + gotReq = r + called <- struct{}{} + return ai.PermissionDecision{Allow: true, UpdatedInput: map[string]any{"command": "ls -la"}}, nil + } + + // CanUseTool is a runtime concern, set on the provider's Config (not the request). + p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}, CanUseTool: canUseTool}) + require.NoError(t, err) + t.Cleanup(func() { _ = p.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + events, err := p.ExecuteStream(ctx, ai.Request{Prompt: api.Prompt{User: "use a tool"}}) + require.NoError(t, err) + + var sawPermission bool + var permEvent ai.Event + var result *ai.Event + for ev := range events { + switch ev.Kind { + case ai.EventPermission: + sawPermission = true + permEvent = ev + case ai.EventResult: + cp := ev + result = &cp + } + } + + // The callback fired with the requested tool and the live session id, and the + // fake's input was forwarded verbatim. + select { + case <-called: + default: + t.Fatal("CanUseTool callback was not invoked") + } + assert.Equal(t, "Bash", gotReq.Tool) + assert.Equal(t, "tu1", gotReq.ToolUseID) + assert.Equal(t, "fake-sess", gotReq.SessionID) + assert.Equal(t, "ls", gotReq.Input["command"]) + + // The same request surfaced as an observable EventPermission. + assert.True(t, sawPermission, "expected an EventPermission") + assert.Equal(t, "Bash", permEvent.Tool) + + // The allow decision (with the updated input) round-tripped back to the agent. + require.NotNil(t, result, "expected a terminal result event") + assert.True(t, result.Success) + resultText, _ := result.Input["result"].(string) + assert.Contains(t, resultText, `"allow":true`) + assert.Contains(t, resultText, "ls -la") +} + +// TestProvider_InterruptNoKill verifies a cancelled turn is ended by the +// interrupt control request — the fake records receiving it — rather than by +// killing the process. +func TestProvider_InterruptNoKill(t *testing.T) { + marker := filepath.Join(t.TempDir(), "interrupted") + withFakeAgentProcessEnv(t, map[string]string{ + fakeServerEnv: "1", + fakeModeEnv: "hang", + fakeMarkerEnv: marker, + }) + + p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) + require.NoError(t, err) + t.Cleanup(func() { _ = p.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + events, err := p.ExecuteStream(ctx, ai.Request{Prompt: api.Prompt{User: "go"}}) + require.NoError(t, err) + + var sawError bool + for ev := range events { + if ev.Kind == ai.EventText { + // The turn is now hanging; cancelling triggers the interrupt path. + cancel() + } + if ev.Kind == ai.EventError { + sawError = true + } + } + + assert.True(t, sawError, "cancelled turn should surface an error event") + // interrupt() completes before the error is emitted, so by now the fake has + // written the marker — proof the graceful interrupt reached it. + require.FileExists(t, marker) +} diff --git a/pkg/ai/provider/claudeagent/turn.go b/pkg/ai/provider/claudeagent/turn.go index cc70cfd..89402c2 100644 --- a/pkg/ai/provider/claudeagent/turn.go +++ b/pkg/ai/provider/claudeagent/turn.go @@ -32,7 +32,7 @@ type promptParams struct { } func composePrompt(req ai.Request) string { - return req.Prompt + return req.Prompt.User } // runTurn owns one turn's event channel: it sends the prompt, forwards mapped @@ -49,7 +49,7 @@ func (p *Provider) runTurn(ctx context.Context, req ai.Request, events chan ai.E term: make(chan struct{}), quit: make(chan struct{}), ctx: ctx, - canUseTool: req.CanUseTool, + canUseTool: p.cfg.CanUseTool, } p.setActive(ts) defer func() { diff --git a/pkg/ai/provider/cli.go b/pkg/ai/provider/cli.go index fcbcd8a..898b56a 100644 --- a/pkg/ai/provider/cli.go +++ b/pkg/ai/provider/cli.go @@ -2,10 +2,10 @@ package provider import ( "bufio" + "bytes" "context" "errors" "fmt" - "io" "os/exec" "strings" @@ -73,21 +73,24 @@ func HandleExitError(exitCode int, stderr string) error { } } -func runCLI(ctx context.Context, command string, stdinData []byte) (stdout []byte, stderr string, err error) { +func runCLI(ctx context.Context, command string, stdinData []byte, cwd string) (stdout []byte, stderr string, err error) { cmd := exec.CommandContext(ctx, command) + if cwd != "" { + cmd.Dir = cwd + } + + // Buffer stdout/stderr instead of using StdoutPipe/StderrPipe: cmd.Wait closes + // those pipes when the process exits, so reading them in a goroutine that races + // Wait fails with "file already closed" (deterministic on Linux). With buffers, + // Wait blocks until the os/exec output copiers finish, making the reads safe. + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf stdin, 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 { if IsCommandNotFound(err) { @@ -96,60 +99,24 @@ func runCLI(ctx context.Context, command string, stdinData []byte) (stdout []byt return nil, "", fmt.Errorf("failed to start %s: %w", command, err) } - if _, err := stdin.Write(stdinData); err != nil { - return nil, "", fmt.Errorf("failed to write to stdin: %w", err) - } - _, _ = stdin.Write([]byte("\n")) - _ = stdin.Close() - - stdoutCh := make(chan []byte, 1) - stderrCh := make(chan string, 1) - errCh := make(chan error, 2) - + // Feed stdin in the background so a large prompt cannot deadlock against a + // process that only drains stdin after producing its output. go func() { - data, err := io.ReadAll(stdoutPipe) - if err != nil { - errCh <- fmt.Errorf("failed to read stdout: %w", err) - return - } - stdoutCh <- data + _, _ = stdin.Write(stdinData) + _, _ = stdin.Write([]byte("\n")) + _ = stdin.Close() }() - go func() { - data, err := io.ReadAll(stderrPipe) - if err != nil { - errCh <- fmt.Errorf("failed to read stderr: %w", err) - return - } - stderrCh <- string(data) - }() - - waitCh := make(chan error, 1) - go func() { waitCh <- cmd.Wait() }() - - var stdoutData []byte - var stderrData string - var waitErr error - - for range 3 { - select { - case <-ctx.Done(): - _ = cmd.Process.Kill() - return nil, "", fmt.Errorf("%w: context cancelled", ai.ErrTimeout) - case e := <-errCh: - return nil, "", e - case data := <-stdoutCh: - stdoutData = data - case data := <-stderrCh: - stderrData = data - case e := <-waitCh: - waitErr = e - } + // CommandContext kills the process when ctx is cancelled, which unblocks Wait. + waitErr := cmd.Wait() + if ctx.Err() != nil { + return nil, "", fmt.Errorf("%w: context cancelled", ai.ErrTimeout) } + stderrData := stderrBuf.String() if waitErr != nil { return nil, stderrData, HandleExitError(GetExitCode(waitErr), ParseStderr(stderrData)) } - return stdoutData, stderrData, nil + return stdoutBuf.Bytes(), stderrData, nil } diff --git a/pkg/ai/provider/cli_test.go b/pkg/ai/provider/cli_test.go index 38f1946..335aa90 100644 --- a/pkg/ai/provider/cli_test.go +++ b/pkg/ai/provider/cli_test.go @@ -1,6 +1,15 @@ package provider -import "testing" +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) func TestParseStderr(t *testing.T) { tests := []struct { @@ -22,3 +31,66 @@ func TestParseStderr(t *testing.T) { }) } } + +func TestRunCLIUsesContextDir(t *testing.T) { + cwd := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(cwd, 0o755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + bin := filepath.Join(t.TempDir(), "fake-cli") + if err := os.WriteFile(bin, []byte("#!/bin/sh\npwd\ncat >/dev/null\n"), 0o755); err != nil { + t.Fatalf("write fake cli: %v", err) + } + + stdout, _, err := runCLI(context.Background(), bin, []byte("input"), cwd) + if err != nil { + t.Fatalf("runCLI: %v", err) + } + got, err := filepath.EvalSymlinks(filepath.Clean(strings.TrimSpace(string(stdout)))) + if err != nil { + t.Fatalf("eval pwd: %v", err) + } + want, err := filepath.EvalSymlinks(cwd) + if err != nil { + t.Fatalf("eval cwd: %v", err) + } + if got != want { + t.Errorf("pwd = %q, want %q", got, want) + } +} + +func TestGeminiCLIUsesContextDir(t *testing.T) { + cwd := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(cwd, 0o755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + binDir := t.TempDir() + gemini := filepath.Join(binDir, "gemini") + script := "#!/bin/sh\nprintf '{\"text\":\"%s\",\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}\\n' \"$(pwd)\"\ncat >/dev/null\n" + if err := os.WriteFile(gemini, []byte(script), 0o755); err != nil { + t.Fatalf("write fake gemini: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + resp, err := NewGeminiCLI("gemini-cli-pro").Execute(context.Background(), ai.Request{ + Prompt: api.Prompt{User: "hello"}, + Context: api.Context{Dir: cwd}, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + got, err := filepath.EvalSymlinks(filepath.Clean(resp.Text)) + if err != nil { + t.Fatalf("eval response text: %v", err) + } + want, err := filepath.EvalSymlinks(cwd) + if err != nil { + t.Fatalf("eval cwd: %v", err) + } + if got != want { + t.Errorf("response text = %q, want %q", got, want) + } + if resp.Usage.InputTokens != 1 || resp.Usage.OutputTokens != 2 { + t.Errorf("usage = %+v, want input=1 output=2", resp.Usage) + } +} diff --git a/pkg/ai/provider/cmux/client.go b/pkg/ai/provider/cmux/client.go new file mode 100644 index 0000000..de78ccf --- /dev/null +++ b/pkg/ai/provider/cmux/client.go @@ -0,0 +1,581 @@ +package cmux + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/flanksource/clicky" +) + +const defaultCommandTimeout = 30 * time.Second + +// Runner executes the cmux CLI. Tests inject this to assert command arguments +// without requiring a live cmux.app instance. +type Runner func(ctx context.Context, cwd, binary string, timeout time.Duration, args ...string) (stdout string, err error) + +type Client struct { + Binary string + Timeout time.Duration + Runner Runner +} + +type NewWorkspaceOpts struct { + Cwd string + Name string + Description string + Command string + Focus bool + ResolveSurface bool +} + +type EnsureWorkspaceOpts struct { + Cwd string + Name string + Description string + Focus bool +} + +type NewSurfaceOpts struct { + WorkspaceRef string + Cwd string + SurfaceType string + Focus bool +} + +type ReadScreenOpts struct { + WorkspaceRef string + SurfaceRef string + Lines int + Scrollback bool +} + +type WorkspaceRef struct { + WorkspaceID string + SurfaceID string + Raw string +} + +func NewClient(binary string) *Client { + return &Client{Binary: binary} +} + +func (c *Client) Available(ctx context.Context) error { + if _, err := c.run(ctx, "", "ping"); err != nil { + return fmt.Errorf("cmux is not available or not running: %w", err) + } + return nil +} + +func (c *Client) NewWorkspace(ctx context.Context, opts NewWorkspaceOpts) (WorkspaceRef, error) { + name := strings.TrimSpace(opts.Name) + if name == "" { + name = filepath.Base(filepath.Clean(opts.Cwd)) + } + if name == "." || name == string(filepath.Separator) { + name = "gavel-todos" + } + + args := []string{ + "new-workspace", + "--cwd", opts.Cwd, + "--name", name, + "--focus", strconv.FormatBool(opts.Focus), + } + if opts.Description != "" { + args = append(args, "--description", opts.Description) + } + if opts.Command != "" { + args = append(args, "--command", opts.Command) + } + args = append(args, "--id-format", "both") + + stdout, err := c.run(ctx, opts.Cwd, args...) + if err != nil { + return WorkspaceRef{}, err + } + ref := ParseWorkspaceRef(stdout) + if ref.String() == "" { + return WorkspaceRef{}, fmt.Errorf("cmux new-workspace did not return a workspace reference: %q", strings.TrimSpace(stdout)) + } + if opts.ResolveSurface && ref.SurfaceID == "" { + surfaceID, err := c.ResolveSurface(ctx, ref.String()) + if err != nil { + return WorkspaceRef{}, err + } + ref.SurfaceID = surfaceID + } + return ref, nil +} + +func (c *Client) EnsureWorkspace(ctx context.Context, opts EnsureWorkspaceOpts) (WorkspaceRef, bool, error) { + name := strings.TrimSpace(opts.Name) + if name == "" { + name = filepath.Base(filepath.Clean(opts.Cwd)) + } + if name == "." || name == string(filepath.Separator) { + name = "gavel-todos" + } + + ref, found, err := c.FindWorkspace(ctx, name, opts.Cwd) + if err != nil { + return WorkspaceRef{}, false, err + } + if found { + return ref, true, nil + } + + created, err := c.NewWorkspace(ctx, NewWorkspaceOpts{ + Cwd: opts.Cwd, + Name: name, + Description: opts.Description, + Focus: opts.Focus, + }) + if err != nil { + return WorkspaceRef{}, false, err + } + return created, false, nil +} + +// SelectWorkspace switches cmux to the given workspace (the documented +// `select-workspace` command), bringing its terminal to the front so the user +// can watch or take over the agent session running there. +func (c *Client) SelectWorkspace(ctx context.Context, workspaceRef string) error { + if strings.TrimSpace(workspaceRef) == "" { + return fmt.Errorf("workspace reference is required") + } + _, err := c.run(ctx, "", "select-workspace", "--workspace", workspaceRef) + return err +} + +func (c *Client) FindWorkspace(ctx context.Context, name, cwd string) (WorkspaceRef, bool, error) { + workspaces, err := c.ListWorkspaces(ctx) + if err != nil { + return WorkspaceRef{}, false, err + } + wantName := strings.TrimSpace(name) + wantCwd := cleanPath(cwd) + for _, workspace := range workspaces { + if strings.TrimSpace(workspace.Ref) == "" { + continue + } + if workspace.DisplayTitle() != wantName { + continue + } + if wantCwd != "" && cleanPath(workspace.CurrentDirectory) != wantCwd { + continue + } + return WorkspaceRef{WorkspaceID: strings.TrimSpace(workspace.Ref), Raw: strings.TrimSpace(workspace.Ref)}, true, nil + } + return WorkspaceRef{}, false, nil +} + +func (c *Client) ListWorkspaces(ctx context.Context) ([]cmuxListedWorkspace, error) { + raw, err := c.run(ctx, "", "list-workspaces", "--json") + if err != nil { + return nil, err + } + var list cmuxWorkspaceList + if err := json.Unmarshal([]byte(jsonPayload(raw)), &list); err != nil { + return nil, fmt.Errorf("parse cmux list-workspaces output: %w", err) + } + return list.Workspaces, nil +} + +func (c *Client) NewSurface(ctx context.Context, opts NewSurfaceOpts) (WorkspaceRef, error) { + workspaceRef := strings.TrimSpace(opts.WorkspaceRef) + if workspaceRef == "" { + return WorkspaceRef{}, fmt.Errorf("workspace reference is required") + } + surfaceType := strings.TrimSpace(opts.SurfaceType) + if surfaceType == "" { + surfaceType = "terminal" + } + args := []string{ + "new-surface", + "--type", surfaceType, + "--workspace", workspaceRef, + } + if opts.Cwd != "" { + args = append(args, "--working-directory", opts.Cwd) + } + args = append(args, "--focus", strconv.FormatBool(opts.Focus)) + + stdout, err := c.run(ctx, opts.Cwd, args...) + if err != nil { + return WorkspaceRef{}, err + } + surfaceID := ParseSurfaceRef(stdout) + if surfaceID == "" { + surfaceID, err = c.ResolveSurface(ctx, workspaceRef) + if err != nil { + return WorkspaceRef{}, err + } + } + return WorkspaceRef{WorkspaceID: workspaceRef, SurfaceID: surfaceID, Raw: strings.TrimSpace(stdout)}, nil +} + +func (c *Client) Send(ctx context.Context, workspaceRef, text string) error { + if strings.TrimSpace(workspaceRef) == "" { + return fmt.Errorf("workspace reference is required") + } + _, err := c.run(ctx, "", "send", "--workspace", workspaceRef, "--", text) + return err +} + +func (c *Client) SendSurface(ctx context.Context, workspaceRef, surfaceRef, text string) error { + if strings.TrimSpace(workspaceRef) == "" { + return fmt.Errorf("workspace reference is required") + } + if strings.TrimSpace(surfaceRef) == "" { + return fmt.Errorf("surface reference is required") + } + _, err := c.run(ctx, "", "send", "--workspace", workspaceRef, "--surface", surfaceRef, "--", text) + return err +} + +func (c *Client) SendKeySurface(ctx context.Context, workspaceRef, surfaceRef, key string) error { + if strings.TrimSpace(workspaceRef) == "" { + return fmt.Errorf("workspace reference is required") + } + if strings.TrimSpace(surfaceRef) == "" { + return fmt.Errorf("surface reference is required") + } + if strings.TrimSpace(key) == "" { + return fmt.Errorf("key is required") + } + _, err := c.run(ctx, "", "send-key", "--workspace", workspaceRef, "--surface", surfaceRef, strings.TrimSpace(key)) + return err +} + +func (c *Client) ReadScreen(ctx context.Context, opts ReadScreenOpts) (string, error) { + if strings.TrimSpace(opts.SurfaceRef) != "" && strings.TrimSpace(opts.WorkspaceRef) == "" { + return "", fmt.Errorf("workspace reference is required when surface reference is set") + } + args := []string{"read-screen"} + if strings.TrimSpace(opts.WorkspaceRef) != "" { + args = append(args, "--workspace", opts.WorkspaceRef) + } + if strings.TrimSpace(opts.SurfaceRef) != "" { + args = append(args, "--surface", opts.SurfaceRef) + } + if opts.Lines > 0 { + args = append(args, "--lines", strconv.Itoa(opts.Lines)) + } else if opts.Scrollback { + args = append(args, "--scrollback") + } + return c.run(ctx, "", args...) +} + +func (c *Client) ResolveSurface(ctx context.Context, workspaceRef string) (string, error) { + if strings.TrimSpace(workspaceRef) == "" { + return "", fmt.Errorf("workspace reference is required") + } + raw, err := c.run(ctx, "", "tree", "--json", "--workspace", workspaceRef) + if err != nil { + return "", err + } + surfaceID := surfaceRefFromTree(raw) + if surfaceID == "" { + return "", fmt.Errorf("cmux tree did not return a surface id for workspace %s", workspaceRef) + } + return surfaceID, nil +} + +func (r WorkspaceRef) String() string { + if r.WorkspaceID != "" { + return r.WorkspaceID + } + if r.Raw != "" { + return r.Raw + } + return r.SurfaceID +} + +func ParseWorkspaceRef(stdout string) WorkspaceRef { + raw := strings.TrimSpace(stdout) + if raw == "" { + return WorkspaceRef{} + } + if isWorkspaceRef(raw) { + return WorkspaceRef{WorkspaceID: raw, Raw: raw} + } + if ref := regexp.MustCompile(`workspace:[A-Za-z0-9._:/-]+`).FindString(raw); ref != "" { + return WorkspaceRef{WorkspaceID: ref, Raw: raw} + } + + var m map[string]any + if err := json.Unmarshal([]byte(jsonPayload(raw)), &m); err == nil { + ref := WorkspaceRef{ + WorkspaceID: firstString(m, "workspace_id", "workspaceId", "workspace", "id"), + SurfaceID: firstString(m, "surface_id", "surfaceId", "surface_ref", "surfaceRef", "surface"), + Raw: raw, + } + if ref.String() != "" { + return ref + } + } + + ref := WorkspaceRef{ + WorkspaceID: matchRef(raw, `(?i)workspace(?:[_ -]?id)?["'=:\s]+([A-Za-z0-9._:/-]+)`), + SurfaceID: matchRef(raw, `(?i)surface(?:[_ -]?id)?["'=:\s]+([A-Za-z0-9._:/-]+)`), + Raw: raw, + } + if ref.String() != "" && ref.WorkspaceID != raw { + return ref + } + + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line != "" { + ref.Raw = line + return ref + } + } + return ref +} + +func ParseSurfaceRef(stdout string) string { + raw := strings.TrimSpace(stdout) + if raw == "" { + return "" + } + + var m map[string]any + if err := json.Unmarshal([]byte(jsonPayload(raw)), &m); err == nil { + for _, candidate := range []string{ + firstString(m, "surface_id", "surfaceId", "surface_ref", "surfaceRef", "surface", "ref", "id"), + } { + if isSurfaceRef(candidate) { + return candidate + } + } + } + + if ref := regexp.MustCompile(`surface:[A-Za-z0-9._:/-]+`).FindString(raw); ref != "" { + return ref + } + if isSurfaceRef(raw) { + return raw + } + return "" +} + +func (c *Client) run(ctx context.Context, cwd string, args ...string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + binary := c.Binary + if binary == "" { + binary = "cmux" + } + timeout := c.Timeout + if timeout <= 0 { + timeout = defaultCommandTimeout + } + runner := c.Runner + if runner == nil { + runner = defaultRunner + } + return runner(ctx, cwd, binary, timeout, args...) +} + +func defaultRunner(_ context.Context, cwd, binary string, timeout time.Duration, args ...string) (string, error) { + proc := clicky.Exec(binary, args...).WithTimeout(timeout) + if cwd != "" { + proc = proc.WithCwd(cwd) + } + result := proc.Run().Result() + if result.Error != nil || result.ExitCode != 0 { + msg := strings.TrimSpace(result.Stderr) + if msg == "" { + msg = strings.TrimSpace(result.Stdout) + } + if msg == "" && result.Error != nil { + msg = result.Error.Error() + } + return result.Stdout, fmt.Errorf("%s %s failed (exit %d): %s", binary, strings.Join(args, " "), result.ExitCode, msg) + } + return result.Stdout, nil +} + +func matchRef(s, pattern string) string { + m := regexp.MustCompile(pattern).FindStringSubmatch(s) + if len(m) < 2 { + return "" + } + return strings.Trim(m[1], `"'`) +} + +func firstString(m map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := m[key]; ok { + switch typed := value.(type) { + case string: + if strings.TrimSpace(typed) != "" { + return strings.TrimSpace(typed) + } + case fmt.Stringer: + if strings.TrimSpace(typed.String()) != "" { + return strings.TrimSpace(typed.String()) + } + case float64: + return strconv.FormatInt(int64(typed), 10) + } + } + } + return "" +} + +func surfaceRefFromTree(raw string) string { + var tree cmuxTree + if err := json.Unmarshal([]byte(raw), &tree); err != nil { + return "" + } + if isSurfaceRef(tree.Active.SurfaceRef) { + return tree.Active.SurfaceRef + } + for _, window := range tree.Windows { + for _, workspace := range window.Workspaces { + if surfaceID := selectedSurfaceFromWorkspace(workspace); surfaceID != "" { + return surfaceID + } + } + } + return "" +} + +func selectedSurfaceFromWorkspace(workspace cmuxTreeWorkspace) string { + for _, pane := range workspace.Panes { + if pane.Active || pane.Focused { + if isSurfaceRef(pane.SelectedSurfaceRef) { + return pane.SelectedSurfaceRef + } + if surfaceID := selectedSurfaceFromPane(pane); surfaceID != "" { + return surfaceID + } + } + } + for _, pane := range workspace.Panes { + if isSurfaceRef(pane.SelectedSurfaceRef) { + return pane.SelectedSurfaceRef + } + if surfaceID := selectedSurfaceFromPane(pane); surfaceID != "" { + return surfaceID + } + for _, surfaceID := range pane.SurfaceRefs { + if isSurfaceRef(surfaceID) { + return surfaceID + } + } + } + return "" +} + +func selectedSurfaceFromPane(pane cmuxTreePane) string { + for _, surface := range pane.Surfaces { + if (surface.Active || surface.Focused || surface.Selected || surface.SelectedInPane) && isSurfaceRef(surface.Ref) { + return surface.Ref + } + } + for _, surface := range pane.Surfaces { + if isSurfaceRef(surface.Ref) { + return surface.Ref + } + } + return "" +} + +func isSurfaceRef(ref string) bool { + return strings.HasPrefix(strings.TrimSpace(ref), "surface:") +} + +func isWorkspaceRef(ref string) bool { + return strings.HasPrefix(strings.TrimSpace(ref), "workspace:") +} + +func cleanPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + return filepath.Clean(path) +} + +func jsonPayload(raw string) string { + trimmed := strings.TrimSpace(raw) + object := strings.Index(trimmed, "{") + array := strings.Index(trimmed, "[") + switch { + case object == -1: + if array >= 0 { + return trimmed[array:] + } + case array == -1: + return trimmed[object:] + case object < array: + return trimmed[object:] + default: + return trimmed[array:] + } + return trimmed +} + +type cmuxWorkspaceList struct { + Workspaces []cmuxListedWorkspace `json:"workspaces"` +} + +type cmuxListedWorkspace struct { + CurrentDirectory string `json:"current_directory"` + CustomTitle string `json:"custom_title"` + Ref string `json:"ref"` + Title string `json:"title"` +} + +func (w cmuxListedWorkspace) DisplayTitle() string { + if strings.TrimSpace(w.CustomTitle) != "" { + return strings.TrimSpace(w.CustomTitle) + } + return strings.TrimSpace(w.Title) +} + +type cmuxTree struct { + Active cmuxTreeActive `json:"active"` + Windows []cmuxTreeWindow `json:"windows"` +} + +type cmuxTreeActive struct { + SurfaceRef string `json:"surface_ref"` +} + +type cmuxTreeWindow struct { + Workspaces []cmuxTreeWorkspace `json:"workspaces"` +} + +type cmuxTreeWorkspace struct { + Panes []cmuxTreePane `json:"panes"` +} + +type cmuxTreePane struct { + Active bool `json:"active"` + Focused bool `json:"focused"` + SelectedSurfaceRef string `json:"selected_surface_ref"` + SurfaceRefs []string `json:"surface_refs"` + Surfaces []cmuxTreeSurface `json:"surfaces"` +} + +type cmuxTreeSurface struct { + Active bool `json:"active"` + Focused bool `json:"focused"` + Ref string `json:"ref"` + Selected bool `json:"selected"` + SelectedInPane bool `json:"selected_in_pane"` +} diff --git a/pkg/ai/provider/cmux/client_test.go b/pkg/ai/provider/cmux/client_test.go new file mode 100644 index 0000000..15c67b0 --- /dev/null +++ b/pkg/ai/provider/cmux/client_test.go @@ -0,0 +1,224 @@ +package cmux + +import ( + "context" + "reflect" + "strings" + "testing" + "time" +) + +type runnerCall struct { + cwd string + binary string + args []string +} + +type recordingRunner struct { + calls []runnerCall + out map[string]string +} + +func (r *recordingRunner) run(_ context.Context, cwd, binary string, _ time.Duration, args ...string) (string, error) { + call := runnerCall{cwd: cwd, binary: binary, args: append([]string(nil), args...)} + r.calls = append(r.calls, call) + if r.out != nil { + if stdout, ok := r.out[joinArgs(args)]; ok { + return stdout, nil + } + } + return "ok", nil +} + +func TestParseWorkspaceRef(t *testing.T) { + cases := []struct { + name string + out string + want WorkspaceRef + }{ + { + name: "json", + out: `{"workspaceId":"ws-json","surfaceId":"sf-json"}`, + want: WorkspaceRef{WorkspaceID: "ws-json", SurfaceID: "sf-json", Raw: `{"workspaceId":"ws-json","surfaceId":"sf-json"}`}, + }, + { + name: "labeled text", + out: "created workspace=ws-text surface=sf-text", + want: WorkspaceRef{WorkspaceID: "ws-text", SurfaceID: "sf-text", Raw: "created workspace=ws-text surface=sf-text"}, + }, + { + name: "plain ref", + out: "ws-plain\n", + want: WorkspaceRef{Raw: "ws-plain"}, + }, + { + name: "cmux workspace ref", + out: "OK workspace:22\n", + want: WorkspaceRef{WorkspaceID: "workspace:22", Raw: "OK workspace:22"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ParseWorkspaceRef(tc.out); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ParseWorkspaceRef() = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestParseSurfaceRef(t *testing.T) { + cases := map[string]string{ + `{"ref":"surface:json"}`: "surface:json", + "created surface=surface:text\n": "surface:text", + "surface:plain\n": "surface:plain", + "cmux: notice\n{\"id\":\"surface:x\"}": "surface:x", + } + for input, want := range cases { + if got := ParseSurfaceRef(input); got != want { + t.Fatalf("ParseSurfaceRef(%q) = %q, want %q", input, got, want) + } + } +} + +func TestClientEnsureWorkspaceReusesFixedWorkspaceAndCreatesSurface(t *testing.T) { + runner := &recordingRunner{out: map[string]string{ + joinArgs([]string{"list-workspaces", "--json"}): workspaceList("workspace:ws1", "repo-claude", "/repo"), + joinArgs([]string{"new-surface", "--type", "terminal", "--workspace", "workspace:ws1", "--working-directory", "/repo", "--focus", "true"}): "OK surface:sf1 pane:41 workspace:ws1", + }} + client := &Client{Runner: runner.run} + + workspace, reused, err := client.EnsureWorkspace(context.Background(), EnsureWorkspaceOpts{ + Cwd: "/repo", + Name: "repo-claude", + Focus: true, + }) + if err != nil { + t.Fatalf("EnsureWorkspace() error = %v", err) + } + if !reused { + t.Fatal("expected existing workspace to be reused") + } + if workspace.String() != "workspace:ws1" { + t.Fatalf("workspace ref = %q, want workspace:ws1", workspace.String()) + } + + ref, err := client.NewSurface(context.Background(), NewSurfaceOpts{ + WorkspaceRef: workspace.String(), + Cwd: "/repo", + Focus: true, + }) + if err != nil { + t.Fatalf("NewSurface() error = %v", err) + } + if ref.SurfaceID != "surface:sf1" { + t.Fatalf("surface ref = %q, want surface:sf1", ref.SurfaceID) + } + + if err := client.SendSurface(context.Background(), workspace.String(), ref.SurfaceID, "hello"); err != nil { + t.Fatalf("SendSurface() error = %v", err) + } + if err := client.SendKeySurface(context.Background(), workspace.String(), ref.SurfaceID, "Enter"); err != nil { + t.Fatalf("SendKeySurface() error = %v", err) + } + + screen, err := client.ReadScreen(context.Background(), ReadScreenOpts{ + WorkspaceRef: workspace.String(), + SurfaceRef: ref.SurfaceID, + Lines: 120, + }) + if err != nil { + t.Fatalf("ReadScreen() error = %v", err) + } + if screen != "ok" { + t.Fatalf("screen = %q, want ok", screen) + } + + if len(runner.calls) != 5 { + t.Fatalf("calls = %d, want 5", len(runner.calls)) + } + wantSurface := []string{"new-surface", "--type", "terminal", "--workspace", "workspace:ws1", "--working-directory", "/repo", "--focus", "true"} + if !reflect.DeepEqual(runner.calls[1].args, wantSurface) { + t.Fatalf("new-surface args = %#v, want %#v", runner.calls[1].args, wantSurface) + } + wantSend := []string{"send", "--workspace", "workspace:ws1", "--surface", "surface:sf1", "--", "hello"} + if !reflect.DeepEqual(runner.calls[2].args, wantSend) { + t.Fatalf("send args = %#v, want %#v", runner.calls[2].args, wantSend) + } + wantEnter := []string{"send-key", "--workspace", "workspace:ws1", "--surface", "surface:sf1", "Enter"} + if !reflect.DeepEqual(runner.calls[3].args, wantEnter) { + t.Fatalf("send-key args = %#v, want %#v", runner.calls[3].args, wantEnter) + } + wantReadScreen := []string{"read-screen", "--workspace", "workspace:ws1", "--surface", "surface:sf1", "--lines", "120"} + if !reflect.DeepEqual(runner.calls[4].args, wantReadScreen) { + t.Fatalf("read-screen args = %#v, want %#v", runner.calls[4].args, wantReadScreen) + } +} + +func TestClientEnsureWorkspaceCreatesMissingWorkspace(t *testing.T) { + runner := &recordingRunner{out: map[string]string{ + joinArgs([]string{"list-workspaces", "--json"}): "cmux: notice\n{\"workspaces\":[]}", + joinArgs([]string{"new-workspace", "--cwd", "/repo", "--name", "repo-claude", "--focus", "true", "--description", "desc", "--id-format", "both"}): "workspace:ws2", + }} + client := &Client{Runner: runner.run} + + workspace, reused, err := client.EnsureWorkspace(context.Background(), EnsureWorkspaceOpts{ + Cwd: "/repo", + Name: "repo-claude", + Description: "desc", + Focus: true, + }) + if err != nil { + t.Fatalf("EnsureWorkspace() error = %v", err) + } + if reused { + t.Fatal("expected workspace creation") + } + if workspace.String() != "workspace:ws2" { + t.Fatalf("workspace ref = %q, want workspace:ws2", workspace.String()) + } + if len(runner.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(runner.calls)) + } +} + +func TestFocusSessionSelectsMatchingWorkspace(t *testing.T) { + runner := &recordingRunner{out: map[string]string{ + joinArgs([]string{"list-workspaces", "--json"}): workspaceList("workspace:ws9", "repo-claude", "/repo"), + }} + client := &Client{Runner: runner.run} + + if err := FocusSession(context.Background(), client, "/repo", "claude"); err != nil { + t.Fatalf("FocusSession() error = %v", err) + } + // The workspace name encodes the agent (repo-claude), so the matched ref is + // switched to with select-workspace as the final command. + want := []string{"select-workspace", "--workspace", "workspace:ws9"} + last := runner.calls[len(runner.calls)-1].args + if !reflect.DeepEqual(last, want) { + t.Fatalf("select-workspace args = %#v, want %#v", last, want) + } +} + +func TestFocusSessionErrorsWhenWorkspaceMissing(t *testing.T) { + runner := &recordingRunner{out: map[string]string{ + joinArgs([]string{"list-workspaces", "--json"}): `{"workspaces":[]}`, + }} + client := &Client{Runner: runner.run} + + err := FocusSession(context.Background(), client, "/repo", "claude") + if err == nil { + t.Fatal("FocusSession() error = nil, want error for a missing workspace") + } + if !strings.Contains(err.Error(), "no cmux workspace") { + t.Fatalf("error = %v, want 'no cmux workspace'", err) + } +} + +func joinArgs(args []string) string { + return strings.Join(args, "\x00") +} + +func workspaceList(ref, title, cwd string) string { + return `{"workspaces":[{"ref":"` + ref + `","title":"` + title + `","custom_title":"` + title + `","current_directory":"` + cwd + `"}]}` +} diff --git a/pkg/ai/provider/cmux/executor.go b/pkg/ai/provider/cmux/executor.go new file mode 100644 index 0000000..462d098 --- /dev/null +++ b/pkg/ai/provider/cmux/executor.go @@ -0,0 +1,515 @@ +package cmux + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +const defaultExecutorTimeout = 30 * time.Minute +const defaultSendAttempts = 3 +const defaultSendRetryDelay = 2 * time.Second +const defaultScreenPollInterval = time.Second +const defaultScreenMaxPollInterval = 5 * time.Second +const defaultScreenStableDuration = 2 * time.Second +const defaultScreenLines = 120 + +// defaultSessionStartRetryDelays is the back-off between re-pressing Enter when +// a submit keystroke did not start the turn. cmux occasionally drops the Enter +// (the REPL was still initializing, or it landed in paste mode), leaving the +// typed text unsent until a downstream timeout fails the run. Re-pressing Enter +// resubmits the already-typed text. The escalation starts fast (2s) and grows so +// a dropped Enter is recovered in seconds rather than tens of seconds. +var defaultSessionStartRetryDelays = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 15 * time.Second} + +// defaultSendSettleDelay is the pause between pasting text onto the surface +// (SendSurface) and pressing Enter (SendKeySurface). cmux can still be applying +// the paste when the Enter arrives, submitting a half-applied buffer or +// swallowing the Enter in paste mode; the settle gives the paste time to land. +const defaultSendSettleDelay = 150 * time.Millisecond + +// defaultREPLReadyTimeout bounds how long the claude launch waits for the REPL's +// input prompt to appear before falling back to plain screen-idle detection. +const defaultREPLReadyTimeout = 30 * time.Second + +// runConfig carries the timeouts and poll intervals the cmux driver reads. Every +// field defaults via the accessor methods on *run, so a zero value drives a real +// run with the production defaults; tests override individual fields. +type runConfig struct { + Timeout time.Duration + SendAttempts int + SendRetryDelay time.Duration + ScreenPollInterval time.Duration + ScreenMaxPollInterval time.Duration + ScreenStableDuration time.Duration + ScreenLines int + + SessionLogPollInterval time.Duration + SessionLogAppearTimeout time.Duration + SessionLogQuiescePeriod time.Duration + + // SessionStartRetryDelays is the back-off used to re-press Enter when a submit + // keystroke did not start the turn. Defaults to defaultSessionStartRetryDelays. + SessionStartRetryDelays []time.Duration + + // SendSettleDelay is the pause between pasting text and pressing Enter. + SendSettleDelay time.Duration + // REPLReadyTimeout bounds the wait for the claude REPL input prompt before + // falling back to screen-idle. + REPLReadyTimeout time.Duration + + // StallTimeout is how long the run may make no progress (neither the session + // log nor the terminal surface advances) before the stall watchdog nudges, + // then fails. + StallTimeout time.Duration + // StallNudges is how many times the watchdog re-presses Enter to revive a + // stalled turn before failing loudly. + StallNudges int + // StallPollInterval is how often the watchdog samples progress and the surface. + StallPollInterval time.Duration +} + +// run drives one cmux session: it owns the cmux client, the run's tuning config, +// the event sink (emit), and the optional tool-permission broker (canUseTool). +// The last* fields capture the live surface/session from the most recent run so a +// follow-up (resume) can reuse the same agent REPL. +type run struct { + client *Client + cfg runConfig + emit func(ai.Event) + canUseTool ai.PermissionFunc + + // approvals tracks the in-flight approval handlers spawned by the stall + // watchdog so the driver can wait for them (and the EventPermission they emit) + // before the event channel is closed. + approvals sync.WaitGroup + + lastSurface WorkspaceRef + lastSessionID string + lastWorkDir string +} + +// readScreen returns the normalized surface contents, or "" if the read failed. +func (r *run) readScreen(ctx context.Context, ref WorkspaceRef) string { + screen, err := r.client.ReadScreen(ctx, ReadScreenOpts{ + WorkspaceRef: ref.String(), + SurfaceRef: ref.SurfaceID, + Lines: r.screenLines(), + }) + if err != nil { + log.Debugf("cmux: read-screen during session-start check failed: %v", err) + return "" + } + return normalizeScreen(screen) +} + +func (r *run) waitForScreenIdle(ctx context.Context, ref WorkspaceRef, phase string, timeout time.Duration, baseline string, requireChange bool) (string, error) { + workspaceRef := strings.TrimSpace(ref.String()) + if workspaceRef == "" { + return "", fmt.Errorf("cmux workspace reference is required for read-screen") + } + surfaceRef := strings.TrimSpace(ref.SurfaceID) + if surfaceRef == "" { + return "", fmt.Errorf("cmux surface reference is required for read-screen") + } + if timeout <= 0 { + timeout = defaultExecutorTimeout + } + poll := r.screenPollInterval() + maxPoll := r.screenMaxPollInterval() + stableFor := r.screenStableDuration() + lines := r.screenLines() + log.Debugf("cmux wait: read-screen workspace=%q surface=%q phase=%q lines=%d poll=%s max-poll=%s stable=%s timeout=%s", workspaceRef, surfaceRef, phase, lines, poll, maxPoll, stableFor, timeout) + + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + waitStart := time.Now() + + var ( + lastScreen string + lastChange time.Time + lastErr error + sawScreen bool + changedEnough = !requireChange + ) + + for { + now := time.Now() + screen, err := r.client.ReadScreen(waitCtx, ReadScreenOpts{ + WorkspaceRef: workspaceRef, + SurfaceRef: surfaceRef, + Lines: lines, + }) + if err != nil { + lastErr = err + log.Debugf("cmux read-screen failed while waiting for %s: %v", phase, err) + } else { + normalized := normalizeScreen(screen) + if normalized != "" { + sawScreen = true + if lastScreen == "" || normalized != lastScreen { + lastScreen = normalized + lastChange = now + if !changedEnough && normalizeScreen(baseline) != normalized { + changedEnough = true + log.Debugf("cmux screen changed for %s", phase) + } + log.Debugf("cmux read-screen %s changed (%d bytes):\n%s", phase, len(normalized), screenSnippet(normalized)) + } + if changedEnough && !lastChange.IsZero() && now.Sub(lastChange) >= stableFor { + log.Debugf("cmux screen stable for %s during %s", now.Sub(lastChange).Round(time.Millisecond), phase) + return normalized, nil + } + } + } + + select { + case <-waitCtx.Done(): + if lastErr != nil && !sawScreen { + return "", fmt.Errorf("timed out waiting for cmux screen during %s: %w", phase, lastErr) + } + if requireChange && !changedEnough { + return "", fmt.Errorf("timed out waiting for cmux screen to change during %s", phase) + } + return "", fmt.Errorf("timed out waiting for cmux screen to stabilize during %s", phase) + default: + } + + delay := screenPollDelay(waitStart, poll, maxPoll) + log.Debugf("cmux read-screen next poll for %s in %s", phase, delay) + if err := sleepContext(waitCtx, delay); err != nil { + if lastErr != nil && !sawScreen { + return "", fmt.Errorf("timed out waiting for cmux screen during %s: %w", phase, lastErr) + } + if requireChange && !changedEnough { + return "", fmt.Errorf("timed out waiting for cmux screen to change during %s", phase) + } + return "", fmt.Errorf("timed out waiting for cmux screen to stabilize during %s", phase) + } + } +} + +type AgentCommandOpts struct { + Agent string + Model string + SessionID string + // Resume reuses SessionID as an existing conversation (claude --resume) + // rather than creating a new one (claude --session-id). Ignored when + // SessionID is empty or for codex. + Resume bool + // Plan starts claude in plan-only mode (--permission-mode plan). codex has + // no equivalent flag, so plan there is enforced by the prompt instruction. + Plan bool + // PermissionMode is the base permission posture for claude (--permission-mode). + // Plan forces it to plan. codex ignores it. + PermissionMode api.PermissionMode + // AllowedTools / DisallowedTools are passed to claude as --allowedTools / + // --disallowedTools. codex ignores them. + AllowedTools []string + DisallowedTools []string +} + +// cliPermissionMode maps an api.PermissionMode onto the claude --permission-mode +// flag value. default/auto/"" all collapse to "default". +func cliPermissionMode(m api.PermissionMode) string { + switch m { + case api.PermissionPlan: + return "plan" + case api.PermissionAcceptEdits: + return "acceptEdits" + case api.PermissionBypass: + return "bypassPermissions" + default: + return "default" + } +} + +func AgentCommand(opts AgentCommandOpts) string { + switch opts.Agent { + case "codex": + if opts.Model != "" { + return "codex -m " + opts.Model + } + return "codex" + default: + cmd := "claude" + if opts.SessionID != "" { + if opts.Resume { + cmd += " --resume " + opts.SessionID + } else { + cmd += " --session-id " + opts.SessionID + } + } + mode := opts.PermissionMode + if opts.Plan { + mode = api.PermissionPlan + } + if opts.Plan || opts.PermissionMode != "" { + cmd += " --permission-mode " + cliPermissionMode(mode) + } + if len(opts.AllowedTools) > 0 { + cmd += " --allowedTools " + strings.Join(opts.AllowedTools, ",") + } + if len(opts.DisallowedTools) > 0 { + cmd += " --disallowedTools " + strings.Join(opts.DisallowedTools, ",") + } + if opts.Model != "" { + cmd += " --model " + opts.Model + } + return cmd + } +} + +// withEnv prepends KEY='value' assignments (sorted by key for deterministic +// output) to the agent launch command so the terminal shell exports them to the +// agent process, whose tool children inherit them. The host supplies the env via +// req.Context.Env. +func withEnv(command string, env map[string]string) string { + if len(env) == 0 { + return command + } + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + assigns := make([]string, 0, len(keys)) + for _, k := range keys { + assigns = append(assigns, k+"="+shellSingleQuote(env[k])) + } + return strings.Join(assigns, " ") + " " + command +} + +// shellSingleQuote wraps v in single quotes so the terminal shell treats it as a +// literal, escaping any embedded single quotes. +func shellSingleQuote(v string) string { + return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'" +} + +// modelFlag normalizes the configured model into the value claude/codex --model +// expects: the bare agent names ("claude"/"codex") and an empty string carry no +// concrete model, so they yield "" (no flag). +func modelFlag(agent, model string) string { + lower := strings.ToLower(strings.TrimSpace(model)) + if lower == "" || lower == agent { + return "" + } + return model +} + +// maxTitleBytes caps the prompt title inlined onto the surface. The title is only +// a pointer into the input file, so a prompt whose first line is itself huge can't +// reintroduce the large-paste failures this design avoids. +const maxTitleBytes = 120 + +// buildInstruction writes the full host-assembled prompt to an input file under +// /.gavel/cmux/ and returns the one-line message dispatched to the agent +// surface: the prompt's title plus a pointer to that file. Always routing the +// inputs through a file keeps the surface paste short and reliable, sidestepping +// the terminal paste truncation and dropped-Enter failures that large inline +// prompts trigger. +func (r *run) buildInstruction(workDir, sessionID, prompt string) (string, error) { + path, err := writePromptFile(workDir, sessionID, prompt) + if err != nil { + return "", err + } + return fmt.Sprintf("%s - See %s for full details", promptTitle(prompt), path), nil +} + +// promptTitle derives a one-line title from the prompt: its first non-empty line +// with leading markdown heading markers stripped, capped to maxTitleBytes. It +// falls back to "Task" when the prompt has no usable text. +func promptTitle(prompt string) string { + for _, line := range strings.Split(prompt, "\n") { + line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#")) + if line == "" { + continue + } + if len(line) > maxTitleBytes { + line = strings.TrimSpace(line[:maxTitleBytes]) + } + return line + } + return "Task" +} + +// writePromptFile persists the full prompt body so the surface paste can point the +// agent at it. The filename is keyed on the session id (or "group" when there is +// none, e.g. codex). +func writePromptFile(workDir, sessionID, prompt string) (string, error) { + if workDir == "" { + return "", fmt.Errorf("workDir is required") + } + absWorkDir, err := filepath.Abs(workDir) + if err != nil { + return "", err + } + dir := filepath.Join(absWorkDir, ".gavel", "cmux") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + name := sanitizeName(sessionID) + if name == "" { + name = "group" + } + path := filepath.Join(dir, "prompt-"+name+".md") + if err := os.WriteFile(path, []byte(strings.TrimSpace(prompt)+"\n"), 0o644); err != nil { + return "", err + } + return path, nil +} + +func (r *run) timeout() time.Duration { + if r.cfg.Timeout > 0 { + return r.cfg.Timeout + } + return defaultExecutorTimeout +} + +func (r *run) sendAttempts() int { + if r.cfg.SendAttempts > 0 { + return r.cfg.SendAttempts + } + return defaultSendAttempts +} + +func (r *run) sendRetryDelay() time.Duration { + if r.cfg.SendRetryDelay > 0 { + return r.cfg.SendRetryDelay + } + return defaultSendRetryDelay +} + +func (r *run) sessionStartRetryDelays() []time.Duration { + if len(r.cfg.SessionStartRetryDelays) > 0 { + return r.cfg.SessionStartRetryDelays + } + return defaultSessionStartRetryDelays +} + +func (r *run) screenPollInterval() time.Duration { + if r.cfg.ScreenPollInterval > 0 { + return r.cfg.ScreenPollInterval + } + return defaultScreenPollInterval +} + +func (r *run) screenMaxPollInterval() time.Duration { + if r.cfg.ScreenMaxPollInterval > 0 { + return r.cfg.ScreenMaxPollInterval + } + return defaultScreenMaxPollInterval +} + +func (r *run) screenStableDuration() time.Duration { + if r.cfg.ScreenStableDuration > 0 { + return r.cfg.ScreenStableDuration + } + return defaultScreenStableDuration +} + +func (r *run) screenLines() int { + if r.cfg.ScreenLines > 0 { + return r.cfg.ScreenLines + } + return defaultScreenLines +} + +func sleepContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func screenPollDelay(start time.Time, base, max time.Duration) time.Duration { + if base <= 0 { + base = defaultScreenPollInterval + } + if max <= 0 { + max = defaultScreenMaxPollInterval + } + if max < base { + max = base + } + elapsed := time.Since(start) + steps := int(elapsed / (10 * time.Second)) + delay := base + for i := 0; i < steps && delay < max; i++ { + delay *= 2 + if delay > max { + return max + } + } + return delay +} + +func normalizeScreen(screen string) string { + return strings.TrimSpace(strings.ReplaceAll(screen, "\r\n", "\n")) +} + +func screenSnippet(screen string) string { + const max = 2000 + screen = normalizeScreen(screen) + if len(screen) <= max { + return screen + } + return screen[:max] + "\n... (truncated)" +} + +// groupWorkDir cleans the host-supplied working directory, defaulting to "." when +// it is empty. +func groupWorkDir(dir string) string { + if strings.TrimSpace(dir) != "" { + return filepath.Clean(dir) + } + return "." +} + +func workspaceName(workDir string) string { + name := filepath.Base(filepath.Clean(workDir)) + if name == "." || name == string(filepath.Separator) { + return "gavel-todos" + } + return name +} + +func AgentWorkspaceName(workDir, agent string) string { + name := workspaceName(workDir) + agent = strings.TrimSpace(agent) + if agent == "" { + return name + } + name = sanitizeName(name + "-" + agent) + if name == "" { + return "gavel-todos-" + sanitizeName(agent) + } + return name +} + +var unsafePromptName = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +func sanitizeName(name string) string { + name = unsafePromptName.ReplaceAllString(strings.TrimSpace(name), "-") + name = strings.Trim(name, "-._") + if len(name) > 64 { + name = name[:64] + } + return name +} diff --git a/pkg/ai/provider/cmux/executor_test.go b/pkg/ai/provider/cmux/executor_test.go new file mode 100644 index 0000000..549266b --- /dev/null +++ b/pkg/ai/provider/cmux/executor_test.go @@ -0,0 +1,147 @@ +package cmux + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/api" +) + +func TestAgentCommand(t *testing.T) { + cases := []struct { + name string + opts AgentCommandOpts + want string + }{ + {"claude fresh session", AgentCommandOpts{Agent: "claude", SessionID: "s1"}, "claude --session-id s1"}, + {"claude resume", AgentCommandOpts{Agent: "claude", SessionID: "s1", Resume: true}, "claude --resume s1"}, + {"claude plan forces plan mode", AgentCommandOpts{Agent: "claude", SessionID: "s1", Plan: true}, "claude --session-id s1 --permission-mode plan"}, + {"claude acceptEdits mode", AgentCommandOpts{Agent: "claude", PermissionMode: api.PermissionAcceptEdits}, "claude --permission-mode acceptEdits"}, + {"claude bypass mode", AgentCommandOpts{Agent: "claude", PermissionMode: api.PermissionBypass}, "claude --permission-mode bypassPermissions"}, + {"claude allow + deny tools", AgentCommandOpts{Agent: "claude", AllowedTools: []string{"Read", "Edit"}, DisallowedTools: []string{"Bash"}}, "claude --allowedTools Read,Edit --disallowedTools Bash"}, + {"claude model flag", AgentCommandOpts{Agent: "claude", Model: "opus"}, "claude --model opus"}, + {"claude no permission flag when unset", AgentCommandOpts{Agent: "claude", SessionID: "s"}, "claude --session-id s"}, + {"claude full", AgentCommandOpts{Agent: "claude", SessionID: "s", PermissionMode: api.PermissionAcceptEdits, AllowedTools: []string{"Read"}, Model: "opus"}, "claude --session-id s --permission-mode acceptEdits --allowedTools Read --model opus"}, + {"codex bare", AgentCommandOpts{Agent: "codex"}, "codex"}, + {"codex with model", AgentCommandOpts{Agent: "codex", Model: "gpt-5"}, "codex -m gpt-5"}, + {"codex ignores tools and permission", AgentCommandOpts{Agent: "codex", AllowedTools: []string{"Read"}, PermissionMode: api.PermissionPlan}, "codex"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := AgentCommand(tc.opts); got != tc.want { + t.Fatalf("AgentCommand() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCLIPermissionMode(t *testing.T) { + cases := map[api.PermissionMode]string{ + api.PermissionPlan: "plan", + api.PermissionAcceptEdits: "acceptEdits", + api.PermissionBypass: "bypassPermissions", + api.PermissionDefault: "default", + api.PermissionAuto: "default", + "": "default", + } + for mode, want := range cases { + if got := cliPermissionMode(mode); got != want { + t.Errorf("cliPermissionMode(%q) = %q, want %q", mode, got, want) + } + } +} + +func TestWithEnvSortsAndQuotes(t *testing.T) { + got := withEnv("claude", map[string]string{"B": "2", "A": "o'clock"}) + want := `A='o'\''clock' B='2' claude` + if got != want { + t.Fatalf("withEnv() = %q, want %q", got, want) + } + if unchanged := withEnv("claude", nil); unchanged != "claude" { + t.Fatalf("withEnv(nil) = %q, want unchanged", unchanged) + } +} + +func TestModelFlag(t *testing.T) { + cases := []struct { + agent, model, want string + }{ + {"claude", "claude", ""}, + {"claude", "", ""}, + {"claude", "opus", "opus"}, + {"codex", "codex", ""}, + {"codex", "gpt-5", "gpt-5"}, + } + for _, tc := range cases { + if got := modelFlag(tc.agent, tc.model); got != tc.want { + t.Errorf("modelFlag(%q,%q) = %q, want %q", tc.agent, tc.model, got, tc.want) + } + } +} + +func TestGroupWorkDir(t *testing.T) { + cases := map[string]string{ + "": ".", + "/a/b/": "/a/b", + "x/./y": "x/y", + "/repo//s": "/repo/s", + } + for in, want := range cases { + if got := groupWorkDir(in); got != want { + t.Errorf("groupWorkDir(%q) = %q, want %q", in, got, want) + } + } +} + +func TestAgentWorkspaceName(t *testing.T) { + if got := AgentWorkspaceName("/repo", "claude"); got != "repo-claude" { + t.Fatalf("AgentWorkspaceName(/repo, claude) = %q, want repo-claude", got) + } + if got := AgentWorkspaceName("/repo", ""); got != "repo" { + t.Fatalf("AgentWorkspaceName(/repo, \"\") = %q, want repo", got) + } +} + +func TestPromptTitle(t *testing.T) { + cases := map[string]string{ + "Fix the bug": "Fix the bug", + "# Heading\n\nbody": "Heading", + "\n\n## Cmux switch\nrest": "Cmux switch", + " ": "Task", + "": "Task", + } + for in, want := range cases { + if got := promptTitle(in); got != want { + t.Errorf("promptTitle(%q) = %q, want %q", in, got, want) + } + } + if got := promptTitle(strings.Repeat("a", 200)); len(got) != maxTitleBytes { + t.Errorf("promptTitle(long) len = %d, want %d", len(got), maxTitleBytes) + } +} + +func TestBuildInstruction(t *testing.T) { + dir := t.TempDir() + r := &run{} + prompt := "# Switch to input file\n\nDo the thing across many lines.\n" + + got, err := r.buildInstruction(dir, "sess-1", prompt) + if err != nil { + t.Fatalf("buildInstruction: %v", err) + } + + path := filepath.Join(dir, ".gavel", "cmux", "prompt-sess-1.md") + if want := "Switch to input file - See " + path + " for full details"; got != want { + t.Fatalf("buildInstruction = %q, want %q", got, want) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read prompt file: %v", err) + } + if !strings.Contains(string(data), "Do the thing across many lines.") { + t.Errorf("prompt file missing full body: %q", data) + } +} diff --git a/pkg/ai/provider/cmux/focus.go b/pkg/ai/provider/cmux/focus.go new file mode 100644 index 0000000..807cf61 --- /dev/null +++ b/pkg/ai/provider/cmux/focus.go @@ -0,0 +1,35 @@ +package cmux + +import ( + "context" + "fmt" +) + +// FocusSession switches cmux to the workspace running the agent session for +// workDir, bringing its terminal to the front. It backs the dashboard's "focus +// session" control. agent selects between the per-agent workspaces +// (claude/codex); an empty agent falls back to the bare directory workspace name. +// +// It fails loudly when cmux is not running or no matching workspace exists (the +// session terminal was closed), rather than silently succeeding — the caller +// surfaces the reason to the user. +func FocusSession(ctx context.Context, client *Client, workDir, agent string) error { + if client == nil { + return fmt.Errorf("cmux client is required") + } + if workDir == "" { + return fmt.Errorf("workDir is required") + } + if err := client.Available(ctx); err != nil { + return err + } + name := AgentWorkspaceName(workDir, agent) + ref, found, err := client.FindWorkspace(ctx, name, workDir) + if err != nil { + return err + } + if !found { + return fmt.Errorf("no cmux workspace %q for %s; the session terminal may have been closed", name, workDir) + } + return client.SelectWorkspace(ctx, ref.String()) +} diff --git a/pkg/ai/provider/cmux/provider.go b/pkg/ai/provider/cmux/provider.go new file mode 100644 index 0000000..de3f689 --- /dev/null +++ b/pkg/ai/provider/cmux/provider.go @@ -0,0 +1,351 @@ +// Package cmux implements captain's interactive-TUI provider: it drives a real +// claude/codex CLI inside a cmux.app terminal surface (terminal automation, not +// JSON-RPC), tailing the Claude session JSONL for structured progress. It backs +// the ai.BackendClaudeCmux / ai.BackendCodexCmux backends. +// +// One Provider serves one backend (claude or codex, derived from cfg.Model.Backend). +// Each ExecuteStream spawns a goroutine that ensures a cmux workspace + terminal +// surface, launches the agent, submits the host-assembled prompt, and streams the +// resulting session events back as ai.Events, always ending in exactly one +// EventResult. Tool-permission dialogs are brokered through cfg.CanUseTool. +// +// Prompt assembly stays with the host: this provider pastes req.Prompt.User as-is. +package cmux + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/commons/logger" +) + +// log is the package-scoped logger for the cmux provider. Its level follows +// -v/--log-level and can be tuned with -Plog.level.cmux=debug. +var log = logger.GetLogger("cmux") + +// Provider drives an interactive claude/codex TUI inside a cmux surface. +type Provider struct { + model string + agent string + cfg ai.Config +} + +var _ ai.StreamingProvider = (*Provider)(nil) + +// New builds a cmux provider for the claude or codex agent, derived from +// cfg.Model.Backend. The model name (cfg.Model.Name) may be empty, a bare agent +// name ("claude"/"codex"), or a concrete model ("opus"/"gpt-…"). +func New(cfg ai.Config) (*Provider, error) { + agent, err := agentForBackend(cfg.Model.Backend) + if err != nil { + return nil, err + } + return &Provider{ + model: cfg.Model.Name, + agent: agent, + cfg: cfg, + }, nil +} + +func agentForBackend(b api.Backend) (string, error) { + switch b { + case api.BackendClaudeCmux: + return "claude", nil + case api.BackendCodexCmux: + return "codex", nil + default: + return "", fmt.Errorf("cmux provider: unsupported backend %q (want %s or %s)", b, api.BackendClaudeCmux, api.BackendCodexCmux) + } +} + +func (p *Provider) GetModel() string { return p.model } + +func (p *Provider) GetBackend() ai.Backend { + if p.agent == "codex" { + return api.BackendCodexCmux + } + return api.BackendClaudeCmux +} + +// Execute drains its own ExecuteStream into a buffered ai.Response. +func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { + if req.Prompt.Schema != nil { + return nil, fmt.Errorf("cmux provider does not support StructuredOutput") + } + start := time.Now() + events, err := p.ExecuteStream(ctx, req) + if err != nil { + return nil, err + } + + var ( + text strings.Builder + usage ai.Usage + sessionID string + success = true + sawResult bool + lastErr string + ) + for ev := range events { + switch ev.Kind { + case ai.EventText: + text.WriteString(ev.Text) + case ai.EventSystem: + if ev.SessionID != "" { + sessionID = ev.SessionID + } + case ai.EventResult: + sawResult = true + success = ev.Success + if ev.Usage != nil { + usage = *ev.Usage + } + if ev.SessionID != "" { + sessionID = ev.SessionID + } + case ai.EventError: + lastErr = ev.Error + } + } + + if !sawResult && lastErr != "" { + return nil, fmt.Errorf("%w: %s", ai.ErrCLIExecutionFailed, lastErr) + } + if sawResult && !success { + msg := lastErr + if msg == "" { + msg = "cmux run returned a failure result" + } + return nil, fmt.Errorf("%w: %s", ai.ErrCLIExecutionFailed, msg) + } + + resp := &ai.Response{ + Text: text.String(), + Model: p.model, + Backend: p.GetBackend(), + Usage: usage, + Duration: time.Since(start), + } + if sessionID != "" { + resp.Raw = map[string]any{"session_id": sessionID} + } + return resp, nil +} + +// ExecuteStream drives one cmux run in a goroutine and streams ai.Events on a +// buffered channel, closing it when done (always after exactly one EventResult). +func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai.Event, error) { + if req.Prompt.Schema != nil { + return nil, fmt.Errorf("cmux provider does not support StructuredOutput") + } + if req.Prompt.User == "" { + return nil, fmt.Errorf("cmux provider: prompt is required") + } + events := make(chan ai.Event, 32) + go p.drive(ctx, req, events) + return events, nil +} + +// drive runs the session and translates its outcome into the single terminal +// EventResult (preceded by an EventError on failure) before closing the channel. +func (p *Provider) drive(ctx context.Context, req ai.Request, events chan ai.Event) { + defer close(events) + + r := &run{ + client: NewClient(""), + emit: func(ev ai.Event) { emit(ctx, events, ev) }, + canUseTool: p.cfg.CanUseTool, + } + + usage, cost, err := p.execute(ctx, req, r) + if err != nil { + emit(ctx, events, ai.Event{Kind: ai.EventError, Error: err.Error(), Model: p.model}) + emit(ctx, events, ai.Event{Kind: ai.EventResult, Success: false, Error: err.Error(), Model: p.model}) + return + } + emit(ctx, events, ai.Event{Kind: ai.EventResult, Success: true, Usage: usage, CostUSD: cost, Model: p.model}) +} + +// execute mirrors gavel's CmuxExecutor.ExecuteGroup: it ensures a workspace + +// surface, launches the agent, submits the prompt, and (for claude) tails the +// session log under a stall watchdog. It returns the run's usage/cost on success, +// or an error describing the failure. Streaming events are emitted via r.emit. +func (p *Provider) execute(ctx context.Context, req ai.Request, r *run) (*ai.Usage, float64, error) { + start := time.Now() + agent := p.agent + model := modelFlag(agent, p.model) + workDir := groupWorkDir(req.Context.Dir) + + // Resolve the Claude session id. Resume reuses req.SessionID (launch with + // --resume, tail from the end). A fresh run pre-generates one (or takes + // cfg.SessionID) and launches with --session-id so the session log can be + // tailed. codex manages its own sessions and keeps the screen-idle path. + sessionID := "" + resume := false + if agent == "claude" { + if req.SessionID != "" { + sessionID = req.SessionID + resume = true + } else if p.cfg.SessionID != "" { + sessionID = p.cfg.SessionID + } else { + sessionID = uuid.NewString() + } + } + if sessionID != "" { + r.emit(ai.Event{Kind: ai.EventSystem, SessionID: sessionID}) + } + + agentCommand := withEnv(AgentCommand(AgentCommandOpts{ + Agent: agent, + Model: model, + SessionID: sessionID, + Resume: resume, + Plan: req.Permissions.Mode == api.PermissionPlan, + PermissionMode: req.Permissions.Mode, + AllowedTools: req.Permissions.Tools.Allow, + DisallowedTools: req.Permissions.Tools.Deny, + }), req.Context.Env) + + timeout := r.timeout() + log.Infof("cmux: dispatching with %s in %s", agent, workDir) + log.Debugf("cmux command: cmux ping") + if err := r.client.Available(ctx); err != nil { + return nil, 0, err + } + + name := AgentWorkspaceName(workDir, agent) + log.Infof("cmux: ensuring workspace %q for %s", name, agent) + workspace, reused, err := r.client.EnsureWorkspace(ctx, EnsureWorkspaceOpts{ + Cwd: workDir, + Name: name, + Description: fmt.Sprintf("gavel todos %s workspace for %s", agent, workDir), + Focus: true, + }) + if err != nil { + return nil, 0, err + } + if reused { + log.Infof("cmux: reusing workspace %s", workspace.String()) + } else { + log.Infof("cmux: created workspace %s", workspace.String()) + } + + log.Infof("cmux: creating %s terminal surface in workspace %s", agent, workspace.String()) + ref, err := r.client.NewSurface(ctx, NewSurfaceOpts{ + WorkspaceRef: workspace.String(), + Cwd: workDir, + SurfaceType: "terminal", + Focus: true, + }) + if err != nil { + return nil, 0, err + } + + log.Infof("cmux: waiting for terminal surface to stabilize before launching %s", agent) + beforeAgentScreen, err := r.waitForScreenIdle(ctx, ref, "before agent launch", timeout, "", false) + if err != nil { + return nil, 0, err + } + + if err := r.sendSurfaceText(ctx, ref.String(), ref.SurfaceID, "agent command", agentCommand); err != nil { + return nil, 0, err + } + + // Wait until the agent is ready for the prompt. claude gates on a positive + // REPL-readiness signal (its input prompt appearing); codex keeps screen-idle. + log.Infof("cmux: waiting for %s to be ready for the prompt", agent) + var beforePromptScreen string + if agent == "claude" { + if _, err := r.waitForREPLReady(ctx, ref, timeout, beforeAgentScreen); err != nil { + return nil, 0, err + } + } else { + beforePromptScreen, err = r.waitForScreenIdle(ctx, ref, "after agent launch", timeout, beforeAgentScreen, true) + if err != nil { + return nil, 0, err + } + } + + instruction, err := r.buildInstruction(workDir, sessionID, req.Prompt.User) + if err != nil { + return nil, 0, err + } + + if sessionID != "" { + logPath, err := SessionLogPath(workDir, sessionID) + if err != nil { + return nil, 0, err + } + // The initial prompt's Enter occasionally gets dropped by cmux, leaving the + // prompt typed but unsent. submitAndConfirm re-presses Enter until the + // session demonstrably started (its log appeared or the surface advanced). + if err := r.submitAndConfirm(ctx, ref, "initial prompt", instruction, submitConfirm{logPath: logPath}); err != nil { + return nil, 0, err + } + + // Register the run as a live in-progress session so the dashboard reads + // token/cost totals from the tailer. Finish freezes the elapsed clock. + acc := GlobalSessionStats().Begin(sessionID, agent, model, string(req.Effort), start) + _, completed, serr := r.awaitWithStallWatchdog(ctx, ref, sessionID, workDir, timeout, resume, acc) + acc.Finish() + snap := acc.snapshot() + + switch { + case errors.Is(serr, errSessionLogNotFound): + // A pre-generated claude session must produce its log; if it never + // appears we fail loudly rather than inferring completion from the screen. + return nil, 0, fmt.Errorf("claude session %s log %s did not appear within %s", sessionID, logPath, r.sessionLogAppearTimeout(timeout)) + case serr != nil: + return nil, 0, serr + case !completed: + return nil, 0, fmt.Errorf("claude session %s did not complete within %s", sessionID, timeout) + default: + log.Infof("cmux: claude session %s completed", sessionID) + r.lastSurface = ref + r.lastSessionID = sessionID + r.lastWorkDir = workDir + usage := usageFromStats(snap) + return &usage, snap.CostUSD, nil + } + } + + // codex: no session log to tail, so completion is the screen settling. + if err := r.sendSurfaceText(ctx, ref.String(), ref.SurfaceID, "initial prompt", instruction); err != nil { + return nil, 0, err + } + log.Infof("cmux: waiting for %s screen to change and stabilize after prompt dispatch", agent) + if _, err := r.waitForScreenIdle(ctx, ref, "after prompt dispatch", timeout, beforePromptScreen, true); err != nil { + return nil, 0, err + } + return nil, 0, nil +} + +// usageFromStats projects the session accumulator's token totals onto an +// ai.Usage so the terminal EventResult carries the run's token usage. +func usageFromStats(s SessionStats) ai.Usage { + return ai.Usage{ + InputTokens: s.InputTokens, + OutputTokens: s.OutputTokens, + CacheReadTokens: s.CacheReadTokens, + CacheWriteTokens: s.CacheCreationTokens, + } +} + +// emit sends ev on events, honouring ctx cancellation. Returns false if ctx was +// cancelled before the send completed. +func emit(ctx context.Context, events chan ai.Event, ev ai.Event) bool { + select { + case events <- ev: + return true + case <-ctx.Done(): + return false + } +} diff --git a/pkg/ai/provider/cmux/provider_test.go b/pkg/ai/provider/cmux/provider_test.go new file mode 100644 index 0000000..077ec75 --- /dev/null +++ b/pkg/ai/provider/cmux/provider_test.go @@ -0,0 +1,92 @@ +package cmux + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +func TestNewDerivesAgentAndBackend(t *testing.T) { + cases := []struct { + name string + backend api.Backend + model string + wantAgent string + wantBackend api.Backend + }{ + {"claude cmux", api.BackendClaudeCmux, "opus", "claude", api.BackendClaudeCmux}, + {"codex cmux", api.BackendCodexCmux, "gpt-5", "codex", api.BackendCodexCmux}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, err := New(ai.Config{Model: api.Model{Name: tc.model, Backend: tc.backend}}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if p.agent != tc.wantAgent { + t.Fatalf("agent = %q, want %q", p.agent, tc.wantAgent) + } + if p.GetModel() != tc.model { + t.Fatalf("GetModel() = %q, want %q", p.GetModel(), tc.model) + } + if p.GetBackend() != tc.wantBackend { + t.Fatalf("GetBackend() = %q, want %q", p.GetBackend(), tc.wantBackend) + } + }) + } +} + +func TestNewRejectsUnsupportedBackend(t *testing.T) { + if _, err := New(ai.Config{Model: api.Model{Name: "claude", Backend: api.BackendClaudeAgent}}); err == nil { + t.Fatal("New() error = nil, want an error for a non-cmux backend") + } +} + +func TestExecuteStreamRejectsStructuredOutput(t *testing.T) { + p, err := New(ai.Config{Model: api.Model{Name: "claude", Backend: api.BackendClaudeCmux}}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + req := ai.Request{Prompt: api.Prompt{User: "hi", Schema: struct{ X int }{}}} + if _, err := p.ExecuteStream(context.Background(), req); err == nil { + t.Fatal("ExecuteStream() error = nil, want StructuredOutput rejection") + } +} + +func TestExecuteStreamRequiresPrompt(t *testing.T) { + p, err := New(ai.Config{Model: api.Model{Name: "claude", Backend: api.BackendClaudeCmux}}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, err := p.ExecuteStream(context.Background(), ai.Request{Prompt: api.Prompt{User: ""}}); err == nil { + t.Fatal("ExecuteStream() error = nil, want a required-prompt error") + } +} + +func TestUsageFromStats(t *testing.T) { + stats := SessionStats{InputTokens: 100, OutputTokens: 20, CacheReadTokens: 5, CacheCreationTokens: 7} + got := usageFromStats(stats) + want := ai.Usage{InputTokens: 100, OutputTokens: 20, CacheReadTokens: 5, CacheWriteTokens: 7} + if got != want { + t.Fatalf("usageFromStats() = %+v, want %+v", got, want) + } +} + +func TestExecuteFailsLoudWhenCmuxUnavailable(t *testing.T) { + // A runner that fails `ping` makes the whole run fail before any cmux surface is + // created; Execute must surface that as a CLI execution failure, never success. + p, err := New(ai.Config{Model: api.Model{Name: "claude", Backend: api.BackendClaudeCmux}}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + r := newTestRun(runConfig{}, func(_ context.Context, _, _ string, _ time.Duration, args ...string) (string, error) { + return "", errors.New("cmux not running") + }) + if _, _, err := p.execute(context.Background(), ai.Request{Prompt: api.Prompt{User: "hi"}, Context: api.Context{Dir: t.TempDir()}}, r); err == nil { + t.Fatal("execute() error = nil, want a failure when cmux ping fails") + } +} diff --git a/pkg/ai/provider/cmux/sessionlog.go b/pkg/ai/provider/cmux/sessionlog.go new file mode 100644 index 0000000..77535ce --- /dev/null +++ b/pkg/ai/provider/cmux/sessionlog.go @@ -0,0 +1,283 @@ +package cmux + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/history" +) + +const ( + defaultSessionLogPollInterval = 500 * time.Millisecond + defaultSessionLogAppearTimeout = 30 * time.Second +) + +// errSessionLogNotFound is returned by the tailer when Claude never created the +// pre-generated session log within the appear timeout. The driver treats it as +// a hard run failure rather than falling back to screen-idle detection. +var errSessionLogNotFound = errors.New("claude session log did not appear") + +// SessionLogPath resolves the on-disk Claude session log for a session id, +// mirroring Claude's `~/.claude/projects//.jsonl` layout. +// It is exported so the dashboard can locate the log to follow it live. +func SessionLogPath(workDir, sessionID string) (string, error) { + if sessionID == "" { + return "", fmt.Errorf("session id is required") + } + abs, err := filepath.Abs(workDir) + if err != nil { + return "", fmt.Errorf("resolve work dir %q: %w", workDir, err) + } + projects := history.GetProjectsDir() + if projects == "" { + return "", fmt.Errorf("could not resolve claude projects directory") + } + return filepath.Join(projects, history.NormalizePath(abs), sessionID+".jsonl"), nil +} + +// fileSize returns the size of path in bytes, or 0 when it cannot be stat-ed (a +// missing log reads as size 0, the natural "not started" baseline). +func fileSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +// sessionTailer follows a Claude session log, streaming each parsed event to a +// callback until the assistant turn ends, the log goes quiet, or the context is +// cancelled. +type sessionTailer struct { + path string + pollInterval time.Duration + appearTimeout time.Duration + quiescePeriod time.Duration + // seekToEnd skips the lines already in the log when tailing begins. Resume + // runs reuse an existing log that ends in the prior turn's end_turn; without + // this the tailer would see that stale terminal event and report completion + // before the resumed turn produces anything. + seekToEnd bool + // onLine, when set, receives each complete raw log line before it is parsed + // into events. It feeds the session-stats accumulator so token/cost totals + // stay live during a run. The slice is only valid for the call's duration. + onLine func([]byte) +} + +func (st sessionTailer) poll() time.Duration { + if st.pollInterval > 0 { + return st.pollInterval + } + return defaultSessionLogPollInterval +} + +// tail blocks until the session reaches a terminal turn (returns true), the log +// never appeared (errSessionLogNotFound), or the context is cancelled. Each +// parsed event is delivered to onEvent in order. +func (st sessionTailer) tail(ctx context.Context, onEvent func(history.SessionEvent)) (bool, error) { + f, err := st.waitForFile(ctx) + if err != nil { + return false, err + } + defer func() { _ = f.Close() }() + + if st.seekToEnd { + if _, err := f.Seek(0, io.SeekEnd); err != nil { + return false, fmt.Errorf("seek session log %q to end: %w", st.path, err) + } + } + + var pending []byte + buf := make([]byte, 32*1024) + sawActivity := false + lastActivity := time.Now() + + for { + progressed, done, err := st.drain(f, &pending, buf, onEvent) + if err != nil { + return false, err + } + if done { + return true, nil + } + if progressed { + lastActivity = time.Now() + } else if sawActivity && st.quiescePeriod > 0 && time.Since(lastActivity) >= st.quiescePeriod { + return true, nil + } + sawActivity = sawActivity || progressed + + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(st.poll()): + } + } +} + +// drain reads all currently-available bytes, dispatches complete lines, and +// reports whether any events were seen (progressed) or a turn ended (done). +func (st sessionTailer) drain(f *os.File, pending *[]byte, buf []byte, onEvent func(history.SessionEvent)) (progressed, done bool, err error) { + for { + n, rerr := f.Read(buf) + if n > 0 { + *pending = append(*pending, buf[:n]...) + for { + i := bytes.IndexByte(*pending, '\n') + if i < 0 { + break + } + line := (*pending)[:i] + *pending = (*pending)[i+1:] + if st.onLine != nil { + st.onLine(line) + } + events, perr := history.ParseSessionEvents(line) + if perr != nil { + continue + } + for _, ev := range events { + progressed = true + onEvent(ev) + // EventTurnEnd is a normal completion; EventError is a terminal + // API/network failure. Both end the tail — the caller distinguishes + // success from failure from the events it saw. + if ev.Kind == history.EventTurnEnd || ev.Kind == history.EventError { + return progressed, true, nil + } + } + } + } + if rerr == io.EOF { + return progressed, false, nil + } + if rerr != nil { + return progressed, false, rerr + } + } +} + +// waitForFile polls until the session log exists or the appear timeout elapses. +func (st sessionTailer) waitForFile(ctx context.Context) (*os.File, error) { + appear := st.appearTimeout + if appear <= 0 { + appear = defaultSessionLogAppearTimeout + } + deadline := time.Now().Add(appear) + for { + f, err := os.Open(st.path) + if err == nil { + return f, nil + } + if !os.IsNotExist(err) { + return nil, fmt.Errorf("open session log %q: %w", st.path, err) + } + if time.Now().After(deadline) { + return nil, errSessionLogNotFound + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(st.poll()): + } + } +} + +// awaitSessionCompletion tails the Claude session log for the given pre-generated +// session id, emitting progress as ai.Events and reporting whether the assistant +// turn completed. It returns the resolved log path for diagnostics even on error. +func (r *run) awaitSessionCompletion(ctx context.Context, sessionID, workDir string, timeout time.Duration, resume bool, acc *SessionAccumulator) (string, bool, error) { + path, err := SessionLogPath(workDir, sessionID) + if err != nil { + return "", false, err + } + log.Infof("cmux: tailing claude session log %s", path) + + tailer := sessionTailer{ + path: path, + pollInterval: r.sessionLogPollInterval(), + appearTimeout: r.sessionLogAppearTimeout(timeout), + quiescePeriod: r.sessionLogQuiescePeriod(), + seekToEnd: resume, + } + if acc != nil { + tailer.onLine = acc.AddLine + } + + tctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // A synthetic API/network error ends the turn in failure. Capture it so the run + // fails loudly with the reason rather than being mis-reported as completed (the + // error's stop_reason is "stop_sequence", indistinguishable from success). + var apiErr error + completed, err := tailer.tail(tctx, func(ev history.SessionEvent) { + if ev.Kind == history.EventError { + apiErr = fmt.Errorf("claude session %s ended on API error: %s", sessionID, sessionErrorText(ev)) + } + r.emitSessionEvent(ev) + }) + if err != nil { + return path, completed, err + } + if apiErr != nil { + return path, false, apiErr + } + return path, completed, nil +} + +// emitSessionEvent maps a parsed session event onto the streamed ai.Events. The +// terminal result (EventResult) is emitted once by the driver from the run's +// outcome (with usage/cost from the accumulator), so this maps only the +// streaming assistant/thinking/tool content; an API error is also surfaced as an +// EventError here so the host sees the reason inline. +func (r *run) emitSessionEvent(ev history.SessionEvent) { + switch ev.Kind { + case history.EventAssistantText: + r.emit(ai.Event{Kind: ai.EventText, Text: ev.Text}) + case history.EventThinking: + r.emit(ai.Event{Kind: ai.EventThinking, Text: ev.Text}) + case history.EventToolUse: + r.emit(ai.Event{ + Kind: ai.EventToolUse, + Tool: ev.ToolUse.Tool, + Input: ev.ToolUse.Input, + ToolCallID: ev.ToolUse.ToolUseID, + }) + case history.EventError: + r.emit(ai.Event{Kind: ai.EventError, Error: sessionErrorText(ev)}) + } +} + +func (r *run) sessionLogPollInterval() time.Duration { + if r.cfg.SessionLogPollInterval > 0 { + return r.cfg.SessionLogPollInterval + } + return defaultSessionLogPollInterval +} + +func (r *run) sessionLogAppearTimeout(timeout time.Duration) time.Duration { + appear := r.cfg.SessionLogAppearTimeout + if appear <= 0 { + appear = defaultSessionLogAppearTimeout + } + if timeout > 0 && appear > timeout { + appear = timeout + } + return appear +} + +// sessionLogQuiescePeriod is opt-in (0 = disabled). The reliable completion +// signal is an end_turn stop_reason; log silence is unreliable because a single +// long-running tool call (build, test suite) leaves the log quiet for minutes +// mid-turn, so quiescence must not be the default. +func (r *run) sessionLogQuiescePeriod() time.Duration { + return r.cfg.SessionLogQuiescePeriod +} diff --git a/pkg/ai/provider/cmux/sessionlog_test.go b/pkg/ai/provider/cmux/sessionlog_test.go new file mode 100644 index 0000000..3d6b350 --- /dev/null +++ b/pkg/ai/provider/cmux/sessionlog_test.go @@ -0,0 +1,167 @@ +package cmux + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai/history" +) + +func TestSessionLogPath(t *testing.T) { + got, err := SessionLogPath("/tmp/work", "abc-123") + if err != nil { + t.Fatalf("SessionLogPath() error = %v", err) + } + wantSuffix := filepath.Join(history.NormalizePath("/tmp/work"), "abc-123.jsonl") + if !strings.HasSuffix(got, wantSuffix) { + t.Fatalf("SessionLogPath() = %q, want suffix %q", got, wantSuffix) + } + if !strings.HasPrefix(got, history.GetProjectsDir()) { + t.Fatalf("SessionLogPath() = %q, want prefix %q", got, history.GetProjectsDir()) + } +} + +func TestSessionLogPathRequiresSessionID(t *testing.T) { + if _, err := SessionLogPath("/tmp/work", ""); err == nil { + t.Fatal("expected error for empty session id") + } +} + +func writeSessionLog(t *testing.T, path string, lines ...string) { + t.Helper() + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSessionTailerStreamsAndCompletesOnEndTurn(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, + `{"type":"assistant","sessionId":"s","message":{"stop_reason":"tool_use","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}`, + `{"type":"user","message":{"content":[{"type":"tool_result"}]}}`, + `{"type":"assistant","sessionId":"s","message":{"stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}`, + ) + + tailer := sessionTailer{path: path, pollInterval: time.Millisecond, appearTimeout: 200 * time.Millisecond} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var kinds []history.SessionEventKind + completed, err := tailer.tail(ctx, func(ev history.SessionEvent) { kinds = append(kinds, ev.Kind) }) + if err != nil { + t.Fatalf("tail() error = %v", err) + } + if !completed { + t.Fatal("tail() completed = false, want true") + } + wantKinds := []history.SessionEventKind{history.EventToolUse, history.EventAssistantText, history.EventTurnEnd} + if len(kinds) != len(wantKinds) { + t.Fatalf("kinds = %v, want %v", kinds, wantKinds) + } + for i := range wantKinds { + if kinds[i] != wantKinds[i] { + t.Fatalf("kinds = %v, want %v", kinds, wantKinds) + } + } +} + +func TestSessionTailerTerminatesOnAPIError(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // A synthetic API error (stop_sequence) is terminal: the tailer must stop and + // emit an EventError rather than waiting for quiescence or a turn end. + writeSessionLog(t, path, + `{"type":"assistant","sessionId":"s","message":{"model":"","stop_reason":"stop_sequence","content":[{"type":"text","text":"API Error: 529 Overloaded"}]},"error":"server_error","isApiErrorMessage":true,"apiErrorStatus":529}`, + ) + + tailer := sessionTailer{path: path, pollInterval: time.Millisecond, appearTimeout: 200 * time.Millisecond} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var kinds []history.SessionEventKind + completed, err := tailer.tail(ctx, func(ev history.SessionEvent) { kinds = append(kinds, ev.Kind) }) + if err != nil { + t.Fatalf("tail() error = %v", err) + } + if !completed { + t.Fatal("tail() completed = false, want true (an error is terminal)") + } + if len(kinds) != 1 || kinds[0] != history.EventError { + t.Fatalf("kinds = %v, want [error]", kinds) + } +} + +func TestSessionTailerCompletesOnQuiescence(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // No end_turn — only a mid-turn entry; completion must come from quiescence. + writeSessionLog(t, path, + `{"type":"assistant","message":{"stop_reason":"tool_use","content":[{"type":"text","text":"working"}]}}`, + ) + + tailer := sessionTailer{path: path, pollInterval: time.Millisecond, appearTimeout: 200 * time.Millisecond, quiescePeriod: 20 * time.Millisecond} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + completed, err := tailer.tail(ctx, func(history.SessionEvent) {}) + if err != nil || !completed { + t.Fatalf("tail() = (%v, %v), want (true, nil)", completed, err) + } +} + +func TestSessionTailerSeekToEndSkipsStaleTurn(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // A resumed session's log already ends in the prior turn's end_turn. seekToEnd + // must skip it so the resume run isn't reported complete before it starts. + writeSessionLog(t, path, + `{"type":"assistant","message":{"stop_reason":"end_turn","content":[{"type":"text","text":"old"}]}}`, + ) + + tailer := sessionTailer{path: path, pollInterval: time.Millisecond, appearTimeout: 200 * time.Millisecond, seekToEnd: true} + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + var got []history.SessionEventKind + completed, err := tailer.tail(ctx, func(ev history.SessionEvent) { got = append(got, ev.Kind) }) + if completed { + t.Fatal("seekToEnd tailer completed on a stale end_turn") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("tail() error = %v, want context deadline", err) + } + if len(got) != 0 { + t.Fatalf("seekToEnd should skip pre-existing events, got %v", got) + } +} + +func TestSessionTailerReturnsNotFound(t *testing.T) { + tailer := sessionTailer{path: filepath.Join(t.TempDir(), "missing.jsonl"), pollInterval: time.Millisecond, appearTimeout: 5 * time.Millisecond} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + if _, err := tailer.tail(ctx, func(history.SessionEvent) {}); !errors.Is(err, errSessionLogNotFound) { + t.Fatalf("tail() error = %v, want errSessionLogNotFound", err) + } +} + +func TestSessionTailerWaitsForAppearance(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + go func() { + time.Sleep(20 * time.Millisecond) + writeSessionLog(t, path, + `{"type":"assistant","message":{"stop_reason":"end_turn","content":[{"type":"text","text":"hi"}]}}`, + ) + }() + + tailer := sessionTailer{path: path, pollInterval: time.Millisecond, appearTimeout: 500 * time.Millisecond} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + completed, err := tailer.tail(ctx, func(history.SessionEvent) {}) + if err != nil || !completed { + t.Fatalf("tail() = (%v, %v), want (true, nil)", completed, err) + } +} diff --git a/pkg/ai/provider/cmux/sessionstats.go b/pkg/ai/provider/cmux/sessionstats.go new file mode 100644 index 0000000..047d5ef --- /dev/null +++ b/pkg/ai/provider/cmux/sessionstats.go @@ -0,0 +1,581 @@ +package cmux + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "regexp" + "sort" + "sync" + "time" + + "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/ai/pricing" +) + +// High-level agent states surfaced to the dashboard, derived from the last +// meaningful event in the session log. Mirrors the TS deriveSessionState mapping. +const ( + sessionStateThinking = "thinking" + sessionStateWorking = "working" + sessionStateAsk = "ask" + sessionStateCompleted = "completed" + // sessionStateError marks a turn that ended on an API/network error rather than + // a normal completion, so the dashboard surfaces the failure instead of a stale + // "completed". See history.EventError. + sessionStateError = "error" +) + +// isAskTool reports whether a tool pauses the turn awaiting the user (Claude +// emits no terminal stop reason for these), so the session is "asking", not +// "working", until they respond. +func isAskTool(tool string) bool { + switch tool { + case "AskUserQuestion", "ExitPlanMode": + return true + default: + return false + } +} + +// sessionStateFromLine maps the last event of one session-log line to the agent +// state it represents, plus the failure reason when that event is an API/network +// error. Non-conversational lines (tool results, bookkeeping) yield no event and +// return ("", "", false) so the caller keeps the prior state. +func sessionStateFromLine(line []byte) (state, errMsg string, ok bool) { + events, err := history.ParseSessionEvents(line) + if err != nil || len(events) == 0 { + return "", "", false + } + last := events[len(events)-1] + switch last.Kind { + case history.EventThinking: + return sessionStateThinking, "", true + case history.EventToolUse: + if isAskTool(last.ToolUse.Tool) { + return sessionStateAsk, "", true + } + return sessionStateWorking, "", true + case history.EventError: + return sessionStateError, sessionErrorText(last), true + case history.EventTurnEnd: + return sessionStateCompleted, "", true + case history.EventAssistantText: + return sessionStateWorking, "", true + default: + return "", "", false + } +} + +// sessionErrorText renders the one-line reason for an API/network error event: +// the synthetic "API Error: …" message Claude Code records, falling back to its +// classification and HTTP status when the message text is absent. +func sessionErrorText(ev history.SessionEvent) string { + if ev.Text != "" { + return ev.Text + } + if ev.ErrorStatus > 0 { + return fmt.Sprintf("API error %d (%s)", ev.ErrorStatus, ev.ErrorType) + } + if ev.ErrorType != "" { + return "API error: " + ev.ErrorType + } + return "API error" +} + +const ( + // sessionStatsTTL bounds how long a cold (disk-derived) stats entry is reused + // before it is recomputed, even when the log's mtime has not changed. + sessionStatsTTL = 5 * time.Second + // sessionStatsMaxLive caps the live map; finished sessions are evicted + // oldest-first beyond it so a long-lived dashboard does not grow unbounded. + sessionStatsMaxLive = 128 + sessionStatsMaxLine = 10 * 1024 * 1024 +) + +// SessionStats is the rolled-up activity of one agent session: its identity +// (agent/model/effort), elapsed time, token usage and derived cost. It backs the +// dashboard's session timer in both the detail pane and the sidebar. Token totals +// are summed across every assistant turn in the session log; cost is derived from +// those totals via captain's pricing registry (best-effort — zero when the model +// is absent from the registry, mirroring the ai-fix context-window lookup). +type SessionStats struct { + SessionID string `json:"sessionId,omitempty"` + Agent string `json:"agent,omitempty"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + StartedAt time.Time `json:"startedAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + DurationMs int64 `json:"durationMs"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + CacheReadTokens int `json:"cacheReadTokens"` + CacheCreationTokens int `json:"cacheCreationTokens"` + TotalTokens int `json:"totalTokens"` + // ContextTokens is the live context-window occupancy: the most recent + // assistant turn's input + cache-read + cache-creation tokens (which a + // compaction resets), as opposed to TotalTokens summing every turn. This is + // what the dashboard surfaces as the token figure. + ContextTokens int `json:"contextTokens"` + // ContextWindow is the model's total context-window size (tokens), looked up + // from captain's pricing registry, so the dashboard can render ContextTokens + // as a fraction of capacity. Zero when the model is absent from the registry. + ContextWindow int `json:"contextWindow"` + Turns int `json:"turns"` + // Compactions counts the context compactions seen so far (Claude's + // `compact_boundary` markers); each one shrinks ContextTokens. + Compactions int `json:"compactions"` + CostUSD float64 `json:"costUsd"` + InProgress bool `json:"inProgress"` + Found bool `json:"found"` + // State is the high-level agent state from the most recent session-log event + // (thinking / working / ask / completed / error); empty before the first event. + State string `json:"state,omitempty"` + // Error is the API/network failure reason when State == "error" — the synthetic + // "API Error: …" message Claude Code records when a request fails after retries. + Error string `json:"error,omitempty"` +} + +// sessionLogLine is the subset of a Claude session-log entry needed for stats: +// the per-request token usage and model carried on each assistant turn, plus the +// `compact_boundary` marker that resets the context window. +type sessionLogLine struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + Timestamp string `json:"timestamp"` + Message struct { + Model string `json:"model"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + } `json:"usage"` + } `json:"message"` + // CompactMetadata is present only on `compact_boundary` system entries; its + // postTokens is the context size that survived the compaction. + CompactMetadata struct { + PostTokens int `json:"postTokens"` + } `json:"compactMetadata"` +} + +func (l sessionLogLine) hasUsage() bool { + u := l.Message.Usage + return u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadInputTokens > 0 || u.CacheCreationInputTokens > 0 +} + +// isCompaction reports whether this line is a context-compaction boundary, which +// shrinks the context window and counts toward SessionStats.Compactions. +func (l sessionLogLine) isCompaction() bool { + return l.Type == "system" && l.Subtype == "compact_boundary" +} + +// applyUsage folds one assistant entry's usage into the running totals and snaps +// the live context window to this turn's prompt size (input + cache). +func (s *SessionStats) applyUsage(l sessionLogLine) { + u := l.Message.Usage + s.InputTokens += u.InputTokens + s.OutputTokens += u.OutputTokens + s.CacheReadTokens += u.CacheReadInputTokens + s.CacheCreationTokens += u.CacheCreationInputTokens + s.ContextTokens = u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens + s.Turns++ + if l.Message.Model != "" { + s.Model = l.Message.Model + } +} + +// applyCompaction records a compaction boundary: it bumps the count and snaps the +// context window down to the post-compaction size the metadata reports. +func (s *SessionStats) applyCompaction(l sessionLogLine) { + s.Compactions++ + if l.CompactMetadata.PostTokens > 0 { + s.ContextTokens = l.CompactMetadata.PostTokens + } +} + +// finalize derives the totals, elapsed duration, and cost from the accumulated +// token counts and timestamps. The model is fixed within a session, so summing +// per-turn cost equals computing cost once from the totals. +func (s *SessionStats) finalize() { + s.TotalTokens = s.InputTokens + s.OutputTokens + s.CacheReadTokens + s.CacheCreationTokens + if !s.StartedAt.IsZero() && !s.UpdatedAt.IsZero() && s.UpdatedAt.After(s.StartedAt) { + s.DurationMs = s.UpdatedAt.Sub(s.StartedAt).Milliseconds() + } + s.CostUSD = sessionCost(s.Model, s.InputTokens, s.OutputTokens, s.CacheReadTokens, s.CacheCreationTokens) + if info, ok := modelInfo(s.Model); ok { + s.ContextWindow = info.ContextWindow + } +} + +// executingRecency bounds how recently a cold (no live tailer) session must have +// advanced to still count as executing again. A run killed mid-turn leaves an +// active state but its log stops growing, so past this window the session settles +// back to its real (e.g. failed) status instead of appearing to run forever. +const executingRecency = 60 * time.Second + +// Executing reports whether the session is doing work right now: a live tailer is +// feeding it (InProgress), or — for a session gavel is not tailing — its on-disk +// log is in an active state (thinking/working/ask) and advanced within +// executingRecency. It lets a todo whose recorded session starts running again (a +// resume, or a continued cmux REPL) surface as in-progress instead of keeping a +// stale terminal status. now is passed in so callers can test without a clock. +func (s SessionStats) Executing(now time.Time) bool { + if !s.Found { + return false + } + if s.InProgress { + return true + } + switch s.State { + case sessionStateThinking, sessionStateWorking, sessionStateAsk: + return !s.UpdatedAt.IsZero() && now.Sub(s.UpdatedAt) < executingRecency + } + return false +} + +// modelVersionRe matches a Claude session-log model id whose version uses hyphens +// (e.g. "claude-opus-4-8" or "claude-sonnet-4-5-20250929") so it can be rewritten +// to the dotted form the pricing registry is keyed by ("claude-opus-4.8"). The +// optional trailing date suffix is dropped. +var modelVersionRe = regexp.MustCompile(`^(claude-[a-z]+)-(\d+)-(\d+)(?:-\d{6,})?$`) + +// normalizeModelID rewrites a bare Claude session-log model id to the dotted +// version the pricing registry uses; ids that don't match are returned unchanged. +func normalizeModelID(model string) string { + if m := modelVersionRe.FindStringSubmatch(model); m != nil { + return m[1] + "-" + m[2] + "." + m[3] + } + return model +} + +// modelIDCandidates lists the registry keys to try for a session-log model id: +// the bare id, the OpenRouter "anthropic/" prefix, and the dotted-version +// normalization that reconciles log ids ("claude-opus-4-8") with registry keys +// ("anthropic/claude-opus-4.8"). Shared by the cost and context-window lookups. +func modelIDCandidates(model string) []string { + norm := normalizeModelID(model) + ids := []string{model, "anthropic/" + model} + if norm != model { + ids = append(ids, norm, "anthropic/"+norm) + } + return ids +} + +// modelInfo resolves a session-log model id to its pricing registry entry, +// returning false when no candidate id matches. It prefers a candidate carrying a +// context window: the bare hyphenated id classifies to the static Claude price +// table (prices only, ContextWindow zero), so stopping at the first match would +// read zero context even when the dotted OpenRouter id carries it. +func modelInfo(model string) (pricing.ModelInfo, bool) { + if model == "" { + return pricing.ModelInfo{}, false + } + var fallback pricing.ModelInfo + var found bool + for _, id := range modelIDCandidates(model) { + info, ok := pricing.GetModelInfo(id) + if !ok { + continue + } + if info.ContextWindow > 0 { + return info, true + } + if !found { + fallback, found = info, true + } + } + return fallback, found +} + +// sessionCost prices the session's tokens via captain's pricing registry. Claude +// session logs report bare hyphenated model ids (e.g. "claude-opus-4-8") while the +// registry is keyed by dotted OpenRouter ids ("anthropic/claude-opus-4.8"), so +// every candidate form is tried. An unknown model yields zero cost rather than +// failing — pricing is optional enrichment, not a correctness invariant. +func sessionCost(model string, in, out, cacheRead, cacheWrite int) float64 { + if model == "" { + return 0 + } + for _, id := range modelIDCandidates(model) { + if res, err := pricing.CalculateCost(id, in, out, 0, cacheRead, cacheWrite); err == nil { + return res.TotalCost + } + } + return 0 +} + +func parseSessionTime(ts string) time.Time { + if ts == "" { + return time.Time{} + } + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { + return t + } + return time.Time{} +} + +// computeSessionStats reads a complete session log from disk and rolls up its +// token usage, model, and first/last timestamps. Used for cold (non-in-progress) +// sessions the live tailers never observed. +func computeSessionStats(path string) (SessionStats, error) { + f, err := os.Open(path) + if err != nil { + return SessionStats{}, err + } + defer func() { _ = f.Close() }() + + stats := SessionStats{Found: true, Agent: "claude"} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), sessionStatsMaxLine) + var first, last time.Time + for scanner.Scan() { + if state, errMsg, ok := sessionStateFromLine(scanner.Bytes()); ok { + stats.State = state + stats.Error = errMsg + } + var entry sessionLogLine + if json.Unmarshal(scanner.Bytes(), &entry) != nil { + continue + } + if ts := parseSessionTime(entry.Timestamp); !ts.IsZero() { + if first.IsZero() || ts.Before(first) { + first = ts + } + if ts.After(last) { + last = ts + } + } + switch { + case entry.isCompaction(): + stats.applyCompaction(entry) + case entry.Type == "assistant" && entry.hasUsage(): + stats.applyUsage(entry) + } + } + if err := scanner.Err(); err != nil { + return SessionStats{}, err + } + stats.StartedAt = first + stats.UpdatedAt = last + stats.finalize() + return stats, nil +} + +// SessionAccumulator is the live, in-progress stats for one running session, +// fed a line at a time by the cmux tailer. It is registered in the cache so the +// dashboard reads live totals without re-reading the growing log on every poll. +type SessionAccumulator struct { + mu sync.Mutex + stats SessionStats +} + +// AddLine folds one raw session-log line into the running stats: its state (from +// the line's last event) and, for assistant turns, its token usage. Safe to use +// as the tailer's onLine hook; it never retains the slice. +func (a *SessionAccumulator) AddLine(line []byte) { + state, errMsg, hasState := sessionStateFromLine(line) + + var entry sessionLogLine + parsed := json.Unmarshal(line, &entry) == nil + isCompaction := parsed && entry.isCompaction() + hasUsage := parsed && entry.Type == "assistant" && entry.hasUsage() + if !hasState && !hasUsage && !isCompaction { + return + } + a.mu.Lock() + if hasState { + a.stats.State = state + a.stats.Error = errMsg + } + if isCompaction { + a.stats.applyCompaction(entry) + a.stats.UpdatedAt = time.Now() + } + if hasUsage { + a.stats.applyUsage(entry) + a.stats.UpdatedAt = time.Now() + } + a.mu.Unlock() +} + +// Finish marks the session no longer in progress, freezing its elapsed duration. +func (a *SessionAccumulator) Finish() { + a.mu.Lock() + a.stats.InProgress = false + if a.stats.UpdatedAt.IsZero() { + a.stats.UpdatedAt = time.Now() + } + a.mu.Unlock() +} + +// snapshot returns a finalized copy. While in progress the elapsed clock runs to +// now so the dashboard timer keeps ticking between polls. +func (a *SessionAccumulator) snapshot() SessionStats { + a.mu.Lock() + defer a.mu.Unlock() + s := a.stats + s.Found = true + if s.InProgress { + s.UpdatedAt = time.Now() + } + s.finalize() + return s +} + +func (a *SessionAccumulator) finished() bool { + a.mu.Lock() + defer a.mu.Unlock() + return !a.stats.InProgress +} + +// lastActivity returns the wall-clock time of the most recent token-usage or +// compaction line folded in, without the snapshot's in-progress now() override, +// so the stall watchdog can tell whether the session log is actually advancing. +func (a *SessionAccumulator) lastActivity() time.Time { + a.mu.Lock() + defer a.mu.Unlock() + return a.stats.UpdatedAt +} + +// state returns the high-level agent state from the most recent session-log event +// (thinking / working / ask / …), used by the stall watchdog to suppress nudges +// while the turn is paused awaiting the user. +func (a *SessionAccumulator) state() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.stats.State +} + +// SetState overrides the live agent state. The cmux stall watchdog uses it to +// surface a tool-permission dialog as "ask" (awaiting input): claude renders the +// approval prompt only on the terminal, so no session-log event marks it and the +// state would otherwise stay "working" while the run is actually paused on a human. +func (a *SessionAccumulator) SetState(state string) { + a.mu.Lock() + a.stats.State = state + a.mu.Unlock() +} + +func (a *SessionAccumulator) startedAt() time.Time { + a.mu.Lock() + defer a.mu.Unlock() + return a.stats.StartedAt +} + +// SessionStatsCache holds the live in-progress sessions (pushed by the tailers) +// and a cold cache of disk-derived stats for sessions no tailer is watching. +type SessionStatsCache struct { + mu sync.Mutex + live map[string]*SessionAccumulator + cold map[string]coldStatsEntry +} + +type coldStatsEntry struct { + stats SessionStats + mtime time.Time + computedAt time.Time +} + +func NewSessionStatsCache() *SessionStatsCache { + return &SessionStatsCache{ + live: map[string]*SessionAccumulator{}, + cold: map[string]coldStatsEntry{}, + } +} + +// Begin registers a live session and returns the accumulator the tailer feeds. +// agent/model/effort are the run's configured values; model is later refined to +// the concrete id reported in the log as turns arrive. +func (c *SessionStatsCache) Begin(sessionID, agent, model, effort string, start time.Time) *SessionAccumulator { + acc := &SessionAccumulator{stats: SessionStats{ + SessionID: sessionID, + Agent: agent, + Model: model, + Effort: effort, + StartedAt: start, + UpdatedAt: start, + InProgress: true, + }} + c.mu.Lock() + c.live[sessionID] = acc + c.evictLiveLocked() + c.mu.Unlock() + return acc +} + +// Get returns the stats for a session: the live accumulator when one is present, +// otherwise a cold read of the on-disk log cached by mtime + TTL. A missing log +// (session never produced output) is the normal "not found" state, returned as a +// zero SessionStats with Found=false rather than an error. +func (c *SessionStatsCache) Get(sessionID, path string) (SessionStats, error) { + c.mu.Lock() + acc := c.live[sessionID] + c.mu.Unlock() + if acc != nil { + return acc.snapshot(), nil + } + return c.coldStats(sessionID, path) +} + +func (c *SessionStatsCache) coldStats(sessionID, path string) (SessionStats, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return SessionStats{}, nil + } + return SessionStats{}, err + } + + c.mu.Lock() + if entry, ok := c.cold[path]; ok && entry.mtime.Equal(info.ModTime()) && time.Since(entry.computedAt) < sessionStatsTTL { + c.mu.Unlock() + return entry.stats, nil + } + c.mu.Unlock() + + stats, err := computeSessionStats(path) + if err != nil { + return SessionStats{}, err + } + stats.SessionID = sessionID + + c.mu.Lock() + c.cold[path] = coldStatsEntry{stats: stats, mtime: info.ModTime(), computedAt: time.Now()} + c.mu.Unlock() + return stats, nil +} + +// evictLiveLocked drops the oldest finished sessions once the live map exceeds +// the cap; in-progress sessions are never evicted. Caller holds c.mu. +func (c *SessionStatsCache) evictLiveLocked() { + if len(c.live) <= sessionStatsMaxLive { + return + } + type aged struct { + id string + start time.Time + } + finished := make([]aged, 0, len(c.live)) + for id, acc := range c.live { + if acc.finished() { + finished = append(finished, aged{id: id, start: acc.startedAt()}) + } + } + sort.Slice(finished, func(i, j int) bool { return finished[i].start.Before(finished[j].start) }) + for _, f := range finished { + if len(c.live) <= sessionStatsMaxLive { + break + } + delete(c.live, f.id) + } +} + +// defaultSessionStats is the process-wide cache: the cmux executor (running +// in-process under the dashboard) feeds live sessions into it and the dashboard's +// stats endpoint reads from it. +var defaultSessionStats = NewSessionStatsCache() + +// GlobalSessionStats returns the process-wide session stats cache. +func GlobalSessionStats() *SessionStatsCache { return defaultSessionStats } diff --git a/pkg/ai/provider/cmux/sessionstats_test.go b/pkg/ai/provider/cmux/sessionstats_test.go new file mode 100644 index 0000000..544f0b3 --- /dev/null +++ b/pkg/ai/provider/cmux/sessionstats_test.go @@ -0,0 +1,369 @@ +package cmux + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai/pricing" +) + +// assistantLine builds a session-log assistant entry with the given usage so the +// stats parser has realistic input without embedding opaque literals. +func assistantLine(ts, model string, in, out, cacheRead, cacheCreate int) string { + return fmt.Sprintf( + `{"type":"assistant","timestamp":%q,"message":{"model":%q,"usage":{"input_tokens":%d,"output_tokens":%d,"cache_read_input_tokens":%d,"cache_creation_input_tokens":%d},"content":[{"type":"text","text":"hi"}]}}`, + ts, model, in, out, cacheRead, cacheCreate, + ) +} + +func TestComputeSessionStatsAggregatesUsage(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // Two assistant turns 30s apart plus a non-assistant line that must be ignored + // for token accounting but still counts toward the time span. + writeSessionLog(t, path, + assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 100, 20, 5, 50), + `{"type":"user","timestamp":"2026-06-23T10:00:10Z","message":{"content":[{"type":"tool_result"}]}}`, + assistantLine("2026-06-23T10:00:30Z", "claude-opus-4-8", 200, 40, 7, 0), + ) + + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if !stats.Found { + t.Fatal("Found = false, want true") + } + if stats.InputTokens != 300 || stats.OutputTokens != 60 { + t.Fatalf("tokens = in:%d out:%d, want in:300 out:60", stats.InputTokens, stats.OutputTokens) + } + if stats.CacheReadTokens != 12 || stats.CacheCreationTokens != 50 { + t.Fatalf("cache tokens = read:%d create:%d, want read:12 create:50", stats.CacheReadTokens, stats.CacheCreationTokens) + } + if stats.TotalTokens != 300+60+12+50 { + t.Fatalf("TotalTokens = %d, want %d", stats.TotalTokens, 300+60+12+50) + } + // ContextTokens tracks the latest turn's window (input + cache), not the sum: + // the second turn's 200 in + 7 cache-read + 0 cache-create. + if stats.ContextTokens != 200+7+0 { + t.Fatalf("ContextTokens = %d, want %d", stats.ContextTokens, 200+7+0) + } + if stats.Compactions != 0 { + t.Fatalf("Compactions = %d, want 0", stats.Compactions) + } + if stats.Turns != 2 { + t.Fatalf("Turns = %d, want 2", stats.Turns) + } + if stats.Model != "claude-opus-4-8" { + t.Fatalf("Model = %q, want claude-opus-4-8", stats.Model) + } + if stats.DurationMs != 30_000 { + t.Fatalf("DurationMs = %d, want 30000", stats.DurationMs) + } + if stats.InProgress { + t.Fatal("cold stats must not be in progress") + } +} + +// compactionLine builds a `compact_boundary` system entry whose metadata reports +// the post-compaction context size, mirroring Claude's session-log marker. +func compactionLine(ts string, postTokens int) string { + return fmt.Sprintf( + `{"type":"system","subtype":"compact_boundary","timestamp":%q,"content":"Conversation compacted","compactMetadata":{"trigger":"auto","preTokens":199417,"postTokens":%d}}`, + ts, postTokens, + ) +} + +func TestComputeSessionStatsCountsCompactions(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // A turn fills the window, a compaction shrinks it to postTokens, then a final + // turn grows it again: ContextTokens must track the latest turn, not the sum. + writeSessionLog(t, path, + assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 5000, 100, 180000, 0), + compactionLine("2026-06-23T10:00:30Z", 12000), + assistantLine("2026-06-23T10:01:00Z", "claude-opus-4-8", 800, 200, 13000, 0), + ) + + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if stats.Compactions != 1 { + t.Fatalf("Compactions = %d, want 1", stats.Compactions) + } + if stats.ContextTokens != 800+13000+0 { + t.Fatalf("ContextTokens = %d, want %d (latest turn's window)", stats.ContextTokens, 800+13000) + } + // The compaction line carries no usage, so it must not inflate token totals. + if stats.Turns != 2 { + t.Fatalf("Turns = %d, want 2 (compaction is not a turn)", stats.Turns) + } +} + +func TestComputeSessionStatsContextFromCompactionPostTokens(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // When a compaction is the most recent event (no turn follows yet), the context + // window reflects the post-compaction size the boundary reports. + writeSessionLog(t, path, + assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 5000, 100, 180000, 0), + compactionLine("2026-06-23T10:00:30Z", 12000), + ) + + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if stats.Compactions != 1 { + t.Fatalf("Compactions = %d, want 1", stats.Compactions) + } + if stats.ContextTokens != 12000 { + t.Fatalf("ContextTokens = %d, want 12000 (post-compaction size)", stats.ContextTokens) + } +} + +// apiErrorLine builds the synthetic assistant entry Claude Code records when an +// API request fails after retries: stop_reason "stop_sequence" plus the +// isApiErrorMessage marker, classification, and (for HTTP errors) status. +func apiErrorLine(ts, errType string, status int, text string) string { + return fmt.Sprintf( + `{"type":"assistant","timestamp":%q,"message":{"model":"","stop_reason":"stop_sequence","content":[{"type":"text","text":%q}],"usage":{"input_tokens":0,"output_tokens":0}},"error":%q,"isApiErrorMessage":true,"apiErrorStatus":%d}`, + ts, text, errType, status, + ) +} + +func TestComputeSessionStatsDetectsAPIError(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // A normal turn, then an API error ends the session: the stop_sequence error + // must surface as State=error (not completed) with its message. + writeSessionLog(t, path, + assistantContentLine("2026-06-23T10:00:00Z", "", `{"type":"text","text":"working"}`), + apiErrorLine("2026-06-23T10:00:30Z", "server_error", 529, "API Error: 529 Overloaded"), + ) + + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if stats.State != sessionStateError { + t.Fatalf("State = %q, want %q", stats.State, sessionStateError) + } + if stats.Error != "API Error: 529 Overloaded" { + t.Fatalf("Error = %q, want the API error message", stats.Error) + } +} + +func TestComputeSessionStatsErrorClearsOnRecovery(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + // A transient error followed by a real end_turn (after the user re-prompted): + // the latest event wins, so State is completed and Error is cleared. + writeSessionLog(t, path, + apiErrorLine("2026-06-23T10:00:00Z", "rate_limit", 429, "API Error: Rate limited"), + assistantContentLine("2026-06-23T10:01:00Z", "end_turn", `{"type":"text","text":"done"}`), + ) + + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if stats.State != sessionStateCompleted { + t.Fatalf("State = %q, want %q (latest event wins)", stats.State, sessionStateCompleted) + } + if stats.Error != "" { + t.Fatalf("Error = %q, want empty after recovery", stats.Error) + } +} + +// assistantContentLine builds an assistant entry with raw content blocks and an +// optional stop reason, for exercising session-state derivation. +func assistantContentLine(ts, stopReason, content string) string { + return fmt.Sprintf( + `{"type":"assistant","timestamp":%q,"message":{"model":"claude-opus-4-8","stop_reason":%q,"content":[%s],"usage":{"input_tokens":1,"output_tokens":1}}}`, + ts, stopReason, content, + ) +} + +func TestComputeSessionStatsDerivesState(t *testing.T) { + cases := []struct { + name string + content string + stop string + want string + }{ + {"thinking block", `{"type":"thinking","thinking":"hmm"}`, "", sessionStateThinking}, + {"running a tool", `{"type":"tool_use","name":"Edit","id":"t1","input":{}}`, "", sessionStateWorking}, + {"awaiting an answer", `{"type":"tool_use","name":"AskUserQuestion","id":"t2","input":{}}`, "", sessionStateAsk}, + {"turn ended", `{"type":"text","text":"done"}`, "end_turn", sessionStateCompleted}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, assistantContentLine("2026-06-23T10:00:00Z", tc.stop, tc.content)) + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if stats.State != tc.want { + t.Fatalf("State = %q, want %q", stats.State, tc.want) + } + }) + } +} + +func TestComputeSessionStatsStatePersistsAcrossToolResult(t *testing.T) { + // A tool_use leaves the agent "working" until the next assistant turn; the + // interleaved user/tool_result line carries no event and must not clear it. + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, + assistantContentLine("2026-06-23T10:00:00Z", "", `{"type":"tool_use","name":"Bash","id":"t1","input":{}}`), + `{"type":"user","timestamp":"2026-06-23T10:00:01Z","message":{"content":[{"type":"tool_result"}]}}`, + ) + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if stats.State != sessionStateWorking { + t.Fatalf("State = %q, want %q (tool_result must not clear working)", stats.State, sessionStateWorking) + } +} + +func TestSessionStatsExecuting(t *testing.T) { + now := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC) + cases := []struct { + name string + stats SessionStats + want bool + }{ + {"not found ignores in-progress", SessionStats{Found: false, InProgress: true}, false}, + {"live tailer in progress", SessionStats{Found: true, InProgress: true}, true}, + {"cold working recently", SessionStats{Found: true, State: sessionStateWorking, UpdatedAt: now.Add(-10 * time.Second)}, true}, + {"cold thinking recently", SessionStats{Found: true, State: sessionStateThinking, UpdatedAt: now.Add(-1 * time.Second)}, true}, + {"cold awaiting input recently", SessionStats{Found: true, State: sessionStateAsk, UpdatedAt: now.Add(-5 * time.Second)}, true}, + {"cold working but stale", SessionStats{Found: true, State: sessionStateWorking, UpdatedAt: now.Add(-10 * time.Minute)}, false}, + {"cold completed", SessionStats{Found: true, State: sessionStateCompleted, UpdatedAt: now}, false}, + {"cold error", SessionStats{Found: true, State: sessionStateError, UpdatedAt: now}, false}, + {"cold active without timestamp", SessionStats{Found: true, State: sessionStateWorking}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.stats.Executing(now); got != tc.want { + t.Fatalf("Executing() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestNormalizeModelID(t *testing.T) { + cases := map[string]string{ + "claude-opus-4-8": "claude-opus-4.8", + "claude-sonnet-4-5-20250929": "claude-sonnet-4.5", + "claude-haiku-4-5-20251001": "claude-haiku-4.5", + "claude-opus-4-1": "claude-opus-4.1", + "sonnet": "sonnet", // unversioned, unchanged + "anthropic/claude-opus-4.8": "anthropic/claude-opus-4.8", // already dotted, unchanged + } + for in, want := range cases { + if got := normalizeModelID(in); got != want { + t.Errorf("normalizeModelID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestComputeSessionStatsPricesHyphenatedModel(t *testing.T) { + // Claude session logs carry hyphenated model ids ("claude-opus-4-8") that the + // pricing registry keys under dotted OpenRouter ids; finalize must normalize so + // the context window and cost resolve instead of silently reading zero. + want, ok := pricing.GetModelInfo("anthropic/claude-opus-4.8") + if !ok { + t.Skip("pricing registry has no anthropic/claude-opus-4.8 entry") + } + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 1000, 500, 2000, 100)) + + stats, err := computeSessionStats(path) + if err != nil { + t.Fatalf("computeSessionStats() error = %v", err) + } + if stats.ContextWindow != want.ContextWindow { + t.Fatalf("ContextWindow = %d, want %d (from pricing registry)", stats.ContextWindow, want.ContextWindow) + } + if stats.CostUSD <= 0 { + t.Fatalf("CostUSD = %v, want > 0 for a priced model", stats.CostUSD) + } +} + +func TestSessionStatsCacheMissingLogIsNotFound(t *testing.T) { + c := NewSessionStatsCache() + stats, err := c.Get("sess", filepath.Join(t.TempDir(), "missing.jsonl")) + if err != nil { + t.Fatalf("Get() error = %v, want nil for a missing log", err) + } + if stats.Found { + t.Fatal("Found = true for a missing log, want false") + } +} + +func TestSessionStatsCacheColdCachesByMtime(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 100, 20, 0, 0)) + + c := NewSessionStatsCache() + first, err := c.Get("sess", path) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if first.OutputTokens != 20 { + t.Fatalf("OutputTokens = %d, want 20", first.OutputTokens) + } + + // Rewriting the log with a new mtime must invalidate the cold entry. + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + writeSessionLog(t, path, + assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 100, 20, 0, 0), + assistantLine("2026-06-23T10:00:05Z", "claude-opus-4-8", 100, 80, 0, 0), + ) + second, err := c.Get("sess", path) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if second.OutputTokens != 100 { + t.Fatalf("OutputTokens after rewrite = %d, want 100", second.OutputTokens) + } +} + +func TestSessionStatsCacheLivePreferredOverDisk(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 1, 1, 0, 0)) + + c := NewSessionStatsCache() + acc := c.Begin("sess", "claude", "opus", "high", time.Now()) + acc.AddLine([]byte(assistantLine("2026-06-23T10:00:00Z", "claude-opus-4-8", 100, 20, 0, 0))) + + live, err := c.Get("sess", path) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !live.InProgress { + t.Fatal("live session InProgress = false, want true") + } + if live.Effort != "high" || live.Agent != "claude" { + t.Fatalf("identity = agent:%q effort:%q, want claude/high", live.Agent, live.Effort) + } + // The live accumulator (out:20) wins over the on-disk line (out:1). + if live.OutputTokens != 20 { + t.Fatalf("OutputTokens = %d, want 20 (live, not disk)", live.OutputTokens) + } + + acc.Finish() + done, err := c.Get("sess", path) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if done.InProgress { + t.Fatal("finished session InProgress = true, want false") + } +} diff --git a/pkg/ai/provider/cmux/stall.go b/pkg/ai/provider/cmux/stall.go new file mode 100644 index 0000000..328fa3c --- /dev/null +++ b/pkg/ai/provider/cmux/stall.go @@ -0,0 +1,321 @@ +package cmux + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "sync/atomic" + "time" + + "github.com/flanksource/captain/pkg/ai" +) + +const ( + // defaultStallTimeout is how long a run may make no progress — neither the + // session jsonl nor the terminal surface advances — before the watchdog acts. + defaultStallTimeout = 5 * time.Minute + // defaultStallNudges is how many times the watchdog re-presses Enter to revive + // a stalled turn before failing loudly. + defaultStallNudges = 2 + // defaultStallPollInterval is how often the watchdog samples progress and the + // surface. It is decoupled from StallTimeout so approval dialogs are detected + // promptly even while the stall budget is minutes long. + defaultStallPollInterval = 5 * time.Second +) + +// errSessionStalled is returned when the stall watchdog gives up: neither the +// session log nor the terminal surface advanced for StallTimeout, and the +// configured nudges (re-pressing Enter) failed to revive the turn. +var errSessionStalled = errors.New("claude session stalled: no log or surface activity") + +// approvalPromptRe matches claude's tool-permission dialog on the terminal +// surface ("Do you want to proceed?", "Do you want to make this edit to …?") — +// the signal that the turn is paused awaiting a human allow/deny. +var approvalPromptRe = regexp.MustCompile(`(?i)\bdo you want to\b`) + +// awaitWithStallWatchdog runs awaitSessionCompletion under a dual-signal stall +// watchdog. A watcher goroutine samples the session jsonl (log byte-growth and +// the accumulator's last-activity time) and the cmux surface (readScreen); if +// neither advances for StallTimeout it nudges (re-presses Enter) up to StallNudges +// times, then cancels the wait and reports errSessionStalled. The same watcher +// surfaces claude's tool-permission dialog for an allow/deny decision. It returns +// the same (logPath, completed, err) as the bare await, except err is +// errSessionStalled when the watchdog gives up. +func (r *run) awaitWithStallWatchdog(ctx context.Context, ref WorkspaceRef, sessionID, workDir string, timeout time.Duration, resume bool, acc *SessionAccumulator) (string, bool, error) { + logPath, err := SessionLogPath(workDir, sessionID) + if err != nil { + return "", false, err + } + + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + + wd := &stallWatchdog{ + r: r, + ref: ref, + sessionID: sessionID, + logPath: logPath, + acc: acc, + timeout: r.stallTimeout(), + maxNudges: r.stallNudges(), + poll: r.stallPollInterval(), + } + + var stalled atomic.Bool + done := make(chan struct{}) + go func() { + defer close(done) + if wd.watch(watchCtx) { + stalled.Store(true) + cancel() + } + }() + + path, completed, serr := r.awaitSessionCompletion(watchCtx, sessionID, workDir, timeout, resume, acc) + cancel() + <-done + // Wait for any in-flight approval handler (spawned by the watchdog) to finish + // emitting before the driver closes the event channel; the cancel above unblocks + // a CanUseTool callback that honours ctx. + r.approvals.Wait() + + if stalled.Load() { + return path, false, fmt.Errorf("claude session %s made no progress for %s after %d nudge(s): %w", sessionID, wd.timeout, wd.maxNudges, errSessionStalled) + } + return path, completed, serr +} + +// stallSignal is the sampled progress fingerprint: the accumulator's last +// activity time, the session-log size, and the surface contents. A change in any +// of the three means the run advanced. +type stallSignal struct { + activity time.Time + logSize int64 + screen string +} + +func (s stallSignal) equal(o stallSignal) bool { + return s.logSize == o.logSize && s.screen == o.screen && s.activity.Equal(o.activity) +} + +type stallWatchdog struct { + r *run + ref WorkspaceRef + sessionID string + logPath string + acc *SessionAccumulator + timeout time.Duration + maxNudges int + poll time.Duration + + // approving guards against spawning more than one approval handler for the + // same on-screen dialog. + approving atomic.Bool +} + +// watch monitors the session for stalls and surfaces tool-permission dialogs. It +// returns true when it has given up — neither signal advanced for StallTimeout +// and all nudges were exhausted — and false when ctx was cancelled (the run +// finished, normally or because the await returned). +func (w *stallWatchdog) watch(ctx context.Context) bool { + last := w.fingerprint(w.r.readScreen(ctx, w.ref)) + lastProgress := time.Now() + nudged := 0 + + for { + select { + case <-ctx.Done(): + return false + case <-time.After(w.poll): + } + + screen := w.r.readScreen(ctx, w.ref) + w.maybeRequestApproval(ctx, screen) + + // While the turn is paused awaiting the user, hold the stall clock — a human + // thinking about an approval (or an AskUserQuestion) is not a stall — and + // surface the pause as the "ask" status, since claude renders the approval + // prompt only on the terminal and no session-log event marks it. + if w.awaitingHuman(screen) { + w.markAwaitingHuman() + lastProgress = time.Now() + continue + } + + fp := w.fingerprint(screen) + if !fp.equal(last) { + last = fp + lastProgress = time.Now() + continue + } + if time.Since(lastProgress) < w.timeout { + continue + } + + if nudged >= w.maxNudges { + log.Errorf("cmux: session %s stalled for %s after %d nudge(s); failing", w.sessionID, w.timeout, nudged) + return true + } + nudged++ + log.Warnf("cmux: session %s stalled for %s; nudging (re-pressing Enter) attempt %d/%d", w.sessionID, w.timeout, nudged, w.maxNudges) + if err := w.r.client.SendKeySurface(ctx, w.ref.String(), w.ref.SurfaceID, "Enter"); err != nil { + if ctx.Err() != nil { + return false + } + log.Warnf("cmux: session %s nudge failed: %v", w.sessionID, err) + } + // Give the nudge time to take before measuring again. + lastProgress = time.Now() + last = w.fingerprint(w.r.readScreen(ctx, w.ref)) + } +} + +func (w *stallWatchdog) fingerprint(screen string) stallSignal { + sig := stallSignal{logSize: fileSize(w.logPath), screen: screen} + if w.acc != nil { + sig.activity = w.acc.lastActivity() + } + return sig +} + +// awaitingHuman reports whether the turn is paused on a human: a tool-permission +// dialog on the surface, an approval already in flight, or an ask-tool state. +func (w *stallWatchdog) awaitingHuman(screen string) bool { + if approvalPromptRe.MatchString(screen) || w.approving.Load() { + return true + } + if w.acc != nil && w.acc.state() == sessionStateAsk { + return true + } + return false +} + +// markAwaitingHuman surfaces the paused-on-human condition as the "ask" status on +// the live session, so the dashboard and CLI show "awaiting input" instead of a +// stale "working" while the run waits on an approval. No-op for feedback turns, +// which do not feed the live accumulator. +func (w *stallWatchdog) markAwaitingHuman() { + if w.acc != nil { + w.acc.SetState(sessionStateAsk) + } +} + +// maybeRequestApproval surfaces a newly-detected tool-permission dialog for an +// allow/deny decision via the CanUseTool broker, spawning the (blocking) handler +// at most once per dialog. With no broker the dialog is left up for an +// interactive terminal user to answer (the stall clock is held by awaitingHuman). +func (w *stallWatchdog) maybeRequestApproval(ctx context.Context, screen string) { + if !approvalPromptRe.MatchString(screen) { + return + } + if w.r.canUseTool == nil { + return + } + if !w.approving.CompareAndSwap(false, true) { + return + } + req := parseApprovalRequest(w.sessionID, screen) + w.r.approvals.Add(1) + go func() { + defer w.r.approvals.Done() + defer w.approving.Store(false) + w.r.handleApproval(ctx, w.ref, req) + }() +} + +// handleApproval brokers a tool-permission request via the CanUseTool callback +// and applies the decision on the surface: Enter accepts the highlighted "Yes", +// Escape cancels (deny). It emits an EventPermission first so the host can observe +// what is awaiting approval, then blocks on the callback (which honours ctx). +func (r *run) handleApproval(ctx context.Context, ref WorkspaceRef, req ai.PermissionRequest) { + log.Infof("cmux: session %s awaiting tool-permission approval: %s", req.SessionID, approvalSummary(req)) + r.emit(ai.Event{Kind: ai.EventPermission, Tool: req.Tool, Input: req.Input}) + if r.canUseTool == nil { + return + } + decision, err := r.canUseTool(ctx, req) + if err != nil { + log.Debugf("cmux: approval for session %s not resolved: %v", req.SessionID, err) + return + } + if decision.Allow { + log.Infof("cmux: approval for session %s allowed; accepting on surface", req.SessionID) + if err := r.client.SendKeySurface(ctx, ref.String(), ref.SurfaceID, "Enter"); err != nil { + log.Warnf("cmux: failed to send approval accept key: %v", err) + } + return + } + log.Infof("cmux: approval for session %s denied; cancelling on surface", req.SessionID) + if err := r.client.SendKeySurface(ctx, ref.String(), ref.SurfaceID, "Escape"); err != nil { + log.Warnf("cmux: failed to send approval deny key: %v", err) + } +} + +// parseApprovalRequest builds a PermissionRequest from the dialog text on the +// surface. The tool and input are best-effort — the cmux surface carries only +// rendered text — but enough for the host to show what is being approved. +func parseApprovalRequest(sessionID, screen string) ai.PermissionRequest { + line := approvalPromptLine(screen) + return ai.PermissionRequest{ + SessionID: sessionID, + Tool: approvalTool(line), + Input: map[string]any{"prompt": line}, + } +} + +// approvalPromptLine extracts the "Do you want to …" line from the dialog, +// stripping box-drawing borders, or a generic fallback when it cannot be found. +func approvalPromptLine(screen string) string { + for _, raw := range strings.Split(screen, "\n") { + line := strings.TrimSpace(strings.Trim(strings.TrimSpace(raw), "│|╮╭╯╰")) + if approvalPromptRe.MatchString(line) { + return line + } + } + return "tool permission requested" +} + +// approvalTool maps the dialog's prompt line to a coarse tool name for display. +func approvalTool(line string) string { + l := strings.ToLower(line) + switch { + case strings.Contains(l, "make this edit"): + return "Edit" + case strings.Contains(l, "create"): + return "Write" + case strings.Contains(l, "command") || strings.Contains(l, "run"): + return "Bash" + default: + return "tool" + } +} + +func approvalSummary(req ai.PermissionRequest) string { + if p, ok := req.Input["prompt"].(string); ok && p != "" { + return p + } + return req.Tool + " permission requested" +} + +func (r *run) stallTimeout() time.Duration { + if r.cfg.StallTimeout > 0 { + return r.cfg.StallTimeout + } + return defaultStallTimeout +} + +func (r *run) stallNudges() int { + if r.cfg.StallNudges > 0 { + return r.cfg.StallNudges + } + return defaultStallNudges +} + +func (r *run) stallPollInterval() time.Duration { + if r.cfg.StallPollInterval > 0 { + return r.cfg.StallPollInterval + } + return defaultStallPollInterval +} diff --git a/pkg/ai/provider/cmux/stall_test.go b/pkg/ai/provider/cmux/stall_test.go new file mode 100644 index 0000000..166b8e6 --- /dev/null +++ b/pkg/ai/provider/cmux/stall_test.go @@ -0,0 +1,343 @@ +package cmux + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" +) + +// stallRunner is a thread-safe cmux Runner for the watchdog tests: it serves a +// (possibly per-call changing) read-screen and counts the Enter/Escape keys the +// watchdog and approval handler send. +type stallRunner struct { + mu sync.Mutex + screen string + dynamic bool + frame int + enters int + escapes int +} + +func (r *stallRunner) run(_ context.Context, _, _ string, _ time.Duration, args ...string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + switch args[0] { + case "read-screen": + if r.dynamic { + r.frame++ + return fmt.Sprintf("frame %d", r.frame), nil + } + return r.screen, nil + case "send-key": + switch args[len(args)-1] { + case "Enter": + r.enters++ + case "Escape": + r.escapes++ + } + } + return "ok", nil +} + +func (r *stallRunner) setScreen(s string) { + r.mu.Lock() + r.screen = s + r.mu.Unlock() +} + +func (r *stallRunner) enterCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.enters +} + +func (r *stallRunner) escapeCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.escapes +} + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func newWatchdog(r *run, sessionID, logPath string, acc *SessionAccumulator) *stallWatchdog { + return &stallWatchdog{ + r: r, + ref: testSurface, + sessionID: sessionID, + logPath: logPath, + acc: acc, + timeout: 20 * time.Millisecond, + maxNudges: 2, + poll: 5 * time.Millisecond, + } +} + +func TestApprovalPromptRe(t *testing.T) { + cases := []struct { + screen string + want bool + }{ + {"Do you want to proceed?", true}, + {"│ Do you want to make this edit to foo.go? │", true}, + {"❯ 1. Yes\n 2. No", false}, + {"Running tests…", false}, + {"claude is working", false}, + } + for _, tc := range cases { + if got := approvalPromptRe.MatchString(tc.screen); got != tc.want { + t.Errorf("approvalPromptRe.MatchString(%q) = %v, want %v", tc.screen, got, tc.want) + } + } +} + +func TestStallWatchdogLogGrowthResetsTimer(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, logPath, "seed") + runner := &stallRunner{} + runner.setScreen("claude working, no surface change") + r := newTestRun(runConfig{}, runner.run) + wd := newWatchdog(r, "grow", logPath, nil) + + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + go func() { + defer func() { _ = f.Close() }() + for { + select { + case <-stop: + return + default: + } + _, _ = f.WriteString("x\n") // grows the jsonl faster than the stall timeout + time.Sleep(3 * time.Millisecond) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan bool, 1) + go func() { result <- wd.watch(ctx) }() + + time.Sleep(80 * time.Millisecond) // several stall windows + close(stop) + cancel() + if gaveUp := <-result; gaveUp { + t.Fatal("watchdog gave up despite continuous log growth") + } + if n := runner.enterCount(); n != 0 { + t.Fatalf("nudges = %d, want 0 while the log keeps growing", n) + } +} + +func TestStallWatchdogSurfaceChangeResetsTimer(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, logPath, "seed") // static log + runner := &stallRunner{dynamic: true} // surface advances on every read + r := newTestRun(runConfig{}, runner.run) + wd := newWatchdog(r, "surface", logPath, nil) + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan bool, 1) + go func() { result <- wd.watch(ctx) }() + + time.Sleep(80 * time.Millisecond) + cancel() + if gaveUp := <-result; gaveUp { + t.Fatal("watchdog gave up despite a streaming surface (long tool call)") + } + if n := runner.enterCount(); n != 0 { + t.Fatalf("nudges = %d, want 0 while the surface keeps streaming", n) + } +} + +func TestStallWatchdogBothStaticNudgesThenStalls(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, logPath, "seed") // static log + runner := &stallRunner{} + runner.setScreen("claude stuck, nothing moving") // static surface, not a dialog + r := newTestRun(runConfig{}, runner.run) + wd := newWatchdog(r, "stall", logPath, nil) // maxNudges = 2 + + gaveUp := wd.watch(context.Background()) + + if !gaveUp { + t.Fatal("watchdog did not give up despite both signals being static") + } + if n := runner.enterCount(); n != 2 { + t.Fatalf("nudges = %d, want 2 (StallNudges) before giving up", n) + } +} + +func TestStallWatchdogAwaitingHumanSuppressesStall(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, logPath, "seed") // static log + runner := &stallRunner{} + runner.setScreen("╭────╮\n│ Do you want to proceed? │\n╰────╯") // approval dialog stays up + // No CanUseTool broker: an interactive terminal user answers, so the dialog is + // left up and the stall clock is held while it is present (no nudges, no give-up). + r := newTestRun(runConfig{}, runner.run) + wd := newWatchdog(r, "await-human", logPath, nil) + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan bool, 1) + go func() { result <- wd.watch(ctx) }() + + time.Sleep(80 * time.Millisecond) // far longer than the stall timeout + cancel() + if gaveUp := <-result; gaveUp { + t.Fatal("watchdog gave up while awaiting a human decision") + } + if n := runner.enterCount(); n != 0 { + t.Fatalf("nudges = %d, want 0 while a tool-permission dialog is up", n) + } +} + +func TestStallWatchdogApprovalSurfacesAskState(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, logPath, "seed") + runner := &stallRunner{} + runner.setScreen("╭────╮\n│ Do you want to proceed? │\n╰────╯") + r := newTestRun(runConfig{}, runner.run) + + // A live accumulator whose last log event left it "working"; the approval must + // flip it to "ask" even though no new session-log line arrives. + acc := GlobalSessionStats().Begin("ask-state", "claude", "", "", time.Now()) + acc.SetState(sessionStateWorking) + wd := newWatchdog(r, "ask-state", logPath, acc) + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan bool, 1) + go func() { result <- wd.watch(ctx) }() + + waitFor(t, "ask state", func() bool { return acc.state() == sessionStateAsk }) + cancel() + <-result + acc.Finish() +} + +func TestHandleApprovalAllowSendsAccept(t *testing.T) { + runner := &stallRunner{} + r, events := recordingRun(runConfig{}, runner.run) + r.canUseTool = func(_ context.Context, _ ai.PermissionRequest) (ai.PermissionDecision, error) { + return ai.PermissionDecision{Allow: true}, nil + } + req := ai.PermissionRequest{SessionID: "allow", Tool: "Edit", Input: map[string]any{"prompt": "Do you want to make this edit?"}} + + r.handleApproval(context.Background(), testSurface, req) + + if runner.enterCount() != 1 { + t.Fatalf("accept key sent %d times, want 1", runner.enterCount()) + } + if runner.escapeCount() != 0 { + t.Fatalf("escape sent on allow: %d", runner.escapeCount()) + } + if !hasPermissionEvent(*events, "Edit") { + t.Fatalf("no EventPermission emitted for the brokered tool, got %v", *events) + } +} + +func TestHandleApprovalDenySendsEscape(t *testing.T) { + runner := &stallRunner{} + r, events := recordingRun(runConfig{}, runner.run) + r.canUseTool = func(_ context.Context, _ ai.PermissionRequest) (ai.PermissionDecision, error) { + return ai.PermissionDecision{Allow: false, Message: "no"}, nil + } + req := ai.PermissionRequest{SessionID: "deny", Tool: "Bash", Input: map[string]any{"prompt": "Do you want to run this command?"}} + + r.handleApproval(context.Background(), testSurface, req) + + if runner.escapeCount() != 1 { + t.Fatalf("deny key sent %d times, want 1", runner.escapeCount()) + } + if runner.enterCount() != 0 { + t.Fatalf("accept sent on deny: %d", runner.enterCount()) + } + if !hasPermissionEvent(*events, "Bash") { + t.Fatalf("no EventPermission emitted for the brokered tool, got %v", *events) + } +} + +func hasPermissionEvent(events []ai.Event, tool string) bool { + for _, ev := range events { + if ev.Kind == ai.EventPermission && ev.Tool == tool { + return true + } + } + return false +} + +func TestAwaitWithStallWatchdogFailsOnStall(t *testing.T) { + repo := t.TempDir() + fakeClaudeHome(t) + logPath := sessionLogFile(t, repo, "stall-sess") + // A non-terminal line: the await never completes on its own, so only the + // watchdog can end the wait. + writeSessionLog(t, logPath, `{"type":"assistant","message":{"stop_reason":"tool_use","content":[{"type":"text","text":"working"}]}}`) + + runner := &stallRunner{} + runner.setScreen("claude working, no surface change") + r := newTestRun(runConfig{ + SessionLogPollInterval: time.Millisecond, + SessionLogAppearTimeout: time.Second, + StallTimeout: 20 * time.Millisecond, + StallNudges: 1, + StallPollInterval: 5 * time.Millisecond, + }, runner.run) + + _, completed, err := r.awaitWithStallWatchdog(context.Background(), testSurface, "stall-sess", repo, 2*time.Second, false, nil) + if !errors.Is(err, errSessionStalled) { + t.Fatalf("awaitWithStallWatchdog() err = %v, want errSessionStalled", err) + } + if completed { + t.Fatal("completed = true, want false on a stall") + } + if n := runner.enterCount(); n != 1 { + t.Fatalf("nudges = %d, want 1 (StallNudges) before failing", n) + } +} + +func TestAwaitWithStallWatchdogCompletesNormally(t *testing.T) { + repo := t.TempDir() + fakeClaudeHome(t) + logPath := sessionLogFile(t, repo, "ok-sess") + writeSessionLog(t, logPath, completedSessionLine) + + runner := &stallRunner{} + runner.setScreen("claude done\n> ") + r := newTestRun(runConfig{ + SessionLogPollInterval: time.Millisecond, + SessionLogAppearTimeout: time.Second, + StallTimeout: time.Minute, // must not interfere with a fast completion + StallPollInterval: 5 * time.Second, + }, runner.run) + + _, completed, err := r.awaitWithStallWatchdog(context.Background(), testSurface, "ok-sess", repo, 2*time.Second, false, nil) + if err != nil { + t.Fatalf("awaitWithStallWatchdog() error = %v", err) + } + if !completed { + t.Fatal("completed = false, want true for a finished session") + } + if n := runner.enterCount(); n != 0 { + t.Fatalf("nudges = %d, want 0 for a normal completion", n) + } +} diff --git a/pkg/ai/provider/cmux/submit.go b/pkg/ai/provider/cmux/submit.go new file mode 100644 index 0000000..b2bf196 --- /dev/null +++ b/pkg/ai/provider/cmux/submit.go @@ -0,0 +1,199 @@ +package cmux + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + "time" +) + +// replReadyRe matches the claude REPL's input prompt — the box-drawn "> " / "❯ " +// line claude renders once it is ready to accept a message. It is a positive +// readiness signal, stronger than screen-idle, which can settle on a half-drawn +// startup banner before the prompt is actually accepting input. +var replReadyRe = regexp.MustCompile(`(?m)^\s*(?:[│|]\s*)?[>❯](?:\s|$)`) + +// submitConfirm describes how a submit confirms it actually took (the agent began +// the turn). The initial prompt waits for the session log to appear; feedback +// waits for it to grow past the byte offset captured before the send. Both also +// accept the terminal surface advancing past the just-submitted screen. +type submitConfirm struct { + logPath string + // baseOffset is the session-log size captured before the send. When growth is + // set, confirmation requires the log to grow beyond it (feedback resumes an + // existing log); otherwise the log merely appearing confirms (initial start). + baseOffset int64 + growth bool +} + +// submitAndConfirm pastes text onto the surface, presses Enter, and confirms the +// submission actually started the turn, re-pressing Enter on the configured +// back-off when neither the session log nor the surface shows progress. cmux +// occasionally drops the Enter (REPL initializing, paste mode), leaving the text +// typed but unsent; this is the shared resilience the initial prompt and feedback +// both rely on. If the turn still hasn't started after the last re-press it +// returns nil and lets the downstream wait surface the loud failure rather than +// masking it here. +func (r *run) submitAndConfirm(ctx context.Context, ref WorkspaceRef, label, text string, sc submitConfirm) error { + if err := r.sendSurfaceText(ctx, ref.String(), ref.SurfaceID, label, text); err != nil { + return err + } + return r.confirmSubmitted(ctx, ref, label, sc) +} + +// confirmSubmitted re-presses Enter until the submit is confirmed (or the +// back-off is exhausted). The post-send screen is the baseline the surface signal +// is compared against: if the Enter was dropped the typed text sits here +// unchanged; if it took, the screen advances past it. +func (r *run) confirmSubmitted(ctx context.Context, ref WorkspaceRef, label string, sc submitConfirm) error { + postSend := r.readScreen(ctx, ref) + if started, why := r.confirmStarted(ctx, ref, sc, postSend); started { + log.Debugf("cmux: %s confirmed (%s)", label, why) + return nil + } + + // Wait, re-check, then re-press only if still not started, so a submit whose + // Enter merely took a moment to register isn't sent a spurious extra Enter. + delays := r.sessionStartRetryDelays() + for i, delay := range delays { + log.Infof("cmux: %s not confirmed yet; waiting %s before re-pressing Enter (attempt %d/%d)", label, delay, i+1, len(delays)) + if err := sleepContext(ctx, delay); err != nil { + return err + } + if started, why := r.confirmStarted(ctx, ref, sc, postSend); started { + log.Infof("cmux: %s confirmed while waiting to re-press Enter (%s)", label, why) + return nil + } + log.Debugf("cmux command: cmux send-key --workspace %q --surface %q Enter", ref.String(), ref.SurfaceID) + if err := r.client.SendKeySurface(ctx, ref.String(), ref.SurfaceID, "Enter"); err != nil { + if ctx.Err() != nil { + return err + } + log.Warnf("cmux: %s Enter re-press failed: %v", label, err) + } + } + + if started, why := r.confirmStarted(ctx, ref, sc, postSend); started { + log.Infof("cmux: %s confirmed after re-pressing Enter (%s)", label, why) + return nil + } + log.Warnf("cmux: %s not confirmed after re-pressing Enter %d time(s); relying on downstream timeout", label, len(delays)) + return nil +} + +// confirmStarted reports whether the submit started the turn, from the session +// jsonl (authoritative — claude writes it as the turn progresses) and, as a +// fallback, the surface having advanced past the post-send baseline. +func (r *run) confirmStarted(ctx context.Context, ref WorkspaceRef, sc submitConfirm, postSend string) (bool, string) { + if sc.growth { + if fileSize(sc.logPath) > sc.baseOffset { + return true, "session log grew past pre-send offset" + } + } else if _, err := os.Stat(sc.logPath); err == nil { + return true, "session log appeared" + } + screen := r.readScreen(ctx, ref) + if screen != "" && postSend != "" && screen != postSend { + return true, "surface advanced past submission" + } + return false, "" +} + +// sendSurfaceText pastes text onto the surface and presses Enter, retrying the +// whole paste+Enter on transient cmux errors. A settle delay separates the paste +// from the Enter so a fast paste→Enter doesn't submit a half-applied buffer or +// get swallowed while the surface is still in paste mode. +func (r *run) sendSurfaceText(ctx context.Context, workspaceRef, surfaceRef, label, text string) error { + attempts := r.sendAttempts() + delay := r.sendRetryDelay() + settle := r.sendSettleDelay() + text = strings.TrimRight(text, "\r\n") + var lastErr error + for attempt := 1; attempt <= attempts; attempt++ { + if attempt > 1 { + log.Debugf("cmux: waiting %s before retrying %s send", delay, label) + if err := sleepContext(ctx, delay); err != nil { + return err + } + } + log.Infof("cmux: sending %s to workspace %s surface %s (attempt %d/%d)", label, workspaceRef, surfaceRef, attempt, attempts) + log.Debugf("cmux command: cmux send --workspace %q --surface %q -- <%s>", workspaceRef, surfaceRef, label) + log.Debugf("cmux command: cmux send-key --workspace %q --surface %q Enter", workspaceRef, surfaceRef) + log.Debugf("cmux send payload:\n%s", text) + if err := r.client.SendSurface(ctx, workspaceRef, surfaceRef, text); err != nil { + lastErr = err + if ctx.Err() != nil { + return err + } + if attempt < attempts { + log.Warnf("cmux: %s send attempt %d/%d failed: %v; retrying in %s", label, attempt, attempts, err, delay) + } else { + log.Warnf("cmux: %s send attempt %d/%d failed: %v", label, attempt, attempts, err) + } + continue + } + if err := sleepContext(ctx, settle); err != nil { + return err + } + if err := r.client.SendKeySurface(ctx, workspaceRef, surfaceRef, "Enter"); err != nil { + lastErr = err + if ctx.Err() != nil { + return err + } + if attempt < attempts { + log.Warnf("cmux: %s enter attempt %d/%d failed: %v; retrying in %s", label, attempt, attempts, err, delay) + } else { + log.Warnf("cmux: %s enter attempt %d/%d failed: %v", label, attempt, attempts, err) + } + continue + } + log.Infof("cmux: sent %s to workspace %s surface %s", label, workspaceRef, surfaceRef) + return nil + } + return fmt.Errorf("send cmux %s after %d attempts: %w", label, attempts, lastErr) +} + +// waitForREPLReady polls the surface until the claude input prompt appears, +// confirming the REPL will accept the initial prompt. On timeout it falls back to +// the screen-idle wait so a REPL whose prompt we failed to recognize (a theme +// change, a wrapped banner) still proceeds rather than failing the run. +func (r *run) waitForREPLReady(ctx context.Context, ref WorkspaceRef, timeout time.Duration, baseline string) (string, error) { + readyTimeout := r.replReadyTimeout(timeout) + poll := r.screenPollInterval() + base := normalizeScreen(baseline) + deadline := time.Now().Add(readyTimeout) + for { + screen := r.readScreen(ctx, ref) + if screen != "" && screen != base && replReadyRe.MatchString(screen) { + log.Debugf("cmux: claude REPL ready (input prompt detected)") + return screen, nil + } + if time.Now().After(deadline) { + log.Debugf("cmux: claude REPL prompt not detected within %s; falling back to screen-idle", readyTimeout) + return r.waitForScreenIdle(ctx, ref, "after agent launch", timeout, baseline, true) + } + if err := sleepContext(ctx, poll); err != nil { + return "", err + } + } +} + +func (r *run) sendSettleDelay() time.Duration { + if r.cfg.SendSettleDelay > 0 { + return r.cfg.SendSettleDelay + } + return defaultSendSettleDelay +} + +func (r *run) replReadyTimeout(timeout time.Duration) time.Duration { + rt := r.cfg.REPLReadyTimeout + if rt <= 0 { + rt = defaultREPLReadyTimeout + } + if timeout > 0 && rt > timeout { + rt = timeout + } + return rt +} diff --git a/pkg/ai/provider/cmux/submit_test.go b/pkg/ai/provider/cmux/submit_test.go new file mode 100644 index 0000000..32e960b --- /dev/null +++ b/pkg/ai/provider/cmux/submit_test.go @@ -0,0 +1,185 @@ +package cmux + +import ( + "context" + "os" + "sync" + "testing" + "time" +) + +func TestReplReadyRe(t *testing.T) { + cases := []struct { + name string + screen string + want bool + }{ + {"bare prompt", "> ", true}, + {"unicode prompt", "❯ ", true}, + {"boxed prompt", "╭────────╮\n│ > │\n╰────────╯", true}, + {"indented prompt", " > ", true}, + {"shell dollar", "user@host:~$ ", false}, + {"plain banner", "Welcome to Claude Code\nStarting up…", false}, + {"prose with arrow midline", "Type > to send your message", false}, + } + for _, tc := range cases { + if got := replReadyRe.MatchString(tc.screen); got != tc.want { + t.Errorf("%s: replReadyRe.MatchString(%q) = %v, want %v", tc.name, tc.screen, got, tc.want) + } + } +} + +func TestDefaultSessionStartRetryDelaysEscalate(t *testing.T) { + want := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 15 * time.Second} + if len(defaultSessionStartRetryDelays) != len(want) { + t.Fatalf("defaultSessionStartRetryDelays = %v, want %v", defaultSessionStartRetryDelays, want) + } + for i := range want { + if defaultSessionStartRetryDelays[i] != want[i] { + t.Fatalf("defaultSessionStartRetryDelays = %v, want %v", defaultSessionStartRetryDelays, want) + } + } +} + +// timedRunner records each cmux invocation with the time it occurred so a test +// can assert the settle delay between the paste and the Enter. +type timedRunner struct { + mu sync.Mutex + ops []string + times []time.Time +} + +func (r *timedRunner) run(_ context.Context, _, _ string, _ time.Duration, args ...string) (string, error) { + r.mu.Lock() + r.ops = append(r.ops, args[0]) + r.times = append(r.times, time.Now()) + r.mu.Unlock() + return "ok", nil +} + +func TestSendSurfaceTextSettlesBetweenPasteAndEnter(t *testing.T) { + runner := &timedRunner{} + r := newTestRun(runConfig{SendSettleDelay: 50 * time.Millisecond}, runner.run) + + if err := r.sendSurfaceText(context.Background(), "workspace:ws1", "surface:sf1", "prompt", "hello"); err != nil { + t.Fatalf("sendSurfaceText() error = %v", err) + } + + if len(runner.ops) != 2 || runner.ops[0] != "send" || runner.ops[1] != "send-key" { + t.Fatalf("ops = %v, want [send send-key]", runner.ops) + } + gap := runner.times[1].Sub(runner.times[0]) + if gap < 45*time.Millisecond { + t.Fatalf("paste→Enter gap = %s, want >= ~50ms settle delay", gap) + } +} + +func TestConfirmStartedInitialDetectsLogAppearance(t *testing.T) { + repo := t.TempDir() + fakeClaudeHome(t) + logPath := sessionLogFile(t, repo, "sess") + writeSessionLog(t, logPath, completedSessionLine) + + // read-screen returns the same content as postSend, so the only signal that can + // fire is the log having appeared. + r := newTestRun(runConfig{}, constantScreenRunner("idle\n> ")) + started, why := r.confirmStarted(context.Background(), testSurface, submitConfirm{logPath: logPath}, "idle\n> ") + if !started { + t.Fatalf("confirmStarted() = false, want true once the log exists") + } + if why != "session log appeared" { + t.Fatalf("confirmStarted reason = %q, want %q", why, "session log appeared") + } +} + +func TestConfirmStartedFeedbackDetectsLogGrowth(t *testing.T) { + repo := t.TempDir() + fakeClaudeHome(t) + logPath := sessionLogFile(t, repo, "sess") + writeSessionLog(t, logPath, completedSessionLine) // the prior turn + base := fileSize(logPath) + + // read-screen matches the post-send baseline so the surface signal never fires, + // isolating log-growth as the only confirmation signal. + r := newTestRun(runConfig{}, constantScreenRunner("idle prompt")) + sc := submitConfirm{logPath: logPath, baseOffset: base, growth: true} + + if started, _ := r.confirmStarted(context.Background(), testSurface, sc, "idle prompt"); started { + t.Fatal("confirmStarted() = true before the log grew, want false") + } + + appendSessionLine(t, logPath, `{"type":"user","message":{"content":[{"type":"text","text":"feedback"}]}}`) + started, why := r.confirmStarted(context.Background(), testSurface, sc, "idle prompt") + if !started { + t.Fatal("confirmStarted() = false after the log grew, want true") + } + if why != "session log grew past pre-send offset" { + t.Fatalf("confirmStarted reason = %q, want growth reason", why) + } +} + +func TestConfirmStartedDetectsSurfaceAdvance(t *testing.T) { + // No log on disk and growth disabled, so only a surface change can confirm. + r := newTestRun(runConfig{}, constantScreenRunner("claude is working…")) + sc := submitConfirm{logPath: "/nonexistent/session.jsonl"} + + started, why := r.confirmStarted(context.Background(), testSurface, sc, "prompt waiting to submit") + if !started { + t.Fatal("confirmStarted() = false, want true when the surface advanced past submission") + } + if why != "surface advanced past submission" { + t.Fatalf("confirmStarted reason = %q, want surface reason", why) + } +} + +func TestWaitForREPLReadyDetectsPrompt(t *testing.T) { + r := newTestRun(runConfig{ScreenPollInterval: time.Millisecond}, constantScreenRunner("Claude ready\n> ")) + got, err := r.waitForREPLReady(context.Background(), testSurface, 2*time.Second, "shell ready\n$ ") + if err != nil { + t.Fatalf("waitForREPLReady() error = %v", err) + } + if got == "" || !replReadyRe.MatchString(got) { + t.Fatalf("waitForREPLReady() = %q, want the ready screen matching the prompt", got) + } +} + +func TestWaitForREPLReadyFallsBackOnTimeout(t *testing.T) { + // The surface never renders a recognizable prompt; readiness must fall back to + // the screen-idle wait (a changed, stable screen) rather than failing. + r := newTestRun(runConfig{ + REPLReadyTimeout: 10 * time.Millisecond, + ScreenPollInterval: time.Millisecond, + ScreenStableDuration: time.Millisecond, + }, constantScreenRunner("compiling, no prompt yet")) + got, err := r.waitForREPLReady(context.Background(), testSurface, time.Second, "shell ready\n$ ") + if err != nil { + t.Fatalf("waitForREPLReady() fallback error = %v", err) + } + if got != "compiling, no prompt yet" { + t.Fatalf("waitForREPLReady() fallback = %q, want the idle screen", got) + } +} + +// constantScreenRunner is a cmux Runner whose read-screen always returns screen +// and whose other commands succeed, for tests that drive surface logic directly. +func constantScreenRunner(screen string) Runner { + return func(_ context.Context, _, _ string, _ time.Duration, args ...string) (string, error) { + if args[0] == "read-screen" { + return screen, nil + } + return "ok", nil + } +} + +// appendSessionLine appends one line to an existing session log on disk. +func appendSessionLine(t *testing.T, path, line string) { + t.Helper() + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if _, err := f.WriteString(line + "\n"); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/ai/provider/cmux/support_test.go b/pkg/ai/provider/cmux/support_test.go new file mode 100644 index 0000000..cfc1c70 --- /dev/null +++ b/pkg/ai/provider/cmux/support_test.go @@ -0,0 +1,54 @@ +package cmux + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/ai" +) + +// testSurface is the fixed workspace/surface ref the driver tests target. +var testSurface = WorkspaceRef{WorkspaceID: "workspace:ws1", SurfaceID: "surface:sf1"} + +// completedSessionLine is one assistant end_turn entry; a tailer that reads it +// reports the session complete. +const completedSessionLine = `{"type":"assistant","sessionId":"s","message":{"stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}` + +// newTestRun builds a *run wired to a recording cmux Runner, with a no-op event +// sink, so the driver methods can be exercised without a live cmux instance. +func newTestRun(cfg runConfig, runner Runner) *run { + client := NewClient("") + client.Runner = runner + return &run{client: client, cfg: cfg, emit: func(ai.Event) {}} +} + +// recordingRun is a *run whose emitted events are captured for assertions. +func recordingRun(cfg runConfig, runner Runner) (*run, *[]ai.Event) { + r := newTestRun(cfg, runner) + var events []ai.Event + r.emit = func(ev ai.Event) { events = append(events, ev) } + return r, &events +} + +// fakeClaudeHome redirects the claude projects directory (resolved from $HOME) to +// a temp dir so cmux runs read and write session logs inside the test sandbox +// instead of the developer's real ~/.claude. +func fakeClaudeHome(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) +} + +// sessionLogFile resolves the session log a cmux run tails for sessionID under +// workDir, creating its parent directory tree. +func sessionLogFile(t *testing.T, workDir, sessionID string) string { + t.Helper() + path, err := SessionLogPath(workDir, sessionID) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + return path +} diff --git a/pkg/ai/provider/codex_appserver.go b/pkg/ai/provider/codex_appserver.go index 5ff6be8..cae23ca 100644 --- a/pkg/ai/provider/codex_appserver.go +++ b/pkg/ai/provider/codex_appserver.go @@ -66,7 +66,7 @@ func (c *CodexAppServer) Execute(ctx context.Context, req ai.Request) (*ai.Respo // ExecuteStream runs a single turn; the channel closes on turn/completed, a // fatal error, ctx cancellation, or a child crash. func (c *CodexAppServer) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai.Event, error) { - if req.StructuredOutput != nil { + if req.Prompt.Schema != nil { return nil, fmt.Errorf("codex app-server does not support StructuredOutput; use a direct API backend") } diff --git a/pkg/ai/provider/codex_appserver_protocol.go b/pkg/ai/provider/codex_appserver_protocol.go index 130ebfd..c4870e7 100644 --- a/pkg/ai/provider/codex_appserver_protocol.go +++ b/pkg/ai/provider/codex_appserver_protocol.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" ) // This file holds the PURE (no I/O, no process/goroutine state) half of the @@ -208,35 +209,35 @@ func appServerStreamedAgentMessage(params json.RawMessage, streamed map[string]b // --- request params -------------------------------------------------------- func composePrompt(req ai.Request) string { - prompt := req.Prompt - if req.SystemPrompt != "" { - prompt = req.SystemPrompt + "\n\n" + prompt + prompt := req.Prompt.User + if req.Prompt.System != "" { + prompt = req.Prompt.System + "\n\n" + prompt } - if req.AppendSystemPrompt != "" { - prompt = prompt + "\n\n" + req.AppendSystemPrompt + if req.Prompt.AppendSystem != "" { + prompt = prompt + "\n\n" + req.Prompt.AppendSystem } return prompt } // buildThreadStartParams translates the provider-agnostic safety knobs into // thread/start params. The CLI-only ignore-user-config / ignore-rules flags -// (req.NoUser/NoProject/NoHooks) have no first-class equivalent in the versioned -// thread/start schema, so only ephemeral + an empty mcp_servers override (the -// knobs the protocol exposes) are emitted. +// (req.Memory.SkipUser/SkipProject/SkipHooks) have no first-class equivalent in +// the versioned thread/start schema, so only ephemeral + an empty mcp_servers +// override (the knobs the protocol exposes) are emitted. func buildThreadStartParams(model string, req ai.Request) map[string]any { p := map[string]any{} - if req.Cwd != "" { - p["cwd"] = req.Cwd + if req.Context.Dir != "" { + p["cwd"] = req.Context.Dir } if model != "" { p["model"] = model } sandbox, approval := codexSafety(req) p["sandbox"], p["approvalPolicy"] = sandbox, approval - if req.NoMemory || req.Bare { + if req.Memory.SkipMemory || req.Memory.Bare { p["ephemeral"] = true } - if req.NoMCP { + if req.Permissions.MCP.Disabled { p["config"] = map[string]any{"mcp_servers": map[string]any{}} } return p @@ -247,9 +248,9 @@ func buildThreadStartParams(model string, req ai.Request) map[string]any { // when codex prompts, never whether work proceeds. func codexSafety(req ai.Request) (sandbox, approval string) { switch { - case req.PermissionMode == "bypassPermissions": + case req.Permissions.Mode == api.PermissionBypass: return "danger-full-access", "never" - case req.Edit && req.PermissionMode == "": + case req.Permissions.HasPreset(api.PresetEdit) && req.Permissions.Mode == "": return "workspace-write", "on-request" default: return "read-only", "on-request" @@ -264,16 +265,16 @@ func buildTurnStartParams(model string, req ai.Request, threadID string) map[str if model != "" { p["model"] = model } - if req.ReasoningEffort != "" { - p["effort"] = req.ReasoningEffort + if req.Effort != "" { + p["effort"] = string(req.Effort) } return p } func buildResumeParams(req ai.Request) map[string]any { p := map[string]any{"threadId": req.SessionID} - if req.Cwd != "" { - p["cwd"] = req.Cwd + if req.Context.Dir != "" { + p["cwd"] = req.Context.Dir } return p } diff --git a/pkg/ai/provider/codex_appserver_test.go b/pkg/ai/provider/codex_appserver_test.go index 4f02df2..8f5b698 100644 --- a/pkg/ai/provider/codex_appserver_test.go +++ b/pkg/ai/provider/codex_appserver_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/claude" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -259,15 +260,24 @@ func TestThreadID(t *testing.T) { } func TestComposePrompt(t *testing.T) { - assert.Equal(t, "task", composePrompt(ai.Request{Prompt: "task"})) - assert.Equal(t, "be brief\n\ntask", composePrompt(ai.Request{Prompt: "task", SystemPrompt: "be brief"})) - assert.Equal(t, "task\n\ntail", composePrompt(ai.Request{Prompt: "task", AppendSystemPrompt: "tail"})) + assert.Equal(t, "task", composePrompt(req(api.Prompt{User: "task"}))) + assert.Equal(t, "be brief\n\ntask", composePrompt(req(api.Prompt{User: "task", System: "be brief"}))) + assert.Equal(t, "task\n\ntail", composePrompt(req(api.Prompt{User: "task", AppendSystem: "tail"}))) assert.Equal(t, "sys\n\ntask\n\ntail", - composePrompt(ai.Request{Prompt: "task", SystemPrompt: "sys", AppendSystemPrompt: "tail"})) + composePrompt(req(api.Prompt{User: "task", System: "sys", AppendSystem: "tail"}))) +} + +// req builds an ai.Request carrying only the given prompt, keeping the nested +// api.Spec literal out of the table tests above. +func req(p api.Prompt) ai.Request { + return ai.Request{Prompt: p} } func TestBuildTurnStartParams(t *testing.T) { - p := buildTurnStartParams("gpt-5", ai.Request{Prompt: "hi", ReasoningEffort: "high"}, "thread-1") + p := buildTurnStartParams("gpt-5", ai.Request{ + Prompt: api.Prompt{User: "hi"}, + Model: api.Model{Effort: api.EffortHigh}, + }, "thread-1") assert.Equal(t, "thread-1", p["threadId"]) assert.Equal(t, "gpt-5", p["model"]) assert.Equal(t, "high", p["effort"]) @@ -279,7 +289,7 @@ func TestBuildTurnStartParams(t *testing.T) { assert.Equal(t, "hi", input[0]["text"]) // No reasoning effort / empty model should be omitted entirely. - bare := buildTurnStartParams("", ai.Request{Prompt: "hi"}, "t") + bare := buildTurnStartParams("", req(api.Prompt{User: "hi"}), "t") _, hasModel := bare["model"] _, hasEffort := bare["effort"] assert.False(t, hasModel, "empty model must be omitted") @@ -297,45 +307,63 @@ func TestBuildThreadStartParams_Safety(t *testing.T) { }{ { name: "default is read-only on-request", - req: ai.Request{Prompt: "p"}, + req: req(api.Prompt{User: "p"}), wantSandbox: "read-only", wantApproval: "on-request", }, { - name: "edit maps to workspace-write", - req: ai.Request{Prompt: "p", Edit: true}, + name: "edit maps to workspace-write", + req: ai.Request{ + Prompt: api.Prompt{User: "p"}, + Permissions: api.Permissions{Presets: []api.Preset{api.PresetEdit}}, + }, wantSandbox: "workspace-write", wantApproval: "on-request", }, { - name: "explicit permission mode skips workspace-write default", - req: ai.Request{Prompt: "p", Edit: true, PermissionMode: "default"}, + name: "explicit permission mode skips workspace-write default", + req: ai.Request{ + Prompt: api.Prompt{User: "p"}, + Permissions: api.Permissions{Presets: []api.Preset{api.PresetEdit}, Mode: api.PermissionDefault}, + }, wantSandbox: "read-only", wantApproval: "on-request", }, { - name: "bypass permissions maps to danger-full-access never", - req: ai.Request{Prompt: "p", PermissionMode: "bypassPermissions"}, + name: "bypass permissions maps to danger-full-access never", + req: ai.Request{ + Prompt: api.Prompt{User: "p"}, + Permissions: api.Permissions{Mode: api.PermissionBypass}, + }, wantSandbox: "danger-full-access", wantApproval: "never", }, { - name: "no-memory sets ephemeral", - req: ai.Request{Prompt: "p", NoMemory: true}, + name: "no-memory sets ephemeral", + req: ai.Request{ + Prompt: api.Prompt{User: "p"}, + Memory: api.Memory{SkipMemory: true}, + }, wantSandbox: "read-only", wantApproval: "on-request", wantEphem: true, }, { - name: "bare sets ephemeral", - req: ai.Request{Prompt: "p", Bare: true}, + name: "bare sets ephemeral", + req: ai.Request{ + Prompt: api.Prompt{User: "p"}, + Memory: api.Memory{Bare: true}, + }, wantSandbox: "read-only", wantApproval: "on-request", wantEphem: true, }, { - name: "no-mcp sets empty mcp_servers override", - req: ai.Request{Prompt: "p", NoMCP: true}, + name: "no-mcp sets empty mcp_servers override", + req: ai.Request{ + Prompt: api.Prompt{User: "p"}, + Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}, + }, wantSandbox: "read-only", wantApproval: "on-request", wantNoMCP: true, @@ -360,17 +388,23 @@ func TestBuildThreadStartParams_Safety(t *testing.T) { } func TestBuildThreadStartParams_CwdAndModel(t *testing.T) { - p := buildThreadStartParams("gpt-5", ai.Request{Prompt: "p", Cwd: "/repo"}) + p := buildThreadStartParams("gpt-5", ai.Request{ + Prompt: api.Prompt{User: "p"}, + Context: api.Context{Dir: "/repo"}, + }) assert.Equal(t, "/repo", p["cwd"]) assert.Equal(t, "gpt-5", p["model"]) - noModel := buildThreadStartParams("", ai.Request{Prompt: "p"}) + noModel := buildThreadStartParams("", req(api.Prompt{User: "p"})) _, hasModel := noModel["model"] assert.False(t, hasModel, "empty model must be omitted") } func TestBuildResumeParams(t *testing.T) { - p := buildResumeParams(ai.Request{SessionID: "thread-9", Cwd: "/repo"}) + p := buildResumeParams(ai.Request{ + SessionID: "thread-9", + Context: api.Context{Dir: "/repo"}, + }) assert.Equal(t, "thread-9", p["threadId"]) assert.Equal(t, "/repo", p["cwd"]) } diff --git a/pkg/ai/provider/gemini_cli.go b/pkg/ai/provider/gemini_cli.go index 997e755..2a627d2 100644 --- a/pkg/ai/provider/gemini_cli.go +++ b/pkg/ai/provider/gemini_cli.go @@ -46,7 +46,7 @@ func (g *GeminiCLI) Execute(ctx context.Context, req ai.Request) (*ai.Response, defer cancel() cliReq := map[string]string{ - "prompt": req.Prompt, + "prompt": req.Prompt.User, "model": g.model, } reqBytes, err := json.Marshal(cliReq) @@ -54,7 +54,7 @@ func (g *GeminiCLI) Execute(ctx context.Context, req ai.Request) (*ai.Response, return nil, fmt.Errorf("failed to marshal request: %w", err) } - stdoutData, _, err := runCLI(ctx, "gemini", reqBytes) + stdoutData, _, err := runCLI(ctx, "gemini", reqBytes, req.Context.Dir) if err != nil { return nil, err } diff --git a/pkg/ai/provider/genkit/genkit.go b/pkg/ai/provider/genkit/genkit.go index 02cf946..d5cb275 100644 --- a/pkg/ai/provider/genkit/genkit.go +++ b/pkg/ai/provider/genkit/genkit.go @@ -39,9 +39,9 @@ var _ ai.StreamingProvider = (*Provider)(nil) // inferring the backend from the model when unset. It fails loud on an // unsupported backend or a missing API key. func New(cfg ai.Config) (*Provider, error) { - backend := cfg.Backend + backend := cfg.Model.Backend if backend == "" { - inferred, err := ai.InferBackend(cfg.Model) + inferred, err := ai.InferBackend(cfg.Model.Name) if err != nil { return nil, err } @@ -62,7 +62,7 @@ func New(cfg ai.Config) (*Provider, error) { return nil, fmt.Errorf("genkit provider: no API key for backend %q (set the provider's API key, e.g. ANTHROPIC_API_KEY/OPENAI_API_KEY/GEMINI_API_KEY)", backend) } - ref, err := modelRef(backend, cfg.Model) + ref, err := modelRef(backend, cfg.Model.Name) if err != nil { return nil, err } @@ -72,11 +72,11 @@ func New(cfg ai.Config) (*Provider, error) { return nil, err } - cfg.Backend = backend + cfg.Model.Backend = backend return &Provider{cfg: cfg, backend: backend, g: g, modelRef: ref}, nil } -func (p *Provider) GetModel() string { return p.cfg.Model } +func (p *Provider) GetModel() string { return p.cfg.Model.Name } func (p *Provider) GetBackend() ai.Backend { return p.backend } // Execute runs one buffered (non-streaming) generation. @@ -91,18 +91,18 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e return nil, fmt.Errorf("genkit %s generate: %w", p.backend, err) } - out := responseToResponse(resp, p.backend, p.cfg.Model, start) + out := responseToResponse(resp, p.backend, p.cfg.Model.Name, start) - if req.StructuredOutput != nil { - if err := resp.Output(req.StructuredOutput); err != nil { + if req.Prompt.Schema != nil { + if err := resp.Output(req.Prompt.Schema); err != nil { return nil, fmt.Errorf("%w: %v", ai.ErrSchemaValidation, err) } - out.StructuredData = req.StructuredOutput + out.StructuredData = req.Prompt.Schema out.Text = "" } if cost := p.costUSD(out.Usage); cost > 0 { - log.Debugf("genkit %s cost: $%.6f (model=%s)", p.backend, cost, p.cfg.Model) + log.Debugf("genkit %s cost: $%.6f (model=%s)", p.backend, cost, p.cfg.Model.Name) } return out, nil @@ -112,13 +112,13 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e // and a terminal EventResult carrying usage + best-effort cost. Structured // output is unsupported in stream mode (mirrors the claude_cli stream provider). func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai.Event, error) { - if req.StructuredOutput != nil { - return nil, fmt.Errorf("genkit stream mode does not support StructuredOutput; use Execute") + if req.Prompt.Schema != nil { + return nil, fmt.Errorf("genkit stream mode does not support structured output; use Execute") } ch := make(chan ai.Event, 16) cb := func(_ context.Context, chunk *gkai.ModelResponseChunk) error { - for _, ev := range chunkToEvents(chunk, p.cfg.Model) { + for _, ev := range chunkToEvents(chunk, p.cfg.Model.Name) { select { case ch <- ev: case <-ctx.Done(): @@ -133,7 +133,7 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai defer close(ch) resp, err := gk.Generate(ctx, p.g, opts...) if err != nil { - ch <- ai.Event{Kind: ai.EventError, Error: err.Error(), Model: p.cfg.Model} + ch <- ai.Event{Kind: ai.EventError, Error: err.Error(), Model: p.cfg.Model.Name} return } usage := mapUsage(resp.Usage) @@ -142,7 +142,7 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai Success: true, Usage: &usage, CostUSD: p.costUSD(usage), - Model: p.cfg.Model, + Model: p.cfg.Model.Name, } }() @@ -169,7 +169,7 @@ func pricingModelID(backend ai.Backend, model string) string { // from the pricing registry — best effort: an unpriced model logs at debug and // returns 0 rather than failing the request. func (p *Provider) costUSD(u ai.Usage) float64 { - id := pricingModelID(p.backend, p.cfg.Model) + id := pricingModelID(p.backend, p.cfg.Model.Name) res, err := pricing.CalculateCost(id, u.InputTokens, u.OutputTokens, u.ReasoningTokens, u.CacheReadTokens, u.CacheWriteTokens) if err != nil { log.Debugf("genkit provider: cost lookup failed for %q: %v", id, err) diff --git a/pkg/ai/provider/genkit/genkit_test.go b/pkg/ai/provider/genkit/genkit_test.go index 42d6f78..aa74b4e 100644 --- a/pkg/ai/provider/genkit/genkit_test.go +++ b/pkg/ai/provider/genkit/genkit_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" gkai "github.com/firebase/genkit/go/ai" "github.com/stretchr/testify/assert" @@ -26,7 +27,7 @@ func TestEffortConfig(t *testing.T) { { name: "anthropic high effort adds thinking budget on top of base", backend: ai.BackendAnthropic, - req: ai.Request{ReasoningEffort: "high"}, + req: ai.Request{Model: api.Model{Effort: api.EffortHigh}}, want: map[string]any{ "max_tokens": 24576 + 4096, "thinking": map[string]any{"type": "enabled", "budget_tokens": 24576}, @@ -35,7 +36,7 @@ func TestEffortConfig(t *testing.T) { { name: "anthropic medium honours explicit max tokens as base", backend: ai.BackendAnthropic, - req: ai.Request{ReasoningEffort: "medium", MaxTokens: 1000}, + req: ai.Request{Model: api.Model{Effort: api.EffortMedium}, Budget: api.Budget{MaxTokens: 1000}}, want: map[string]any{ "max_tokens": 8192 + 1000, "thinking": map[string]any{"type": "enabled", "budget_tokens": 8192}, @@ -44,7 +45,7 @@ func TestEffortConfig(t *testing.T) { { name: "openai high effort sets reasoning_effort", backend: ai.BackendOpenAI, - req: ai.Request{ReasoningEffort: "high"}, + req: ai.Request{Model: api.Model{Effort: api.EffortHigh}}, want: map[string]any{"reasoning_effort": "high"}, }, { @@ -56,7 +57,7 @@ func TestEffortConfig(t *testing.T) { { name: "gemini low effort sets thinkingBudget", backend: ai.BackendGemini, - req: ai.Request{ReasoningEffort: "low"}, + req: ai.Request{Model: api.Model{Effort: api.EffortLow}}, want: map[string]any{"thinkingConfig": map[string]any{"thinkingBudget": 2048}}, }, { @@ -138,7 +139,7 @@ func TestNewMissingAPIKey(t *testing.T) { for _, backend := range []ai.Backend{ai.BackendAnthropic, ai.BackendOpenAI, ai.BackendGemini} { t.Run(string(backend), func(t *testing.T) { - _, err := New(ai.Config{Backend: backend, Model: "some-model"}) + _, err := New(ai.Config{Model: api.Model{Backend: backend, Name: "some-model"}}) require.Error(t, err) assert.Contains(t, err.Error(), "no API key") }) @@ -146,7 +147,7 @@ func TestNewMissingAPIKey(t *testing.T) { } func TestNewUnsupportedBackend(t *testing.T) { - _, err := New(ai.Config{Backend: ai.BackendClaudeCLI, Model: "claude-code-foo", APIKey: "x"}) + _, err := New(ai.Config{Model: api.Model{Backend: ai.BackendClaudeCLI, Name: "claude-code-foo"}, APIKey: "x"}) require.Error(t, err) assert.Contains(t, err.Error(), "does not support backend") } diff --git a/pkg/ai/provider/genkit/options.go b/pkg/ai/provider/genkit/options.go index 069eb65..f1c4e12 100644 --- a/pkg/ai/provider/genkit/options.go +++ b/pkg/ai/provider/genkit/options.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" gkai "github.com/firebase/genkit/go/ai" ) @@ -13,29 +14,6 @@ import ( // request; it is added on top of any extended-thinking budget. const defaultMaxOutputTokens = 4096 -// effort is the normalized reasoning level parsed from ai.Request.ReasoningEffort. -type effort string - -const ( - effortNone effort = "" - effortLow effort = "low" - effortMedium effort = "medium" - effortHigh effort = "high" -) - -func parseEffort(s string) effort { - switch strings.ToLower(strings.TrimSpace(s)) { - case "low": - return effortLow - case "medium": - return effortMedium - case "high": - return effortHigh - default: - return effortNone - } -} - // bareModel strips a leading provider prefix so a model id can be re-prefixed for // either a genkit ref (anthropic/openai/googleai) or an OpenRouter pricing key. func bareModel(model string) string { @@ -66,41 +44,41 @@ func modelRef(backend ai.Backend, model string) (string, error) { } } -func anthropicThinkingBudget(e effort) int { +// thinkingBudget maps a reasoning effort to an extended-thinking token budget +// (shared by Anthropic and Gemini). xhigh is captain's top tier. +func thinkingBudget(e api.Effort) int { switch e { - case effortLow: + case api.EffortLow: return 2048 - case effortMedium: + case api.EffortMedium: return 8192 - case effortHigh: + case api.EffortHigh: return 24576 + case api.EffortXHigh: + return 32768 default: return 0 } } -func geminiThinkingBudget(e effort) int { - switch e { - case effortLow: - return 2048 - case effortMedium: - return 8192 - case effortHigh: - return 24576 - default: - return 0 +// openaiReasoningEffort maps captain's effort onto OpenAI's reasoning_effort +// values; OpenAI tops out at "high", so xhigh clamps to it. +func openaiReasoningEffort(e api.Effort) string { + if e == api.EffortXHigh { + return string(api.EffortHigh) } + return string(e) } // anthropicMaxTokens returns max_tokens for an Anthropic request. The thinking // budget is counted inside max_tokens, so leave room for the visible answer on -// top of it; honour an explicit ai.Request.MaxTokens as the visible-answer base. -func anthropicMaxTokens(req ai.Request, e effort) int { - base := req.MaxTokens +// top of it; honour an explicit budget MaxTokens as the visible-answer base. +func anthropicMaxTokens(req ai.Request, e api.Effort) int { + base := req.Budget.MaxTokens if base <= 0 { base = defaultMaxOutputTokens } - budget := anthropicThinkingBudget(e) + budget := thinkingBudget(e) if budget == 0 { return base } @@ -111,24 +89,24 @@ func anthropicMaxTokens(req ai.Request, e effort) int { // request's reasoning effort into each backend's native control. Anthropic also // requires max_tokens on every request, so it always returns a config. func effortConfig(backend ai.Backend, req ai.Request) map[string]any { - e := parseEffort(req.ReasoningEffort) + e := req.Effort switch backend { case ai.BackendOpenAI: - if e == effortNone { + if e == api.EffortNone { return nil } - return map[string]any{"reasoning_effort": string(e)} + return map[string]any{"reasoning_effort": openaiReasoningEffort(e)} case ai.BackendGemini: - if e == effortNone { + if e == api.EffortNone { return nil } - return map[string]any{"thinkingConfig": map[string]any{"thinkingBudget": geminiThinkingBudget(e)}} + return map[string]any{"thinkingConfig": map[string]any{"thinkingBudget": thinkingBudget(e)}} case ai.BackendAnthropic: cfg := map[string]any{"max_tokens": anthropicMaxTokens(req, e)} - if e != effortNone { + if e != api.EffortNone { cfg["thinking"] = map[string]any{ "type": "enabled", - "budget_tokens": anthropicThinkingBudget(e), + "budget_tokens": thinkingBudget(e), } } return cfg @@ -144,10 +122,10 @@ func effortConfig(backend ai.Backend, req ai.Request) map[string]any { func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallback) []gkai.GenerateOption { opts := []gkai.GenerateOption{gkai.WithModelName(p.modelRef)} - if req.SystemPrompt != "" { - opts = append(opts, gkai.WithSystem(req.SystemPrompt)) + if req.Prompt.System != "" { + opts = append(opts, gkai.WithSystem(req.Prompt.System)) } - opts = append(opts, gkai.WithPrompt(req.Prompt)) + opts = append(opts, gkai.WithPrompt(req.Prompt.User)) if cfg := effortConfig(p.backend, req); cfg != nil { opts = append(opts, gkai.WithConfig(cfg)) @@ -155,8 +133,8 @@ func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallbac if stream != nil { opts = append(opts, gkai.WithStreaming(stream)) } - if req.StructuredOutput != nil && stream == nil { - opts = append(opts, gkai.WithOutputType(req.StructuredOutput)) + if req.Prompt.Schema != nil && stream == nil { + opts = append(opts, gkai.WithOutputType(req.Prompt.Schema)) } return opts } diff --git a/pkg/ai/provider/init.go b/pkg/ai/provider/init.go index f6ce3e4..f4e490c 100644 --- a/pkg/ai/provider/init.go +++ b/pkg/ai/provider/init.go @@ -3,6 +3,7 @@ package provider import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/ai/provider/claudeagent" + "github.com/flanksource/captain/pkg/ai/provider/cmux" "github.com/flanksource/captain/pkg/ai/provider/genkit" ) @@ -18,8 +19,14 @@ func init() { ai.RegisterProvider(ai.BackendClaudeCLI, func(cfg ai.Config) (ai.Provider, error) { return claudeagent.New(cfg) }) // Codex via `codex app-server` (JSON-RPC) replaces `codex exec --json`. - ai.RegisterProvider(ai.BackendCodexCLI, func(cfg ai.Config) (ai.Provider, error) { return NewCodexAppServer(cfg.Model) }) + ai.RegisterProvider(ai.BackendCodexCLI, func(cfg ai.Config) (ai.Provider, error) { return NewCodexAppServer(cfg.Model.Name) }) + + // cmux drives an interactive claude/codex TUI inside a tmux/cmux surface, + // tailing the session JSONL; the same provider serves both agents (it reads + // the agent from cfg.Model.Backend). + ai.RegisterProvider(ai.BackendClaudeCmux, func(cfg ai.Config) (ai.Provider, error) { return cmux.New(cfg) }) + ai.RegisterProvider(ai.BackendCodexCmux, func(cfg ai.Config) (ai.Provider, error) { return cmux.New(cfg) }) // Gemini CLI is unchanged. - ai.RegisterProvider(ai.BackendGeminiCLI, func(cfg ai.Config) (ai.Provider, error) { return NewGeminiCLI(cfg.Model), nil }) + ai.RegisterProvider(ai.BackendGeminiCLI, func(cfg ai.Config) (ai.Provider, error) { return NewGeminiCLI(cfg.Model.Name), nil }) } diff --git a/pkg/ai/provider_test.go b/pkg/ai/provider_test.go new file mode 100644 index 0000000..0304b00 --- /dev/null +++ b/pkg/ai/provider_test.go @@ -0,0 +1,123 @@ +package ai + +import ( + "strings" + "testing" +) + +func TestBackendValid(t *testing.T) { + for _, b := range AllBackends() { + if !b.Valid() { + t.Errorf("AllBackends()[%q].Valid() = false", b) + } + } + for _, junk := range []Backend{"", "nope", "claude", "anthropic-cli"} { + if junk.Valid() { + t.Errorf("Backend(%q).Valid() = true, want false", junk) + } + } +} + +func TestBackendListContainsEveryBackend(t *testing.T) { + list := BackendList() + for _, b := range AllBackends() { + if !strings.Contains(list, string(b)) { + t.Errorf("BackendList() = %q is missing %q", list, b) + } + } + // All seven backends are enumerated. + if got := strings.Count(list, ",") + 1; got != len(AllBackends()) { + t.Errorf("BackendList() lists %d backends, want %d", got, len(AllBackends())) + } +} + +// TestInferBackendErrorListsAllBackends ensures the "pass --backend explicitly" +// hint stays in sync with AllBackends(). +func TestInferBackendErrorListsAllBackends(t *testing.T) { + _, err := InferBackend("totally-unknown-model") + if err == nil { + t.Fatal("InferBackend(unknown) = nil error, want failure") + } + for _, b := range AllBackends() { + if !strings.Contains(err.Error(), string(b)) { + t.Errorf("InferBackend error %q is missing %q", err.Error(), b) + } + } +} + +func TestBackendKind(t *testing.T) { + api := map[Backend]bool{BackendAnthropic: true, BackendGemini: true, BackendOpenAI: true} + for _, b := range AllBackends() { + want := "cli" + if api[b] { + want = "api" + } + if got := b.Kind(); got != want { + t.Errorf("Backend(%q).Kind() = %q, want %q", b, got, want) + } + } +} + +// TestAuthEnvVars pins each backend to the env vars it authenticates with, +// including the CLI backends that share their parent provider's key. Every +// backend must resolve to a non-empty list so model listing and whoami never +// silently treat a backend as keyless. +func TestAuthEnvVars(t *testing.T) { + cases := map[Backend][]string{ + BackendAnthropic: {"ANTHROPIC_API_KEY"}, + BackendClaudeCLI: {"ANTHROPIC_API_KEY"}, + BackendClaudeAgent: {"ANTHROPIC_API_KEY"}, + BackendOpenAI: {"OPENAI_API_KEY"}, + BackendCodexCLI: {"OPENAI_API_KEY"}, + BackendGemini: {"GEMINI_API_KEY", "GOOGLE_API_KEY"}, + BackendGeminiCLI: {"GEMINI_API_KEY", "GOOGLE_API_KEY"}, + } + for _, b := range AllBackends() { + want, ok := cases[b] + if !ok { + t.Errorf("backend %q has no AuthEnvVars expectation in test", b) + continue + } + got := AuthEnvVars(b) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("AuthEnvVars(%q) = %v, want %v", b, got, want) + } + } +} + +// TestGetAPIKeyFromEnvPrefersFirstSet verifies the priority order and that a CLI +// backend resolves the same key as its parent API backend. +func TestGetAPIKeyFromEnvPrefersFirstSet(t *testing.T) { + t.Setenv("GEMINI_API_KEY", "") + t.Setenv("GOOGLE_API_KEY", "from-google") + if got := GetAPIKeyFromEnv(BackendGemini); got != "from-google" { + t.Errorf("GetAPIKeyFromEnv(gemini) = %q, want fallback to GOOGLE_API_KEY", got) + } + + t.Setenv("ANTHROPIC_API_KEY", "ant-123") + if got := GetAPIKeyFromEnv(BackendClaudeCLI); got != "ant-123" { + t.Errorf("GetAPIKeyFromEnv(claude-cli) = %q, want claude-cli to share ANTHROPIC_API_KEY", got) + } +} + +func TestInferBackendKnownPrefixes(t *testing.T) { + cases := map[string]Backend{ + "claude-agent-sonnet": BackendClaudeAgent, + "claude-code-opus": BackendClaudeCLI, + "claude-sonnet-4": BackendAnthropic, + "gemini-2.0-flash": BackendGemini, + "gemini-cli-pro": BackendGeminiCLI, + "gpt-4o": BackendOpenAI, + "codex-mini": BackendCodexCLI, + } + for model, want := range cases { + got, err := InferBackend(model) + if err != nil { + t.Errorf("InferBackend(%q) error: %v", model, err) + continue + } + if got != want { + t.Errorf("InferBackend(%q) = %q, want %q", model, got, want) + } + } +} diff --git a/pkg/ai/typed.go b/pkg/ai/typed.go index e3a2d88..10842b5 100644 --- a/pkg/ai/typed.go +++ b/pkg/ai/typed.go @@ -8,7 +8,7 @@ import ( func ExecuteTyped[T any](ctx context.Context, p Provider, req Request) (*T, *Response, error) { var zero T - req.StructuredOutput = &zero + req.Prompt.Schema = &zero resp, err := p.Execute(ctx, req) if err != nil { diff --git a/pkg/ai/types.go b/pkg/ai/types.go index 74be2f6..3af94b6 100644 --- a/pkg/ai/types.go +++ b/pkg/ai/types.go @@ -1,199 +1,47 @@ package ai import ( - "context" - "time" + "github.com/flanksource/captain/pkg/api" ) -type Request struct { - SystemPrompt string - AppendSystemPrompt string // claude --append-system-prompt - Prompt string - MaxTokens int - Temperature float64 - StructuredOutput any // nil = text mode, non-nil = JSON schema target - Metadata map[string]string // arbitrary caller metadata - - // Cwd runs the CLI provider's subprocess in this working directory. Empty - // means the calling process's cwd (the prior behaviour), so existing callers - // are unaffected. Honoured by the streaming CLI providers (claude_cli, codex_cli). - Cwd string - - // Per-request CLI knobs honoured by ExecuteStream-capable providers - // (currently claude_cli). Zero values are equivalent to "let the - // provider/CLI use its default" so existing buffered Execute callers - // stay byte-identical. - SessionID string // resume an existing session (claude --session-id) - PermissionMode string // claude --permission-mode (e.g. "acceptEdits") - StrictMCP bool // claude --strict-mcp-config - Verbose bool // claude --verbose (required for stream-json) - MaxTurns int // claude --max-turns (0 = omit, let CLI default) - ReasoningEffort string // codex -c model_reasoning_effort=... ("low" | "medium" | "high"); other providers ignore - - // Safety / sandbox knobs. Zero values mean "use provider/CLI default". - // Each provider translates what it understands; unknowns are ignored or - // surfaced as a config error. - Edit bool // shorthand: acceptEdits + curated Read/Edit/Write/Glob/Grep allowlist - AllowedTools []string // claude --allowedTools / codex: not supported - DisallowedTools []string // claude --disallowedTools / codex: not supported - NoMCP bool // claude --strict-mcp-config + empty inline / codex -c mcp_servers={} - NoHooks bool // claude: requires --bare or --setting-sources / codex --ignore-rules - NoSkills bool // claude --disable-slash-commands / codex --ignore-rules (best effort) - SkillDirs []string // claude --plugin-dir (repeatable) - NoUser bool // claude --setting-sources without "user" / codex --ignore-user-config - NoProject bool // claude --setting-sources without "project,local" / codex --ignore-rules - NoMemory bool // claude: requires --bare / codex --ephemeral - Bare bool // claude --bare / codex composite (--ignore-user-config --ignore-rules --ephemeral) - - // CanUseTool, when set, brokers tool permissions over the stream-json control - // protocol: the streaming provider asks this callback before a tool that needs - // approval runs, and forwards the decision to the agent. Only providers that - // support a server→client permission round-trip honour it (claude-agent); - // others ignore it. A nil callback keeps the auto-approve (bypass) behaviour. - // It is never serialized (the agent process never sees the Go closure). - CanUseTool PermissionFunc `json:"-"` -} - -// PermissionFunc decides whether an agent may run a tool. It is invoked by a -// streaming provider on a can_use_tool control request; returning an error denies -// the tool with the error text fed back to the agent as the reason. -type PermissionFunc func(ctx context.Context, req PermissionRequest) (PermissionDecision, error) - -// PermissionRequest describes the tool an agent wants to run. SessionID is filled -// in by the provider from the live session so a caller can key approvals by it. -type PermissionRequest struct { - Tool string - Input map[string]any - ToolUseID string - SessionID string -} - -// PermissionDecision is the answer to a PermissionRequest. On Allow the tool runs -// (with UpdatedInput substituted when non-nil); otherwise it is denied and Message -// is fed back to the agent as the reason. -type PermissionDecision struct { - Allow bool - Message string - UpdatedInput map[string]any -} - -type Response struct { - Text string - StructuredData any - Model string - Backend Backend - Usage Usage - Duration time.Duration - CacheHit bool - Raw any -} - -type Usage struct { - InputTokens int - OutputTokens int - ReasoningTokens int - CacheReadTokens int - CacheWriteTokens int -} - -func (u Usage) TotalTokens() int { - return u.InputTokens + u.OutputTokens + u.ReasoningTokens + u.CacheReadTokens + u.CacheWriteTokens -} - -type EventKind string +// Request is one model/agent call. It is a type alias for the serializable +// api.Spec (model, prompt, budget, memory, permissions, context, session, +// turns) — ai.Request IS the spec, so providers read the nested fields directly: +// req.Temperature/req.Effort (Model is inlined), req.Prompt.User, +// req.Permissions.Mode, req.Context.Dir, req.Memory.Skills. The structured-output +// Go type rides on Prompt.Schema; the +// runtime-only tool-permission broker callback lives on Config.CanUseTool. +type Request = api.Spec + +// The tool-permission broker, the buffered Response, the streaming Event/EventKind +// and the provider construction Config now live in pkg/api (the stable runtime +// contract). They are re-exported here as aliases so existing call sites and +// clicky/aichat's captainai.* keep compiling unchanged. +type ( + PermissionFunc = api.PermissionFunc + PermissionRequest = api.PermissionRequest + PermissionDecision = api.PermissionDecision + Response = api.Response + EventKind = api.EventKind + Event = api.Event + Config = api.Config +) const ( - EventText EventKind = "text" - EventThinking EventKind = "thinking" - EventToolUse EventKind = "tool_use" - EventToolResult EventKind = "tool_result" - EventResult EventKind = "result" - EventError EventKind = "error" - EventSystem EventKind = "system" - // EventPermission surfaces a tool-permission request brokered via CanUseTool - // so callers can observe what is awaiting approval. Tool/Input/ToolCallID carry - // the requested tool; the decision itself flows back through the CanUseTool - // callback, not through the event stream. - EventPermission EventKind = "permission" + EventText = api.EventText + EventThinking = api.EventThinking + EventToolUse = api.EventToolUse + EventToolResult = api.EventToolResult + EventResult = api.EventResult + EventError = api.EventError + EventSystem = api.EventSystem + EventPermission = api.EventPermission ) -type Event struct { - Kind EventKind - Text string // text content; tool output when Kind == EventToolResult - - Tool string // when Kind == EventToolUse - Input map[string]any // when Kind == EventToolUse - - // ToolCallID correlates a tool call with its result. Set on EventToolUse - // (the call) and EventToolResult (its complete output). Backends that stream - // output incrementally accumulate it and emit a single EventToolResult. - ToolCallID string - - Usage *Usage // when Kind == EventResult - CostUSD float64 // when Kind == EventResult - Success bool // when Kind == EventResult; for EventToolResult, false = the tool errored - SessionID string // when Kind == EventSystem - Model string - Error string // when Kind == EventError - - // Raw carries the backend-native event (e.g. claude.HistoryEntry for the - // claude_cli stream) so renderers can use the rich pretty-printers in - // pkg/claude/tools instead of reformatting from Tool/Input. - Raw any -} - -type Cost struct { - Model string - InputTokens int - OutputTokens int - TotalTokens int - InputCost float64 - OutputCost float64 -} - -func (c Cost) Total() float64 { return c.InputCost + c.OutputCost } - -func (c Cost) Add(other Cost) Cost { - return Cost{ - Model: c.Model, - InputTokens: c.InputTokens + other.InputTokens, - OutputTokens: c.OutputTokens + other.OutputTokens, - TotalTokens: c.TotalTokens + other.TotalTokens, - InputCost: c.InputCost + other.InputCost, - OutputCost: c.OutputCost + other.OutputCost, - } -} - -type Costs []Cost - -func (c Costs) Sum() Cost { - var total Cost - for _, cost := range c { - total = total.Add(cost) - } - return total -} - -func (c Costs) ByModel() map[string]Cost { - m := make(map[string]Cost) - for _, cost := range c { - m[cost.Model] = m[cost.Model].Add(cost) - } - return m -} +// Usage is an alias for the canonical api.Usage (per-call token breakdown). +type Usage = api.Usage -type Config struct { - Model string - Backend Backend // empty = infer from model - APIKey string // empty = env lookup - APIURL string - MaxTokens int - Temperature float64 - CacheDBPath string - CacheTTL time.Duration - NoCache bool - MaxConcurrent int - SessionID string - ProjectName string - BudgetUSD float64 // 0 = no budget -} +// Cost and Costs are aliases for the canonical api types (token + money +// accounting). The methods (Total/Add/Sum/ByModel) live on the api types. +type Cost = api.Cost +type Costs = api.Costs diff --git a/pkg/api/budget.go b/pkg/api/budget.go new file mode 100644 index 0000000..a358041 --- /dev/null +++ b/pkg/api/budget.go @@ -0,0 +1,25 @@ +package api + +import "fmt" + +// Budget caps a run's resource consumption. The zero value is unbounded. +type Budget struct { + // Cost is the maximum spend in USD; 0 = no ceiling. Enforced by aborting once + // accumulated spend would exceed it. (legacy ai.Config.BudgetUSD) + Cost float64 `json:"cost,omitempty" yaml:"cost,omitempty" jsonschema:"minimum=0" pretty:"label=Budget USD"` + + // MaxTokens caps output tokens per model call; 0 = backend default. + // (legacy ai.Request.MaxTokens / ai.Config.MaxTokens) + MaxTokens int `json:"maxTokens,omitempty" yaml:"maxTokens,omitempty" jsonschema:"minimum=0" pretty:"label=Max Tokens"` +} + +// Validate rejects negative ceilings (fail loud). +func (b Budget) Validate() error { + if b.Cost < 0 { + return fmt.Errorf("invalid budget cost %v (must be >= 0)", b.Cost) + } + if b.MaxTokens < 0 { + return fmt.Errorf("invalid maxTokens %d (must be >= 0)", b.MaxTokens) + } + return nil +} diff --git a/pkg/api/context.go b/pkg/api/context.go new file mode 100644 index 0000000..5242bf8 --- /dev/null +++ b/pkg/api/context.go @@ -0,0 +1,39 @@ +package api + +// Context is the workspace a request runs against. Consolidates the legacy +// agent.RunContext{Cwd,Repo,ChangedFiles} and the worktree plugin's inputs/outputs. +type Context struct { + // Dir is the working directory the provider runs in. (RunContext.Cwd / ai.Request.Cwd) + Dir string `json:"dir,omitempty" yaml:"dir,omitempty" pretty:"label=Dir"` + // Diff is the unified diff of the run's changes. (git.Diff / worktree.Result.Diff) + Diff string `json:"diff,omitempty" yaml:"diff,omitempty" pretty:"label=Diff"` + // Files are the repo-relative paths the run changed. (RunContext.ChangedFiles) + Files []string `json:"files,omitempty" yaml:"files,omitempty" pretty:"label=Files"` + // Git pins the run to a repository, revision, and optional pull request. + Git *Git `json:"git,omitempty" yaml:"git,omitempty"` + // Worktree isolates the run in a dedicated git worktree on a new branch. + Worktree *Worktree `json:"worktree,omitempty" yaml:"worktree,omitempty"` + // Env are extra environment variables exported to the agent process for the + // run (e.g. host-app metadata a tool the agent runs reads back). CLI/cmux + // providers that launch a child process apply them; API providers ignore them. + Env map[string]string `json:"env,omitempty" yaml:"env,omitempty" pretty:"label=Env"` +} + +// Git pins a run to a repository, revision, and optional pull request. +type Git struct { + Repo string `json:"repo,omitempty" yaml:"repo,omitempty" pretty:"label=Repo"` // repo root (RunContext.Repo) + SHA string `json:"sha,omitempty" yaml:"sha,omitempty" pretty:"label=SHA"` // base/commit ref + PR string `json:"pr,omitempty" yaml:"pr,omitempty" pretty:"label=PR"` // pull-request number or URL +} + +// Worktree isolates a run in a dedicated git worktree on a new branch. Branch is +// required to enable isolation; Path/Commit are populated on teardown. Mirrors +// the legacy worktree.Plugin/Result. +type Worktree struct { + Branch string `json:"branch,omitempty" yaml:"branch,omitempty" pretty:"label=Branch"` // new branch name + Base string `json:"base,omitempty" yaml:"base,omitempty" pretty:"label=Base"` // base ref; HEAD when empty + CommitMsg string `json:"commitMsg,omitempty" yaml:"commitMsg,omitempty" pretty:"label=Commit Msg"` // commit-all message on teardown + KeepOnExit bool `json:"keepOnExit,omitempty" yaml:"keepOnExit,omitempty" pretty:"label=Keep"` // keep worktree+branch for inspection + Path string `json:"path,omitempty" yaml:"path,omitempty" pretty:"label=Path"` // output: filesystem path + Commit string `json:"commit,omitempty" yaml:"commit,omitempty" pretty:"label=Commit"` // output: commit SHA +} diff --git a/pkg/api/cost.go b/pkg/api/cost.go new file mode 100644 index 0000000..981d781 --- /dev/null +++ b/pkg/api/cost.go @@ -0,0 +1,77 @@ +package api + +// Usage is the token breakdown for one or more model calls. Canonical home; +// pkg/ai re-exports it via `type Usage = api.Usage`. +type Usage struct { + InputTokens int `json:"inputTokens" yaml:"inputTokens" pretty:"label=Input,table"` + OutputTokens int `json:"outputTokens" yaml:"outputTokens" pretty:"label=Output,table"` + ReasoningTokens int `json:"reasoningTokens,omitempty" yaml:"reasoningTokens,omitempty" pretty:"label=Reasoning,table"` + CacheReadTokens int `json:"cacheReadTokens,omitempty" yaml:"cacheReadTokens,omitempty" pretty:"label=Cache Read,table"` + CacheWriteTokens int `json:"cacheWriteTokens,omitempty" yaml:"cacheWriteTokens,omitempty" pretty:"label=Cache Write,table"` +} + +// TotalTokens sums every token bucket. +func (u Usage) TotalTokens() int { + return u.InputTokens + u.OutputTokens + u.ReasoningTokens + u.CacheReadTokens + u.CacheWriteTokens +} + +// Cost is the token + money accounting for a model call. Canonical home; pkg/ai +// re-exports it via `type Cost = api.Cost`. +type Cost struct { + Model string `json:"model,omitempty" yaml:"model,omitempty" pretty:"label=Model,table"` + InputTokens int `json:"inputTokens" yaml:"inputTokens" pretty:"label=Input,table"` + OutputTokens int `json:"outputTokens" yaml:"outputTokens" pretty:"label=Output,table"` + ReasoningTokens int `json:"reasoningTokens,omitempty" yaml:"reasoningTokens,omitempty" pretty:"label=Reasoning,table"` + CacheReadTokens int `json:"cacheReadTokens,omitempty" yaml:"cacheReadTokens,omitempty" pretty:"label=Cache Read,table"` + CacheWriteTokens int `json:"cacheWriteTokens,omitempty" yaml:"cacheWriteTokens,omitempty" pretty:"label=Cache Write,table"` + TotalTokens int `json:"totalTokens" yaml:"totalTokens" pretty:"label=Total,table"` + InputCost float64 `json:"inputCost" yaml:"inputCost" pretty:"label=Input $,table"` + OutputCost float64 `json:"outputCost" yaml:"outputCost" pretty:"label=Output $,table"` + ReasoningCost float64 `json:"reasoningCost,omitempty" yaml:"reasoningCost,omitempty" pretty:"label=Reasoning $,table"` + CacheReadCost float64 `json:"cacheReadCost,omitempty" yaml:"cacheReadCost,omitempty" pretty:"label=Cache Read $,table"` + CacheWriteCost float64 `json:"cacheWriteCost,omitempty" yaml:"cacheWriteCost,omitempty" pretty:"label=Cache Write $,table"` +} + +// Total is the combined cost in USD across every billable bucket. +func (c Cost) Total() float64 { + return c.InputCost + c.OutputCost + c.ReasoningCost + c.CacheReadCost + c.CacheWriteCost +} + +// Add returns the field-wise sum of two costs, keeping the receiver's Model. +func (c Cost) Add(other Cost) Cost { + return Cost{ + Model: c.Model, + InputTokens: c.InputTokens + other.InputTokens, + OutputTokens: c.OutputTokens + other.OutputTokens, + ReasoningTokens: c.ReasoningTokens + other.ReasoningTokens, + CacheReadTokens: c.CacheReadTokens + other.CacheReadTokens, + CacheWriteTokens: c.CacheWriteTokens + other.CacheWriteTokens, + TotalTokens: c.TotalTokens + other.TotalTokens, + InputCost: c.InputCost + other.InputCost, + OutputCost: c.OutputCost + other.OutputCost, + ReasoningCost: c.ReasoningCost + other.ReasoningCost, + CacheReadCost: c.CacheReadCost + other.CacheReadCost, + CacheWriteCost: c.CacheWriteCost + other.CacheWriteCost, + } +} + +// Costs is a list of per-call costs. +type Costs []Cost + +// Sum collapses all entries into a single Cost. +func (c Costs) Sum() Cost { + var total Cost + for _, cost := range c { + total = total.Add(cost) + } + return total +} + +// ByModel groups and sums the costs keyed by model name. +func (c Costs) ByModel() map[string]Cost { + m := make(map[string]Cost) + for _, cost := range c { + m[cost.Model] = m[cost.Model].Add(cost) + } + return m +} diff --git a/pkg/api/cost_test.go b/pkg/api/cost_test.go new file mode 100644 index 0000000..f472940 --- /dev/null +++ b/pkg/api/cost_test.go @@ -0,0 +1,69 @@ +package api + +import "testing" + +func TestUsageTotalTokens(t *testing.T) { + u := Usage{InputTokens: 10, OutputTokens: 20, ReasoningTokens: 5, CacheReadTokens: 3, CacheWriteTokens: 2} + if got := u.TotalTokens(); got != 40 { + t.Errorf("TotalTokens() = %d, want 40", got) + } +} + +func TestCostAddAndTotal(t *testing.T) { + a := Cost{ + Model: "m1", + InputTokens: 100, + OutputTokens: 50, + ReasoningTokens: 20, + CacheReadTokens: 30, + CacheWriteTokens: 10, + TotalTokens: 210, + InputCost: 0.10, + OutputCost: 0.25, + ReasoningCost: 0.03, + CacheReadCost: 0.02, + CacheWriteCost: 0.04, + } + b := Cost{ + Model: "ignored", + InputTokens: 10, + OutputTokens: 5, + ReasoningTokens: 2, + CacheReadTokens: 3, + CacheWriteTokens: 1, + TotalTokens: 21, + InputCost: 0.01, + OutputCost: 0.02, + ReasoningCost: 0.003, + CacheReadCost: 0.002, + CacheWriteCost: 0.004, + } + sum := a.Add(b) + if sum.Model != "m1" { + t.Errorf("Add keeps receiver Model, got %q", sum.Model) + } + if sum.InputTokens != 110 || sum.ReasoningTokens != 22 || sum.CacheReadTokens != 33 || sum.CacheWriteTokens != 11 || sum.TotalTokens != 231 { + t.Errorf("Add tokens wrong: %+v", sum) + } + if got := sum.Total(); got < 0.478999 || got > 0.479001 { + t.Errorf("Total() = %v, want 0.479", got) + } +} + +func TestCostsSumAndByModel(t *testing.T) { + cs := Costs{ + {Model: "a", InputCost: 1, OutputCost: 1}, + {Model: "b", InputCost: 2, OutputCost: 0}, + {Model: "a", InputCost: 0.5, OutputCost: 0.5}, + } + if got := cs.Sum().Total(); got != 5 { + t.Errorf("Sum().Total() = %v, want 5", got) + } + byModel := cs.ByModel() + if got := byModel["a"].Total(); got != 3 { + t.Errorf("ByModel[a].Total() = %v, want 3", got) + } + if got := byModel["b"].Total(); got != 2 { + t.Errorf("ByModel[b].Total() = %v, want 2", got) + } +} diff --git a/pkg/api/enums.go b/pkg/api/enums.go new file mode 100644 index 0000000..422f776 --- /dev/null +++ b/pkg/api/enums.go @@ -0,0 +1,225 @@ +// Package api holds captain's root domain types — the serializable, nested +// specification of a model/agent run (Model, Cost, Budget, Memory, Permissions, +// Context, Prompt) and the Spec that composes them. It is a leaf package: it +// imports only clicky/api (pretty-printing), invopop/jsonschema, and the stdlib, +// never pkg/ai. pkg/ai re-exports the enum/value types here via aliases, so this +// package is the single source of truth for Backend/Effort/Cost. +package api + +import ( + "fmt" + "strings" +) + +// Backend is the provider/runtime that serves a request. This is the canonical +// definition; pkg/ai re-exports it via `type Backend = api.Backend`. +type Backend string + +const ( + BackendAnthropic Backend = "anthropic" + BackendGemini Backend = "gemini" + BackendOpenAI Backend = "openai" + BackendClaudeCLI Backend = "claude-cli" + BackendCodexCLI Backend = "codex-cli" + BackendGeminiCLI Backend = "gemini-cli" + BackendClaudeAgent Backend = "claude-agent" + // BackendClaudeCmux / BackendCodexCmux drive an interactive claude/codex TUI + // inside a tmux/cmux surface (the cmux provider), tailing the session JSONL. + // They are registered providers selected explicitly by a host (gavel), not + // part of AllBackends(): they are not user-facing, carry no prompt-rendering + // fixture (the provider pastes the host's prompt verbatim), and are never + // inferred from a model name. Backend.Valid() is therefore false for them, + // which is fine — nothing in the run path validates the backend. + BackendClaudeCmux Backend = "claude-cmux" + BackendCodexCmux Backend = "codex-cmux" +) + +// AllBackends lists every supported backend in canonical order — the single +// source of truth behind Valid, BackendList, and the help/error strings. +func AllBackends() []Backend { + return []Backend{ + BackendAnthropic, BackendGemini, BackendOpenAI, + BackendClaudeCLI, BackendClaudeAgent, BackendCodexCLI, BackendGeminiCLI, + } +} + +// Valid reports whether b is one of the supported backends. +func (b Backend) Valid() bool { + for _, x := range AllBackends() { + if b == x { + return true + } + } + return false +} + +// Kind classifies a backend as "api" (called directly over HTTP with an API key) +// or "cli" (delegated to an installed coding-agent binary with its own auth). +func (b Backend) Kind() string { + switch b { + case BackendAnthropic, BackendGemini, BackendOpenAI: + return "api" + default: + return "cli" + } +} + +// AuthEnvVars returns the environment variables consulted for a backend's API +// key, in priority order. CLI backends share their parent provider's key. +func AuthEnvVars(b Backend) []string { + switch b { + case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux: + return []string{"ANTHROPIC_API_KEY"} + case BackendOpenAI, BackendCodexCLI, BackendCodexCmux: + return []string{"OPENAI_API_KEY"} + case BackendGemini, BackendGeminiCLI: + return []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} + default: + return nil + } +} + +// BackendList renders AllBackends as a comma-separated string for help/error text. +func BackendList() string { + parts := make([]string, len(AllBackends())) + for i, b := range AllBackends() { + parts[i] = string(b) + } + return strings.Join(parts, ", ") +} + +// InferBackend resolves the backend from a model name prefix, failing loud when +// the name matches nothing (the caller must then pass an explicit backend). +func InferBackend(model string) (Backend, error) { + m := strings.ToLower(model) + + // CLI backends (check before API backends to avoid prefix conflicts). + switch { + case strings.HasPrefix(m, "claude-agent-"): + return BackendClaudeAgent, nil + case strings.HasPrefix(m, "claude-code-"): + return BackendClaudeCLI, nil + case strings.HasPrefix(m, "codex"): + return BackendCodexCLI, nil + case strings.HasPrefix(m, "gemini-cli-"): + return BackendGeminiCLI, nil + } + + switch { + case strings.HasPrefix(m, "claude-"): + return BackendAnthropic, nil + case strings.HasPrefix(m, "gemini-"), strings.HasPrefix(m, "models/gemini-"): + return BackendGemini, nil + case strings.HasPrefix(m, "grok-"): + return BackendCodexCLI, nil + case strings.HasPrefix(m, "gpt-"), strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"), strings.HasPrefix(m, "o4"): + return BackendOpenAI, nil + } + + return "", fmt.Errorf("unable to infer backend from model name: %s (pass an explicit backend: %s)", model, BackendList()) +} + +// Effort is the per-request reasoning effort. captain owns this enum (it adds +// the "xhigh" tier that clicky's aichat.Effort lacks); "" means backend default. +type Effort string + +const ( + EffortNone Effort = "" + EffortLow Effort = "low" + EffortMedium Effort = "medium" + EffortHigh Effort = "high" + EffortXHigh Effort = "xhigh" +) + +// AllEfforts lists the non-empty effort tiers in ascending order. +func AllEfforts() []Effort { + return []Effort{EffortLow, EffortMedium, EffortHigh, EffortXHigh} +} + +// Valid reports whether e is a recognised effort tier (including none/""). +func (e Effort) Valid() bool { + switch e { + case EffortNone, EffortLow, EffortMedium, EffortHigh, EffortXHigh: + return true + default: + return false + } +} + +// Validate fails loud on an unknown effort tier, naming the valid set. +func (e Effort) Validate() error { + if e.Valid() { + return nil + } + return fmt.Errorf("invalid reasoning effort %q; want one of: low, medium, high, xhigh", e) +} + +// ToolMode is the per-tool exposure for one request. +type ToolMode string + +const ( + ToolModeEnabled ToolMode = "enabled" + ToolModeAsk ToolMode = "ask" + ToolModeDisabled ToolMode = "disabled" +) + +// Valid reports whether m is a recognised tool mode. +func (m ToolMode) Valid() bool { + switch m { + case ToolModeEnabled, ToolModeAsk, ToolModeDisabled: + return true + default: + return false + } +} + +// PermissionMode is the base permission posture (claude --permission-mode). +type PermissionMode string + +const ( + PermissionDefault PermissionMode = "default" + PermissionPlan PermissionMode = "plan" + PermissionAcceptEdits PermissionMode = "acceptEdits" + PermissionAuto PermissionMode = "auto" + PermissionBypass PermissionMode = "bypassPermissions" +) + +// AllPermissionModes lists the permission postures in canonical order. +func AllPermissionModes() []PermissionMode { + return []PermissionMode{ + PermissionAcceptEdits, PermissionAuto, PermissionBypass, PermissionDefault, PermissionPlan, + } +} + +// Valid reports whether m is a recognised permission mode (including ""). +func (m PermissionMode) Valid() bool { + if m == "" { + return true + } + for _, x := range AllPermissionModes() { + if m == x { + return true + } + } + return false +} + +// Preset is a named bundle of safety defaults applied before per-tool rules. +type Preset string + +const ( + // PresetEdit applies acceptEdits + a curated Read/Edit/Write/Glob/Grep allowlist. + PresetEdit Preset = "edit" + // PresetBare skips hooks, skills, memory, and ambient settings. + PresetBare Preset = "bare" +) + +// Valid reports whether p is a recognised preset. +func (p Preset) Valid() bool { + switch p { + case PresetEdit, PresetBare: + return true + default: + return false + } +} diff --git a/pkg/api/enums_test.go b/pkg/api/enums_test.go new file mode 100644 index 0000000..17f549e --- /dev/null +++ b/pkg/api/enums_test.go @@ -0,0 +1,61 @@ +package api + +import ( + "reflect" + "testing" +) + +func TestInferBackend(t *testing.T) { + cases := map[string]Backend{ + "claude-sonnet-4-6": BackendAnthropic, + "claude-agent-opus": BackendClaudeAgent, + "claude-code-x": BackendClaudeCLI, + "gpt-5.5": BackendOpenAI, + "o3": BackendOpenAI, + "gemini-2.5-pro": BackendGemini, + "gemini-cli-pro": BackendGeminiCLI, + "codex-gpt-5-codex": BackendCodexCLI, + "grok-2": BackendCodexCLI, + } + for model, want := range cases { + got, err := InferBackend(model) + if err != nil || got != want { + t.Errorf("InferBackend(%q) = %q, %v; want %q", model, got, err, want) + } + } + if _, err := InferBackend("totally-unknown"); err == nil { + t.Error("InferBackend(unknown) should fail loud") + } +} + +func TestEffortValidateIncludesXHigh(t *testing.T) { + for _, e := range []Effort{EffortNone, EffortLow, EffortMedium, EffortHigh, EffortXHigh} { + if err := e.Validate(); err != nil { + t.Errorf("Effort(%q).Validate() = %v, want nil", e, err) + } + } + if err := Effort("max").Validate(); err == nil { + t.Error("Effort(max).Validate() should fail (only low/medium/high/xhigh)") + } + if want := []Effort{EffortLow, EffortMedium, EffortHigh, EffortXHigh}; !reflect.DeepEqual(AllEfforts(), want) { + t.Errorf("AllEfforts() = %v, want %v", AllEfforts(), want) + } +} + +func TestBackendKindAndAuth(t *testing.T) { + if BackendAnthropic.Kind() != "api" || BackendClaudeAgent.Kind() != "cli" { + t.Errorf("Kind() classification wrong: api=%q cli=%q", BackendAnthropic.Kind(), BackendClaudeAgent.Kind()) + } + if got := AuthEnvVars(BackendGemini); !reflect.DeepEqual(got, []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}) { + t.Errorf("AuthEnvVars(gemini) = %v", got) + } + if !BackendOpenAI.Valid() || Backend("nope").Valid() { + t.Error("Backend.Valid() wrong") + } +} + +func TestPermissionModeValid(t *testing.T) { + if !PermissionMode("").Valid() || !PermissionAcceptEdits.Valid() || PermissionMode("yolo").Valid() { + t.Error("PermissionMode.Valid() wrong") + } +} diff --git a/pkg/api/memory.go b/pkg/api/memory.go new file mode 100644 index 0000000..0f7a921 --- /dev/null +++ b/pkg/api/memory.go @@ -0,0 +1,22 @@ +package api + +// Memory controls which ambient context an agent loads. The zero value loads +// everything (default agent behaviour); each Skip* strips one source. +// Consolidates the legacy ai.Request.{SkillDirs,NoSkills,NoProject,NoUser, +// NoMemory,NoHooks,Bare} toggles. +type Memory struct { + // Skills are extra skill/plugin directories to load. (ai.Request.SkillDirs) + Skills []string `json:"skills,omitempty" yaml:"skills,omitempty" pretty:"label=Skill Dirs"` + // SkipProject drops project/local settings. (ai.Request.NoProject) + SkipProject bool `json:"skipProject,omitempty" yaml:"skipProject,omitempty" pretty:"label=Skip Project"` + // SkipUser drops ~/.claude user settings. (ai.Request.NoUser) + SkipUser bool `json:"skipUser,omitempty" yaml:"skipUser,omitempty" pretty:"label=Skip User"` + // SkipSkills disables slash commands/skills. (ai.Request.NoSkills) + SkipSkills bool `json:"skipSkills,omitempty" yaml:"skipSkills,omitempty" pretty:"label=Skip Skills"` + // SkipHooks skips hooks. (ai.Request.NoHooks) + SkipHooks bool `json:"skipHooks,omitempty" yaml:"skipHooks,omitempty" pretty:"label=Skip Hooks"` + // SkipMemory skips auto-memory and CLAUDE.md. (ai.Request.NoMemory) + SkipMemory bool `json:"skipMemory,omitempty" yaml:"skipMemory,omitempty" pretty:"label=Skip Memory"` + // Bare skips hooks, skills, memory, and ambient settings at once. (ai.Request.Bare) + Bare bool `json:"bare,omitempty" yaml:"bare,omitempty" pretty:"label=Bare"` +} diff --git a/pkg/api/model.go b/pkg/api/model.go new file mode 100644 index 0000000..9230f6d --- /dev/null +++ b/pkg/api/model.go @@ -0,0 +1,60 @@ +package api + +import "fmt" + +// Model identifies which LLM serves a request plus the per-request inference +// knobs. Maps onto the legacy ai.Config.Model + ai.Request.{Temperature, +// ReasoningEffort}. +type Model struct { + // Name is the catalog model slug, e.g. "claude-sonnet-4-6"; it drives backend + // inference and pricing lookup. + Name string `json:"model" yaml:"model" jsonschema:"required" pretty:"label=Model"` + + // ID is the fully-qualified provider id when it differs from Name, e.g. the + // genkit "anthropic/claude-sonnet-4-6" or a codex slug. Empty means use Name. + ID string `json:"id,omitempty" yaml:"id,omitempty" pretty:"label=ID"` + + // Backend overrides backend inference from Name; empty means InferBackend(Name). + Backend Backend `json:"backend,omitempty" yaml:"backend,omitempty" pretty:"label=Backend"` + + // Temperature is the sampling temperature in [0,2]. A pointer so an explicit + // 0.0 is distinguishable from "unset" (fail loud, not a silent default). + Temperature *float64 `json:"temperature,omitempty" yaml:"temperature,omitempty" pretty:"label=Temp"` + + // Effort is the reasoning effort for thinking-capable models. + Effort Effort `json:"effort,omitempty" yaml:"effort,omitempty" jsonschema:"enum=,enum=low,enum=medium,enum=high,enum=xhigh" pretty:"label=Effort"` +} + +// ResolveBackend returns Backend when set, otherwise infers it from Name. +func (m Model) ResolveBackend() (Backend, error) { + if m.Backend != "" { + if !m.Backend.Valid() { + return "", fmt.Errorf("invalid backend %q (valid: %s)", m.Backend, BackendList()) + } + return m.Backend, nil + } + return InferBackend(m.Name) +} + +// Temp returns the temperature and whether it was explicitly set (non-nil), so +// providers can distinguish an intentional 0.0 from "unset, use the default". +func (m Model) Temp() (float64, bool) { + if m.Temperature == nil { + return 0, false + } + return *m.Temperature, true +} + +// Validate checks the model name is present and the knobs are in range. +func (m Model) Validate() error { + if m.Name == "" { + return fmt.Errorf("model name is required") + } + if m.Backend != "" && !m.Backend.Valid() { + return fmt.Errorf("invalid backend %q (valid: %s)", m.Backend, BackendList()) + } + if m.Temperature != nil && (*m.Temperature < 0 || *m.Temperature > 2) { + return fmt.Errorf("invalid temperature %v (valid: 0.0-2.0)", *m.Temperature) + } + return m.Effort.Validate() +} diff --git a/pkg/api/permissions.go b/pkg/api/permissions.go new file mode 100644 index 0000000..24742d2 --- /dev/null +++ b/pkg/api/permissions.go @@ -0,0 +1,61 @@ +package api + +import ( + "fmt" + "slices" +) + +// Permissions governs what an agent may do: the base posture, named presets, +// per-tool policy, MCP servers, and plugin directories. Consolidates the legacy +// ai.Request.{PermissionMode,AllowedTools,DisallowedTools,Edit,Bare,NoMCP,SkillDirs}. +type Permissions struct { + // Mode is the base permission posture. (ai.Request.PermissionMode) + Mode PermissionMode `json:"mode,omitempty" yaml:"mode,omitempty" pretty:"label=Mode"` + // Presets are named safety bundles applied before per-tool rules. (--edit/--bare) + Presets []Preset `json:"presets,omitempty" yaml:"presets,omitempty" pretty:"label=Presets"` + // Tools is the per-tool allow/deny/mode policy. + Tools Tools `json:"tools,omitempty" yaml:"tools,omitempty"` + // MCP controls Model-Context-Protocol servers. + MCP MCP `json:"mcp,omitempty" yaml:"mcp,omitempty"` + // Plugins are extra plugin directories (claude --plugin-dir). + Plugins []string `json:"plugins,omitempty" yaml:"plugins,omitempty" pretty:"label=Plugins"` +} + +// Tools is the per-tool policy. Allow/Deny are explicit lists (ai.Request. +// AllowedTools / DisallowedTools); Modes maps a tool to enabled|ask|disabled. +type Tools struct { + Allow []string `json:"allow,omitempty" yaml:"allow,omitempty" pretty:"label=Allow"` + Deny []string `json:"deny,omitempty" yaml:"deny,omitempty" pretty:"label=Deny"` + Modes map[string]ToolMode `json:"modes,omitempty" yaml:"modes,omitempty" pretty:"label=Modes"` +} + +// MCP controls Model-Context-Protocol servers. +type MCP struct { + // Disabled turns off all MCP servers. (ai.Request.NoMCP) + Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty" pretty:"label=Disabled"` + // Servers is an optional allowlist subset of configured servers. + Servers []string `json:"servers,omitempty" yaml:"servers,omitempty" pretty:"label=Servers"` +} + +// HasPreset reports whether the named preset is enabled. +func (p Permissions) HasPreset(x Preset) bool { + return slices.Contains(p.Presets, x) +} + +// Validate checks the mode, presets, and tool modes are recognised. +func (p Permissions) Validate() error { + if !p.Mode.Valid() { + return fmt.Errorf("invalid permission mode %q", p.Mode) + } + for _, preset := range p.Presets { + if !preset.Valid() { + return fmt.Errorf("invalid preset %q (valid: edit, bare)", preset) + } + } + for tool, mode := range p.Tools.Modes { + if !mode.Valid() { + return fmt.Errorf("invalid tool mode %q for tool %q (valid: enabled, ask, disabled)", mode, tool) + } + } + return nil +} diff --git a/pkg/api/pretty.go b/pkg/api/pretty.go new file mode 100644 index 0000000..c456104 --- /dev/null +++ b/pkg/api/pretty.go @@ -0,0 +1,91 @@ +package api + +import ( + "fmt" + + clickyapi "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/api/icons" +) + +// orDash renders an empty string as a dash so blank fields stay visible. +func orDash(s string) string { + if s == "" { + return "—" + } + return s +} + +// Pretty renders a one-line cost summary suitable for a table cell or status line. +func (c Cost) Pretty() clickyapi.Text { + return clickyapi.Text{}. + Appendf("$%.4f", c.Total()).Space(). + Appendf("· %d in / %d out", c.InputTokens, c.OutputTokens) +} + +// summary renders the git pin as "repo@sha (PR pr)". +func (g Git) summary() string { + s := orDash(g.Repo) + if g.SHA != "" { + s += "@" + g.SHA + } + if g.PR != "" { + s += " (PR " + g.PR + ")" + } + return s +} + +// Pretty renders a one-line permissions summary instead of a noisy field dump. +func (p Permissions) Pretty() clickyapi.Text { + mode := string(p.Mode) + if mode == "" { + mode = "default" + } + t := clickyapi.Text{}.Append("mode=").Append(mode, "font-medium") + if n := len(p.Tools.Allow); n > 0 { + t = t.Appendf(" · %d allow", n) + } + if n := len(p.Tools.Deny); n > 0 { + t = t.Appendf(" · %d deny", n) + } + if p.MCP.Disabled { + t = t.Append(" · mcp:off") + } + if len(p.Presets) > 0 { + t = t.Appendf(" · presets=%v", p.Presets) + } + return t +} + +// Pretty renders the workspace as a small tree: the working dir, then git, file +// count, and worktree branch as indented children. +func (c Context) Pretty() clickyapi.Text { + t := clickyapi.Text{}.Add(icons.Folder).Space().Append(orDash(c.Dir), "font-medium") + if c.Git != nil { + t = t.NewLine().Append(" ").Add(icons.Git).Space().Append(c.Git.summary()) + } + if n := len(c.Files); n > 0 { + t = t.NewLine().Append(" ").Add(icons.File).Space().Appendf("%d changed file(s)", n) + } + if c.Worktree != nil && c.Worktree.Branch != "" { + t = t.NewLine().Append(" ").Add(icons.Git).Space().Appendf("worktree %s", c.Worktree.Branch) + } + return t +} + +// Pretty renders the spec as a compact multi-line summary headed by the model. +func (s Spec) Pretty() clickyapi.Text { + t := clickyapi.Text{}.Add(icons.Robot).Space(). + Append("Spec", "font-bold text-blue-600").NewLine(). + Append(" model: ").Append(orDash(s.Name), "font-medium") + if s.Effort != "" { + t = t.Appendf(" (effort=%s)", s.Effort) + } + if s.Budget.Cost > 0 || s.Budget.MaxTokens > 0 { + t = t.NewLine().Append(fmt.Sprintf(" budget: $%.2f / %d tokens", s.Budget.Cost, s.Budget.MaxTokens)) + } + t = t.NewLine().Append(" perms: ").Add(s.Permissions.Pretty()) + if s.Context.Dir != "" || s.Context.Git != nil { + t = t.NewLine().Append(" ").Add(s.Context.Pretty()) + } + return t +} diff --git a/pkg/api/pretty_test.go b/pkg/api/pretty_test.go new file mode 100644 index 0000000..a3bd847 --- /dev/null +++ b/pkg/api/pretty_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "strings" + "testing" +) + +func TestCostPretty(t *testing.T) { + c := Cost{InputTokens: 1200, OutputTokens: 300, InputCost: 0.01, OutputCost: 0.02} + got := c.Pretty().String() + for _, want := range []string{"$0.0300", "1200 in", "300 out"} { + if !strings.Contains(got, want) { + t.Errorf("Cost.Pretty() = %q, want substring %q", got, want) + } + } +} + +func TestSpecPretty(t *testing.T) { + got := sampleSpec().Pretty().String() + for _, want := range []string{"Spec", "claude-sonnet-4-6", "effort=xhigh", "mode=acceptEdits"} { + if !strings.Contains(got, want) { + t.Errorf("Spec.Pretty() = %q, want substring %q", got, want) + } + } +} + +func TestContextPretty(t *testing.T) { + got := sampleSpec().Context.Pretty().String() + for _, want := range []string{"/repo", "abc123", "PR 42", "1 changed file"} { + if !strings.Contains(got, want) { + t.Errorf("Context.Pretty() = %q, want substring %q", got, want) + } + } +} diff --git a/pkg/api/prompt.go b/pkg/api/prompt.go new file mode 100644 index 0000000..def1f60 --- /dev/null +++ b/pkg/api/prompt.go @@ -0,0 +1,31 @@ +package api + +import "fmt" + +// Prompt is the instruction payload: the user prompt plus optional system +// framing, a structured-output schema target, and diagnostic metadata. +// Consolidates the legacy ai.Request.{Prompt,SystemPrompt,AppendSystemPrompt, +// Source,StructuredOutput,Metadata}. +type Prompt struct { + // User is the user prompt. (ai.Request.Prompt) + User string `json:"user" yaml:"user" jsonschema:"required" pretty:"label=Prompt"` + // System is the system prompt. (ai.Request.SystemPrompt) + System string `json:"system,omitempty" yaml:"system,omitempty" pretty:"label=System"` + // AppendSystem is appended to the default system prompt. (ai.Request.AppendSystemPrompt) + AppendSystem string `json:"appendSystem,omitempty" yaml:"appendSystem,omitempty" pretty:"label=Append System"` + // Source identifies the prompt's origin (e.g. a .prompt filename) for diagnostics. + Source string `json:"source,omitempty" yaml:"source,omitempty" pretty:"label=Source"` + // Schema is the Go struct the response must conform to (structured output); a + // runtime-only Go type, never serialized as data. (ai.Request.StructuredOutput) + Schema any `json:"-" yaml:"-" pretty:"-"` + // Metadata is arbitrary caller metadata. (ai.Request.Metadata) + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty" pretty:"label=Metadata"` +} + +// Validate requires a non-empty user prompt. +func (p Prompt) Validate() error { + if p.User == "" { + return fmt.Errorf("prompt text is required") + } + return nil +} diff --git a/pkg/api/runtime_config.go b/pkg/api/runtime_config.go new file mode 100644 index 0000000..3ff3aaa --- /dev/null +++ b/pkg/api/runtime_config.go @@ -0,0 +1,55 @@ +package api + +import ( + "context" + "time" +) + +// PermissionFunc decides whether an agent may run a tool. It is invoked by a +// streaming provider on a can_use_tool control request; returning an error denies +// the tool with the error text fed back to the agent as the reason. +type PermissionFunc func(ctx context.Context, req PermissionRequest) (PermissionDecision, error) + +// PermissionRequest describes the tool an agent wants to run. SessionID is filled +// in by the provider from the live session so a caller can key approvals by it. +type PermissionRequest struct { + Tool string + Input map[string]any + ToolUseID string + SessionID string +} + +// PermissionDecision is the answer to a PermissionRequest. On Allow the tool runs +// (with UpdatedInput substituted when non-nil); otherwise it is denied and Message +// is fed back to the agent as the reason. +type PermissionDecision struct { + Allow bool + Message string + UpdatedInput map[string]any +} + +// Config is the provider construction/runtime config. Model (name/backend/temp/ +// effort) and Budget (cost ceiling, max tokens) come from the serializable spec +// types; the rest are transport/runtime concerns that never belong in Spec. It is +// part of the stable runtime contract: a consumer constructs a Config and hands it +// to NewProvider. +type Config struct { + Model Model // Name, ID, Backend (empty = infer), Temperature, Effort + Budget Budget // Cost (USD ceiling, 0 = unlimited), MaxTokens + APIKey string // empty = env lookup + APIURL string + CacheDBPath string + CacheTTL time.Duration + NoCache bool + MaxConcurrent int + SessionID string + ProjectName string + + // CanUseTool, when set, brokers tool permissions over the stream-json control + // protocol: the streaming provider asks this callback before a tool that needs + // approval runs, and forwards the decision to the agent. Only providers that + // support a server→client permission round-trip honour it (claude-agent); + // others ignore it. A nil callback keeps the auto-approve (bypass) behaviour. + // It is never serialized (the agent process never sees the Go closure). + CanUseTool PermissionFunc `json:"-"` +} diff --git a/pkg/api/runtime_event.go b/pkg/api/runtime_event.go new file mode 100644 index 0000000..daa7ceb --- /dev/null +++ b/pkg/api/runtime_event.go @@ -0,0 +1,59 @@ +package api + +import "time" + +// Response is the result of a buffered (non-streaming) provider execution. +type Response struct { + Text string + StructuredData any + Model string + Backend Backend + Usage Usage + Duration time.Duration + CacheHit bool + Raw any +} + +// EventKind classifies a streaming provider Event. +type EventKind string + +const ( + EventText EventKind = "text" + EventThinking EventKind = "thinking" + EventToolUse EventKind = "tool_use" + EventToolResult EventKind = "tool_result" + EventResult EventKind = "result" + EventError EventKind = "error" + EventSystem EventKind = "system" + // EventPermission surfaces a tool-permission request brokered via CanUseTool + // so callers can observe what is awaiting approval. Tool/Input/ToolCallID carry + // the requested tool; the decision itself flows back through the CanUseTool + // callback, not through the event stream. + EventPermission EventKind = "permission" +) + +// Event is one item in a streaming provider's output channel. +type Event struct { + Kind EventKind + Text string // text content; tool output when Kind == EventToolResult + + Tool string // when Kind == EventToolUse + Input map[string]any // when Kind == EventToolUse + + // ToolCallID correlates a tool call with its result. Set on EventToolUse + // (the call) and EventToolResult (its complete output). Backends that stream + // output incrementally accumulate it and emit a single EventToolResult. + ToolCallID string + + Usage *Usage // when Kind == EventResult + CostUSD float64 // when Kind == EventResult + Success bool // when Kind == EventResult; for EventToolResult, false = the tool errored + SessionID string // when Kind == EventSystem + Model string + Error string // when Kind == EventError + + // Raw carries the backend-native event (e.g. claude.HistoryEntry for the + // claude_cli stream) so renderers can use the rich pretty-printers in + // pkg/claude/tools instead of reformatting from Tool/Input. + Raw any +} diff --git a/pkg/api/runtime_provider.go b/pkg/api/runtime_provider.go new file mode 100644 index 0000000..1700429 --- /dev/null +++ b/pkg/api/runtime_provider.go @@ -0,0 +1,17 @@ +package api + +import "context" + +// Provider executes a single buffered model/agent call. Spec is the serializable +// request (model, prompt, budget, permissions, context, session). +type Provider interface { + Execute(ctx context.Context, req Spec) (*Response, error) + GetModel() string + GetBackend() Backend +} + +// StreamingProvider adds incremental streaming over the buffered Provider. +type StreamingProvider interface { + Provider + ExecuteStream(ctx context.Context, req Spec) (<-chan Event, error) +} diff --git a/pkg/api/runtime_registry.go b/pkg/api/runtime_registry.go new file mode 100644 index 0000000..25e0daa --- /dev/null +++ b/pkg/api/runtime_registry.go @@ -0,0 +1,62 @@ +package api + +import ( + "fmt" + "os" +) + +// ProviderFactory constructs a Provider for a backend from a Config. +type ProviderFactory func(cfg Config) (Provider, error) + +// factories is the process-global backend registry. It is unexported on purpose: +// it is internal plumbing, mutated only through RegisterProvider and read only +// through NewProvider, so the public api surface never exposes the map itself. +var factories = map[Backend]ProviderFactory{} + +// RegisterProvider registers a factory for a backend. Provider packages call it +// from init(); the registration is global and read by NewProvider. +func RegisterProvider(backend Backend, factory ProviderFactory) { + factories[backend] = factory +} + +// NewProvider constructs the registered provider for cfg's backend, inferring the +// backend from the model name and filling the API key from the environment when +// unset. +func NewProvider(cfg Config) (Provider, error) { + backend := cfg.Model.Backend + + if cfg.Model.Name == "" { + return nil, fmt.Errorf("model cannot be empty; pass --model or run `captain configure` to set a default") + } + + if backend == "" { + var err error + backend, err = InferBackend(cfg.Model.Name) + if err != nil { + return nil, err + } + } + cfg.Model.Backend = backend + + factory, ok := factories[backend] + if !ok { + return nil, fmt.Errorf("no provider registered for backend: %s", backend) + } + + if cfg.APIKey == "" { + cfg.APIKey = GetAPIKeyFromEnv(backend) + } + + return factory(cfg) +} + +// GetAPIKeyFromEnv returns the first non-empty value among a backend's auth +// environment variables. +func GetAPIKeyFromEnv(backend Backend) string { + for _, envVar := range AuthEnvVars(backend) { + if key := os.Getenv(envVar); key != "" { + return key + } + } + return "" +} diff --git a/pkg/api/schema.go b/pkg/api/schema.go new file mode 100644 index 0000000..6ec34c5 --- /dev/null +++ b/pkg/api/schema.go @@ -0,0 +1,23 @@ +package api + +import ( + "encoding/json" + + "github.com/invopop/jsonschema" +) + +// Schema reflects v into a JSON Schema, referencing nested types under $defs, +// honouring `jsonschema:"required,enum=,minimum="` constraints, and using the +// json field names as property names (which match the yaml names byte-for-byte). +func Schema(v any) *jsonschema.Schema { + r := &jsonschema.Reflector{ + FieldNameTag: "json", + RequiredFromJSONSchemaTags: true, + } + return r.Reflect(v) +} + +// SchemaJSON marshals Schema(v) to indented JSON. +func SchemaJSON(v any) ([]byte, error) { + return json.MarshalIndent(Schema(v), "", " ") +} diff --git a/pkg/api/schema_test.go b/pkg/api/schema_test.go new file mode 100644 index 0000000..b74fc55 --- /dev/null +++ b/pkg/api/schema_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestSchemaJSON checks the generated JSON schema is valid JSON, enumerates the +// effort tiers (including the new "xhigh"), and references the nested types. +func TestSchemaJSON(t *testing.T) { + data, err := SchemaJSON(&Spec{}) + if err != nil { + t.Fatalf("SchemaJSON: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("schema is not valid JSON: %v", err) + } + s := string(data) + for _, want := range []string{"xhigh", "Permissions", "Context", "Budget", `"maxTokens"`} { + if !strings.Contains(s, want) { + t.Errorf("schema missing %q\n%s", want, s) + } + } + if _, ok := doc["$defs"]; !ok { + t.Errorf("schema should reference nested types under $defs\n%s", s) + } +} + +func TestSchemaJSON_PromptRequired(t *testing.T) { + data, _ := SchemaJSON(&Prompt{}) + if !strings.Contains(string(data), `"required"`) || !strings.Contains(string(data), `"user"`) { + t.Errorf("Prompt schema should mark user required:\n%s", data) + } +} diff --git a/pkg/api/spec.go b/pkg/api/spec.go new file mode 100644 index 0000000..9faaf95 --- /dev/null +++ b/pkg/api/spec.go @@ -0,0 +1,45 @@ +package api + +import "fmt" + +// Spec is the complete, structured specification of one model/agent run — the +// canonical shape the CLI flags and saved config build, and that the legacy +// ai.Request + ai.Config project onto. Model/Budget derive from ai.Config; +// Prompt/Memory/Permissions/Context derive from ai.Request. +// +// Runtime-only concerns (API key, cache settings, the CanUseTool callback) are +// deliberately excluded — they live in a separate runtime config, not in this +// serializable domain object. +type Spec struct { + Model `json:",inline" yaml:",inline"` + Prompt Prompt `json:"prompt" yaml:"prompt"` + Budget Budget `json:"budget,omitempty" yaml:"budget,omitempty"` + Memory Memory `json:"memory,omitempty" yaml:"memory,omitempty"` + Permissions Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty"` + Context Context `json:"context,omitempty" yaml:"context,omitempty"` + + // SessionID resumes an existing session. (ai.Request.SessionID) + SessionID string `json:"sessionId,omitempty" yaml:"sessionId,omitempty" pretty:"label=Session"` + // MaxTurns caps agent turns; 0 = backend default. (ai.Request.MaxTurns) + MaxTurns int `json:"maxTurns,omitempty" yaml:"maxTurns,omitempty" pretty:"label=Max Turns"` +} + +// Validate runs each component's validation, failing loud on the first error. +func (s Spec) Validate() error { + if err := s.Model.Validate(); err != nil { + return fmt.Errorf("model: %w", err) + } + if err := s.Prompt.Validate(); err != nil { + return fmt.Errorf("prompt: %w", err) + } + if err := s.Budget.Validate(); err != nil { + return fmt.Errorf("budget: %w", err) + } + if err := s.Permissions.Validate(); err != nil { + return fmt.Errorf("permissions: %w", err) + } + if s.MaxTurns < 0 || s.MaxTurns > 100 { + return fmt.Errorf("invalid maxTurns %d (valid: 0-100, 0=backend default)", s.MaxTurns) + } + return nil +} diff --git a/pkg/api/spec_test.go b/pkg/api/spec_test.go new file mode 100644 index 0000000..bb35fe9 --- /dev/null +++ b/pkg/api/spec_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func floatPtr(f float64) *float64 { return &f } + +// sampleSpec is a fully-populated spec used across round-trip and render tests. +func sampleSpec() Spec { + return Spec{ + Model: Model{Name: "claude-sonnet-4-6", Backend: BackendAnthropic, Temperature: floatPtr(0.7), Effort: EffortXHigh}, + Prompt: Prompt{User: "refactor the parser", System: "be precise", Source: "cli"}, + Budget: Budget{Cost: 2.5, MaxTokens: 8000}, + Memory: Memory{Skills: []string{"/skills/a"}, SkipUser: true}, + Permissions: Permissions{ + Mode: PermissionAcceptEdits, + Presets: []Preset{PresetEdit}, + Tools: Tools{Allow: []string{"Read", "Edit"}, Modes: map[string]ToolMode{"Bash": ToolModeAsk}}, + MCP: MCP{Disabled: true}, + }, + Context: Context{Dir: "/repo", Files: []string{"parser.go"}, Git: &Git{Repo: "/repo", SHA: "abc123", PR: "42"}}, + SessionID: "sess-1", + MaxTurns: 5, + } +} + +func TestSpec_JSONRoundTrip(t *testing.T) { + in := sampleSpec() + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out Spec + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(in, out) { + t.Errorf("JSON round-trip mismatch:\n in=%+v\nout=%+v", in, out) + } +} + +func TestSpec_YAMLRoundTrip(t *testing.T) { + in := sampleSpec() + data, err := yaml.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out Spec + if err := yaml.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(in, out) { + t.Errorf("YAML round-trip mismatch:\n in=%+v\nout=%+v", in, out) + } +} + +// TestSpec_JSONFieldNames pins the nested camelCase wire shape callers depend on. +func TestSpec_JSONFieldNames(t *testing.T) { + data, _ := json.Marshal(sampleSpec()) + s := string(data) + for _, want := range []string{`"model"`, `"prompt"`, `"budget"`, `"permissions"`, `"context"`, `"maxTokens"`, `"skipUser"`, `"sessionId"`, `"effort":"xhigh"`} { + if !strings.Contains(s, want) { + t.Errorf("marshalled spec missing %s\ngot: %s", want, s) + } + } +} + +// TestSpec_ModelInlined pins that the embedded Model is flattened to the spec's +// top level (json:",inline"/yaml:",inline") for both encoders, rather than nested +// under a "model" object — so "model"/"effort" are top-level keys. +func TestSpec_ModelInlined(t *testing.T) { + for _, tc := range []struct { + name string + marshal func(any) ([]byte, error) + unmarshal func([]byte, any) error + }{ + {"json", json.Marshal, json.Unmarshal}, + {"yaml", yaml.Marshal, yaml.Unmarshal}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.marshal(sampleSpec()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var top map[string]any + if err := tc.unmarshal(data, &top); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + for _, key := range []string{"model", "effort", "prompt", "budget"} { + if _, ok := top[key]; !ok { + t.Errorf("top-level key %q missing (Model not inlined?); keys=%v", key, keysOf(top)) + } + } + if _, nested := top["name"]; nested { + t.Errorf("found top-level %q; model name should serialize as \"model\"", "name") + } + }) + } +} + +func keysOf(m map[string]any) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +} + +func TestSpec_Validate(t *testing.T) { + cases := []struct { + name string + mutate func(*Spec) + wantErr string + }{ + {"valid", func(*Spec) {}, ""}, + {"missing model", func(s *Spec) { s.Model.Name = "" }, "model name is required"}, + {"bad effort", func(s *Spec) { s.Model.Effort = "extreme" }, "invalid reasoning effort"}, + {"temp too high", func(s *Spec) { s.Model.Temperature = floatPtr(3) }, "0.0-2.0"}, + {"bad backend", func(s *Spec) { s.Model.Backend = "nope" }, "invalid backend"}, + {"empty prompt", func(s *Spec) { s.Prompt.User = "" }, "prompt text is required"}, + {"negative budget", func(s *Spec) { s.Budget.Cost = -1 }, "budget cost"}, + {"too many turns", func(s *Spec) { s.MaxTurns = 200 }, "0-100"}, + {"bad permission mode", func(s *Spec) { s.Permissions.Mode = "yolo" }, "invalid permission mode"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := sampleSpec() + tc.mutate(&s) + err := s.Validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Validate() = %v, want mention of %q", err, tc.wantErr) + } + }) + } +} diff --git a/pkg/claude/history.go b/pkg/claude/history.go index ed941a2..14348b7 100644 --- a/pkg/claude/history.go +++ b/pkg/claude/history.go @@ -8,17 +8,24 @@ import ( // HistoryEntry represents a single line in a JSONL transcript type HistoryEntry struct { - ParentUUID string `json:"parentUuid,omitempty"` - SessionID string `json:"sessionId"` - Version string `json:"version,omitempty"` - CWD string `json:"cwd,omitempty"` - GitBranch string `json:"gitBranch,omitempty"` - Message Message `json:"message"` - UUID string `json:"uuid"` - Timestamp string `json:"timestamp"` - IsSidechain bool `json:"isSidechain,omitempty"` - AgentID string `json:"agentId,omitempty"` - RawLine json.RawMessage `json:"-"` + ParentUUID string `json:"parentUuid,omitempty"` + SessionID string `json:"sessionId"` + Version string `json:"version,omitempty"` + CWD string `json:"cwd,omitempty"` + GitBranch string `json:"gitBranch,omitempty"` + Message Message `json:"message"` + UUID string `json:"uuid"` + Timestamp string `json:"timestamp"` + IsSidechain bool `json:"isSidechain,omitempty"` + AgentID string `json:"agentId,omitempty"` + // Slug is Claude Code's session title slug. It doubles as the basename of the + // exit-plan-mode plan file (~/.claude/plans/.md) when a plan exists. + Slug string `json:"slug,omitempty"` + // PlanFilePath is set on synthetic entries surfaced from plan_mode / + // plan_mode_exit attachments; it points at the session's plan file even when + // the transcript carries no ExitPlanMode tool call or plan-file write. + PlanFilePath string `json:"-"` + RawLine json.RawMessage `json:"-"` } // Message represents a conversation message diff --git a/pkg/claude/plan.go b/pkg/claude/plan.go new file mode 100644 index 0000000..e4b7c1e --- /dev/null +++ b/pkg/claude/plan.go @@ -0,0 +1,114 @@ +package claude + +import ( + "encoding/json" + "path/filepath" + "strings" +) + +// GetPlansDir returns the directory where Claude Code stores exit-plan-mode +// plan files (~/.claude/plans). +func GetPlansDir() string { + return filepath.Join(GetClaudeHome(), "plans") +} + +// SessionPlan is the exit-plan-mode plan recovered from a Claude session +// transcript. +type SessionPlan struct { + // Path is the absolute plan file path (~/.claude/plans/.md). + Path string + // Slug is the session/plan slug. + Slug string + // Content is the most recent plan markdown captured inline from the + // transcript (the ExitPlanMode plan field or a write to the plan file). + // Callers should prefer the on-disk file when it exists, as the agent may + // have revised it after exiting plan mode. + Content string + // Explicit reports whether the session produced a plan signal — an + // ExitPlanMode call, a write to the plans directory, or a plan-mode + // attachment. When false the path was derived only from the session slug, + // which every session carries regardless of whether it ever planned. + Explicit bool +} + +// PlanFromEntries recovers the plan associated with a Claude session from its +// history entries. It returns nil when no plan path can be determined. +// +// The path is taken from the ExitPlanMode tool's planFilePath when present, +// otherwise from a write to ~/.claude/plans, otherwise from a plan-mode +// attachment, otherwise derived from the session slug. When only the slug is +// available, Explicit is false and callers should confirm the plan exists on +// disk before treating the session as having a plan. +func PlanFromEntries(entries []HistoryEntry) *SessionPlan { + var slug, path, content string + explicit := false + + for _, entry := range entries { + if entry.Slug != "" { + slug = entry.Slug + } + if entry.PlanFilePath != "" { + path = entry.PlanFilePath + explicit = true + } + for _, block := range entry.Message.Content { + if block.Type != ContentTypeToolUse { + continue + } + input := decodeBlockInput(block.Input) + switch block.Name { + case "ExitPlanMode": + explicit = true + if p := mapString(input, "planFilePath"); p != "" { + path = p + } + if c := mapString(input, "plan"); c != "" { + content = c + } + case "Write", "Edit": + fp := mapString(input, "file_path") + if !isPlanPath(fp) { + continue + } + explicit = true + path = fp + // Write carries the full plan; Edit only a fragment, so let the + // on-disk file (which an Edit implies exists) be the content source. + if block.Name == "Write" { + if c := mapString(input, "content"); c != "" { + content = c + } + } + } + } + } + + if path == "" && slug != "" { + path = filepath.Join(GetPlansDir(), slug+".md") + } + if path == "" { + return nil + } + return &SessionPlan{Path: path, Slug: slug, Content: content, Explicit: explicit} +} + +// isPlanPath reports whether a file path lives in a Claude Code plans directory. +func isPlanPath(path string) bool { + return path != "" && strings.Contains(filepath.ToSlash(path), "/.claude/plans/") +} + +func decodeBlockInput(raw json.RawMessage) map[string]any { + if len(raw) == 0 { + return nil + } + var out map[string]any + _ = json.Unmarshal(raw, &out) + return out +} + +func mapString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/pkg/claude/plan_test.go b/pkg/claude/plan_test.go new file mode 100644 index 0000000..3fcdb55 --- /dev/null +++ b/pkg/claude/plan_test.go @@ -0,0 +1,131 @@ +package claude + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func toolUseEntry(name string, input map[string]any) HistoryEntry { + raw, _ := json.Marshal(input) + return HistoryEntry{ + Message: Message{ + Role: MessageRoleAssistant, + Content: []ContentBlock{{Type: ContentTypeToolUse, Name: name, Input: raw}}, + }, + } +} + +func TestPlanFromEntries(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + plansDir := filepath.Join(home, ".claude", "plans") + plan := func(slug string) string { return filepath.Join(plansDir, slug+".md") } + + tests := []struct { + name string + entries []HistoryEntry + wantNil bool + wantPath string + wantContent string + wantExplicit bool + wantSlug string + }{ + { + name: "exit plan mode carries path and content", + entries: []HistoryEntry{ + {Slug: "tidy-otter"}, + toolUseEntry("ExitPlanMode", map[string]any{ + "planFilePath": plan("tidy-otter"), + "plan": "# Plan\n\nstep one", + }), + }, + wantPath: plan("tidy-otter"), + wantContent: "# Plan\n\nstep one", + wantExplicit: true, + wantSlug: "tidy-otter", + }, + { + name: "write to plans dir captures full content", + entries: []HistoryEntry{ + toolUseEntry("Write", map[string]any{ + "file_path": plan("brave-fox"), + "content": "# Brave fox plan", + }), + }, + wantPath: plan("brave-fox"), + wantContent: "# Brave fox plan", + wantExplicit: true, + }, + { + name: "edit to plans dir sets path but not fragment content", + entries: []HistoryEntry{ + toolUseEntry("Edit", map[string]any{ + "file_path": plan("calm-lynx"), + "new_string": "patched line", + }), + }, + wantPath: plan("calm-lynx"), + wantContent: "", + wantExplicit: true, + }, + { + name: "slug only derives path but is not explicit", + entries: []HistoryEntry{{Slug: "quiet-heron"}}, + wantPath: plan("quiet-heron"), + wantExplicit: false, + wantSlug: "quiet-heron", + }, + { + name: "plan-mode attachment is explicit", + entries: []HistoryEntry{ + {Slug: "swift-marten", PlanFilePath: plan("swift-marten")}, + }, + wantPath: plan("swift-marten"), + wantExplicit: true, + wantSlug: "swift-marten", + }, + { + name: "later exit plan mode supersedes earlier inline content", + entries: []HistoryEntry{ + toolUseEntry("ExitPlanMode", map[string]any{"planFilePath": plan("revised"), "plan": "v1"}), + toolUseEntry("ExitPlanMode", map[string]any{"planFilePath": plan("revised"), "plan": "v2 final"}), + }, + wantPath: plan("revised"), + wantContent: "v2 final", + wantExplicit: true, + }, + { + name: "write outside plans dir is not a plan", + entries: []HistoryEntry{ + toolUseEntry("Write", map[string]any{"file_path": "/repo/main.go", "content": "x"}), + }, + wantNil: true, + }, + { + name: "no signals returns nil", + entries: []HistoryEntry{{Message: Message{Role: MessageRoleUser}}}, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PlanFromEntries(tt.entries) + if tt.wantNil { + assert.Nil(t, got) + return + } + require.NotNil(t, got) + assert.Equal(t, tt.wantPath, got.Path) + assert.Equal(t, tt.wantContent, got.Content) + assert.Equal(t, tt.wantExplicit, got.Explicit) + if tt.wantSlug != "" { + assert.Equal(t, tt.wantSlug, got.Slug) + } + }) + } +} diff --git a/pkg/claude/reader.go b/pkg/claude/reader.go index 152a2c6..6f1eb76 100644 --- a/pkg/claude/reader.go +++ b/pkg/claude/reader.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" ) // ReadHistoryFile reads all entries from a JSONL history file @@ -96,11 +97,13 @@ type streamJSONLine struct { TimestampSnake string `json:"timestamp,omitempty"` // Session-file fields - SessionIDCamel string `json:"sessionId,omitempty"` - UUID string `json:"uuid,omitempty"` - Version string `json:"version,omitempty"` - CWD string `json:"cwd,omitempty"` - GitBranch string `json:"gitBranch,omitempty"` + SessionIDCamel string `json:"sessionId,omitempty"` + UUID string `json:"uuid,omitempty"` + Version string `json:"version,omitempty"` + CWD string `json:"cwd,omitempty"` + GitBranch string `json:"gitBranch,omitempty"` + Slug string `json:"slug,omitempty"` + Attachment json.RawMessage `json:"attachment,omitempty"` } func (sj streamJSONLine) sessionID() string { @@ -122,7 +125,38 @@ var knownSessionStorageTypes = map[string]bool{ "last-prompt": true, "permission-mode": true, "agent-name": true, - "attachment": true, +} + +// planAttachment is the plan-mode attachment Claude Code writes when entering +// ("plan_mode") and exiting ("plan_mode_exit") plan mode. It names the plan file +// even for sessions whose transcript carries no ExitPlanMode tool call. +type planAttachment struct { + Type string `json:"type"` + PlanFilePath string `json:"planFilePath"` +} + +// attachmentEntry surfaces plan-mode attachments as a synthetic, message-less +// HistoryEntry carrying the plan file path. Non-plan attachments are dropped. +func attachmentEntry(sj streamJSONLine) []HistoryEntry { + if len(sj.Attachment) == 0 { + return nil + } + var a planAttachment + if err := json.Unmarshal(sj.Attachment, &a); err != nil { + return nil + } + if !strings.HasPrefix(a.Type, "plan_mode") || a.PlanFilePath == "" { + return nil + } + return single(HistoryEntry{ + SessionID: sj.sessionID(), + UUID: sj.UUID, + Timestamp: sj.timestamp(), + CWD: sj.CWD, + GitBranch: sj.GitBranch, + Slug: sj.Slug, + PlanFilePath: a.PlanFilePath, + }) } // dispatchEvent routes a typed line to zero, one, or more HistoryEntry rows. @@ -147,6 +181,7 @@ func dispatchEvent(sj streamJSONLine, raw []byte, lineNo int) []HistoryEntry { Version: sj.Version, CWD: sj.CWD, GitBranch: sj.GitBranch, + Slug: sj.Slug, Message: msg, }} if errEntry, ok := apiErrorFromAssistantLine(sj, raw); ok { @@ -194,6 +229,9 @@ func dispatchEvent(sj streamJSONLine, raw []byte, lineNo int) []HistoryEntry { case "ai-title": return single(syntheticEntry(sj, "SessionTitle", raw, []string{"aiTitle"})) + + case "attachment": + return attachmentEntry(sj) } if knownSessionStorageTypes[sj.Type] { diff --git a/pkg/claude/reader_test.go b/pkg/claude/reader_test.go index 4ae3f14..6be136d 100644 --- a/pkg/claude/reader_test.go +++ b/pkg/claude/reader_test.go @@ -67,6 +67,36 @@ not valid json } } +func TestReadHistory_SlugAndPlanModeAttachment(t *testing.T) { + // Session-file lines carry a `type`, so they route through dispatchEvent. + // The plan_mode_exit attachment surfaces as a message-less entry holding the + // plan path; the non-plan attachment is dropped. + jsonl := `{"type":"assistant","sessionId":"s1","uuid":"a1","timestamp":"2026-06-01T10:00:00Z","slug":"keen-otter","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}} +{"type":"attachment","sessionId":"s1","uuid":"at1","timestamp":"2026-06-01T10:00:01Z","slug":"keen-otter","cwd":"/repo","attachment":{"type":"plan_mode_exit","planFilePath":"/home/u/.claude/plans/keen-otter.md","planExists":true}} +{"type":"attachment","sessionId":"s1","uuid":"at2","attachment":{"type":"file_edit","path":"/x"}}` + + entries, err := ReadHistory(strings.NewReader(jsonl)) + if err != nil { + t.Fatalf("ReadHistory failed: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries (assistant + plan attachment), got %d", len(entries)) + } + if entries[0].Slug != "keen-otter" { + t.Errorf("assistant slug = %q, want keen-otter", entries[0].Slug) + } + plan := entries[1] + if plan.PlanFilePath != "/home/u/.claude/plans/keen-otter.md" { + t.Errorf("attachment PlanFilePath = %q", plan.PlanFilePath) + } + if plan.Slug != "keen-otter" || plan.CWD != "/repo" { + t.Errorf("attachment slug/cwd = %q/%q", plan.Slug, plan.CWD) + } + if len(plan.Message.Content) != 0 { + t.Errorf("plan attachment entry should be message-less, got %+v", plan.Message.Content) + } +} + func TestReadStreamJSON(t *testing.T) { input := `{"type":"system","subtype":"init","cwd":"/tmp","session_id":"sess-1","uuid":"u-init","model":"claude-sonnet-4-20250514","tools":["Bash","Read"]} {"type":"user","message":{"role":"user","content":[{"type":"text","text":"list files"}]},"session_id":"sess-1","uuid":"msg-1"} diff --git a/pkg/claude/session.go b/pkg/claude/session.go index f784ef9..8fc4cdb 100644 --- a/pkg/claude/session.go +++ b/pkg/claude/session.go @@ -132,6 +132,30 @@ func FindAgentTranscripts(projectsDir, currentDir string, searchAll bool) ([]str return findProjectFiles(projectsDir, currentDir, searchAll, "*", "subagents", "agent-*.jsonl") } +func filterSessionFilesBySessionID(files []string, filter Filter) []string { + if !filter.HasSessionIDFilter() { + return files + } + out := make([]string, 0, len(files)) + for _, file := range files { + if filter.MatchesSessionID(sessionIDFromTranscriptPath(file)) { + out = append(out, file) + } + } + return out +} + +func sessionIDFromTranscriptPath(path string) string { + parts := strings.Split(filepath.ToSlash(path), "/") + for i, part := range parts { + if part == "subagents" && i > 0 { + return parts[i-1] + } + } + base := filepath.Base(path) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + // findProjectFiles globs each in-scope project directory under projectsDir for // files matching the given path segments (joined onto the project dir). func findProjectFiles(projectsDir, currentDir string, searchAll bool, globParts ...string) ([]string, error) { @@ -310,6 +334,7 @@ func ParseHistory(currentDir string, searchAll bool, filter Filter) (*ParseResul sessionFiles = append(sessionFiles, agentFiles...) } } + sessionFiles = filterSessionFilesBySessionID(sessionFiles, filter) result := &ParseResult{ SessionsFound: len(sessionFiles), @@ -393,6 +418,7 @@ func ParseHistoryTools(currentDir string, searchAll bool, filter Filter) ([]tool sessionFiles = append(sessionFiles, agentFiles...) } } + sessionFiles = filterSessionFilesBySessionID(sessionFiles, filter) var allToolUses []ToolUse uses := make(map[string][]HistoryEntry) @@ -435,10 +461,15 @@ type SessionCost struct { } func ParseCosts(currentDir string, searchAll bool, since *time.Time) ([]SessionCost, error) { + return ParseCostsWithFilter(currentDir, searchAll, since, Filter{}) +} + +func ParseCostsWithFilter(currentDir string, searchAll bool, since *time.Time, filter Filter) ([]SessionCost, error) { sessionFiles, err := FindSessionFiles(GetProjectsDir(), currentDir, searchAll) if err != nil { return nil, err } + sessionFiles = filterSessionFilesBySessionID(sessionFiles, filter) type sessionKey struct { sessionID string @@ -530,10 +561,16 @@ func ParseCosts(currentDir string, searchAll bool, since *time.Time) ([]SessionC // ParseCostsDetailed extends ParseCosts with context categorization and per-tool token breakdown. func ParseCostsDetailed(currentDir string, searchAll bool, since *time.Time) ([]SessionCost, error) { + return ParseCostsDetailedWithFilter(currentDir, searchAll, since, Filter{}) +} + +// ParseCostsDetailedWithFilter extends ParseCostsWithFilter with context categorization and per-tool token breakdown. +func ParseCostsDetailedWithFilter(currentDir string, searchAll bool, since *time.Time, filter Filter) ([]SessionCost, error) { sessionFiles, err := FindSessionFiles(GetProjectsDir(), currentDir, searchAll) if err != nil { return nil, err } + sessionFiles = filterSessionFilesBySessionID(sessionFiles, filter) type sessionKey struct { sessionID string diff --git a/pkg/claude/session_test.go b/pkg/claude/session_test.go index 8ddcbad..83f0e1b 100644 --- a/pkg/claude/session_test.go +++ b/pkg/claude/session_test.go @@ -84,3 +84,23 @@ func TestFindSessionFiles_CaseInsensitiveProjectDirectory(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{sessionPath}, files) } + +func TestFilterSessionFilesBySessionID(t *testing.T) { + projectsDir := t.TempDir() + projectDir := filepath.Join(projectsDir, NormalizePath("/work/project")) + sessionA := "11111111-1111-1111-1111-111111111111" + sessionB := "22222222-2222-2222-2222-222222222222" + mainA := filepath.Join(projectDir, sessionA+".jsonl") + mainB := filepath.Join(projectDir, sessionB+".jsonl") + agentA := filepath.Join(projectDir, sessionA, "subagents", "agent-a.jsonl") + agentB := filepath.Join(projectDir, sessionB, "subagents", "agent-b.jsonl") + + for _, path := range []string{mainA, mainB, agentA, agentB} { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) + } + + files := []string{mainA, mainB, agentA, agentB} + assert.Equal(t, files, filterSessionFilesBySessionID(files, Filter{})) + assert.Equal(t, []string{mainA, agentA}, filterSessionFilesBySessionID(files, Filter{SessionID: "11111111"})) +} diff --git a/pkg/claude/tools/plan.go b/pkg/claude/tools/plan.go index da7f60c..9a6e00f 100644 --- a/pkg/claude/tools/plan.go +++ b/pkg/claude/tools/plan.go @@ -22,8 +22,7 @@ func (t *PlanTool) Pretty() api.Text { if t.Denied { text = text.Append(" ✗", "text-red-500") } - filename := strings.TrimSuffix(filepath.Base(t.FilePath()), ".md") - text = text.Append(" "+filename, "text-cyan-400") + text = text.Append(" "+planName(t.FilePath()), "text-cyan-400") content := t.Str("content") if content == "" { content = t.Str("new_string") @@ -48,6 +47,15 @@ func (t *PlanTool) Detail() api.Textable { func (t *PlanTool) ExtractPath() string { return t.Rel(t.FilePath()) } +// planName is the human-facing plan label: the plan file's basename without its +// .md extension. +func planName(path string) string { + if path == "" { + return "" + } + return strings.TrimSuffix(filepath.Base(path), ".md") +} + func extractMarkdownTitle(content string) string { for _, line := range strings.SplitN(content, "\n", 20) { if strings.HasPrefix(line, "# ") { @@ -61,13 +69,25 @@ func extractMarkdownTitle(content string) string { type ExitPlanTool struct{ BaseTool } -func (t *ExitPlanTool) Name() string { return "Plan" } -func (t *ExitPlanTool) Category() string { return "" } -func (t *ExitPlanTool) FilePath() string { return "" } -func (t *ExitPlanTool) ExtractPath() string { return "" } +func (t *ExitPlanTool) Name() string { return "Plan" } +func (t *ExitPlanTool) Category() string { return "" } + +// FilePath returns the plan file the agent exited plan mode against, so plan +// writes and the exit row attribute to the same file. +func (t *ExitPlanTool) FilePath() string { return t.Str("planFilePath") } + +func (t *ExitPlanTool) ExtractPath() string { + if p := t.FilePath(); p != "" { + return t.Rel(p) + } + return "" +} func (t *ExitPlanTool) Pretty() api.Text { text := t.header(planIcon, "plan", planColor) + if name := planName(t.FilePath()); name != "" { + text = text.Append(" "+name, "text-cyan-400") + } if t.Denied { text = text.Append(" ✗", "text-red-500") } else { diff --git a/pkg/claude/tools/plan_test.go b/pkg/claude/tools/plan_test.go new file mode 100644 index 0000000..bb3d41f --- /dev/null +++ b/pkg/claude/tools/plan_test.go @@ -0,0 +1,35 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExitPlanTool_SurfacesPlanFilePath(t *testing.T) { + tool := NewTool(BaseTool{ + RawTool: "ExitPlanMode", + ProjectRoot: "/home/u", + Input: map[string]any{ + "planFilePath": "/home/u/.claude/plans/tidy-otter.md", + "plan": "# Tidy otter", + }, + }) + + exit, ok := tool.(*ExitPlanTool) + if !ok { + t.Fatalf("expected *ExitPlanTool, got %T", tool) + } + assert.Equal(t, "/home/u/.claude/plans/tidy-otter.md", exit.FilePath()) + assert.Equal(t, ".claude/plans/tidy-otter.md", exit.ExtractPath()) + + pretty := exit.Pretty().String() + assert.Contains(t, pretty, "tidy-otter") + assert.Contains(t, pretty, "approved") +} + +func TestExitPlanTool_NoPlanFilePath(t *testing.T) { + exit := &ExitPlanTool{} + assert.Equal(t, "", exit.FilePath()) + assert.Equal(t, "", exit.ExtractPath()) +} diff --git a/pkg/claude/tooluse.go b/pkg/claude/tooluse.go index 5e5a997..294a339 100644 --- a/pkg/claude/tooluse.go +++ b/pkg/claude/tooluse.go @@ -42,12 +42,13 @@ type ToolUse struct { // Filter defines criteria for filtering tool uses type Filter struct { - Tools []string - Paths []string - Since *time.Time - Before *time.Time - Limit int - SessionID string // exact or prefix match against ToolUse.SessionID + Tools []string + Paths []string + Since *time.Time + Before *time.Time + Limit int + SessionID string // exact or prefix match against ToolUse.SessionID + SessionIDs []string // exact or prefix match against ToolUse.SessionID // IncludeAgents, when set, makes ParseHistory also read nested sub-agent // transcripts (/subagents/agent-*.jsonl) for the in-scope sessions. IncludeAgents bool @@ -57,10 +58,35 @@ type Filter struct { // criterion. An empty filter matches everything; otherwise an exact match or a // prefix match (so the short IDs printed by `captain info` work) succeeds. func (f Filter) MatchesSessionID(sessionID string) bool { - if f.SessionID == "" { + if matchesSessionID(sessionID, f.SessionID) { return true } - return sessionID == f.SessionID || strings.HasPrefix(sessionID, f.SessionID) + for _, filter := range f.SessionIDs { + if matchesSessionID(sessionID, filter) { + return true + } + } + return !f.HasSessionIDFilter() +} + +func (f Filter) HasSessionIDFilter() bool { + if strings.TrimSpace(f.SessionID) != "" { + return true + } + for _, filter := range f.SessionIDs { + if strings.TrimSpace(filter) != "" { + return true + } + } + return false +} + +func matchesSessionID(sessionID, filter string) bool { + filter = strings.TrimSpace(filter) + if filter == "" { + return false + } + return sessionID == filter || strings.HasPrefix(sessionID, filter) } const denialPrefix = "The user doesn't want to proceed with this tool use." diff --git a/pkg/claude/tooluse_test.go b/pkg/claude/tooluse_test.go index 3542c29..19ca79b 100644 --- a/pkg/claude/tooluse_test.go +++ b/pkg/claude/tooluse_test.go @@ -103,6 +103,11 @@ func TestFilterToolUses(t *testing.T) { filter: Filter{SessionID: "sess-bbbb"}, expected: []string{"Write", "Edit"}, }, + { + name: "multiple session filters", + filter: Filter{SessionIDs: []string{"sess-aaaa", "sess-bbbb"}}, + expected: []string{"Bash", "Read", "Write", "Edit"}, + }, { name: "session and tool combined", filter: Filter{SessionID: "sess-aaaa1111", Tools: []string{"Read"}}, diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index d9c3dc0..5de1dba 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -3,12 +3,15 @@ package cli import ( "context" "fmt" + "io" "os" "strconv" + "strings" "time" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/ai/middleware" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/claude" "github.com/flanksource/captain/pkg/claude/tools" @@ -28,15 +31,31 @@ func loadSavedAI() captainconfig.AIDefaults { type AIProviderOptions struct { Model string `flag:"model" help:"Model name, e.g. claude-sonnet-4, gemini-2.0-flash (defaults to the value saved by 'captain configure')" short:"m"` - Backend string `flag:"backend" help:"Force backend: anthropic, gemini, codex-cli, claude-cli, gemini-cli (default: inferred from model or saved by 'captain configure')" short:"b"` + Backend string `flag:"backend" help:"Force backend: anthropic|gemini|openai|claude-cli|claude-agent|codex-cli|gemini-cli (default: inferred from model or saved by 'captain configure')" short:"b"` APIKey string `flag:"api-key" help:"API key (env: ANTHROPIC_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY)"` NoCache bool `flag:"no-cache" help:"Disable response caching"` Budget string `flag:"budget" help:"Max spend in USD, 0=unlimited" default:"0"` } -func (o AIProviderOptions) ToConfig() ai.Config { +// parseFloatFlag parses a numeric string flag, returning a descriptive error +// instead of silently coercing malformed input to zero. +func parseFloatFlag(name, val string) (float64, error) { + if val == "" { + return 0, nil + } + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return 0, fmt.Errorf("invalid --%s %q: %w", name, val, err) + } + return f, nil +} + +func (o AIProviderOptions) ToConfig() (ai.Config, error) { saved := loadSavedAI() - budget, _ := strconv.ParseFloat(o.Budget, 64) + budget, err := parseFloatFlag("budget", o.Budget) + if err != nil { + return ai.Config{}, err + } model := o.Model if model == "" { @@ -46,19 +65,19 @@ func (o AIProviderOptions) ToConfig() ai.Config { if backend == "" { backend = saved.Backend } + if backend != "" && !ai.Backend(backend).Valid() { + return ai.Config{}, fmt.Errorf("invalid --backend %q (valid: %s)", backend, ai.BackendList()) + } if budget == 0 { budget = saved.BudgetUSD } - noCache := o.NoCache || saved.NoCache - cfg := ai.Config{ - Model: model, - Backend: ai.Backend(backend), - APIKey: o.APIKey, - NoCache: noCache, - BudgetUSD: budget, - } - return cfg + return ai.Config{ + Model: api.Model{Name: model, Backend: ai.Backend(backend)}, + Budget: api.Budget{Cost: budget}, + APIKey: o.APIKey, + NoCache: o.NoCache || saved.NoCache, + }, nil } // AIRuntimeOptions binds the per-invocation knobs every AI command shares — @@ -74,116 +93,205 @@ func (o AIProviderOptions) ToConfig() ai.Config { type AIRuntimeOptions struct { AIProviderOptions - MaxTokens int `flag:"max-tokens" help:"Maximum output tokens" default:"4096"` - Temperature string `flag:"temperature" help:"Sampling temperature" default:"0"` + MaxTokens int `flag:"max-tokens" help:"Maximum output tokens (0 = saved default or 4096)"` + Temperature string `flag:"temperature" help:"Sampling temperature (0.0-2.0)" default:"0"` + Effort string `flag:"effort" help:"Reasoning effort: low|medium|high|xhigh (codex/genkit; others ignore)"` + MaxTurns int `flag:"max-turns" help:"Max agent turns 0-100, 0 = provider default (claude-agent)"` + Resume string `flag:"resume" help:"Resume an existing session by id (claude-agent, codex)"` Edit bool `flag:"edit" help:"Safe defaults: acceptEdits + Read/Edit/Write/Glob/Grep allowlist"` AllowedTools []string `flag:"allowed-tools" help:"Override --edit's built-in allowlist (claude only)"` DisallowedTools []string `flag:"disallowed-tools" help:"Tools to deny (claude only)"` PermissionMode string `flag:"permission-mode" help:"acceptEdits|auto|bypassPermissions|default|plan"` - MCP bool `flag:"mcp" help:"Set --mcp=false to disable all MCP servers" default:"true"` - Hooks bool `flag:"hooks" help:"Set --hooks=false to skip hooks" default:"true"` - Skills bool `flag:"skills" help:"Set --skills=false to disable slash commands" default:"true"` + NoMCP bool `flag:"no-mcp" help:"Disable all MCP servers"` + NoHooks bool `flag:"no-hooks" help:"Skip hooks"` + NoSkills bool `flag:"no-skills" help:"Disable slash commands"` SkillDirs []string `flag:"skill-dir" help:"Additional skill/plugin directory (repeatable)"` - User bool `flag:"user" help:"Load user-level settings" default:"true"` - Project bool `flag:"project" help:"Load project-level settings" default:"true"` - Memory bool `flag:"memory" help:"Load auto-memory and CLAUDE.md" default:"true"` + NoUser bool `flag:"no-user" help:"Skip user-level settings"` + NoProject bool `flag:"no-project" help:"Skip project-level settings"` + NoMemory bool `flag:"no-memory" help:"Skip auto-memory and CLAUDE.md"` Bare bool `flag:"bare" help:"Skip hooks, skills, memory, and ambient settings"` } +var validPermissionModes = []string{"acceptEdits", "auto", "bypassPermissions", "default", "plan"} + +func validatePermissionMode(s string) error { + if s == "" { + return nil + } + for _, m := range validPermissionModes { + if s == m { + return nil + } + } + return fmt.Errorf("invalid --permission-mode %q (valid: %s)", s, strings.Join(validPermissionModes, "|")) +} + type AIPromptOptions struct { AIRuntimeOptions - Prompt string `flag:"prompt" help:"Prompt text" short:"p" required:"true" stdin:"true"` - System string `flag:"system" help:"System prompt" short:"s"` - AppendSystem string `flag:"append-system" help:"Append text to the default system prompt"` - Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` - NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"` + // File is a positional .prompt template path rendered through pkg/ai/prompt. + // The frontmatter sets model + any ai.Request option; the body is the prompt. + File string `args:"true" help:"Path to a .prompt template to render"` + Prompt string `flag:"prompt" help:"Prompt text, or @file to load and render a .prompt template" short:"p"` + System string `flag:"system" help:"System prompt" short:"s"` + AppendSystem string `flag:"append-system" help:"Append text to the default system prompt"` + Var []string `flag:"var" help:"Template variable key=value (repeatable)" short:"V"` + Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` + NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"` } type AIPromptResult struct { - Text string `json:"text" pretty:"label=Response"` - Model string `json:"model" pretty:"label=Model"` - Backend string `json:"backend" pretty:"label=Backend"` - Input int `json:"inputTokens" pretty:"label=Input Tokens"` - Output int `json:"outputTokens" pretty:"label=Output Tokens"` - CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` - Duration string `json:"duration" pretty:"label=Duration"` + Text string `json:"text" pretty:"label=Response"` + Model string `json:"model" pretty:"label=Model"` + Backend string `json:"backend" pretty:"label=Backend"` + Dir string `json:"dir,omitempty" pretty:"label=Dir"` + SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + Input ai.Request `json:"input" pretty:"-"` + InputTokens int `json:"inputTokens" pretty:"label=Input Tokens"` + Output int `json:"outputTokens" pretty:"label=Output Tokens"` + CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` + Duration string `json:"duration" pretty:"label=Duration"` } -// ToRequest translates the runtime knobs into the typed ai.Request. Truthy -// flags like --mcp/--hooks/--memory invert into the No*-style fields the -// providers consume. When --bare is set, it implicitly strips memory/hooks/ -// skills/user/project regardless of those flags' values (claude --bare -// composes them) so we let the provider decide the final argv. +// ToRequest translates the runtime knobs into the typed ai.Request, overlaying +// saved defaults from ~/.captain.yaml onto unset fields. Precedence is +// flag > saved > built-in: max-tokens uses the explicit flag when > 0, else the +// saved default, else 4096; --effort uses the flag when set, else saved. +// The ambient toggles are negative flags (--no-mcp, …) that compose with the +// saved No* defaults via OR, so either a flag or a saved default switches a +// feature off; re-enabling a saved-off feature is done via `captain configure`. // // systemPrompt / appendSystemPrompt / userPrompt are passed explicitly so -// non-prompt callers (gavel's ai-fix loop) can build them per-iteration -// without leaking those fields into the shared runtime struct. -// -// Saved defaults from ~/.captain.yaml overlay onto unset fields. For the -// boolean toggles, "saved off" wins over "flag default on" because clicky -// does not yet expose a Changed() bit. -// -// WORKAROUND(no-flag-changed-bit): The boolean toggles default to true, so -// we cannot tell whether --mcp=true was passed explicitly or inherited from -// the default. As a result, when the saved config has NoMCP=true the user -// cannot force MCP back on from the command line by passing --mcp=true alone -// — they must edit ~/.captain.yaml or rerun `captain configure`. -// Correct fix: thread clicky's per-flag Changed() bit (or a tri-state flag -// type) through AIRuntimeOptions so we can distinguish "explicitly true" -// from "default true". Ref: discussed with user 2026-05-07. -func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt string) ai.Request { +// non-prompt callers (gavel's ai-fix loop) can build them per-iteration without +// leaking those fields into the shared runtime struct. Parse/validation errors +// are returned rather than silently coerced to zero values. +func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt string) (ai.Request, error) { saved := loadSavedAI() - temperature, _ := strconv.ParseFloat(o.Temperature, 64) + + temperature, err := parseFloatFlag("temperature", o.Temperature) + if err != nil { + return ai.Request{}, err + } + if temperature < 0 || temperature > 2 { + return ai.Request{}, fmt.Errorf("invalid --temperature %v (valid: 0.0-2.0)", temperature) + } + if o.MaxTurns < 0 || o.MaxTurns > 100 { + return ai.Request{}, fmt.Errorf("invalid --max-turns %d (valid: 0-100, 0=provider default)", o.MaxTurns) + } + if err := validatePermissionMode(o.PermissionMode); err != nil { + return ai.Request{}, err + } maxTokens := o.MaxTokens - if maxTokens == 4096 && saved.MaxTokens != 0 { + switch { + case maxTokens > 0: // explicit flag wins + case saved.MaxTokens != 0: maxTokens = saved.MaxTokens + default: + maxTokens = 4096 } - effort := saved.ReasoningEffort + effort := o.Effort + if effort == "" { + effort = saved.ReasoningEffort + } + if err := api.Effort(effort).Validate(); err != nil { + return ai.Request{}, fmt.Errorf("invalid --effort %q: %w", effort, err) + } - return ai.Request{ - SystemPrompt: systemPrompt, - AppendSystemPrompt: appendSystemPrompt, - Prompt: userPrompt, - MaxTokens: maxTokens, - Temperature: temperature, - ReasoningEffort: effort, - PermissionMode: o.PermissionMode, - Edit: o.Edit, - AllowedTools: o.AllowedTools, - DisallowedTools: o.DisallowedTools, - NoMCP: !o.MCP || saved.NoMCP, - NoHooks: !o.Hooks || saved.NoHooks, - NoSkills: !o.Skills || saved.NoSkills, - SkillDirs: o.SkillDirs, - NoUser: !o.User || saved.NoUser, - NoProject: !o.Project || saved.NoProject, - NoMemory: !o.Memory || saved.NoMemory, - Bare: o.Bare, + // Temperature is *float64 on the model: leave it nil for the default 0 so an + // explicit 0 and "unset" hash identically (matches the prior flat behaviour); + // no captain provider sends temperature to the model, only the cache key. + var temperaturePtr *float64 + if temperature != 0 { + t := temperature + temperaturePtr = &t + } + + perms := api.Permissions{ + Mode: api.PermissionMode(o.PermissionMode), + Tools: api.Tools{Allow: o.AllowedTools, Deny: o.DisallowedTools}, + MCP: api.MCP{Disabled: o.NoMCP || saved.NoMCP}, + } + if o.Edit { + perms.Presets = append(perms.Presets, api.PresetEdit) } + + return ai.Request{ + Prompt: api.Prompt{System: systemPrompt, AppendSystem: appendSystemPrompt, User: userPrompt}, + Model: api.Model{Temperature: temperaturePtr, Effort: api.Effort(effort)}, + Budget: api.Budget{MaxTokens: maxTokens}, + Memory: api.Memory{ + Skills: o.SkillDirs, + SkipHooks: o.NoHooks || saved.NoHooks, + SkipSkills: o.NoSkills || saved.NoSkills, + SkipUser: o.NoUser || saved.NoUser, + SkipProject: o.NoProject || saved.NoProject, + SkipMemory: o.NoMemory || saved.NoMemory, + Bare: o.Bare, + }, + Permissions: perms, + SessionID: o.Resume, + MaxTurns: o.MaxTurns, + }, nil } // ToRequest delegates to AIRuntimeOptions.ToRequest, lifting the prompt // fields the prompt-shaped command owns onto the typed request. -func (o AIPromptOptions) ToRequest() ai.Request { +func (o AIPromptOptions) ToRequest() (ai.Request, error) { return o.AIRuntimeOptions.ToRequest(o.System, o.AppendSystem, o.Prompt) } func RunAIPrompt(opts AIPromptOptions) (any, error) { - if opts.Prompt == "" { - return nil, fmt.Errorf("prompt text required (use --prompt or pipe via stdin)") + var stdin string + if claude.IsStdinPiped() { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("read stdin: %w", err) + } + stdin = string(b) } - cfg := opts.ToConfig() - if cfg.Model == "" { - return nil, fmt.Errorf("no model: pass --model or run 'captain configure' to set a default") + tmpl, usedStdin, err := resolvePromptTemplate(opts, stdin) + if err != nil { + return nil, err + } + + data, err := parseVars(opts.Var) + if err != nil { + return nil, err + } + if s := strings.TrimSpace(stdin); s != "" && !usedStdin { + data["input"] = s } - req := opts.ToRequest() + fileReq, fileCfg, err := tmpl.Render(data, nil) + if err != nil { + return nil, err + } + + req, cfg, err := overlayCLI(fileReq, fileCfg, opts) + if err != nil { + return nil, err + } + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + if err := normalizePromptContextDir(&req, cwd); err != nil { + return nil, err + } + if req.Prompt.User == "" { + return nil, fmt.Errorf("prompt text required (use --prompt/-p, a file arg, or pipe via stdin)") + } + if cfg.Model.Name == "" { + return nil, fmt.Errorf("no model: pass --model or run 'captain configure' to set a default") + } + if err := req.Validate(); err != nil { + return nil, err + } p, err := ai.NewProvider(cfg) if err != nil { @@ -212,13 +320,19 @@ func runBuffered(ctx context.Context, p ai.Provider, req ai.Request) (any, error if err != nil { return nil, err } + model := firstNonEmpty(resp.Model, p.GetModel(), req.Name) + backend := firstNonEmpty(string(resp.Backend), string(p.GetBackend()), string(req.Backend)) + input := resolvedPromptInput(req, model, backend, req.SessionID) return AIPromptResult{ - Text: resp.Text, - Model: resp.Model, - Backend: string(resp.Backend), - Input: resp.Usage.InputTokens, - Output: resp.Usage.OutputTokens, - Duration: time.Since(start).Round(time.Millisecond).String(), + Text: resp.Text, + Model: model, + Backend: backend, + Dir: input.Context.Dir, + SessionID: input.SessionID, + Input: input, + InputTokens: resp.Usage.InputTokens, + Output: resp.Usage.OutputTokens, + Duration: time.Since(start).Round(time.Millisecond).String(), }, nil } @@ -233,6 +347,7 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) cost float64 backend = string(sp.GetBackend()) model = sp.GetModel() + session = req.SessionID ) renderer := newLineRenderer(os.Stderr, 8) loop, err := ai.RunUntil(ctx, ai.LoopOptions{ @@ -248,6 +363,9 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) if ev.Model != "" { model = ev.Model } + if ev.SessionID != "" { + session = ev.SessionID + } renderEvent(os.Stderr, renderer, ev) if ev.Kind == ai.EventText { text += ev.Text @@ -266,17 +384,38 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) if loop.StopReason == "error" { return nil, fmt.Errorf("streaming loop stopped: %s", loop.StopReason) } + if session == "" && len(loop.Iterations) > 0 { + session = loop.Iterations[0].SessionID + } + input := resolvedPromptInput(req, model, backend, session) return AIPromptResult{ - Text: text, - Model: model, - Backend: backend, - Input: usage.InputTokens, - Output: usage.OutputTokens, - CostUSD: cost, - Duration: time.Since(start).Round(time.Millisecond).String(), + Text: text, + Model: model, + Backend: backend, + Dir: input.Context.Dir, + SessionID: input.SessionID, + Input: input, + InputTokens: usage.InputTokens, + Output: usage.OutputTokens, + CostUSD: cost, + Duration: time.Since(start).Round(time.Millisecond).String(), }, nil } +func resolvedPromptInput(req ai.Request, model, backend, sessionID string) ai.Request { + out := req + if model != "" { + out.Name = model + } + if backend != "" { + out.Backend = api.Backend(backend) + } + if sessionID != "" { + out.SessionID = sessionID + } + return out +} + // renderEvent writes a human-readable representation of an ai.Event to w. // When the event carries a claude.HistoryEntry in Raw, route through the // shared lineRenderer so live `captain ai prompt` output matches @@ -432,8 +571,11 @@ type AITestResult struct { } func RunAITest(opts AITestOptions) (any, error) { - cfg := opts.ToConfig() - if cfg.Model == "" { + cfg, err := opts.ToConfig() + if err != nil { + return nil, err + } + if cfg.Model.Name == "" { return nil, fmt.Errorf("no model: pass --model or run 'captain configure' to set a default") } p, err := ai.NewProvider(cfg) @@ -453,8 +595,8 @@ func RunAITest(opts AITestOptions) (any, error) { start := time.Now() _, err = p.Execute(ctx, ai.Request{ - Prompt: "Respond with exactly: ok", - MaxTokens: 10, + Prompt: api.Prompt{User: "Respond with exactly: ok"}, + Budget: api.Budget{MaxTokens: 10}, }) result := AITestResult{ diff --git a/pkg/cli/ai_agent.go b/pkg/cli/ai_agent.go new file mode 100644 index 0000000..8641aaa --- /dev/null +++ b/pkg/cli/ai_agent.go @@ -0,0 +1,227 @@ +package cli + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent" + "github.com/flanksource/captain/pkg/ai/agent/verify" + "github.com/flanksource/captain/pkg/ai/agent/worktree" + "github.com/flanksource/captain/pkg/ai/middleware" + "github.com/flanksource/captain/pkg/ai/prompt" +) + +// AIAgentOptions runs the plugin-based agent loop (pkg/ai/agent) from the CLI: +// an iterative AI run wrapped by optional verifiers (re-run on failure), a +// throwaway git worktree, a commit step, and an LLM judge. It embeds +// AIRuntimeOptions so it inherits model/backend/tool/effort selection. +type AIAgentOptions struct { + AIRuntimeOptions + + Prompt string `flag:"prompt" help:"Task prompt" short:"p" required:"true" stdin:"true"` + System string `flag:"system" help:"System prompt" short:"s"` + AppendSystem string `flag:"append-system" help:"Append text to the default system prompt"` + Timeout string `flag:"timeout" help:"Overall run timeout" default:"600s"` + + MaxIterations int `flag:"max-iterations" help:"Max verify-and-rerun iterations" default:"1"` + Verify []string `flag:"verify" help:"Shell command run after each turn; non-zero exit triggers a re-run (repeatable)"` + Scope string `flag:"scope" help:"Verifier scope: changed|all" default:"all"` + Worktree bool `flag:"worktree" help:"Run inside a throwaway git worktree/branch"` + Branch string `flag:"branch" help:"Worktree branch name (with --worktree; default: captain/agent-)"` + Commit bool `flag:"commit" help:"Commit changes on the worktree branch (requires --worktree)"` + Judge string `flag:"judge" help:"LLM-judge rubric; fails a turn when the judge rejects the result"` +} + +type AIAgentResult struct { + Iterations int `json:"iterations" pretty:"label=Iterations"` + StopReason string `json:"stopReason" pretty:"label=Stop Reason"` + Passed bool `json:"passed" pretty:"label=Passed"` + CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` + SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + Branch string `json:"branch,omitempty" pretty:"label=Branch"` + ChangedFiles []string `json:"changedFiles,omitempty" pretty:"label=Changed Files"` + Duration string `json:"duration" pretty:"label=Duration"` +} + +// judgeTemplate is the inline dotprompt the LLM judge renders. The rubric, cwd +// and changed-file list are supplied as data; the {ok,reason,feedback} schema is +// enforced by LLMJudgeVerifier's structured-output target. +const judgeTemplate = `{{role "system"}} +You are a strict reviewer. Decide whether the change satisfies the rubric. +Set ok=true only if it fully satisfies the rubric; otherwise ok=false with +specific, actionable feedback the agent can act on next turn. +{{role "user"}} +Rubric: {{rubric}} +Working directory: {{cwd}} +Changed files: {{changed}}` + +func scopeFromFlag(s string) (agent.Scope, error) { + return agent.ParseScope(s) +} + +// buildAgentPlugins assembles the verify/worktree/judge plugins from the flags. +// The worktree plugin owns the commit (it commits the branch on teardown), so +// --commit requires --worktree. +func buildAgentPlugins(opts AIAgentOptions, p ai.Provider) ([]agent.Plugin, *worktree.Plugin, error) { + if opts.Commit && !opts.Worktree { + return nil, nil, fmt.Errorf("--commit requires --worktree (captain commits the isolated branch, not your working tree)") + } + + var plugins []agent.Plugin + var wt *worktree.Plugin + if opts.Worktree { + branch := opts.Branch + if branch == "" { + branch = fmt.Sprintf("captain/agent-%d", time.Now().Unix()) + } + wt = &worktree.Plugin{Branch: branch} + if opts.Commit { + wt.CommitMsg = "captain: " + commitSubject(opts.Prompt) + } + plugins = append(plugins, wt) + } + + for _, cmd := range opts.Verify { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + continue + } + plugins = append(plugins, verify.New("verify:"+cmd, &verify.CmdVerifier{Cmd: "sh", Args: []string{"-c", cmd}})) + } + + if opts.Judge != "" { + judge := &verify.LLMJudgeVerifier{ + Provider: p, + Prompt: prompt.Load(judgeTemplate), + Data: func(cwd string, changed []string) map[string]any { + return map[string]any{"cwd": cwd, "changed": strings.Join(changed, ", "), "rubric": opts.Judge} + }, + } + plugins = append(plugins, verify.New("judge", judge)) + } + + return plugins, wt, nil +} + +func RunAIAgent(opts AIAgentOptions) (any, error) { + if opts.Prompt == "" { + return nil, fmt.Errorf("prompt text required (use --prompt or pipe via stdin)") + } + + cfg, err := opts.ToConfig() + if err != nil { + return nil, err + } + if cfg.Model.Name == "" { + return nil, fmt.Errorf("no model: pass --model or run 'captain configure' to set a default") + } + scope, err := scopeFromFlag(opts.Scope) + if err != nil { + return nil, err + } + // Validate runtime knobs (temperature/permission-mode/effort) and + // snapshot the base request once; Build only varies the prompt per turn. + baseReq, err := opts.ToRequest(opts.System, opts.AppendSystem, opts.Prompt) + if err != nil { + return nil, err + } + + p, err := ai.NewProvider(cfg) + if err != nil { + return nil, err + } + if p, err = middleware.Wrap(p, middleware.WithLogging()); err != nil { + return nil, err + } + sp, ok := p.(ai.StreamingProvider) + if !ok { + return nil, fmt.Errorf("backend %s does not support the streaming agent loop", cfg.Model.Backend) + } + + plugins, wt, err := buildAgentPlugins(opts, p) + if err != nil { + return nil, err + } + + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + + renderer := newLineRenderer(os.Stderr, 8) + runner := &agent.Runner{ + Provider: sp, + Plugins: plugins, + Loop: ai.LoopOptions{ + MaxIterations: opts.MaxIterations, + OnEvent: func(_ int, ev ai.Event) { renderEvent(os.Stderr, renderer, ev) }, + }, + Build: func(_ *agent.RunContext, _ int, _ *ai.LoopIteration, feedback string) ai.Request { + req := baseReq + if feedback != "" { + req.Prompt.User = opts.Prompt + "\n\n[verifier feedback]\n" + feedback + "\n\nFix the issues above and continue." + } + return req + }, + Repo: cwd, + Cwd: cwd, + Scope: scope, + } + + timeout, _ := time.ParseDuration(opts.Timeout) + if timeout <= 0 { + timeout = 600 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + start := time.Now() + result, runErr := runner.Run(ctx) + if result == nil { + return nil, runErr + } + + res := AIAgentResult{ + ChangedFiles: result.ChangedFiles, + SessionID: result.SessionID, + Duration: time.Since(start).Round(time.Millisecond).String(), + Passed: verdictsPassed(result.Verdicts, runErr), + } + if result.Loop != nil { + res.Iterations = len(result.Loop.Iterations) + res.StopReason = result.Loop.StopReason + res.CostUSD = result.Loop.TotalCost + } + if wt != nil && wt.Result != nil { + res.Branch = wt.Result.Branch + } + // A failed loop/verify is surfaced through the result (Passed=false), not as + // a command error, so --format output is still rendered. A genuine provider + // or plugin error is returned. + if runErr != nil && len(result.Verdicts) == 0 { + return res, runErr + } + return res, nil +} + +// verdictsPassed reports whether the last verifier verdict was OK. With no +// verifiers, the run passed when the loop returned no error. +func verdictsPassed(verdicts []agent.Verdict, runErr error) bool { + if len(verdicts) == 0 { + return runErr == nil + } + return verdicts[len(verdicts)-1].OK +} + +// commitSubject derives a one-line, length-capped commit subject from the prompt. +func commitSubject(s string) string { + s = strings.TrimSpace(firstLine(s)) + if len(s) > 72 { + s = s[:72] + } + return s +} diff --git a/pkg/cli/ai_agent_test.go b/pkg/cli/ai_agent_test.go new file mode 100644 index 0000000..a714e6a --- /dev/null +++ b/pkg/cli/ai_agent_test.go @@ -0,0 +1,123 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai/agent" +) + +func pluginNames(ps []agent.Plugin) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = p.Name() + } + return out +} + +func TestScopeFromFlag(t *testing.T) { + cases := map[string]struct { + want agent.Scope + wantErr bool + }{ + "": {agent.ScopeAll, false}, + "all": {agent.ScopeAll, false}, + "changed": {agent.ScopeChanged, false}, + "bogus": {"", true}, + } + for in, exp := range cases { + got, err := scopeFromFlag(in) + if exp.wantErr { + if err == nil { + t.Errorf("scopeFromFlag(%q) err = nil, want error", in) + } else if !strings.Contains(err.Error(), agent.ScopeList()) { + t.Errorf("scopeFromFlag(%q) err = %v, want mention of valid scopes %q", in, err, agent.ScopeList()) + } + continue + } + if err != nil || got != exp.want { + t.Errorf("scopeFromFlag(%q) = %q, %v; want %q, nil", in, got, err, exp.want) + } + } +} + +func TestBuildAgentPlugins_CommitRequiresWorktree(t *testing.T) { + _, _, err := buildAgentPlugins(AIAgentOptions{Commit: true}, nil) + if err == nil || !strings.Contains(err.Error(), "worktree") { + t.Fatalf("err = %v, want a --commit-requires-worktree error", err) + } +} + +func TestBuildAgentPlugins_WorktreeBranchAndCommit(t *testing.T) { + opts := AIAgentOptions{ + Worktree: true, + Commit: true, + } + opts.Prompt = "fix the failing lint\nsecond line ignored" + plugins, wt, err := buildAgentPlugins(opts, nil) + if err != nil { + t.Fatalf("buildAgentPlugins: %v", err) + } + if wt == nil { + t.Fatal("worktree plugin not returned") + } + if !strings.HasPrefix(wt.Branch, "captain/agent-") { + t.Errorf("default branch = %q, want captain/agent- prefix", wt.Branch) + } + if wt.CommitMsg != "captain: fix the failing lint" { + t.Errorf("CommitMsg = %q, want one-line prompt subject", wt.CommitMsg) + } + if names := pluginNames(plugins); len(names) != 1 || names[0] != "worktree" { + t.Errorf("plugins = %v, want [worktree]", names) + } +} + +func TestBuildAgentPlugins_VerifyAndJudge(t *testing.T) { + opts := AIAgentOptions{ + Verify: []string{"make lint", " ", "go test ./..."}, + Judge: "the change must include a test", + } + plugins, wt, err := buildAgentPlugins(opts, nil) + if err != nil { + t.Fatalf("buildAgentPlugins: %v", err) + } + if wt != nil { + t.Errorf("worktree plugin = %v, want nil without --worktree", wt) + } + want := []string{"verify:make lint", "verify:go test ./...", "judge"} + got := pluginNames(plugins) + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("plugin names = %v, want %v (blank --verify entries skipped)", got, want) + } +} + +func TestVerdictsPassed(t *testing.T) { + if !verdictsPassed(nil, nil) { + t.Error("no verdicts + no error should pass") + } + if verdictsPassed(nil, errSentinel) { + t.Error("no verdicts + error should fail") + } + if !verdictsPassed([]agent.Verdict{{OK: false}, {OK: true}}, nil) { + t.Error("last verdict OK should pass") + } + if verdictsPassed([]agent.Verdict{{OK: true}, {OK: false}}, nil) { + t.Error("last verdict not OK should fail") + } +} + +func TestCommitSubject(t *testing.T) { + if got := commitSubject(" one line \nrest"); got != "one line" { + t.Errorf("commitSubject = %q, want %q", got, "one line") + } + long := strings.Repeat("x", 100) + if got := commitSubject(long); len(got) != 72 { + t.Errorf("commitSubject len = %d, want 72 (capped)", len(got)) + } +} + +var errSentinel = errSentinelType("boom") + +type errSentinelType string + +func (e errSentinelType) Error() string { return string(e) } diff --git a/pkg/cli/ai_catalog.go b/pkg/cli/ai_catalog.go new file mode 100644 index 0000000..4200827 --- /dev/null +++ b/pkg/cli/ai_catalog.go @@ -0,0 +1,44 @@ +package cli + +import ( + "sort" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/clicky/aichat" +) + +// agentCatalogModels returns the model list for a CLI/agent backend from +// clicky/aichat's static catalog — the key-free source of truth shared with the +// chat menu and shell completion. CLI/agent backends authenticate internally +// (subscription/OAuth via the installed binary), so enumerating their models +// must never require the parent provider's API key. +// +// The returned ID is the slug the captain provider expects at run time: +// AgentModel when the catalog sets one (e.g. codex's "gpt-5-codex", which the +// app-server sends verbatim) otherwise the catalog ID (e.g. "claude-agent-sonnet", +// which the claude-agent provider de-prefixes itself). claude-cli shares the +// claude-agent provider, so it shares its catalog entries. +func agentCatalogModels(b ai.Backend) []ai.ModelDef { + want := b + if want == ai.BackendClaudeCLI { + want = ai.BackendClaudeAgent + } + + out := []ai.ModelDef{} + for _, m := range aichat.Catalog() { + if m.Engine != aichat.EngineAgent || m.Backend != want { + continue + } + id := m.ID + if m.AgentModel != "" { + id = m.AgentModel + } + label := m.Label + if label == "" { + label = id + } + out = append(out, ai.ModelDef{ID: id, Name: label, Backend: b}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} diff --git a/pkg/cli/ai_catalog_test.go b/pkg/cli/ai_catalog_test.go new file mode 100644 index 0000000..472f405 --- /dev/null +++ b/pkg/cli/ai_catalog_test.go @@ -0,0 +1,76 @@ +package cli + +import ( + "reflect" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/clicky/aichat" +) + +// installTestCatalog swaps in a deterministic catalog for the duration of the +// test so the assertions don't drift with clicky's shipped model list. The +// catalog mixes a genkit (API) entry with agent entries for two backends so the +// filtering — agent-engine only, exact backend match — is actually exercised. +func installTestCatalog(t *testing.T) { + t.Helper() + prev := aichat.Catalog() + t.Cleanup(func() { _ = aichat.SetModelCatalog(prev) }) + + if err := aichat.SetModelCatalog([]aichat.Model{ + {ID: "anthropic/claude-sonnet-4-5", Provider: aichat.ProviderAnthropic, Label: "Claude Sonnet 4.5"}, + {ID: "claude-agent-opus", Engine: aichat.EngineAgent, Backend: ai.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Opus"}, + {ID: "claude-agent-sonnet", Engine: aichat.EngineAgent, Backend: ai.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Sonnet"}, + {ID: "codex-gpt-5-codex", Engine: aichat.EngineAgent, Backend: ai.BackendCodexCLI, AgentModel: "gpt-5-codex", Provider: "codex-cli", Label: "Codex · GPT-5"}, + }); err != nil { + t.Fatalf("SetModelCatalog: %v", err) + } +} + +func TestAgentCatalogModels(t *testing.T) { + installTestCatalog(t) + + cases := []struct { + backend ai.Backend + want []ai.ModelDef + }{ + { + // AgentModel slug wins over the catalog ID: codex receives the + // model string verbatim, so it must be "gpt-5-codex" not + // "codex-gpt-5-codex". + backend: ai.BackendCodexCLI, + want: []ai.ModelDef{{ID: "gpt-5-codex", Name: "Codex · GPT-5", Backend: ai.BackendCodexCLI}}, + }, + { + // Sorted by ID; genkit (API) anthropic entry excluded. + backend: ai.BackendClaudeAgent, + want: []ai.ModelDef{ + {ID: "claude-agent-opus", Name: "Claude Agent · Opus", Backend: ai.BackendClaudeAgent}, + {ID: "claude-agent-sonnet", Name: "Claude Agent · Sonnet", Backend: ai.BackendClaudeAgent}, + }, + }, + { + // claude-cli reuses the claude-agent entries but is re-tagged with + // its own backend. + backend: ai.BackendClaudeCLI, + want: []ai.ModelDef{ + {ID: "claude-agent-opus", Name: "Claude Agent · Opus", Backend: ai.BackendClaudeCLI}, + {ID: "claude-agent-sonnet", Name: "Claude Agent · Sonnet", Backend: ai.BackendClaudeCLI}, + }, + }, + { + // No catalog entries for gemini-cli: empty, never an error. + backend: ai.BackendGeminiCLI, + want: []ai.ModelDef{}, + }, + } + + for _, tc := range cases { + t.Run(string(tc.backend), func(t *testing.T) { + got := agentCatalogModels(tc.backend) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("agentCatalogModels(%s) = %+v, want %+v", tc.backend, got, tc.want) + } + }) + } +} diff --git a/pkg/cli/ai_filters.go b/pkg/cli/ai_filters.go new file mode 100644 index 0000000..3644cc3 --- /dev/null +++ b/pkg/cli/ai_filters.go @@ -0,0 +1,114 @@ +package cli + +import ( + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent" + capapi "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/clicky/aichat" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/entity" +) + +// aiEnumFilter is a phantom-typed clicky entity.Filter whose option set is a +// fixed enum/catalog independent of the current flag values, so one definition +// serves every AI options struct (the opts argument is ignored). It powers shell +// completion and web/RPC typeahead for the model/effort/scope/backend flags; +// fail-loud validation of the chosen value still happens in ToConfig/ToRequest. +type aiEnumFilter[T any] struct { + key string + label string + options func() map[string]api.Textable +} + +func (f aiEnumFilter[T]) Key() string { return f.key } +func (f aiEnumFilter[T]) Label() string { return f.label } +func (f aiEnumFilter[T]) Lookup(*T) (map[string]api.Textable, error) { return nil, nil } +func (f aiEnumFilter[T]) Options(T) map[string]api.Textable { return f.options() } + +// aiFilters is the shared lookup surface for the AI commands. The completion +// binder skips keys a given command does not expose, so this single +// over-declared set is safe to return from prompt/agent/test alike (e.g. --scope +// is agent-only). +func aiFilters[T any]() []entity.Filter[T] { + return []entity.Filter[T]{ + aiEnumFilter[T]{key: "model", label: "Model", options: modelFilterOptions}, + aiEnumFilter[T]{key: "effort", label: "Reasoning Effort", options: effortFilterOptions}, + aiEnumFilter[T]{key: "scope", label: "Verifier Scope", options: scopeFilterOptions}, + aiEnumFilter[T]{key: "backend", label: "Backend", options: backendFilterOptions}, + } +} + +// effortFilterOptions sources the reasoning-effort values from captain's shared +// api.Effort enum — the same source ToRequest validates against — so completion +// includes the captain-owned xhigh tier that clicky's aichat.Effort lacks. +func effortFilterOptions() map[string]api.Textable { + labels := map[capapi.Effort]string{ + capapi.EffortLow: "Low", + capapi.EffortMedium: "Medium", + capapi.EffortHigh: "High", + capapi.EffortXHigh: "Extra High", + } + out := make(map[string]api.Textable, len(capapi.AllEfforts())) + for _, e := range capapi.AllEfforts() { + out[string(e)] = api.Text{Content: labels[e]} + } + return out +} + +// scopeOptions sources the verifier scopes from agent.AllScopes (the same source +// ParseScope validates against). +func scopeFilterOptions() map[string]api.Textable { + out := make(map[string]api.Textable, len(agent.AllScopes())) + for _, s := range agent.AllScopes() { + out[string(s)] = api.Text{Content: string(s)} + } + return out +} + +// backendOptions sources the backends from ai.AllBackends (the same source +// Backend.Valid validates against). +func backendFilterOptions() map[string]api.Textable { + out := make(map[string]api.Textable, len(ai.AllBackends())) + for _, b := range ai.AllBackends() { + out[string(b)] = api.Text{Content: string(b)} + } + return out +} + +// modelOptions suggests the clicky/aichat model catalog as bare model names that +// captain's --model accepts (InferBackend matches bare prefixes). Genkit ids are +// "provider/model" so the provider prefix is stripped; agent ids already carry a +// captain-recognised backend prefix (claude-agent-*, codex-*) and are used +// verbatim — using their AgentModel slug instead would misroute (e.g. +// "gpt-5-codex" infers OpenAI, not codex). Completion is suggest-only: arbitrary +// models are still accepted, so the catalog need not be exhaustive. +func modelFilterOptions() map[string]api.Textable { + catalog := aichat.Catalog() + out := make(map[string]api.Textable, len(catalog)) + for _, m := range catalog { + id := m.ID + if m.Engine == aichat.EngineGenkit { + if i := strings.IndexByte(id, '/'); i >= 0 { + id = id[i+1:] + } + } + label := m.Label + if label == "" { + label = id + } + out[id] = api.Text{Content: label} + } + return out +} + +// Filterable implementations publish the shared lookup surface to clicky's +// AddNamedCommand, which wires shell completion + RPC typeahead automatically. +// Declared per top-level struct because a promoted method would carry the +// embedded type's T, not the command's. +func (AIPromptOptions) Filters() []entity.Filter[AIPromptOptions] { + return aiFilters[AIPromptOptions]() +} +func (AIAgentOptions) Filters() []entity.Filter[AIAgentOptions] { return aiFilters[AIAgentOptions]() } +func (AITestOptions) Filters() []entity.Filter[AITestOptions] { return aiFilters[AITestOptions]() } diff --git a/pkg/cli/ai_filters_test.go b/pkg/cli/ai_filters_test.go new file mode 100644 index 0000000..179af74 --- /dev/null +++ b/pkg/cli/ai_filters_test.go @@ -0,0 +1,102 @@ +package cli + +import ( + "sort" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent" + capapi "github.com/flanksource/captain/pkg/api" +) + +// optionKeys returns the sorted option keys of the aiFilters filter with the +// given key. The completion binder uses these keys as the suggested values. +func optionKeys(t *testing.T, key string) []string { + t.Helper() + for _, f := range aiFilters[AIAgentOptions]() { + if f.Key() != key { + continue + } + opts := f.Options(AIAgentOptions{}) + keys := make([]string, 0, len(opts)) + for k := range opts { + keys = append(keys, k) + } + sort.Strings(keys) + return keys + } + t.Fatalf("no aiFilter with key %q", key) + return nil +} + +func TestAIFilters_EffortFromSharedEnum(t *testing.T) { + want := make([]string, 0, len(capapi.AllEfforts())) + for _, e := range capapi.AllEfforts() { + want = append(want, string(e)) // includes the captain-owned "xhigh" tier + } + sort.Strings(want) + if got := optionKeys(t, "effort"); !equalStrings(got, want) { + t.Errorf("effort options = %v, want %v", got, want) + } +} + +func TestAIFilters_ScopeFromAllScopes(t *testing.T) { + want := make([]string, 0, len(agent.AllScopes())) + for _, s := range agent.AllScopes() { + want = append(want, string(s)) + } + sort.Strings(want) + if got := optionKeys(t, "scope"); !equalStrings(got, want) { + t.Errorf("scope options = %v, want %v", got, want) + } +} + +func TestAIFilters_BackendFromAllBackends(t *testing.T) { + want := make([]string, 0, len(ai.AllBackends())) + for _, b := range ai.AllBackends() { + want = append(want, string(b)) + } + sort.Strings(want) + if got := optionKeys(t, "backend"); !equalStrings(got, want) { + t.Errorf("backend options = %v, want %v", got, want) + } +} + +// TestAIFilters_ModelSuggestsBareNames pins that genkit catalog ids are +// suggested without their "provider/" prefix, so the value is directly usable as +// --model (captain's InferBackend matches bare prefixes). The synthetic catalog +// keeps the assertion deterministic across clicky/aichat dependency bumps. +func TestAIFilters_ModelSuggestsBareNames(t *testing.T) { + installTestCatalog(t) + + keys := optionKeys(t, "model") + if !contains(keys, "claude-sonnet-4-5") { + t.Errorf("model options %v missing bare name claude-sonnet-4-5", keys) + } + for _, k := range keys { + if k == "anthropic/claude-sonnet-4-5" { + t.Errorf("model options should be bare names, found provider-prefixed %q", k) + } + } +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/cli/ai_models.go b/pkg/cli/ai_models.go index 4ebc8ed..3a24a5f 100644 --- a/pkg/cli/ai_models.go +++ b/pkg/cli/ai_models.go @@ -67,7 +67,7 @@ func isLegacyModelID(id string) bool { type AIModelsOptions struct { Filter string `flag:"filter" help:"Filter models by name substring" short:"f"` - Backend string `flag:"backend" help:"Filter by backend (anthropic, openai)" short:"b"` + Backend string `flag:"backend" help:"Filter by backend: anthropic|gemini|openai|claude-cli|claude-agent|codex-cli|gemini-cli" short:"b"` Limit int `flag:"limit" help:"Maximum models to show" default:"50" short:"l"` All bool `flag:"all" help:"Include all OpenRouter models" short:"a"` } @@ -103,10 +103,15 @@ func RunAIModels(opts AIModelsOptions) (any, error) { // being shown a stale hard-coded catalog. func runLiveModels(opts AIModelsOptions) (any, error) { backendFilter := ai.Backend(strings.TrimSpace(opts.Backend)) + // CLI/agent backends authenticate internally, so their models come from the + // static catalog without an API key. + if backendFilter != "" && backendFilter.Kind() == "cli" { + return catalogModelsResult(opts, backendFilter), nil + } switch backendFilter { case "", ai.BackendOpenAI, ai.BackendAnthropic: default: - return nil, fmt.Errorf("--backend must be one of: openai, anthropic (got %q)", opts.Backend) + return nil, fmt.Errorf("--backend must be one of: openai, anthropic, or a CLI backend (%s) (got %q)", strings.Join(cliBackendNames(), ", "), opts.Backend) } ctx := context.Background() @@ -186,6 +191,43 @@ func runLiveModels(opts AIModelsOptions) (any, error) { return AIModelsResult{Total: len(rows), Rows: rows}, nil } +// catalogModelsResult lists a CLI/agent backend's models from the static +// catalog (no API key). --filter narrows by id/name substring; pricing and +// context columns are filled when the OpenRouter registry knows the model and +// shown as "-" otherwise. Rows arrive pre-sorted by id from agentCatalogModels. +func catalogModelsResult(opts AIModelsOptions, backend ai.Backend) AIModelsResult { + filterLower := strings.ToLower(opts.Filter) + rows := make([]AIModelRow, 0) + for _, m := range agentCatalogModels(backend) { + if opts.Filter != "" && !strings.Contains(strings.ToLower(m.ID), filterLower) && !strings.Contains(strings.ToLower(m.Name), filterLower) { + continue + } + row := AIModelRow{Model: m.ID, Backend: string(backend), Input: "-", Output: "-", Context: "-", MaxTokens: "-"} + if info, ok := lookupPricing(backend, m.ID); ok { + row.Input = formatPrice(info.InputPrice) + row.Output = formatPrice(info.OutputPrice) + row.Context = formatContext(info.ContextWindow) + row.MaxTokens = formatContext(info.MaxTokens) + } + rows = append(rows, row) + } + if opts.Limit > 0 && len(rows) > opts.Limit { + rows = rows[:opts.Limit] + } + return AIModelsResult{Total: len(rows), Rows: rows} +} + +// cliBackendNames lists the CLI/agent backends, for the --backend error message. +func cliBackendNames() []string { + out := make([]string, 0) + for _, b := range ai.AllBackends() { + if b.Kind() == "cli" { + out = append(out, string(b)) + } + } + return out +} + func openAIAPIKey() string { return strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) } func anthropicAPIKey() string { return strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) } diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go new file mode 100644 index 0000000..404ce1f --- /dev/null +++ b/pkg/cli/ai_prompt_file.go @@ -0,0 +1,181 @@ +package cli + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/prompt" + "github.com/flanksource/captain/pkg/api" +) + +// resolvePromptTemplate picks the prompt source for `captain ai prompt` and loads +// it as a dotprompt template. Precedence: positional file path > --prompt/-p value +// (literal text, or file content clicky already loaded from an @ reference) > +// piped stdin. usedStdin reports whether stdin became the prompt source, so the +// caller knows not to also expose it as the {{input}} template variable. +func resolvePromptTemplate(opts AIPromptOptions, stdin string) (tmpl *prompt.Template, usedStdin bool, err error) { + switch { + case opts.File != "": + t, err := prompt.LoadFile(opts.File) + return t, false, err + case opts.Prompt != "": + return prompt.Load(opts.Prompt), false, nil + case strings.TrimSpace(stdin) != "": + return prompt.Load(stdin), true, nil + default: + return nil, false, fmt.Errorf("prompt required: pass a .prompt file, --prompt/-p text, or pipe via stdin") + } +} + +// parseVars turns repeated --var key=value flags into the template data map. +func parseVars(pairs []string) (map[string]any, error) { + data := make(map[string]any, len(pairs)) + for _, p := range pairs { + k, v, ok := strings.Cut(p, "=") + if !ok { + return nil, fmt.Errorf("invalid --var %q: want key=value", p) + } + data[k] = v + } + return data, nil +} + +// overlayCLI layers the CLI flags over the rendered file spec, implementing the +// precedence CLI flag (non-zero) > frontmatter > saved defaults > built-in. The +// user prompt always comes from the rendered template body; everything else is +// merged per nested group. Negative toggles (--no-*) OR across all three layers, +// matching the existing flag semantics. Range/enum validation is left to the +// caller via req.Validate so the rules live in one place (pkg/api). +func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Request, ai.Config, error) { + saved := loadSavedAI() + + temperature, err := parseFloatFlag("temperature", o.Temperature) + if err != nil { + return base, baseCfg, err + } + budget, err := parseFloatFlag("budget", o.Budget) + if err != nil { + return base, baseCfg, err + } + + req := base + + bm := base.Model + m := bm + m.Name = firstNonEmpty(o.Model, bm.Name, saved.Model) + m.Backend = api.Backend(firstNonEmpty(o.Backend, string(bm.Backend), saved.Backend)) + if temperature != 0 { + t := temperature + m.Temperature = &t + } + m.Effort = api.Effort(firstNonEmpty(o.Effort, string(bm.Effort), saved.ReasoningEffort)) + req.Model = m + + req.Budget.MaxTokens = firstPositive(o.MaxTokens, base.Budget.MaxTokens, saved.MaxTokens, 4096) + req.Budget.Cost = firstPositiveFloat(budget, base.Budget.Cost, saved.BudgetUSD) + + if o.System != "" { + req.Prompt.System = o.System + } + if o.AppendSystem != "" { + req.Prompt.AppendSystem = o.AppendSystem + } + + if o.PermissionMode != "" { + req.Permissions.Mode = api.PermissionMode(o.PermissionMode) + } + if o.Edit && !req.Permissions.HasPreset(api.PresetEdit) { + req.Permissions.Presets = append(req.Permissions.Presets, api.PresetEdit) + } + if o.AllowedTools != nil { + req.Permissions.Tools.Allow = o.AllowedTools + } + if o.DisallowedTools != nil { + req.Permissions.Tools.Deny = o.DisallowedTools + } + req.Permissions.MCP.Disabled = o.NoMCP || base.Permissions.MCP.Disabled || saved.NoMCP + + if o.SkillDirs != nil { + req.Memory.Skills = o.SkillDirs + } + req.Memory.SkipHooks = o.NoHooks || base.Memory.SkipHooks || saved.NoHooks + req.Memory.SkipSkills = o.NoSkills || base.Memory.SkipSkills || saved.NoSkills + req.Memory.SkipUser = o.NoUser || base.Memory.SkipUser || saved.NoUser + req.Memory.SkipProject = o.NoProject || base.Memory.SkipProject || saved.NoProject + req.Memory.SkipMemory = o.NoMemory || base.Memory.SkipMemory || saved.NoMemory + req.Memory.Bare = o.Bare || base.Memory.Bare + + if o.Resume != "" { + req.SessionID = o.Resume + } + if o.MaxTurns > 0 { + req.MaxTurns = o.MaxTurns + } + + // Config mirrors the resolved model + budget; runtime-only knobs from CLI+saved. + cfg := baseCfg + cfg.Model = req.Model + cfg.Budget = req.Budget + cfg.APIKey = o.APIKey + cfg.NoCache = o.NoCache || saved.NoCache + return req, cfg, nil +} + +// normalizePromptContextDir makes the prompt command's workspace explicit before +// providers see the request. Empty means the invocation cwd; relative values are +// interpreted from that same cwd so SDK child-process directories cannot change +// the meaning of context.dir: . +func normalizePromptContextDir(req *ai.Request, cwd string) error { + if cwd == "" { + return fmt.Errorf("working directory is required") + } + if !filepath.IsAbs(cwd) { + abs, err := filepath.Abs(cwd) + if err != nil { + return fmt.Errorf("resolve working directory %q: %w", cwd, err) + } + cwd = abs + } + if req.Context.Dir == "" { + req.Context.Dir = filepath.Clean(cwd) + return nil + } + if filepath.IsAbs(req.Context.Dir) { + req.Context.Dir = filepath.Clean(req.Context.Dir) + return nil + } + req.Context.Dir = filepath.Clean(filepath.Join(cwd, req.Context.Dir)) + return nil +} + +// firstNonEmpty returns the first non-empty string, or "" when all are empty. +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// firstPositive returns the first value > 0, or 0 when none qualify. +func firstPositive(vals ...int) int { + for _, v := range vals { + if v > 0 { + return v + } + } + return 0 +} + +// firstPositiveFloat returns the first value > 0, or 0 when none qualify. +func firstPositiveFloat(vals ...float64) float64 { + for _, v := range vals { + if v > 0 { + return v + } + } + return 0 +} diff --git a/pkg/cli/ai_prompt_file_test.go b/pkg/cli/ai_prompt_file_test.go new file mode 100644 index 0000000..9254d6e --- /dev/null +++ b/pkg/cli/ai_prompt_file_test.go @@ -0,0 +1,235 @@ +package cli + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +func TestParseVars(t *testing.T) { + cases := []struct { + name string + in []string + want map[string]any + wantErr bool + }{ + {"empty", nil, map[string]any{}, false}, + {"simple", []string{"lang=go"}, map[string]any{"lang": "go"}, false}, + {"value has equals", []string{"q=a=b"}, map[string]any{"q": "a=b"}, false}, + {"multiple", []string{"a=1", "b=2"}, map[string]any{"a": "1", "b": "2"}, false}, + {"missing equals", []string{"oops"}, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseVars(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("parseVars(%v) = nil error, want error", tc.in) + } + return + } + if err != nil { + t.Fatalf("parseVars(%v): %v", tc.in, err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("parseVars(%v) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func writePromptFile(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "p.prompt") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatalf("write prompt file: %v", err) + } + return p +} + +func TestResolvePromptTemplate(t *testing.T) { + file := writePromptFile(t, "{{role \"user\"}}\nfrom file") + + t.Run("positional file", func(t *testing.T) { + tmpl, usedStdin, err := resolvePromptTemplate(AIPromptOptions{File: file}, "ignored stdin") + if err != nil { + t.Fatal(err) + } + if usedStdin { + t.Error("usedStdin = true, want false when a file is the source") + } + req, _, err := tmpl.Render(nil, nil) + if err != nil { + t.Fatal(err) + } + if req.Prompt.User != "from file" { + t.Errorf("User = %q, want %q", req.Prompt.User, "from file") + } + }) + + t.Run("literal prompt", func(t *testing.T) { + opts := AIPromptOptions{} + opts.Prompt = "literal text" + tmpl, usedStdin, err := resolvePromptTemplate(opts, "ignored") + if err != nil { + t.Fatal(err) + } + if usedStdin { + t.Error("usedStdin = true, want false when --prompt is the source") + } + req, _, _ := tmpl.Render(nil, nil) + if req.Prompt.User != "literal text" { + t.Errorf("User = %q, want %q", req.Prompt.User, "literal text") + } + }) + + t.Run("stdin source", func(t *testing.T) { + tmpl, usedStdin, err := resolvePromptTemplate(AIPromptOptions{}, "from stdin") + if err != nil { + t.Fatal(err) + } + if !usedStdin { + t.Error("usedStdin = false, want true when stdin is the source") + } + req, _, _ := tmpl.Render(nil, nil) + if req.Prompt.User != "from stdin" { + t.Errorf("User = %q, want %q", req.Prompt.User, "from stdin") + } + }) + + t.Run("no source", func(t *testing.T) { + if _, _, err := resolvePromptTemplate(AIPromptOptions{}, " "); err == nil { + t.Fatal("expected error when no prompt source is provided") + } + }) +} + +// baseFileReq is a spec as it would come back from rendering a .prompt file with +// frontmatter, used as the merge base in overlay tests. +func baseFileReq() ai.Request { + return ai.Request{ + Prompt: api.Prompt{User: "body prompt"}, + Model: api.Model{Name: "claude-file-4-6"}, + Budget: api.Budget{MaxTokens: 100}, + Permissions: api.Permissions{Mode: api.PermissionAcceptEdits}, + Memory: api.Memory{SkipUser: true}, + } +} + +func TestOverlayCLI_CLIOverridesFrontmatter(t *testing.T) { + isolateSavedAI(t) + opts := AIPromptOptions{} + opts.Model = "claude-cli-4-6" + opts.MaxTokens = 200 + opts.PermissionMode = "plan" + + req, cfg, err := overlayCLI(baseFileReq(), ai.Config{}, opts) + if err != nil { + t.Fatal(err) + } + if req.Model.Name != "claude-cli-4-6" { + t.Errorf("Model.Name = %q, want CLI value claude-cli-4-6", req.Model.Name) + } + if cfg.Model.Name != "claude-cli-4-6" { + t.Errorf("cfg.Model.Name = %q, want CLI value mirrored into config", cfg.Model.Name) + } + if req.Budget.MaxTokens != 200 { + t.Errorf("MaxTokens = %d, want CLI value 200", req.Budget.MaxTokens) + } + if req.Permissions.Mode != api.PermissionPlan { + t.Errorf("Mode = %q, want CLI value plan", req.Permissions.Mode) + } +} + +func TestOverlayCLI_FrontmatterOverridesSaved(t *testing.T) { + seedSavedAI(t, "ai:\n model: claude-saved-4-6\n maxTokens: 16000\n") + req, _, err := overlayCLI(baseFileReq(), ai.Config{}, AIPromptOptions{}) + if err != nil { + t.Fatal(err) + } + if req.Model.Name != "claude-file-4-6" { + t.Errorf("Model.Name = %q, want frontmatter value claude-file-4-6 (beats saved)", req.Model.Name) + } + if req.Budget.MaxTokens != 100 { + t.Errorf("MaxTokens = %d, want frontmatter value 100 (beats saved 16000)", req.Budget.MaxTokens) + } +} + +func TestOverlayCLI_BuiltinMaxTokens(t *testing.T) { + isolateSavedAI(t) + base := baseFileReq() + base.Budget.MaxTokens = 0 // frontmatter omitted it + req, _, err := overlayCLI(base, ai.Config{}, AIPromptOptions{}) + if err != nil { + t.Fatal(err) + } + if req.Budget.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want built-in 4096", req.Budget.MaxTokens) + } +} + +func TestOverlayCLI_BooleansOR(t *testing.T) { + isolateSavedAI(t) + opts := AIPromptOptions{} + opts.NoMCP = true // CLI sets it + opts.Edit = true // CLI preset + opts.NoUser = false // base already has SkipUser=true + + req, _, err := overlayCLI(baseFileReq(), ai.Config{}, opts) + if err != nil { + t.Fatal(err) + } + if !req.Permissions.MCP.Disabled { + t.Error("MCP.Disabled = false, want true (CLI --no-mcp)") + } + if !req.Memory.SkipUser { + t.Error("SkipUser = false, want true (frontmatter set it; CLI false must not clear it)") + } + if !req.Permissions.HasPreset(api.PresetEdit) { + t.Error("missing edit preset from --edit") + } + // --edit must not duplicate a preset the frontmatter already declared. + base := baseFileReq() + base.Permissions.Presets = []api.Preset{api.PresetEdit} + req2, _, err := overlayCLI(base, ai.Config{}, opts) + if err != nil { + t.Fatal(err) + } + if got := len(req2.Permissions.Presets); got != 1 { + t.Errorf("presets = %d, want 1 (no duplicate edit)", got) + } +} + +func TestNormalizePromptContextDir(t *testing.T) { + cwd := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(cwd, 0o755); err != nil { + t.Fatalf("mkdir cwd: %v", err) + } + abs := filepath.Join(t.TempDir(), "other", "..", "other") + + tests := []struct { + name string + dir string + want string + }{ + {name: "missing defaults to cwd", want: cwd}, + {name: "dot resolves against cwd", dir: ".", want: cwd}, + {name: "relative resolves against cwd", dir: "sub/../work", want: filepath.Join(cwd, "work")}, + {name: "absolute is preserved and cleaned", dir: abs, want: filepath.Clean(abs)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := ai.Request{Context: api.Context{Dir: tc.dir}} + if err := normalizePromptContextDir(&req, cwd); err != nil { + t.Fatalf("normalizePromptContextDir: %v", err) + } + if req.Context.Dir != tc.want { + t.Errorf("Context.Dir = %q, want %q", req.Context.Dir, tc.want) + } + }) + } +} diff --git a/pkg/cli/ai_test.go b/pkg/cli/ai_test.go index aa2b1f0..1f5fcec 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -1,22 +1,67 @@ package cli import ( + "context" + "encoding/json" "os" "path/filepath" "reflect" + "strings" "testing" + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" ) +type promptResultProvider struct{} + +func (promptResultProvider) GetModel() string { return "claude-sonnet-4-6" } + +func (promptResultProvider) GetBackend() ai.Backend { return ai.BackendAnthropic } + +func (promptResultProvider) Execute(context.Context, ai.Request) (*ai.Response, error) { + return &ai.Response{ + Text: "done", + Model: "claude-sonnet-4-6", + Backend: ai.BackendAnthropic, + Usage: ai.Usage{InputTokens: 12, OutputTokens: 7}, + }, nil +} + +type promptResultStreamingProvider struct{} + +func (promptResultStreamingProvider) GetModel() string { return "gpt-5-codex" } + +func (promptResultStreamingProvider) GetBackend() ai.Backend { return ai.BackendCodexCLI } + +func (promptResultStreamingProvider) Execute(context.Context, ai.Request) (*ai.Response, error) { + return nil, nil +} + +func (promptResultStreamingProvider) ExecuteStream(_ context.Context, _ ai.Request) (<-chan ai.Event, error) { + events := make(chan ai.Event, 3) + events <- ai.Event{Kind: ai.EventSystem, SessionID: "stream-session-1", Model: "gpt-5-codex"} + events <- ai.Event{Kind: ai.EventText, Text: "streamed", Model: "gpt-5-codex"} + events <- ai.Event{Kind: ai.EventResult, Model: "gpt-5-codex", Usage: &ai.Usage{InputTokens: 21, OutputTokens: 9}, CostUSD: 0.02} + close(events) + return events, nil +} + // isolateSavedAI redirects captainconfig.Path() to an empty file inside // t.TempDir() so loadSavedAI() returns zero defaults rather than leaking // the developer's real ~/.captain.yaml into table-test expectations. func isolateSavedAI(t *testing.T) { + t.Helper() + seedSavedAI(t, "") +} + +// seedSavedAI redirects captainconfig.Path() to a temp file containing yaml. +func seedSavedAI(t *testing.T, yaml string) { t.Helper() p := filepath.Join(t.TempDir(), ".captain.yaml") - if err := os.WriteFile(p, []byte(""), 0o644); err != nil { - t.Fatalf("seed empty captain config: %v", err) + if err := os.WriteFile(p, []byte(yaml), 0o644); err != nil { + t.Fatalf("seed captain config: %v", err) } captainconfig.SetPathForTesting(p) t.Cleanup(func() { captainconfig.SetPathForTesting("") }) @@ -25,20 +70,21 @@ func isolateSavedAI(t *testing.T) { func TestAIPromptOptions_ToRequest_Defaults(t *testing.T) { isolateSavedAI(t) opts := defaultPromptOptions(t) - req := opts.ToRequest() + req, err := opts.ToRequest() + if err != nil { + t.Fatalf("ToRequest: %v", err) + } - if req.Prompt != "hello" { - t.Errorf("Prompt = %q, want hello", req.Prompt) + if req.Prompt.User != "hello" { + t.Errorf("Prompt = %q, want hello", req.Prompt.User) + } + if req.Budget.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want 4096 built-in default", req.Budget.MaxTokens) } for name, got := range map[string]bool{ - "NoMCP": req.NoMCP, - "NoHooks": req.NoHooks, - "NoSkills": req.NoSkills, - "NoUser": req.NoUser, - "NoProject": req.NoProject, - "NoMemory": req.NoMemory, - "Bare": req.Bare, - "Edit": req.Edit, + "NoMCP": req.Permissions.MCP.Disabled, "NoHooks": req.Memory.SkipHooks, "NoSkills": req.Memory.SkipSkills, + "NoUser": req.Memory.SkipUser, "NoProject": req.Memory.SkipProject, "NoMemory": req.Memory.SkipMemory, + "Bare": req.Memory.Bare, "Edit": req.Permissions.HasPreset(api.PresetEdit), } { if got { t.Errorf("%s = true; default-shape options must not flip any No*/Bare/Edit", name) @@ -46,75 +92,33 @@ func TestAIPromptOptions_ToRequest_Defaults(t *testing.T) { } } -func TestAIPromptOptions_ToRequest_TruthyInversion(t *testing.T) { +// TestAIPromptOptions_ToRequest_NegativeFlags verifies each --no-* flag sets the +// matching No* field on the request. +func TestAIPromptOptions_ToRequest_NegativeFlags(t *testing.T) { isolateSavedAI(t) cases := []struct { name string mutate func(*AIPromptOptions) - check func(t *testing.T, got any) + get func(ai.Request) bool }{ - { - name: "mcp=false", - mutate: func(o *AIPromptOptions) { o.MCP = false }, - check: func(t *testing.T, r any) { mustBool(t, r.(bool), true, "NoMCP") }, - }, - { - name: "hooks=false", - mutate: func(o *AIPromptOptions) { o.Hooks = false }, - check: func(t *testing.T, r any) { mustBool(t, r.(bool), true, "NoHooks") }, - }, - { - name: "skills=false", - mutate: func(o *AIPromptOptions) { o.Skills = false }, - check: func(t *testing.T, r any) { mustBool(t, r.(bool), true, "NoSkills") }, - }, - { - name: "user=false", - mutate: func(o *AIPromptOptions) { o.User = false }, - check: func(t *testing.T, r any) { mustBool(t, r.(bool), true, "NoUser") }, - }, - { - name: "project=false", - mutate: func(o *AIPromptOptions) { o.Project = false }, - check: func(t *testing.T, r any) { mustBool(t, r.(bool), true, "NoProject") }, - }, - { - name: "memory=false", - mutate: func(o *AIPromptOptions) { o.Memory = false }, - check: func(t *testing.T, r any) { mustBool(t, r.(bool), true, "NoMemory") }, - }, + {"no-mcp", func(o *AIPromptOptions) { o.NoMCP = true }, func(r ai.Request) bool { return r.Permissions.MCP.Disabled }}, + {"no-hooks", func(o *AIPromptOptions) { o.NoHooks = true }, func(r ai.Request) bool { return r.Memory.SkipHooks }}, + {"no-skills", func(o *AIPromptOptions) { o.NoSkills = true }, func(r ai.Request) bool { return r.Memory.SkipSkills }}, + {"no-user", func(o *AIPromptOptions) { o.NoUser = true }, func(r ai.Request) bool { return r.Memory.SkipUser }}, + {"no-project", func(o *AIPromptOptions) { o.NoProject = true }, func(r ai.Request) bool { return r.Memory.SkipProject }}, + {"no-memory", func(o *AIPromptOptions) { o.NoMemory = true }, func(r ai.Request) bool { return r.Memory.SkipMemory }}, } - for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { opts := defaultPromptOptions(t) tc.mutate(&opts) - req := opts.ToRequest() - // Map field names back to Request fields the test expects. - rv := reflect.ValueOf(req) - fieldMap := map[string]string{ - "NoMCP": "NoMCP", - "NoHooks": "NoHooks", - "NoSkills": "NoSkills", - "NoUser": "NoUser", - "NoProject": "NoProject", - "NoMemory": "NoMemory", - } - for _, name := range fieldMap { - if rv.FieldByName(name).Kind() != reflect.Bool { - continue - } + req, err := opts.ToRequest() + if err != nil { + t.Fatalf("ToRequest: %v", err) } - // Each test case checks one inverted flag. - lookup := map[string]string{ - "mcp=false": "NoMCP", - "hooks=false": "NoHooks", - "skills=false": "NoSkills", - "user=false": "NoUser", - "project=false": "NoProject", - "memory=false": "NoMemory", + if !tc.get(req) { + t.Errorf("flag --%s did not set the mapped request field", tc.name) } - tc.check(t, rv.FieldByName(lookup[tc.name]).Bool()) }) } } @@ -132,78 +136,153 @@ func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) { opts.Bare = true opts.MaxTokens = 1024 opts.Temperature = "0.5" + opts.Effort = "high" + opts.MaxTurns = 7 + opts.Resume = "sess-123" - req := opts.ToRequest() - if req.SystemPrompt != "be careful" { - t.Errorf("SystemPrompt = %q", req.SystemPrompt) + req, err := opts.ToRequest() + if err != nil { + t.Fatalf("ToRequest: %v", err) } - if req.AppendSystemPrompt != "also be brief" { - t.Errorf("AppendSystemPrompt = %q", req.AppendSystemPrompt) + if req.Prompt.System != "be careful" { + t.Errorf("SystemPrompt = %q", req.Prompt.System) } - if req.PermissionMode != "acceptEdits" { - t.Errorf("PermissionMode = %q", req.PermissionMode) + if req.Prompt.AppendSystem != "also be brief" { + t.Errorf("AppendSystemPrompt = %q", req.Prompt.AppendSystem) } - if !req.Edit { - t.Error("Edit not propagated") + if req.Permissions.Mode != api.PermissionAcceptEdits { + t.Errorf("PermissionMode = %q", req.Permissions.Mode) } - if !reflect.DeepEqual(req.AllowedTools, []string{"Read", "Bash"}) { - t.Errorf("AllowedTools = %v", req.AllowedTools) + if !req.Permissions.HasPreset(api.PresetEdit) { + t.Error("Edit preset not propagated") } - if !reflect.DeepEqual(req.DisallowedTools, []string{"Write"}) { - t.Errorf("DisallowedTools = %v", req.DisallowedTools) + if !reflect.DeepEqual(req.Permissions.Tools.Allow, []string{"Read", "Bash"}) { + t.Errorf("AllowedTools = %v", req.Permissions.Tools.Allow) } - if !reflect.DeepEqual(req.SkillDirs, []string{"/skills/a", "/skills/b"}) { - t.Errorf("SkillDirs = %v", req.SkillDirs) + if !reflect.DeepEqual(req.Permissions.Tools.Deny, []string{"Write"}) { + t.Errorf("DisallowedTools = %v", req.Permissions.Tools.Deny) } - if !req.Bare { + if !reflect.DeepEqual(req.Memory.Skills, []string{"/skills/a", "/skills/b"}) { + t.Errorf("SkillDirs = %v", req.Memory.Skills) + } + if !req.Memory.Bare { t.Error("Bare not propagated") } - if req.MaxTokens != 1024 { - t.Errorf("MaxTokens = %d", req.MaxTokens) + if req.Budget.MaxTokens != 1024 { + t.Errorf("MaxTokens = %d", req.Budget.MaxTokens) + } + if temp, ok := req.Temp(); !ok || temp != 0.5 { + t.Errorf("Temperature = %v (set=%v)", temp, ok) + } + if req.Effort != api.EffortHigh { + t.Errorf("ReasoningEffort = %q", req.Effort) + } + if req.MaxTurns != 7 { + t.Errorf("MaxTurns = %d", req.MaxTurns) } - if req.Temperature != 0.5 { - t.Errorf("Temperature = %v", req.Temperature) + if req.SessionID != "sess-123" { + t.Errorf("SessionID = %q (from --resume)", req.SessionID) } } -// TestAIRuntimeOptions_ToRequest_OverlaysSaved verifies the path gavel (and any -// other embedder) takes: AIRuntimeOptions with zero/default flag values should -// still pick up NoMCP/NoHooks/MaxTokens/ReasoningEffort from ~/.captain.yaml. -func TestAIRuntimeOptions_ToRequest_OverlaysSaved(t *testing.T) { - tmp := filepath.Join(t.TempDir(), ".captain.yaml") - saved := []byte("ai:\n noMCP: true\n noHooks: true\n noSkills: true\n noUser: true\n noProject: true\n noMemory: true\n maxTokens: 16000\n reasoningEffort: high\n") - if err := os.WriteFile(tmp, saved, 0o644); err != nil { - t.Fatalf("seed captain config: %v", err) +// TestAIRuntimeOptions_ToRequest_ValidationErrors verifies malformed input fails +// loudly instead of being silently coerced to a zero value. +func TestAIRuntimeOptions_ToRequest_ValidationErrors(t *testing.T) { + isolateSavedAI(t) + cases := []struct { + name string + mutate func(*AIPromptOptions) + want string + }{ + {"bad temperature", func(o *AIPromptOptions) { o.Temperature = "hot" }, "temperature"}, + {"temperature above max", func(o *AIPromptOptions) { o.Temperature = "3" }, "0.0-2.0"}, + {"temperature below min", func(o *AIPromptOptions) { o.Temperature = "-1" }, "0.0-2.0"}, + {"bad permission-mode", func(o *AIPromptOptions) { o.PermissionMode = "yolo" }, "permission-mode"}, + {"bad effort", func(o *AIPromptOptions) { o.Effort = "max" }, "effort"}, + {"max-turns above max", func(o *AIPromptOptions) { o.MaxTurns = 200 }, "max-turns"}, + {"max-turns below min", func(o *AIPromptOptions) { o.MaxTurns = -1 }, "max-turns"}, } - captainconfig.SetPathForTesting(tmp) - t.Cleanup(func() { captainconfig.SetPathForTesting("") }) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := defaultPromptOptions(t) + tc.mutate(&opts) + if _, err := opts.ToRequest(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want mention of %q", err, tc.want) + } + }) + } +} - opts := AIRuntimeOptions{ - MaxTokens: 4096, // sentinel default — should be overridden by saved 16000 - MCP: true, // flag-default true; saved NoMCP=true must still win - Hooks: true, - Skills: true, - User: true, - Project: true, - Memory: true, +func TestAIProviderOptions_ToConfig_ValidationErrors(t *testing.T) { + isolateSavedAI(t) + if _, err := (AIProviderOptions{Model: "claude-x", Backend: "nope"}).ToConfig(); err == nil || !strings.Contains(err.Error(), "backend") { + t.Fatalf("expected invalid backend error, got %v", err) } - req := opts.ToRequest("sys", "", "user") + if _, err := (AIProviderOptions{Model: "claude-x", Budget: "free"}).ToConfig(); err == nil || !strings.Contains(err.Error(), "budget") { + t.Fatalf("expected invalid budget error, got %v", err) + } +} + +// TestAIRuntimeOptions_ToRequest_MaxTokensPrecedence pins the flag > saved > +// built-in order, replacing the old magic-4096 sentinel. +func TestAIRuntimeOptions_ToRequest_MaxTokensPrecedence(t *testing.T) { + t.Run("explicit flag wins over saved", func(t *testing.T) { + seedSavedAI(t, "ai:\n maxTokens: 16000\n") + req, err := AIRuntimeOptions{MaxTokens: 2048}.ToRequest("", "", "p") + if err != nil { + t.Fatal(err) + } + if req.Budget.MaxTokens != 2048 { + t.Errorf("MaxTokens = %d, want 2048 (explicit flag)", req.Budget.MaxTokens) + } + }) + t.Run("unset falls back to saved", func(t *testing.T) { + seedSavedAI(t, "ai:\n maxTokens: 16000\n") + req, err := AIRuntimeOptions{}.ToRequest("", "", "p") + if err != nil { + t.Fatal(err) + } + if req.Budget.MaxTokens != 16000 { + t.Errorf("MaxTokens = %d, want 16000 (saved)", req.Budget.MaxTokens) + } + }) + t.Run("unset with no saved uses built-in 4096", func(t *testing.T) { + isolateSavedAI(t) + req, err := AIRuntimeOptions{}.ToRequest("", "", "p") + if err != nil { + t.Fatal(err) + } + if req.Budget.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want 4096 (built-in)", req.Budget.MaxTokens) + } + }) +} - if req.SystemPrompt != "sys" { - t.Errorf("SystemPrompt = %q, want sys", req.SystemPrompt) +// TestAIRuntimeOptions_ToRequest_OverlaysSaved verifies the path gavel (and any +// other embedder) takes: AIRuntimeOptions with zero flag values should pick up +// NoMCP/.../MaxTokens/ReasoningEffort from ~/.captain.yaml, and an explicit +// --effort flag should override the saved value. +func TestAIRuntimeOptions_ToRequest_OverlaysSaved(t *testing.T) { + seedSavedAI(t, "ai:\n noMCP: true\n noHooks: true\n noSkills: true\n noUser: true\n noProject: true\n noMemory: true\n maxTokens: 16000\n reasoningEffort: low\n") + + opts := AIRuntimeOptions{Effort: "high"} // flag overrides saved low + req, err := opts.ToRequest("sys", "", "user") + if err != nil { + t.Fatalf("ToRequest: %v", err) } - if req.Prompt != "user" { - t.Errorf("Prompt = %q, want user", req.Prompt) + + if req.Prompt.System != "sys" || req.Prompt.User != "user" { + t.Errorf("prompt fields = %q/%q, want sys/user", req.Prompt.System, req.Prompt.User) } - if req.MaxTokens != 16000 { - t.Errorf("MaxTokens = %d, want 16000 (saved overlay)", req.MaxTokens) + if req.Budget.MaxTokens != 16000 { + t.Errorf("MaxTokens = %d, want 16000 (saved overlay)", req.Budget.MaxTokens) } - if req.ReasoningEffort != "high" { - t.Errorf("ReasoningEffort = %q, want high", req.ReasoningEffort) + if req.Effort != api.EffortHigh { + t.Errorf("ReasoningEffort = %q, want high (flag overrides saved)", req.Effort) } for name, got := range map[string]bool{ - "NoMCP": req.NoMCP, "NoHooks": req.NoHooks, "NoSkills": req.NoSkills, - "NoUser": req.NoUser, "NoProject": req.NoProject, "NoMemory": req.NoMemory, + "NoMCP": req.Permissions.MCP.Disabled, "NoHooks": req.Memory.SkipHooks, "NoSkills": req.Memory.SkipSkills, + "NoUser": req.Memory.SkipUser, "NoProject": req.Memory.SkipProject, "NoMemory": req.Memory.SkipMemory, } { if !got { t.Errorf("%s = false, want true (from saved config)", name) @@ -211,27 +290,125 @@ func TestAIRuntimeOptions_ToRequest_OverlaysSaved(t *testing.T) { } } +// TestBackendHelpEnumeratesAllBackends guards the static --backend help strings +// against drift from ai.AllBackends() (the single source of truth). +func TestBackendHelpEnumeratesAllBackends(t *testing.T) { + for _, c := range []struct { + typ reflect.Type + name string + }{ + {reflect.TypeOf(AIProviderOptions{}), "AIProviderOptions"}, + {reflect.TypeOf(AIModelsOptions{}), "AIModelsOptions"}, + } { + f, ok := c.typ.FieldByName("Backend") + if !ok { + t.Fatalf("%s has no Backend field", c.name) + } + help := f.Tag.Get("help") + for _, b := range ai.AllBackends() { + if !strings.Contains(help, string(b)) { + t.Errorf("%s Backend help %q is missing backend %q", c.name, help, b) + } + } + } +} + +func TestRunBuffered_JSONIncludesFullInputSpec(t *testing.T) { + req := ai.Request{ + Model: api.Model{Name: "claude-sonnet-4-6", Backend: api.BackendAnthropic, Effort: api.EffortMedium}, + Prompt: api.Prompt{System: "be precise", User: "summarize"}, + Budget: api.Budget{MaxTokens: 2048}, + Context: api.Context{Dir: "/repo"}, + Permissions: api.Permissions{ + Presets: []api.Preset{api.PresetEdit}, + Tools: api.Tools{Allow: []string{"Read"}}, + }, + SessionID: "resume-1", + } + + got, err := runBuffered(context.Background(), promptResultProvider{}, req) + if err != nil { + t.Fatal(err) + } + result, ok := got.(AIPromptResult) + if !ok { + t.Fatalf("runBuffered returned %T, want AIPromptResult", got) + } + if result.Input.Prompt.User != "summarize" || result.Input.Model.Name != "claude-sonnet-4-6" { + t.Fatalf("result input = %+v, want original request", result.Input) + } + if result.InputTokens != 12 { + t.Fatalf("InputTokens = %d, want 12", result.InputTokens) + } + if result.Model != "claude-sonnet-4-6" || result.Backend != "anthropic" || result.Dir != "/repo" || result.SessionID != "resume-1" { + t.Fatalf("resolved fields = model %q backend %q dir %q session %q", result.Model, result.Backend, result.Dir, result.SessionID) + } + + var encoded map[string]any + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &encoded); err != nil { + t.Fatal(err) + } + input, ok := encoded["input"].(map[string]any) + if !ok { + t.Fatalf("json input = %T, want object: %s", encoded["input"], data) + } + prompt, ok := input["prompt"].(map[string]any) + if !ok || prompt["user"] != "summarize" || prompt["system"] != "be precise" { + t.Fatalf("json input.prompt = %#v, want rendered prompt", input["prompt"]) + } + if input["model"] != "claude-sonnet-4-6" || input["backend"] != "anthropic" || input["effort"] != "medium" { + t.Fatalf("json input model fields = %#v", input) + } + context, ok := input["context"].(map[string]any) + if !ok || context["dir"] != "/repo" { + t.Fatalf("json input.context = %#v, want dir /repo", input["context"]) + } + if input["sessionId"] != "resume-1" || encoded["sessionId"] != "resume-1" || encoded["dir"] != "/repo" { + t.Fatalf("json session/dir fields = top %#v input %#v", encoded, input) + } + if got := encoded["inputTokens"]; got != float64(12) { + t.Fatalf("json inputTokens = %#v, want 12", got) + } +} + +func TestRunStreaming_JSONIncludesFullInputSpec(t *testing.T) { + req := ai.Request{ + Model: api.Model{Name: "gpt-5-codex", Backend: api.BackendCodexCLI, Effort: api.EffortHigh}, + Prompt: api.Prompt{User: "fix tests"}, + Context: api.Context{Dir: "/repo"}, + Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}, + } + + got, err := runStreaming(context.Background(), promptResultStreamingProvider{}, req) + if err != nil { + t.Fatal(err) + } + result, ok := got.(AIPromptResult) + if !ok { + t.Fatalf("runStreaming returned %T, want AIPromptResult", got) + } + if result.Input.Prompt.User != "fix tests" || result.Input.Model.Backend != api.BackendCodexCLI { + t.Fatalf("result input = %+v, want original request", result.Input) + } + if result.Dir != "/repo" || result.SessionID != "stream-session-1" || result.Input.SessionID != "stream-session-1" { + t.Fatalf("dir/session = dir %q session %q input session %q", result.Dir, result.SessionID, result.Input.SessionID) + } + if result.InputTokens != 21 || result.Output != 9 || result.CostUSD != 0.02 { + t.Fatalf("usage/cost = input %d output %d cost %f", result.InputTokens, result.Output, result.CostUSD) + } +} + func defaultPromptOptions(t *testing.T) AIPromptOptions { t.Helper() return AIPromptOptions{ AIRuntimeOptions: AIRuntimeOptions{ - MaxTokens: 4096, Temperature: "0", - MCP: true, - Hooks: true, - Skills: true, - User: true, - Project: true, - Memory: true, }, Timeout: "120s", Prompt: "hello", } } - -func mustBool(t *testing.T, got, want bool, name string) { - t.Helper() - if got != want { - t.Errorf("%s = %v, want %v", name, got, want) - } -} diff --git a/pkg/cli/chat_thread_store.go b/pkg/cli/chat_thread_store.go new file mode 100644 index 0000000..fb9a24c --- /dev/null +++ b/pkg/cli/chat_thread_store.go @@ -0,0 +1,231 @@ +package cli + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/flanksource/clicky/aichat" +) + +type fileThreadStore struct { + path string + mu sync.Mutex +} + +type threadStoreFile struct { + Threads []*aichat.Thread `json:"threads"` +} + +func newFileThreadStore(path string) *fileThreadStore { + return &fileThreadStore{path: path} +} + +func (s *fileThreadStore) Create(_ context.Context, title string) (*aichat.Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + + state, err := s.loadLocked() + if err != nil { + return nil, err + } + if title == "" { + title = "New conversation" + } + now := time.Now() + thread := &aichat.Thread{ + ID: newThreadID(), + Title: title, + CreatedAt: now, + UpdatedAt: now, + } + state.Threads = append(state.Threads, thread) + if err := s.saveLocked(state); err != nil { + return nil, err + } + return cloneThread(thread), nil +} + +func (s *fileThreadStore) List(_ context.Context) ([]*aichat.Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + + state, err := s.loadLocked() + if err != nil { + return nil, err + } + threads := make([]*aichat.Thread, 0, len(state.Threads)) + for _, thread := range state.Threads { + threads = append(threads, cloneThread(thread)) + } + sort.Slice(threads, func(i, j int) bool { + return threads[i].UpdatedAt.After(threads[j].UpdatedAt) + }) + return threads, nil +} + +func (s *fileThreadStore) Get(_ context.Context, id string) (*aichat.Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + + state, err := s.loadLocked() + if err != nil { + return nil, err + } + thread := findThread(state, id) + if thread == nil { + return nil, fmt.Errorf("thread %q not found", id) + } + return cloneThread(thread), nil +} + +func (s *fileThreadStore) AppendMessage(_ context.Context, id string, msg aichat.UIMessage) error { + s.mu.Lock() + defer s.mu.Unlock() + + state, err := s.loadLocked() + if err != nil { + return err + } + thread := findThread(state, id) + if thread == nil { + return fmt.Errorf("thread %q not found", id) + } + thread.Messages = append(thread.Messages, msg) + thread.UpdatedAt = time.Now() + return s.saveLocked(state) +} + +func (s *fileThreadStore) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + state, err := s.loadLocked() + if err != nil { + return err + } + for i, thread := range state.Threads { + if thread.ID == id { + state.Threads = append(state.Threads[:i], state.Threads[i+1:]...) + return s.saveLocked(state) + } + } + return fmt.Errorf("thread %q not found", id) +} + +func (s *fileThreadStore) SetProviderSession(_ context.Context, id, providerSessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + state, err := s.loadLocked() + if err != nil { + return err + } + thread := findThread(state, id) + if thread == nil { + return fmt.Errorf("thread %q not found", id) + } + thread.ProviderSessionID = providerSessionID + thread.UpdatedAt = time.Now() + return s.saveLocked(state) +} + +func (s *fileThreadStore) AddUsage(_ context.Context, id string, usage aichat.TurnUsage) (*aichat.Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + + state, err := s.loadLocked() + if err != nil { + return nil, err + } + thread := findThread(state, id) + if thread == nil { + return nil, fmt.Errorf("thread %q not found", id) + } + thread.TotalInputTokens += usage.InputTokens + thread.TotalOutputTokens += usage.OutputTokens + thread.TotalCostUsd += usage.CostUSD + thread.LastContextTokens = usage.InputTokens + thread.UpdatedAt = time.Now() + if err := s.saveLocked(state); err != nil { + return nil, err + } + return cloneThread(thread), nil +} + +func (s *fileThreadStore) loadLocked() (*threadStoreFile, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return &threadStoreFile{}, nil + } + if err != nil { + return nil, err + } + if len(data) == 0 { + return &threadStoreFile{}, nil + } + var state threadStoreFile + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("read chat threads %s: %w", s.path, err) + } + return &state, nil +} + +func (s *fileThreadStore) saveLocked(state *threadStoreFile) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func findThread(state *threadStoreFile, id string) *aichat.Thread { + for _, thread := range state.Threads { + if thread.ID == id { + return thread + } + } + return nil +} + +func cloneThread(thread *aichat.Thread) *aichat.Thread { + if thread == nil { + return nil + } + data, err := json.Marshal(thread) + if err != nil { + copy := *thread + copy.Messages = append([]aichat.UIMessage(nil), thread.Messages...) + return © + } + var out aichat.Thread + if err := json.Unmarshal(data, &out); err != nil { + copy := *thread + copy.Messages = append([]aichat.UIMessage(nil), thread.Messages...) + return © + } + return &out +} + +func newThreadID() string { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return fmt.Sprintf("thread-%d", time.Now().UnixNano()) + } + return "thread-" + hex.EncodeToString(raw[:]) +} diff --git a/pkg/cli/chat_thread_store_test.go b/pkg/cli/chat_thread_store_test.go new file mode 100644 index 0000000..a404ae9 --- /dev/null +++ b/pkg/cli/chat_thread_store_test.go @@ -0,0 +1,78 @@ +package cli + +import ( + "context" + "path/filepath" + "testing" + + "github.com/flanksource/clicky/aichat" +) + +func TestFileThreadStorePersistsThreads(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "threads.json") + store := newFileThreadStore(path) + + thread, err := store.Create(ctx, "Launch cleanup") + if err != nil { + t.Fatalf("Create: %v", err) + } + if thread.ID == "" { + t.Fatal("Create returned empty thread id") + } + + if err := store.SetProviderSession(ctx, thread.ID, "provider-session-1"); err != nil { + t.Fatalf("SetProviderSession: %v", err) + } + msg := aichat.UIMessage{ + Role: "user", + Parts: []aichat.UIPart{{Type: "text", Text: "continue"}}, + } + if err := store.AppendMessage(ctx, thread.ID, msg); err != nil { + t.Fatalf("AppendMessage: %v", err) + } + updated, err := store.AddUsage(ctx, thread.ID, aichat.TurnUsage{ + InputTokens: 10, + OutputTokens: 5, + CostUSD: 0.25, + }) + if err != nil { + t.Fatalf("AddUsage: %v", err) + } + if updated.ProviderSessionID != "provider-session-1" { + t.Fatalf("ProviderSessionID = %q", updated.ProviderSessionID) + } + + reloaded := newFileThreadStore(path) + got, err := reloaded.Get(ctx, thread.ID) + if err != nil { + t.Fatalf("Get reloaded: %v", err) + } + if got.Title != "Launch cleanup" { + t.Errorf("Title = %q", got.Title) + } + if got.ProviderSessionID != "provider-session-1" { + t.Errorf("ProviderSessionID = %q", got.ProviderSessionID) + } + if len(got.Messages) != 1 || got.Messages[0].Parts[0].Text != "continue" { + t.Errorf("Messages = %+v", got.Messages) + } + if got.TotalInputTokens != 10 || got.TotalOutputTokens != 5 || got.TotalCostUsd != 0.25 { + t.Errorf("usage totals = input %d output %d cost %f", got.TotalInputTokens, got.TotalOutputTokens, got.TotalCostUsd) + } + + list, err := reloaded.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 || list[0].ID != thread.ID { + t.Fatalf("List = %+v", list) + } + + if err := reloaded.Delete(ctx, thread.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := reloaded.Get(ctx, thread.ID); err == nil { + t.Fatal("Get deleted thread returned nil error") + } +} diff --git a/pkg/cli/configure.go b/pkg/cli/configure.go index d1ffb20..bec23d1 100644 --- a/pkg/cli/configure.go +++ b/pkg/cli/configure.go @@ -71,7 +71,7 @@ func RunConfigure(opts ConfigureOptions) (any, error) { Value(&model), huh.NewSelect[string](). Title("Reasoning effort"). - Description("Currently honoured by codex-cli only; other providers ignore."). + Description("Honoured by codex-cli and the API backends (thinking budget); CLI wrappers may ignore."). Options( huh.NewOption("low", "low"), huh.NewOption("medium", "medium"), @@ -97,7 +97,7 @@ func RunConfigure(opts ConfigureOptions) (any, error) { huh.NewGroup( huh.NewMultiSelect[string](). Title("Defaults to ENABLE"). - Description("Selected = on. Unselected items are disabled by default; pass --mcp=true etc. to override per-call."). + Description("Selected = enabled by default. Per call, --no-mcp/--no-hooks/etc. disable further; re-enable a disabled default by rerunning configure."). Options(toggleHuhOptions()...). Value(&enabled), ), @@ -207,17 +207,24 @@ func backendOptions() []huh.Option[string] { huh.NewOption("Google Gemini API", string(ai.BackendGemini)), huh.NewOption("OpenAI API", string(ai.BackendOpenAI)), huh.NewOption("Claude CLI", string(ai.BackendClaudeCLI)), + huh.NewOption("Claude Agent (SDK)", string(ai.BackendClaudeAgent)), huh.NewOption("Codex CLI", string(ai.BackendCodexCLI)), huh.NewOption("Gemini CLI", string(ai.BackendGeminiCLI)), } } -// modelOptionsFor fetches the live model catalogue for the chosen backend -// and renders it as huh select options. Live data is the only source of -// truth — there is no static fallback. If the API key is missing or the call -// fails, the picker shows a single sentinel row carrying the error so the -// user can fix their environment without dropping out of the form. +// modelOptionsFor renders the chosen backend's models as huh select options. +// CLI/agent backends authenticate internally, so their models come from the +// static catalog (no API key). API backends fetch the live /v1/models +// catalogue — the only source of truth, with no static fallback: if the key is +// missing or the call fails, the picker shows a single sentinel row carrying +// the error so the user can fix their environment without dropping out of the +// form. func modelOptionsFor(b ai.Backend) []huh.Option[string] { + if b.Kind() == "cli" { + return modelHuhOptions(agentCatalogModels(b)) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -228,7 +235,9 @@ func modelOptionsFor(b ai.Backend) []huh.Option[string] { // Drop legacy / non-chat IDs (Grok 3 mini, gpt-3.5, dall-e, ...) so the // picker shows only models worth defaulting to. Shared with `captain ai - // models` so both surfaces stay consistent. + // models` so both surfaces stay consistent. The catalog used for CLI + // backends is already curated, so this filter applies only to the raw + // live list. filtered := make([]ai.ModelDef, 0, len(models)) for _, m := range models { if isLegacyModelID(m.ID) { @@ -236,33 +245,39 @@ func modelOptionsFor(b ai.Backend) []huh.Option[string] { } filtered = append(filtered, m) } - if len(filtered) == 0 { + return modelHuhOptions(filtered) +} + +// modelHuhOptions renders a (pre-sorted) model list as picker options, falling +// back to a single disabled sentinel row when the list is empty. +func modelHuhOptions(models []ai.ModelDef) []huh.Option[string] { + if len(models) == 0 { return []huh.Option[string]{huh.NewOption("(no models available)", "")} } - - // Already sorted alphabetically by ListModels. - out := make([]huh.Option[string], 0, len(filtered)) - for _, m := range filtered { + out := make([]huh.Option[string], 0, len(models)) + for _, m := range models { out = append(out, huh.NewOption(m.Name, m.ID)) } return out } -// defaultModelFor returns a hard-coded picker default per backend. With the -// static catalogue gone the API itself doesn't expose a "default" flag, so we -// fall back to the most-current model id we expect each provider to keep -// stable. The user can pick anything else from the live list; this just -// seeds the form. +// defaultModelFor returns a hard-coded picker default per backend that seeds the +// form. CLI/agent backends use the catalog slug their picker actually lists +// (agentCatalogModels) so the seeded default is a selectable option. API +// backends have no "default" flag on /v1/models, so we use the most-current id +// we expect each provider to keep stable; the user can pick anything else. func defaultModelFor(b ai.Backend) string { switch b { - case ai.BackendAnthropic, ai.BackendClaudeCLI: + case ai.BackendAnthropic: return "claude-sonnet-4-5" + case ai.BackendClaudeCLI, ai.BackendClaudeAgent: + return "claude-agent-sonnet" case ai.BackendOpenAI: - return "gpt-5" + return "gpt-5.5" case ai.BackendCodexCLI: return "gpt-5-codex" case ai.BackendGemini, ai.BackendGeminiCLI: - return "gemini-2.5-flash" + return "gemini-3.5-flash" } return "" } diff --git a/pkg/cli/configure_test.go b/pkg/cli/configure_test.go index e65acd8..7b42df1 100644 --- a/pkg/cli/configure_test.go +++ b/pkg/cli/configure_test.go @@ -100,14 +100,15 @@ func TestTogglesFromConfig_RoundTripsThroughBuildConfig(t *testing.T) { } func TestModelOptionsFor_NoKeyShowsErrorRow(t *testing.T) { - // With the static catalog gone, a missing API key surfaces as a - // single sentinel option carrying the error so the user can fix - // their environment without leaving the wizard. + // API backends have no static fallback: a missing key surfaces as a single + // sentinel option carrying the error so the user can fix their environment + // without leaving the wizard. CLI/agent backends are covered separately — + // they list from the catalog and never require a key. t.Setenv("OPENAI_API_KEY", "") t.Setenv("ANTHROPIC_API_KEY", "") t.Setenv("GEMINI_API_KEY", "") - for _, b := range []ai.Backend{ai.BackendAnthropic, ai.BackendOpenAI, ai.BackendGemini, ai.BackendCodexCLI} { + for _, b := range []ai.Backend{ai.BackendAnthropic, ai.BackendOpenAI, ai.BackendGemini} { opts := modelOptionsFor(b) if len(opts) != 1 { t.Errorf("backend=%s: expected 1 sentinel row, got %d (%+v)", b, len(opts), opts) @@ -119,14 +120,35 @@ func TestModelOptionsFor_NoKeyShowsErrorRow(t *testing.T) { } } +// TestModelOptionsFor_CLIBackendsUseCatalogWithoutKey verifies CLI/agent +// backends populate the picker from the static catalog with no API key set: +// they authenticate internally, so the wizard must never gate them on a key. +func TestModelOptionsFor_CLIBackendsUseCatalogWithoutKey(t *testing.T) { + installTestCatalog(t) + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("GEMINI_API_KEY", "") + + opts := modelOptionsFor(ai.BackendCodexCLI) + if len(opts) != 1 { + t.Fatalf("codex-cli picker = %+v, want a single catalog option", opts) + } + // huh.Option.Key is the display label; the selectable value is the runtime + // slug the codex provider expects verbatim. + if opts[0].Value != "gpt-5-codex" { + t.Errorf("codex-cli option value = %q, want catalog slug gpt-5-codex", opts[0].Value) + } +} + func TestDefaultModelFor_HardcodedPerBackend(t *testing.T) { cases := map[ai.Backend]string{ - ai.BackendAnthropic: "claude-sonnet-4-5", - ai.BackendClaudeCLI: "claude-sonnet-4-5", - ai.BackendOpenAI: "gpt-5", - ai.BackendCodexCLI: "gpt-5-codex", - ai.BackendGemini: "gemini-2.5-flash", - ai.BackendGeminiCLI: "gemini-2.5-flash", + ai.BackendAnthropic: "claude-sonnet-4-5", + ai.BackendClaudeCLI: "claude-agent-sonnet", + ai.BackendClaudeAgent: "claude-agent-sonnet", + ai.BackendOpenAI: "gpt-5.5", + ai.BackendCodexCLI: "gpt-5-codex", + ai.BackendGemini: "gemini-3.5-flash", + ai.BackendGeminiCLI: "gemini-3.5-flash", } for b, want := range cases { if got := defaultModelFor(b); got != want { diff --git a/pkg/cli/cost.go b/pkg/cli/cost.go index fa20aa1..85908cb 100644 --- a/pkg/cli/cost.go +++ b/pkg/cli/cost.go @@ -11,9 +11,10 @@ import ( ) type CostOptions struct { - Since time.Time `flag:"since" help:"Only include sessions after this time" default:"now-7d" short:"s"` - All bool `flag:"all" help:"Search all projects" short:"a"` - GroupBy string `flag:"group-by" help:"Group results: session, project, model, day, dir, file, tool, category" default:"session" short:"g"` + Since time.Time `flag:"since" help:"Only include sessions after this time" default:"now-7d" short:"s"` + All bool `flag:"all" help:"Search all projects" short:"a"` + GroupBy string `flag:"group-by" help:"Group results: session, project, model, day, dir, file, tool, category" default:"session" short:"g"` + SessionID string `flag:"session-id" help:"Filter by session ID (exact or prefix match)"` } type CostRow struct { @@ -69,10 +70,12 @@ func RunCost(opts CostOptions) (any, error) { return runCostDetailed(cwd, opts) } - sessions, err := claude.ParseCosts(cwd, opts.All, &opts.Since) + sessionIDs := costSessionIDs(opts) + sessions, err := claude.ParseCostsWithFilter(cwd, opts.All, &opts.Since, claude.Filter{SessionIDs: sessionIDs}) if err != nil { return nil, err } + sessions = filterCostsBySessionID(sessions, sessionIDs) grouped := groupSessions(sessions, opts.GroupBy) @@ -111,10 +114,12 @@ func RunCost(opts CostOptions) (any, error) { } func runCostDetailed(cwd string, opts CostOptions) (any, error) { - sessions, err := claude.ParseCostsDetailed(cwd, opts.All, &opts.Since) + sessionIDs := costSessionIDs(opts) + sessions, err := claude.ParseCostsDetailedWithFilter(cwd, opts.All, &opts.Since, claude.Filter{SessionIDs: sessionIDs}) if err != nil { return nil, err } + sessions = filterCostsBySessionID(sessions, sessionIDs) if opts.GroupBy == "tool" { return aggregateToolCosts(sessions), nil @@ -122,6 +127,11 @@ func runCostDetailed(cwd string, opts CostOptions) (any, error) { return aggregateCategoryCosts(sessions), nil } +func costSessionIDs(opts CostOptions) []string { + ids, _ := normalizeSessionIDFilters("", opts.SessionID, nil) + return ids +} + func aggregateToolCosts(sessions []claude.SessionCost) ToolCostResult { merged := make(map[string]*claude.ToolTokenSummary) for _, s := range sessions { diff --git a/pkg/cli/history.go b/pkg/cli/history.go index 7d750a4..af80148 100644 --- a/pkg/cli/history.go +++ b/pkg/cli/history.go @@ -19,12 +19,13 @@ import ( ) type HistoryOptions struct { - Paths []string `args:"true" help:"Filter by file or directory paths"` + Paths []string `args:"true" help:"Filter by file or directory paths; canonical UUID args are treated as session IDs"` File string `flag:"file" help:"Read from a JSONL/JSON file ('-' for stdin) instead of session history" short:"f"` Tools []string `flag:"tool" help:"Filter by tool patterns" short:"t"` Categories []string `flag:"category" help:"Filter by category patterns" short:"c"` Approved string `flag:"approved" help:"Filter by approval status (true=approved, false=denied)"` - Session string `flag:"session" help:"Filter by session ID (exact or prefix match)"` + Session string `flag:"session" help:"Alias for --session-id (exact or prefix match)"` + SessionID string `flag:"session-id" help:"Filter by session ID (exact or prefix match)"` TextFilter string Limit int `flag:"limit" help:"Maximum results" default:"100" short:"l"` Since time.Time `flag:"since" help:"Only include commands after this time" default:"now-7d" short:"s"` @@ -66,16 +67,25 @@ func RunHistory(opts HistoryOptions) (any, error) { return out, err } + var sessionIDs []string + var err error + opts, sessionIDs, err = normalizeHistoryOptions(opts) + if err != nil { + return nil, err + } + cwd, err := os.Getwd() if err != nil { return nil, err } + cwd = historyScanCWD(cwd, opts.Paths, opts.All) filter := claude.Filter{ Tools: opts.Tools, Paths: resolvePaths(opts.Paths), Since: &opts.Since, - SessionID: opts.Session, + SessionID: firstSessionID(sessionIDs), + SessionIDs: sessionIDs, IncludeAgents: opts.Agents, } @@ -98,13 +108,15 @@ func RunHistory(opts HistoryOptions) (any, error) { var costs []claude.SessionCost if opts.Summary { if showClaude { - costs, _ = claude.ParseCostsDetailed(cwd, opts.All, &opts.Since) + costs, _ = claude.ParseCostsDetailedWithFilter(cwd, opts.All, &opts.Since, filter) + costs = filterCostsBySessionID(costs, sessionIDs) } return runHistorySummary(allToolUses, opts, classifier, costs) } if showClaude { - costs, _ = claude.ParseCosts(cwd, opts.All, &opts.Since) + costs, _ = claude.ParseCostsWithFilter(cwd, opts.All, &opts.Since, filter) + costs = filterCostsBySessionID(costs, sessionIDs) } var tl []tools.Tool @@ -135,6 +147,16 @@ func RunHistory(opts HistoryOptions) (any, error) { return runHistorySingle(tl, opts, classifier, costs) } +func normalizeHistoryOptions(opts HistoryOptions) (HistoryOptions, []string, error) { + positionalIDs, paths := splitSessionIDPathArgs(opts.Paths) + sessionIDs, err := normalizeSessionIDFilters(opts.Session, opts.SessionID, positionalIDs) + if err != nil { + return opts, nil, err + } + opts.Paths = paths + return opts, sessionIDs, nil +} + // lastSessionTools returns the trailing run of tools that share a sessionKey // with the final tool. The input is expected to be sorted oldest-first; the // result preserves that order. @@ -170,7 +192,7 @@ func gatherToolUses(cwd string, searchAll, showClaude, showCodex bool, filter cl } if showCodex { - codexUses, err := collectCodexHistory(cwd, searchAll) + codexUses, err := collectCodexHistory(cwd, searchAll, filter) if err == nil { converted := codexToClaudeToolUses(codexUses) converted = claude.FilterToolUses(converted, filter) @@ -183,7 +205,7 @@ func gatherToolUses(cwd string, searchAll, showClaude, showCodex bool, filter cl // collectCodexHistory loads codex sessions and returns their tool uses // filtered to the current project (or all if searchAll is true). -func collectCodexHistory(cwd string, searchAll bool) ([]history.ToolUse, error) { +func collectCodexHistory(cwd string, searchAll bool, filter claude.Filter) ([]history.ToolUse, error) { files, err := history.FindCodexSessionFiles() if err != nil { return nil, err @@ -197,10 +219,26 @@ func collectCodexHistory(cwd string, searchAll bool) ([]history.ToolUse, error) var out []history.ToolUse for _, f := range files { + info, infoErr := history.ReadCodexSessionMeta(f) + if infoErr == nil && info != nil { + if info.ID != "" && !filter.MatchesSessionID(info.ID) { + continue + } + if !searchAll && info.CWD != "" && !codexCWDMatchesProject(info.CWD, matchRoot) { + continue + } + // When a session-id filter is set but the metadata has no id yet, fall + // through to full parsing: older/live schemas may only expose the session + // id on later events. + } + uses, err := history.ExtractCodexToolUses(f) if err != nil || len(uses) == 0 { continue } + if !codexUsesMatchSession(uses, filter) { + continue + } if !searchAll && !codexSessionMatchesProject(uses, matchRoot) { continue } @@ -209,6 +247,18 @@ func collectCodexHistory(cwd string, searchAll bool) ([]history.ToolUse, error) return out, nil } +func codexUsesMatchSession(uses []history.ToolUse, filter claude.Filter) bool { + if !filter.HasSessionIDFilter() { + return true + } + for _, use := range uses { + if filter.MatchesSessionID(use.SessionID) { + return true + } + } + return false +} + func sortToolUsesByTime(uses []claude.ToolUse) { sort.Slice(uses, func(i, j int) bool { ti := uses[i].Timestamp diff --git a/pkg/cli/info.go b/pkg/cli/info.go index 340921c..24c68f2 100644 --- a/pkg/cli/info.go +++ b/pkg/cli/info.go @@ -467,29 +467,41 @@ func codexSessionMatchesProject(uses []history.ToolUse, projectRoot string) bool if projectRoot == "" { return true } - rootAbs, err := filepath.Abs(projectRoot) - if err != nil { - rootAbs = projectRoot - } for _, u := range uses { - if u.CWD == "" { - continue - } - cwdAbs, err := filepath.Abs(u.CWD) - if err != nil { - cwdAbs = u.CWD - } - if cwdAbs == rootAbs { - return true - } - rel, err := filepath.Rel(rootAbs, cwdAbs) - if err == nil && rel != ".." && !startsWithParent(rel) { + if codexCWDMatchesProject(u.CWD, projectRoot) { return true } } return false } +func codexCWDMatchesProject(cwd, projectRoot string) bool { + if projectRoot == "" { + return true + } + if cwd == "" { + return false + } + rootAbs := canonicalPath(projectRoot) + cwdAbs := canonicalPath(cwd) + if cwdAbs == rootAbs { + return true + } + rel, err := filepath.Rel(rootAbs, cwdAbs) + return err == nil && rel != ".." && !startsWithParent(rel) +} + +func canonicalPath(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + func startsWithParent(rel string) bool { return len(rel) >= 2 && rel[:2] == ".." } diff --git a/pkg/cli/plan.go b/pkg/cli/plan.go new file mode 100644 index 0000000..6bdc588 --- /dev/null +++ b/pkg/cli/plan.go @@ -0,0 +1,243 @@ +package cli + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/api/icons" +) + +type PlanOptions struct { + SessionID string `flag:"session-id" args:"true" help:"Session ID (exact or prefix) to resolve the plan for; defaults to the most recent session with a plan in the current directory" short:"s"` + Source string `flag:"source" help:"Restrict source: all, claude, codex" default:"all"` + All bool `flag:"all" help:"Search all projects, not just the current directory" short:"a"` + PathOnly bool `flag:"path" help:"Print only the plan file path"` +} + +func (PlanOptions) GetName() string { return "plan [session-id]" } + +// PlanResult is the exit-plan-mode plan for a single session. +type PlanResult struct { + SessionID string `json:"sessionId" pretty:"label=Session"` + Source string `json:"source,omitempty" pretty:"label=Source"` + Path string `json:"path,omitempty" pretty:"label=Plan"` + OnDisk bool `json:"onDisk" pretty:"label=On Disk"` + Slug string `json:"slug,omitempty" pretty:"label=Slug"` + Content string `json:"content,omitempty"` + + pathOnly bool +} + +func RunPlan(opts PlanOptions) (PlanResult, error) { + source, err := normalizeSessionSource(opts.Source) + if err != nil { + return PlanResult{}, err + } + cwd, err := os.Getwd() + if err != nil { + return PlanResult{}, err + } + + id := strings.TrimSpace(opts.SessionID) + if id != "" { + // A session id can name a session in any project, so search everywhere. + candidates, err := discoverSessionCandidates("", true, source) + if err != nil { + return PlanResult{}, err + } + candidate, ok := matchPlanCandidate(candidates, id) + if !ok { + return PlanResult{}, fmt.Errorf("session %q not found", id) + } + plan, err := resolveSessionPlan(candidate) + if err != nil { + return PlanResult{}, err + } + if plan == nil { + return PlanResult{}, fmt.Errorf("session %q has no plan", id) + } + plan.pathOnly = opts.PathOnly + return *plan, nil + } + + candidates, err := discoverSessionCandidates(cwd, opts.All, source) + if err != nil { + return PlanResult{}, err + } + sort.SliceStable(candidates, func(i, j int) bool { + return sessionSortTime(candidates[i].record).After(sessionSortTime(candidates[j].record)) + }) + for _, candidate := range candidates { + plan, err := resolveSessionPlan(candidate) + if err != nil || plan == nil { + continue + } + plan.pathOnly = opts.PathOnly + return *plan, nil + } + return PlanResult{}, fmt.Errorf("no session with a plan found in %s", scopeLabel(cwd, opts.All)) +} + +// matchPlanCandidate finds the candidate whose record key or id matches id +// exactly, or whose id has id as a prefix (so the short ids printed elsewhere work). +func matchPlanCandidate(candidates []sessionCandidate, id string) (sessionCandidate, bool) { + for _, c := range candidates { + if c.record.Key == id || c.record.ID == id || (c.record.ID != "" && strings.HasPrefix(c.record.ID, id)) { + return c, true + } + } + return sessionCandidate{}, false +} + +// resolveSessionPlan reads a session transcript and recovers its plan. It returns +// nil (no error) when the session has no plan. +func resolveSessionPlan(candidate sessionCandidate) (*PlanResult, error) { + switch candidate.record.Source { + case "claude": + return resolveClaudePlan(candidate) + case "codex": + return resolveCodexPlan(candidate) + default: + return nil, fmt.Errorf("unknown session source %q", candidate.record.Source) + } +} + +func resolveClaudePlan(candidate sessionCandidate) (*PlanResult, error) { + entries, err := claude.ReadHistoryFile(candidate.path) + if err != nil { + return nil, err + } + sp := claude.PlanFromEntries(entries) + if sp == nil { + return nil, nil + } + + content := sp.Content + onDisk := false + if data, err := os.ReadFile(sp.Path); err == nil { + onDisk = true + // The on-disk file is canonical: the agent may revise it after exiting + // plan mode, so it supersedes the inline copy captured in the transcript. + if s := string(data); strings.TrimSpace(s) != "" { + content = s + } + } + + // A slug alone (no ExitPlanMode call, write, or plan-mode attachment) is just + // the session title — only treat it as a plan if the file actually exists. + if !sp.Explicit && !onDisk { + return nil, nil + } + + return &PlanResult{ + SessionID: candidate.record.ID, + Source: "claude", + Path: sp.Path, + OnDisk: onDisk, + Slug: sp.Slug, + Content: content, + }, nil +} + +func resolveCodexPlan(candidate sessionCandidate) (*PlanResult, error) { + uses, err := history.ExtractCodexToolUses(candidate.path) + if err != nil { + return nil, err + } + content := latestCodexPlan(uses) + if content == "" { + return nil, nil + } + return &PlanResult{ + SessionID: candidate.record.ID, + Source: "codex", + Content: content, + }, nil +} + +// latestCodexPlan renders the most recent Codex update_plan checklist as markdown. +// Codex keeps its plan inline (no file), revising it via update_plan; the last +// revision is the final plan. +func latestCodexPlan(uses []history.ToolUse) string { + var steps []any + for _, use := range uses { + if use.Tool != "TodoWrite" { + continue + } + if todos, ok := use.Input["todos"].([]any); ok && len(todos) > 0 { + steps = todos + } + } + if len(steps) == 0 { + return "" + } + var b strings.Builder + for _, raw := range steps { + step, ok := raw.(map[string]any) + if !ok { + continue + } + text, _ := step["step"].(string) + if text == "" { + text, _ = step["content"].(string) + } + status, _ := step["status"].(string) + mark := " " + suffix := "" + switch status { + case "completed", "done": + mark = "x" + case "in_progress": + suffix = " _(in progress)_" + } + fmt.Fprintf(&b, "- [%s] %s%s\n", mark, text, suffix) + } + return strings.TrimRight(b.String(), "\n") +} + +// shortenHomePath rewrites a leading home directory to ~ for display. +func shortenHomePath(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if path == home { + return "~" + } + if strings.HasPrefix(path, home+"/") { + return "~" + strings.TrimPrefix(path, home) + } + return path +} + +func (r PlanResult) Pretty() api.Text { + if r.pathOnly { + return clicky.Text(r.Path, "") + } + + text := clicky.Text("").Add(icons.Icon{Unicode: "📋", Iconify: "codicon:checklist", Style: "muted"}).Space(). + Append(r.SessionID, "text-gray-700 font-medium") + if r.Source != "" { + text = text.Append(" ", "").Append(r.Source, "text-purple-600") + } + if r.Path != "" { + text = text.NewLine().Append("Plan: ", "font-bold").Append(shortenHomePath(r.Path), "text-cyan-400") + if r.OnDisk { + text = text.Append(" ✓ on disk", "text-green-500") + } else { + text = text.Append(" (not on disk)", "text-gray-500") + } + } else { + text = text.NewLine().Append("Plan: ", "font-bold").Append("(inline)", "text-gray-500") + } + if r.Content != "" { + text = text.NewLine().Add(clicky.CodeBlock("markdown", r.Content)) + } + return text +} diff --git a/pkg/cli/plan_test.go b/pkg/cli/plan_test.go new file mode 100644 index 0000000..f4867a2 --- /dev/null +++ b/pkg/cli/plan_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/claude" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// claudePlanSession writes a Claude session whose assistant turn exits plan mode +// against planPath, carrying inlineBody as the inline plan copy. +func claudePlanSession(t *testing.T, home, project, id, slug, planPath, inlineBody string) { + t.Helper() + sessionFile := filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), id+".jsonl") + writeJSONL(t, sessionFile, + map[string]any{ + "type": "user", "sessionId": id, "uuid": "u1", + "timestamp": "2026-06-01T10:00:00Z", "cwd": project, "slug": slug, + "message": map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "text", "text": "make a plan"}, + }}, + }, + map[string]any{ + "type": "assistant", "sessionId": id, "uuid": "a1", + "timestamp": "2026-06-01T10:00:01Z", "cwd": project, "slug": slug, + "message": map[string]any{"role": "assistant", "content": []any{ + map[string]any{"type": "tool_use", "id": "tu1", "name": "ExitPlanMode", + "input": map[string]any{"planFilePath": planPath, "plan": inlineBody}}, + }}, + }, + ) +} + +func TestRunPlanClaudePrefersDiskContent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "proj") + require.NoError(t, os.MkdirAll(project, 0o755)) + t.Chdir(project) + + planPath := filepath.Join(home, ".claude", "plans", "tidy-otter.md") + require.NoError(t, os.MkdirAll(filepath.Dir(planPath), 0o755)) + require.NoError(t, os.WriteFile(planPath, []byte("# Tidy otter\n\non-disk body"), 0o644)) + claudePlanSession(t, home, project, "sess-plan", "tidy-otter", planPath, "# inline body") + + got, err := RunPlan(PlanOptions{SessionID: "sess-plan", Source: "claude"}) + require.NoError(t, err) + assert.Equal(t, "sess-plan", got.SessionID) + assert.Equal(t, "claude", got.Source) + assert.Equal(t, planPath, got.Path) + assert.True(t, got.OnDisk) + assert.Equal(t, "tidy-otter", got.Slug) + assert.Equal(t, "# Tidy otter\n\non-disk body", got.Content) +} + +func TestRunPlanClaudeInlineWhenMissingOnDisk(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "proj") + require.NoError(t, os.MkdirAll(project, 0o755)) + t.Chdir(project) + + planPath := filepath.Join(home, ".claude", "plans", "lone-wolf.md") + claudePlanSession(t, home, project, "sess-inline", "lone-wolf", planPath, "# inline only") + + got, err := RunPlan(PlanOptions{SessionID: "sess-inline", Source: "claude"}) + require.NoError(t, err) + assert.False(t, got.OnDisk) + assert.Equal(t, planPath, got.Path) + assert.Equal(t, "# inline only", got.Content) +} + +func TestRunPlanClaudeNoPlanErrors(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "proj") + require.NoError(t, os.MkdirAll(project, 0o755)) + t.Chdir(project) + + // A session with a slug but no plan signal and no plan file is not a plan. + sessionFile := filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-noplan.jsonl") + writeJSONL(t, sessionFile, + map[string]any{ + "type": "assistant", "sessionId": "sess-noplan", "uuid": "a1", + "timestamp": "2026-06-01T10:00:01Z", "cwd": project, "slug": "plain-otter", + "message": map[string]any{"role": "assistant", "content": []any{ + map[string]any{"type": "tool_use", "id": "tu1", "name": "Read", + "input": map[string]any{"file_path": "README.md"}}, + }}, + }, + ) + + _, err := RunPlan(PlanOptions{SessionID: "sess-noplan", Source: "claude"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no plan") +} + +func TestRunPlanCodexChecklist(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "proj") + require.NoError(t, os.MkdirAll(project, 0o755)) + t.Chdir(project) + + codexFile := filepath.Join(home, ".codex", "sessions", "2026", "06", "rollout-plan.jsonl") + writeJSONL(t, codexFile, + map[string]any{ + "timestamp": "2026-06-01T10:00:00Z", "type": "session_meta", + "payload": map[string]any{"id": "codex-plan", "cwd": project, "cli_version": "0.1.0"}, + }, + map[string]any{ + "timestamp": "2026-06-01T10:00:01Z", "type": "response_item", + "payload": map[string]any{ + "type": "function_call", "name": "update_plan", "call_id": "c1", + "arguments": `{"plan":[{"step":"first thing","status":"completed"},{"step":"second thing","status":"in_progress"}]}`, + }, + }, + ) + + got, err := RunPlan(PlanOptions{SessionID: "codex-plan", Source: "codex"}) + require.NoError(t, err) + assert.Equal(t, "codex", got.Source) + assert.Empty(t, got.Path) + assert.Equal(t, "- [x] first thing\n- [ ] second thing _(in progress)_", got.Content) +} diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go new file mode 100644 index 0000000..9fab916 --- /dev/null +++ b/pkg/cli/serve.go @@ -0,0 +1,399 @@ +package cli + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/flanksource/clicky/aichat" + "github.com/flanksource/clicky/rpc" + "github.com/spf13/cobra" +) + +// all: keeps the committed dist/.gitkeep placeholder in the embed so the binary +// compiles without a built webapp (dist is gitignored and built on demand or in +// the release workflow). When index.html is absent, serve.go reports it at runtime. +// +//go:embed all:webapp/dist +var captainWebappFS embed.FS + +type ServeOptions struct { + Host string + Port int + Dev bool + UIPort int + Open bool + ThreadsFile string +} + +func NewServeCommand(version string) *cobra.Command { + opts := ServeOptions{ + Host: "localhost", + Port: 8080, + UIPort: 5173, + ThreadsFile: ".captain/chat-threads.json", + } + + cmd := &cobra.Command{ + Use: "serve", + Short: "Start the Captain agent launcher UI", + Long: `Start Captain's HTTP API and web UI. + +The API exposes Clicky RPC endpoints at /api/openapi.json and /api/v1/..., plus +the AI chat backend at /api/chat. The UI launches the existing "captain ai agent" +operation and opens follow-up chat windows that resume the returned agent +session. + +With --dev, Captain also starts the Vite dev server from pkg/cli/webapp and +proxies /api back to this Go process.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.validate(); err != nil { + return err + } + return RunServe(cmd.Context(), cmd.Root(), opts, version, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + + cmd.Flags().StringVar(&opts.Host, "host", opts.Host, "Host to bind the API server to") + cmd.Flags().IntVarP(&opts.Port, "port", "p", opts.Port, "Port to bind the API server to") + cmd.Flags().BoolVar(&opts.Dev, "dev", false, "Launch the Vite dev server with /api proxied to Captain") + cmd.Flags().IntVar(&opts.UIPort, "ui-port", opts.UIPort, "Port for the Vite dev server when --dev is set") + cmd.Flags().BoolVar(&opts.Open, "open", false, "Open the web UI in the default browser") + cmd.Flags().StringVar(&opts.ThreadsFile, "threads-file", opts.ThreadsFile, "Path to persisted chat thread JSON") + + return cmd +} + +func (o ServeOptions) validate() error { + if strings.TrimSpace(o.Host) == "" { + return fmt.Errorf("host cannot be empty") + } + if o.Port < 1 || o.Port > 65535 { + return fmt.Errorf("invalid --port %d", o.Port) + } + if o.Dev && (o.UIPort < 1 || o.UIPort > 65535) { + return fmt.Errorf("invalid --ui-port %d", o.UIPort) + } + if strings.TrimSpace(o.ThreadsFile) == "" { + return fmt.Errorf("threads file cannot be empty") + } + return nil +} + +func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, version string, stdout, stderr io.Writer) error { + if ctx == nil { + ctx = context.Background() + } + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + threadStore := newFileThreadStore(opts.ThreadsFile) + openAPIConfig := &rpc.OpenAPIConfig{ + Title: "Captain", + Description: "Captain command and agent launcher API.", + Version: version, + } + serveConfig := &rpc.ServeConfig{ + Host: opts.Host, + Port: opts.Port, + Title: openAPIConfig.Title, + Description: openAPIConfig.Description, + Version: openAPIConfig.Version, + Executor: &rpc.ExecutorConfig{ + Enabled: true, + SkipPreRun: true, + PathPrefix: "/api/v1", + }, + } + + rpcServer := rpc.NewSwaggerServer(serveConfig, rootCmd, openAPIConfig) + chat := aichat.NewServer(aichat.Options{ + RootCmd: rootCmd, + System: "You are Captain's coding-agent launcher assistant. Use Captain and Clicky tools when useful, " + + "prefer read-only inspection unless the user explicitly asks for edits, and keep follow-up guidance concise.", + Threads: threadStore, + ToolFilter: captainChatToolEnabled, + ToolApprovalPolicy: captainChatRequiresApproval, + Agent: aichat.AgentOptions{ + Cwd: cwd, + }, + }) + defer chat.Close() + + mux := http.NewServeMux() + rpcServer.RegisterRoutes(mux) + mux.HandleFunc("POST /api/captain/chat/threads/from-agent", handleThreadFromAgent(threadStore)) + mux.Handle("/api/chat", chat.Handler()) + mux.Handle("/api/chat/", chat.Handler()) + + uiHandler, err := newCaptainWebappHandler() + if err != nil { + return err + } + mux.Handle("/", uiHandler) + + addr := fmt.Sprintf("%s:%d", opts.Host, opts.Port) + httpSrv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 30 * time.Second, + // /api/chat streams SSE; a fixed write timeout truncates long turns. + IdleTimeout: 60 * time.Second, + } + + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + fmt.Fprintf(stdout, "Captain API listening on http://%s\n", addr) + fmt.Fprintf(stdout, " UI: http://%s/\n", addr) + fmt.Fprintf(stdout, " OpenAPI JSON: http://%s/api/openapi.json\n", addr) + fmt.Fprintf(stdout, " AI Chat: http://%s/api/chat\n", addr) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + var vite *exec.Cmd + if opts.Dev { + vite, err = startCaptainViteDevServer(ctx, opts.Host, opts.Port, opts.UIPort, stdout, stderr) + if err != nil { + _ = httpSrv.Close() + return err + } + defer func() { + if vite.Process != nil { + _ = vite.Cancel() + } + _ = vite.Wait() + }() + } + + if opts.Open { + openURL := fmt.Sprintf("http://%s/", addr) + if opts.Dev { + openURL = fmt.Sprintf("http://localhost:%d/", opts.UIPort) + } + go func() { + time.Sleep(500 * time.Millisecond) + if err := rpc.OpenBrowser(openURL); err != nil { + fmt.Fprintf(stderr, "failed to open browser: %v\n", err) + } + }() + } + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return httpSrv.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } +} + +func handleThreadFromAgent(store aichat.ThreadStore) http.HandlerFunc { + type request struct { + Title string `json:"title"` + ProviderSessionID string `json:"providerSessionId"` + Model string `json:"model"` + } + type response struct { + *aichat.Thread + LaunchURL string `json:"launchUrl"` + } + + return func(w http.ResponseWriter, r *http.Request) { + var body request + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) + return + } + body.ProviderSessionID = strings.TrimSpace(body.ProviderSessionID) + if body.ProviderSessionID == "" { + http.Error(w, "providerSessionId is required", http.StatusBadRequest) + return + } + if strings.TrimSpace(body.Title) == "" { + body.Title = "Captain agent " + body.ProviderSessionID + } + thread, err := store.Create(r.Context(), body.Title) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := store.SetProviderSession(r.Context(), thread.ID, body.ProviderSessionID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + thread, err = store.Get(r.Context(), thread.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + launchURL := "/chat/" + url.PathEscape(thread.ID) + if model := strings.TrimSpace(body.Model); model != "" { + launchURL += "?model=" + url.QueryEscape(model) + } + writeServeJSON(w, http.StatusCreated, response{ + Thread: thread, + LaunchURL: launchURL, + }) + } +} + +func captainChatToolEnabled(tool aichat.ToolInfo) bool { + raw := strings.ToLower(strings.TrimSpace(tool.OperationName)) + if raw == "" { + raw = strings.ToLower(strings.TrimSpace(tool.Name)) + } + raw = strings.ReplaceAll(raw, "/", " ") + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ' ' || r == '_' || r == '-' + }) + if len(fields) == 0 { + return true + } + switch fields[0] { + case "serve", "mcp", "hook", "container", "sandbox", "port", "configure", "ai": + return false + default: + return true + } +} + +func captainChatRequiresApproval(tool aichat.ToolInfo, _ any) bool { + switch strings.ToUpper(tool.Method) { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return false + } + if isReadOnlyCaptainTool(tool.ClickyVerb) || + isReadOnlyCaptainTool(tool.Name) || + isReadOnlyCaptainTool(tool.OperationName) || + isReadOnlyCaptainTool(tool.Path) { + return false + } + return true +} + +func isReadOnlyCaptainTool(value string) bool { + for _, part := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool { + return r == '_' || r == '-' || r == '/' || r == '.' || r == ' ' + }) { + switch part { + case "list", "get", "read", "show", "lookup", "search", "history", "info", "cost", "changes", "models", "status", "check": + return true + } + } + return false +} + +func startCaptainViteDevServer(ctx context.Context, apiHost string, apiPort, uiPort int, stdout, stderr io.Writer) (*exec.Cmd, error) { + webappDir, err := captainWebappDevDir() + if err != nil { + return nil, err + } + targetHost := apiHost + if targetHost == "0.0.0.0" || targetHost == "::" { + targetHost = "127.0.0.1" + } + apiURL := fmt.Sprintf("http://%s:%d", targetHost, apiPort) + vite := exec.CommandContext(ctx, "pnpm", "exec", "vite", "--port", strconv.Itoa(uiPort), "--strictPort") + vite.Dir = webappDir + vite.Env = append(os.Environ(), "CAPTAIN_API_URL="+apiURL) + vite.Stdout = stdout + vite.Stderr = stderr + vite.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + vite.Cancel = func() error { + if vite.Process == nil { + return nil + } + return syscall.Kill(-vite.Process.Pid, syscall.SIGTERM) + } + vite.WaitDelay = 5 * time.Second + if err := vite.Start(); err != nil { + return nil, fmt.Errorf("start vite dev server in %s: %w", webappDir, err) + } + fmt.Fprintf(stdout, " Dev UI: http://localhost:%d/ (/api -> %s)\n", uiPort, apiURL) + return vite, nil +} + +func captainWebappDevDir() (string, error) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("cannot locate webapp source") + } + dir := filepath.Join(filepath.Dir(thisFile), "webapp") + if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil { + return "", fmt.Errorf("webapp/package.json not found at %s: %w", dir, err) + } + return dir, nil +} + +func newCaptainWebappHandler() (http.Handler, error) { + sub, err := fs.Sub(captainWebappFS, "webapp/dist") + if err != nil { + return nil, err + } + fileServer := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested := strings.TrimPrefix(r.URL.Path, "/") + if requested == "" { + serveCaptainIndex(w, sub) + return + } + if _, err := fs.Stat(sub, requested); err != nil { + if !looksLikeAssetRequest(requested) { + serveCaptainIndex(w, sub) + return + } + http.NotFound(w, r) + return + } + fileServer.ServeHTTP(w, r) + }), nil +} + +func serveCaptainIndex(w http.ResponseWriter, sub fs.FS) { + data, err := fs.ReadFile(sub, "index.html") + if err != nil { + http.Error(w, "webapp index.html missing; run pnpm --dir pkg/cli/webapp build", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) +} + +func looksLikeAssetRequest(path string) bool { + base := filepath.Base(path) + return strings.Contains(base, ".") +} + +func writeServeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/pkg/cli/serve_test.go b/pkg/cli/serve_test.go new file mode 100644 index 0000000..d0af3f6 --- /dev/null +++ b/pkg/cli/serve_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestHandleThreadFromAgentCreatesThread(t *testing.T) { + store := newFileThreadStore(filepath.Join(t.TempDir(), "threads.json")) + body := `{"title":"Fix flaky test","providerSessionId":"sess-123","model":"codex-gpt-5-codex"}` + req := httptest.NewRequest(http.MethodPost, "/api/captain/chat/threads/from-agent", strings.NewReader(body)) + rec := httptest.NewRecorder() + + handleThreadFromAgent(store).ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var got struct { + ID string `json:"id"` + Title string `json:"title"` + ProviderSessionID string `json:"providerSessionId"` + LaunchURL string `json:"launchUrl"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("Decode: %v", err) + } + if got.ID == "" { + t.Fatal("empty thread id") + } + if got.Title != "Fix flaky test" { + t.Errorf("Title = %q", got.Title) + } + if got.ProviderSessionID != "sess-123" { + t.Errorf("ProviderSessionID = %q", got.ProviderSessionID) + } + if want := "/chat/" + got.ID + "?model=codex-gpt-5-codex"; got.LaunchURL != want { + t.Errorf("LaunchURL = %q, want %q", got.LaunchURL, want) + } +} + +func TestHandleThreadFromAgentRequiresProviderSession(t *testing.T) { + store := newFileThreadStore(filepath.Join(t.TempDir(), "threads.json")) + req := httptest.NewRequest(http.MethodPost, "/api/captain/chat/threads/from-agent", strings.NewReader(`{"title":"missing"}`)) + rec := httptest.NewRecorder() + + handleThreadFromAgent(store).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "providerSessionId is required") { + t.Fatalf("body = %q", rec.Body.String()) + } +} diff --git a/pkg/cli/session_filter.go b/pkg/cli/session_filter.go new file mode 100644 index 0000000..9bf3606 --- /dev/null +++ b/pkg/cli/session_filter.go @@ -0,0 +1,135 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/flanksource/captain/pkg/claude" +) + +var canonicalUUIDArgRE = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +func splitSessionIDPathArgs(args []string) (sessionIDs []string, paths []string) { + for _, arg := range args { + if isSessionIDArg(arg) { + sessionIDs = appendUniqueSessionID(sessionIDs, arg) + continue + } + paths = append(paths, arg) + } + return sessionIDs, paths +} + +func isSessionIDArg(arg string) bool { + return canonicalUUIDArgRE.MatchString(strings.TrimSpace(arg)) +} + +func normalizeSessionIDFilters(legacySession, sessionID string, positionalIDs []string) ([]string, error) { + legacySession = strings.TrimSpace(legacySession) + sessionID = strings.TrimSpace(sessionID) + if legacySession != "" && sessionID != "" && legacySession != sessionID { + return nil, fmt.Errorf("--session and --session-id must match when both are set") + } + + var ids []string + ids = appendUniqueSessionID(ids, sessionID) + ids = appendUniqueSessionID(ids, legacySession) + for _, id := range positionalIDs { + ids = appendUniqueSessionID(ids, id) + } + return ids, nil +} + +func appendUniqueSessionID(ids []string, id string) []string { + id = normalizeSessionID(id) + if id == "" { + return ids + } + for _, existing := range ids { + if existing == id { + return ids + } + } + return append(ids, id) +} + +func normalizeSessionID(id string) string { + id = strings.TrimSpace(id) + if isSessionIDArg(id) { + return strings.ToLower(id) + } + return id +} + +func firstSessionID(sessionIDs []string) string { + if len(sessionIDs) == 0 { + return "" + } + return sessionIDs[0] +} + +func filterCostsBySessionID(costs []claude.SessionCost, sessionIDs []string) []claude.SessionCost { + if len(sessionIDs) == 0 { + return costs + } + filter := claude.Filter{SessionIDs: sessionIDs} + out := make([]claude.SessionCost, 0, len(costs)) + for _, cost := range costs { + if filter.MatchesSessionID(cost.SessionID) { + out = append(out, cost) + } + } + return out +} + +func historyScanCWD(defaultCWD string, paths []string, searchAll bool) string { + if searchAll { + return defaultCWD + } + + var scoped string + for _, path := range paths { + root := projectRootForPathArg(path) + if root == "" { + continue + } + if scoped == "" { + scoped = root + continue + } + if canonicalPath(scoped) != canonicalPath(root) { + return defaultCWD + } + } + if scoped != "" { + return scoped + } + return defaultCWD +} + +func projectRootForPathArg(path string) string { + path = strings.TrimSpace(path) + if path == "" || strings.ContainsAny(path, "*?[]") { + return "" + } + abs, err := filepath.Abs(path) + if err != nil { + return "" + } + info, err := os.Stat(abs) + if err != nil { + return "" + } + dir := abs + if !info.IsDir() { + dir = filepath.Dir(abs) + } + projectInfo := claude.FindProjectInfo(dir) + if projectInfo.MarkerFile == "" { + return "" + } + return projectInfo.Root +} diff --git a/pkg/cli/session_filter_test.go b/pkg/cli/session_filter_test.go new file mode 100644 index 0000000..a8f1e5f --- /dev/null +++ b/pkg/cli/session_filter_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/claude" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSplitSessionIDPathArgs(t *testing.T) { + sessionIDs, paths := splitSessionIDPathArgs([]string{ + "pkg/cli", + "019e0365-dc2a-7ad0-a5a8-78936481a928", + "README.md", + "6522FE00-9A7C-4CEE-9A1A-123456789ABC", + }) + + assert.Equal(t, []string{ + "019e0365-dc2a-7ad0-a5a8-78936481a928", + "6522fe00-9a7c-4cee-9a1a-123456789abc", + }, sessionIDs) + assert.Equal(t, []string{"pkg/cli", "README.md"}, paths) +} + +func TestNormalizeHistoryOptionsTreatsUUIDArgsAsSessions(t *testing.T) { + opts, sessionIDs, err := normalizeHistoryOptions(HistoryOptions{ + Paths: []string{"019e0365-dc2a-7ad0-a5a8-78936481a928", "pkg/cli"}, + SessionID: "sess-prefix", + }) + require.NoError(t, err) + + assert.Equal(t, []string{"pkg/cli"}, opts.Paths) + assert.Equal(t, []string{"sess-prefix", "019e0365-dc2a-7ad0-a5a8-78936481a928"}, sessionIDs) +} + +func TestNormalizeSessionIDFiltersRejectsConflictingAliases(t *testing.T) { + _, err := normalizeSessionIDFilters("old", "new", nil) + require.Error(t, err) +} + +func TestFilterCostsBySessionIDSupportsPrefixes(t *testing.T) { + costs := []claude.SessionCost{ + {SessionID: "019e0365-dc2a-7ad0-a5a8-78936481a928"}, + {SessionID: "6522fe00-9a7c-4cee-9a1a-123456789abc"}, + {SessionID: "other"}, + } + + got := filterCostsBySessionID(costs, []string{"019e0365", "6522fe00-9a7c"}) + require.Len(t, got, 2) + assert.Equal(t, "019e0365-dc2a-7ad0-a5a8-78936481a928", got[0].SessionID) + assert.Equal(t, "6522fe00-9a7c-4cee-9a1a-123456789abc", got[1].SessionID) +} + +func TestHistoryScanCWDUsesProjectPathArgs(t *testing.T) { + defaultCWD := t.TempDir() + repo := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module example.com/repo\n"), 0o644)) + subdir := filepath.Join(repo, "pkg", "cli") + require.NoError(t, os.MkdirAll(subdir, 0o755)) + + assert.Equal(t, repo, historyScanCWD(defaultCWD, []string{subdir}, false)) + assert.Equal(t, defaultCWD, historyScanCWD(defaultCWD, []string{subdir}, true)) +} + +func TestHistoryScanCWDLeavesConflictingProjectPathsAlone(t *testing.T) { + defaultCWD := t.TempDir() + repoA := t.TempDir() + repoB := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repoA, "go.mod"), []byte("module example.com/a\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(repoB, "go.mod"), []byte("module example.com/b\n"), 0o644)) + + assert.Equal(t, defaultCWD, historyScanCWD(defaultCWD, []string{repoA, repoB}, false)) +} + +func TestCollectCodexHistoryFiltersByMetadata(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + repo := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module example.com/repo\n"), 0o644)) + otherRepo := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(otherRepo, "go.mod"), []byte("module example.com/other\n"), 0o644)) + + writeCodexHistoryFixture(t, home, "match", "sess-match", repo, "hello") + writeCodexHistoryFixture(t, home, "other", "sess-other", otherRepo, "skip me") + + uses, err := collectCodexHistory(repo, false, claude.Filter{SessionID: "sess-match"}) + require.NoError(t, err) + require.Len(t, uses, 1) + assert.Equal(t, "sess-match", uses[0].SessionID) + assert.Equal(t, "hello", uses[0].Input["text"]) + + uses, err = collectCodexHistory(repo, false, claude.Filter{SessionID: "sess-other"}) + require.NoError(t, err) + assert.Empty(t, uses) +} + +func writeCodexHistoryFixture(t *testing.T, home, name, sessionID, cwd, message string) { + t.Helper() + dir := filepath.Join(home, ".codex", "sessions", "2026", "06", "29") + require.NoError(t, os.MkdirAll(dir, 0o755)) + content := fmt.Sprintf( + `{"timestamp":"2026-06-29T10:00:00Z","type":"session_meta","payload":{"id":%q,"cwd":%q}}`+"\n"+ + `{"timestamp":"2026-06-29T10:00:01Z","type":"event_msg","payload":{"type":"agent_message","message":%q}}`+"\n", + sessionID, + cwd, + message, + ) + require.NoError(t, os.WriteFile(filepath.Join(dir, name+".jsonl"), []byte(content), 0o644)) +} diff --git a/pkg/cli/sessions.go b/pkg/cli/sessions.go new file mode 100644 index 0000000..30da4eb --- /dev/null +++ b/pkg/cli/sessions.go @@ -0,0 +1,559 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/claude" +) + +type SessionListOptions struct { + Source string `flag:"source" help:"Filter source: all, claude, codex" default:"all"` + All bool `flag:"all" help:"Include sessions from all projects" short:"a"` + Query string `flag:"q" help:"Search session id, model, cwd, branch, or provider"` + Limit int `flag:"limit" help:"Maximum sessions to return; 0 means no limit" default:"100" short:"l"` +} + +type SessionGetOptions struct { + ID string `flag:"id" args:"true" help:"Session key or session id" required:"true"` + Source string `flag:"source" help:"Restrict source: all, claude, codex" default:"all"` +} + +func (SessionGetOptions) GetName() string { return "get " } + +type SessionListResult struct { + Sessions []SessionRecord `json:"sessions"` + Total int `json:"total"` + Source string `json:"source"` + Scope string `json:"scope"` +} + +type SessionRecord struct { + Key string `json:"key"` + ID string `json:"id"` + Source string `json:"source"` + StartedAt *time.Time `json:"startedAt,omitempty"` + EndedAt *time.Time `json:"endedAt,omitempty"` + Model string `json:"model,omitempty"` + ReasoningEffort string `json:"reasoningEffort,omitempty"` + Version string `json:"version,omitempty"` + GitBranch string `json:"gitBranch,omitempty"` + Provider string `json:"provider,omitempty"` + CWD string `json:"cwd,omitempty"` + ToolCalls int `json:"toolCalls"` + Messages int `json:"messages"` + Entries []SessionEntryWire `json:"entries,omitempty"` +} + +type SessionEntryWire struct { + Type string `json:"type,omitempty"` + ToolUse *SessionToolUseWire `json:"tool_use,omitempty"` + Message *SessionMessageWire `json:"message,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + CWD string `json:"cwd,omitempty"` + SessionID string `json:"sessionId,omitempty"` + UUID string `json:"uuid,omitempty"` + IsAPIErrorMessage bool `json:"isApiErrorMessage,omitempty"` + APIErrorStatus int `json:"apiErrorStatus,omitempty"` + Error string `json:"error,omitempty"` +} + +type SessionMessageWire struct { + Role string `json:"role,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + Content []SessionContentWire `json:"content,omitempty"` +} + +type SessionContentWire struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + Name string `json:"name,omitempty"` + Input map[string]any `json:"input,omitempty"` + ID string `json:"id,omitempty"` +} + +type SessionToolUseWire struct { + Tool string `json:"tool,omitempty"` + Input map[string]any `json:"input,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + CWD string `json:"cwd,omitempty"` + SessionID string `json:"session_id,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Source string `json:"source,omitempty"` + Model string `json:"model,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Response string `json:"response,omitempty"` +} + +type sessionCandidate struct { + record SessionRecord + path string +} + +func RunSessionList(opts SessionListOptions) (SessionListResult, error) { + source, err := normalizeSessionSource(opts.Source) + if err != nil { + return SessionListResult{}, err + } + cwd, err := os.Getwd() + if err != nil { + return SessionListResult{}, err + } + + candidates, err := discoverSessionCandidates(cwd, opts.All, source) + if err != nil { + return SessionListResult{}, err + } + records := make([]SessionRecord, 0, len(candidates)) + for _, candidate := range candidates { + if sessionMatchesQuery(candidate.record, opts.Query) { + records = append(records, candidate.record) + } + } + sortSessionRecords(records) + total := len(records) + if opts.Limit > 0 && len(records) > opts.Limit { + records = records[:opts.Limit] + } + + scope := "current" + if opts.All { + scope = "all" + } + return SessionListResult{ + Sessions: records, + Total: total, + Source: source, + Scope: scope, + }, nil +} + +func RunSessionGet(opts SessionGetOptions) (SessionRecord, error) { + source, err := normalizeSessionSource(opts.Source) + if err != nil { + return SessionRecord{}, err + } + id := strings.TrimSpace(opts.ID) + if id == "" { + return SessionRecord{}, fmt.Errorf("id is required") + } + + candidates, err := discoverSessionCandidates("", true, source) + if err != nil { + return SessionRecord{}, err + } + for _, candidate := range candidates { + if candidate.record.Key != id && candidate.record.ID != id && !strings.HasPrefix(candidate.record.ID, id) { + continue + } + record, err := loadSessionDetail(candidate) + if err != nil { + return SessionRecord{}, err + } + return record, nil + } + return SessionRecord{}, fmt.Errorf("session %q not found", id) +} + +func normalizeSessionSource(source string) (string, error) { + source = strings.ToLower(strings.TrimSpace(source)) + switch source { + case "", "all": + return "all", nil + case "claude", "codex": + return source, nil + default: + return "", fmt.Errorf("invalid source %q: expected all, claude, or codex", source) + } +} + +func discoverSessionCandidates(cwd string, searchAll bool, source string) ([]sessionCandidate, error) { + var candidates []sessionCandidate + if source == "all" || source == "claude" { + claudeSessions, err := discoverClaudeSessions(cwd, searchAll) + if err != nil { + return nil, err + } + candidates = append(candidates, claudeSessions...) + } + if source == "all" || source == "codex" { + codexSessions, err := discoverCodexSessions(cwd, searchAll) + if err != nil { + return nil, err + } + candidates = append(candidates, codexSessions...) + } + return candidates, nil +} + +func discoverClaudeSessions(cwd string, searchAll bool) ([]sessionCandidate, error) { + files, err := claude.FindSessionFiles(claude.GetProjectsDir(), cwd, searchAll) + if err != nil { + return nil, err + } + candidates := make([]sessionCandidate, 0, len(files)) + for _, file := range files { + entries, err := claude.ReadHistoryFile(file) + if err != nil { + continue + } + record := summarizeClaudeSession(file, entries) + candidates = append(candidates, sessionCandidate{record: record, path: file}) + } + return candidates, nil +} + +func discoverCodexSessions(cwd string, searchAll bool) ([]sessionCandidate, error) { + files, err := history.FindCodexSessionFiles() + if err != nil { + return nil, err + } + matchRoot := cwd + if cwd != "" { + projectInfo := claude.FindProjectInfo(cwd) + if projectInfo.Root != "" { + matchRoot = projectInfo.Root + } + } + + candidates := make([]sessionCandidate, 0, len(files)) + for _, file := range files { + uses, err := history.ExtractCodexToolUses(file) + if err != nil || len(uses) == 0 { + continue + } + if !searchAll && !codexSessionMatchesProject(uses, matchRoot) { + continue + } + record := summarizeCodexSession(file, uses) + candidates = append(candidates, sessionCandidate{record: record, path: file}) + } + return candidates, nil +} + +func summarizeClaudeSession(file string, entries []claude.HistoryEntry) SessionRecord { + record := SessionRecord{ + Key: sessionRecordKey("claude", file), + ID: sessionIDFromFile(file), + Source: "claude", + } + for _, entry := range entries { + ts, err := entry.ParseTimestamp() + if err == nil { + extendSessionRange(&record, ts) + } + if entry.SessionID != "" { + record.ID = entry.SessionID + } + if record.Model == "" && entry.Message.Model != "" { + record.Model = entry.Message.Model + } + if record.Version == "" && entry.Version != "" { + record.Version = entry.Version + } + if record.GitBranch == "" && entry.GitBranch != "" { + record.GitBranch = entry.GitBranch + } + if record.CWD == "" && entry.CWD != "" { + record.CWD = entry.CWD + } + if entry.Message.Role == claude.MessageRoleUser || entry.Message.Role == claude.MessageRoleAssistant { + if len(messageTextBlocks(entry)) > 0 { + record.Messages++ + } + } + record.ToolCalls += len(entry.Message.GetToolUses()) + } + return record +} + +func summarizeCodexSession(file string, uses []history.ToolUse) SessionRecord { + record := SessionRecord{ + Key: sessionRecordKey("codex", file), + Source: "codex", + } + for _, use := range uses { + if use.SessionID != "" { + record.ID = use.SessionID + } + if use.Timestamp != nil { + extendSessionRange(&record, *use.Timestamp) + } + if record.CWD == "" && use.CWD != "" { + record.CWD = use.CWD + } + if record.Model == "" && use.Model != "" { + record.Model = use.Model + } + if record.ReasoningEffort == "" && use.ReasoningEffort != "" { + record.ReasoningEffort = use.ReasoningEffort + } + if use.Tool == "Assistant" || use.Tool == "Reasoning" { + record.Messages++ + } else { + record.ToolCalls++ + } + } + if meta, err := history.ReadCodexSessionInfo(file); err == nil && meta != nil { + if record.ID == "" { + record.ID = meta.ID + } + if record.CWD == "" { + record.CWD = meta.CWD + } + record.Provider = meta.ModelProvider + record.Version = meta.CLIVersion + record.GitBranch = meta.GitBranch + if record.Model == "" { + record.Model = meta.Model + } + if record.ReasoningEffort == "" { + record.ReasoningEffort = meta.ReasoningEffort + } + if record.StartedAt == nil && meta.StartedAt != nil { + record.StartedAt = meta.StartedAt + } + } + if record.ID == "" { + record.ID = sessionIDFromFile(file) + } + return record +} + +func loadSessionDetail(candidate sessionCandidate) (SessionRecord, error) { + record := candidate.record + switch record.Source { + case "claude": + entries, err := claude.ReadHistoryFile(candidate.path) + if err != nil { + return SessionRecord{}, err + } + record = summarizeClaudeSession(candidate.path, entries) + record.Entries = claudeEntriesForViewer(entries) + case "codex": + uses, err := history.ExtractCodexToolUses(candidate.path) + if err != nil { + return SessionRecord{}, err + } + record = summarizeCodexSession(candidate.path, uses) + record.Entries = codexEntriesForViewer(uses) + default: + return SessionRecord{}, fmt.Errorf("unknown session source %q", record.Source) + } + return record, nil +} + +func claudeEntriesForViewer(entries []claude.HistoryEntry) []SessionEntryWire { + toolUses := claude.ExtractToolUsesWithTokens(entries) + toolByID := make(map[string]claude.ToolUse, len(toolUses)) + for _, use := range toolUses { + if use.ToolUseID != "" { + toolByID[use.ToolUseID] = use + } + } + + var out []SessionEntryWire + for entryIndex, entry := range entries { + if blocks := messageTextBlocks(entry); len(blocks) > 0 { + out = append(out, SessionEntryWire{ + Type: string(entry.Message.Role), + Message: &SessionMessageWire{Role: string(entry.Message.Role), StopReason: string(entry.Message.StopReason), Content: blocks}, + Timestamp: entry.Timestamp, + CWD: entry.CWD, + SessionID: entry.SessionID, + UUID: entry.UUID, + }) + } + + for blockIndex, block := range entry.Message.Content { + if block.Type != claude.ContentTypeToolUse { + continue + } + use, ok := toolByID[block.ID] + if !ok { + use = claude.ToolUse{ + Tool: block.Name, + Input: rawJSONMap(block.Input), + SessionID: entry.SessionID, + ToolUseID: block.ID, + Source: "claude", + } + if ts, err := entry.ParseTimestamp(); err == nil { + use.Timestamp = &ts + } + } + if use.Source == "" { + use.Source = "claude" + } + if use.Model == "" { + use.Model = entry.Message.Model + } + if use.CWD == "" { + use.CWD = entry.CWD + } + out = append(out, SessionEntryWire{ + Type: "assistant", + ToolUse: claudeToolUseForViewer(use), + Timestamp: entry.Timestamp, + CWD: entry.CWD, + SessionID: entry.SessionID, + UUID: fallbackUUID(entry.UUID, entryIndex, blockIndex), + }) + } + } + return out +} + +func codexEntriesForViewer(uses []history.ToolUse) []SessionEntryWire { + out := make([]SessionEntryWire, 0, len(uses)) + for i, use := range uses { + out = append(out, SessionEntryWire{ + Type: "assistant", + ToolUse: codexToolUseForViewer(use), + Timestamp: formatOptionalTime(use.Timestamp), + CWD: use.CWD, + SessionID: use.SessionID, + UUID: fmt.Sprintf("codex-%d", i), + }) + } + return out +} + +func messageTextBlocks(entry claude.HistoryEntry) []SessionContentWire { + var blocks []SessionContentWire + for _, block := range entry.Message.Content { + switch block.Type { + case claude.ContentTypeText: + if block.Text != "" { + blocks = append(blocks, SessionContentWire{Type: "text", Text: block.Text, ID: block.ID}) + } + case claude.ContentTypeThinking, claude.ContentTypeRedactedThinking: + if block.Thinking != "" { + blocks = append(blocks, SessionContentWire{Type: "thinking", Thinking: block.Thinking, ID: block.ID}) + } + } + } + return blocks +} + +func claudeToolUseForViewer(use claude.ToolUse) *SessionToolUseWire { + return &SessionToolUseWire{ + Tool: use.Tool, + Input: use.Input, + Timestamp: formatOptionalTime(use.Timestamp), + CWD: use.CWD, + SessionID: use.SessionID, + ToolUseID: use.ToolUseID, + Source: use.Source, + Model: use.Model, + ReasoningEffort: use.ReasoningEffort, + Response: use.Response, + } +} + +func codexToolUseForViewer(use history.ToolUse) *SessionToolUseWire { + return &SessionToolUseWire{ + Tool: use.Tool, + Input: use.Input, + Timestamp: formatOptionalTime(use.Timestamp), + CWD: use.CWD, + SessionID: use.SessionID, + ToolUseID: use.ToolUseID, + Source: use.Source, + Model: use.Model, + ReasoningEffort: use.ReasoningEffort, + Response: use.Response, + } +} + +func rawJSONMap(raw json.RawMessage) map[string]any { + if len(raw) == 0 { + return nil + } + var out map[string]any + _ = json.Unmarshal(raw, &out) + return out +} + +func sessionRecordKey(source, path string) string { + sum := sha256.Sum256([]byte(source + "\x00" + path)) + return source + "-" + hex.EncodeToString(sum[:])[:16] +} + +func extendSessionRange(record *SessionRecord, ts time.Time) { + if ts.IsZero() { + return + } + if record.StartedAt == nil || ts.Before(*record.StartedAt) { + t := ts + record.StartedAt = &t + } + if record.EndedAt == nil || ts.After(*record.EndedAt) { + t := ts + record.EndedAt = &t + } +} + +func sessionMatchesQuery(record SessionRecord, query string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return true + } + values := []string{ + record.Key, + record.ID, + record.Source, + record.Model, + record.ReasoningEffort, + record.Version, + record.GitBranch, + record.Provider, + record.CWD, + filepath.Base(record.CWD), + } + for _, value := range values { + if strings.Contains(strings.ToLower(value), query) { + return true + } + } + return false +} + +func sortSessionRecords(records []SessionRecord) { + sort.Slice(records, func(i, j int) bool { + return sessionSortTime(records[i]).After(sessionSortTime(records[j])) + }) +} + +func sessionSortTime(record SessionRecord) time.Time { + if record.EndedAt != nil { + return *record.EndedAt + } + if record.StartedAt != nil { + return *record.StartedAt + } + return time.Time{} +} + +func formatOptionalTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func fallbackUUID(base string, entryIndex, blockIndex int) string { + if base != "" { + return fmt.Sprintf("%s-tool-%d", base, blockIndex) + } + return fmt.Sprintf("entry-%d-tool-%d", entryIndex, blockIndex) +} diff --git a/pkg/cli/sessions_test.go b/pkg/cli/sessions_test.go new file mode 100644 index 0000000..fce6c1d --- /dev/null +++ b/pkg/cli/sessions_test.go @@ -0,0 +1,208 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/claude" +) + +func TestRunSessionListAndGetClaude(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "project") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + + sessionFile := filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-claude.jsonl") + writeJSONL(t, sessionFile, + map[string]any{ + "type": "user", + "sessionId": "sess-claude", + "uuid": "u1", + "timestamp": "2026-06-01T10:00:00Z", + "cwd": project, + "message": map[string]any{ + "role": "user", + "content": []any{map[string]any{"type": "text", "text": "inspect this repo"}}, + }, + }, + map[string]any{ + "type": "assistant", + "sessionId": "sess-claude", + "uuid": "a1", + "timestamp": "2026-06-01T10:00:01Z", + "cwd": project, + "version": "1.2.3", + "gitBranch": "main", + "message": map[string]any{ + "role": "assistant", + "model": "claude-sonnet-4", + "content": []any{map[string]any{ + "type": "tool_use", + "id": "tu-1", + "name": "Read", + "input": map[string]any{"file_path": "README.md"}, + }}, + }, + }, + map[string]any{ + "type": "user", + "sessionId": "sess-claude", + "uuid": "r1", + "timestamp": "2026-06-01T10:00:02Z", + "cwd": project, + "message": map[string]any{ + "role": "user", + "content": []any{map[string]any{ + "type": "tool_result", + "tool_use_id": "tu-1", + "content": "README contents", + }}, + }, + }, + ) + + list, err := RunSessionList(SessionListOptions{Source: "claude", Limit: 10}) + if err != nil { + t.Fatalf("RunSessionList: %v", err) + } + if list.Total != 1 || len(list.Sessions) != 1 { + t.Fatalf("sessions = %+v", list) + } + session := list.Sessions[0] + if session.ID != "sess-claude" || session.Source != "claude" { + t.Fatalf("session summary = %+v", session) + } + if session.ToolCalls != 1 || session.Messages != 1 { + t.Fatalf("ToolCalls/Messages = %d/%d", session.ToolCalls, session.Messages) + } + if session.Key == "" { + t.Fatal("empty session key") + } + + detail, err := RunSessionGet(SessionGetOptions{ID: session.Key}) + if err != nil { + t.Fatalf("RunSessionGet: %v", err) + } + if len(detail.Entries) != 2 { + t.Fatalf("entries = %+v", detail.Entries) + } + if got := detail.Entries[0].Message.Content[0].Text; got != "inspect this repo" { + t.Fatalf("first message text = %q", got) + } + if detail.Entries[1].ToolUse == nil { + t.Fatalf("second entry should be tool use: %+v", detail.Entries[1]) + } + if detail.Entries[1].ToolUse.Response != "README contents" { + t.Fatalf("tool response = %q", detail.Entries[1].ToolUse.Response) + } +} + +func TestRunSessionListCodexScope(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "project") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + actualProject, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + other := filepath.Join(filepath.Dir(actualProject), "other") + + writeCodexSession(t, filepath.Join(home, ".codex", "sessions", "2026", "06", "rollout-current.jsonl"), "codex-current", actualProject) + writeCodexSession(t, filepath.Join(home, ".codex", "sessions", "2026", "06", "rollout-other.jsonl"), "codex-other", other) + + current, err := RunSessionList(SessionListOptions{Source: "codex", Limit: 10}) + if err != nil { + t.Fatalf("RunSessionList current: %v", err) + } + if current.Total != 1 || current.Sessions[0].ID != "codex-current" { + t.Fatalf("current sessions = %+v", current) + } + + all, err := RunSessionList(SessionListOptions{Source: "codex", All: true, Limit: 10}) + if err != nil { + t.Fatalf("RunSessionList all: %v", err) + } + if all.Total != 2 { + t.Fatalf("all sessions = %+v", all) + } +} + +func TestRunSessionGetUnknown(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + _, err := RunSessionGet(SessionGetOptions{ID: "missing"}) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err = %v", err) + } +} + +func writeCodexSession(t *testing.T, path, id, cwd string) { + t.Helper() + writeJSONL(t, path, + map[string]any{ + "timestamp": "2026-06-01T10:00:00Z", + "type": "session_meta", + "payload": map[string]any{ + "id": id, + "cwd": cwd, + "cli_version": "0.1.0", + "model_provider": "openai", + "git": map[string]any{"branch": "main"}, + }, + }, + map[string]any{ + "timestamp": "2026-06-01T10:00:01Z", + "type": "turn_context", + "payload": map[string]any{"model": "gpt-5", "effort": "medium"}, + }, + map[string]any{ + "timestamp": "2026-06-01T10:00:02Z", + "type": "response_item", + "payload": map[string]any{ + "type": "function_call", + "name": "shell", + "arguments": `{"cmd":"ls"}`, + "call_id": "call-1", + }, + }, + map[string]any{ + "timestamp": "2026-06-01T10:00:03Z", + "type": "response_item", + "payload": map[string]any{ + "type": "function_call_output", + "call_id": "call-1", + "output": "ok", + }, + }, + ) +} + +func writeJSONL(t *testing.T, path string, rows ...map[string]any) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + var b strings.Builder + for _, row := range rows { + data, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + b.Write(data) + b.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/cli/stdin.go b/pkg/cli/stdin.go index 2fb5c89..ee0077b 100644 --- a/pkg/cli/stdin.go +++ b/pkg/cli/stdin.go @@ -99,6 +99,13 @@ func detectStreamFormat(data []byte) (claude.StreamFormat, []byte) { } func runHistoryFromReader(data []byte, opts HistoryOptions) (any, error) { + var sessionIDs []string + var err error + opts, sessionIDs, err = normalizeHistoryOptions(opts) + if err != nil { + return nil, err + } + parsed, err := parseFromReader(data) if err != nil { return nil, err @@ -122,8 +129,10 @@ func runHistoryFromReader(data []byte, opts HistoryOptions) (any, error) { classifier := bash.NewCategoryClassifier(bash.DefaultCategoryConfig()) filter := claude.Filter{ - Tools: opts.Tools, - Paths: resolvePaths(opts.Paths), + Tools: opts.Tools, + Paths: resolvePaths(opts.Paths), + SessionID: firstSessionID(sessionIDs), + SessionIDs: sessionIDs, } if !opts.Since.IsZero() { filter.Since = &opts.Since diff --git a/pkg/cli/webapp/dist/.gitkeep b/pkg/cli/webapp/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/cli/webapp/index.html b/pkg/cli/webapp/index.html new file mode 100644 index 0000000..0c82e09 --- /dev/null +++ b/pkg/cli/webapp/index.html @@ -0,0 +1,12 @@ + + + + + + Captain + + +
+ + + diff --git a/pkg/cli/webapp/src/AgentLauncher.tsx b/pkg/cli/webapp/src/AgentLauncher.tsx new file mode 100644 index 0000000..4e973e7 --- /dev/null +++ b/pkg/cli/webapp/src/AgentLauncher.tsx @@ -0,0 +1,151 @@ +import { useMemo, useState } from "react"; +import { Button } from "@flanksource/clicky-ui/components"; +import { + OperationCommandPage, + useOperations, + type ExecutionResponse, + type ResolvedOperation, +} from "@flanksource/clicky-ui/rpc"; +import { useChatWindowManager } from "@flanksource/clicky-ui/ai"; +import { apiClient } from "./api"; +import { + agentModelFor, + extractSessionId, + isAgentOperation, + titleFor, + type CommandValues, +} from "./session"; + +type LaunchThreadResponse = { + id: string; + title: string; + launchUrl: string; + providerSessionId?: string; +}; + +type AgentLauncherProps = { + onNavigate: (to: string, opts?: { replace?: boolean }) => void; +}; + +export function AgentLauncher({ onNavigate }: AgentLauncherProps) { + const { operations, isLoading, error } = useOperations(apiClient); + const operation = useMemo( + () => operations.find(isAgentOperation), + [operations], + ); + const { openPanel } = useChatWindowManager(); + const [launchError, setLaunchError] = useState(""); + const [launching, setLaunching] = useState(false); + + async function handleResult( + response: ExecutionResponse, + _operation: ResolvedOperation, + values: CommandValues, + ) { + setLaunchError(""); + if (!response.success) { + setLaunchError(response.error || "Agent command failed."); + return; + } + + const sessionId = extractSessionId(response); + if (!sessionId) { + setLaunchError("Agent command completed without a session id."); + return; + } + + const model = agentModelFor(values); + setLaunching(true); + try { + const thread = await createThreadFromAgent({ + providerSessionId: sessionId, + title: titleFor(values, sessionId), + model, + }); + openPanel({ threadId: thread.id, initialModel: model }); + onNavigate(thread.launchUrl || `/chat/${encodeURIComponent(thread.id)}`); + } catch (err) { + setLaunchError(err instanceof Error ? err.message : String(err)); + } finally { + setLaunching(false); + } + } + + if (isLoading) { + return
Loading operations...
; + } + + if (error) { + return ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ); + } + + if (!operation) { + return ( +
+
+ `captain ai agent` was not found in the operation catalog. +
+ +
+ ); + } + + return ( +
+
+
+

New Captain Agent

+
+ {operation.operation["x-clicky"]?.command ?? "captain ai agent"} +
+
+ +
+ + {launchError && ( +
+ {launchError} +
+ )} + {launching && ( +
+ Opening chat window... +
+ )} + + +
+ ); +} + +async function createThreadFromAgent(body: { + title: string; + providerSessionId: string; + model: string; +}) { + const response = await fetch("/api/captain/chat/threads/from-agent", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Create chat thread failed with ${response.status}`); + } + return (await response.json()) as LaunchThreadResponse; +} diff --git a/pkg/cli/webapp/src/App.tsx b/pkg/cli/webapp/src/App.tsx new file mode 100644 index 0000000..ed07676 --- /dev/null +++ b/pkg/cli/webapp/src/App.tsx @@ -0,0 +1,176 @@ +import { useMemo, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + AppShell, + Button, + DensitySwitcher, + ThemeSwitcher, +} from "@flanksource/clicky-ui/components"; +import { + RouterProvider, + useBrowserRouter, +} from "@flanksource/clicky-ui/rpc"; +import { ChatWindowManagerProvider } from "@flanksource/clicky-ui/ai"; +import { EntityExplorerApp } from "@flanksource/clicky-ui/rpc"; +import { apiClient } from "./api"; +import { AgentLauncher } from "./AgentLauncher"; +import { ChatLayer } from "./ChatLayer"; +import { ChatRoute } from "./ChatRoute"; +import { SessionBrowser } from "./SessionBrowser"; + +export function App() { + const queryClient = useMemo( + () => + new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 5 * 60 * 1000 } }, + }), + [], + ); + const router = useBrowserRouter(); + const route = parseRoute(router.pathname, window.location.search); + + return ( + + + + {route.kind === "sessions" ? ( + } + actions={} + /> + ) : ( + + {route.kind === "operations" ? ( + + ) : route.kind === "chat" ? ( + + ) : ( +
+ +
+ )} +
+ )} + +
+
+
+ ); +} + +type PrimaryRoute = "agent" | "sessions" | "operations"; + +function CaptainShell({ + active, + onNavigate, + children, +}: { + active: PrimaryRoute; + onNavigate: (to: string, opts?: { replace?: boolean }) => void; + children: ReactNode; +}) { + return ( + } + nav={} + actions={} + contentClassName="p-0 overflow-hidden" + > + {children} + + ); +} + +function ShellBrand({ + onNavigate, +}: { + onNavigate: (to: string, opts?: { replace?: boolean }) => void; +}) { + return ( + + ); +} + +function CaptainNav({ + active, + onNavigate, +}: { + active: PrimaryRoute; + onNavigate: (to: string, opts?: { replace?: boolean }) => void; +}) { + return ( +
+ + + +
+ ); +} + +function ShellActions() { + return ( + <> + + + + ); +} + +type Route = + | { kind: "launcher" } + | { kind: "sessions"; sessionId?: string } + | { kind: "operations" } + | { kind: "chat"; threadId: string; model?: string }; + +function parseRoute(pathname: string, search: string): Route { + if (pathname.startsWith("/operations")) return { kind: "operations" }; + if (pathname.startsWith("/sessions")) { + const raw = pathname.slice("/sessions".length).replace(/^\/+/, ""); + const sessionId = raw ? decodeURIComponent(raw.split("/")[0] ?? "") : undefined; + return sessionId ? { kind: "sessions", sessionId } : { kind: "sessions" }; + } + if (pathname.startsWith("/chat/")) { + const threadId = decodeURIComponent(pathname.slice("/chat/".length).split("/")[0] ?? ""); + const model = new URLSearchParams(search).get("model") || undefined; + return threadId ? { kind: "chat", threadId, model } : { kind: "launcher" }; + } + return { kind: "launcher" }; +} diff --git a/pkg/cli/webapp/src/ChatLayer.tsx b/pkg/cli/webapp/src/ChatLayer.tsx new file mode 100644 index 0000000..8ca51d6 --- /dev/null +++ b/pkg/cli/webapp/src/ChatLayer.tsx @@ -0,0 +1,38 @@ +import { useMemo } from "react"; +import { ChatFab, ChatWindowLayer } from "@flanksource/clicky-ui/ai"; +import { clickyOperationsToTools } from "@flanksource/clicky-ui/chat"; +import { useOperations } from "@flanksource/clicky-ui/rpc"; +import { apiClient } from "./api"; +import { isChatToolOperation } from "./session"; + +export function ChatLayer() { + const { operations } = useOperations(apiClient); + const tools = useMemo( + () => clickyOperationsToTools(operations.filter(isChatToolOperation)), + [operations], + ); + + return ( + <> + + + + ); +} + diff --git a/pkg/cli/webapp/src/ChatRoute.tsx b/pkg/cli/webapp/src/ChatRoute.tsx new file mode 100644 index 0000000..7c65332 --- /dev/null +++ b/pkg/cli/webapp/src/ChatRoute.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { Button } from "@flanksource/clicky-ui/components"; +import { useChatWindowManager } from "@flanksource/clicky-ui/ai"; + +type ChatRouteProps = { + threadId: string; + model?: string; + onNavigate: (to: string, opts?: { replace?: boolean }) => void; +}; + +export function ChatRoute({ threadId, model, onNavigate }: ChatRouteProps) { + const { panels, openPanel } = useChatWindowManager(); + + useEffect(() => { + if (!threadId) return; + if (panels.some((panel) => panel.threadId === threadId)) return; + openPanel({ threadId, initialModel: model || null }); + }, [model, openPanel, panels, threadId]); + + return ( +
+
+
Chat window opened
+ +
+
+ ); +} diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx new file mode 100644 index 0000000..a0d33b5 --- /dev/null +++ b/pkg/cli/webapp/src/SessionBrowser.tsx @@ -0,0 +1,370 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AppShell, + Button, + SearchInput, + SegmentedControl, + Switch, +} from "@flanksource/clicky-ui/components"; +import { SessionViewer, type SessionEntry } from "@flanksource/clicky-ui/ai"; +import { apiClient } from "./api"; + +type SourceFilter = "all" | "claude" | "codex"; + +type SessionListResult = { + sessions: SessionRecord[]; + total: number; + source: SourceFilter; + scope: "current" | "all"; +}; + +type SessionRecord = { + key: string; + id: string; + source: "claude" | "codex"; + startedAt?: string; + endedAt?: string; + model?: string; + reasoningEffort?: string; + version?: string; + gitBranch?: string; + provider?: string; + cwd?: string; + toolCalls: number; + messages: number; + entries?: SessionEntry[]; +}; + +type SessionBrowserProps = { + selectedId?: string; + onNavigate: (to: string, opts?: { replace?: boolean }) => void; + nav: ReactNode; + actions: ReactNode; +}; + +const SOURCE_OPTIONS = [ + { id: "all", label: "All" }, + { id: "claude", label: "Claude" }, + { id: "codex", label: "Codex" }, +] satisfies Array<{ id: SourceFilter; label: string }>; + +export function SessionBrowser({ + selectedId, + onNavigate, + nav, + actions, +}: SessionBrowserProps) { + const [source, setSource] = useState("all"); + const [allProjects, setAllProjects] = useState(false); + const [query, setQuery] = useState(""); + + const listQuery = useQuery({ + queryKey: ["sessions", source, allProjects, query], + queryFn: () => fetchSessions({ source, allProjects, query }), + }); + const sessions = listQuery.data?.sessions ?? []; + + useEffect(() => { + if (selectedId || sessions.length === 0) return; + onNavigate(`/sessions/${encodeURIComponent(sessions[0].key)}`, { replace: true }); + }, [onNavigate, selectedId, sessions]); + + const selectedSummary = useMemo( + () => sessions.find((session) => session.key === selectedId || session.id === selectedId), + [selectedId, sessions], + ); + + const detailQuery = useQuery({ + queryKey: ["session", selectedId], + queryFn: () => fetchSession(String(selectedId)), + enabled: Boolean(selectedId), + }); + const selected = detailQuery.data ?? selectedSummary; + + return ( + Captain} + nav={nav} + actions={actions} + bodySidebar={ + onNavigate(`/sessions/${encodeURIComponent(session.key)}`)} + onRefresh={() => void listQuery.refetch()} + /> + } + bodyHeader={} + bodyActions={ + + } + bodySplit={28} + contentClassName="p-0 overflow-hidden" + > + + + ); +} + +function SessionSidebar({ + source, + onSourceChange, + allProjects, + onAllProjectsChange, + query, + onQueryChange, + sessions, + selectedId, + total, + loading, + error, + onSelect, + onRefresh, +}: { + source: SourceFilter; + onSourceChange: (source: SourceFilter) => void; + allProjects: boolean; + onAllProjectsChange: (enabled: boolean) => void; + query: string; + onQueryChange: (query: string) => void; + sessions: SessionRecord[]; + selectedId?: string; + total: number; + loading: boolean; + error: unknown; + onSelect: (session: SessionRecord) => void; + onRefresh: () => void; +}) { + return ( +
+
+
+
Sessions
+ +
+ + + +
+ {loading ? "Loading..." : `${sessions.length} shown / ${total} total`} +
+
+ +
+ {error ? ( +
{errorMessage(error)}
+ ) : sessions.length === 0 && !loading ? ( +
No sessions found.
+ ) : ( +
+ {sessions.map((session) => { + const active = session.key === selectedId || session.id === selectedId; + return ( + + ); + })} +
+ )} +
+
+ ); +} + +function SessionHeader({ + session, + loading, +}: { + session?: SessionRecord; + loading: boolean; +}) { + if (loading && !session) { + return
Loading session...
; + } + if (!session) { + return ( +
+
Session Browser
+
Select a session to inspect activity.
+
+ ); + } + return ( +
+
+
{sessionTitle(session)}
+ + {session.source} + +
+
+ {session.model && {session.model}} + {session.reasoningEffort && reasoning={session.reasoningEffort}} + {session.toolCalls} actions + {session.messages} messages + {session.cwd && {session.cwd}} +
+
+ ); +} + +function SessionDetail({ + session, + loading, + error, + hasSelection, +}: { + session?: SessionRecord; + loading: boolean; + error: unknown; + hasSelection: boolean; +}) { + if (!hasSelection) { + return ( +
+ Select a session. +
+ ); + } + if (loading) { + return ( +
+ Loading session... +
+ ); + } + if (error) { + return ( +
+ {errorMessage(error)} +
+ ); + } + return ( +
+ +
+ ); +} + +async function fetchSessions(params: { + source: SourceFilter; + allProjects: boolean; + query: string; +}) { + const response = await apiClient.executeCommand( + "/api/v1/sessions", + "GET", + { + source: params.source, + all: params.allProjects ? "true" : "false", + q: params.query, + limit: "100", + }, + { Accept: "application/json" }, + ); + if (!response.success) { + throw new Error(response.error || "Failed to load sessions."); + } + return response.parsed as SessionListResult; +} + +async function fetchSession(id: string) { + const response = await apiClient.executeCommand( + "/api/v1/sessions/{id}", + "GET", + { id }, + { Accept: "application/json" }, + ); + if (!response.success) { + throw new Error(response.error || "Failed to load session."); + } + return response.parsed as SessionRecord; +} + +function sessionTitle(session: SessionRecord) { + if (session.gitBranch) return `${session.gitBranch} - ${shortID(session.id)}`; + if (session.model) return `${session.model} - ${shortID(session.id)}`; + return shortID(session.id) || session.key; +} + +function shortID(id: string) { + return id.length > 12 ? id.slice(0, 12) : id; +} + +function formatTime(value: string | undefined) { + if (!value) return "No timestamp"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/pkg/cli/webapp/src/api.ts b/pkg/cli/webapp/src/api.ts new file mode 100644 index 0000000..f91d60b --- /dev/null +++ b/pkg/cli/webapp/src/api.ts @@ -0,0 +1,4 @@ +import { createOperationsApiClient } from "@flanksource/clicky-ui/rpc"; + +export const apiClient = createOperationsApiClient(); + diff --git a/pkg/cli/webapp/src/index.css b/pkg/cli/webapp/src/index.css new file mode 100644 index 0000000..d6c8b72 --- /dev/null +++ b/pkg/cli/webapp/src/index.css @@ -0,0 +1,20 @@ +@import "tailwindcss"; + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +button, +input, +select, +textarea { + font: inherit; +} diff --git a/pkg/cli/webapp/src/main.tsx b/pkg/cli/webapp/src/main.tsx new file mode 100644 index 0000000..f8e36a2 --- /dev/null +++ b/pkg/cli/webapp/src/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { DensityProvider, ThemeProvider } from "@flanksource/clicky-ui/hooks"; +import "@flanksource/clicky-ui/styles.css"; +import "./index.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/pkg/cli/webapp/src/session.ts b/pkg/cli/webapp/src/session.ts new file mode 100644 index 0000000..f26a7dc --- /dev/null +++ b/pkg/cli/webapp/src/session.ts @@ -0,0 +1,181 @@ +import type { + ExecutionResponse, + OperationCommandPageProps, + ResolvedOperation, +} from "@flanksource/clicky-ui/rpc"; + +export type CommandValues = Parameters< + NonNullable +>[2]; + +const DEFAULT_AGENT_MODEL = "claude-agent-sonnet"; + +export function isAgentOperation(op: ResolvedOperation) { + const operationId = normalizeCommandName(op.operation.operationId); + const command = normalizeCommandName(op.operation["x-clicky"]?.command); + const path = normalizeCommandName(op.path.replace(/^\/api\/v1\/?/, "")); + + return ( + operationId === "ai/agent" || + command === "ai/agent" || + path === "ai/agent" + ); +} + +export function isChatToolOperation(op: ResolvedOperation) { + const command = + normalizeCommandName(op.operation["x-clicky"]?.command) || + normalizeCommandName(op.operation.operationId) || + normalizeCommandName(op.path.replace(/^\/api\/v1\/?/, "")); + const root = command.split("/")[0]; + return ![ + "ai", + "serve", + "mcp", + "hook", + "container", + "sandbox", + "port", + "configure", + ].includes(root); +} + +export function agentModelFor(values: CommandValues) { + const backend = stringValue(values.backend).toLowerCase(); + const model = stringValue(values.model).toLowerCase(); + + if ( + backend.includes("codex") || + model.includes("codex") || + model.includes("gpt-5-codex") + ) { + return "codex-gpt-5-codex"; + } + if (model.includes("opus")) return "claude-agent-opus"; + if (model.includes("haiku")) return "claude-agent-haiku"; + if (model.startsWith("claude-agent-")) return model; + return DEFAULT_AGENT_MODEL; +} + +export function titleFor(values: CommandValues, sessionId: string) { + const prompt = stringValue(values.prompt).trim(); + if (!prompt) return `Captain agent ${sessionId}`; + return prompt.length > 96 ? `${prompt.slice(0, 93)}...` : prompt; +} + +export function extractSessionId(response: ExecutionResponse) { + return ( + stringFromHeaders(response.responseHeaders) || + sessionIdFromValue(response.parsed) || + sessionIdFromText(response.stdout) || + sessionIdFromText(response.output) + ); +} + +function stringFromHeaders(headers: Record | undefined) { + if (!headers) return ""; + return ( + headers["x-session-id"] || + headers["x-provider-session-id"] || + headers["x-captain-session-id"] || + "" + ); +} + +function sessionIdFromValue(value: unknown, parentKey = ""): string { + if (value == null) return ""; + if (typeof value === "string") { + return looksLikeSessionKey(parentKey) ? value.trim() : ""; + } + if (Array.isArray(value)) { + for (const item of value) { + const found = sessionIdFromValue(item, parentKey); + if (found) return found; + } + return ""; + } + if (typeof value !== "object") return ""; + + const record = value as Record; + for (const key of [ + "sessionId", + "sessionID", + "session_id", + "SessionID", + "ProviderSessionID", + "providerSessionId", + ]) { + const direct = stringValue(record[key]).trim(); + if (direct) return direct; + } + + const fieldName = stringValue(record.name) || stringValue(record.label) || parentKey; + if (looksLikeSessionKey(fieldName)) { + const text = nodeText(record.value) || nodeText(record); + if (text) return text; + } + + for (const key of ["node", "fields", "rows", "value", "children", "items"]) { + const found = sessionIdFromValue(record[key], fieldName); + if (found) return found; + } + + for (const [key, nested] of Object.entries(record)) { + const found = sessionIdFromValue(nested, key); + if (found) return found; + } + return ""; +} + +function nodeText(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value.trim(); + if (typeof value !== "object") return ""; + const record = value as Record; + return ( + stringValue(record.plain) || + stringValue(record.text) || + stringValue(record.value) + ).trim(); +} + +function sessionIdFromText(text: string | undefined) { + if (!text) return ""; + try { + const parsed = JSON.parse(text) as unknown; + const found = sessionIdFromValue(parsed); + if (found) return found; + } catch { + // Fall through to the plain-text patterns. + } + const match = + text.match(/"sessionId"\s*:\s*"([^"]+)"/i) || + text.match(/"session_id"\s*:\s*"([^"]+)"/i) || + text.match(/\bSession\s+([A-Za-z0-9_.:-]+)/i); + return match?.[1]?.trim() ?? ""; +} + +function looksLikeSessionKey(key: string) { + const normalized = normalizeCommandName(key); + return ( + normalized === "session" || + normalized === "session/id" || + normalized === "sessionid" || + normalized === "provider/session/id" || + normalized === "providersessionid" + ); +} + +function normalizeCommandName(value: unknown) { + return stringValue(value) + .trim() + .toLowerCase() + .replace(/^\/+|\/+$/g, "") + .replace(/[_\s.-]+/g, "/") + .replace(/\/+/g, "/"); +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : ""; +} + diff --git a/pkg/cli/webapp/tsconfig.json b/pkg/cli/webapp/tsconfig.json new file mode 100644 index 0000000..e2df4cd --- /dev/null +++ b/pkg/cli/webapp/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/pkg/cli/webapp/vite.config.ts b/pkg/cli/webapp/vite.config.ts new file mode 100644 index 0000000..aa5ccb6 --- /dev/null +++ b/pkg/cli/webapp/vite.config.ts @@ -0,0 +1,124 @@ +import path from "node:path"; +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +const apiTarget = process.env.CAPTAIN_API_URL || "http://localhost:8080"; +const clickyPackageRoot = path.resolve(__dirname, "../../../../clicky-ui/packages/ui"); +const clickySourceRoot = path.resolve(clickyPackageRoot, "src"); + +export default defineConfig(({ command }) => { + const useClickySource = + command === "serve" && process.env.CAPTAIN_UI_CLICKY_SOURCE !== "0"; + + return { + base: "/", + define: useClickySource ? clickyVersionDefines() : {}, + plugins: [tailwindcss(), react()], + resolve: { + alias: [ + ...clickySourceAliases(useClickySource), + { find: "@", replacement: path.resolve(__dirname, "./src") }, + ], + dedupe: ["react", "react-dom", "@tanstack/react-query"], + }, + server: { + fs: useClickySource ? { allow: [__dirname, clickyPackageRoot] } : undefined, + proxy: { + "/api": apiTarget, + "/health": apiTarget, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, + }; +}); + +function clickySourceAliases(enabled: boolean) { + if (!enabled) return []; + return [ + { + find: "@flanksource/clicky-ui/styles.css", + replacement: path.resolve(clickySourceRoot, "styles/full.css"), + }, + { + find: "@flanksource/clicky-ui/tailwind-preset", + replacement: path.resolve(clickySourceRoot, "tailwind-preset.ts"), + }, + { + find: "@flanksource/clicky-ui/components", + replacement: path.resolve(clickySourceRoot, "components.ts"), + }, + { + find: "@flanksource/clicky-ui/clicky", + replacement: path.resolve(clickySourceRoot, "clicky.ts"), + }, + { + find: "@flanksource/clicky-ui/icons", + replacement: path.resolve(clickySourceRoot, "icons.ts"), + }, + { + find: "@flanksource/clicky-ui/hooks", + replacement: path.resolve(clickySourceRoot, "hooks.ts"), + }, + { + find: "@flanksource/clicky-ui/utils", + replacement: path.resolve(clickySourceRoot, "utils.ts"), + }, + { + find: "@flanksource/clicky-ui/data", + replacement: path.resolve(clickySourceRoot, "data.ts"), + }, + { + find: "@flanksource/clicky-ui/chat", + replacement: path.resolve(clickySourceRoot, "chat.ts"), + }, + { + find: "@flanksource/clicky-ui/ai", + replacement: path.resolve(clickySourceRoot, "ai.ts"), + }, + { + find: "@flanksource/clicky-ui/rpc", + replacement: path.resolve(clickySourceRoot, "rpc.ts"), + }, + { + find: "@flanksource/clicky-ui", + replacement: path.resolve(clickySourceRoot, "index.ts"), + }, + ]; +} + +function clickyVersionDefines() { + const version = readClickyPackageVersion(); + return { + __CLICKY_COMMIT__: JSON.stringify(git("rev-parse --short HEAD", "")), + __CLICKY_TAG__: JSON.stringify(`clicky-ui@${version}`), + __CLICKY_DATE__: JSON.stringify(new Date().toISOString()), + __CLICKY_DIRTY__: JSON.stringify(git("status --porcelain", "").length > 0), + }; +} + +function readClickyPackageVersion() { + try { + const raw = readFileSync(path.resolve(clickyPackageRoot, "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return parsed.version || "dev"; + } catch { + return "dev"; + } +} + +function git(args: string, fallback: string) { + try { + return execSync(`git -C ${JSON.stringify(clickyPackageRoot)} ${args}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return fallback; + } +} diff --git a/pkg/cli/whoami.go b/pkg/cli/whoami.go new file mode 100644 index 0000000..d035130 --- /dev/null +++ b/pkg/cli/whoami.go @@ -0,0 +1,272 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/flanksource/captain/pkg/ai" +) + +type WhoamiOptions struct { + Backend string `flag:"backend" help:"Show only this backend: anthropic|openai|gemini|claude-cli|claude-agent|codex-cli|gemini-cli" short:"b"` + Models bool `flag:"models" help:"Probe each provider's models endpoint via a live API call" default:"true" short:"m"` + Limit int `flag:"limit" help:"Max sample model IDs to show per adapter in pretty output (0 = all)" default:"10" short:"l"` +} + +// AdapterStatus is the resolved auth/availability of a single agent adapter +// (backend). Type is "api" for HTTP providers called with a key, "cli" for +// backends delegated to an installed coding-agent binary. +type AdapterStatus struct { + Backend string `json:"backend"` + Type string `json:"type"` + Authenticated bool `json:"authenticated"` + AuthMethod string `json:"authMethod,omitempty"` + AuthDetail string `json:"authDetail,omitempty"` + Binary string `json:"binary,omitempty"` + BinaryMissing string `json:"binaryMissing,omitempty"` + ModelCount int `json:"modelCount"` + Models []string `json:"models,omitempty"` + ModelError string `json:"modelError,omitempty"` +} + +// Ready reports whether the adapter can actually run: authenticated, and (for +// CLI backends) with its binary present in PATH. +func (a AdapterStatus) Ready() bool { + if !a.Authenticated { + return false + } + if a.Type == "cli" { + return a.Binary != "" + } + return true +} + +type WhoamiResult struct { + Adapters []AdapterStatus `json:"adapters"` + + // Display-only knobs for Pretty(); never serialized. + sampleLimit int + showModels bool +} + +// authProbe abstracts the host environment (env vars, PATH, credential files) +// so resolveAdapter stays pure and testable. +type authProbe struct { + getenv func(string) string + lookPath func(string) (string, error) + fileExists func(string) bool + home string +} + +func osAuthProbe() authProbe { + home, _ := os.UserHomeDir() + return authProbe{ + getenv: os.Getenv, + lookPath: exec.LookPath, + fileExists: func(p string) bool { + _, err := os.Stat(p) + return err == nil + }, + home: home, + } +} + +// loginFile is a credential file whose presence indicates a CLI has been logged +// in out-of-band (subscription/OAuth) rather than via an API-key env var. +type loginFile struct { + rel string // path relative to the user's home directory + label string // human label, e.g. "codex login" +} + +// cliAdapter holds the CLI-only metadata for a backend: the binary that must be +// on PATH and the credential files that signal a completed login. +type cliAdapter struct { + binary string + logins []loginFile +} + +func cliAdapters() map[ai.Backend]cliAdapter { + claude := cliAdapter{ + binary: "claude", + logins: []loginFile{ + {rel: filepath.Join(".claude", ".credentials.json"), label: "claude login"}, + {rel: ".claude.json", label: "claude login"}, + }, + } + return map[ai.Backend]cliAdapter{ + ai.BackendClaudeAgent: claude, + ai.BackendClaudeCLI: claude, + ai.BackendCodexCLI: { + binary: "codex", + logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, + }, + ai.BackendGeminiCLI: { + binary: "gemini", + logins: []loginFile{ + {rel: filepath.Join(".gemini", "oauth_creds.json"), label: "gemini login"}, + {rel: filepath.Join(".gemini", "google_accounts.json"), label: "gemini login"}, + }, + }, + } +} + +// resolveAdapter determines a backend's auth method and (for CLI backends) +// binary availability from the probed environment. An API-key env var always +// wins over a CLI login file because that is the path NewProvider/ListModels +// actually take. +func resolveAdapter(backend ai.Backend, p authProbe) AdapterStatus { + st := AdapterStatus{Backend: string(backend), Type: backend.Kind()} + + for _, v := range ai.AuthEnvVars(backend) { + if val := p.getenv(v); strings.TrimSpace(val) != "" { + st.Authenticated = true + st.AuthMethod = v + " (env)" + st.AuthDetail = maskKey(val) + break + } + } + + if cli, ok := cliAdapters()[backend]; ok { + if path, err := p.lookPath(cli.binary); err == nil { + st.Binary = path + } else { + st.BinaryMissing = cli.binary + } + if !st.Authenticated { + for _, lf := range cli.logins { + full := filepath.Join(p.home, lf.rel) + if p.fileExists(full) { + st.Authenticated = true + st.AuthMethod = lf.label + st.AuthDetail = full + break + } + } + } + } + + return st +} + +// maskKey renders a secret as the first and last four characters so the output +// is identifiable without ever exposing the full token. +func maskKey(s string) string { + s = strings.TrimSpace(s) + if len(s) <= 8 { + return "****" + } + return s[:4] + "…" + s[len(s)-4:] +} + +func firstEnv(vars []string, getenv func(string) string) string { + for _, v := range vars { + if val := getenv(v); strings.TrimSpace(val) != "" { + return val + } + } + return "" +} + +func RunWhoami(opts WhoamiOptions) (any, error) { + backends := ai.AllBackends() + if opts.Backend != "" { + b := ai.Backend(opts.Backend) + if !b.Valid() { + return nil, fmt.Errorf("--backend must be one of: %s (got %q)", ai.BackendList(), opts.Backend) + } + backends = []ai.Backend{b} + } + + probe := osAuthProbe() + + var models map[ai.Backend]modelFetch + if opts.Models { + models = fetchAPIModels(backends, probe.getenv) + } + + result := WhoamiResult{sampleLimit: opts.Limit, showModels: opts.Models} + for _, b := range backends { + st := resolveAdapter(b, probe) + if opts.Models { + applyModels(&st, b, models, probe.getenv) + } + result.Adapters = append(result.Adapters, st) + } + return result, nil +} + +type modelFetch struct { + models []ai.ModelDef + err error +} + +// fetchAPIModels hits each API backend's /v1/models endpoint once, concurrently. +// Only API backends are fetched: CLI/agent backends list from the static +// catalog (see applyModels) since their model menu needs no API key. An API +// backend with no key in the environment is skipped (the listing endpoint +// requires the key). +func fetchAPIModels(backends []ai.Backend, getenv func(string) string) map[ai.Backend]modelFetch { + apis := map[ai.Backend]bool{} + for _, b := range backends { + if b.Kind() == "api" && firstEnv(ai.AuthEnvVars(b), getenv) != "" { + apis[b] = true + } + } + + out := make(map[ai.Backend]modelFetch, len(apis)) + var mu sync.Mutex + var wg sync.WaitGroup + for b := range apis { + wg.Add(1) + go func(backend ai.Backend) { + defer wg.Done() + m, err := ai.ListModels(context.Background(), backend) + mu.Lock() + out[backend] = modelFetch{models: m, err: err} + mu.Unlock() + }(b) + } + wg.Wait() + return out +} + +// applyModels fills in the model listing (or the reason it is unavailable) for a +// single adapter. CLI/agent backends are served from the static catalog without +// an API key; API backends use the pre-fetched live results. +func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetch, getenv func(string) string) { + if b.Kind() == "cli" { + setModels(st, agentCatalogModels(b)) + return + } + + envVars := ai.AuthEnvVars(b) + if firstEnv(envVars, getenv) == "" { + st.ModelError = "set " + strings.Join(envVars, " or ") + " to list models" + return + } + + fetch, ok := cache[b] + if !ok { + return + } + if fetch.err != nil { + st.ModelError = fetch.err.Error() + return + } + setModels(st, fetch.models) +} + +// setModels copies a model list onto the adapter status as a count plus id list. +func setModels(st *AdapterStatus, models []ai.ModelDef) { + st.ModelCount = len(models) + ids := make([]string, 0, len(models)) + for _, m := range models { + ids = append(ids, m.ID) + } + st.Models = ids +} diff --git a/pkg/cli/whoami_render.go b/pkg/cli/whoami_render.go new file mode 100644 index 0000000..b857fdd --- /dev/null +++ b/pkg/cli/whoami_render.go @@ -0,0 +1,119 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/api/icons" +) + +// Pretty renders the adapters grouped into API providers and CLI agents, each as +// a status line with its auth method and (when probed) model count. +func (r WhoamiResult) Pretty() api.Text { + t := api.Text{}. + Add(icons.AI).Space(). + Append("AI Adapters", "font-bold text-blue-600") + + t = r.renderGroup(t, "api", icons.Cloud, "API providers") + t = r.renderGroup(t, "cli", icons.Terminal, "CLI agents") + return t +} + +func (r WhoamiResult) renderGroup(t api.Text, kind string, icon api.Textable, title string) api.Text { + var rows []AdapterStatus + for _, a := range r.Adapters { + if a.Type == kind { + rows = append(rows, a) + } + } + if len(rows) == 0 { + return t + } + + t = t.NewLine().NewLine(). + Add(icon).Space(). + Append(title, "font-bold text-gray-700") + for _, a := range rows { + t = t.NewLine().Add(a.prettyLine(r.showModels, r.sampleLimit)) + } + return t +} + +func (a AdapterStatus) prettyLine(showModels bool, limit int) api.Text { + t := api.Text{}. + Append(" ", ""). + Add(a.statusIcon()).Space(). + Append(fmt.Sprintf("%-13s", a.Backend), "font-medium") + + t = a.appendAuth(t) + if a.Type == "cli" { + t = a.appendBinary(t) + } + if showModels { + t = a.appendModels(t, limit) + } + return t +} + +// statusIcon is a green check when the adapter can run, a yellow warning when it +// is authenticated but unusable (CLI binary missing), and a red cross otherwise. +func (a AdapterStatus) statusIcon() api.Textable { + switch { + case a.Ready(): + return icons.Check + case a.Authenticated: + return icons.Warning + default: + return icons.Cross + } +} + +func (a AdapterStatus) appendAuth(t api.Text) api.Text { + if !a.Authenticated { + msg := "not configured" + if vars := strings.Join(ai.AuthEnvVars(ai.Backend(a.Backend)), " or "); vars != "" { + msg += " (set " + vars + ")" + } + return t.Append(msg, "text-red-500") + } + t = t.Append(a.AuthMethod, "text-green-700") + if a.AuthDetail != "" { + t = t.Append(" ", "").Add(icons.Key).Space().Append(a.AuthDetail, "text-gray-500") + } + return t +} + +func (a AdapterStatus) appendBinary(t api.Text) api.Text { + if a.Binary != "" { + return t.Append(" ", "").Append(a.Binary, "text-gray-400 italic") + } + return t.Append(" ", "").Append(a.BinaryMissing+" not in PATH", "text-amber-600") +} + +func (a AdapterStatus) appendModels(t api.Text, limit int) api.Text { + if a.ModelError != "" { + return t.NewLine().Append(" ", "").Add(icons.Info).Space(). + Append(a.ModelError, "text-gray-500 italic") + } + if a.ModelCount == 0 { + return t + } + + t = t.Append(" ", ""). + Append(fmt.Sprintf("%d models", a.ModelCount), "text-blue-600 font-medium") + + sample := a.Models + if limit > 0 && len(sample) > limit { + sample = sample[:limit] + } + if len(sample) > 0 { + line := strings.Join(sample, ", ") + if len(sample) < a.ModelCount { + line += fmt.Sprintf(", … (+%d more)", a.ModelCount-len(sample)) + } + t = t.NewLine().Append(" ", "").Append(line, "text-gray-500") + } + return t +} diff --git a/pkg/cli/whoami_test.go b/pkg/cli/whoami_test.go new file mode 100644 index 0000000..c696a3e --- /dev/null +++ b/pkg/cli/whoami_test.go @@ -0,0 +1,147 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/ai" +) + +func TestMaskKey(t *testing.T) { + cases := map[string]string{ + "sk-ant-api03-ABCDEFGH": "sk-a…EFGH", + "AIzaSyD-xyz123": "AIza…z123", + "short": "****", + "": "****", + "12345678": "****", + } + for in, want := range cases { + if got := maskKey(in); got != want { + t.Errorf("maskKey(%q) = %q, want %q", in, got, want) + } + } +} + +// fakeProbe builds an authProbe whose env, PATH, and filesystem are fully +// controlled so resolveAdapter can be exercised without touching the host. +func fakeProbe(env map[string]string, binaries map[string]string, files map[string]bool, home string) authProbe { + return authProbe{ + getenv: func(k string) string { return env[k] }, + lookPath: func(b string) (string, error) { + if p, ok := binaries[b]; ok { + return p, nil + } + return "", os.ErrNotExist + }, + fileExists: func(p string) bool { return files[p] }, + home: home, + } +} + +func TestResolveAdapter_APIKeyFromEnv(t *testing.T) { + st := resolveAdapter(ai.BackendAnthropic, fakeProbe( + map[string]string{"ANTHROPIC_API_KEY": "sk-ant-api03-SECRETKEY"}, + nil, nil, "/home/u")) + + if st.Type != "api" { + t.Errorf("Type = %q, want api", st.Type) + } + if !st.Authenticated || !st.Ready() { + t.Errorf("expected authenticated+ready, got %+v", st) + } + if st.AuthMethod != "ANTHROPIC_API_KEY (env)" { + t.Errorf("AuthMethod = %q", st.AuthMethod) + } + if st.AuthDetail != "sk-a…TKEY" { + t.Errorf("AuthDetail = %q (key should be masked, never printed in full)", st.AuthDetail) + } +} + +func TestResolveAdapter_APINotConfigured(t *testing.T) { + st := resolveAdapter(ai.BackendOpenAI, fakeProbe(nil, nil, nil, "/home/u")) + if st.Authenticated || st.Ready() { + t.Errorf("expected unauthenticated, got %+v", st) + } +} + +func TestResolveAdapter_CLILoginFile(t *testing.T) { + home := "/home/u" + authFile := filepath.Join(home, ".codex", "auth.json") + st := resolveAdapter(ai.BackendCodexCLI, fakeProbe( + nil, + map[string]string{"codex": "/usr/local/bin/codex"}, + map[string]bool{authFile: true}, + home)) + + if st.Type != "cli" { + t.Errorf("Type = %q, want cli", st.Type) + } + if st.Binary != "/usr/local/bin/codex" { + t.Errorf("Binary = %q", st.Binary) + } + if !st.Authenticated || !st.Ready() { + t.Errorf("expected authenticated+ready via login file, got %+v", st) + } + if st.AuthMethod != "codex login" || st.AuthDetail != authFile { + t.Errorf("AuthMethod=%q AuthDetail=%q", st.AuthMethod, st.AuthDetail) + } +} + +func TestResolveAdapter_CLIBinaryMissingNotReady(t *testing.T) { + home := "/home/u" + st := resolveAdapter(ai.BackendGeminiCLI, fakeProbe( + map[string]string{"GEMINI_API_KEY": "AIzaSyD-aaaaaaaa"}, // authenticated... + nil, // ...but no gemini binary + nil, home)) + + if !st.Authenticated { + t.Fatalf("expected authenticated via env key, got %+v", st) + } + if st.Binary != "" || st.BinaryMissing != "gemini" { + t.Errorf("expected BinaryMissing=gemini, got Binary=%q BinaryMissing=%q", st.Binary, st.BinaryMissing) + } + if st.Ready() { + t.Error("a CLI adapter with no binary in PATH must not be Ready") + } +} + +func TestResolveAdapter_EnvKeyPreferredOverLogin(t *testing.T) { + home := "/home/u" + st := resolveAdapter(ai.BackendClaudeAgent, fakeProbe( + map[string]string{"ANTHROPIC_API_KEY": "sk-ant-PREFERREDKEY"}, + map[string]string{"claude": "/usr/local/bin/claude"}, + map[string]bool{filepath.Join(home, ".claude.json"): true}, + home)) + + if st.AuthMethod != "ANTHROPIC_API_KEY (env)" { + t.Errorf("env key should win over login file, got AuthMethod=%q", st.AuthMethod) + } +} + +// TestRunWhoami_NoModelsCoversEveryBackend asserts the command lists exactly one +// adapter per backend without making any network calls when --models=false. +func TestRunWhoami_NoModelsCoversEveryBackend(t *testing.T) { + res, err := RunWhoami(WhoamiOptions{Models: false}) + if err != nil { + t.Fatalf("RunWhoami: %v", err) + } + r, ok := res.(WhoamiResult) + if !ok { + t.Fatalf("RunWhoami returned %T, want WhoamiResult", res) + } + if len(r.Adapters) != len(ai.AllBackends()) { + t.Fatalf("got %d adapters, want %d", len(r.Adapters), len(ai.AllBackends())) + } + for _, a := range r.Adapters { + if a.ModelCount != 0 || len(a.Models) != 0 { + t.Errorf("adapter %s has models with --models=false: %+v", a.Backend, a) + } + } +} + +func TestRunWhoami_RejectsUnknownBackend(t *testing.T) { + if _, err := RunWhoami(WhoamiOptions{Backend: "bogus", Models: false}); err == nil { + t.Fatal("expected error for unknown --backend") + } +}