From a3e8440bf899e72ccdeaa84f1050e229f765deb0 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 07:40:47 +0300 Subject: [PATCH 01/22] feat: add iterative agent loop with verifiers, worktree, and commit --- cmd/captain/main.go | 1 + pkg/ai/agent.go | 5 + pkg/ai/middleware/logging.go | 66 ++++- pkg/ai/middleware/logging_test.go | 99 +++++++ pkg/ai/models_remote.go | 2 +- pkg/ai/prompt/prompt.go | 1 + pkg/ai/provider.go | 32 ++- pkg/ai/provider/claudeagent/provider_test.go | 183 +++++++++++-- pkg/ai/provider_test.go | 68 +++++ pkg/ai/types.go | 7 +- pkg/cli/ai.go | 169 ++++++++---- pkg/cli/ai_agent.go | 234 +++++++++++++++++ pkg/cli/ai_agent_test.go | 121 +++++++++ pkg/cli/ai_models.go | 2 +- pkg/cli/ai_test.go | 261 +++++++++++-------- pkg/cli/configure.go | 7 +- 16 files changed, 1065 insertions(+), 193 deletions(-) create mode 100644 pkg/ai/middleware/logging_test.go create mode 100644 pkg/ai/provider_test.go create mode 100644 pkg/cli/ai_agent.go create mode 100644 pkg/cli/ai_agent_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 8becbe3..82ab5bb 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -92,6 +92,7 @@ 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" diff --git a/pkg/ai/agent.go b/pkg/ai/agent.go index 189bec5..89ed88f 100644 --- a/pkg/ai/agent.go +++ b/pkg/ai/agent.go @@ -30,6 +30,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. @@ -75,6 +79,7 @@ func (a *Agent) ExecutePrompt(ctx context.Context, req PromptRequest) (*PromptRe SystemPrompt: req.SystemPrompt, Prompt: req.Prompt, StructuredOutput: req.StructuredOutput, + Source: req.Source, }) if err != nil { return &PromptResponse{Request: req, Model: a.cfg.Model, Error: err.Error(), Duration: time.Since(start)}, err diff --git a/pkg/ai/middleware/logging.go b/pkg/ai/middleware/logging.go index 9ffe1c5..cffe0e8 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.Source != "" { + t = t.Append(fmt.Sprintf(" [%s]", req.Source), "text-gray-500") + } + t = t.NewLine().Append(req.Prompt, "text-gray-600") + if s := schemaInJSON(req.StructuredOutput); 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.Source != "" { + t = t.Append(fmt.Sprintf(" [%s]", req.Source), "text-gray-500") + } + log.Debugf("%v", t.NewLine().Append(req.Prompt, "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..f0e87a3 --- /dev/null +++ b/pkg/ai/middleware/logging_test.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "bytes" + "context" + "regexp" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "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: "rendered input prompt", + Source: "ai-commit-message.prompt", + StructuredOutput: &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/models_remote.go b/pkg/ai/models_remote.go index e37e97d..7b1674f 100644 --- a/pkg/ai/models_remote.go +++ b/pkg/ai/models_remote.go @@ -175,7 +175,7 @@ func remoteFetcherFor(backend Backend) (fetch func(context.Context, string) ([]M switch backend { case BackendOpenAI, BackendCodexCLI: return FetchOpenAIModels, os.Getenv("OPENAI_API_KEY"), BackendOpenAI - case BackendAnthropic, BackendClaudeCLI: + case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: return FetchAnthropicModels, os.Getenv("ANTHROPIC_API_KEY"), BackendAnthropic case BackendGemini, BackendGeminiCLI: return FetchGeminiModels, os.Getenv("GEMINI_API_KEY"), BackendGemini diff --git a/pkg/ai/prompt/prompt.go b/pkg/ai/prompt/prompt.go index 7d7eaec..118aeca 100644 --- a/pkg/ai/prompt/prompt.go +++ b/pkg/ai/prompt/prompt.go @@ -76,6 +76,7 @@ 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")), + Source: t.name, } cfg := ai.Config{Model: rendered.Model} if rendered.Model != "" { diff --git a/pkg/ai/provider.go b/pkg/ai/provider.go index f121756..133170c 100644 --- a/pkg/ai/provider.go +++ b/pkg/ai/provider.go @@ -21,6 +21,36 @@ const ( BackendClaudeAgent Backend = "claude-agent" ) +// AllBackends lists every supported backend in canonical order. It is the single +// source of truth behind Backend.Valid, BackendList, and the help/error strings +// that enumerate backends — keep new backends here and they propagate everywhere. +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 +} + +// BackendList renders AllBackends as a comma-separated string for help text and +// error messages so the enumeration lives in exactly one place. +func BackendList() string { + parts := make([]string, len(AllBackends())) + for i, b := range AllBackends() { + parts[i] = string(b) + } + return strings.Join(parts, ", ") +} + type Provider interface { Execute(ctx context.Context, req Request) (*Response, error) GetModel() string @@ -63,5 +93,5 @@ func InferBackend(model string) (Backend, error) { 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) + return "", fmt.Errorf("unable to infer backend from model name: %s (pass --backend explicitly: %s)", model, BackendList()) } diff --git a/pkg/ai/provider/claudeagent/provider_test.go b/pkg/ai/provider/claudeagent/provider_test.go index ba7a5d6..e5ca6b3 100644 --- a/pkg/ai/provider/claudeagent/provider_test.go +++ b/pkg/ai/provider/claudeagent/provider_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "os" + "path/filepath" "testing" "time" @@ -19,6 +20,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 +39,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 +56,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 +132,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 }) } @@ -169,3 +214,107 @@ 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"}) + + p, err := New(ai.Config{Model: "claude-agent-sonnet"}) + require.NoError(t, err) + t.Cleanup(func() { _ = p.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var gotReq ai.PermissionRequest + called := make(chan struct{}, 1) + req := ai.Request{ + Prompt: "use a tool", + 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 + }, + } + + events, err := p.ExecuteStream(ctx, req) + 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: "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: "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_test.go b/pkg/ai/provider_test.go new file mode 100644 index 0000000..e6293d4 --- /dev/null +++ b/pkg/ai/provider_test.go @@ -0,0 +1,68 @@ +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 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/types.go b/pkg/ai/types.go index 74be2f6..557deb4 100644 --- a/pkg/ai/types.go +++ b/pkg/ai/types.go @@ -14,6 +14,11 @@ type Request struct { StructuredOutput any // nil = text mode, non-nil = JSON schema target Metadata map[string]string // arbitrary caller metadata + // Source identifies the prompt's origin (e.g. the .prompt filename), purely + // for diagnostics — the logging middleware prints it so callers can tell which + // template produced a request. Empty means unknown. + Source string + // 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). @@ -25,8 +30,6 @@ type Request struct { // 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 diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index d9c3dc0..58e8f7b 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" "github.com/flanksource/captain/pkg/ai" @@ -28,15 +29,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 +63,20 @@ 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{ + return ai.Config{ Model: model, Backend: ai.Backend(backend), APIKey: o.APIKey, - NoCache: noCache, + NoCache: o.NoCache || saved.NoCache, BudgetUSD: budget, - } - return cfg + }, nil } // AIRuntimeOptions binds the per-invocation knobs every AI command shares — @@ -74,24 +92,55 @@ 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" default:"0"` + ReasoningEffort string `flag:"reasoning-effort" help:"Reasoning effort: low|medium|high (codex/genkit; others ignore)"` + MaxTurns int `flag:"max-turns" help:"Max agent turns, 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, "|")) +} + +var validReasoningEfforts = []string{"low", "medium", "high"} + +func validateReasoningEffort(s string) error { + if s == "" { + return nil + } + for _, e := range validReasoningEfforts { + if s == e { + return nil + } + } + return fmt.Errorf("invalid --reasoning-effort %q (valid: %s)", s, strings.Join(validReasoningEfforts, "|")) +} + type AIPromptOptions struct { AIRuntimeOptions @@ -112,38 +161,45 @@ type AIPromptResult struct { 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; reasoning-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 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.ReasoningEffort + if effort == "" { + effort = saved.ReasoningEffort + } + if err := validateReasoningEffort(effort); err != nil { + return ai.Request{}, err + } return ai.Request{ SystemPrompt: systemPrompt, @@ -152,24 +208,26 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt MaxTokens: maxTokens, Temperature: temperature, ReasoningEffort: effort, + MaxTurns: o.MaxTurns, + SessionID: o.Resume, 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, + NoMCP: o.NoMCP || saved.NoMCP, + NoHooks: o.NoHooks || saved.NoHooks, + NoSkills: o.NoSkills || saved.NoSkills, SkillDirs: o.SkillDirs, - NoUser: !o.User || saved.NoUser, - NoProject: !o.Project || saved.NoProject, - NoMemory: !o.Memory || saved.NoMemory, + NoUser: o.NoUser || saved.NoUser, + NoProject: o.NoProject || saved.NoProject, + NoMemory: o.NoMemory || saved.NoMemory, Bare: o.Bare, - } + }, 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) } @@ -178,12 +236,18 @@ func RunAIPrompt(opts AIPromptOptions) (any, error) { return nil, fmt.Errorf("prompt text required (use --prompt or pipe via stdin)") } - cfg := opts.ToConfig() + cfg, err := opts.ToConfig() + if err != nil { + return nil, err + } if cfg.Model == "" { return nil, fmt.Errorf("no model: pass --model or run 'captain configure' to set a default") } - req := opts.ToRequest() + req, err := opts.ToRequest() + if err != nil { + return nil, err + } p, err := ai.NewProvider(cfg) if err != nil { @@ -432,7 +496,10 @@ type AITestResult struct { } func RunAITest(opts AITestOptions) (any, error) { - cfg := opts.ToConfig() + cfg, err := opts.ToConfig() + if err != nil { + return nil, err + } if cfg.Model == "" { return nil, fmt.Errorf("no model: pass --model or run 'captain configure' to set a default") } diff --git a/pkg/cli/ai_agent.go b/pkg/cli/ai_agent.go new file mode 100644 index 0000000..ef003b7 --- /dev/null +++ b/pkg/cli/ai_agent.go @@ -0,0 +1,234 @@ +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) { + switch s { + case "", "all": + return agent.ScopeAll, nil + case "changed": + return agent.ScopeChanged, nil + default: + return "", fmt.Errorf("invalid --scope %q (valid: changed|all)", 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 == "" { + 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/reasoning-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.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 = 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..cdbf124 --- /dev/null +++ b/pkg/cli/ai_agent_test.go @@ -0,0 +1,121 @@ +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) + } + 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_models.go b/pkg/cli/ai_models.go index 4ebc8ed..18ad643 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"` } diff --git a/pkg/cli/ai_test.go b/pkg/cli/ai_test.go index aa2b1f0..73e8569 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -4,8 +4,10 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" + "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/captainconfig" ) @@ -13,10 +15,16 @@ import ( // 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 +33,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.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want 4096 built-in default", req.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.NoMCP, "NoHooks": req.NoHooks, "NoSkills": req.NoSkills, + "NoUser": req.NoUser, "NoProject": req.NoProject, "NoMemory": req.NoMemory, + "Bare": req.Bare, "Edit": req.Edit, } { if got { t.Errorf("%s = true; default-shape options must not flip any No*/Bare/Edit", name) @@ -46,75 +55,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) + field string }{ - { - 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 }, "NoMCP"}, + {"no-hooks", func(o *AIPromptOptions) { o.NoHooks = true }, "NoHooks"}, + {"no-skills", func(o *AIPromptOptions) { o.NoSkills = true }, "NoSkills"}, + {"no-user", func(o *AIPromptOptions) { o.NoUser = true }, "NoUser"}, + {"no-project", func(o *AIPromptOptions) { o.NoProject = true }, "NoProject"}, + {"no-memory", func(o *AIPromptOptions) { o.NoMemory = true }, "NoMemory"}, } - 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", + req, err := opts.ToRequest() + if err != nil { + t.Fatalf("ToRequest: %v", err) } - for _, name := range fieldMap { - if rv.FieldByName(name).Kind() != reflect.Bool { - continue - } + if !reflect.ValueOf(req).FieldByName(tc.field).Bool() { + t.Errorf("%s = false, want true after --%s", tc.field, tc.name) } - // 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", - } - tc.check(t, rv.FieldByName(lookup[tc.name]).Bool()) }) } } @@ -132,8 +99,14 @@ func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) { opts.Bare = true opts.MaxTokens = 1024 opts.Temperature = "0.5" + opts.ReasoningEffort = "high" + opts.MaxTurns = 7 + opts.Resume = "sess-123" - req := opts.ToRequest() + req, err := opts.ToRequest() + if err != nil { + t.Fatalf("ToRequest: %v", err) + } if req.SystemPrompt != "be careful" { t.Errorf("SystemPrompt = %q", req.SystemPrompt) } @@ -164,42 +137,107 @@ func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) { if req.Temperature != 0.5 { t.Errorf("Temperature = %v", req.Temperature) } + if req.ReasoningEffort != "high" { + t.Errorf("ReasoningEffort = %q", req.ReasoningEffort) + } + if req.MaxTurns != 7 { + t.Errorf("MaxTurns = %d", req.MaxTurns) + } + 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"}, + {"bad permission-mode", func(o *AIPromptOptions) { o.PermissionMode = "yolo" }, "permission-mode"}, + {"bad reasoning-effort", func(o *AIPromptOptions) { o.ReasoningEffort = "max" }, "reasoning-effort"}, } - 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.MaxTokens != 2048 { + t.Errorf("MaxTokens = %d, want 2048 (explicit flag)", req.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.MaxTokens != 16000 { + t.Errorf("MaxTokens = %d, want 16000 (saved)", req.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.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want 4096 (built-in)", req.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 +// reasoning-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{ReasoningEffort: "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.SystemPrompt != "sys" || req.Prompt != "user" { + t.Errorf("prompt fields = %q/%q, want sys/user", req.SystemPrompt, req.Prompt) } if req.MaxTokens != 16000 { t.Errorf("MaxTokens = %d, want 16000 (saved overlay)", req.MaxTokens) } if req.ReasoningEffort != "high" { - t.Errorf("ReasoningEffort = %q, want high", req.ReasoningEffort) + t.Errorf("ReasoningEffort = %q, want high (flag overrides saved)", req.ReasoningEffort) } for name, got := range map[string]bool{ "NoMCP": req.NoMCP, "NoHooks": req.NoHooks, "NoSkills": req.NoSkills, @@ -211,27 +249,36 @@ 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 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/configure.go b/pkg/cli/configure.go index d1ffb20..a55fc4f 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,6 +207,7 @@ 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)), } @@ -255,7 +256,7 @@ func modelOptionsFor(b ai.Backend) []huh.Option[string] { // seeds the form. func defaultModelFor(b ai.Backend) string { switch b { - case ai.BackendAnthropic, ai.BackendClaudeCLI: + case ai.BackendAnthropic, ai.BackendClaudeCLI, ai.BackendClaudeAgent: return "claude-sonnet-4-5" case ai.BackendOpenAI: return "gpt-5" From 875a15d9fd394f2eb3bc4cfe9f3288eff1b37328 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 08:42:24 +0300 Subject: [PATCH 02/22] feat: add captain serve command with web UI and chat interface --- .gitignore | 4 + cmd/captain/main.go | 6 + go.mod | 3 + go.sum | 8 + pkg/ai/client.go | 10 +- pkg/ai/models_remote.go | 7 +- pkg/ai/provider.go | 31 +++ pkg/ai/provider_test.go | 55 ++++ pkg/cli/chat_thread_store.go | 231 ++++++++++++++++ pkg/cli/chat_thread_store_test.go | 78 ++++++ pkg/cli/serve.go | 395 +++++++++++++++++++++++++++ pkg/cli/serve_test.go | 59 ++++ pkg/cli/webapp/index.html | 12 + pkg/cli/webapp/src/AgentLauncher.tsx | 151 ++++++++++ pkg/cli/webapp/src/App.tsx | 102 +++++++ pkg/cli/webapp/src/ChatLayer.tsx | 38 +++ pkg/cli/webapp/src/ChatRoute.tsx | 30 ++ pkg/cli/webapp/src/api.ts | 4 + pkg/cli/webapp/src/index.css | 20 ++ pkg/cli/webapp/src/main.tsx | 16 ++ pkg/cli/webapp/src/session.ts | 181 ++++++++++++ pkg/cli/webapp/tsconfig.json | 20 ++ pkg/cli/webapp/vite.config.ts | 124 +++++++++ pkg/cli/whoami.go | 277 +++++++++++++++++++ pkg/cli/whoami_render.go | 119 ++++++++ pkg/cli/whoami_test.go | 147 ++++++++++ 26 files changed, 2115 insertions(+), 13 deletions(-) create mode 100644 pkg/cli/chat_thread_store.go create mode 100644 pkg/cli/chat_thread_store_test.go create mode 100644 pkg/cli/serve.go create mode 100644 pkg/cli/serve_test.go create mode 100644 pkg/cli/webapp/index.html create mode 100644 pkg/cli/webapp/src/AgentLauncher.tsx create mode 100644 pkg/cli/webapp/src/App.tsx create mode 100644 pkg/cli/webapp/src/ChatLayer.tsx create mode 100644 pkg/cli/webapp/src/ChatRoute.tsx create mode 100644 pkg/cli/webapp/src/api.ts create mode 100644 pkg/cli/webapp/src/index.css create mode 100644 pkg/cli/webapp/src/main.tsx create mode 100644 pkg/cli/webapp/src/session.ts create mode 100644 pkg/cli/webapp/tsconfig.json create mode 100644 pkg/cli/webapp/vite.config.ts create mode 100644 pkg/cli/whoami.go create mode 100644 pkg/cli/whoami_render.go create mode 100644 pkg/cli/whoami_test.go diff --git a/.gitignore b/.gitignore index 565a73f..041beb3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ specs/ *.png go.work go.work.sum +pkg/cli/webapp/node_modules/ +pkg/cli/webapp/*.tsbuildinfo +!pkg/cli/webapp/dist/ +!pkg/cli/webapp/dist/** diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 82ab5bb..df6525f 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -97,10 +97,16 @@ func main() { 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..cfb4fef 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ 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/clicky/aichat v1.21.33 github.com/flanksource/commons v1.51.3 github.com/flanksource/sandbox-runtime v1.0.2 github.com/google/dotprompt/go v0.0.0-20260502013637-5cd4a8405ca3 @@ -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..a62fe2f 100644 --- a/go.sum +++ b/go.sum @@ -149,6 +149,8 @@ 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/clicky/aichat v1.21.33 h1:SquXXDXlCarx5w7yCpRGDxDDnBrH1KG1bnDcWkDnNjo= +github.com/flanksource/clicky/aichat v1.21.33/go.mod h1:fgCn0YNytYOFWOWHrd9JDmCFy73XG/iCNdUdgqXNoaY= 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/gomplate/v3 v3.24.82 h1:22HOZYeNRMX40G8OF3qRAqRoR5Lkf8Vm4x3WDqugZkE= @@ -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/client.go b/pkg/ai/client.go index abfc67b..d820816 100644 --- a/pkg/ai/client.go +++ b/pkg/ai/client.go @@ -42,15 +42,7 @@ func NewProvider(cfg Config) (Provider, error) { } 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] { + for _, envVar := range AuthEnvVars(backend) { if key := os.Getenv(envVar); key != "" { return key } diff --git a/pkg/ai/models_remote.go b/pkg/ai/models_remote.go index 7b1674f..07c60b1 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" @@ -174,11 +173,11 @@ func ListModels(ctx context.Context, backend Backend) ([]ModelDef, error) { func remoteFetcherFor(backend Backend) (fetch func(context.Context, string) ([]ModelDef, error), apiKey string, parent Backend) { switch backend { case BackendOpenAI, BackendCodexCLI: - return FetchOpenAIModels, os.Getenv("OPENAI_API_KEY"), BackendOpenAI + return FetchOpenAIModels, GetAPIKeyFromEnv(backend), BackendOpenAI case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: - return FetchAnthropicModels, os.Getenv("ANTHROPIC_API_KEY"), BackendAnthropic + return FetchAnthropicModels, GetAPIKeyFromEnv(backend), BackendAnthropic case BackendGemini, BackendGeminiCLI: - return FetchGeminiModels, os.Getenv("GEMINI_API_KEY"), BackendGemini + return FetchGeminiModels, GetAPIKeyFromEnv(backend), BackendGemini default: return nil, "", "" } diff --git a/pkg/ai/provider.go b/pkg/ai/provider.go index 133170c..a7cffe6 100644 --- a/pkg/ai/provider.go +++ b/pkg/ai/provider.go @@ -41,6 +41,37 @@ func (b Backend) Valid() bool { 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 that carries its +// own auth/login). Used by `captain whoami` to group adapters and decide which +// auth signals to probe. +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 (the +// CLIs honour the same env var and the model-listing endpoints are the parent +// provider's), so this is the single source of truth for both NewProvider's key +// resolution and the live model listing in models_remote.go. +func AuthEnvVars(b Backend) []string { + switch b { + case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: + return []string{"ANTHROPIC_API_KEY"} + case BackendOpenAI, BackendCodexCLI: + 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 text and // error messages so the enumeration lives in exactly one place. func BackendList() string { diff --git a/pkg/ai/provider_test.go b/pkg/ai/provider_test.go index e6293d4..0304b00 100644 --- a/pkg/ai/provider_test.go +++ b/pkg/ai/provider_test.go @@ -45,6 +45,61 @@ func TestInferBackendErrorListsAllBackends(t *testing.T) { } } +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, 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/serve.go b/pkg/cli/serve.go new file mode 100644 index 0000000..20f1407 --- /dev/null +++ b/pkg/cli/serve.go @@ -0,0 +1,395 @@ +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" +) + +//go:embed 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/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..831047b --- /dev/null +++ b/pkg/cli/webapp/src/App.tsx @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + 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"; + +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 === "operations" ? ( + + ) : route.kind === "chat" ? ( + + ) : ( +
+ +
+ )} +
+ +
+
+
+
+ ); +} + +type Route = + | { kind: "launcher" } + | { 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("/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/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..9d784eb --- /dev/null +++ b/pkg/cli/whoami.go @@ -0,0 +1,277 @@ +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:] +} + +// parentBackend maps a CLI backend onto the API backend whose /v1/models +// endpoint and key it shares; API backends are their own parent. +func parentBackend(b ai.Backend) ai.Backend { + switch b { + case ai.BackendCodexCLI: + return ai.BackendOpenAI + case ai.BackendClaudeCLI, ai.BackendClaudeAgent: + return ai.BackendAnthropic + case ai.BackendGeminiCLI: + return ai.BackendGemini + default: + return b + } +} + +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 = fetchParentModels(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 +} + +// fetchParentModels hits each distinct parent provider's models endpoint once, +// concurrently, so claude-cli and claude-agent don't both re-query Anthropic. +// A parent with no API key in the environment is skipped entirely (the listing +// endpoint requires the key even when the CLI is logged in via OAuth). +func fetchParentModels(backends []ai.Backend, getenv func(string) string) map[ai.Backend]modelFetch { + parents := map[ai.Backend]bool{} + for _, b := range backends { + p := parentBackend(b) + if firstEnv(ai.AuthEnvVars(p), getenv) != "" { + parents[p] = true + } + } + + out := make(map[ai.Backend]modelFetch, len(parents)) + var mu sync.Mutex + var wg sync.WaitGroup + for p := range parents { + wg.Add(1) + go func(parent ai.Backend) { + defer wg.Done() + m, err := ai.ListModels(context.Background(), parent) + mu.Lock() + out[parent] = modelFetch{models: m, err: err} + mu.Unlock() + }(p) + } + wg.Wait() + return out +} + +// applyModels fills in the model listing (or the reason it is unavailable) for a +// single adapter from the pre-fetched per-parent results. +func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetch, getenv func(string) string) { + envVars := ai.AuthEnvVars(b) + if firstEnv(envVars, getenv) == "" { + st.ModelError = "set " + strings.Join(envVars, " or ") + " to list models" + return + } + + fetch, ok := cache[parentBackend(b)] + if !ok { + return + } + if fetch.err != nil { + st.ModelError = fetch.err.Error() + return + } + + st.ModelCount = len(fetch.models) + ids := make([]string, 0, len(fetch.models)) + for _, m := range fetch.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") + } +} From 0e3ec09d880bc839224cc213ecb6a36864364e21 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 11:00:28 +0300 Subject: [PATCH 03/22] refactor: consolidate domain types into pkg/api and add session browsing --- .gitignore | 1 + README.md | 154 ++++++- cmd/captain/main.go | 5 + go.mod | 2 +- pkg/ai/agent/runner.go | 40 ++ pkg/ai/agent/scope_test.go | 38 ++ pkg/ai/models_remote.go | 47 +-- pkg/ai/models_remote_test.go | 44 +- pkg/ai/provider.go | 127 ++---- pkg/ai/types.go | 58 +-- pkg/api/budget.go | 25 ++ pkg/api/context.go | 35 ++ pkg/api/cost.go | 63 +++ pkg/api/cost_test.go | 43 ++ pkg/api/enums.go | 216 ++++++++++ pkg/api/enums_test.go | 61 +++ pkg/api/memory.go | 22 + pkg/api/model.go | 51 +++ pkg/api/permissions.go | 53 +++ pkg/api/pretty.go | 91 +++++ pkg/api/pretty_test.go | 34 ++ pkg/api/prompt.go | 31 ++ pkg/api/schema.go | 23 ++ pkg/api/schema_test.go | 36 ++ pkg/api/spec.go | 45 +++ pkg/api/spec_test.go | 147 +++++++ pkg/cli/ai.go | 39 +- pkg/cli/ai_agent.go | 11 +- pkg/cli/ai_agent_test.go | 2 + pkg/cli/ai_catalog.go | 44 ++ pkg/cli/ai_catalog_test.go | 76 ++++ pkg/cli/ai_filters.go | 106 +++++ pkg/cli/ai_filters_test.go | 96 +++++ pkg/cli/ai_models.go | 44 +- pkg/cli/ai_test.go | 12 +- pkg/cli/configure.go | 52 ++- pkg/cli/configure_test.go | 42 +- pkg/cli/info.go | 21 +- pkg/cli/sessions.go | 559 ++++++++++++++++++++++++++ pkg/cli/sessions_test.go | 208 ++++++++++ pkg/cli/webapp/src/App.tsx | 140 +++++-- pkg/cli/webapp/src/SessionBrowser.tsx | 370 +++++++++++++++++ pkg/cli/whoami.go | 67 ++- 43 files changed, 3011 insertions(+), 370 deletions(-) create mode 100644 pkg/ai/agent/scope_test.go create mode 100644 pkg/api/budget.go create mode 100644 pkg/api/context.go create mode 100644 pkg/api/cost.go create mode 100644 pkg/api/cost_test.go create mode 100644 pkg/api/enums.go create mode 100644 pkg/api/enums_test.go create mode 100644 pkg/api/memory.go create mode 100644 pkg/api/model.go create mode 100644 pkg/api/permissions.go create mode 100644 pkg/api/pretty.go create mode 100644 pkg/api/pretty_test.go create mode 100644 pkg/api/prompt.go create mode 100644 pkg/api/schema.go create mode 100644 pkg/api/schema_test.go create mode 100644 pkg/api/spec.go create mode 100644 pkg/api/spec_test.go create mode 100644 pkg/cli/ai_catalog.go create mode 100644 pkg/cli/ai_catalog_test.go create mode 100644 pkg/cli/ai_filters.go create mode 100644 pkg/cli/ai_filters_test.go create mode 100644 pkg/cli/sessions.go create mode 100644 pkg/cli/sessions_test.go create mode 100644 pkg/cli/webapp/src/SessionBrowser.tsx diff --git a/.gitignore b/.gitignore index 041beb3..e15bcb2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ pkg/cli/webapp/node_modules/ pkg/cli/webapp/*.tsbuildinfo !pkg/cli/webapp/dist/ !pkg/cli/webapp/dist/** +dist/ diff --git a/README.md b/README.md index af76039..41aefa1 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 @@ -187,6 +233,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 +274,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 +290,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 +440,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 +551,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 +563,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 +581,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 df6525f..3f34e64 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -70,6 +70,11 @@ 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." + 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", diff --git a/go.mod b/go.mod index cfb4fef..bb7a2ba 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/flanksource/commons v1.51.3 github.com/flanksource/sandbox-runtime v1.0.2 github.com/google/dotprompt/go v0.0.0-20260502013637-5cd4a8405ca3 + 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 @@ -113,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 diff --git a/pkg/ai/agent/runner.go b/pkg/ai/agent/runner.go index 82ac4e8..da61dd9 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. 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/models_remote.go b/pkg/ai/models_remote.go index 07c60b1..d9edc97 100644 --- a/pkg/ai/models_remote.go +++ b/pkg/ai/models_remote.go @@ -132,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) } @@ -152,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, GetAPIKeyFromEnv(backend), BackendOpenAI - case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: - return FetchAnthropicModels, GetAPIKeyFromEnv(backend), BackendAnthropic - case BackendGemini, BackendGeminiCLI: - return FetchGeminiModels, GetAPIKeyFromEnv(backend), 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..34760aa 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,21 @@ func TestListModels_ErrorsOnMissingKey(t *testing.T) { } } +// TestListModels_RejectsCLIBackends pins that CLI/agent 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_RejectsCLIBackends(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} { + if _, err := ListModels(context.Background(), b); err == nil { + t.Errorf("backend=%s: expected error (no live listing for CLI/agent 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 +175,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/provider.go b/pkg/ai/provider.go index a7cffe6..f4fe322 100644 --- a/pkg/ai/provider.go +++ b/pkg/ai/provider.go @@ -2,85 +2,35 @@ 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 ) -// AllBackends lists every supported backend in canonical order. It is the single -// source of truth behind Backend.Valid, BackendList, and the help/error strings -// that enumerate backends — keep new backends here and they propagate everywhere. -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 that carries its -// own auth/login). Used by `captain whoami` to group adapters and decide which -// auth signals to probe. -func (b Backend) Kind() string { - switch b { - case BackendAnthropic, BackendGemini, BackendOpenAI: - return "api" - default: - return "cli" - } -} +// AllBackends lists every supported backend in canonical order. +func AllBackends() []Backend { return api.AllBackends() } -// AuthEnvVars returns the environment variables consulted for a backend's API -// key, in priority order. CLI backends share their parent provider's key (the -// CLIs honour the same env var and the model-listing endpoints are the parent -// provider's), so this is the single source of truth for both NewProvider's key -// resolution and the live model listing in models_remote.go. -func AuthEnvVars(b Backend) []string { - switch b { - case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: - return []string{"ANTHROPIC_API_KEY"} - case BackendOpenAI, BackendCodexCLI: - return []string{"OPENAI_API_KEY"} - case BackendGemini, BackendGeminiCLI: - return []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} - default: - return nil - } -} +// AuthEnvVars returns the environment variables consulted for a backend's API key. +func AuthEnvVars(b Backend) []string { return api.AuthEnvVars(b) } -// BackendList renders AllBackends as a comma-separated string for help text and -// error messages so the enumeration lives in exactly one place. -func BackendList() string { - parts := make([]string, len(AllBackends())) - for i, b := range AllBackends() { - parts[i] = string(b) - } - return strings.Join(parts, ", ") -} +// BackendList renders AllBackends as a comma-separated string for help/error text. +func BackendList() string { return api.BackendList() } type Provider interface { Execute(ctx context.Context, req Request) (*Response, error) @@ -93,36 +43,5 @@ type StreamingProvider interface { ExecuteStream(ctx context.Context, req Request) (<-chan Event, error) } -func InferBackend(model string) (Backend, error) { - m := strings.ToLower(model) - - // 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 - } - - // 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: %s)", model, BackendList()) -} +// 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/types.go b/pkg/ai/types.go index 557deb4..875a864 100644 --- a/pkg/ai/types.go +++ b/pkg/ai/types.go @@ -3,6 +3,8 @@ package ai import ( "context" "time" + + "github.com/flanksource/captain/pkg/api" ) type Request struct { @@ -91,17 +93,8 @@ type Response struct { 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 -} +// Usage is an alias for the canonical api.Usage (per-call token breakdown). +type Usage = api.Usage type EventKind string @@ -145,45 +138,10 @@ type Event struct { 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 -} +// 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 type Config struct { Model string 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..0f5af6a --- /dev/null +++ b/pkg/api/context.go @@ -0,0 +1,35 @@ +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"` +} + +// 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..41c7cf7 --- /dev/null +++ b/pkg/api/cost.go @@ -0,0 +1,63 @@ +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"` + 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"` +} + +// Total is the combined input + output cost in USD. +func (c Cost) Total() float64 { return c.InputCost + c.OutputCost } + +// 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, + TotalTokens: c.TotalTokens + other.TotalTokens, + InputCost: c.InputCost + other.InputCost, + OutputCost: c.OutputCost + other.OutputCost, + } +} + +// 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..619b359 --- /dev/null +++ b/pkg/api/cost_test.go @@ -0,0 +1,43 @@ +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, TotalTokens: 150, InputCost: 0.10, OutputCost: 0.25} + b := Cost{Model: "ignored", InputTokens: 10, OutputTokens: 5, TotalTokens: 15, InputCost: 0.01, OutputCost: 0.02} + sum := a.Add(b) + if sum.Model != "m1" { + t.Errorf("Add keeps receiver Model, got %q", sum.Model) + } + if sum.InputTokens != 110 || sum.TotalTokens != 165 { + t.Errorf("Add tokens wrong: %+v", sum) + } + if got := sum.Total(); got < 0.379999 || got > 0.380001 { + t.Errorf("Total() = %v, want 0.38", 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..92dbc59 --- /dev/null +++ b/pkg/api/enums.go @@ -0,0 +1,216 @@ +// 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" +) + +// 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: + return []string{"ANTHROPIC_API_KEY"} + case BackendOpenAI, BackendCodexCLI: + 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..386a87c --- /dev/null +++ b/pkg/api/model.go @@ -0,0 +1,51 @@ +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) +} + +// 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..6d8daba --- /dev/null +++ b/pkg/api/permissions.go @@ -0,0 +1,53 @@ +package api + +import "fmt" + +// 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"` +} + +// 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/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/cli/ai.go b/pkg/cli/ai.go index 58e8f7b..c95aa24 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -13,6 +13,7 @@ import ( "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/claude" "github.com/flanksource/captain/pkg/claude/tools" + "github.com/flanksource/clicky/aichat" ) // loadSavedAI returns the saved AI defaults from ~/.captain.yaml. Errors are @@ -92,11 +93,11 @@ func (o AIProviderOptions) ToConfig() (ai.Config, error) { type AIRuntimeOptions struct { AIProviderOptions - MaxTokens int `flag:"max-tokens" help:"Maximum output tokens (0 = saved default or 4096)"` - Temperature string `flag:"temperature" help:"Sampling temperature" default:"0"` - ReasoningEffort string `flag:"reasoning-effort" help:"Reasoning effort: low|medium|high (codex/genkit; others ignore)"` - MaxTurns int `flag:"max-turns" help:"Max agent turns, 0 = provider default (claude-agent)"` - Resume string `flag:"resume" help:"Resume an existing session by id (claude-agent, codex)"` + 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 (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)"` @@ -127,20 +128,6 @@ func validatePermissionMode(s string) error { return fmt.Errorf("invalid --permission-mode %q (valid: %s)", s, strings.Join(validPermissionModes, "|")) } -var validReasoningEfforts = []string{"low", "medium", "high"} - -func validateReasoningEffort(s string) error { - if s == "" { - return nil - } - for _, e := range validReasoningEfforts { - if s == e { - return nil - } - } - return fmt.Errorf("invalid --reasoning-effort %q (valid: %s)", s, strings.Join(validReasoningEfforts, "|")) -} - type AIPromptOptions struct { AIRuntimeOptions @@ -164,7 +151,7 @@ type AIPromptResult struct { // 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; reasoning-effort uses the flag when set, else saved. +// 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`. @@ -180,6 +167,12 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt 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 } @@ -193,12 +186,12 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt maxTokens = 4096 } - effort := o.ReasoningEffort + effort := o.Effort if effort == "" { effort = saved.ReasoningEffort } - if err := validateReasoningEffort(effort); err != nil { - return ai.Request{}, err + if err := aichat.ValidateEffort(aichat.Effort(effort)); err != nil { + return ai.Request{}, fmt.Errorf("invalid --effort %q (valid: low|medium|high): %w", effort, err) } return ai.Request{ diff --git a/pkg/cli/ai_agent.go b/pkg/cli/ai_agent.go index ef003b7..b01cb98 100644 --- a/pkg/cli/ai_agent.go +++ b/pkg/cli/ai_agent.go @@ -60,14 +60,7 @@ Working directory: {{cwd}} Changed files: {{changed}}` func scopeFromFlag(s string) (agent.Scope, error) { - switch s { - case "", "all": - return agent.ScopeAll, nil - case "changed": - return agent.ScopeChanged, nil - default: - return "", fmt.Errorf("invalid --scope %q (valid: changed|all)", s) - } + return agent.ParseScope(s) } // buildAgentPlugins assembles the verify/worktree/judge plugins from the flags. @@ -130,7 +123,7 @@ func RunAIAgent(opts AIAgentOptions) (any, error) { if err != nil { return nil, err } - // Validate runtime knobs (temperature/permission-mode/reasoning-effort) and + // 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 { diff --git a/pkg/cli/ai_agent_test.go b/pkg/cli/ai_agent_test.go index cdbf124..a714e6a 100644 --- a/pkg/cli/ai_agent_test.go +++ b/pkg/cli/ai_agent_test.go @@ -30,6 +30,8 @@ func TestScopeFromFlag(t *testing.T) { 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 } 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..88d0379 --- /dev/null +++ b/pkg/cli/ai_filters.go @@ -0,0 +1,106 @@ +package cli + +import ( + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent" + "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}, + } +} + +// effortOptions sources the reasoning-effort values from clicky/aichat's shared +// Effort enum (the same source ToRequest validates against via ValidateEffort). +func effortFilterOptions() map[string]api.Textable { + return map[string]api.Textable{ + string(aichat.EffortLow): api.Text{Content: "Low"}, + string(aichat.EffortMedium): api.Text{Content: "Medium"}, + string(aichat.EffortHigh): api.Text{Content: "High"}, + } +} + +// 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..b08937c --- /dev/null +++ b/pkg/cli/ai_filters_test.go @@ -0,0 +1,96 @@ +package cli + +import ( + "sort" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/agent" + "github.com/flanksource/clicky/aichat" +) + +// 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 := []string{string(aichat.EffortHigh), string(aichat.EffortLow), string(aichat.EffortMedium)} + 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). +func TestAIFilters_ModelSuggestsBareNames(t *testing.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 18ad643..3a24a5f 100644 --- a/pkg/cli/ai_models.go +++ b/pkg/cli/ai_models.go @@ -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_test.go b/pkg/cli/ai_test.go index 73e8569..edb847d 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -99,7 +99,7 @@ func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) { opts.Bare = true opts.MaxTokens = 1024 opts.Temperature = "0.5" - opts.ReasoningEffort = "high" + opts.Effort = "high" opts.MaxTurns = 7 opts.Resume = "sess-123" @@ -158,8 +158,12 @@ func TestAIRuntimeOptions_ToRequest_ValidationErrors(t *testing.T) { 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 reasoning-effort", func(o *AIPromptOptions) { o.ReasoningEffort = "max" }, "reasoning-effort"}, + {"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"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -220,11 +224,11 @@ func TestAIRuntimeOptions_ToRequest_MaxTokensPrecedence(t *testing.T) { // 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 -// reasoning-effort flag should override the saved value. +// --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{ReasoningEffort: "high"} // flag overrides saved low + opts := AIRuntimeOptions{Effort: "high"} // flag overrides saved low req, err := opts.ToRequest("sys", "", "user") if err != nil { t.Fatalf("ToRequest: %v", err) diff --git a/pkg/cli/configure.go b/pkg/cli/configure.go index a55fc4f..bec23d1 100644 --- a/pkg/cli/configure.go +++ b/pkg/cli/configure.go @@ -213,12 +213,18 @@ func backendOptions() []huh.Option[string] { } } -// 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() @@ -229,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) { @@ -237,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, ai.BackendClaudeAgent: + 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/info.go b/pkg/cli/info.go index 340921c..797ebd1 100644 --- a/pkg/cli/info.go +++ b/pkg/cli/info.go @@ -467,18 +467,12 @@ func codexSessionMatchesProject(uses []history.ToolUse, projectRoot string) bool if projectRoot == "" { return true } - rootAbs, err := filepath.Abs(projectRoot) - if err != nil { - rootAbs = projectRoot - } + rootAbs := canonicalPath(projectRoot) for _, u := range uses { if u.CWD == "" { continue } - cwdAbs, err := filepath.Abs(u.CWD) - if err != nil { - cwdAbs = u.CWD - } + cwdAbs := canonicalPath(u.CWD) if cwdAbs == rootAbs { return true } @@ -490,6 +484,17 @@ func codexSessionMatchesProject(uses []history.ToolUse, projectRoot string) bool return false } +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/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/webapp/src/App.tsx b/pkg/cli/webapp/src/App.tsx index 831047b..ed07676 100644 --- a/pkg/cli/webapp/src/App.tsx +++ b/pkg/cli/webapp/src/App.tsx @@ -1,6 +1,7 @@ -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { + AppShell, Button, DensitySwitcher, ThemeSwitcher, @@ -15,6 +16,7 @@ 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( @@ -31,35 +33,18 @@ export function App() { -
-
- - - -
- - -
- -
+ {route.kind === "sessions" ? ( + } + actions={} + /> + ) : ( + {route.kind === "operations" ? (
)} - - - + + )} +
); } +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; 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/whoami.go b/pkg/cli/whoami.go index 9d784eb..d035130 100644 --- a/pkg/cli/whoami.go +++ b/pkg/cli/whoami.go @@ -163,21 +163,6 @@ func maskKey(s string) string { return s[:4] + "…" + s[len(s)-4:] } -// parentBackend maps a CLI backend onto the API backend whose /v1/models -// endpoint and key it shares; API backends are their own parent. -func parentBackend(b ai.Backend) ai.Backend { - switch b { - case ai.BackendCodexCLI: - return ai.BackendOpenAI - case ai.BackendClaudeCLI, ai.BackendClaudeAgent: - return ai.BackendAnthropic - case ai.BackendGeminiCLI: - return ai.BackendGemini - default: - return b - } -} - func firstEnv(vars []string, getenv func(string) string) string { for _, v := range vars { if val := getenv(v); strings.TrimSpace(val) != "" { @@ -201,7 +186,7 @@ func RunWhoami(opts WhoamiOptions) (any, error) { var models map[ai.Backend]modelFetch if opts.Models { - models = fetchParentModels(backends, probe.getenv) + models = fetchAPIModels(backends, probe.getenv) } result := WhoamiResult{sampleLimit: opts.Limit, showModels: opts.Models} @@ -220,46 +205,52 @@ type modelFetch struct { err error } -// fetchParentModels hits each distinct parent provider's models endpoint once, -// concurrently, so claude-cli and claude-agent don't both re-query Anthropic. -// A parent with no API key in the environment is skipped entirely (the listing -// endpoint requires the key even when the CLI is logged in via OAuth). -func fetchParentModels(backends []ai.Backend, getenv func(string) string) map[ai.Backend]modelFetch { - parents := map[ai.Backend]bool{} +// 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 { - p := parentBackend(b) - if firstEnv(ai.AuthEnvVars(p), getenv) != "" { - parents[p] = true + if b.Kind() == "api" && firstEnv(ai.AuthEnvVars(b), getenv) != "" { + apis[b] = true } } - out := make(map[ai.Backend]modelFetch, len(parents)) + out := make(map[ai.Backend]modelFetch, len(apis)) var mu sync.Mutex var wg sync.WaitGroup - for p := range parents { + for b := range apis { wg.Add(1) - go func(parent ai.Backend) { + go func(backend ai.Backend) { defer wg.Done() - m, err := ai.ListModels(context.Background(), parent) + m, err := ai.ListModels(context.Background(), backend) mu.Lock() - out[parent] = modelFetch{models: m, err: err} + out[backend] = modelFetch{models: m, err: err} mu.Unlock() - }(p) + }(b) } wg.Wait() return out } // applyModels fills in the model listing (or the reason it is unavailable) for a -// single adapter from the pre-fetched per-parent results. +// 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[parentBackend(b)] + fetch, ok := cache[b] if !ok { return } @@ -267,10 +258,14 @@ func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetc st.ModelError = fetch.err.Error() return } + setModels(st, fetch.models) +} - st.ModelCount = len(fetch.models) - ids := make([]string, 0, len(fetch.models)) - for _, m := range 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 From 2f57ce010de9604b082d55befb1e9ec4d0c2f4ff Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 12:00:59 +0300 Subject: [PATCH 04/22] refactor: restructure ai.Request and ai.Config to use nested types --- pkg/ai/provider/claudeagent/provider.go | 24 ++++++------ pkg/ai/provider/claudeagent/provider_test.go | 41 ++++++++++---------- pkg/ai/provider/claudeagent/turn.go | 4 +- 3 files changed, 35 insertions(+), 34 deletions(-) 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 e5ca6b3..2d5eec2 100644 --- a/pkg/ai/provider/claudeagent/provider_test.go +++ b/pkg/ai/provider/claudeagent/provider_test.go @@ -10,6 +10,7 @@ import ( "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" @@ -140,7 +141,7 @@ func withFakeAgentProcessEnv(t *testing.T, env map[string]string) { 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() }) @@ -150,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 @@ -176,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) @@ -193,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() }) @@ -202,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 { @@ -222,25 +223,23 @@ func TestProvider_MultiTurnSerialized(t *testing.T) { func TestProvider_CanUseTool(t *testing.T) { withFakeAgentProcessEnv(t, map[string]string{fakeServerEnv: "1", fakeModeEnv: "approval"}) - p, err := New(ai.Config{Model: "claude-agent-sonnet"}) + 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() - var gotReq ai.PermissionRequest - called := make(chan struct{}, 1) - req := ai.Request{ - Prompt: "use a tool", - 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 - }, - } - - events, err := p.ExecuteStream(ctx, req) + events, err := p.ExecuteStream(ctx, ai.Request{Prompt: api.Prompt{User: "use a tool"}}) require.NoError(t, err) var sawPermission bool @@ -292,14 +291,14 @@ func TestProvider_InterruptNoKill(t *testing.T) { fakeMarkerEnv: marker, }) - 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.WithCancel(context.Background()) defer cancel() - 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 sawError bool 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() { From 89b338ea10e431373545e259ac9e2869a2578a40 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 12:01:15 +0300 Subject: [PATCH 05/22] refactor: restructure ai.Request and ai.Config with nested field groups --- pkg/ai/provider/codex_appserver.go | 2 +- pkg/ai/provider/codex_appserver_protocol.go | 37 ++++----- pkg/ai/provider/codex_appserver_test.go | 78 +++++++++++++------ pkg/ai/provider/gemini_cli.go | 2 +- pkg/ai/provider/genkit/genkit.go | 32 ++++---- pkg/ai/provider/genkit/genkit_test.go | 13 ++-- pkg/ai/provider/genkit/options.go | 84 ++++++++------------- pkg/ai/provider/init.go | 4 +- 8 files changed, 133 insertions(+), 119 deletions(-) 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..1bd928d 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) 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..85934ec 100644 --- a/pkg/ai/provider/init.go +++ b/pkg/ai/provider/init.go @@ -18,8 +18,8 @@ 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) }) // 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 }) } From ee9ce0d8c397616875475d9dab86cae6a68a54bd Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 12:01:39 +0300 Subject: [PATCH 06/22] refactor: restructure ai.Request to use nested api types --- .gitignore | 1 + pkg/ai/agent.go | 17 ++--- pkg/ai/agent/runner.go | 2 +- pkg/ai/agent/runner_test.go | 5 +- pkg/ai/agent_test.go | 11 ++-- pkg/ai/client.go | 8 +-- pkg/ai/loop_test.go | 14 ++-- pkg/ai/middleware/caching.go | 9 +-- pkg/ai/middleware/logging.go | 14 ++-- pkg/ai/middleware/logging_test.go | 9 ++- pkg/ai/prompt/prompt.go | 34 +++++----- pkg/ai/prompt/prompt_test.go | 24 +++---- pkg/ai/typed.go | 2 +- pkg/ai/types.go | 79 +++++++---------------- pkg/api/model.go | 9 +++ pkg/api/permissions.go | 10 ++- pkg/cli/ai.go | 78 ++++++++++++---------- pkg/cli/ai_agent.go | 6 +- pkg/cli/ai_filters.go | 20 ++++-- pkg/cli/ai_filters_test.go | 7 +- pkg/cli/ai_test.go | 103 +++++++++++++++--------------- 21 files changed, 243 insertions(+), 219 deletions(-) diff --git a/.gitignore b/.gitignore index e15bcb2..f683126 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ pkg/cli/webapp/*.tsbuildinfo !pkg/cli/webapp/dist/ !pkg/cli/webapp/dist/** dist/ +.grite/ diff --git a/pkg/ai/agent.go b/pkg/ai/agent.go index 89ed88f..4eb3272 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 / @@ -75,14 +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, - Source: req.Source, - }) + 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) @@ -159,7 +160,7 @@ 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, diff --git a/pkg/ai/agent/runner.go b/pkg/ai/agent/runner.go index da61dd9..8b99231 100644 --- a/pkg/ai/agent/runner.go +++ b/pkg/ai/agent/runner.go @@ -212,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_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/client.go b/pkg/ai/client.go index d820816..eb4459e 100644 --- a/pkg/ai/client.go +++ b/pkg/ai/client.go @@ -14,20 +14,20 @@ func RegisterProvider(backend Backend, factory ProviderFactory) { } func NewProvider(cfg Config) (Provider, error) { - backend := cfg.Backend + backend := cfg.Model.Backend - if cfg.Model == "" { + 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) + backend, err = InferBackend(cfg.Model.Name) if err != nil { return nil, err } } - cfg.Backend = backend + cfg.Model.Backend = backend factory, ok := factories[backend] if !ok { 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/logging.go b/pkg/ai/middleware/logging.go index cffe0e8..c398fdc 100644 --- a/pkg/ai/middleware/logging.go +++ b/pkg/ai/middleware/logging.go @@ -31,11 +31,11 @@ func (l *loggingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp t := clicky.Text(""). Add(icons.AI). Append(fmt.Sprintf(" %s/%s", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium") - if req.Source != "" { - t = t.Append(fmt.Sprintf(" [%s]", req.Source), "text-gray-500") + if req.Prompt.Source != "" { + t = t.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") } - t = t.NewLine().Append(req.Prompt, "text-gray-600") - if s := schemaInJSON(req.StructuredOutput); s != "" { + 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) @@ -90,10 +90,10 @@ func (l *loggingProvider) ExecuteStream(ctx context.Context, req ai.Request) (<- t := clicky.Text(""). Add(icons.AI). Append(fmt.Sprintf(" %s/%s (stream)", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium") - if req.Source != "" { - t = t.Append(fmt.Sprintf(" [%s]", req.Source), "text-gray-500") + if req.Prompt.Source != "" { + t = t.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") } - log.Debugf("%v", t.NewLine().Append(req.Prompt, "text-gray-600")) + log.Debugf("%v", t.NewLine().Append(req.Prompt.User, "text-gray-600")) } return streamer.ExecuteStream(ctx, req) diff --git a/pkg/ai/middleware/logging_test.go b/pkg/ai/middleware/logging_test.go index f0e87a3..fc31237 100644 --- a/pkg/ai/middleware/logging_test.go +++ b/pkg/ai/middleware/logging_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/commons/logger" ) @@ -76,9 +77,11 @@ func TestLoggingProvider_EmitsSourceAndSchemas(t *testing.T) { } if _, err := p.Execute(context.Background(), ai.Request{ - Prompt: "rendered input prompt", - Source: "ai-commit-message.prompt", - StructuredOutput: &sampleOut{}, + Prompt: api.Prompt{ + User: "rendered input prompt", + Source: "ai-commit-message.prompt", + Schema: &sampleOut{}, + }, }); err != nil { t.Fatalf("Execute: %v", err) } diff --git a/pkg/ai/prompt/prompt.go b/pkg/ai/prompt/prompt.go index 118aeca..86af43f 100644 --- a/pkg/ai/prompt/prompt.go +++ b/pkg/ai/prompt/prompt.go @@ -4,9 +4,9 @@ // {{role "user"}} markers split the rendered output into messages. // // 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,6 +17,7 @@ import ( "strings" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" dp "github.com/google/dotprompt/go/dotprompt" ) @@ -56,7 +57,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,20 +74,20 @@ 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")), - Source: t.name, - } - cfg := ai.Config{Model: rendered.Model} + req := ai.Request{Prompt: api.Prompt{ + System: strings.TrimSpace(strings.Join(system, "\n")), + User: strings.TrimSpace(strings.Join(user, "\n")), + Source: t.name, + }} + cfg := ai.Config{Model: api.Model{Name: rendered.Model}} if rendered.Model != "" { if b, berr := ai.InferBackend(rendered.Model); berr == nil { - cfg.Backend = b + cfg.Model.Backend = b } } applyModelConfig(rendered.Config, &req, &cfg) if out != nil { - req.StructuredOutput = out + req.Prompt.Schema = out } return req, cfg, nil } @@ -111,15 +112,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..71856f1 100644 --- a/pkg/ai/prompt/prompt_test.go +++ b/pkg/ai/prompt/prompt_test.go @@ -19,14 +19,16 @@ 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.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.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.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) } func TestRender_StructuredOutputTarget(t *testing.T) { @@ -38,13 +40,13 @@ 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") } 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 875a864..0dc3c41 100644 --- a/pkg/ai/types.go +++ b/pkg/ai/types.go @@ -7,57 +7,14 @@ import ( "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 - - // Source identifies the prompt's origin (e.g. the .prompt filename), purely - // for diagnostics — the logging middleware prints it so callers can tell which - // template produced a request. Empty means unknown. - Source string - - // 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") - 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:"-"` -} +// 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 // 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 @@ -143,18 +100,26 @@ type Event struct { type Cost = api.Cost type Costs = api.Costs +// Config is the provider construction/runtime config. Model (name/backend/temp/ +// effort) and Budget (cost ceiling, max tokens) come from api; the rest are +// transport/runtime concerns that never belong in the serializable Spec. type Config struct { - Model string - Backend Backend // empty = infer from model - APIKey string // empty = env lookup + Model api.Model // Name, ID, Backend (empty = infer), Temperature, Effort + Budget api.Budget // Cost (USD ceiling, 0 = unlimited), MaxTokens + 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 + + // 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/model.go b/pkg/api/model.go index 386a87c..9230f6d 100644 --- a/pkg/api/model.go +++ b/pkg/api/model.go @@ -36,6 +36,15 @@ func (m Model) ResolveBackend() (Backend, error) { 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 == "" { diff --git a/pkg/api/permissions.go b/pkg/api/permissions.go index 6d8daba..24742d2 100644 --- a/pkg/api/permissions.go +++ b/pkg/api/permissions.go @@ -1,6 +1,9 @@ package api -import "fmt" +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 @@ -34,6 +37,11 @@ type MCP struct { 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() { diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index c95aa24..4b24d5d 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -10,10 +10,10 @@ import ( "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" - "github.com/flanksource/clicky/aichat" ) // loadSavedAI returns the saved AI defaults from ~/.captain.yaml. Errors are @@ -72,11 +72,10 @@ func (o AIProviderOptions) ToConfig() (ai.Config, error) { } return ai.Config{ - Model: model, - Backend: ai.Backend(backend), - APIKey: o.APIKey, - NoCache: o.NoCache || saved.NoCache, - BudgetUSD: budget, + Model: api.Model{Name: model, Backend: ai.Backend(backend)}, + Budget: api.Budget{Cost: budget}, + APIKey: o.APIKey, + NoCache: o.NoCache || saved.NoCache, }, nil } @@ -95,7 +94,7 @@ type AIRuntimeOptions struct { 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 (codex/genkit; others ignore)"` + 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)"` @@ -190,31 +189,44 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt if effort == "" { effort = saved.ReasoningEffort } - if err := aichat.ValidateEffort(aichat.Effort(effort)); err != nil { - return ai.Request{}, fmt.Errorf("invalid --effort %q (valid: low|medium|high): %w", effort, err) + if err := api.Effort(effort).Validate(); err != nil { + return ai.Request{}, fmt.Errorf("invalid --effort %q: %w", effort, err) + } + + // 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{ - SystemPrompt: systemPrompt, - AppendSystemPrompt: appendSystemPrompt, - Prompt: userPrompt, - MaxTokens: maxTokens, - Temperature: temperature, - ReasoningEffort: effort, - MaxTurns: o.MaxTurns, - SessionID: o.Resume, - PermissionMode: o.PermissionMode, - Edit: o.Edit, - AllowedTools: o.AllowedTools, - DisallowedTools: o.DisallowedTools, - NoMCP: o.NoMCP || saved.NoMCP, - NoHooks: o.NoHooks || saved.NoHooks, - NoSkills: o.NoSkills || saved.NoSkills, - SkillDirs: o.SkillDirs, - NoUser: o.NoUser || saved.NoUser, - NoProject: o.NoProject || saved.NoProject, - NoMemory: o.NoMemory || saved.NoMemory, - Bare: o.Bare, + 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 } @@ -233,7 +245,7 @@ func RunAIPrompt(opts AIPromptOptions) (any, error) { if err != nil { return nil, err } - if cfg.Model == "" { + if cfg.Model.Name == "" { return nil, fmt.Errorf("no model: pass --model or run 'captain configure' to set a default") } @@ -493,7 +505,7 @@ func RunAITest(opts AITestOptions) (any, error) { if err != nil { return nil, err } - if cfg.Model == "" { + 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) @@ -513,8 +525,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 index b01cb98..8641aaa 100644 --- a/pkg/cli/ai_agent.go +++ b/pkg/cli/ai_agent.go @@ -116,7 +116,7 @@ func RunAIAgent(opts AIAgentOptions) (any, error) { if err != nil { return nil, err } - if cfg.Model == "" { + 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) @@ -139,7 +139,7 @@ func RunAIAgent(opts AIAgentOptions) (any, error) { } sp, ok := p.(ai.StreamingProvider) if !ok { - return nil, fmt.Errorf("backend %s does not support the streaming agent loop", cfg.Backend) + return nil, fmt.Errorf("backend %s does not support the streaming agent loop", cfg.Model.Backend) } plugins, wt, err := buildAgentPlugins(opts, p) @@ -163,7 +163,7 @@ func RunAIAgent(opts AIAgentOptions) (any, error) { Build: func(_ *agent.RunContext, _ int, _ *ai.LoopIteration, feedback string) ai.Request { req := baseReq if feedback != "" { - req.Prompt = opts.Prompt + "\n\n[verifier feedback]\n" + feedback + "\n\nFix the issues above and continue." + req.Prompt.User = opts.Prompt + "\n\n[verifier feedback]\n" + feedback + "\n\nFix the issues above and continue." } return req }, diff --git a/pkg/cli/ai_filters.go b/pkg/cli/ai_filters.go index 88d0379..3644cc3 100644 --- a/pkg/cli/ai_filters.go +++ b/pkg/cli/ai_filters.go @@ -5,6 +5,7 @@ import ( "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" @@ -39,14 +40,21 @@ func aiFilters[T any]() []entity.Filter[T] { } } -// effortOptions sources the reasoning-effort values from clicky/aichat's shared -// Effort enum (the same source ToRequest validates against via ValidateEffort). +// 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 { - return map[string]api.Textable{ - string(aichat.EffortLow): api.Text{Content: "Low"}, - string(aichat.EffortMedium): api.Text{Content: "Medium"}, - string(aichat.EffortHigh): api.Text{Content: "High"}, + 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 diff --git a/pkg/cli/ai_filters_test.go b/pkg/cli/ai_filters_test.go index b08937c..75d2af3 100644 --- a/pkg/cli/ai_filters_test.go +++ b/pkg/cli/ai_filters_test.go @@ -6,7 +6,7 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/ai/agent" - "github.com/flanksource/clicky/aichat" + capapi "github.com/flanksource/captain/pkg/api" ) // optionKeys returns the sorted option keys of the aiFilters filter with the @@ -30,7 +30,10 @@ func optionKeys(t *testing.T, key string) []string { } func TestAIFilters_EffortFromSharedEnum(t *testing.T) { - want := []string{string(aichat.EffortHigh), string(aichat.EffortLow), string(aichat.EffortMedium)} + 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) diff --git a/pkg/cli/ai_test.go b/pkg/cli/ai_test.go index edb847d..a624ff4 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" ) @@ -38,16 +39,16 @@ func TestAIPromptOptions_ToRequest_Defaults(t *testing.T) { 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.MaxTokens != 4096 { - t.Errorf("MaxTokens = %d, want 4096 built-in default", req.MaxTokens) + 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) @@ -62,14 +63,14 @@ func TestAIPromptOptions_ToRequest_NegativeFlags(t *testing.T) { cases := []struct { name string mutate func(*AIPromptOptions) - field string + get func(ai.Request) bool }{ - {"no-mcp", func(o *AIPromptOptions) { o.NoMCP = true }, "NoMCP"}, - {"no-hooks", func(o *AIPromptOptions) { o.NoHooks = true }, "NoHooks"}, - {"no-skills", func(o *AIPromptOptions) { o.NoSkills = true }, "NoSkills"}, - {"no-user", func(o *AIPromptOptions) { o.NoUser = true }, "NoUser"}, - {"no-project", func(o *AIPromptOptions) { o.NoProject = true }, "NoProject"}, - {"no-memory", func(o *AIPromptOptions) { o.NoMemory = 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) { @@ -79,8 +80,8 @@ func TestAIPromptOptions_ToRequest_NegativeFlags(t *testing.T) { if err != nil { t.Fatalf("ToRequest: %v", err) } - if !reflect.ValueOf(req).FieldByName(tc.field).Bool() { - t.Errorf("%s = false, want true after --%s", tc.field, tc.name) + if !tc.get(req) { + t.Errorf("flag --%s did not set the mapped request field", tc.name) } }) } @@ -107,38 +108,38 @@ func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) { if err != nil { t.Fatalf("ToRequest: %v", err) } - if req.SystemPrompt != "be careful" { - t.Errorf("SystemPrompt = %q", req.SystemPrompt) + if req.Prompt.System != "be careful" { + t.Errorf("SystemPrompt = %q", req.Prompt.System) } - if req.AppendSystemPrompt != "also be brief" { - t.Errorf("AppendSystemPrompt = %q", req.AppendSystemPrompt) + if req.Prompt.AppendSystem != "also be brief" { + t.Errorf("AppendSystemPrompt = %q", req.Prompt.AppendSystem) } - if req.PermissionMode != "acceptEdits" { - t.Errorf("PermissionMode = %q", req.PermissionMode) + if req.Permissions.Mode != api.PermissionAcceptEdits { + t.Errorf("PermissionMode = %q", req.Permissions.Mode) } - if !req.Edit { - t.Error("Edit not propagated") + if !req.Permissions.HasPreset(api.PresetEdit) { + t.Error("Edit preset not propagated") } - if !reflect.DeepEqual(req.AllowedTools, []string{"Read", "Bash"}) { - t.Errorf("AllowedTools = %v", req.AllowedTools) + if !reflect.DeepEqual(req.Permissions.Tools.Allow, []string{"Read", "Bash"}) { + t.Errorf("AllowedTools = %v", req.Permissions.Tools.Allow) } - if !reflect.DeepEqual(req.DisallowedTools, []string{"Write"}) { - t.Errorf("DisallowedTools = %v", req.DisallowedTools) + if !reflect.DeepEqual(req.Permissions.Tools.Deny, []string{"Write"}) { + t.Errorf("DisallowedTools = %v", req.Permissions.Tools.Deny) } - if !reflect.DeepEqual(req.SkillDirs, []string{"/skills/a", "/skills/b"}) { - t.Errorf("SkillDirs = %v", req.SkillDirs) + if !reflect.DeepEqual(req.Memory.Skills, []string{"/skills/a", "/skills/b"}) { + t.Errorf("SkillDirs = %v", req.Memory.Skills) } - if !req.Bare { + 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 req.Temperature != 0.5 { - t.Errorf("Temperature = %v", req.Temperature) + if temp, ok := req.Temp(); !ok || temp != 0.5 { + t.Errorf("Temperature = %v (set=%v)", temp, ok) } - if req.ReasoningEffort != "high" { - t.Errorf("ReasoningEffort = %q", req.ReasoningEffort) + if req.Effort != api.EffortHigh { + t.Errorf("ReasoningEffort = %q", req.Effort) } if req.MaxTurns != 7 { t.Errorf("MaxTurns = %d", req.MaxTurns) @@ -195,8 +196,8 @@ func TestAIRuntimeOptions_ToRequest_MaxTokensPrecedence(t *testing.T) { if err != nil { t.Fatal(err) } - if req.MaxTokens != 2048 { - t.Errorf("MaxTokens = %d, want 2048 (explicit flag)", req.MaxTokens) + 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) { @@ -205,8 +206,8 @@ func TestAIRuntimeOptions_ToRequest_MaxTokensPrecedence(t *testing.T) { if err != nil { t.Fatal(err) } - if req.MaxTokens != 16000 { - t.Errorf("MaxTokens = %d, want 16000 (saved)", req.MaxTokens) + 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) { @@ -215,8 +216,8 @@ func TestAIRuntimeOptions_ToRequest_MaxTokensPrecedence(t *testing.T) { if err != nil { t.Fatal(err) } - if req.MaxTokens != 4096 { - t.Errorf("MaxTokens = %d, want 4096 (built-in)", req.MaxTokens) + if req.Budget.MaxTokens != 4096 { + t.Errorf("MaxTokens = %d, want 4096 (built-in)", req.Budget.MaxTokens) } }) } @@ -234,18 +235,18 @@ func TestAIRuntimeOptions_ToRequest_OverlaysSaved(t *testing.T) { t.Fatalf("ToRequest: %v", err) } - if req.SystemPrompt != "sys" || req.Prompt != "user" { - t.Errorf("prompt fields = %q/%q, want sys/user", req.SystemPrompt, 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 (flag overrides saved)", 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) From a3c67e3a13286892f53be89ee0ae0ca7610ff979 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 21:28:42 +0300 Subject: [PATCH 07/22] feat(prompt): support spec-native frontmatter in .prompt files Promote google/uuid to a direct dependency to support the new spec frontmatter parsing feature. Add decodeSpecFrontmatter function that re-encodes the raw dotprompt frontmatter map as YAML and unmarshals it into ai.Request, allowing spec-native keys (permissions, memory, budget, context, maxTurns) to be declared directly in .prompt files. The dotprompt config block remains canonical for the three knobs it owns (maxOutputTokens, temperature, reasoning). Extend test coverage with new fixtures for all supported backends (Anthropic, ClaudeAgent, ClaudeCLI, CodexCLI, Gemini, GeminiCLI, OpenAI) and a dedicated spec frontmatter test. --- go.mod | 2 +- pkg/ai/prompt/prompt.go | 63 ++++++++-- pkg/ai/prompt/prompt_test.go | 113 +++++++++++++++++- .../fixtures/anthropic-claude-opus.prompt | 16 +++ .../fixtures/anthropic-claude-sonnet.prompt | 15 +++ .../fixtures/claude-agent-opus.prompt | 24 ++++ .../fixtures/claude-agent-sonnet.prompt | 24 ++++ .../testdata/fixtures/claude-cli-opus.prompt | 23 ++++ .../fixtures/claude-cli-sonnet.prompt | 22 ++++ .../prompt/testdata/fixtures/codex-cli.prompt | 20 ++++ .../testdata/fixtures/gemini-api.prompt | 16 +++ .../testdata/fixtures/gemini-cli.prompt | 10 ++ .../testdata/fixtures/openai-gpt.prompt | 16 +++ pkg/ai/prompt/testdata/options.prompt | 26 ++++ 14 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 pkg/ai/prompt/testdata/fixtures/anthropic-claude-opus.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/anthropic-claude-sonnet.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/claude-agent-opus.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/claude-agent-sonnet.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/claude-cli-opus.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/claude-cli-sonnet.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/codex-cli.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/gemini-api.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/gemini-cli.prompt create mode 100644 pkg/ai/prompt/testdata/fixtures/openai-gpt.prompt create mode 100644 pkg/ai/prompt/testdata/options.prompt diff --git a/go.mod b/go.mod index bb7a2ba..182919e 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/flanksource/commons v1.51.3 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 @@ -105,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 diff --git a/pkg/ai/prompt/prompt.go b/pkg/ai/prompt/prompt.go index 86af43f..398c139 100644 --- a/pkg/ai/prompt/prompt.go +++ b/pkg/ai/prompt/prompt.go @@ -1,7 +1,15 @@ // 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.Prompt.Schema — captain's providers derive the JSON @@ -19,6 +27,7 @@ import ( "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. @@ -74,24 +83,56 @@ func (t *Template) Render(data map[string]any, out any) (ai.Request, ai.Config, } } - req := ai.Request{Prompt: api.Prompt{ - System: strings.TrimSpace(strings.Join(system, "\n")), - User: strings.TrimSpace(strings.Join(user, "\n")), - Source: t.name, - }} - cfg := ai.Config{Model: api.Model{Name: rendered.Model}} - if rendered.Model != "" { - if b, berr := ai.InferBackend(rendered.Model); berr == nil { + // 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 + } + 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.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 } diff --git a/pkg/ai/prompt/prompt_test.go b/pkg/ai/prompt/prompt_test.go index 71856f1..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) { @@ -31,6 +35,37 @@ func TestRender_FrontmatterAndMessages(t *testing.T) { 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) { type commitMsg struct { Type string `json:"type"` @@ -50,3 +85,79 @@ func TestLibrary_Render(t *testing.T) { 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}} From 5a93ec4bbfd103d4b8e4f4a002879acac0f18775 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 21:29:03 +0300 Subject: [PATCH 08/22] feat(cmux): add interactive claude/codex TUI provider with session tailing --- pkg/ai/provider/cli.go | 5 +- pkg/ai/provider/cli_test.go | 74 ++- pkg/ai/provider/cmux/client.go | 581 ++++++++++++++++++++++ pkg/ai/provider/cmux/client_test.go | 224 +++++++++ pkg/ai/provider/cmux/executor.go | 514 +++++++++++++++++++ pkg/ai/provider/cmux/executor_test.go | 117 +++++ pkg/ai/provider/cmux/focus.go | 35 ++ pkg/ai/provider/cmux/provider.go | 351 +++++++++++++ pkg/ai/provider/cmux/provider_test.go | 92 ++++ pkg/ai/provider/cmux/sessionlog.go | 283 +++++++++++ pkg/ai/provider/cmux/sessionlog_test.go | 167 +++++++ pkg/ai/provider/cmux/sessionstats.go | 569 +++++++++++++++++++++ pkg/ai/provider/cmux/sessionstats_test.go | 369 ++++++++++++++ pkg/ai/provider/cmux/stall.go | 321 ++++++++++++ pkg/ai/provider/cmux/stall_test.go | 343 +++++++++++++ pkg/ai/provider/cmux/submit.go | 199 ++++++++ pkg/ai/provider/cmux/submit_test.go | 185 +++++++ pkg/ai/provider/cmux/support_test.go | 54 ++ pkg/ai/provider/gemini_cli.go | 2 +- pkg/ai/provider/init.go | 7 + 20 files changed, 4489 insertions(+), 3 deletions(-) create mode 100644 pkg/ai/provider/cmux/client.go create mode 100644 pkg/ai/provider/cmux/client_test.go create mode 100644 pkg/ai/provider/cmux/executor.go create mode 100644 pkg/ai/provider/cmux/executor_test.go create mode 100644 pkg/ai/provider/cmux/focus.go create mode 100644 pkg/ai/provider/cmux/provider.go create mode 100644 pkg/ai/provider/cmux/provider_test.go create mode 100644 pkg/ai/provider/cmux/sessionlog.go create mode 100644 pkg/ai/provider/cmux/sessionlog_test.go create mode 100644 pkg/ai/provider/cmux/sessionstats.go create mode 100644 pkg/ai/provider/cmux/sessionstats_test.go create mode 100644 pkg/ai/provider/cmux/stall.go create mode 100644 pkg/ai/provider/cmux/stall_test.go create mode 100644 pkg/ai/provider/cmux/submit.go create mode 100644 pkg/ai/provider/cmux/submit_test.go create mode 100644 pkg/ai/provider/cmux/support_test.go diff --git a/pkg/ai/provider/cli.go b/pkg/ai/provider/cli.go index fcbcd8a..379f561 100644 --- a/pkg/ai/provider/cli.go +++ b/pkg/ai/provider/cli.go @@ -73,8 +73,11 @@ 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 + } stdin, err := cmd.StdinPipe() if err != 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..1927ffb --- /dev/null +++ b/pkg/ai/provider/cmux/executor.go @@ -0,0 +1,514 @@ +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 +} + +// maxInlinePromptBytes bounds how much of the prompt is dispatched directly to +// the agent surface. Prompts at or below this are inlined in full; larger ones +// are truncated to this size with a pointer to the prompt file for the rest. +const maxInlinePromptBytes = 10 * 1024 + +// buildInstruction renders the message dispatched to the agent surface from the +// host-assembled prompt body. Small prompts are pasted verbatim; large ones are +// truncated and the full text is written to /.gavel/cmux/prompt-*.md +// with a pointer appended. +func (r *run) buildInstruction(workDir, sessionID, prompt string) (string, error) { + body, truncated := truncatePrompt(prompt, maxInlinePromptBytes) + if !truncated { + return body, nil + } + path, err := writePromptFile(workDir, sessionID, prompt) + if err != nil { + return "", err + } + return body + fmt.Sprintf("\n\n... (prompt truncated — read %s for the full prompt)", path), nil +} + +// writePromptFile persists the full prompt body so the truncated 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 +} + +// truncatePrompt clamps prompt to max bytes, cutting on the last line boundary so +// the inlined body never ends mid-line. The bool reports whether truncation happened. +func truncatePrompt(prompt string, max int) (string, bool) { + prompt = strings.TrimSpace(prompt) + if len(prompt) <= max { + return prompt, false + } + clipped := prompt[:max] + if idx := strings.LastIndexByte(clipped, '\n'); idx > 0 { + clipped = clipped[:idx] + } + return strings.TrimRight(clipped, "\n"), true +} + +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..9b9619a --- /dev/null +++ b/pkg/ai/provider/cmux/executor_test.go @@ -0,0 +1,117 @@ +package cmux + +import ( + "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 TestTruncatePrompt(t *testing.T) { + if body, truncated := truncatePrompt("short", 100); truncated || body != "short" { + t.Fatalf("truncatePrompt(short) = (%q, %v), want (short, false)", body, truncated) + } + long := strings.Repeat("line\n", 100) + body, truncated := truncatePrompt(long, 20) + if !truncated { + t.Fatal("truncatePrompt(long) truncated = false, want true") + } + if len(body) > 20 { + t.Fatalf("truncated body = %d bytes, want <= 20", len(body)) + } +} 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..dd19340 --- /dev/null +++ b/pkg/ai/provider/cmux/sessionstats.go @@ -0,0 +1,569 @@ +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. +func modelInfo(model string) (pricing.ModelInfo, bool) { + if model == "" { + return pricing.ModelInfo{}, false + } + for _, id := range modelIDCandidates(model) { + if info, ok := pricing.GetModelInfo(id); ok { + return info, true + } + } + return pricing.ModelInfo{}, false +} + +// 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/gemini_cli.go b/pkg/ai/provider/gemini_cli.go index 1bd928d..2a627d2 100644 --- a/pkg/ai/provider/gemini_cli.go +++ b/pkg/ai/provider/gemini_cli.go @@ -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/init.go b/pkg/ai/provider/init.go index 85934ec..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" ) @@ -20,6 +21,12 @@ func init() { // 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.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.Name), nil }) } From 257d41666e31321757c0307e66a676b870ff7259 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 21:29:22 +0300 Subject: [PATCH 09/22] refactor: Refactor cost tracking to break out reasoning and cache token billing --- pkg/ai/agent.go | 21 ++++++----- pkg/ai/history/codex_parser.go | 55 +++++++++++++++++++++++++++++ pkg/ai/history/codex_parser_test.go | 50 ++++++++++++++++++++++++++ pkg/ai/middleware/cost.go | 18 ++++++---- pkg/ai/pricing/registry.go | 45 ++++++++++++++++------- pkg/ai/pricing/registry_test.go | 39 ++++++++++++++++++++ pkg/ai/provider.go | 2 ++ pkg/api/context.go | 4 +++ pkg/api/cost.go | 42 ++++++++++++++-------- pkg/api/cost_test.go | 36 ++++++++++++++++--- pkg/api/enums.go | 13 +++++-- 11 files changed, 277 insertions(+), 48 deletions(-) create mode 100644 pkg/ai/pricing/registry_test.go diff --git a/pkg/ai/agent.go b/pkg/ai/agent.go index 4eb3272..446c21a 100644 --- a/pkg/ai/agent.go +++ b/pkg/ai/agent.go @@ -163,18 +163,23 @@ func (a *Agent) accrue(resp *Response) Cost { 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/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/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/pricing/registry.go b/pkg/ai/pricing/registry.go index 17039d0..c2d468d 100644 --- a/pkg/ai/pricing/registry.go +++ b/pkg/ai/pricing/registry.go @@ -64,10 +64,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 +86,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/provider.go b/pkg/ai/provider.go index f4fe322..f3f9f89 100644 --- a/pkg/ai/provider.go +++ b/pkg/ai/provider.go @@ -21,6 +21,8 @@ const ( BackendCodexCLI = api.BackendCodexCLI BackendGeminiCLI = api.BackendGeminiCLI BackendClaudeAgent = api.BackendClaudeAgent + BackendClaudeCmux = api.BackendClaudeCmux + BackendCodexCmux = api.BackendCodexCmux ) // AllBackends lists every supported backend in canonical order. diff --git a/pkg/api/context.go b/pkg/api/context.go index 0f5af6a..5242bf8 100644 --- a/pkg/api/context.go +++ b/pkg/api/context.go @@ -13,6 +13,10 @@ type Context struct { 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. diff --git a/pkg/api/cost.go b/pkg/api/cost.go index 41c7cf7..981d781 100644 --- a/pkg/api/cost.go +++ b/pkg/api/cost.go @@ -18,26 +18,40 @@ func (u Usage) TotalTokens() int { // 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"` - 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"` + 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 input + output cost in USD. -func (c Cost) Total() float64 { return c.InputCost + c.OutputCost } +// 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, - TotalTokens: c.TotalTokens + other.TotalTokens, - InputCost: c.InputCost + other.InputCost, - OutputCost: c.OutputCost + other.OutputCost, + 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, } } diff --git a/pkg/api/cost_test.go b/pkg/api/cost_test.go index 619b359..f472940 100644 --- a/pkg/api/cost_test.go +++ b/pkg/api/cost_test.go @@ -10,17 +10,43 @@ func TestUsageTotalTokens(t *testing.T) { } func TestCostAddAndTotal(t *testing.T) { - a := Cost{Model: "m1", InputTokens: 100, OutputTokens: 50, TotalTokens: 150, InputCost: 0.10, OutputCost: 0.25} - b := Cost{Model: "ignored", InputTokens: 10, OutputTokens: 5, TotalTokens: 15, InputCost: 0.01, OutputCost: 0.02} + 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.TotalTokens != 165 { + 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.379999 || got > 0.380001 { - t.Errorf("Total() = %v, want 0.38", got) + if got := sum.Total(); got < 0.478999 || got > 0.479001 { + t.Errorf("Total() = %v, want 0.479", got) } } diff --git a/pkg/api/enums.go b/pkg/api/enums.go index 92dbc59..422f776 100644 --- a/pkg/api/enums.go +++ b/pkg/api/enums.go @@ -23,6 +23,15 @@ const ( 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 @@ -59,9 +68,9 @@ func (b Backend) Kind() string { // key, in priority order. CLI backends share their parent provider's key. func AuthEnvVars(b Backend) []string { switch b { - case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: + case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux: return []string{"ANTHROPIC_API_KEY"} - case BackendOpenAI, BackendCodexCLI: + case BackendOpenAI, BackendCodexCLI, BackendCodexCmux: return []string{"OPENAI_API_KEY"} case BackendGemini, BackendGeminiCLI: return []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} From 14776659656e02f1fa4df7b728858f2e7c159f2b Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 29 Jun 2026 21:29:49 +0300 Subject: [PATCH 10/22] feat: add multi-session filtering and .prompt file support to AI commands --- pkg/claude/session.go | 37 ++++++ pkg/claude/session_test.go | 20 +++ pkg/claude/tooluse.go | 42 ++++-- pkg/claude/tooluse_test.go | 5 + pkg/cli/ai.go | 132 +++++++++++++----- pkg/cli/ai_prompt_file.go | 181 +++++++++++++++++++++++++ pkg/cli/ai_prompt_file_test.go | 235 +++++++++++++++++++++++++++++++++ pkg/cli/ai_test.go | 125 ++++++++++++++++++ pkg/cli/cost.go | 20 ++- pkg/cli/history.go | 65 ++++++++- pkg/cli/info.go | 27 ++-- pkg/cli/session_filter.go | 135 +++++++++++++++++++ pkg/cli/session_filter_test.go | 113 ++++++++++++++++ pkg/cli/stdin.go | 13 +- 14 files changed, 1087 insertions(+), 63 deletions(-) create mode 100644 pkg/cli/ai_prompt_file.go create mode 100644 pkg/cli/ai_prompt_file_test.go create mode 100644 pkg/cli/session_filter.go create mode 100644 pkg/cli/session_filter_test.go 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/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 4b24d5d..61a5d25 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "io" "os" "strconv" "strings" @@ -130,21 +131,28 @@ func validatePermissionMode(s string) error { 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, overlaying @@ -237,23 +245,54 @@ func (o AIPromptOptions) ToRequest() (ai.Request, error) { } 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, err := opts.ToConfig() + tmpl, usedStdin, err := resolvePromptTemplate(opts, stdin) 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") + + data, err := parseVars(opts.Var) + if err != nil { + return nil, err + } + if s := strings.TrimSpace(stdin); s != "" && !usedStdin { + data["input"] = s } - req, err := 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 { return nil, err @@ -281,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.Model.Name) + backend := firstNonEmpty(string(resp.Backend), string(p.GetBackend()), string(req.Model.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 } @@ -302,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{ @@ -317,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 @@ -335,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.Model.Name = model + } + if backend != "" { + out.Model.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 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 a624ff4..1f5fcec 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -1,6 +1,8 @@ package cli import ( + "context" + "encoding/json" "os" "path/filepath" "reflect" @@ -12,6 +14,40 @@ import ( "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. @@ -277,6 +313,95 @@ func TestBackendHelpEnumeratesAllBackends(t *testing.T) { } } +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{ 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..6107817 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,27 @@ 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 + } + if filter.HasSessionIDFilter() && info.ID == "" { + // 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 +248,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 797ebd1..24c68f2 100644 --- a/pkg/cli/info.go +++ b/pkg/cli/info.go @@ -467,23 +467,30 @@ func codexSessionMatchesProject(uses []history.ToolUse, projectRoot string) bool if projectRoot == "" { return true } - rootAbs := canonicalPath(projectRoot) for _, u := range uses { - if u.CWD == "" { - continue - } - cwdAbs := canonicalPath(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 { 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/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 From 0b50c63b27e893a45bb008756eea1fdaa86c5291 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 06:43:53 +0300 Subject: [PATCH 11/22] refactor(cli): refactor request model field access patterns --- .gitignore | 8 +++++++- README.md | 4 ++++ pkg/cli/ai.go | 8 ++++---- pkg/cli/history.go | 7 +++---- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index f683126..2f60626 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,13 @@ 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/** -dist/ .grite/ +pkg/cli/webapp/dist/ diff --git a/README.md b/README.md index 41aefa1..125060f 100644 --- a/README.md +++ b/README.md @@ -186,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: @@ -195,6 +197,7 @@ Useful flags include: - `--dir` - `--category` - `--approved` +- `--session-id` - `--limit` - `--since` - `--all` @@ -219,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 ``` diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 61a5d25..5de1dba 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -320,8 +320,8 @@ 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.Model.Name) - backend := firstNonEmpty(string(resp.Backend), string(p.GetBackend()), string(req.Model.Backend)) + 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, @@ -405,10 +405,10 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) func resolvedPromptInput(req ai.Request, model, backend, sessionID string) ai.Request { out := req if model != "" { - out.Model.Name = model + out.Name = model } if backend != "" { - out.Model.Backend = api.Backend(backend) + out.Backend = api.Backend(backend) } if sessionID != "" { out.SessionID = sessionID diff --git a/pkg/cli/history.go b/pkg/cli/history.go index 6107817..af80148 100644 --- a/pkg/cli/history.go +++ b/pkg/cli/history.go @@ -227,10 +227,9 @@ func collectCodexHistory(cwd string, searchAll bool, filter claude.Filter) ([]hi if !searchAll && info.CWD != "" && !codexCWDMatchesProject(info.CWD, matchRoot) { continue } - if filter.HasSessionIDFilter() && info.ID == "" { - // Fall through to full parsing: older/live schemas may only expose - // the session id on later events. - } + // 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) From 437709c68d0d4a8aea15c273d2e5f8c668877ae8 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 08:30:26 +0300 Subject: [PATCH 12/22] chore(deps): bump flanksource/commons to v1.53.1 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 182919e..4b1fd75 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/firebase/genkit/go v1.8.0 github.com/flanksource/clicky v1.21.33 github.com/flanksource/clicky/aichat v1.21.33 - github.com/flanksource/commons v1.51.3 + 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 diff --git a/go.sum b/go.sum index a62fe2f..1a68543 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,8 @@ github.com/flanksource/clicky/aichat v1.21.33 h1:SquXXDXlCarx5w7yCpRGDxDDnBrH1KG github.com/flanksource/clicky/aichat v1.21.33/go.mod h1:fgCn0YNytYOFWOWHrd9JDmCFy73XG/iCNdUdgqXNoaY= 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/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= From 526166f1193b9a8875d5221a4161b0194437b9d9 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 08:33:00 +0300 Subject: [PATCH 13/22] fix(serve): embed dist placeholder so the binary builds without a prebuilt webapp The //go:embed webapp/dist directive failed to compile in CI (build, test, and lint jobs) because dist is gitignored and the webapp cannot be built in CI (it depends on a local clicky-ui link). Commit a tracked dist/.gitkeep placeholder and switch to //go:embed all:webapp/dist so the directive always matches. The real webapp overlays dist when built locally or in the release workflow. --- pkg/cli/serve.go | 6 +++++- pkg/cli/webapp/dist/.gitkeep | 0 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 pkg/cli/webapp/dist/.gitkeep diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index 20f1407..9fab916 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -24,7 +24,11 @@ import ( "github.com/spf13/cobra" ) -//go:embed webapp/dist +// 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 { diff --git a/pkg/cli/webapp/dist/.gitkeep b/pkg/cli/webapp/dist/.gitkeep new file mode 100644 index 0000000..e69de29 From 54ab1449a2cc08abcc404b19c250c4b13c8def1a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 08:55:22 +0300 Subject: [PATCH 14/22] refactor: refactor prompt handling to use file-based routing instead of inline truncation Gavel-Issue-Id: aac4545f57ee05bb834447a3dad121aa Claude-Session-Id: 327b3a33-9f27-4db0-97f2-675dbcd5c55c --- go.sum | 2 - pkg/ai/provider/cmux/executor.go | 63 ++++++++++++++------------- pkg/ai/provider/cmux/executor_test.go | 48 ++++++++++++++++---- 3 files changed, 71 insertions(+), 42 deletions(-) diff --git a/go.sum b/go.sum index 1a68543..575a2f2 100644 --- a/go.sum +++ b/go.sum @@ -151,8 +151,6 @@ github.com/flanksource/clicky v1.21.33 h1:fiZ4+sjKdtcmqGgaJiER5wM/34QSjS9pL29gQ8 github.com/flanksource/clicky v1.21.33/go.mod h1:1vjQu7ZeWqvEy98bIlZWUni6F+fy9KtWgaHCtAJs6yE= github.com/flanksource/clicky/aichat v1.21.33 h1:SquXXDXlCarx5w7yCpRGDxDDnBrH1KG1bnDcWkDnNjo= github.com/flanksource/clicky/aichat v1.21.33/go.mod h1:fgCn0YNytYOFWOWHrd9JDmCFy73XG/iCNdUdgqXNoaY= -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/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= diff --git a/pkg/ai/provider/cmux/executor.go b/pkg/ai/provider/cmux/executor.go index 1927ffb..462d098 100644 --- a/pkg/ai/provider/cmux/executor.go +++ b/pkg/ai/provider/cmux/executor.go @@ -306,30 +306,45 @@ func modelFlag(agent, model string) string { return model } -// maxInlinePromptBytes bounds how much of the prompt is dispatched directly to -// the agent surface. Prompts at or below this are inlined in full; larger ones -// are truncated to this size with a pointer to the prompt file for the rest. -const maxInlinePromptBytes = 10 * 1024 - -// buildInstruction renders the message dispatched to the agent surface from the -// host-assembled prompt body. Small prompts are pasted verbatim; large ones are -// truncated and the full text is written to /.gavel/cmux/prompt-*.md -// with a pointer appended. +// 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) { - body, truncated := truncatePrompt(prompt, maxInlinePromptBytes) - if !truncated { - return body, nil - } path, err := writePromptFile(workDir, sessionID, prompt) if err != nil { return "", err } - return body + fmt.Sprintf("\n\n... (prompt truncated — read %s for the full prompt)", path), nil + 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 truncated 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). +// 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") @@ -353,20 +368,6 @@ func writePromptFile(workDir, sessionID, prompt string) (string, error) { return path, nil } -// truncatePrompt clamps prompt to max bytes, cutting on the last line boundary so -// the inlined body never ends mid-line. The bool reports whether truncation happened. -func truncatePrompt(prompt string, max int) (string, bool) { - prompt = strings.TrimSpace(prompt) - if len(prompt) <= max { - return prompt, false - } - clipped := prompt[:max] - if idx := strings.LastIndexByte(clipped, '\n'); idx > 0 { - clipped = clipped[:idx] - } - return strings.TrimRight(clipped, "\n"), true -} - func (r *run) timeout() time.Duration { if r.cfg.Timeout > 0 { return r.cfg.Timeout diff --git a/pkg/ai/provider/cmux/executor_test.go b/pkg/ai/provider/cmux/executor_test.go index 9b9619a..549266b 100644 --- a/pkg/ai/provider/cmux/executor_test.go +++ b/pkg/ai/provider/cmux/executor_test.go @@ -1,6 +1,8 @@ package cmux import ( + "os" + "path/filepath" "strings" "testing" @@ -102,16 +104,44 @@ func TestAgentWorkspaceName(t *testing.T) { } } -func TestTruncatePrompt(t *testing.T) { - if body, truncated := truncatePrompt("short", 100); truncated || body != "short" { - t.Fatalf("truncatePrompt(short) = (%q, %v), want (short, false)", body, truncated) +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) } - long := strings.Repeat("line\n", 100) - body, truncated := truncatePrompt(long, 20) - if !truncated { - t.Fatal("truncatePrompt(long) truncated = false, want true") + + 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 len(body) > 20 { - t.Fatalf("truncated body = %d bytes, want <= 20", len(body)) + if !strings.Contains(string(data), "Do the thing across many lines.") { + t.Errorf("prompt file missing full body: %q", data) } } From b22c191d8574ddac1b44248b312ee9f6fbdfbdc7 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 09:10:55 +0300 Subject: [PATCH 15/22] feat scope: cli,claude,tools: add plan command to query session exit-plan-mode plans Gavel-Issue-Id: 55471644bcb5b00631cb9c4d70d0fe29 Claude-Session-Id: 0355fe0b-55fe-4391-b44d-c5897c93e931 --- cmd/captain/main.go | 4 + pkg/claude/history.go | 9 +- pkg/claude/plan.go | 114 ++++++++++++++++ pkg/claude/plan_test.go | 131 ++++++++++++++++++ pkg/claude/reader.go | 50 ++++++- pkg/claude/reader_test.go | 30 +++++ pkg/claude/tools/plan.go | 32 ++++- pkg/claude/tools/plan_test.go | 35 +++++ pkg/cli/plan.go | 243 ++++++++++++++++++++++++++++++++++ pkg/cli/plan_test.go | 128 ++++++++++++++++++ 10 files changed, 763 insertions(+), 13 deletions(-) create mode 100644 pkg/claude/plan.go create mode 100644 pkg/claude/plan_test.go create mode 100644 pkg/claude/tools/plan_test.go create mode 100644 pkg/cli/plan.go create mode 100644 pkg/cli/plan_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 3f34e64..512dabd 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -70,6 +70,10 @@ 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" diff --git a/pkg/claude/history.go b/pkg/claude/history.go index ed941a2..236d278 100644 --- a/pkg/claude/history.go +++ b/pkg/claude/history.go @@ -18,7 +18,14 @@ type HistoryEntry struct { Timestamp string `json:"timestamp"` IsSidechain bool `json:"isSidechain,omitempty"` AgentID string `json:"agentId,omitempty"` - RawLine json.RawMessage `json:"-"` + // 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/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/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) +} From cb58b74d562bfb1fd2c47acf9fc0bc598b9479d4 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 09:22:18 +0300 Subject: [PATCH 16/22] test: expand non-API backend test coverage and improve determinism --- pkg/ai/models_remote_test.go | 15 +++++++++++---- pkg/cli/ai_filters_test.go | 5 ++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/ai/models_remote_test.go b/pkg/ai/models_remote_test.go index 34760aa..6070534 100644 --- a/pkg/ai/models_remote_test.go +++ b/pkg/ai/models_remote_test.go @@ -147,17 +147,24 @@ func TestListModels_ErrorsOnMissingKey(t *testing.T) { } } -// TestListModels_RejectsCLIBackends pins that CLI/agent backends have no live +// 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_RejectsCLIBackends(t *testing.T) { +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} { + 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 CLI/agent backends)", b) + t.Errorf("backend=%s: expected error (no live listing for non-API backends)", b) } } } diff --git a/pkg/cli/ai_filters_test.go b/pkg/cli/ai_filters_test.go index 75d2af3..179af74 100644 --- a/pkg/cli/ai_filters_test.go +++ b/pkg/cli/ai_filters_test.go @@ -64,8 +64,11 @@ func TestAIFilters_BackendFromAllBackends(t *testing.T) { // 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). +// --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) From dac4b0df96759314832754e0af47a69791d1caf1 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 10:59:48 +0300 Subject: [PATCH 17/22] refactor(api): move AI runtime contract into pkg/api Move the provider runtime contract (Config, Provider/StreamingProvider, Response, Event/EventKind/Event*, PermissionFunc/Request/Decision, the provider registry and NewProvider) from pkg/ai into pkg/api so external consumers (clicky/aichat) can depend on the stable api surface without importing the heavier pkg/ai runtime. pkg/ai re-exports every moved symbol as a type alias / const / wrapper func, so all existing call sites and pkg/ai/provider registration compile unchanged. Additive to pkg/api (backwards compatible); the registry map stays unexported. --- pkg/ai/client.go | 50 ++++----------- pkg/ai/provider.go | 17 ++--- pkg/ai/types.go | 124 +++++++----------------------------- pkg/api/runtime_config.go | 55 ++++++++++++++++ pkg/api/runtime_event.go | 59 +++++++++++++++++ pkg/api/runtime_provider.go | 17 +++++ pkg/api/runtime_registry.go | 62 ++++++++++++++++++ 7 files changed, 234 insertions(+), 150 deletions(-) create mode 100644 pkg/api/runtime_config.go create mode 100644 pkg/api/runtime_event.go create mode 100644 pkg/api/runtime_provider.go create mode 100644 pkg/api/runtime_registry.go diff --git a/pkg/ai/client.go b/pkg/ai/client.go index eb4459e..32df732 100644 --- a/pkg/ai/client.go +++ b/pkg/ai/client.go @@ -1,51 +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.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) + return api.NewProvider(cfg) } +// GetAPIKeyFromEnv returns the first non-empty value among a backend's auth env vars. func GetAPIKeyFromEnv(backend Backend) string { - for _, envVar := range AuthEnvVars(backend) { - if key := os.Getenv(envVar); key != "" { - return key - } - } - return "" + return api.GetAPIKeyFromEnv(backend) } diff --git a/pkg/ai/provider.go b/pkg/ai/provider.go index f3f9f89..c70f2f6 100644 --- a/pkg/ai/provider.go +++ b/pkg/ai/provider.go @@ -1,8 +1,6 @@ package ai import ( - "context" - "github.com/flanksource/captain/pkg/api" ) @@ -34,16 +32,11 @@ func AuthEnvVars(b Backend) []string { return api.AuthEnvVars(b) } // BackendList renders AllBackends as a comma-separated string for help/error text. func BackendList() string { return api.BackendList() } -type Provider interface { - Execute(ctx context.Context, req Request) (*Response, error) - GetModel() string - GetBackend() Backend -} - -type StreamingProvider interface { - Provider - ExecuteStream(ctx context.Context, req Request) (<-chan Event, error) -} +// 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 // 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/types.go b/pkg/ai/types.go index 0dc3c41..3af94b6 100644 --- a/pkg/ai/types.go +++ b/pkg/ai/types.go @@ -1,9 +1,6 @@ package ai import ( - "context" - "time" - "github.com/flanksource/captain/pkg/api" ) @@ -16,110 +13,35 @@ import ( // runtime-only tool-permission broker callback lives on Config.CanUseTool. type Request = api.Spec -// 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 -} - -// Usage is an alias for the canonical api.Usage (per-call token breakdown). -type Usage = api.Usage - -type EventKind string +// 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 -} +// Usage is an alias for the canonical api.Usage (per-call token breakdown). +type Usage = api.Usage // 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 - -// Config is the provider construction/runtime config. Model (name/backend/temp/ -// effort) and Budget (cost ceiling, max tokens) come from api; the rest are -// transport/runtime concerns that never belong in the serializable Spec. -type Config struct { - Model api.Model // Name, ID, Backend (empty = infer), Temperature, Effort - Budget api.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_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 "" +} From 72e558b85d19f8e823a8d5d42d33571df594ff12 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 11:20:52 +0300 Subject: [PATCH 18/22] build(deps): pin clicky/aichat to pkg/api-based pseudo-version Bump clicky/aichat to v1.21.34-0.20260630075958-cd55f2f4fce3, the commit where aichat depends on captain/pkg/api (the relocated runtime contract) instead of pkg/ai. Resolves the CI build failure: captain (the main module) satisfies aichat's captain imports from its own pkg/api source. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4b1fd75..466c551 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ 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/clicky/aichat v1.21.33 + 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 diff --git a/go.sum b/go.sum index 575a2f2..436e72c 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,8 @@ 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/clicky/aichat v1.21.33 h1:SquXXDXlCarx5w7yCpRGDxDDnBrH1KG1bnDcWkDnNjo= -github.com/flanksource/clicky/aichat v1.21.33/go.mod h1:fgCn0YNytYOFWOWHrd9JDmCFy73XG/iCNdUdgqXNoaY= +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= From b66cb3a497b9cc4355a92ea0c1f07b9b01363660 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 11:25:10 +0300 Subject: [PATCH 19/22] style: gofmt HistoryEntry struct tag alignment --- pkg/claude/history.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/claude/history.go b/pkg/claude/history.go index 236d278..14348b7 100644 --- a/pkg/claude/history.go +++ b/pkg/claude/history.go @@ -8,16 +8,16 @@ 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"` + 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"` From b03a728c85074ceb7635732039821cafe3600fee Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 11:27:44 +0300 Subject: [PATCH 20/22] feat(ai): add model catalog system with pricing and caching This implements a comprehensive model catalog system with support for both API and agent backends. The catalog manages model registration, lookup, and resolution with live model fetching, pricing integration, and caching. Key features: - Model registry with in-place updates and safe concurrent access - Resolution pipeline that merges catalog entries with live API listings - Pricing integration supporting both OpenRouter and static Claude pricing tables - Model cache with 24-hour TTL and fingerprint-based invalidation - JSON API for client-driven model selection with configurability detection - Comprehensive test coverage for all catalog operations --- pkg/ai/catalog.go | 206 +++++++++++++++++++++ pkg/ai/catalog_info.go | 81 +++++++++ pkg/ai/catalog_info_test.go | 50 +++++ pkg/ai/catalog_resolve.go | 321 +++++++++++++++++++++++++++++++++ pkg/ai/catalog_resolve_test.go | 165 +++++++++++++++++ pkg/ai/catalog_test.go | 122 +++++++++++++ pkg/ai/effort.go | 22 +++ pkg/ai/model_cache.go | 80 ++++++++ pkg/ai/pricing/openrouter.go | 2 + pkg/ai/pricing/registry.go | 63 ++++++- pkg/ai/pricing/static.go | 42 +++++ pkg/ai/pricing/static_test.go | 132 ++++++++++++++ 12 files changed, 1283 insertions(+), 3 deletions(-) create mode 100644 pkg/ai/catalog.go create mode 100644 pkg/ai/catalog_info.go create mode 100644 pkg/ai/catalog_info_test.go create mode 100644 pkg/ai/catalog_resolve.go create mode 100644 pkg/ai/catalog_resolve_test.go create mode 100644 pkg/ai/catalog_test.go create mode 100644 pkg/ai/effort.go create mode 100644 pkg/ai/model_cache.go create mode 100644 pkg/ai/pricing/static.go create mode 100644 pkg/ai/pricing/static_test.go 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..2f0b3f1 --- /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.Model.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..110458b --- /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) { + t.Setenv("ANTHROPIC_API_KEY", "k") + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + return nil, errors.New("boom") + }) + + 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) { + t.Setenv("ANTHROPIC_API_KEY", "k") + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + if b != BackendAnthropic { + return nil, nil + } + return []ModelDef{{ID: "claude-3-5-sonnet-20241022", Backend: BackendAnthropic}}, nil + }) + + 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) { + t.Setenv("ANTHROPIC_API_KEY", "k") + calls := 0 + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + if b == BackendAnthropic { + calls++ + } + return nil, nil + }) + + 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/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/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/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 c2d468d..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 { 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") + } +} From 51794606321b7a7197c54324ca0dc6fa943975d1 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 11:40:43 +0300 Subject: [PATCH 21/22] fix(provider): buffer runCLI output to avoid Wait/pipe-read race runCLI read StdoutPipe/StderrPipe in goroutines that raced cmd.Wait(); Wait closes those pipes on process exit, so a Wait-first ordering failed an in-flight read with 'file already closed' (deterministic on Linux CI, passing on macOS). Use bytes.Buffer for cmd.Stdout/Stderr so Wait blocks until the output copiers finish, and feed stdin from a goroutine. Fixes TestRunCLIUsesContextDir on Linux. --- pkg/ai/provider/cli.go | 76 +++++++++++------------------------------- 1 file changed, 20 insertions(+), 56 deletions(-) diff --git a/pkg/ai/provider/cli.go b/pkg/ai/provider/cli.go index 379f561..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" @@ -79,18 +79,18 @@ func runCLI(ctx context.Context, command string, stdinData []byte, cwd string) ( 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) { @@ -99,60 +99,24 @@ func runCLI(ctx context.Context, command string, stdinData []byte, cwd string) ( 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 } From 764a29cebce68beefae43d4786391be33dd9f675 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 12:00:33 +0300 Subject: [PATCH 22/22] fix(ai): resolve model-catalog test ordering and context-window lookup Three defects in the model-catalog work surfaced once CI could compile: - catalog_resolve_test.go: the three live-fetch tests set ANTHROPIC_API_KEY before stubLiveFetcher, which clears all keys, so the live fetch was skipped. Set the key after the stub (matching TestResolveModels_TokensUnionDedup). - catalog_resolve.go: use the promoted ResolvedModel.ContextWindow (staticcheck QF1008). - cmux/sessionstats.go: modelInfo stopped at the first candidate, but the bare hyphenated id classifies to the static Claude table (prices only, zero context), shadowing the dotted OpenRouter id that carries the context window. Prefer a candidate with a non-zero context window. --- pkg/ai/catalog_resolve.go | 10 +++++----- pkg/ai/catalog_resolve_test.go | 6 +++--- pkg/ai/provider/cmux/sessionstats.go | 18 +++++++++++++++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/pkg/ai/catalog_resolve.go b/pkg/ai/catalog_resolve.go index 2f0b3f1..6f4ff35 100644 --- a/pkg/ai/catalog_resolve.go +++ b/pkg/ai/catalog_resolve.go @@ -41,15 +41,15 @@ func (r ResolvedModel) Context() int { if r.Price.ContextWindow > 0 { return r.Price.ContextWindow } - return r.Model.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 + 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 diff --git a/pkg/ai/catalog_resolve_test.go b/pkg/ai/catalog_resolve_test.go index 110458b..f2f6684 100644 --- a/pkg/ai/catalog_resolve_test.go +++ b/pkg/ai/catalog_resolve_test.go @@ -93,10 +93,10 @@ func TestResolveModels_TokensUnionDedup(t *testing.T) { } func TestResolveModels_LiveErrorFailsLoud(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "k") 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)") @@ -104,13 +104,13 @@ func TestResolveModels_LiveErrorFailsLoud(t *testing.T) { } func TestResolveModels_LegacyHiddenUnlessFiltered(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "k") 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 { @@ -130,7 +130,6 @@ func TestResolveModels_LegacyHiddenUnlessFiltered(t *testing.T) { } func TestResolveModels_CacheWrittenAndReused(t *testing.T) { - t.Setenv("ANTHROPIC_API_KEY", "k") calls := 0 stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { if b == BackendAnthropic { @@ -138,6 +137,7 @@ func TestResolveModels_CacheWrittenAndReused(t *testing.T) { } 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 { diff --git a/pkg/ai/provider/cmux/sessionstats.go b/pkg/ai/provider/cmux/sessionstats.go index dd19340..047d5ef 100644 --- a/pkg/ai/provider/cmux/sessionstats.go +++ b/pkg/ai/provider/cmux/sessionstats.go @@ -264,17 +264,29 @@ func modelIDCandidates(model string) []string { } // modelInfo resolves a session-log model id to its pricing registry entry, -// returning false when no candidate id matches. +// 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) { - if info, ok := pricing.GetModelInfo(id); ok { + info, ok := pricing.GetModelInfo(id) + if !ok { + continue + } + if info.ContextWindow > 0 { return info, true } + if !found { + fallback, found = info, true + } } - return pricing.ModelInfo{}, false + return fallback, found } // sessionCost prices the session's tokens via captain's pricing registry. Claude