From 34a2cdf8b9301d44d01d886d805f60f825395b7b Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 8 Jul 2026 20:37:50 +0300 Subject: [PATCH 001/131] feat(ai): introduce automated model registry and runtime model selectors Introduce a dynamic, code-generated LLM model registry sourced from models.dev, replacing the hardcoded catalog. This enables precise runtime model mapping across API, CLI, and agent backends. Add support for runtime model selectors (e.g., `cmux:opus` or `*:sonnet-5`) to allow flexible multi-model selection and targeted execution environments. Add a new `BackendCodexAgent` to support supervised OpenAI-compatible agents. BREAKING CHANGE: Removed the unused `Output` schema field from the `api.Workflow` struct. --- .../workflows/update-llm-model-registry.yml | 72 +++ Makefile | 5 +- Taskfile.yaml | 5 + pkg/ai/catalog.go | 53 +-- pkg/ai/catalog_info.go | 11 +- pkg/ai/catalog_resolve.go | 17 +- pkg/ai/catalog_test.go | 11 +- pkg/ai/client.go | 22 + pkg/ai/internal/gen-model-registry/main.go | 379 +++++++++++++++ pkg/ai/model_lists.go | 74 ++- pkg/ai/model_registry.go | 437 ++++++++++++++++++ pkg/ai/model_registry_static.go | 26 ++ pkg/ai/provider.go | 1 + pkg/ai/provider_test.go | 1 + pkg/ai/runtime_selector.go | 383 +++++++++++++++ pkg/ai/runtime_selector_test.go | 173 +++++++ pkg/api/enums.go | 9 +- pkg/api/enums_test.go | 2 + pkg/api/workflow.go | 22 +- pkg/api/workflow_test.go | 9 +- 20 files changed, 1624 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/update-llm-model-registry.yml create mode 100644 pkg/ai/internal/gen-model-registry/main.go create mode 100644 pkg/ai/model_registry.go create mode 100644 pkg/ai/model_registry_static.go create mode 100644 pkg/ai/runtime_selector.go create mode 100644 pkg/ai/runtime_selector_test.go diff --git a/.github/workflows/update-llm-model-registry.yml b/.github/workflows/update-llm-model-registry.yml new file mode 100644 index 0000000..2c514f8 --- /dev/null +++ b/.github/workflows/update-llm-model-registry.yml @@ -0,0 +1,72 @@ +name: Update LLM Model Registry + +on: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-llm-model-registry + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + env: + PR_BRANCH: chore/update-llm-model-registry + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.repository.default_branch }} + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.x" + + - name: Generate model registry + run: make generate-llm-model-registry + + - name: Check for registry changes + id: changes + run: | + if git diff --quiet -- pkg/ai/model_registry_static.go; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + git diff --stat -- pkg/ai/model_registry_static.go + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push registry update + if: steps.changes.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B "$PR_BRANCH" + git add pkg/ai/model_registry_static.go + git commit -m "chore: update llm model registry" + git push --force-with-lease origin "$PR_BRANCH" + + - name: Create pull request + if: steps.changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh pr view "$PR_BRANCH" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Pull request already exists for $PR_BRANCH" + exit 0 + fi + + gh pr create \ + --repo "${{ github.repository }}" \ + --base "${{ github.event.repository.default_branch }}" \ + --head "$PR_BRANCH" \ + --title "chore: update llm model registry" \ + --body "Automated update from models.dev using \`make generate-llm-model-registry\`." diff --git a/Makefile b/Makefile index 4f14f80..e150fbf 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Makefile stub - forwards to Taskfile # Install task: https://taskfile.dev/installation/ -.PHONY: build lint test install fmt tidy clean all +.PHONY: build lint test install fmt tidy clean all generate-llm-model-registry build: @task build @@ -26,3 +26,6 @@ clean: all: @task all + +generate-llm-model-registry: + go run ./pkg/ai/internal/gen-model-registry --output pkg/ai/model_registry_static.go diff --git a/Taskfile.yaml b/Taskfile.yaml index 4e2e7ca..e23c938 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -82,6 +82,11 @@ tasks: cmds: - go mod tidy + generate:llm-model-registry: + desc: Regenerate the exact LLM model registry from models.dev + cmds: + - go run ./pkg/ai/internal/gen-model-registry --output pkg/ai/model_registry_static.go + clean: desc: Remove build artifacts cmds: diff --git a/pkg/ai/catalog.go b/pkg/ai/catalog.go index 05ccb1f..344c5c5 100644 --- a/pkg/ai/catalog.go +++ b/pkg/ai/catalog.go @@ -9,10 +9,9 @@ import ( // 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 aliases. API backends carry a provider-prefixed ID for storage/display +// stability, while CLI/agent backends carry the exact provider model ID that the +// local backend receives (never a family alias or synthetic backend prefix). type Model struct { ID string Backend Backend @@ -20,9 +19,9 @@ type Model struct { Reasoning bool // model honours Effort ContextWindow int // max context tokens, for a usage gauge's denominator ReleaseDate string // YYYY-MM-DD release date, when known - // 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 is retained for backward compatibility with older registered + // models. New catalog entries should use exact runtime IDs directly in ID and + // leave AgentModel empty. AgentModel string // Default marks the catalog default. Exactly one entry sets it; its ID equals // DefaultModelID. @@ -51,42 +50,10 @@ func (m Model) BareID() string { // The catalog entry with this ID sets Default: true. const DefaultModelID = "anthropic/claude-sonnet-5" -// defaultCatalog is the model menu: only the latest generally-available model -// per tier for each provider — no preview or superseded entries. 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. -// -// Provider currency (reviewed 2026-07-02): -// - Anthropic: Fable 5 (most capable), Opus 4.8, Sonnet 5, Haiku 4.5. Mythos 5 -// is Project Glasswing invite-only, so it is intentionally excluded. -// - OpenAI: GPT-5.5 (flagship) and GPT-5.4 mini. GPT-5.6 is preview-only. -// - Google: keep recent Pro and Flash families visible separately so a newer -// Flash model does not hide recent Pro entries. -var defaultCatalog = []Model{ - {ID: "anthropic/claude-fable-5", Backend: BackendAnthropic, Label: "Claude Fable 5", Reasoning: true, ContextWindow: 1000000, ReleaseDate: "2026-06-15"}, - {ID: "anthropic/claude-opus-4-8", Backend: BackendAnthropic, Label: "Claude Opus 4.8", Reasoning: true, ContextWindow: 1000000, ReleaseDate: "2026-04-15"}, - {ID: "anthropic/claude-sonnet-5", Backend: BackendAnthropic, Label: "Claude Sonnet 5", Reasoning: true, ContextWindow: 1000000, ReleaseDate: "2026-05-20", Default: true}, - {ID: "anthropic/claude-haiku-4-5", Backend: BackendAnthropic, Label: "Claude Haiku 4.5", Reasoning: true, ContextWindow: 200000, ReleaseDate: "2025-10-15"}, - {ID: "openai/gpt-5.5", Backend: BackendOpenAI, Label: "GPT-5.5", Reasoning: true, ContextWindow: 1000000, ReleaseDate: "2026-06-01"}, - {ID: "openai/gpt-5.4-mini", Backend: BackendOpenAI, Label: "GPT-5.4 mini", Reasoning: true, ContextWindow: 400000, ReleaseDate: "2026-05-15"}, - {ID: "googleai/gemini-2.5-pro", Backend: BackendGemini, Label: "Gemini 2.5 Pro", Reasoning: true, ContextWindow: 1048576, ReleaseDate: "2025-06-17"}, - {ID: "googleai/gemini-3.0-pro", Backend: BackendGemini, Label: "Gemini 3.0 Pro", Reasoning: true, ContextWindow: 1048576}, - {ID: "googleai/gemini-3.5-flash", Backend: BackendGemini, Label: "Gemini 3.5 Flash", Reasoning: true, ContextWindow: 1048576, ReleaseDate: "2026-06-10"}, - // DeepSeek is OpenAI-compatible; deepseek-chat is the non-thinking model and - // deepseek-reasoner is the thinking model (reasoning selected by model, not - // effort). IDs are stable aliases that always resolve to DeepSeek's latest. - {ID: "deepseek/deepseek-chat", Backend: BackendDeepSeek, Label: "DeepSeek Chat", ContextWindow: 131072}, - {ID: "deepseek/deepseek-reasoner", Backend: BackendDeepSeek, Label: "DeepSeek Reasoner", Reasoning: true, ContextWindow: 131072}, - - // Agent-framework models (captain pkg/ai StreamingProvider). These run a - // supervised local subprocess that owns its own tools. IDs are tier aliases - // (not version-pinned), so they always resolve to the installed CLI's latest. - {ID: "claude-agent-sonnet", Backend: BackendClaudeAgent, Label: "Claude Agent · Sonnet", Reasoning: true, ContextWindow: 1000000, ReleaseDate: "2026-05-20"}, - {ID: "claude-agent-opus", Backend: BackendClaudeAgent, Label: "Claude Agent · Opus", Reasoning: true, ContextWindow: 1000000, ReleaseDate: "2026-04-15"}, - {ID: "claude-agent-haiku", Backend: BackendClaudeAgent, Label: "Claude Agent · Haiku", Reasoning: true, ContextWindow: 200000, ReleaseDate: "2025-10-15"}, - {ID: "codex-gpt-5-codex", Backend: BackendCodexCLI, AgentModel: "gpt-5-codex", Label: "Codex · GPT-5", Reasoning: true, ContextWindow: 400000, ReleaseDate: "2025-08-07"}, -} +// defaultCatalog is projected from the internal exact model registry. The API +// rows keep provider prefixes for stable storage; CLI/agent rows keep exact +// backend model IDs without synthetic claude-agent/codex prefixes. +var defaultCatalog = registryCatalogModels() var ( modelRegistryMu sync.RWMutex diff --git a/pkg/ai/catalog_info.go b/pkg/ai/catalog_info.go index 2854f60..e94cdce 100644 --- a/pkg/ai/catalog_info.go +++ b/pkg/ai/catalog_info.go @@ -64,13 +64,16 @@ func CatalogInfo(configuredProviders []string) []ModelInfo { } // 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. +// installed (best effort): codex backends need the `codex` binary; claude-cli +// needs `claude`; claude-agent needs `tsx`. A turn still fails loud if the probe +// is wrong. func agentBackendAvailable(b Backend) bool { switch b { - case BackendCodexCLI, BackendCodexCmux: + case BackendCodexCLI, BackendCodexAgent, BackendCodexCmux: return binaryOnPath("codex") - case BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: + case BackendClaudeCLI, BackendClaudeCmux: + return binaryOnPath("claude") + case BackendClaudeAgent: return binaryOnPath("tsx") default: return false diff --git a/pkg/ai/catalog_resolve.go b/pkg/ai/catalog_resolve.go index 9eb53bf..38e4670 100644 --- a/pkg/ai/catalog_resolve.go +++ b/pkg/ai/catalog_resolve.go @@ -134,13 +134,16 @@ func seedCatalog(backend Backend) ([]ResolvedModel, map[modelKey]int) { } // catalogBackendMatch reports whether a catalog model's backend satisfies the -// requested filter. claude-cli shares the claude-agent catalog entries. +// requested filter. cli/cmux backends share their agent catalog entries. func catalogBackendMatch(want, modelBackend Backend) bool { if want == "" { return true } - if want == BackendClaudeCLI { + switch want { + case BackendClaudeCLI, BackendClaudeCmux: want = BackendClaudeAgent + case BackendCodexCLI, BackendCodexCmux: + want = BackendCodexAgent } return modelBackend == want } @@ -213,13 +216,15 @@ func bareModelID(id string) string { // 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. +// completion. Returned IDs are exact provider model IDs; legacy AgentModel is +// still honored for externally registered old entries. func AgentCatalogModels(b Backend) []ModelDef { want := b - if want == BackendClaudeCLI { + switch want { + case BackendClaudeCLI, BackendClaudeCmux: want = BackendClaudeAgent + case BackendCodexCLI, BackendCodexCmux: + want = BackendCodexAgent } out := []ModelDef{} diff --git a/pkg/ai/catalog_test.go b/pkg/ai/catalog_test.go index 1e1d2e5..cebb727 100644 --- a/pkg/ai/catalog_test.go +++ b/pkg/ai/catalog_test.go @@ -43,8 +43,8 @@ func TestBareID(t *testing.T) { }{ "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"}, + "agent id verbatim": {Model{ID: "claude-sonnet-5", Backend: BackendClaudeAgent}, "claude-sonnet-5"}, + "codex slug id verbatim": {Model{ID: "gpt-5.5", Backend: BackendCodexCLI}, "gpt-5.5"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -148,6 +148,8 @@ func TestCurrentModelsByReleaseDateFiltersAndSorts(t *testing.T) { {ID: "claude-sonnet-4-6", Backend: BackendAnthropic, ReleaseDate: "2026-05-01"}, {ID: "claude-sonnet-4-5", Backend: BackendAnthropic, ReleaseDate: "2026-04-01"}, {ID: "claude-sonnet-4-4", Backend: BackendAnthropic, ReleaseDate: "2026-03-01"}, + {ID: "sonnet-5", Backend: BackendClaudeCmux, ReleaseDate: "2026-06-01"}, + {ID: "sonnet-4-6", Backend: BackendClaudeCmux, ReleaseDate: "2026-05-01"}, {ID: "claude-haiku-4-5", Backend: BackendAnthropic, ReleaseDate: "2025-10-15"}, {ID: "gemini-3.0-pro", Backend: BackendGemini}, {ID: "gpt-4o", Backend: BackendOpenAI, ReleaseDate: "2026-04-01"}, @@ -159,7 +161,6 @@ func TestCurrentModelsByReleaseDateFiltersAndSorts(t *testing.T) { "claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", - "gpt-5-codex", "claude-haiku-4-5", "gemini-3.0-pro", } @@ -177,6 +178,10 @@ func TestModelFamilyPrefix(t *testing.T) { cases := map[string]string{ "anthropic/claude-haiku-4-5": "claude-haiku", "claude-agent-sonnet": "claude-agent-sonnet", + "claude-sonnet-5": "claude-sonnet", + "sonnet-5": "sonnet", + "sonnet-4-6": "sonnet", + "opus-4-8": "opus", "googleai/gemini-3.0-pro": "gemini-pro", "gemini-3.5-flash": "gemini-flash", "gemini-2.5-flash-lite": "gemini-flash-lite", diff --git a/pkg/ai/client.go b/pkg/ai/client.go index 2c7a6ee..e5ad6f4 100644 --- a/pkg/ai/client.go +++ b/pkg/ai/client.go @@ -28,7 +28,15 @@ func RegisterProvider(backend Backend, factory ProviderFactory) { // comma-separated Name or a Fallbacks list), a fallback provider is returned that // tries each in order on a retryable failure. func NewProvider(cfg Config) (Provider, error) { + resolved, err := ResolveModelSelectors(cfg.Model) + if err != nil { + return nil, err + } + cfg.Model = normalizeProviderModel(resolved) candidates := cfg.Model.Candidates() + for i := range candidates { + candidates[i] = normalizeProviderModel(candidates[i]) + } cfg.Model = candidates[0] if len(candidates) > 1 { return newFallbackProvider(cfg, candidates), nil @@ -40,6 +48,20 @@ func NewProvider(cfg Config) (Provider, error) { return p, nil } +func normalizeProviderModel(model api.Model) api.Model { + backend := model.Backend + if backend == "" { + if inferred, err := api.InferBackend(model.Name); err == nil { + backend = inferred + } + } + if backend != "" { + model.Name = NormalizeModelForBackend(backend, model.Name) + model.Backend = backend + } + return model +} + // suggestModelName appends the closest catalog model ids to an unresolvable-model // error, so e.g. "claud-sonnet-4" points at "claude-sonnet-4". func suggestModelName(err error, model string) error { diff --git a/pkg/ai/internal/gen-model-registry/main.go b/pkg/ai/internal/gen-model-registry/main.go new file mode 100644 index 0000000..1badd36 --- /dev/null +++ b/pkg/ai/internal/gen-model-registry/main.go @@ -0,0 +1,379 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "go/format" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + captainai "github.com/flanksource/captain/pkg/ai" +) + +const modelsDevAPIURL = "https://models.dev/api.json" + +type modelsDevProvider struct { + Models map[string]modelsDevModel `json:"models"` +} + +type modelsDevModel struct { + ID string `json:"id"` + Name string `json:"name"` + Family string `json:"family"` + Reasoning bool `json:"reasoning"` + ReleaseDate string `json:"release_date"` + Modalities modelsDevModalities `json:"modalities"` + Limit modelsDevLimit `json:"limit"` +} + +type modelsDevModalities struct { + Output []string `json:"output"` +} + +type modelsDevLimit struct { + Context int `json:"context"` +} + +type generatedModel struct { + ID string + Provider string + Family string + Version string + Label string + ReleaseDate string + Reasoning bool + ContextWindow int + Preferred bool +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "gen-model-registry: %v\n", err) + os.Exit(1) + } +} + +func run() error { + var output string + var source string + flag.StringVar(&output, "output", "", "Path to write pkg/ai/model_registry_static.go") + flag.StringVar(&source, "source", modelsDevAPIURL, "models.dev API URL, local file path, or - for stdin") + flag.Parse() + if output == "" { + return fmt.Errorf("--output is required") + } + + data, err := readSource(source) + if err != nil { + return err + } + models, err := generateModels(data) + if err != nil { + return err + } + src, err := renderRegistry(models) + if err != nil { + return err + } + if dir := filepath.Dir(output); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + } + if err := os.WriteFile(output, src, 0o644); err != nil { + return fmt.Errorf("write %s: %w", output, err) + } + return nil +} + +func readSource(source string) ([]byte, error) { + switch { + case source == "-": + data, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("read stdin: %w", err) + } + return data, nil + case strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://"): + return readURL(source) + default: + data, err := os.ReadFile(source) + if err != nil { + return nil, fmt.Errorf("read %s: %w", source, err) + } + return data, nil + } +} + +func readURL(source string) ([]byte, error) { + client := http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest(http.MethodGet, source, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("User-Agent", "captain/gen-model-registry") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", source, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetch %s: unexpected status %s", source, resp.Status) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read %s: %w", source, err) + } + return data, nil +} + +func generateModels(data []byte) ([]generatedModel, error) { + var providers map[string]modelsDevProvider + if err := json.Unmarshal(data, &providers); err != nil { + return nil, fmt.Errorf("parse models.dev catalog: %w", err) + } + out := map[string]generatedModel{} + for _, mapping := range []struct { + source string + provider string + }{ + {"anthropic", "anthropic"}, + {"openai", "openai"}, + {"google", "google"}, + {"deepseek", "deepseek"}, + } { + provider, ok := providers[mapping.source] + if !ok { + return nil, fmt.Errorf("models.dev catalog is missing provider %q", mapping.source) + } + for id, model := range provider.Models { + if model.ID != "" { + id = model.ID + } + row, ok := generatedModelFromModelsDev(mapping.provider, id, model) + if ok { + out[row.ID] = row + } + } + } + for _, row := range localRegistryOverrides() { + out[row.ID] = row + } + + models := make([]generatedModel, 0, len(out)) + for _, row := range out { + models = append(models, row) + } + sort.SliceStable(models, func(i, j int) bool { + if models[i].Provider == models[j].Provider { + if models[i].ReleaseDate == models[j].ReleaseDate { + return models[i].ID < models[j].ID + } + return models[i].ReleaseDate > models[j].ReleaseDate + } + return providerRank(models[i].Provider) < providerRank(models[j].Provider) + }) + return models, nil +} + +func generatedModelFromModelsDev(provider, id string, model modelsDevModel) (generatedModel, bool) { + id = strings.TrimSpace(id) + if id == "" || !supportsTextOutput(model) || hiddenModel(provider, id) { + return generatedModel{}, false + } + identity, ok := captainai.ParseModelIdentity(provider, id) + if !ok || identity.Provider != provider { + return generatedModel{}, false + } + if !supportedFamily(identity) { + return generatedModel{}, false + } + label := strings.TrimSpace(model.Name) + if label == "" { + label = titleModelID(id) + } + return generatedModel{ + ID: id, + Provider: provider, + Family: identity.Family, + Version: identity.Version, + Label: label, + ReleaseDate: model.ReleaseDate, + Reasoning: model.Reasoning, + ContextWindow: model.Limit.Context, + Preferred: !isNonPreferredID(id), + }, true +} + +func supportsTextOutput(model modelsDevModel) bool { + if len(model.Modalities.Output) == 0 { + return true + } + for _, output := range model.Modalities.Output { + if output == "text" { + return true + } + } + return false +} + +func hiddenModel(provider, id string) bool { + switch provider { + case "openai": + return captainai.IsLegacyModelIDForBackend(id, captainai.BackendOpenAI) + case "anthropic": + return captainai.IsLegacyModelIDForBackend(id, captainai.BackendAnthropic) + case "google": + lower := strings.ToLower(id) + return strings.Contains(lower, "embedding") || strings.Contains(lower, "image") || strings.Contains(lower, "tts") + default: + return false + } +} + +func supportedFamily(identity captainai.ModelIdentity) bool { + switch identity.Provider { + case "anthropic": + switch identity.Family { + case "opus", "sonnet", "haiku", "fable": + return true + } + case "openai": + return identity.Family == "gpt" + case "google": + return identity.Family == "gemini" + case "deepseek": + return identity.Family == "deepseek" + } + return false +} + +func isNonPreferredID(id string) bool { + lower := strings.ToLower(id) + return hasTrailingDateID(lower) || strings.Contains(lower, "preview") || strings.Contains(lower, "latest") +} + +func hasTrailingDateID(id string) bool { + parts := strings.Split(id, "-") + if len(parts) == 0 { + return false + } + last := parts[len(parts)-1] + if len(last) != 8 { + return false + } + for _, r := range last { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func renderRegistry(models []generatedModel) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("// Code generated by pkg/ai/internal/gen-model-registry; DO NOT EDIT.\n\n") + buf.WriteString("package ai\n\n") + buf.WriteString("var exactModelRegistry = []registryModel{\n") + for i, model := range models { + if i > 0 && models[i-1].Provider != model.Provider { + buf.WriteByte('\n') + } + fmt.Fprintf(&buf, "\t{ID: %q, Provider: %s, Family: %q, Version: %q, Label: %q", + model.ID, providerConst(model.Provider), model.Family, model.Version, model.Label) + if model.ReleaseDate != "" { + fmt.Fprintf(&buf, ", ReleaseDate: %q", model.ReleaseDate) + } + if model.Reasoning { + buf.WriteString(", Reasoning: true") + } + if model.ContextWindow > 0 { + fmt.Fprintf(&buf, ", ContextWindow: %d", model.ContextWindow) + } + if model.Preferred { + buf.WriteString(", Preferred: true") + } + buf.WriteString("},\n") + } + buf.WriteString("}\n") + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, err + } + return src, nil +} + +func providerConst(provider string) string { + switch provider { + case "anthropic": + return "modelProviderAnthropic" + case "openai": + return "modelProviderOpenAI" + case "google": + return "modelProviderGoogle" + case "deepseek": + return "modelProviderDeepSeek" + default: + panic("unhandled provider " + provider) + } +} + +func providerRank(provider string) int { + switch provider { + case "anthropic": + return 0 + case "openai": + return 1 + case "google": + return 2 + case "deepseek": + return 3 + default: + return 100 + } +} + +func titleModelID(id string) string { + parts := strings.Split(strings.ReplaceAll(id, "_", "-"), "-") + for i, part := range parts { + switch strings.ToLower(part) { + case "gpt": + parts[i] = "GPT" + default: + if part != "" { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + } + return strings.Join(parts, " ") +} + +func localRegistryOverrides() []generatedModel { + return []generatedModel{ + {ID: "claude-sonnet-5", Provider: "anthropic", Family: "sonnet", Version: "5", Label: "Claude Sonnet 5", ReleaseDate: "2026-06-29", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-fable-5", Provider: "anthropic", Family: "fable", Version: "5", Label: "Claude Fable 5", ReleaseDate: "2026-06-07", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-opus-4-8", Provider: "anthropic", Family: "opus", Version: "4.8", Label: "Claude Opus 4.8", ReleaseDate: "2026-05-28", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-opus-4-7", Provider: "anthropic", Family: "opus", Version: "4.7", Label: "Claude Opus 4.7", ReleaseDate: "2026-04-14", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-sonnet-4-6", Provider: "anthropic", Family: "sonnet", Version: "4.6", Label: "Claude Sonnet 4.6", ReleaseDate: "2026-02-17", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-haiku-4-5", Provider: "anthropic", Family: "haiku", Version: "4.5", Label: "Claude Haiku 4.5", ReleaseDate: "2025-10-15", Reasoning: true, ContextWindow: 200000, Preferred: true}, + {ID: "claude-haiku-4-5-20251001", Provider: "anthropic", Family: "haiku", Version: "4.5-20251001", Label: "Claude Haiku 4.5", ReleaseDate: "2025-10-15", Reasoning: true, ContextWindow: 200000}, + {ID: "gpt-5.5", Provider: "openai", Family: "gpt", Version: "5.5", Label: "GPT-5.5", ReleaseDate: "2026-04-23", Reasoning: true, ContextWindow: 1050000, Preferred: true}, + {ID: "gpt-5.4", Provider: "openai", Family: "gpt", Version: "5.4", Label: "GPT-5.4", ReleaseDate: "2026-03-05", Reasoning: true, ContextWindow: 1050000, Preferred: true}, + {ID: "gpt-5", Provider: "openai", Family: "gpt", Version: "5", Label: "GPT-5", ReleaseDate: "2025-08-07", Reasoning: true, ContextWindow: 400000, Preferred: true}, + {ID: "gemini-3.5-flash", Provider: "google", Family: "gemini", Version: "3.5-flash", Label: "Gemini 3.5 Flash", ReleaseDate: "2026-05-19", Reasoning: true, ContextWindow: 1048576, Preferred: true}, + {ID: "gemini-2.5-pro", Provider: "google", Family: "gemini", Version: "2.5-pro", Label: "Gemini 2.5 Pro", ReleaseDate: "2025-06-17", Reasoning: true, ContextWindow: 1048576, Preferred: true}, + {ID: "gemini-2.5-flash", Provider: "google", Family: "gemini", Version: "2.5-flash", Label: "Gemini 2.5 Flash", ReleaseDate: "2025-06-17", Reasoning: true, ContextWindow: 1048576, Preferred: true}, + {ID: "deepseek-v4-pro", Provider: "deepseek", Family: "deepseek", Version: "v4-pro", Label: "DeepSeek V4 Pro", ReleaseDate: "2026-04-24", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "deepseek-v4-flash", Provider: "deepseek", Family: "deepseek", Version: "v4-flash", Label: "DeepSeek V4 Flash", ReleaseDate: "2026-04-24", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "deepseek-reasoner", Provider: "deepseek", Family: "deepseek", Version: "reasoner", Label: "DeepSeek Reasoner", ReleaseDate: "2025-12-01", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "deepseek-chat", Provider: "deepseek", Family: "deepseek", Version: "chat", Label: "DeepSeek Chat", ReleaseDate: "2025-12-01", ContextWindow: 1000000, Preferred: true}, + } +} diff --git a/pkg/ai/model_lists.go b/pkg/ai/model_lists.go index 62d6330..b690757 100644 --- a/pkg/ai/model_lists.go +++ b/pkg/ai/model_lists.go @@ -9,7 +9,7 @@ import ( const currentModelsPerFamily = 3 // legacyModelPrefixes hides model IDs that are either superseded by a newer -// generation or aren't chat completions (image/audio/embedding/moderation). +// generation or aren't primary text models. var legacyModelPrefixes = []string{ // OpenAI legacy "gpt-3", @@ -17,9 +17,16 @@ var legacyModelPrefixes = []string{ "gpt-5-", // API variants like mini/nano/codex/pro; CLI Codex is exempt by backend "o1", "o3", + "o4", "codex-mini", - // OpenAI non-chat endpoints + // OpenAI non-primary endpoints and aliases + "gpt-realtime", + "gpt-image", + "gpt-audio", + "sora", "dall-", + "image-", + "audio-", "whisper", "tts-", "text-embedding", @@ -27,6 +34,7 @@ var legacyModelPrefixes = []string{ "omni-moderation", "babbage", "davinci", + "chat-latest", "chatgpt-", "computer-use-preview", // Claude legacy @@ -37,6 +45,10 @@ var legacyModelPrefixes = []string{ "claude-sonnet-4-2", "claude-opus-4-0", "claude-opus-4-1", + "fable-", + "opus-", + "sonnet-", + "haiku-", // Gemini legacy "gemini-1", "gemini-2.0", @@ -49,6 +61,9 @@ var legacyModelPrefixes = []string{ // model id. Call IsLegacyModelIDForBackend when backend context is available. func IsLegacyModelID(id string) bool { idLower := strings.ToLower(bareModelID(strings.TrimPrefix(strings.TrimSpace(id), "models/"))) + if IsIgnoredOpenAIModelID(idLower) { + return true + } for _, p := range legacyModelPrefixes { if strings.HasPrefix(idLower, p) { return true @@ -57,13 +72,56 @@ func IsLegacyModelID(id string) bool { return false } -// IsLegacyModelIDForBackend keeps API model menus clean while preserving local -// agent model slugs such as gpt-5-codex, which are current for Codex CLI even -// though the same id would be noisy in an OpenAI API model listing. -func IsLegacyModelIDForBackend(id string, backend Backend) bool { - if backend.Kind() == "cli" { +// IsIgnoredOpenAIModelID reports whether an OpenAI model-list id should be +// hidden from ordinary model pickers. OpenAI exposes many non-primary surfaces +// (realtime, audio, image, Sora, code/chat aliases, dated and size variants); +// Captain's picker keeps the stable primary GPT text ids such as gpt-5 and +// gpt-5.5. Use this before remapping live OpenAI ids onto Codex CLI/cmux +// backends. +func IsIgnoredOpenAIModelID(id string) bool { + idLower := strings.ToLower(bareModelID(strings.TrimPrefix(strings.TrimSpace(id), "models/"))) + if idLower == "" { return false } + for _, p := range legacyModelPrefixes { + if strings.HasPrefix(idLower, p) { + return true + } + } + if strings.HasPrefix(idLower, "gpt-") { + return !isPrimaryGPTModelID(idLower) + } + for _, needle := range []string{"realtime", "image", "audio", "whisper", "tts", "sora", "codex", "code", "chat-latest", "chatgpt", "computer-use"} { + if strings.Contains(idLower, needle) { + return true + } + } + return false +} + +func isPrimaryGPTModelID(id string) bool { + version := strings.TrimPrefix(id, "gpt-") + if version == "" || version == id { + return false + } + sawDigit := false + for _, r := range version { + switch { + case r >= '0' && r <= '9': + sawDigit = true + case r == '.': + continue + default: + return false + } + } + return sawDigit +} + +// IsLegacyModelIDForBackend keeps model menus clean for every backend. CLI and +// agent backends receive exact provider IDs too, so code/chat/realtime/audio +// variants are hidden there just as they are for direct API backends. +func IsLegacyModelIDForBackend(id string, backend Backend) bool { return IsLegacyModelID(id) } @@ -151,6 +209,8 @@ func ModelFamilyPrefix(id string) string { return strings.Join(parts[:3], "-") } return "claude-" + parts[1] + case "fable", "opus", "sonnet", "haiku": + return parts[0] case "gemini": for i := 1; i < len(parts); i++ { if isModelVersionToken(parts[i]) { diff --git a/pkg/ai/model_registry.go b/pkg/ai/model_registry.go new file mode 100644 index 0000000..8e84c4b --- /dev/null +++ b/pkg/ai/model_registry.go @@ -0,0 +1,437 @@ +package ai + +import ( + "sort" + "strings" + + "github.com/flanksource/captain/pkg/api" +) + +// ModelIdentity is Captain's parsed model key. It is intentionally provider +// native: aliases and backend prefixes are input conveniences only, never the +// value sent to a backend. +type ModelIdentity struct { + Provider string + Family string + Version string +} + +type registryModel struct { + ID string + Provider string + Family string + Version string + Label string + ReleaseDate string + Reasoning bool + ContextWindow int + Preferred bool +} + +const ( + modelProviderAnthropic = "anthropic" + modelProviderOpenAI = "openai" + modelProviderGoogle = "google" + modelProviderDeepSeek = "deepseek" +) + +func registryProviderForBackend(backend api.Backend) string { + switch backend { + case api.BackendAnthropic, api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux: + return modelProviderAnthropic + case api.BackendOpenAI, api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux: + return modelProviderOpenAI + case api.BackendGemini, api.BackendGeminiCLI: + return modelProviderGoogle + case api.BackendDeepSeek: + return modelProviderDeepSeek + default: + return "" + } +} + +func registryBackendForProvider(provider string) Backend { + switch provider { + case modelProviderAnthropic: + return BackendAnthropic + case modelProviderOpenAI: + return BackendOpenAI + case modelProviderGoogle: + return BackendGemini + case modelProviderDeepSeek: + return BackendDeepSeek + default: + return "" + } +} + +func registryProviderPrefix(provider string) string { + switch provider { + case modelProviderGoogle: + return "googleai" + default: + return provider + } +} + +func registryCatalogModels() []Model { + out := make([]Model, 0, len(exactModelRegistry)+5) + for _, m := range exactModelRegistry { + if !m.Preferred { + continue + } + backend := registryBackendForProvider(m.Provider) + if backend == "" { + continue + } + out = append(out, Model{ + ID: registryProviderPrefix(m.Provider) + "/" + m.ID, + Backend: backend, + Label: m.Label, + Reasoning: m.Reasoning, + ContextWindow: m.ContextWindow, + ReleaseDate: m.ReleaseDate, + Default: m.Provider == modelProviderAnthropic && m.ID == "claude-sonnet-5", + }) + } + for _, m := range exactModelRegistry { + if !m.Preferred { + continue + } + switch m.Provider { + case modelProviderAnthropic: + if m.Family == "fable" { + continue + } + out = append(out, Model{ + ID: m.ID, + Backend: BackendClaudeAgent, + Label: "Claude Agent · " + strings.TrimPrefix(m.Label, "Claude "), + Reasoning: m.Reasoning, + ContextWindow: m.ContextWindow, + ReleaseDate: m.ReleaseDate, + }) + case modelProviderOpenAI: + out = append(out, Model{ + ID: m.ID, + Backend: BackendCodexAgent, + Label: "Codex Agent · " + m.Label, + Reasoning: m.Reasoning, + ContextWindow: m.ContextWindow, + ReleaseDate: m.ReleaseDate, + }) + } + } + return out +} + +// RegistryModelDefs returns exact, provider-native model IDs for a backend. CLI +// and cmux backends are projected from their parent provider's model registry. +func RegistryModelDefs(backend Backend) []ModelDef { + provider := registryProviderForBackend(backend) + if provider == "" { + return nil + } + out := make([]ModelDef, 0, len(exactModelRegistry)) + for _, m := range exactModelRegistry { + if m.Provider != provider || !m.Preferred { + continue + } + out = append(out, ModelDef{ID: m.ID, Name: m.Label, Backend: backend, ReleaseDate: m.ReleaseDate}) + } + SortModelsByReleaseDateDesc(out) + return out +} + +// ResolveExactModelForBackend resolves a user/catalog model token into the exact +// model ID the selected backend should receive. It accepts old aliases for input +// compatibility but never returns an alias. +func ResolveExactModelForBackend(backend Backend, model string) (string, bool) { + model = strings.TrimSpace(model) + if model == "" { + return "", false + } + if strings.EqualFold(model, backendAgentSentinel(backend)) { + if m, ok := latestRegistryModel(registryProviderForBackend(backend), ""); ok { + return m.ID, true + } + return model, false + } + provider := registryProviderForBackend(backend) + if provider == "" { + return stripModelProviderPrefix(model), false + } + if m, ok := lookupRegistryExact(provider, model); ok { + return m.ID, true + } + identity, ok := ParseModelIdentity(provider, model) + if !ok { + return stripModelProviderPrefix(model), false + } + if identity.Provider != provider { + return stripModelProviderPrefix(model), false + } + if m, ok := resolveRegistryIdentity(identity); ok { + return m.ID, true + } + if m, ok := latestRegistryModelForVersionLine(identity); ok { + return m.ID, true + } + if m, ok := latestRegistryModel(identity.Provider, identity.Family); ok { + return m.ID, true + } + return stripModelProviderPrefix(model), false +} + +func backendAgentSentinel(backend Backend) string { + switch backend { + case BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: + return "claude" + case BackendCodexAgent, BackendCodexCLI, BackendCodexCmux: + return "codex" + default: + return "" + } +} + +func lookupRegistryExact(provider, model string) (registryModel, bool) { + needle := canonicalModelToken(model) + for _, m := range exactModelRegistry { + if m.Provider != provider { + continue + } + if canonicalModelToken(m.ID) == needle { + return m, true + } + } + return registryModel{}, false +} + +// ParseModelIdentity parses a model token into the provider/family/version tuple +// used by the resolver. It is deliberately permissive on input prefixes. +func ParseModelIdentity(defaultProvider, model string) (ModelIdentity, bool) { + provider, token := splitModelProvider(defaultProvider, model) + token = canonicalModelToken(token) + switch provider { + case modelProviderAnthropic: + token = strings.TrimPrefix(token, "claude-agent-") + token = strings.TrimPrefix(token, "claude-code-") + token = strings.TrimPrefix(token, "claude-") + if token == "" { + return ModelIdentity{Provider: provider, Family: "sonnet"}, true + } + family, version, ok := splitKnownFamily(token, []string{"fable", "opus", "sonnet", "haiku"}) + return ModelIdentity{Provider: provider, Family: family, Version: version}, ok + case modelProviderOpenAI: + token = strings.TrimPrefix(token, "codex-agent-") + token = strings.TrimPrefix(token, "codex-") + if token == "" || token == "codex" { + return ModelIdentity{Provider: provider, Family: "gpt"}, true + } + family, version, ok := splitKnownFamily(token, []string{"gpt"}) + return ModelIdentity{Provider: provider, Family: family, Version: version}, ok + case modelProviderGoogle: + token = strings.TrimPrefix(token, "gemini-cli-") + if token == "gemini" { + return ModelIdentity{Provider: provider, Family: "gemini"}, true + } + family, version, ok := splitKnownFamily(token, []string{"gemini"}) + return ModelIdentity{Provider: provider, Family: family, Version: version}, ok + case modelProviderDeepSeek: + if strings.HasPrefix(token, "deepseek") { + version := strings.TrimPrefix(token, "deepseek-") + if version == token { + version = "" + } + return ModelIdentity{Provider: provider, Family: "deepseek", Version: normalizeModelVersion(version)}, true + } + } + return ModelIdentity{}, false +} + +func splitKnownFamily(token string, families []string) (family, version string, ok bool) { + for _, candidate := range families { + if token == candidate { + return candidate, "", true + } + prefix := candidate + "-" + if strings.HasPrefix(token, prefix) { + return candidate, normalizeModelVersion(strings.TrimPrefix(token, prefix)), true + } + } + return "", "", false +} + +func resolveRegistryIdentity(identity ModelIdentity) (registryModel, bool) { + candidates := make([]registryModel, 0) + for _, m := range exactModelRegistry { + if !m.Preferred || m.Provider != identity.Provider || m.Family != identity.Family { + continue + } + if identity.Version == "" || modelVersionMatches(m.Version, identity.Version) { + candidates = append(candidates, m) + } + } + if len(candidates) == 0 { + return registryModel{}, false + } + sortRegistryModels(candidates) + return candidates[0], true +} + +func latestRegistryModel(provider, family string) (registryModel, bool) { + candidates := make([]registryModel, 0) + for _, m := range exactModelRegistry { + if !m.Preferred || m.Provider != provider { + continue + } + if family != "" && m.Family != family { + continue + } + candidates = append(candidates, m) + } + if len(candidates) == 0 { + return registryModel{}, false + } + sortRegistryModels(candidates) + return candidates[0], true +} + +func latestRegistryModelForVersionLine(identity ModelIdentity) (registryModel, bool) { + if identity.Version == "" { + return registryModel{}, false + } + major := strings.Split(normalizeModelVersion(identity.Version), ".")[0] + if major == "" { + return registryModel{}, false + } + candidates := make([]registryModel, 0) + for _, m := range exactModelRegistry { + if !m.Preferred || m.Provider != identity.Provider || m.Family != identity.Family { + continue + } + if modelVersionMatches(m.Version, major) { + candidates = append(candidates, m) + } + } + if len(candidates) == 0 { + return registryModel{}, false + } + sortRegistryModels(candidates) + return candidates[0], true +} + +func sortRegistryModels(models []registryModel) { + sort.SliceStable(models, func(i, j int) bool { + left, right := models[i], models[j] + if left.ReleaseDate == right.ReleaseDate { + return compareModelVersions(left.ID, right.ID) > 0 + } + return left.ReleaseDate > right.ReleaseDate + }) +} + +func modelVersionMatches(registryVersion, requested string) bool { + registryVersion = normalizeModelVersion(registryVersion) + requested = normalizeModelVersion(requested) + if requested == "" { + return true + } + if registryVersion == requested { + return true + } + return strings.HasPrefix(registryVersion, requested+".") || strings.HasPrefix(registryVersion, requested+"-") +} + +func splitModelProvider(defaultProvider, model string) (string, string) { + model = strings.TrimSpace(model) + if i := strings.IndexByte(model, '/'); i >= 0 { + prefix := strings.ToLower(strings.TrimSpace(model[:i])) + token := model[i+1:] + switch prefix { + case "anthropic": + return modelProviderAnthropic, token + case "openai": + return modelProviderOpenAI, token + case "google", "googleai": + return modelProviderGoogle, token + case "deepseek": + return modelProviderDeepSeek, token + } + } + token := canonicalModelToken(model) + switch { + case strings.HasPrefix(token, "claude-") || + strings.HasPrefix(token, "opus") || + strings.HasPrefix(token, "sonnet") || + strings.HasPrefix(token, "haiku") || + strings.HasPrefix(token, "fable"): + return modelProviderAnthropic, model + case strings.HasPrefix(token, "codex-") || + strings.HasPrefix(token, "gpt-") || + token == "gpt" || + strings.HasPrefix(token, "o1") || + strings.HasPrefix(token, "o3") || + strings.HasPrefix(token, "o4") || + strings.HasPrefix(token, "sora"): + return modelProviderOpenAI, model + case strings.HasPrefix(token, "gemini"): + return modelProviderGoogle, model + case strings.HasPrefix(token, "deepseek"): + return modelProviderDeepSeek, model + } + return defaultProvider, model +} + +func stripModelProviderPrefix(model string) string { + _, token := splitModelProvider("", model) + return strings.TrimPrefix(strings.TrimSpace(token), "models/") +} + +func canonicalModelToken(model string) string { + model = strings.ToLower(strings.TrimSpace(model)) + model = strings.TrimPrefix(model, "models/") + model = strings.ReplaceAll(model, "_", "-") + model = strings.ReplaceAll(model, " ", "-") + return model +} + +func normalizeModelVersion(version string) string { + version = strings.Trim(strings.ToLower(strings.TrimSpace(version)), "-.") + if version == "" { + return "" + } + parts := strings.Split(version, "-") + if len(parts) == 1 { + return parts[0] + } + i := 0 + out := "" + if isRegistryNumericVersionPart(parts[0]) { + out = parts[0] + i = 1 + if len(parts) > 1 && isRegistryNumericVersionPart(parts[1]) { + out += "." + parts[1] + i = 2 + } + } + if i < len(parts) { + if out != "" { + out += "-" + } + out += strings.Join(parts[i:], "-") + } + return out +} + +func isRegistryNumericVersionPart(part string) bool { + if part == "" { + return false + } + for _, r := range part { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/pkg/ai/model_registry_static.go b/pkg/ai/model_registry_static.go new file mode 100644 index 0000000..c707f39 --- /dev/null +++ b/pkg/ai/model_registry_static.go @@ -0,0 +1,26 @@ +// Code generated by pkg/ai/internal/gen-model-registry; DO NOT EDIT. + +package ai + +var exactModelRegistry = []registryModel{ + {ID: "claude-sonnet-5", Provider: modelProviderAnthropic, Family: "sonnet", Version: "5", Label: "Claude Sonnet 5", ReleaseDate: "2026-06-29", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-fable-5", Provider: modelProviderAnthropic, Family: "fable", Version: "5", Label: "Claude Fable 5", ReleaseDate: "2026-06-07", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-opus-4-8", Provider: modelProviderAnthropic, Family: "opus", Version: "4.8", Label: "Claude Opus 4.8", ReleaseDate: "2026-05-28", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-opus-4-7", Provider: modelProviderAnthropic, Family: "opus", Version: "4.7", Label: "Claude Opus 4.7", ReleaseDate: "2026-04-14", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-sonnet-4-6", Provider: modelProviderAnthropic, Family: "sonnet", Version: "4.6", Label: "Claude Sonnet 4.6", ReleaseDate: "2026-02-17", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "claude-haiku-4-5", Provider: modelProviderAnthropic, Family: "haiku", Version: "4.5", Label: "Claude Haiku 4.5", ReleaseDate: "2025-10-15", Reasoning: true, ContextWindow: 200000, Preferred: true}, + {ID: "claude-haiku-4-5-20251001", Provider: modelProviderAnthropic, Family: "haiku", Version: "4.5-20251001", Label: "Claude Haiku 4.5", ReleaseDate: "2025-10-15", Reasoning: true, ContextWindow: 200000}, + + {ID: "gpt-5.5", Provider: modelProviderOpenAI, Family: "gpt", Version: "5.5", Label: "GPT-5.5", ReleaseDate: "2026-04-23", Reasoning: true, ContextWindow: 1050000, Preferred: true}, + {ID: "gpt-5.4", Provider: modelProviderOpenAI, Family: "gpt", Version: "5.4", Label: "GPT-5.4", ReleaseDate: "2026-03-05", Reasoning: true, ContextWindow: 1050000, Preferred: true}, + {ID: "gpt-5", Provider: modelProviderOpenAI, Family: "gpt", Version: "5", Label: "GPT-5", ReleaseDate: "2025-08-07", Reasoning: true, ContextWindow: 400000, Preferred: true}, + + {ID: "gemini-3.5-flash", Provider: modelProviderGoogle, Family: "gemini", Version: "3.5-flash", Label: "Gemini 3.5 Flash", ReleaseDate: "2026-05-19", Reasoning: true, ContextWindow: 1048576, Preferred: true}, + {ID: "gemini-2.5-pro", Provider: modelProviderGoogle, Family: "gemini", Version: "2.5-pro", Label: "Gemini 2.5 Pro", ReleaseDate: "2025-06-17", Reasoning: true, ContextWindow: 1048576, Preferred: true}, + {ID: "gemini-2.5-flash", Provider: modelProviderGoogle, Family: "gemini", Version: "2.5-flash", Label: "Gemini 2.5 Flash", ReleaseDate: "2025-06-17", Reasoning: true, ContextWindow: 1048576, Preferred: true}, + + {ID: "deepseek-v4-pro", Provider: modelProviderDeepSeek, Family: "deepseek", Version: "v4-pro", Label: "DeepSeek V4 Pro", ReleaseDate: "2026-04-24", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "deepseek-v4-flash", Provider: modelProviderDeepSeek, Family: "deepseek", Version: "v4-flash", Label: "DeepSeek V4 Flash", ReleaseDate: "2026-04-24", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "deepseek-reasoner", Provider: modelProviderDeepSeek, Family: "deepseek", Version: "reasoner", Label: "DeepSeek Reasoner", ReleaseDate: "2025-12-01", Reasoning: true, ContextWindow: 1000000, Preferred: true}, + {ID: "deepseek-chat", Provider: modelProviderDeepSeek, Family: "deepseek", Version: "chat", Label: "DeepSeek Chat", ReleaseDate: "2025-12-01", ContextWindow: 1000000, Preferred: true}, +} diff --git a/pkg/ai/provider.go b/pkg/ai/provider.go index 97c687e..dc980f9 100644 --- a/pkg/ai/provider.go +++ b/pkg/ai/provider.go @@ -20,6 +20,7 @@ const ( BackendCodexCLI = api.BackendCodexCLI BackendGeminiCLI = api.BackendGeminiCLI BackendClaudeAgent = api.BackendClaudeAgent + BackendCodexAgent = api.BackendCodexAgent BackendClaudeCmux = api.BackendClaudeCmux BackendCodexCmux = api.BackendCodexCmux ) diff --git a/pkg/ai/provider_test.go b/pkg/ai/provider_test.go index f3487be..b50fcc7 100644 --- a/pkg/ai/provider_test.go +++ b/pkg/ai/provider_test.go @@ -68,6 +68,7 @@ func TestAuthEnvVars(t *testing.T) { BackendClaudeCmux: nil, BackendOpenAI: {"OPENAI_API_KEY"}, BackendCodexCLI: {"OPENAI_API_KEY"}, + BackendCodexAgent: {"OPENAI_API_KEY"}, BackendCodexCmux: nil, BackendDeepSeek: {"DEEPSEEK_API_KEY"}, BackendGemini: {"GEMINI_API_KEY", "GOOGLE_API_KEY"}, diff --git a/pkg/ai/runtime_selector.go b/pkg/ai/runtime_selector.go new file mode 100644 index 0000000..a08bf8a --- /dev/null +++ b/pkg/ai/runtime_selector.go @@ -0,0 +1,383 @@ +package ai + +import ( + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/api" +) + +type selectorFamily string + +const ( + selectorFamilyClaude selectorFamily = "claude" + selectorFamilyCodex selectorFamily = "codex" + selectorFamilyGemini selectorFamily = "gemini" + selectorFamilyDeepSeek selectorFamily = "deepseek" +) + +// ContainsRuntimeSelector reports whether s contains a backend/mode selector such +// as "cmux:gpt-5.5" or "*:sonnet-5". Comma-separated fallback lists are scanned +// item by item. +func ContainsRuntimeSelector(s string) bool { + for _, part := range splitSelectorCSV(s) { + prefix, _, ok := strings.Cut(part, ":") + if !ok { + continue + } + if isSelectorPrefix(strings.TrimSpace(prefix)) { + return true + } + } + return false +} + +// ResolveModelSelectors normalizes selector-prefixed model names on a single +// api.Model, preserving the model's fallback semantics. +func ResolveModelSelectors(model api.Model) (api.Model, error) { + model = model.ExpandCSV() + resolved, err := resolveSelectorModel(model, "", false) + if err != nil { + return api.Model{}, err + } + out := mergeModelRuntime(model, resolved) + out.Fallbacks = make([]api.Model, 0, len(model.Fallbacks)) + for _, fb := range model.Fallbacks { + rfb, err := resolveSelectorModel(fb, "", false) + if err != nil { + return api.Model{}, err + } + out.Fallbacks = append(out.Fallbacks, mergeModelRuntime(fb, rfb)) + } + return out, nil +} + +// ResolveRuntimeSelectors expands --multi-models values into concrete runtime +// model/backend pairs. Values may be repeated and/or comma-separated. +func ResolveRuntimeSelectors(values []string, base api.Model) ([]api.Model, error) { + base, err := ResolveModelSelectors(base) + if err != nil { + return nil, err + } + parts := splitSelectorValues(values) + out := make([]api.Model, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + models, err := resolveSelectorPart(part, base.Name, "", true) + if err != nil { + return nil, err + } + for _, m := range models { + m.Temperature = base.Temperature + m.Effort = base.Effort + m.NoCache = base.NoCache + key := string(m.Backend) + "\x00" + m.Name + if seen[key] { + continue + } + seen[key] = true + out = append(out, m) + } + } + return out, nil +} + +func resolveSelectorModel(model api.Model, baseName string, allowWildcard bool) (api.Model, error) { + if strings.TrimSpace(model.Name) == "" { + return model, nil + } + resolved, err := resolveSelectorPart(model.Name, baseName, model.Backend, allowWildcard) + if err != nil { + return api.Model{}, err + } + if len(resolved) != 1 { + return api.Model{}, fmt.Errorf("wildcard selector %q is only valid for --multi-models", model.Name) + } + return resolved[0], nil +} + +func resolveSelectorPart(raw, baseName string, forced api.Backend, allowWildcard bool) ([]api.Model, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty runtime selector") + } + + prefix, modelName, hasPrefix := strings.Cut(raw, ":") + prefix = strings.ToLower(strings.TrimSpace(prefix)) + modelName = strings.TrimSpace(modelName) + if !hasPrefix { + if isSelectorPrefix(raw) { + prefix = strings.ToLower(raw) + modelName = strings.TrimSpace(baseName) + hasPrefix = true + } else { + return []api.Model{{Name: raw, Backend: forced}}, nil + } + } + if !isSelectorPrefix(prefix) { + return nil, fmt.Errorf("unknown runtime selector prefix %q in %q", prefix, raw) + } + if modelName == "" { + modelName = strings.TrimSpace(baseName) + } + if modelName == "" { + return nil, fmt.Errorf("runtime selector %q needs a model, or --model must provide a base model", raw) + } + if prefix == "*" { + if !allowWildcard { + return nil, fmt.Errorf("wildcard selector %q is only valid for --multi-models", raw) + } + family, err := selectorModelFamily(modelName) + if err != nil { + return nil, err + } + backends := wildcardBackends(family) + out := make([]api.Model, 0, len(backends)) + for _, backend := range backends { + out = append(out, api.Model{Name: normalizeSelectorModel(backend, modelName), Backend: backend}) + } + return out, nil + } + + backend, err := selectorBackend(prefix, modelName) + if err != nil { + return nil, err + } + if forced != "" && backend != forced { + return nil, fmt.Errorf("runtime selector %q resolves to backend %q but --backend is %q", raw, backend, forced) + } + return []api.Model{{Name: normalizeSelectorModel(backend, modelName), Backend: backend}}, nil +} + +func selectorBackend(prefix, model string) (api.Backend, error) { + if b := api.Backend(prefix); b.Valid() { + return b, nil + } + family, err := selectorModelFamily(model) + if err != nil { + return "", err + } + switch prefix { + case "api": + switch family { + case selectorFamilyClaude: + return api.BackendAnthropic, nil + case selectorFamilyCodex: + return api.BackendOpenAI, nil + case selectorFamilyGemini: + return api.BackendGemini, nil + case selectorFamilyDeepSeek: + return api.BackendDeepSeek, nil + } + case "cli": + switch family { + case selectorFamilyClaude: + return api.BackendClaudeCLI, nil + case selectorFamilyCodex: + return api.BackendCodexCLI, nil + case selectorFamilyGemini: + return api.BackendGeminiCLI, nil + } + case "agent", "sdk": + switch family { + case selectorFamilyClaude: + return api.BackendClaudeAgent, nil + case selectorFamilyCodex: + return api.BackendCodexAgent, nil + } + case "cmux": + switch family { + case selectorFamilyClaude: + return api.BackendClaudeCmux, nil + case selectorFamilyCodex: + return api.BackendCodexCmux, nil + } + } + return "", fmt.Errorf("runtime selector %q does not support %s models", prefix, family) +} + +func selectorModelFamily(model string) (selectorFamily, error) { + m := strings.ToLower(strings.TrimSpace(model)) + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + switch { + case strings.HasPrefix(m, "claude-"), + strings.HasPrefix(m, "claude-agent-"), + strings.HasPrefix(m, "claude-code-"), + strings.HasPrefix(m, "opus"), + strings.HasPrefix(m, "sonnet"), + strings.HasPrefix(m, "haiku"), + strings.HasPrefix(m, "fable"): + return selectorFamilyClaude, nil + case strings.HasPrefix(m, "gemini-"), strings.HasPrefix(m, "models/gemini-"): + return selectorFamilyGemini, nil + case strings.HasPrefix(m, "deepseek"): + return selectorFamilyDeepSeek, nil + case strings.HasPrefix(m, "codex"), + strings.HasPrefix(m, "gpt-"), + strings.HasPrefix(m, "o1"), + strings.HasPrefix(m, "o3"), + strings.HasPrefix(m, "o4"): + return selectorFamilyCodex, nil + default: + if b, err := api.InferBackend(model); err == nil { + switch b { + case api.BackendAnthropic, api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux: + return selectorFamilyClaude, nil + case api.BackendGemini, api.BackendGeminiCLI: + return selectorFamilyGemini, nil + case api.BackendDeepSeek: + return selectorFamilyDeepSeek, nil + case api.BackendOpenAI, api.BackendCodexCLI, api.BackendCodexAgent, api.BackendCodexCmux: + return selectorFamilyCodex, nil + } + } + return "", fmt.Errorf("cannot infer model family for %q", model) + } +} + +func wildcardBackends(family selectorFamily) []api.Backend { + switch family { + case selectorFamilyClaude: + return []api.Backend{api.BackendAnthropic, api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux} + case selectorFamilyCodex: + return []api.Backend{api.BackendOpenAI, api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux} + case selectorFamilyGemini: + return []api.Backend{api.BackendGemini, api.BackendGeminiCLI} + case selectorFamilyDeepSeek: + return []api.Backend{api.BackendDeepSeek} + default: + return nil + } +} + +func normalizeSelectorModel(backend api.Backend, model string) string { + return NormalizeModelForBackend(backend, model) +} + +// NormalizeModelForBackend maps a catalog/live/provider model id onto the exact +// runtime id accepted by backend. Provider prefixes and old backend-specific +// aliases are input compatibility only; the returned value is never a family +// alias such as "opus" or a synthetic id such as "claude-agent-opus". +func NormalizeModelForBackend(backend api.Backend, model string) string { + resolved, _ := ResolveExactModelForBackend(backend, model) + return resolved +} + +func lookupSelectorCatalogModel(backend api.Backend, model string) (string, bool) { + want := selectorCatalogBackend(backend) + for _, m := range Catalog() { + if m.Backend != want { + continue + } + if !selectorCatalogMatch(m, model, backend) { + continue + } + if m.IsAgent() { + if m.AgentModel != "" { + return m.AgentModel, true + } + return m.ID, true + } + return m.BareID(), true + } + return "", false +} + +func selectorCatalogBackend(backend api.Backend) api.Backend { + switch backend { + case api.BackendClaudeCLI, api.BackendClaudeCmux: + return api.BackendClaudeAgent + case api.BackendCodexCLI, api.BackendCodexCmux: + return api.BackendCodexAgent + default: + return backend + } +} + +func selectorCatalogMatch(m Model, model string, backend api.Backend) bool { + needle := strings.ToLower(strings.TrimSpace(model)) + if i := strings.LastIndex(needle, "/"); i >= 0 { + needle = needle[i+1:] + } + candidates := []string{m.ID, m.BareID(), m.AgentModel} + for _, c := range candidates { + c = strings.ToLower(strings.TrimSpace(c)) + if c == "" { + continue + } + if c == needle { + return true + } + if i := strings.LastIndex(c, "/"); i >= 0 && c[i+1:] == needle { + return true + } + } + catalogBackend := selectorCatalogBackend(backend) + if catalogBackend == api.BackendClaudeAgent || backend == api.BackendAnthropic { + if tier := claudeTier(needle); tier != "" && tier == claudeTier(m.ID) { + if catalogBackend == api.BackendClaudeAgent { + return true + } + if strings.Contains(needle, "-") { + return strings.Contains(strings.ToLower(m.ID), needle) + } + return true + } + } + return false +} + +func claudeTier(model string) string { + m := strings.ToLower(model) + for _, tier := range []string{"fable", "opus", "sonnet", "haiku"} { + if strings.Contains(m, tier) { + return tier + } + } + return "" +} + +func mergeModelRuntime(original, resolved api.Model) api.Model { + out := original + if strings.TrimSpace(resolved.Name) != "" { + out.Name = resolved.Name + } + if resolved.ID != "" { + out.ID = resolved.ID + } + if resolved.Backend != "" { + out.Backend = resolved.Backend + } + return out +} + +func isSelectorPrefix(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + switch s { + case "api", "agent", "cli", "sdk", "cmux", "*": + return true + default: + return api.Backend(s).Valid() + } +} + +func splitSelectorValues(values []string) []string { + var out []string + for _, value := range values { + out = append(out, splitSelectorCSV(value)...) + } + return out +} + +func splitSelectorCSV(value string) []string { + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/pkg/ai/runtime_selector_test.go b/pkg/ai/runtime_selector_test.go new file mode 100644 index 0000000..5e9e835 --- /dev/null +++ b/pkg/ai/runtime_selector_test.go @@ -0,0 +1,173 @@ +package ai + +import ( + "reflect" + "testing" + + "github.com/flanksource/captain/pkg/api" +) + +func TestResolveModelSelectors(t *testing.T) { + cases := []struct { + name string + in api.Model + wantName string + wantBackend api.Backend + }{ + { + name: "api claude shorthand", + in: api.Model{Name: "api:sonnet-5"}, + wantName: "claude-sonnet-5", + wantBackend: api.BackendAnthropic, + }, + { + name: "cmux codex shorthand", + in: api.Model{Name: "cmux:gpt-5.5"}, + wantName: "gpt-5.5", + wantBackend: api.BackendCodexCmux, + }, + { + name: "agent claude shorthand", + in: api.Model{Name: "agent:opus"}, + wantName: "claude-opus-4-8", + wantBackend: api.BackendClaudeAgent, + }, + { + name: "agent codex app server", + in: api.Model{Name: "agent:gpt-5.5"}, + wantName: "gpt-5.5", + wantBackend: api.BackendCodexAgent, + }, + { + name: "exact backend", + in: api.Model{Name: "claude-cmux:opus"}, + wantName: "claude-opus-4-8", + wantBackend: api.BackendClaudeCmux, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveModelSelectors(tc.in) + if err != nil { + t.Fatalf("ResolveModelSelectors: %v", err) + } + if got.Name != tc.wantName || got.Backend != tc.wantBackend { + t.Fatalf("got %s/%s, want %s/%s", got.Backend, got.Name, tc.wantBackend, tc.wantName) + } + }) + } +} + +func TestNormalizeModelForBackend(t *testing.T) { + cases := []struct { + backend api.Backend + model string + want string + }{ + {api.BackendClaudeAgent, "opus-4-8", "claude-opus-4-8"}, + {api.BackendClaudeCmux, "claude-opus-4-8", "claude-opus-4-8"}, + {api.BackendClaudeCLI, "fable-5", "claude-fable-5"}, + {api.BackendAnthropic, "opus-4-8", "claude-opus-4-8"}, + {api.BackendClaudeAgent, "claude-agent-opus", "claude-opus-4-8"}, + {api.BackendClaudeAgent, "sonnet", "claude-sonnet-5"}, + {api.BackendClaudeAgent, "sonnet-4", "claude-sonnet-4-6"}, + {api.BackendClaudeAgent, "claude-sonnet-4-5", "claude-sonnet-4-6"}, + {api.BackendClaudeAgent, "haiku", "claude-haiku-4-5"}, + {api.BackendClaudeAgent, "haiku-4", "claude-haiku-4-5"}, + {api.BackendClaudeAgent, "haiku-4.5", "claude-haiku-4-5"}, + {api.BackendCodexCmux, "openai/gpt-5.5", "gpt-5.5"}, + } + for _, tc := range cases { + if got := NormalizeModelForBackend(tc.backend, tc.model); got != tc.want { + t.Fatalf("NormalizeModelForBackend(%s, %q) = %q, want %q", tc.backend, tc.model, got, tc.want) + } + } +} + +func TestParseModelIdentity(t *testing.T) { + cases := []struct { + model string + want ModelIdentity + }{ + {"opus-4-8", ModelIdentity{Provider: modelProviderAnthropic, Family: "opus", Version: "4.8"}}, + {"claude-opus-4-8-3003", ModelIdentity{Provider: modelProviderAnthropic, Family: "opus", Version: "4.8-3003"}}, + {"claude-agent-sonnet", ModelIdentity{Provider: modelProviderAnthropic, Family: "sonnet"}}, + {"openai/gpt-5.5", ModelIdentity{Provider: modelProviderOpenAI, Family: "gpt", Version: "5.5"}}, + {"codex-gpt-5.4", ModelIdentity{Provider: modelProviderOpenAI, Family: "gpt", Version: "5.4"}}, + } + for _, tc := range cases { + got, ok := ParseModelIdentity(modelProviderAnthropic, tc.model) + if !ok { + t.Fatalf("ParseModelIdentity(%q) did not parse", tc.model) + } + if got != tc.want { + t.Fatalf("ParseModelIdentity(%q) = %+v, want %+v", tc.model, got, tc.want) + } + } +} + +func TestResolveModelSelectors_FallbackSelectors(t *testing.T) { + got, err := ResolveModelSelectors(api.Model{Name: "api:sonnet-5,cmux:opus"}) + if err != nil { + t.Fatalf("ResolveModelSelectors: %v", err) + } + if got.Name != "claude-sonnet-5" || got.Backend != api.BackendAnthropic { + t.Fatalf("primary = %s/%s", got.Backend, got.Name) + } + if len(got.Fallbacks) != 1 { + t.Fatalf("fallback count = %d, want 1", len(got.Fallbacks)) + } + if fb := got.Fallbacks[0]; fb.Name != "claude-opus-4-8" || fb.Backend != api.BackendClaudeCmux { + t.Fatalf("fallback = %s/%s, want %s/%s", fb.Backend, fb.Name, api.BackendClaudeCmux, "claude-opus-4-8") + } +} + +func TestResolveRuntimeSelectors(t *testing.T) { + got, err := ResolveRuntimeSelectors( + []string{"cli:sonnet-5,cmux:opus", "*:gpt-5.5"}, + api.Model{Name: "claude-sonnet-5", Backend: api.BackendAnthropic}, + ) + if err != nil { + t.Fatalf("ResolveRuntimeSelectors: %v", err) + } + var labels []string + for _, model := range got { + labels = append(labels, string(model.Backend)+":"+model.Name) + } + want := []string{ + "claude-cli:claude-sonnet-5", + "claude-cmux:claude-opus-4-8", + "openai:gpt-5.5", + "codex-agent:gpt-5.5", + "codex-cli:gpt-5.5", + "codex-cmux:gpt-5.5", + } + if !reflect.DeepEqual(labels, want) { + t.Fatalf("labels = %#v, want %#v", labels, want) + } +} + +func TestResolveRuntimeSelectors_RequiresModelForPrefixOnly(t *testing.T) { + got, err := ResolveRuntimeSelectors([]string{"cmux"}, api.Model{Name: "gpt-5.5"}) + if err != nil { + t.Fatalf("ResolveRuntimeSelectors: %v", err) + } + if len(got) != 1 || got[0].Backend != api.BackendCodexCmux || got[0].Name != "gpt-5.5" { + t.Fatalf("got %#v, want codex-cmux:gpt-5.5", got) + } + if _, err := ResolveRuntimeSelectors([]string{"cmux"}, api.Model{}); err == nil { + t.Fatal("expected missing base model error") + } +} + +func TestResolveModelSelectors_WildcardOnlyForMultiModels(t *testing.T) { + if _, err := ResolveModelSelectors(api.Model{Name: "*:sonnet-5"}); err == nil { + t.Fatal("expected wildcard selector to be rejected for single model") + } +} + +func TestResolveModelSelectors_UnknownPrefixFails(t *testing.T) { + if _, err := ResolveModelSelectors(api.Model{Name: "nope:sonnet-5"}); err == nil { + t.Fatal("expected unknown selector prefix error") + } +} diff --git a/pkg/api/enums.go b/pkg/api/enums.go index 83089ac..71648e9 100644 --- a/pkg/api/enums.go +++ b/pkg/api/enums.go @@ -28,6 +28,7 @@ const ( BackendCodexCLI Backend = "codex-cli" BackendGeminiCLI Backend = "gemini-cli" BackendClaudeAgent Backend = "claude-agent" + BackendCodexAgent Backend = "codex-agent" // BackendClaudeCmux / BackendCodexCmux drive an interactive claude/codex TUI // inside a tmux/cmux surface (the cmux provider), tailing the session JSONL. // They are selected explicitly, not inferred from a model name. @@ -41,7 +42,7 @@ func AllBackends() []Backend { return []Backend{ BackendAnthropic, BackendGemini, BackendOpenAI, BackendDeepSeek, BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux, - BackendCodexCLI, BackendCodexCmux, BackendGeminiCLI, + BackendCodexCLI, BackendCodexAgent, BackendCodexCmux, BackendGeminiCLI, } } @@ -73,7 +74,7 @@ func AuthEnvVars(b Backend) []string { switch b { case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: return []string{"ANTHROPIC_API_KEY"} - case BackendOpenAI, BackendCodexCLI: + case BackendOpenAI, BackendCodexCLI, BackendCodexAgent: return []string{"OPENAI_API_KEY"} case BackendDeepSeek: return []string{"DEEPSEEK_API_KEY"} @@ -104,6 +105,8 @@ func InferBackend(model string) (Backend, error) { return BackendClaudeAgent, nil case strings.HasPrefix(m, "claude-code-"): return BackendClaudeCLI, nil + case strings.HasPrefix(m, "codex-agent-"): + return BackendCodexAgent, nil case strings.HasPrefix(m, "codex"): return BackendCodexCLI, nil case strings.HasPrefix(m, "gemini-cli-"): @@ -111,7 +114,7 @@ func InferBackend(model string) (Backend, error) { } switch { - case strings.HasPrefix(m, "claude-"): + case strings.HasPrefix(m, "claude-"), strings.HasPrefix(m, "opus"), strings.HasPrefix(m, "sonnet"), strings.HasPrefix(m, "haiku"), strings.HasPrefix(m, "fable"): return BackendAnthropic, nil case strings.HasPrefix(m, "gemini-"), strings.HasPrefix(m, "models/gemini-"): return BackendGemini, nil diff --git a/pkg/api/enums_test.go b/pkg/api/enums_test.go index 5776623..d70037a 100644 --- a/pkg/api/enums_test.go +++ b/pkg/api/enums_test.go @@ -10,6 +10,8 @@ func TestInferBackend(t *testing.T) { "claude-sonnet-4-6": BackendAnthropic, "claude-agent-opus": BackendClaudeAgent, "claude-code-x": BackendClaudeCLI, + "opus-4-8": BackendAnthropic, + "sonnet": BackendAnthropic, "gpt-5.5": BackendOpenAI, "o3": BackendOpenAI, "gemini-2.5-pro": BackendGemini, diff --git a/pkg/api/workflow.go b/pkg/api/workflow.go index 8739462..fd35b45 100644 --- a/pkg/api/workflow.go +++ b/pkg/api/workflow.go @@ -1,25 +1,19 @@ package api -import ( - "encoding/json" - "fmt" -) +import "fmt" // Workflow declares the generate→verify loop around a run as hook // declarations: an optional verification stage (commands run after each -// generation, whose failure feedback drives a re-run), an optional postRun -// stage (commit the result), and an optional typed output schema. It is the -// serializable form of pkg/ai/agent's Verify/PostRun/Output hooks, and mirrors -// clicky-ui's AISpecRuntimeLocalWorkflow so the SpecRuntimeEditor "Verify" -// section round-trips. +// generation, whose failure feedback drives a re-run), and an optional postRun +// stage (commit the result). It is the serializable form of pkg/ai/agent's +// Verify/PostRun hooks, and mirrors clicky-ui's AISpecRuntimeLocalWorkflow so +// the SpecRuntimeEditor "Verify" section round-trips. // // A spec with a Verify but an empty Prompt.User runs verify-only (generation is // skipped); a spec with no Verify runs generate-only (today's behaviour). type Workflow struct { Verify *Verify `json:"verify,omitempty" yaml:"verify,omitempty"` PostRun *PostRun `json:"postRun,omitempty" yaml:"postRun,omitempty"` - // Output declares the workflow's typed final-result schema. - Output *Output `json:"output,omitempty" yaml:"output,omitempty"` } // Verify is the loop's definition-of-done: it runs after each generation and @@ -50,12 +44,6 @@ type PostRun struct { KeepWorktree bool `json:"keepWorktree,omitempty" yaml:"keepWorktree,omitempty"` } -// Output declares the workflow's typed final-result schema (a JSON Schema -// document) so callers/editors know the shape of the run's structured result. -type Output struct { - SchemaJSON json.RawMessage `json:"schemaJSON,omitempty" yaml:"schemaJSON,omitempty"` -} - // Validate checks the workflow's enum-typed fields. func (w *Workflow) Validate() error { if w == nil || w.Verify == nil { diff --git a/pkg/api/workflow_test.go b/pkg/api/workflow_test.go index 7af0bb8..9133469 100644 --- a/pkg/api/workflow_test.go +++ b/pkg/api/workflow_test.go @@ -18,7 +18,6 @@ func TestWorkflowSpecRoundTrip(t *testing.T) { MaxIterations: 3, }, PostRun: &PostRun{Commit: true, CommitMessage: "apply"}, - Output: &Output{SchemaJSON: json.RawMessage(`{"type":"object"}`)}, }, } @@ -43,9 +42,6 @@ func TestWorkflowSpecRoundTrip(t *testing.T) { if got.Workflow.PostRun == nil || !got.Workflow.PostRun.Commit { t.Errorf("postRun not preserved: %+v", got.Workflow.PostRun) } - if got.Workflow.Output == nil || string(got.Workflow.Output.SchemaJSON) != string(spec.Workflow.Output.SchemaJSON) { - t.Errorf("output schema not preserved: %+v", got.Workflow.Output) - } } func TestWorkflowOmittedWhenNil(t *testing.T) { @@ -68,6 +64,11 @@ func TestSpecSchemaIncludesWorkflow(t *testing.T) { t.Errorf("reflected spec schema missing %q", want) } } + for _, gone := range []string{"\"output\"", "\"Output\""} { + if strings.Contains(string(data), gone) { + t.Errorf("reflected spec schema still contains removed workflow field %s", gone) + } + } } func TestVerifyScopeValidate(t *testing.T) { From aa114b543f211e6661ea363df6f2074c12ddbd5c Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 8 Jul 2026 20:37:58 +0300 Subject: [PATCH 002/131] feat(ai): implement native Claude and Codex CLI providers and support adaptive thinking Add native ClaudeCLI and CodexCLI providers to execute CLI wrappers directly. Route the CLI backends to these new providers while preserving the agent backends for JSON-RPC servers. Also, introduce support for Anthropic adaptive thinking on Claude 5 models and map Genkit schema validation failures to ErrSchemaValidation to enable middleware-driven retries. --- pkg/ai/provider/claude_cli.go | 297 ++++++++++++++ pkg/ai/provider/claude_cli_test.go | 139 +++++++ pkg/ai/provider/claudeagent/mapping_test.go | 16 +- pkg/ai/provider/claudeagent/provider.go | 3 +- pkg/ai/provider/claudeagent/provider_test.go | 16 +- pkg/ai/provider/claudeagent/runner.go | 18 +- pkg/ai/provider/cli.go | 90 ++++- pkg/ai/provider/cmux/executor.go | 12 +- pkg/ai/provider/cmux/executor_test.go | 4 +- pkg/ai/provider/cmux/provider.go | 3 +- pkg/ai/provider/cmux/provider_test.go | 9 +- pkg/ai/provider/coalesce.go | 13 +- pkg/ai/provider/coalesce_test.go | 15 + pkg/ai/provider/codex_appserver.go | 5 +- pkg/ai/provider/codex_appserver_test.go | 6 +- pkg/ai/provider/codex_cli.go | 395 +++++++++++++++++++ pkg/ai/provider/codex_cli_test.go | 93 +++++ pkg/ai/provider/gemini_cli.go | 3 +- pkg/ai/provider/genkit/genkit.go | 17 + pkg/ai/provider/genkit/genkit_test.go | 77 +++- pkg/ai/provider/genkit/options.go | 33 +- pkg/ai/provider/init.go | 8 +- 22 files changed, 1217 insertions(+), 55 deletions(-) create mode 100644 pkg/ai/provider/claude_cli.go create mode 100644 pkg/ai/provider/claude_cli_test.go create mode 100644 pkg/ai/provider/codex_cli.go create mode 100644 pkg/ai/provider/codex_cli_test.go diff --git a/pkg/ai/provider/claude_cli.go b/pkg/ai/provider/claude_cli.go new file mode 100644 index 0000000..9784e62 --- /dev/null +++ b/pkg/ai/provider/claude_cli.go @@ -0,0 +1,297 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/claude" +) + +var claudeCLICommand = "claude" + +type ClaudeCLI struct { + model string +} + +func NewClaudeCLI(model string) *ClaudeCLI { + if strings.TrimSpace(model) == "" { + model = "claude-sonnet-5" + } + model = ai.NormalizeModelForBackend(ai.BackendClaudeCLI, model) + return &ClaudeCLI{model: model} +} + +func (c *ClaudeCLI) GetModel() string { return c.model } +func (c *ClaudeCLI) GetBackend() ai.Backend { return ai.BackendClaudeCLI } + +func (c *ClaudeCLI) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { + start := time.Now() + events, err := c.ExecuteStream(ctx, req) + if err != nil { + return nil, err + } + resp, err := CoalesceStreamForBackend(ctx, ai.BackendClaudeCLI, c.model, events, start) + if err != nil { + return nil, err + } + if req.Prompt.Schema != nil { + raw, _ := resp.StructuredData.(json.RawMessage) + if len(raw) == 0 { + raw = json.RawMessage(resp.Text) + } + if err := ai.BindStructuredOutput(req.Prompt.Schema, raw); err != nil { + return nil, err + } + resp.StructuredData = req.Prompt.Schema + resp.Text = "" + } + return resp, nil +} + +func (c *ClaudeCLI) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai.Event, error) { + args, err := buildClaudeCLIArgs(c.model, req) + if err != nil { + return nil, err + } + env := []string(nil) + if req.Setup != nil { + env = commandEnv(req.Setup.Env) + } + cmd, stdout, stderrBuf, err := startCLIStream(ctx, claudeCLICommand, args, []byte(composePrompt(req)), req.Cwd(), env) + if err != nil { + return nil, err + } + out := make(chan ai.Event, 16) + go func() { + defer close(out) + defer func() { _ = stdout.Close() }() + iterator := claude.NewStreamJSONIterator(stdout) + for iterator.Next() { + for _, ev := range claudeEntryEvents(iterator.Entry(), c.model) { + emit(ctx, out, ev) + } + } + if err := iterator.Err(); err != nil { + emit(ctx, out, ai.Event{Kind: ai.EventError, Error: fmt.Sprintf("claude stream-json: %v", err), Model: c.model}) + } + if err := finishCLIStream(ctx, cmd, stderrBuf); err != nil { + emit(ctx, out, ai.Event{Kind: ai.EventError, Error: err.Error(), Model: c.model}) + } + }() + return out, nil +} + +func buildClaudeCLIArgs(model string, req ai.Request) ([]string, error) { + args := []string{"-p", "--verbose", "--output-format", "stream-json"} + if m := claudeCLIModel(model); m != "" { + args = append(args, "--model", m) + } + if req.Prompt.System != "" { + args = append(args, "--system-prompt", req.Prompt.System) + } + if req.Prompt.AppendSystem != "" { + args = append(args, "--append-system-prompt", req.Prompt.AppendSystem) + } + if req.SessionID != "" { + args = append(args, "--resume", req.SessionID) + } + if req.Effort != "" { + args = append(args, "--effort", string(req.Effort)) + } + if req.Budget.Cost > 0 { + args = append(args, "--max-budget-usd", fmt.Sprintf("%g", req.Budget.Cost)) + } + if mode := cliClaudePermissionMode(req.Permissions.Mode); mode != "" { + args = append(args, "--permission-mode", mode) + } + if len(req.Permissions.Tools.Allow) > 0 { + args = append(args, "--allowedTools", strings.Join(req.Permissions.Tools.Allow, ",")) + } + if len(req.Permissions.Tools.Deny) > 0 { + args = append(args, "--disallowedTools", strings.Join(req.Permissions.Tools.Deny, ",")) + } + for _, dir := range req.Memory.Skills { + if strings.TrimSpace(dir) != "" { + args = append(args, "--plugin-dir", dir) + } + } + if req.Memory.SkipSkills { + args = append(args, "--disable-slash-commands") + } + if req.Memory.Bare || req.Permissions.HasPreset(api.PresetBare) { + args = append(args, "--bare") + } + if req.Permissions.MCP.Disabled { + args = append(args, "--mcp-config", "{}", "--strict-mcp-config") + } + schema, err := ai.SchemaJSONFor(req.Prompt) + if err != nil { + return nil, fmt.Errorf("claude-cli: cannot derive structured-output schema: %w", err) + } + if len(schema) > 0 { + args = append(args, "--json-schema", string(schema)) + } + return args, nil +} + +func claudeCLIModel(model string) string { + model = strings.TrimSpace(model) + if model == "claude" { + return "" + } + return ai.NormalizeModelForBackend(ai.BackendClaudeCLI, model) +} + +func cliClaudePermissionMode(mode api.PermissionMode) string { + switch mode { + case "", api.PermissionDefault: + return "" + case api.PermissionAuto: + return "auto" + case api.PermissionAcceptEdits: + return "acceptEdits" + case api.PermissionBypass: + return "bypassPermissions" + case api.PermissionDontAsk: + return "dontAsk" + case api.PermissionPlan: + return "plan" + default: + return string(mode) + } +} + +func claudeEntryEvents(entry claude.HistoryEntry, fallbackModel string) []ai.Event { + model := firstNonEmpty(entry.Message.Model, fallbackModel) + var out []ai.Event + for _, block := range entry.Message.Content { + switch block.Type { + case claude.ContentTypeText: + if block.Text != "" && entry.Message.Role == claude.MessageRoleAssistant { + out = append(out, ai.Event{Kind: ai.EventText, Text: block.Text, SessionID: entry.SessionID, Model: model}) + } + case claude.ContentTypeThinking: + if block.Thinking != "" { + out = append(out, ai.Event{Kind: ai.EventThinking, Text: block.Thinking, SessionID: entry.SessionID, Model: model}) + } + case claude.ContentTypeToolUse: + input := map[string]any{} + if len(block.Input) > 0 { + _ = json.Unmarshal(block.Input, &input) + } + ev := ai.Event{Kind: ai.EventToolUse, Tool: block.Name, Input: input, ToolCallID: block.ID, SessionID: entry.SessionID, Model: model, Raw: entry} + switch block.Name { + case "SessionInit": + ev.Kind = ai.EventSystem + ev.Tool = "SessionInit" + if session, _ := input["session_id"].(string); session != "" { + ev.SessionID = session + } + case "Result": + ev = claudeResultEvent(entry, block, input, model) + case "ApiError": + ev.Kind = ai.EventError + ev.Error = firstString(input, "error", "message", "result") + ev.Raw = entry + } + out = append(out, ev) + case claude.ContentTypeToolResult: + ev := ai.Event{Kind: ai.EventToolResult, Text: string(block.Content), ToolCallID: block.ToolUseID, Success: !block.IsError, SessionID: entry.SessionID, Model: model, Raw: entry} + out = append(out, ev) + } + } + return out +} + +func claudeResultEvent(entry claude.HistoryEntry, block claude.ContentBlock, input map[string]any, model string) ai.Event { + ev := ai.Event{ + Kind: ai.EventResult, + Tool: "Result", + Success: !truthy(input["is_error"]), + SessionID: entry.SessionID, + Model: model, + Input: input, + Raw: entry, + } + if result, _ := input["result"].(string); result != "" { + ev.Text = result + } + if ev.SessionID == "" { + if session, _ := input["session_id"].(string); session != "" { + ev.SessionID = session + } + } + if errText := firstString(input, "error", "result"); !ev.Success && errText != "" { + ev.Error = errText + } + if cost, ok := number(input["total_cost_usd"]); ok { + ev.CostUSD = cost + } + if entry.Message.Usage != nil { + ev.Usage = &ai.Usage{ + InputTokens: entry.Message.Usage.InputTokens, + OutputTokens: entry.Message.Usage.OutputTokens, + CacheReadTokens: entry.Message.Usage.CacheReadInputTokens, + CacheWriteTokens: entry.Message.Usage.CacheCreationInputTokens, + } + } + if len(block.Input) > 0 { + ev.StructuredData = structuredJSONFromResult(input) + } + return ev +} + +func structuredJSONFromResult(input map[string]any) json.RawMessage { + result, _ := input["result"].(string) + if strings.TrimSpace(result) == "" { + return nil + } + var js json.RawMessage + if json.Unmarshal([]byte(result), &js) == nil { + return js + } + return nil +} + +func firstString(input map[string]any, keys ...string) string { + for _, key := range keys { + if v, ok := input[key].(string); ok && v != "" { + return v + } + } + return "" +} + +func truthy(v any) bool { + switch x := v.(type) { + case bool: + return x + case string: + return x == "true" + default: + return false + } +} + +func number(v any) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case float32: + return float64(x), true + case int: + return float64(x), true + case int64: + return float64(x), true + case json.Number: + f, err := x.Float64() + return f, err == nil + default: + return 0, false + } +} diff --git a/pkg/ai/provider/claude_cli_test.go b/pkg/ai/provider/claude_cli_test.go new file mode 100644 index 0000000..ea99d16 --- /dev/null +++ b/pkg/ai/provider/claude_cli_test.go @@ -0,0 +1,139 @@ +package provider + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/claude" +) + +func TestBuildClaudeCLIArgs(t *testing.T) { + req := ai.Request{ + Prompt: api.Prompt{ + User: "hello", + System: "system prompt", + AppendSystem: "append system", + SchemaJSON: json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}}}`), + }, + Model: api.Model{Effort: api.EffortHigh}, + Budget: api.Budget{Cost: 1.25}, + SessionID: "sess-1", + Memory: api.Memory{ + Skills: []string{"/skills/a", " "}, + SkipSkills: true, + Bare: true, + }, + Permissions: api.Permissions{ + Mode: api.PermissionAcceptEdits, + Tools: api.Tools{ + Allow: []string{"Read", "Grep"}, + Deny: []string{"Bash"}, + }, + MCP: api.MCP{Disabled: true}, + }, + } + + args, err := buildClaudeCLIArgs("claude-sonnet-5", req) + if err != nil { + t.Fatalf("buildClaudeCLIArgs: %v", err) + } + wantPrefix := []string{"-p", "--verbose", "--output-format", "stream-json"} + if !reflect.DeepEqual(args[:len(wantPrefix)], wantPrefix) { + t.Fatalf("args prefix = %v, want %v", args[:len(wantPrefix)], wantPrefix) + } + requireFlagValue(t, args, "--model", "claude-sonnet-5") + requireFlagValue(t, args, "--system-prompt", "system prompt") + requireFlagValue(t, args, "--append-system-prompt", "append system") + requireFlagValue(t, args, "--resume", "sess-1") + requireFlagValue(t, args, "--effort", "high") + requireFlagValue(t, args, "--max-budget-usd", "1.25") + requireFlagValue(t, args, "--permission-mode", "acceptEdits") + requireFlagValue(t, args, "--allowedTools", "Read,Grep") + requireFlagValue(t, args, "--disallowedTools", "Bash") + requireFlagValue(t, args, "--plugin-dir", "/skills/a") + requireFlagValue(t, args, "--mcp-config", "{}") + requireHasArg(t, args, "--strict-mcp-config") + requireHasArg(t, args, "--disable-slash-commands") + requireHasArg(t, args, "--bare") + schema := flagValue(t, args, "--json-schema") + if !json.Valid([]byte(schema)) { + t.Fatalf("--json-schema = %q, want valid JSON", schema) + } +} + +func TestClaudeEntryEventsMapsTextThinkingAndResult(t *testing.T) { + entry := claude.HistoryEntry{ + SessionID: "sess-1", + Message: claude.Message{ + Model: "sonnet", + Role: claude.MessageRoleAssistant, + Usage: &claude.Usage{InputTokens: 11, OutputTokens: 7, CacheReadInputTokens: 3, CacheCreationInputTokens: 2}, + Content: []claude.ContentBlock{ + {Type: claude.ContentTypeThinking, Thinking: "thinking"}, + {Type: claude.ContentTypeText, Text: "hello"}, + { + Type: claude.ContentTypeToolUse, + ID: "result-1", + Name: "Result", + Input: json.RawMessage(`{"result":"{\"answer\":\"42\"}","total_cost_usd":0.5}`), + }, + }, + }, + } + + events := claudeEntryEvents(entry, "fallback") + if len(events) != 3 { + t.Fatalf("events = %+v, want 3", events) + } + if events[0].Kind != ai.EventThinking || events[0].Text != "thinking" { + t.Fatalf("thinking event = %+v", events[0]) + } + if events[1].Kind != ai.EventText || events[1].Text != "hello" { + t.Fatalf("text event = %+v", events[1]) + } + result := events[2] + if result.Kind != ai.EventResult || !result.Success || result.Text != `{"answer":"42"}` { + t.Fatalf("result event = %+v", result) + } + if string(result.StructuredData) != `{"answer":"42"}` { + t.Fatalf("StructuredData = %s, want answer JSON", result.StructuredData) + } + if result.Usage == nil || result.Usage.InputTokens != 11 || result.Usage.OutputTokens != 7 { + t.Fatalf("usage = %+v", result.Usage) + } +} + +func requireHasArg(t *testing.T, args []string, want string) { + t.Helper() + for _, arg := range args { + if arg == want { + return + } + } + t.Fatalf("args %v do not contain %q", args, want) +} + +func requireFlagValue(t *testing.T, args []string, flag, want string) { + t.Helper() + got := flagValue(t, args, flag) + if got != want { + t.Fatalf("%s = %q, want %q (args: %v)", flag, got, want, args) + } +} + +func flagValue(t *testing.T, args []string, flag string) string { + t.Helper() + for i, arg := range args { + if arg == flag { + if i+1 >= len(args) { + t.Fatalf("%s has no value in args %v", flag, args) + } + return args[i+1] + } + } + t.Fatalf("args %v do not contain flag %s", args, flag) + return "" +} diff --git a/pkg/ai/provider/claudeagent/mapping_test.go b/pkg/ai/provider/claudeagent/mapping_test.go index 9b52296..28b79ad 100644 --- a/pkg/ai/provider/claudeagent/mapping_test.go +++ b/pkg/ai/provider/claudeagent/mapping_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -const testModel = "claude-agent-sonnet" +const testModel = "claude-sonnet-5" func mustMap(t *testing.T, method, params string) ai.Event { t.Helper() @@ -215,13 +215,13 @@ func TestDecodeUsage(t *testing.T) { func TestAliasModel(t *testing.T) { cases := map[string]string{ - "claude-agent-sonnet": "sonnet", - "claude-agent-opus": "opus", - "claude-code-haiku": "haiku", - "claude-sonnet-4-5": "claude-sonnet-4-5", - "claude-agent-": "sonnet", - "": "sonnet", - "claude-agent-opus-4-1": "opus-4-1", + "claude-agent-sonnet": "claude-sonnet-5", + "claude-agent-opus": "claude-opus-4-8", + "claude-code-haiku": "claude-haiku-4-5", + "claude-sonnet-4-5": "claude-sonnet-4-6", + "claude-agent-": "claude-sonnet-5", + "": "claude-sonnet-5", + "claude-agent-opus-4-1": "claude-opus-4-8", } for in, want := range cases { assert.Equalf(t, want, aliasModel(in), "aliasModel(%q)", in) diff --git a/pkg/ai/provider/claudeagent/provider.go b/pkg/ai/provider/claudeagent/provider.go index d1e70ce..dfbb15d 100644 --- a/pkg/ai/provider/claudeagent/provider.go +++ b/pkg/ai/provider/claudeagent/provider.go @@ -47,7 +47,7 @@ const ( const methodCanUseTool = "can_use_tool" const ( - defaultModel = "claude-agent-sonnet" + defaultModel = "claude-sonnet-5" // initTimeout bounds the initialize handshake (after provisioning). The npm // install / tsx cold start happen synchronously before this window. initTimeout = 2 * time.Minute @@ -126,6 +126,7 @@ func New(cfg ai.Config) (*Provider, error) { if model == "" { model = defaultModel } + model = ai.NormalizeModelForBackend(ai.BackendClaudeAgent, model) ctx, cancel := context.WithCancel(context.Background()) return &Provider{ model: model, diff --git a/pkg/ai/provider/claudeagent/provider_test.go b/pkg/ai/provider/claudeagent/provider_test.go index 7de86ff..6725acd 100644 --- a/pkg/ai/provider/claudeagent/provider_test.go +++ b/pkg/ai/provider/claudeagent/provider_test.go @@ -161,12 +161,12 @@ func withFakeAgentProcessEnv(t *testing.T, env map[string]string) { func TestProvider_StreamLifecycle(t *testing.T) { withFakeAgentProcess(t) - p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-sonnet-5"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) assert.Equal(t, ai.BackendClaudeAgent, p.GetBackend()) - assert.Equal(t, "claude-agent-sonnet", p.GetModel()) + assert.Equal(t, "claude-sonnet-5", p.GetModel()) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -197,7 +197,7 @@ func TestProvider_StreamLifecycle(t *testing.T) { func TestProvider_ExecuteCoalesce(t *testing.T) { withFakeAgentProcess(t) - p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-sonnet-5"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) @@ -225,7 +225,7 @@ type companyInfo struct { func TestProvider_StructuredOutput(t *testing.T) { withFakeAgentProcessEnv(t, map[string]string{fakeServerEnv: "1", fakeModeEnv: "structured"}) - p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-sonnet-5"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) @@ -263,7 +263,7 @@ func TestRequestSchemaJSON(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, raw) - p := &Provider{model: "claude-agent-sonnet", sessionSchema: raw} + p := &Provider{model: "claude-sonnet-5", sessionSchema: raw} ip := p.initializeParams(ai.Request{Prompt: api.Prompt{User: "x", Schema: &plan{}}}) require.NotEmpty(t, ip.OutputSchema, "outputSchema should be set from the session schema") @@ -285,7 +285,7 @@ func TestRequestSchemaJSON(t *testing.T) { func TestProvider_MultiTurnSerialized(t *testing.T) { withFakeAgentProcess(t) - p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-sonnet-5"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) @@ -323,7 +323,7 @@ func TestProvider_CanUseTool(t *testing.T) { } // 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}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-sonnet-5"}, CanUseTool: canUseTool}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) @@ -382,7 +382,7 @@ func TestProvider_InterruptNoKill(t *testing.T) { fakeMarkerEnv: marker, }) - p, err := New(ai.Config{Model: api.Model{Name: "claude-agent-sonnet"}}) + p, err := New(ai.Config{Model: api.Model{Name: "claude-sonnet-5"}}) require.NoError(t, err) t.Cleanup(func() { _ = p.Close() }) diff --git a/pkg/ai/provider/claudeagent/runner.go b/pkg/ai/provider/claudeagent/runner.go index a199f9f..67eccf7 100644 --- a/pkg/ai/provider/claudeagent/runner.go +++ b/pkg/ai/provider/claudeagent/runner.go @@ -9,6 +9,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/flanksource/captain/pkg/ai" ) //go:embed agent.ts @@ -115,15 +117,13 @@ func nestingEnvOverrides(environ []string) map[string]string { return overrides } -// aliasModel strips the captain backend prefix from a model id and passes the -// remainder (e.g. "sonnet", "opus", "claude-sonnet-4-5") straight to the SDK, -// which resolves the short aliases via the Claude Code CLI. Kept local so the -// claudeagent package never imports pkg/ai/provider (which imports it back). +// aliasModel is retained as the local model renderer for the agent.ts bridge. +// It accepts legacy backend-prefixed aliases as input compatibility, but returns +// the exact Claude model ID the SDK should receive. func aliasModel(model string) string { - m := strings.TrimPrefix(model, "claude-agent-") - m = strings.TrimPrefix(m, "claude-code-") - if m == "" { - return "sonnet" + m := strings.TrimSpace(model) + if m == "" || m == "claude" { + return "claude-sonnet-5" } - return m + return ai.NormalizeModelForBackend(ai.BackendClaudeAgent, m) } diff --git a/pkg/ai/provider/cli.go b/pkg/ai/provider/cli.go index b38d360..0c92870 100644 --- a/pkg/ai/provider/cli.go +++ b/pkg/ai/provider/cli.go @@ -6,6 +6,8 @@ import ( "context" "errors" "fmt" + "io" + "os" "os/exec" "strings" @@ -74,7 +76,11 @@ func HandleExitError(exitCode int, stderr string) error { } func runCLI(ctx context.Context, command string, stdinData []byte, cwd string, env ...[]string) (stdout []byte, stderr string, err error) { - cmd := exec.CommandContext(ctx, command) + return runCLICommand(ctx, command, nil, stdinData, cwd, env...) +} + +func runCLICommand(ctx context.Context, command string, args []string, stdinData []byte, cwd string, env ...[]string) (stdout []byte, stderr string, err error) { + cmd := exec.CommandContext(ctx, command, args...) if cwd != "" { cmd.Dir = cwd } @@ -123,3 +129,85 @@ func runCLI(ctx context.Context, command string, stdinData []byte, cwd string, e return stdoutBuf.Bytes(), stderrData, nil } + +func startCLIStream(ctx context.Context, command string, args []string, stdinData []byte, cwd string, env []string) (*exec.Cmd, io.ReadCloser, *bytes.Buffer, error) { + cmd := exec.CommandContext(ctx, command, args...) + if cwd != "" { + cmd.Dir = cwd + } + if len(env) > 0 { + cmd.Env = env + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create stdin pipe: %w", err) + } + stderrBuf := &bytes.Buffer{} + cmd.Stderr = stderrBuf + if err := cmd.Start(); err != nil { + if IsCommandNotFound(err) { + return nil, nil, nil, fmt.Errorf("%w: %v", ai.ErrCLINotFound, err) + } + return nil, nil, nil, fmt.Errorf("failed to start %s: %w", command, err) + } + go func() { + if len(stdinData) > 0 { + _, _ = stdin.Write(stdinData) + if stdinData[len(stdinData)-1] != '\n' { + _, _ = stdin.Write([]byte("\n")) + } + } + _ = stdin.Close() + }() + return cmd, stdout, stderrBuf, nil +} + +func finishCLIStream(ctx context.Context, cmd *exec.Cmd, stderrBuf *bytes.Buffer) error { + waitErr := cmd.Wait() + if ctx.Err() != nil { + return fmt.Errorf("%w: context cancelled", ai.ErrTimeout) + } + if waitErr == nil { + return nil + } + return HandleExitError(GetExitCode(waitErr), ParseStderr(stderrBuf.String())) +} + +func commandEnv(setupEnv []string) []string { + if len(setupEnv) == 0 { + return nil + } + merged := os.Environ() + positions := map[string]int{} + for i, item := range merged { + if key, _, ok := strings.Cut(item, "="); ok { + positions[key] = i + } + } + for _, item := range setupEnv { + key, _, ok := strings.Cut(item, "=") + if !ok || key == "" { + continue + } + if idx, ok := positions[key]; ok { + merged[idx] = item + continue + } + positions[key] = len(merged) + merged = append(merged, item) + } + return merged +} + +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/executor.go b/pkg/ai/provider/cmux/executor.go index f66f128..046b0b2 100644 --- a/pkg/ai/provider/cmux/executor.go +++ b/pkg/ai/provider/cmux/executor.go @@ -423,14 +423,20 @@ 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). +// modelFlag normalizes the configured model into the exact 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 "" } + if agent == "claude" { + return ai.NormalizeModelForBackend(ai.BackendClaudeCmux, model) + } + if agent == "codex" { + return ai.NormalizeModelForBackend(ai.BackendCodexCmux, model) + } return model } diff --git a/pkg/ai/provider/cmux/executor_test.go b/pkg/ai/provider/cmux/executor_test.go index 2507ef6..7b37872 100644 --- a/pkg/ai/provider/cmux/executor_test.go +++ b/pkg/ai/provider/cmux/executor_test.go @@ -123,7 +123,9 @@ func TestModelFlag(t *testing.T) { }{ {"claude", "claude", ""}, {"claude", "", ""}, - {"claude", "opus", "opus"}, + {"claude", "opus", "claude-opus-4-8"}, + {"claude", "claude-agent-opus", "claude-opus-4-8"}, + {"claude", "claude-code-sonnet", "claude-sonnet-5"}, {"codex", "codex", ""}, {"codex", "gpt-5", "gpt-5"}, } diff --git a/pkg/ai/provider/cmux/provider.go b/pkg/ai/provider/cmux/provider.go index 88aecd6..a12bfb6 100644 --- a/pkg/ai/provider/cmux/provider.go +++ b/pkg/ai/provider/cmux/provider.go @@ -48,8 +48,9 @@ func New(cfg ai.Config) (*Provider, error) { if err != nil { return nil, err } + model := ai.NormalizeModelForBackend(cfg.Model.Backend, cfg.Model.Name) return &Provider{ - model: cfg.Model.Name, + model: model, agent: agent, cfg: cfg, }, nil diff --git a/pkg/ai/provider/cmux/provider_test.go b/pkg/ai/provider/cmux/provider_test.go index 4f777f4..20383eb 100644 --- a/pkg/ai/provider/cmux/provider_test.go +++ b/pkg/ai/provider/cmux/provider_test.go @@ -49,11 +49,12 @@ func TestNewDerivesAgentAndBackend(t *testing.T) { name string backend api.Backend model string + wantModel string wantAgent string wantBackend api.Backend }{ - {"claude cmux", api.BackendClaudeCmux, "opus", "claude", api.BackendClaudeCmux}, - {"codex cmux", api.BackendCodexCmux, "gpt-5", "codex", api.BackendCodexCmux}, + {"claude cmux", api.BackendClaudeCmux, "opus", "claude-opus-4-8", "claude", api.BackendClaudeCmux}, + {"codex cmux", api.BackendCodexCmux, "gpt-5", "gpt-5", "codex", api.BackendCodexCmux}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -64,8 +65,8 @@ func TestNewDerivesAgentAndBackend(t *testing.T) { 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.GetModel() != tc.wantModel { + t.Fatalf("GetModel() = %q, want %q", p.GetModel(), tc.wantModel) } if p.GetBackend() != tc.wantBackend { t.Fatalf("GetBackend() = %q, want %q", p.GetBackend(), tc.wantBackend) diff --git a/pkg/ai/provider/coalesce.go b/pkg/ai/provider/coalesce.go index e29bfee..18e4302 100644 --- a/pkg/ai/provider/coalesce.go +++ b/pkg/ai/provider/coalesce.go @@ -14,6 +14,10 @@ import ( // the live event stream call this on a tee'd channel; for one-shot use, prefer // Provider.Execute. func CoalesceStream(ctx context.Context, model string, events <-chan ai.Event, start time.Time) (*ai.Response, error) { + return CoalesceStreamForBackend(ctx, ai.BackendClaudeCLI, model, events, start) +} + +func CoalesceStreamForBackend(ctx context.Context, backend ai.Backend, model string, events <-chan ai.Event, start time.Time) (*ai.Response, error) { var ( text strings.Builder usage ai.Usage @@ -26,7 +30,7 @@ func CoalesceStream(ctx context.Context, model string, events <-chan ai.Event, s select { case ev, ok := <-events: if !ok { - return finaliseCoalescedResponse(model, text.String(), usage, lastResult, errEvents, sessionID, start) + return finaliseCoalescedResponse(backend, model, text.String(), usage, lastResult, errEvents, sessionID, start) } switch ev.Kind { case ai.EventText: @@ -34,6 +38,9 @@ func CoalesceStream(ctx context.Context, model string, events <-chan ai.Event, s case ai.EventResult: cp := ev lastResult = &cp + if ev.Text != "" && text.Len() == 0 { + text.WriteString(ev.Text) + } if ev.Usage != nil { usage = *ev.Usage } @@ -50,7 +57,7 @@ func CoalesceStream(ctx context.Context, model string, events <-chan ai.Event, s } } -func finaliseCoalescedResponse(model, text string, usage ai.Usage, lastResult *ai.Event, errEvents []ai.Event, sessionID string, start time.Time) (*ai.Response, error) { +func finaliseCoalescedResponse(backend ai.Backend, model, text string, usage ai.Usage, lastResult *ai.Event, errEvents []ai.Event, sessionID string, start time.Time) (*ai.Response, error) { if lastResult != nil && !lastResult.Success { msg := lastResult.Error if msg == "" { @@ -65,7 +72,7 @@ func finaliseCoalescedResponse(model, text string, usage ai.Usage, lastResult *a resp := &ai.Response{ Text: text, Model: model, - Backend: ai.BackendClaudeCLI, + Backend: backend, Usage: usage, Duration: time.Since(start), } diff --git a/pkg/ai/provider/coalesce_test.go b/pkg/ai/provider/coalesce_test.go index fc8a755..d6eacde 100644 --- a/pkg/ai/provider/coalesce_test.go +++ b/pkg/ai/provider/coalesce_test.go @@ -66,6 +66,21 @@ func TestCoalesceStream_CarriesStructuredData(t *testing.T) { } } +func TestCoalesceStream_UsesResultTextWhenNoTextEvents(t *testing.T) { + const model = "claude-sonnet-5" + events := []ai.Event{ + {Kind: ai.EventResult, Tool: "Result", Success: true, Text: "final answer", Model: model}, + } + + resp, err := CoalesceStream(context.Background(), model, feedEvents(events), time.Now()) + if err != nil { + t.Fatalf("CoalesceStream err: %v", err) + } + if resp.Text != "final answer" { + t.Errorf("Text = %q, want final answer", resp.Text) + } +} + func TestCoalesceStream_ResultErrorReturnsError(t *testing.T) { const wantMsg = "upstream 500" events := []ai.Event{ diff --git a/pkg/ai/provider/codex_appserver.go b/pkg/ai/provider/codex_appserver.go index 891063f..00ff819 100644 --- a/pkg/ai/provider/codex_appserver.go +++ b/pkg/ai/provider/codex_appserver.go @@ -47,11 +47,12 @@ func NewCodexAppServer(model string) (*CodexAppServer, error) { if model == "" { model = CodexCLIDefaultModel } + model = ai.NormalizeModelForBackend(ai.BackendCodexAgent, model) return &CodexAppServer{model: model}, nil } func (c *CodexAppServer) GetModel() string { return c.model } -func (c *CodexAppServer) GetBackend() ai.Backend { return ai.BackendCodexCLI } +func (c *CodexAppServer) GetBackend() ai.Backend { return ai.BackendCodexAgent } // Execute drains the streaming output into a buffered ai.Response. When the // request carries a structured-output schema, the final agent message's JSON is @@ -63,7 +64,7 @@ func (c *CodexAppServer) Execute(ctx context.Context, req ai.Request) (*ai.Respo if err != nil { return nil, err } - resp, err := CoalesceStream(ctx, c.model, events, start) + resp, err := CoalesceStreamForBackend(ctx, ai.BackendCodexAgent, c.model, events, start) if err != nil { return nil, err } diff --git a/pkg/ai/provider/codex_appserver_test.go b/pkg/ai/provider/codex_appserver_test.go index 220b5b1..35dd4b6 100644 --- a/pkg/ai/provider/codex_appserver_test.go +++ b/pkg/ai/provider/codex_appserver_test.go @@ -16,11 +16,11 @@ func TestNewCodexAppServer_Defaults(t *testing.T) { c, err := NewCodexAppServer("") require.NoError(t, err) assert.Equal(t, CodexCLIDefaultModel, c.GetModel()) - assert.Equal(t, ai.BackendCodexCLI, c.GetBackend()) + assert.Equal(t, ai.BackendCodexAgent, c.GetBackend()) - c2, err := NewCodexAppServer("gpt-5-codex") + c2, err := NewCodexAppServer("gpt-5.4") require.NoError(t, err) - assert.Equal(t, "gpt-5-codex", c2.GetModel()) + assert.Equal(t, "gpt-5.4", c2.GetModel()) } func TestMapAppServerNotification_Kinds(t *testing.T) { diff --git a/pkg/ai/provider/codex_cli.go b/pkg/ai/provider/codex_cli.go new file mode 100644 index 0000000..7f71d45 --- /dev/null +++ b/pkg/ai/provider/codex_cli.go @@ -0,0 +1,395 @@ +package provider + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai" + history "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/claude" +) + +var codexCLICommand = "codex" + +type CodexCLI struct { + model string +} + +func NewCodexCLI(model string) *CodexCLI { + if strings.TrimSpace(model) == "" { + model = CodexCLIDefaultModel + } + model = ai.NormalizeModelForBackend(ai.BackendCodexCLI, model) + return &CodexCLI{model: model} +} + +func (c *CodexCLI) GetModel() string { return c.model } +func (c *CodexCLI) GetBackend() ai.Backend { return ai.BackendCodexCLI } + +func (c *CodexCLI) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { + start := time.Now() + events, err := c.ExecuteStream(ctx, req) + if err != nil { + return nil, err + } + resp, err := CoalesceStreamForBackend(ctx, ai.BackendCodexCLI, c.model, events, start) + if err != nil { + return nil, err + } + if req.Prompt.Schema != nil { + raw, _ := resp.StructuredData.(json.RawMessage) + if len(raw) == 0 { + raw = json.RawMessage(resp.Text) + } + if err := ai.BindStructuredOutput(req.Prompt.Schema, raw); err != nil { + return nil, err + } + resp.StructuredData = req.Prompt.Schema + resp.Text = "" + } + return resp, nil +} + +func (c *CodexCLI) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai.Event, error) { + args, cleanup, err := buildCodexCLIArgs(c.model, req) + if err != nil { + return nil, err + } + env := []string(nil) + if req.Setup != nil { + env = commandEnv(req.Setup.Env) + } + cmd, stdout, stderrBuf, err := startCLIStream(ctx, codexCLICommand, args, []byte(composePrompt(req)), req.Cwd(), env) + if err != nil { + cleanup() + return nil, err + } + out := make(chan ai.Event, 16) + go func() { + defer close(out) + defer cleanup() + defer func() { _ = stdout.Close() }() + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024) + state := codexCLIState{model: c.model, cwd: req.Cwd(), pending: map[string]history.CodexEvent{}} + for scanner.Scan() { + for _, ev := range state.mapLine(scanner.Bytes()) { + emit(ctx, out, ev) + } + } + if err := scanner.Err(); err != nil { + emit(ctx, out, ai.Event{Kind: ai.EventError, Error: fmt.Sprintf("codex exec --json: %v", err), Model: c.model}) + } + if err := finishCLIStream(ctx, cmd, stderrBuf); err != nil { + emit(ctx, out, ai.Event{Kind: ai.EventError, Error: err.Error(), Model: c.model}) + } + }() + return out, nil +} + +func buildCodexCLIArgs(model string, req ai.Request) ([]string, func(), error) { + args := []string{"exec", "--json"} + cleanup := func() {} + if m := strings.TrimSpace(model); m != "" && m != "codex" { + args = append(args, "-m", m) + } + if cwd := req.Cwd(); cwd != "" { + args = append(args, "-C", cwd) + } + sandbox, _ := codexSafety(req) + if sandbox != "" { + args = append(args, "--sandbox", sandbox) + } + if req.Memory.SkipMemory || req.Memory.Bare || req.Permissions.HasPreset(api.PresetBare) { + args = append(args, "--ephemeral") + } + if req.Memory.SkipUser { + args = append(args, "--ignore-user-config") + } + if req.Memory.SkipProject || req.Memory.SkipHooks { + args = append(args, "--ignore-rules") + } + schema, err := ai.SchemaJSONFor(req.Prompt) + if err != nil { + return nil, cleanup, fmt.Errorf("codex-cli: cannot derive structured-output schema: %w", err) + } + if len(schema) > 0 { + path, err := writeTempSchema(schema) + if err != nil { + return nil, cleanup, err + } + cleanup = func() { _ = os.Remove(path) } + args = append(args, "--output-schema", path) + } + if req.SessionID != "" { + args = append(args, "resume", req.SessionID) + } + return args, cleanup, nil +} + +func writeTempSchema(schema json.RawMessage) (string, error) { + f, err := os.CreateTemp("", "captain-codex-schema-*.json") + if err != nil { + return "", fmt.Errorf("codex-cli: create schema file: %w", err) + } + path := f.Name() + if _, err := f.Write(schema); err != nil { + _ = f.Close() + _ = os.Remove(path) + return "", fmt.Errorf("codex-cli: write schema file %s: %w", filepath.Base(path), err) + } + if err := f.Close(); err != nil { + _ = os.Remove(path) + return "", fmt.Errorf("codex-cli: close schema file %s: %w", filepath.Base(path), err) + } + return path, nil +} + +type codexCLIState struct { + model string + cwd string + sessionID string + pending map[string]history.CodexEvent + usage ai.Usage +} + +func (s *codexCLIState) mapLine(line []byte) []ai.Event { + var event history.CodexEvent + if err := json.Unmarshal(line, &event); err != nil { + return []ai.Event{{Kind: ai.EventError, Error: fmt.Sprintf("codex json parse: %v", err), Model: s.model}} + } + switch event.Type { + case "thread.started": + if event.ThreadID != "" { + s.sessionID = event.ThreadID + } + ev := ai.Event{Kind: ai.EventSystem, Tool: "SessionInit", SessionID: s.sessionID, Model: s.model} + ev.Raw = codexSessionToolUse(s.sessionID, s.model) + return []ai.Event{ev} + case "item.completed": + return s.mapItemCompleted(event) + case "response_item": + return s.mapResponseItem(event) + case "event_msg": + return s.mapEventMsg(event) + case "turn.failed", "error": + msg := extractCodexErrorText(codexErrorMessage(event)) + ev := ai.Event{Kind: ai.EventError, Error: msg, Model: s.model} + ev.Raw = codexToolUse("ApiError", map[string]any{"error": msg}, s.sessionID, s.model) + return []ai.Event{ev} + case "turn.completed": + if event.Usage != nil { + s.usage.InputTokens = event.Usage.InputTokens + s.usage.OutputTokens = event.Usage.OutputTokens + } + usage := s.usage + ev := ai.Event{Kind: ai.EventResult, Tool: "Result", Success: true, SessionID: s.sessionID, Model: s.model, Usage: &usage} + ev.Raw = codexResultToolUse(ev, s.sessionID) + return []ai.Event{ev} + } + return nil +} + +func (s *codexCLIState) mapResponseItem(event history.CodexEvent) []ai.Event { + switch event.Payload.Type { + case "function_call": + s.pending[event.Payload.CallID] = event + return nil + case "function_call_output": + call, ok := s.pending[event.Payload.CallID] + if !ok { + return nil + } + delete(s.pending, event.Payload.CallID) + return s.codexToolEvents([]claude.ToolUse{s.buildFunctionToolUse(call, event)}) + case "reasoning": + text := codexReasoningText(event) + if text == "" { + return nil + } + return []ai.Event{{Kind: ai.EventThinking, Text: text, SessionID: s.sessionID, Model: s.model}} + case "message": + text := codexMessageText(event) + if text == "" { + return nil + } + return []ai.Event{{Kind: ai.EventText, Text: text, SessionID: s.sessionID, Model: s.model}} + } + return nil +} + +func (s *codexCLIState) mapEventMsg(event history.CodexEvent) []ai.Event { + if event.Payload.Type == "token_count" && event.Payload.Info != nil { + s.usage.InputTokens = event.Payload.Info.TotalTokenUsage.InputTokens + s.usage.OutputTokens = event.Payload.Info.TotalTokenUsage.OutputTokens + s.usage.CacheReadTokens = event.Payload.Info.TotalTokenUsage.CachedInputTokens + return nil + } + switch event.Payload.Type { + case "agent_reasoning": + if event.Payload.Text != "" { + return []ai.Event{{Kind: ai.EventThinking, Text: event.Payload.Text, SessionID: s.sessionID, Model: s.model}} + } + case "agent_message": + if event.Payload.Message != "" { + return []ai.Event{{Kind: ai.EventText, Text: event.Payload.Message, SessionID: s.sessionID, Model: s.model}} + } + } + return nil +} + +func (s *codexCLIState) mapItemCompleted(event history.CodexEvent) []ai.Event { + if event.Item == nil { + return nil + } + text := event.Item.Text + if text == "" { + for _, content := range event.Item.Content { + if content.Type == "output_text" && content.Text != "" { + text += content.Text + } + } + } + if text != "" { + return []ai.Event{{Kind: ai.EventText, Text: text, SessionID: s.sessionID, Model: s.model}} + } + name := firstNonEmpty(event.Item.Name, event.Item.Type) + if name == "" { + return nil + } + input := map[string]any{} + tu := codexToolUse(name, input, s.sessionID, s.model) + tu.ToolUseID = event.Item.Name + return s.codexToolEvents([]claude.ToolUse{tu}) +} + +func (s *codexCLIState) codexToolEvents(toolUses []claude.ToolUse) []ai.Event { + out := make([]ai.Event, 0, len(toolUses)) + for _, tu := range toolUses { + ev := ai.Event{ + Kind: ai.EventToolUse, + Tool: tu.Tool, + Input: tu.Input, + ToolCallID: tu.ToolUseID, + SessionID: firstNonEmpty(tu.SessionID, s.sessionID), + Model: firstNonEmpty(tu.Model, s.model), + Raw: tu, + } + if tu.Tool == "Assistant" { + if text, _ := tu.Input["text"].(string); text != "" { + ev.Kind = ai.EventText + ev.Text = text + } + } + if tu.Tool == "Reasoning" { + if text, _ := tu.Input["text"].(string); text != "" { + ev.Kind = ai.EventThinking + ev.Text = text + } + } + if tu.Tool == "ApiError" { + ev.Kind = ai.EventError + ev.Error = firstString(tu.Input, "error", "message", "result") + } + out = append(out, ev) + } + return out +} + +func codexErrorMessage(event history.CodexEvent) string { + if event.Error != nil && event.Error.Message != "" { + return event.Error.Message + } + if event.Message != "" { + return event.Message + } + return event.Payload.Message +} + +func (s *codexCLIState) buildFunctionToolUse(call, output history.CodexEvent) claude.ToolUse { + name := call.Payload.Name + input := codexArgumentsMap(call.Payload.Arguments) + if name == "update_plan" { + name = "TodoWrite" + if plan, ok := input["plan"]; ok { + input["todos"] = plan + } + } + if name == "request_user_input" { + name = "AskUserQuestion" + } + if name == "" { + name = "Bash" + if command := codexCommand(call.Payload.Arguments); command != "" { + input = map[string]any{"command": command} + } + } + tu := codexToolUse(name, input, s.sessionID, s.model) + tu.ToolUseID = call.Payload.CallID + tu.Response = output.Payload.Output + return tu +} + +func codexArgumentsMap(argsJSON string) map[string]any { + var args map[string]any + if argsJSON != "" { + _ = json.Unmarshal([]byte(argsJSON), &args) + } + if args == nil { + args = map[string]any{} + } + return args +} + +func codexCommand(argsJSON string) string { + args := codexArgumentsMap(argsJSON) + for _, key := range []string{"command", "cmd"} { + if value, _ := args[key].(string); value != "" { + return value + } + } + return argsJSON +} + +func codexReasoningText(event history.CodexEvent) string { + if len(event.Payload.Summary) == 0 { + return event.Payload.Text + } + var summaries []history.CodexReasoningSummary + if err := json.Unmarshal(event.Payload.Summary, &summaries); err == nil { + var text string + for _, summary := range summaries { + if summary.Text != "" { + text = summary.Text + } + } + if text != "" { + return text + } + } + var text string + if err := json.Unmarshal(event.Payload.Summary, &text); err == nil { + return text + } + return event.Payload.Text +} + +func codexMessageText(event history.CodexEvent) string { + if event.Payload.Role != "" && event.Payload.Role != "assistant" { + return "" + } + var text string + for _, content := range event.Payload.Content { + if content.Type == "output_text" && content.Text != "" { + text += content.Text + } + } + return text +} diff --git a/pkg/ai/provider/codex_cli_test.go b/pkg/ai/provider/codex_cli_test.go new file mode 100644 index 0000000..ef36c1f --- /dev/null +++ b/pkg/ai/provider/codex_cli_test.go @@ -0,0 +1,93 @@ +package provider + +import ( + "encoding/json" + "os" + "testing" + + "github.com/flanksource/captain/pkg/ai" + history "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/commons-db/shell" +) + +func TestBuildCodexCLIArgs(t *testing.T) { + cwd := t.TempDir() + req := ai.Request{ + Prompt: api.Prompt{ + User: "hello", + SchemaJSON: json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}}}`), + }, + Setup: &shell.Setup{Cwd: cwd}, + SessionID: "thread-1", + Memory: api.Memory{ + SkipMemory: true, + SkipUser: true, + SkipProject: true, + SkipHooks: true, + }, + Permissions: api.Permissions{Presets: []api.Preset{api.PresetEdit}}, + } + + args, cleanup, err := buildCodexCLIArgs("gpt-5.5", req) + if err != nil { + t.Fatalf("buildCodexCLIArgs: %v", err) + } + defer cleanup() + requireFlagValue(t, args, "-m", "gpt-5.5") + requireFlagValue(t, args, "-C", cwd) + requireFlagValue(t, args, "--sandbox", "workspace-write") + requireHasArg(t, args, "--json") + requireHasArg(t, args, "--ephemeral") + requireHasArg(t, args, "--ignore-user-config") + requireHasArg(t, args, "--ignore-rules") + if got := args[len(args)-2:]; got[0] != "resume" || got[1] != "thread-1" { + t.Fatalf("args suffix = %v, want resume thread-1 (args: %v)", got, args) + } + schemaPath := flagValue(t, args, "--output-schema") + data, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read schema file: %v", err) + } + if !json.Valid(data) { + t.Fatalf("schema file is not valid JSON: %s", data) + } + cleanup() + if _, err := os.Stat(schemaPath); !os.IsNotExist(err) { + t.Fatalf("schema cleanup stat err = %v, want not exist", err) + } +} + +func TestCodexCLIStateMapsJSONLEvents(t *testing.T) { + state := codexCLIState{model: "gpt-5.5", pending: map[string]history.CodexEvent{}} + + events := state.mapLine([]byte(`{"type":"thread.started","thread_id":"thread-1"}`)) + if len(events) != 1 || events[0].Kind != ai.EventSystem || events[0].SessionID != "thread-1" { + t.Fatalf("thread.started events = %+v", events) + } + + events = state.mapLine([]byte(`{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}`)) + if len(events) != 1 || events[0].Kind != ai.EventText || events[0].Text != "hello" { + t.Fatalf("message events = %+v", events) + } + + events = state.mapLine([]byte(`{"type":"response_item","payload":{"type":"function_call","name":"shell","call_id":"call-1","arguments":"{\"command\":\"pwd\"}"}}`)) + if len(events) != 0 { + t.Fatalf("function_call events = %+v, want none until output", events) + } + events = state.mapLine([]byte(`{"type":"response_item","payload":{"type":"function_call_output","call_id":"call-1","output":"ok"}}`)) + if len(events) != 1 || events[0].Kind != ai.EventToolUse || events[0].Tool != "shell" { + t.Fatalf("function_call_output events = %+v", events) + } + if got, _ := events[0].Input["command"].(string); got != "pwd" { + t.Fatalf("tool input command = %q, want pwd", got) + } + + events = state.mapLine([]byte(`{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2}}`)) + if len(events) != 1 || events[0].Kind != ai.EventResult || !events[0].Success { + t.Fatalf("turn.completed events = %+v", events) + } + if events[0].Usage == nil || events[0].Usage.InputTokens != 3 || events[0].Usage.OutputTokens != 2 { + t.Fatalf("usage = %+v", events[0].Usage) + } +} diff --git a/pkg/ai/provider/gemini_cli.go b/pkg/ai/provider/gemini_cli.go index 3b00417..4b3bf69 100644 --- a/pkg/ai/provider/gemini_cli.go +++ b/pkg/ai/provider/gemini_cli.go @@ -16,8 +16,9 @@ type GeminiCLI struct { func NewGeminiCLI(model string) *GeminiCLI { if model == "" { - model = "gemini-cli-pro" + model = "gemini-3.5-flash" } + model = ai.NormalizeModelForBackend(ai.BackendGeminiCLI, model) return &GeminiCLI{model: model} } diff --git a/pkg/ai/provider/genkit/genkit.go b/pkg/ai/provider/genkit/genkit.go index c6301d7..8ce81ab 100644 --- a/pkg/ai/provider/genkit/genkit.go +++ b/pkg/ai/provider/genkit/genkit.go @@ -12,6 +12,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/flanksource/captain/pkg/ai" @@ -93,6 +94,13 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e if ctx.Err() != nil { return nil, fmt.Errorf("%w: %v", ai.ErrTimeout, ctx.Err()) } + // genkit validates the model's constrained output against the request + // schema during generation; a rejection is recoverable by re-asking the + // model with the errors, so classify it as ErrSchemaValidation (preserving + // genkit's detail lines) for the schema-validation middleware to act on. + if isSchemaMismatch(err) { + return nil, fmt.Errorf("%w: %v", ai.ErrSchemaValidation, err) + } return nil, fmt.Errorf("genkit %s generate: %w", p.backend, err) } @@ -119,6 +127,15 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e return out, nil } +// isSchemaMismatch reports whether a genkit Generate error is the library's +// constrained-output validation failure (vs a transport/model error), so the +// middleware can re-ask the model with the errors instead of failing. +func isSchemaMismatch(err error) bool { + msg := err.Error() + return strings.Contains(msg, "did not match expected schema") || + strings.Contains(msg, "output matching expected schema") +} + // ExecuteStream runs a streaming generation, publishing each chunk as ai.Events // and a terminal EventResult carrying usage + best-effort cost. Structured // output is unsupported in stream mode (mirrors the claude_cli stream provider). diff --git a/pkg/ai/provider/genkit/genkit_test.go b/pkg/ai/provider/genkit/genkit_test.go index d206589..3159b81 100644 --- a/pkg/ai/provider/genkit/genkit_test.go +++ b/pkg/ai/provider/genkit/genkit_test.go @@ -1,6 +1,7 @@ package genkit import ( + "fmt" "testing" "github.com/flanksource/captain/pkg/ai" @@ -27,16 +28,36 @@ func TestEffortConfig(t *testing.T) { { name: "anthropic high effort adds thinking budget on top of base", backend: ai.BackendAnthropic, - req: ai.Request{Model: api.Model{Effort: api.EffortHigh}}, + req: ai.Request{Model: api.Model{Name: "claude-sonnet-4-6", Effort: api.EffortHigh}}, want: map[string]any{ "max_tokens": 24576 + 4096, "thinking": map[string]any{"type": "enabled", "budget_tokens": 24576}, }, }, + { + name: "anthropic claude 5 effort uses adaptive output config", + backend: ai.BackendAnthropic, + req: ai.Request{Model: api.Model{Name: "claude-sonnet-5", Effort: api.EffortHigh}}, + want: map[string]any{ + "max_tokens": 24576 + 4096, + "thinking": map[string]any{"type": "adaptive"}, + "output_config": map[string]any{"effort": "high"}, + }, + }, + { + name: "anthropic claude 5 clamps xhigh output effort", + backend: ai.BackendAnthropic, + req: ai.Request{Model: api.Model{Name: "anthropic/claude-fable-5", Effort: api.EffortXHigh}}, + want: map[string]any{ + "max_tokens": 32768 + 4096, + "thinking": map[string]any{"type": "adaptive"}, + "output_config": map[string]any{"effort": "high"}, + }, + }, { name: "anthropic medium honours explicit max tokens as base", backend: ai.BackendAnthropic, - req: ai.Request{Model: api.Model{Effort: api.EffortMedium}, Budget: api.Budget{MaxTokens: 1000}}, + req: ai.Request{Model: api.Model{Name: "claude-sonnet-4-6", 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}, @@ -81,6 +102,24 @@ func TestEffortConfig(t *testing.T) { } } +func TestUsesAnthropicAdaptiveThinking(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"claude-sonnet-5", true}, + {"anthropic/claude-fable-5", true}, + {"claude-haiku-4-5", false}, + {"claude-3-5-sonnet-20241022", false}, + {"claude-opus-4-8", false}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + assert.Equal(t, tt.want, usesAnthropicAdaptiveThinking(tt.model)) + }) + } +} + func TestMapUsage(t *testing.T) { got := mapUsage(&gkai.GenerationUsage{ InputTokens: 120, @@ -160,3 +199,37 @@ func TestNewUnsupportedBackend(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "does not support backend") } + +func TestIsSchemaMismatch(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "genkit constrained-output rejection", + err: fmt.Errorf("model failed to generate output matching expected schema: data did not match expected schema:\n- title: String length must be less than or equal to 40"), + want: true, + }, + { + name: "genkit inner detail phrasing", + err: fmt.Errorf("data did not match expected schema:\n- branch: String length must be less than or equal to 40"), + want: true, + }, + { + name: "unrelated transport error", + err: fmt.Errorf("dial tcp: connection refused"), + want: false, + }, + { + name: "rate-limit error is not a schema mismatch", + err: fmt.Errorf("429 rate limit exceeded"), + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isSchemaMismatch(tt.err)) + }) + } +} diff --git a/pkg/ai/provider/genkit/options.go b/pkg/ai/provider/genkit/options.go index ab2828a..532299f 100644 --- a/pkg/ai/provider/genkit/options.go +++ b/pkg/ai/provider/genkit/options.go @@ -73,6 +73,28 @@ func openaiReasoningEffort(e api.Effort) string { return string(e) } +func anthropicOutputEffort(e api.Effort) string { + if e == api.EffortXHigh { + return string(api.EffortHigh) + } + return string(e) +} + +func usesAnthropicAdaptiveThinking(model string) bool { + bare := bareModel(model) + for _, prefix := range []string{ + "claude-fable-5", + "claude-haiku-5", + "claude-opus-5", + "claude-sonnet-5", + } { + if strings.HasPrefix(bare, prefix) { + return true + } + } + return false +} + // 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 budget MaxTokens as the visible-answer base. @@ -111,9 +133,14 @@ func effortConfig(backend ai.Backend, req ai.Request) map[string]any { case ai.BackendAnthropic: cfg := map[string]any{"max_tokens": anthropicMaxTokens(req, e)} if e != api.EffortNone { - cfg["thinking"] = map[string]any{ - "type": "enabled", - "budget_tokens": thinkingBudget(e), + if usesAnthropicAdaptiveThinking(req.Model.Name) || usesAnthropicAdaptiveThinking(req.Model.ID) { + cfg["thinking"] = map[string]any{"type": "adaptive"} + cfg["output_config"] = map[string]any{"effort": anthropicOutputEffort(e)} + } else { + cfg["thinking"] = map[string]any{ + "type": "enabled", + "budget_tokens": thinkingBudget(e), + } } } return cfg diff --git a/pkg/ai/provider/init.go b/pkg/ai/provider/init.go index e2583dd..90ea5c5 100644 --- a/pkg/ai/provider/init.go +++ b/pkg/ai/provider/init.go @@ -16,13 +16,11 @@ func init() { // with a custom base URL (see pluginFor). ai.RegisterProvider(ai.BackendDeepSeek, func(cfg ai.Config) (ai.Provider, error) { return genkit.New(cfg) }) - // Claude Agent SDK as a supervised TS process over JSON-RPC replaces the - // one-shot `claude -p` path; claude-code-* models route here too. ai.RegisterProvider(ai.BackendClaudeAgent, func(cfg ai.Config) (ai.Provider, error) { return claudeagent.New(cfg) }) - ai.RegisterProvider(ai.BackendClaudeCLI, func(cfg ai.Config) (ai.Provider, error) { return claudeagent.New(cfg) }) + ai.RegisterProvider(ai.BackendClaudeCLI, func(cfg ai.Config) (ai.Provider, error) { return NewClaudeCLI(cfg.Model.Name), nil }) - // 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) }) + ai.RegisterProvider(ai.BackendCodexCLI, func(cfg ai.Config) (ai.Provider, error) { return NewCodexCLI(cfg.Model.Name), nil }) + ai.RegisterProvider(ai.BackendCodexAgent, 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 From f71ee35456d01de09dadd796662a90ee9e47b4ae Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 8 Jul 2026 20:38:10 +0300 Subject: [PATCH 003/131] feat(ai): support synthetic lifecycle and metadata events and improve schema validation retries Expand Codex and Claude transcript parsing to capture rich metadata and lifecycle events (such as token usage, task states, budgets, and worktree events) as synthetic tool uses. This unified approach categorizes assistant, user, and reasoning as chat tools. Additionally, refactor the JSON schema validation middleware to support robust retries with model feedback on both post-generation and provider-side schema validation failures. --- pkg/ai/history/codex_parser.go | 256 +++++- pkg/ai/history/codex_parser_test.go | 41 + pkg/ai/history/codex_types.go | 29 +- pkg/ai/history/types.go | 1 + pkg/ai/middleware/logging.go | 22 +- pkg/ai/middleware/validation.go | 85 +- pkg/ai/middleware/validation_test.go | 104 ++- pkg/ai/prompt/prompt_test.go | 1 + .../testdata/fixtures/codex-agent.prompt | 19 + pkg/claude/history.go | 16 +- pkg/claude/reader.go | 80 +- pkg/claude/reader_test.go | 99 ++- pkg/claude/tools/assistant.go | 4 +- pkg/claude/tools/event.go | 762 ++++++++++++++++++ pkg/claude/tools/stream.go | 19 +- pkg/claude/tools/stream_test.go | 13 +- pkg/claude/tools/tool.go | 46 ++ pkg/claude/tools/user.go | 2 +- pkg/claude/tooluse.go | 138 +++- 19 files changed, 1610 insertions(+), 127 deletions(-) create mode 100644 pkg/ai/prompt/testdata/fixtures/codex-agent.prompt create mode 100644 pkg/claude/tools/event.go diff --git a/pkg/ai/history/codex_parser.go b/pkg/ai/history/codex_parser.go index 107fcaf..f5a8dc2 100644 --- a/pkg/ai/history/codex_parser.go +++ b/pkg/ai/history/codex_parser.go @@ -2,6 +2,7 @@ package history import ( "bufio" + "github.com/flanksource/captain/pkg/claude/tools" "github.com/segmentio/encoding/json" "io" "os" @@ -235,7 +236,7 @@ func ExtractCodexToolUsesFromReader(r io.Reader) ([]ToolUse, error) { if err := scanner.Err(); err != nil { return nil, err } - return toolUses, nil + return dedupeCodexToolUses(toolUses), nil } func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cwd, sessionID string) []ToolUse { @@ -267,34 +268,42 @@ func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cw return nil } return []ToolUse{{ - Tool: "Reasoning", - Input: map[string]any{"text": text}, - Timestamp: event.Time(), - CWD: cwd, - SessionID: sessionID, - Source: "codex", + Tool: "Reasoning", + Input: map[string]any{"text": text}, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + Source: "codex", + RecordType: "response_item.reasoning", }} case "message": - if event.Payload.Role != "assistant" { - return nil - } + var tool string var text string - for _, c := range event.Payload.Content { - if c.Type == "output_text" && c.Text != "" { - text += c.Text + switch event.Payload.Role { + case "assistant": + tool = "Assistant" + text = codexContentText(event.Payload.Content, "output_text", "text") + case "user": + tool = "User" + text = codexContentText(event.Payload.Content, "input_text", "text") + if isCodexInternalUserText(text) { + return nil } + default: + return nil } if text == "" { return nil } return []ToolUse{{ - Tool: "Assistant", - Input: map[string]any{"text": text}, - Timestamp: event.Time(), - CWD: cwd, - SessionID: sessionID, - Source: "codex", + Tool: tool, + Input: map[string]any{"text": text}, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + Source: "codex", + RecordType: "response_item.message", }} } return nil @@ -307,24 +316,24 @@ func extractLiveItemCompleted(event CodexEvent, cwd, sessionID string) []ToolUse if event.Item == nil { return nil } + if event.Item.Role != "" && event.Item.Role != "assistant" { + return nil + } text := event.Item.Text if text == "" { - for _, c := range event.Item.Content { - if c.Type == "output_text" && c.Text != "" { - text += c.Text - } - } + text = codexContentText(event.Item.Content, "output_text", "text") } if text == "" { return nil } return []ToolUse{{ - Tool: "Assistant", - Input: map[string]any{"text": text}, - Timestamp: event.Time(), - CWD: cwd, - SessionID: sessionID, - Source: "codex", + Tool: "Assistant", + Input: map[string]any{"text": text}, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + Source: "codex", + RecordType: "item.completed", }} } @@ -382,12 +391,13 @@ func extractEventMsg(event CodexEvent, cwd, sessionID string) []ToolUse { return nil } return []ToolUse{{ - Tool: "Reasoning", - Input: map[string]any{"text": event.Payload.Text}, - Timestamp: event.Time(), - CWD: cwd, - SessionID: sessionID, - Source: "codex", + Tool: "Reasoning", + Input: map[string]any{"text": event.Payload.Text}, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + Source: "codex", + RecordType: "event_msg.agent_reasoning", }} case "agent_message": @@ -395,15 +405,175 @@ func extractEventMsg(event CodexEvent, cwd, sessionID string) []ToolUse { return nil } return []ToolUse{{ - Tool: "Assistant", - Input: map[string]any{"text": event.Payload.Message}, - Timestamp: event.Time(), - CWD: cwd, - SessionID: sessionID, - Source: "codex", + Tool: "Assistant", + Input: map[string]any{"text": event.Payload.Message}, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + Source: "codex", + RecordType: "event_msg.agent_message", + }} + + case "user_message": + text := event.Payload.Message + if text == "" { + text = event.Payload.Text + } + if text == "" || isCodexInternalUserText(text) { + return nil + } + return []ToolUse{{ + Tool: "User", + Input: map[string]any{"text": text}, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + Source: "codex", + RecordType: "event_msg.user_message", }} } - return nil + if event.Payload.Type == "" { + return nil + } + return []ToolUse{buildCodexEventUse(event, cwd, sessionID)} +} + +func codexContentText(content []CodexContent, accepted ...string) string { + accept := make(map[string]struct{}, len(accepted)) + for _, typ := range accepted { + accept[typ] = struct{}{} + } + var text string + for _, c := range content { + if _, ok := accept[c.Type]; ok && c.Text != "" { + text += c.Text + } + } + return text +} + +func isCodexInternalUserText(text string) bool { + text = strings.TrimSpace(text) + return strings.HasPrefix(text, "") || + strings.HasPrefix(text, "") || + strings.HasPrefix(text, "") || + strings.HasPrefix(text, "") +} + +func buildCodexEventUse(event CodexEvent, cwd, sessionID string) ToolUse { + input := make(map[string]any, len(event.Payload.Raw)+1) + for k, v := range event.Payload.Raw { + input[k] = v + } + input["event"] = event.Payload.Type + add := func(key string, value any) { + switch v := value.(type) { + case string: + if v != "" { + input[key] = v + } + case int: + if v != 0 { + input[key] = v + } + case int64: + if v != 0 { + input[key] = v + } + case float64: + if v != 0 { + input[key] = v + } + } + } + add("turn_id", event.Payload.TurnID) + add("message", event.Payload.Message) + add("phase", event.Payload.Phase) + add("started_at", event.Payload.StartedAt) + add("completed_at", event.Payload.CompletedAt) + add("duration_ms", event.Payload.DurationMS) + add("time_to_first_token_ms", event.Payload.TimeToFirstTokenMS) + add("last_agent_message", event.Payload.LastAgentMessage) + add("model_context_window", event.Payload.ModelContextWindow) + add("collaboration_mode_kind", event.Payload.CollaborationModeKind) + if event.Payload.Info != nil { + input["total_tokens"] = event.Payload.Info.LastTokenUsage.TotalTokens + input["input_tokens"] = event.Payload.Info.LastTokenUsage.InputTokens + input["output_tokens"] = event.Payload.Info.LastTokenUsage.OutputTokens + input["cached_input_tokens"] = event.Payload.Info.LastTokenUsage.CachedInputTokens + if event.Payload.Info.ModelContextWindow != 0 { + input["model_context_window"] = event.Payload.Info.ModelContextWindow + } + } + return ToolUse{ + Tool: tools.EventToolName(event.Payload.Type), + Input: input, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + Source: "codex", + RecordType: "event_msg." + event.Payload.Type, + } +} + +func dedupeCodexToolUses(uses []ToolUse) []ToolUse { + var out []ToolUse + seen := map[string]int{} + priorities := map[string]int{} + for _, use := range uses { + key, ok := codexChatDedupeKey(use) + if !ok { + out = append(out, use) + continue + } + priority := codexRecordPriority(use.RecordType) + if idx, exists := seen[key]; exists { + if priority > priorities[key] { + out[idx] = use + priorities[key] = priority + } + continue + } + seen[key] = len(out) + priorities[key] = priority + out = append(out, use) + } + return out +} + +func codexChatDedupeKey(use ToolUse) (string, bool) { + switch use.Tool { + case "User", "Assistant", "Reasoning": + default: + return "", false + } + if use.Timestamp == nil { + return "", false + } + text, _ := use.Input["text"].(string) + text = strings.TrimSpace(text) + if text == "" { + return "", false + } + return strings.Join([]string{ + use.SessionID, + use.Tool, + use.Timestamp.UTC().Format(time.RFC3339Nano), + text, + }, "\x00"), true +} + +func codexRecordPriority(recordType string) int { + switch { + case strings.HasPrefix(recordType, "response_item."): + return 3 + case strings.HasPrefix(recordType, "item.completed"): + return 2 + case strings.HasPrefix(recordType, "event_msg."): + return 1 + default: + return 0 + } } func buildToolUse(callEvent, outputEvent CodexEvent, cwd, sessionID string) ToolUse { diff --git a/pkg/ai/history/codex_parser_test.go b/pkg/ai/history/codex_parser_test.go index 879bea2..a4c3ff7 100644 --- a/pkg/ai/history/codex_parser_test.go +++ b/pkg/ai/history/codex_parser_test.go @@ -115,6 +115,47 @@ func TestExtractCodexToolUses_MapsCodexControlCalls(t *testing.T) { } } +func TestExtractCodexToolUses_RolloutChatAndEvents(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-08T11:19:57.028Z","type":"session_meta","payload":{"id":"sess-rollout","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}}`, + `{"timestamp":"2026-07-08T11:19:57.028Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1","started_at":"2026-07-08T11:19:57.028Z","model_context_window":258400}}`, + `{"timestamp":"2026-07-08T11:19:58.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n /repo\n"}]}}`, + `{"timestamp":"2026-07-08T11:19:58.758Z","type":"turn_context","payload":{"model":"gpt-5.5","effort":"high"}}`, + `{"timestamp":"2026-07-08T11:19:58.760Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}`, + `{"timestamp":"2026-07-08T11:19:58.760Z","type":"event_msg","payload":{"type":"user_message","message":"hi"}}`, + `{"timestamp":"2026-07-08T11:20:00.403Z","type":"event_msg","payload":{"type":"agent_message","message":"Hi. What do you want to work on in ` + "`captain`" + `?"}}`, + `{"timestamp":"2026-07-08T11:20:00.403Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi. What do you want to work on in ` + "`captain`" + `?"}]}}`, + `{"timestamp":"2026-07-08T11:20:00.432Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":5,"output_tokens":2,"total_tokens":12},"model_context_window":258400}}}`, + `{"timestamp":"2026-07-08T11:20:00.435Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","duration_ms":3519,"time_to_first_token_ms":3123}}`, + }, "\n") + + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + if err != nil { + t.Fatalf("ExtractCodexToolUsesFromReader: %v", err) + } + + var got []string + var texts []string + for _, use := range uses { + got = append(got, use.Tool) + if text, _ := use.Input["text"].(string); text != "" { + texts = append(texts, text) + } + } + want := []string{"TaskStarted", "User", "Assistant", "TokenCount", "TaskComplete"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("tools = %v, want %v; uses=%+v", got, want, uses) + } + if len(texts) != 2 || texts[0] != "hi" || texts[1] != "Hi. What do you want to work on in `captain`?" { + t.Fatalf("texts = %v", texts) + } + for _, use := range uses { + if use.Model != "" && use.Model != "gpt-5.5" { + t.Fatalf("unexpected model on %s: %q", use.Tool, use.Model) + } + } +} + func TestReadCodexSessionInfo_ModelAndEffort(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","originator":"codex_exec","git":{"branch":"main","commit_hash":"abc"}}}`, diff --git a/pkg/ai/history/codex_types.go b/pkg/ai/history/codex_types.go index 381adc4..70109bc 100644 --- a/pkg/ai/history/codex_types.go +++ b/pkg/ai/history/codex_types.go @@ -51,7 +51,8 @@ func (e CodexEvent) Time() *time.Time { } type CodexPayload struct { - Type string `json:"type"` + Type string `json:"type"` + Raw map[string]any `json:"-"` // session_meta ID string `json:"id,omitempty"` @@ -80,8 +81,16 @@ type CodexPayload struct { Content []CodexContent `json:"content,omitempty"` // event_msg: agent_reasoning / agent_message / user_message - Text string `json:"text,omitempty"` - Message string `json:"message,omitempty"` + Text string `json:"text,omitempty"` + Message string `json:"message,omitempty"` + Phase string `json:"phase,omitempty"` + StartedAt any `json:"started_at,omitempty"` + CompletedAt int64 `json:"completed_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + TimeToFirstTokenMS int64 `json:"time_to_first_token_ms,omitempty"` + LastAgentMessage string `json:"last_agent_message,omitempty"` + ModelContextWindow int `json:"model_context_window,omitempty"` + CollaborationModeKind string `json:"collaboration_mode_kind,omitempty"` // event_msg: token_count Info *CodexTokenInfo `json:"info,omitempty"` @@ -96,6 +105,20 @@ type CodexPayload struct { Effort string `json:"effort,omitempty"` } +func (p *CodexPayload) UnmarshalJSON(data []byte) error { + type alias CodexPayload + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *p = CodexPayload(a) + var raw map[string]any + if err := json.Unmarshal(data, &raw); err == nil { + p.Raw = raw + } + return nil +} + type CodexGitMeta struct { CommitHash string `json:"commit_hash,omitempty"` Branch string `json:"branch,omitempty"` diff --git a/pkg/ai/history/types.go b/pkg/ai/history/types.go index 6784a24..8725ceb 100644 --- a/pkg/ai/history/types.go +++ b/pkg/ai/history/types.go @@ -13,6 +13,7 @@ type ToolUse struct { Model string `json:"model,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` Response string `json:"response,omitempty"` + RecordType string `json:"-"` } type Filter struct { diff --git a/pkg/ai/middleware/logging.go b/pkg/ai/middleware/logging.go index e3a466b..c6d4ad3 100644 --- a/pkg/ai/middleware/logging.go +++ b/pkg/ai/middleware/logging.go @@ -26,10 +26,11 @@ func (l *loggingProvider) GetBackend() ai.Backend { return l.provider.GetBackend func (l *loggingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { start := time.Now() + backend, model := logRuntime(l.provider, req) dispatch := clicky.Text(""). Add(icons.AI). - Append(fmt.Sprintf(" %s/%s", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium") + Append(fmt.Sprintf(" %s/%s", backend, model), "text-purple-600 font-medium") if req.Prompt.Source != "" { dispatch = dispatch.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") } @@ -49,14 +50,14 @@ func (l *loggingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp if err != nil { log.Errorf("%v", clicky.Text(""). Add(icons.Error). - Append(fmt.Sprintf(" %s/%s", l.provider.GetBackend(), l.provider.GetModel()), "text-red-600 font-medium"). + Append(fmt.Sprintf(" %s/%s", backend, model), "text-red-600 font-medium"). Append(fmt.Sprintf(" failed after %v: %v", duration.Round(time.Millisecond), err), "text-red-500")) return resp, err } log.Infof("%v", clicky.Text(""). Add(icons.Check). - Append(fmt.Sprintf(" %s/%s", l.provider.GetBackend(), l.provider.GetModel()), "text-green-600 font-medium"). + Append(fmt.Sprintf(" %s/%s", backend, model), "text-green-600 font-medium"). Append(fmt.Sprintf(" %v", duration.Round(time.Millisecond)), "text-gray-500"). Append(fmt.Sprintf(" (tokens: %d in / %d out)", resp.Usage.InputTokens, resp.Usage.OutputTokens), "text-gray-400")) @@ -85,10 +86,11 @@ func (l *loggingProvider) ExecuteStream(ctx context.Context, req ai.Request) (<- if !ok { return nil, fmt.Errorf("provider %s/%s does not support streaming", l.provider.GetBackend(), l.provider.GetModel()) } + backend, model := logRuntime(l.provider, req) dispatch := clicky.Text(""). Add(icons.AI). - Append(fmt.Sprintf(" %s/%s (stream)", l.provider.GetBackend(), l.provider.GetModel()), "text-purple-600 font-medium") + Append(fmt.Sprintf(" %s/%s (stream)", backend, model), "text-purple-600 font-medium") if req.Prompt.Source != "" { dispatch = dispatch.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") } @@ -101,6 +103,18 @@ func (l *loggingProvider) ExecuteStream(ctx context.Context, req ai.Request) (<- return streamer.ExecuteStream(ctx, req) } +func logRuntime(provider ai.Provider, req ai.Request) (api.Backend, string) { + backend := req.Model.Backend + if backend == "" { + backend = provider.GetBackend() + } + model := req.Model.Name + if model == "" { + model = provider.GetModel() + } + return backend, model +} + func WithLogging() Option { return func(p ai.Provider) (ai.Provider, error) { return &loggingProvider{provider: p}, nil diff --git a/pkg/ai/middleware/validation.go b/pkg/ai/middleware/validation.go index 015c83e..be03eaf 100644 --- a/pkg/ai/middleware/validation.go +++ b/pkg/ai/middleware/validation.go @@ -3,6 +3,7 @@ package middleware import ( "context" "encoding/json" + "errors" "fmt" "strings" @@ -11,6 +12,11 @@ import ( "github.com/xeipuuv/gojsonschema" ) +// maxSchemaRetries bounds the number of fix-up re-asks the retry strictness makes +// when a structured response fails schema validation before giving up with a hard +// error. +const maxSchemaRetries = 2 + // validatingProvider validates a structured-output response against the request's // JSON schema and applies the prompt's SchemaStrictness policy: warning (log and // continue), error (fail), retry (re-ask the model once with the validation error, @@ -33,11 +39,20 @@ func WithSchemaValidation() Option { func (v *validatingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { resp, err := v.provider.Execute(ctx, req) + strictness := req.Prompt.SchemaStrictness if err != nil { + // A provider that validates the model's structured output during generation + // (e.g. genkit's constrained output) rejects it before we ever see a + // response. Under "retry" that rejection is recoverable: re-ask the model + // with the schema errors instead of failing outright. + if strictness == api.SchemaStrictnessRetry && errors.Is(err, ai.ErrSchemaValidation) { + if schema, serr := ai.SchemaJSONFor(req.Prompt); serr == nil && len(schema) > 0 { + return v.retryWithFeedback(ctx, req, schema, err.Error(), nil) + } + } return resp, err } - strictness := req.Prompt.SchemaStrictness if strictness == api.SchemaStrictnessNone { return resp, nil } @@ -59,7 +74,7 @@ func (v *validatingProvider) Execute(ctx context.Context, req ai.Request) (*ai.R log.Warnf("schema validation failed (%s/%s): %s", v.provider.GetBackend(), v.provider.GetModel(), verrs) return resp, nil case api.SchemaStrictnessRetry: - return v.retry(ctx, req, resp, schema, verrs) + return v.retryWithFeedback(ctx, req, schema, verrs, resp) case api.SchemaStrictnessError: return resp, fmt.Errorf("%w: %s", ai.ErrSchemaValidation, verrs) default: @@ -67,31 +82,55 @@ func (v *validatingProvider) Execute(ctx context.Context, req ai.Request) (*ai.R } } -// retry re-asks the model once, feeding back the previous response and the schema -// validation errors, then re-validates. A still-invalid response is a hard error -// (retry → then error). -func (v *validatingProvider) retry(ctx context.Context, req ai.Request, prev *ai.Response, schema json.RawMessage, verrs string) (*ai.Response, error) { - correction := fmt.Sprintf( +// retryWithFeedback re-asks the model up to maxSchemaRetries times, feeding back +// the schema validation errors (and the previous response when we have one) so it +// can correct itself. It serves both failure modes: a post-response validation +// failure (prev is the offending response) and a provider-side rejection during +// generation (prev is nil because the response never surfaced). A response that is +// still invalid after the last attempt is a hard error. +func (v *validatingProvider) retryWithFeedback(ctx context.Context, req ai.Request, schema json.RawMessage, verrs string, prev *ai.Response) (*ai.Response, error) { + for attempt := 1; attempt <= maxSchemaRetries; attempt++ { + req2 := req + req2.Prompt.User = req.Prompt.User + correctionFeedback(prev, verrs) + + resp, err := v.provider.Execute(ctx, req2) + if err != nil { + // The provider rejected the corrected output too; feed the new errors + // back and keep trying until the retry budget is exhausted. + if errors.Is(err, ai.ErrSchemaValidation) && attempt < maxSchemaRetries { + verrs, prev = err.Error(), nil + continue + } + return resp, err + } + + verrs, err = validateResponse(schema, resp) + if err != nil { + return resp, err + } + if verrs == "" { + return resp, nil + } + prev = resp + } + return nil, fmt.Errorf("%w: still invalid after %d retries: %s", ai.ErrSchemaValidation, maxSchemaRetries, verrs) +} + +// correctionFeedback builds the instruction appended to the user prompt on a +// retry: the schema errors, plus the previous response when one was captured. +func correctionFeedback(prev *ai.Response, verrs string) string { + if prev == nil { + return fmt.Sprintf( + "\n\nYour previous response did not conform to the required JSON schema.\n"+ + "Schema validation errors:\n%s\n\n"+ + "Return a corrected JSON response that satisfies the schema.", + verrs) + } + return fmt.Sprintf( "\n\nYour previous response did not conform to the required JSON schema.\n"+ "Previous response:\n%s\n\nSchema validation errors:\n%s\n\n"+ "Return a corrected JSON response that satisfies the schema.", responseJSON(prev), verrs) - - req2 := req - req2.Prompt.User = req.Prompt.User + correction - - resp2, err := v.provider.Execute(ctx, req2) - if err != nil { - return resp2, err - } - verrs2, err := validateResponse(schema, resp2) - if err != nil { - return resp2, err - } - if verrs2 == "" { - return resp2, nil - } - return resp2, fmt.Errorf("%w: still invalid after retry: %s", ai.ErrSchemaValidation, verrs2) } // ExecuteStream tees the stream: it forwards every event unchanged, accumulates diff --git a/pkg/ai/middleware/validation_test.go b/pkg/ai/middleware/validation_test.go index 0d2c83c..4c8e81e 100644 --- a/pkg/ai/middleware/validation_test.go +++ b/pkg/ai/middleware/validation_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "fmt" + "strings" "testing" "github.com/flanksource/captain/pkg/ai" @@ -37,6 +39,36 @@ func (s *scriptedProvider) Execute(context.Context, ai.Request) (*ai.Response, e func (s *scriptedProvider) GetModel() string { return "test-model" } func (s *scriptedProvider) GetBackend() ai.Backend { return ai.BackendAnthropic } +// outcome is one scripted provider result: a text response or an error. +type outcome struct { + text string + err error +} + +// erroringProvider scripts a sequence of (response|error) outcomes (repeating the +// last), records every request's user prompt, and counts calls — so a provider-side +// schema rejection and the feedback appended on the retry can both be asserted. +type erroringProvider struct { + outcomes []outcome + calls int + prompts []string +} + +func (e *erroringProvider) Execute(_ context.Context, req ai.Request) (*ai.Response, error) { + e.prompts = append(e.prompts, req.Prompt.User) + idx := e.calls + if idx >= len(e.outcomes) { + idx = len(e.outcomes) - 1 + } + e.calls++ + if o := e.outcomes[idx]; o.err != nil { + return nil, o.err + } + return &ai.Response{Text: e.outcomes[idx].text, Model: "test-model"}, nil +} +func (e *erroringProvider) GetModel() string { return "test-model" } +func (e *erroringProvider) GetBackend() ai.Backend { return ai.BackendAnthropic } + func capRequest(strictness api.SchemaStrictness) ai.Request { return ai.Request{Prompt: api.Prompt{ User: "group the files", @@ -115,12 +147,74 @@ func TestValidation_RetrySucceedsOnSecond(t *testing.T) { } func TestValidation_RetryThenErrorWhenStillInvalid(t *testing.T) { - // Both attempts violate → hard error after exactly one retry (retry → then error). - _, err, calls := execWith(t, api.SchemaStrictnessRetry, overCapJSON, overCapJSON) + // Every attempt violates → hard error after the retry budget is exhausted + // (1 initial call + maxSchemaRetries re-asks). + _, err, calls := execWith(t, api.SchemaStrictnessRetry, overCapJSON) if !errors.Is(err, ai.ErrSchemaValidation) { - t.Fatalf("want ErrSchemaValidation after retry, got %v", err) + t.Fatalf("want ErrSchemaValidation after retries, got %v", err) } - if calls != 2 { - t.Errorf("retry must re-ask exactly once; want 2 calls, got %d", calls) + if want := 1 + maxSchemaRetries; calls != want { + t.Errorf("retry must re-ask maxSchemaRetries times; want %d calls, got %d", want, calls) + } +} + +func TestValidation_RetryRecoversProviderSchemaError(t *testing.T) { + // A genkit-style hard rejection during generation on the first call, then a + // conforming response on the retry: the middleware re-asks and returns the + // valid response instead of surfacing the error. + schemaErr := fmt.Errorf("%w: - groups: Array must have at most 2 items", ai.ErrSchemaValidation) + inner := &erroringProvider{outcomes: []outcome{{err: schemaErr}, {text: validJSON}}} + p, err := WithSchemaValidation()(inner) + if err != nil { + t.Fatalf("WithSchemaValidation: %v", err) + } + resp, err := p.Execute(context.Background(), capRequest(api.SchemaStrictnessRetry)) + if err != nil { + t.Fatalf("provider schema error should recover on retry, got %v", err) + } + if resp.Text != validJSON { + t.Errorf("want the corrected response, got %q", resp.Text) + } + if inner.calls != 2 { + t.Errorf("want 1 initial + 1 retry = 2 calls, got %d", inner.calls) + } + // The re-ask must feed the schema errors back so the model can correct itself. + if len(inner.prompts) < 2 || !strings.Contains(inner.prompts[1], "Array must have at most 2 items") { + t.Errorf("retry prompt must include the validation errors, got %v", inner.prompts) + } +} + +func TestValidation_ProviderSchemaErrorPassesThroughWithoutRetryStrictness(t *testing.T) { + // Without retry strictness a provider schema error surfaces as-is; no re-ask. + schemaErr := fmt.Errorf("%w: bad", ai.ErrSchemaValidation) + inner := &erroringProvider{outcomes: []outcome{{err: schemaErr}}} + p, err := WithSchemaValidation()(inner) + if err != nil { + t.Fatalf("WithSchemaValidation: %v", err) + } + _, err = p.Execute(context.Background(), capRequest(api.SchemaStrictnessNone)) + if !errors.Is(err, ai.ErrSchemaValidation) { + t.Fatalf("want the provider error surfaced, got %v", err) + } + if inner.calls != 1 { + t.Errorf("none strictness must not retry; want 1 call, got %d", inner.calls) + } +} + +func TestValidation_ProviderSchemaErrorExhaustsRetries(t *testing.T) { + // A provider that keeps rejecting during generation exhausts the retry budget + // and fails hard with ErrSchemaValidation. + schemaErr := fmt.Errorf("%w: - groups: too many", ai.ErrSchemaValidation) + inner := &erroringProvider{outcomes: []outcome{{err: schemaErr}}} // repeats + p, err := WithSchemaValidation()(inner) + if err != nil { + t.Fatalf("WithSchemaValidation: %v", err) + } + _, err = p.Execute(context.Background(), capRequest(api.SchemaStrictnessRetry)) + if !errors.Is(err, ai.ErrSchemaValidation) { + t.Fatalf("want ErrSchemaValidation after exhausting retries, got %v", err) + } + if want := 1 + maxSchemaRetries; inner.calls != want { + t.Errorf("want %d calls, got %d", want, inner.calls) } } diff --git a/pkg/ai/prompt/prompt_test.go b/pkg/ai/prompt/prompt_test.go index 447a5d8..bfc21c1 100644 --- a/pkg/ai/prompt/prompt_test.go +++ b/pkg/ai/prompt/prompt_test.go @@ -130,6 +130,7 @@ func TestRender_BackendFixtureExamples(t *testing.T) { "testdata/fixtures/claude-cmux-sonnet.prompt": {backend: api.BackendClaudeCmux, model: "claude-cmux-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-agent.prompt": {backend: api.BackendCodexAgent, model: "gpt-5-codex"}, "testdata/fixtures/codex-cmux.prompt": {backend: api.BackendCodexCmux, model: "gpt-5-codex"}, "testdata/fixtures/deepseek.prompt": {backend: api.BackendDeepSeek, model: "deepseek-reasoner"}, "testdata/fixtures/codex-cli.prompt": {backend: api.BackendCodexCLI, model: "gpt-5-codex"}, diff --git a/pkg/ai/prompt/testdata/fixtures/codex-agent.prompt b/pkg/ai/prompt/testdata/fixtures/codex-agent.prompt new file mode 100644 index 0000000..6677efc --- /dev/null +++ b/pkg/ai/prompt/testdata/fixtures/codex-agent.prompt @@ -0,0 +1,19 @@ +--- +model: gpt-5-codex +backend: codex-agent +effort: medium +setup: + cwd: . +permissions: + presets: + - edit + mcp: + disabled: true +memory: + bare: true +--- +{{role "system"}} +You are a Codex agent running through the app-server SDK. Make scoped edits and verify the result. +{{role "user"}} +Code task: +{{task}} diff --git a/pkg/claude/history.go b/pkg/claude/history.go index d473fae..ea7c52c 100644 --- a/pkg/claude/history.go +++ b/pkg/claude/history.go @@ -24,8 +24,19 @@ type HistoryEntry struct { // 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:"-"` + PlanFilePath string `json:"-"` + Event *TranscriptEvent `json:"-"` + RawLine json.RawMessage `json:"-"` +} + +// TranscriptEvent is a non-message, non-tool transcript line. It lets callers +// preserve session/turn metadata without pretending discovery or budget records +// are model messages. +type TranscriptEvent struct { + Type string `json:"type"` + Scope string `json:"scope,omitempty"` + Subtype string `json:"subtype,omitempty"` + Data map[string]any `json:"data,omitempty"` } // Message represents a conversation message @@ -107,6 +118,7 @@ type CacheCreation struct { // ServerToolUse tracks server-side tool usage type ServerToolUse struct { WebSearchRequests int `json:"web_search_requests,omitempty"` + WebFetchRequests int `json:"web_fetch_requests,omitempty"` } // IsUserMessage returns true if this is a user message diff --git a/pkg/claude/reader.go b/pkg/claude/reader.go index addceb3..f5c1393 100644 --- a/pkg/claude/reader.go +++ b/pkg/claude/reader.go @@ -136,7 +136,7 @@ type streamJSONLine struct { CWD string `json:"cwd,omitempty"` GitBranch string `json:"gitBranch,omitempty"` Slug string `json:"slug,omitempty"` - Error string `json:"error,omitempty"` + Error json.RawMessage `json:"error,omitempty"` Attachment json.RawMessage `json:"attachment,omitempty"` } @@ -156,16 +156,14 @@ func (sj streamJSONLine) timestamp() string { // Listed explicitly so they don't pollute the unhandled-types diagnostic. var knownSessionStorageTypes = map[string]bool{ "file-history-snapshot": true, - "last-prompt": true, "permission-mode": true, "agent-name": true, // Operational/streaming state with no unique row-level content — the real // content surfaces via the actual user/assistant messages. Listed so they // don't pollute the unhandled-types diagnostic. - "mode": true, // active mode marker (e.g. {"mode":"normal"}) - "bridge-session": true, // cloud bridge-session linkage - "progress": true, // intermediate streaming progress, superseded by the final message - "queue-operation": true, // message-queue bookkeeping; dequeued content appears as a real message + "mode": true, // active mode marker (e.g. {"mode":"normal"}) + "bridge-session": true, // cloud bridge-session linkage + "progress": true, // intermediate streaming progress, superseded by the final message } // planAttachment is the plan-mode attachment Claude Code writes when entering @@ -200,6 +198,50 @@ func attachmentEntry(sj streamJSONLine) []HistoryEntry { }) } +func metadataEventEntry(sj streamJSONLine, eventType, scope string, data map[string]any) []HistoryEntry { + if eventType == "" { + return nil + } + return single(HistoryEntry{ + SessionID: sj.sessionID(), + UUID: sj.UUID, + Timestamp: sj.timestamp(), + CWD: sj.CWD, + GitBranch: sj.GitBranch, + Slug: sj.Slug, + Event: &TranscriptEvent{ + Type: eventType, + Scope: scope, + Data: data, + }, + }) +} + +func rawObject(raw []byte) map[string]any { + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil + } + delete(out, "message") + return out +} + +func attachmentEventEntry(sj streamJSONLine, raw []byte) []HistoryEntry { + var attachment map[string]any + if err := json.Unmarshal(sj.Attachment, &attachment); err != nil { + return nil + } + typ, _ := attachment["type"].(string) + switch typ { + case "deferred_tools_delta", "agent_listing_delta", "skill_listing": + return metadataEventEntry(sj, typ, "session", attachment) + case "budget_usd": + return metadataEventEntry(sj, typ, "turn", attachment) + default: + return attachmentEntry(sj) + } +} + // dispatchEvent routes a typed line to zero, one, or more HistoryEntry rows. // A single line can emit multiple entries — e.g. an assistant message with a // top-level "error" field yields both the regular assistant entry and an @@ -230,6 +272,9 @@ func dispatchEvent(sj streamJSONLine, raw []byte, lineNo int) []HistoryEntry { } return out + case "queue-operation": + return metadataEventEntry(sj, "queue-operation", "turn", rawObject(raw)) + case "system": switch sj.Subtype { case "init": @@ -263,11 +308,16 @@ func dispatchEvent(sj streamJSONLine, raw []byte, lineNo int) []HistoryEntry { "content", "compactMetadata", "level", })) case "local_command": - return single(syntheticEntry(sj, "LocalCommand", raw, []string{"content", "level"})) + return single(syntheticEntry(sj, "LocalCommand", raw, []string{"content", "level", "cwd"})) case "scheduled_task_fire": return single(syntheticEntry(sj, "ScheduledTaskFire", raw, []string{"content"})) case "informational": return single(syntheticEntry(sj, "Informational", raw, []string{"content", "level"})) + case "api_error": + return single(syntheticEntry(sj, "ApiError", raw, []string{ + "error", "level", "retryInMs", "retryAttempt", "maxRetries", + "api_error_status", + })) } case "result": @@ -287,8 +337,20 @@ func dispatchEvent(sj streamJSONLine, raw []byte, lineNo int) []HistoryEntry { "prNumber", "prUrl", "prRepository", })) + case "worktree-state": + return single(syntheticEntry(sj, "WorktreeState", raw, []string{"worktreeSession"})) + + case "relocated": + return single(syntheticEntry(sj, "Relocated", raw, []string{"relocatedCwd"})) + + case "started": + return single(syntheticEntry(sj, "Started", raw, []string{"cwd"})) + case "attachment": - return attachmentEntry(sj) + return attachmentEventEntry(sj, raw) + + case "last-prompt": + return metadataEventEntry(sj, "last-prompt", "session", rawObject(raw)) } if knownSessionStorageTypes[sj.Type] { @@ -310,7 +372,7 @@ func single(e HistoryEntry) []HistoryEntry { return []HistoryEntry{e} } // carries a top-level "error" field (e.g. invalid_request, 4xx/5xx from the // model API). The error would otherwise be invisible in history output. func apiErrorFromAssistantLine(sj streamJSONLine, raw []byte) (HistoryEntry, bool) { - if sj.Error == "" { + if len(sj.Error) == 0 || string(sj.Error) == "null" { return HistoryEntry{}, false } return syntheticEntry(sj, "ApiError", raw, []string{ diff --git a/pkg/claude/reader_test.go b/pkg/claude/reader_test.go index e3f1ab9..7fe96f0 100644 --- a/pkg/claude/reader_test.go +++ b/pkg/claude/reader_test.go @@ -237,14 +237,57 @@ func TestReadStreamJSON_UnhandledTypes(t *testing.T) { if err != nil { t.Fatalf("ReadStreamJSON(state) failed: %v", err) } - if len(stateEntries) != 0 { - t.Errorf("operational state types should produce no rows, got %d", len(stateEntries)) + if len(stateEntries) != 1 { + t.Fatalf("queue-operation should produce one turn metadata event, got %d", len(stateEntries)) + } + if stateEntries[0].Event == nil || stateEntries[0].Event.Type != "queue-operation" || stateEntries[0].Event.Scope != "turn" { + t.Fatalf("queue-operation event = %+v, want turn metadata event", stateEntries[0].Event) } if got := SnapshotUnhandledStreamTypes(); len(got) != 0 { t.Errorf("operational state types should not be reported as unhandled, got %v", got) } } +func TestReadHistory_MetadataEvents(t *testing.T) { + jsonl := `{"type":"attachment","sessionId":"s","uuid":"tools","timestamp":"2026-07-05T10:00:00Z","attachment":{"type":"deferred_tools_delta","addedNames":["Read","Bash"],"pendingMcpServers":["github"]}} +{"type":"attachment","sessionId":"s","uuid":"agents","timestamp":"2026-07-05T10:00:01Z","attachment":{"type":"agent_listing_delta","addedTypes":["general-purpose"]}} +{"type":"attachment","sessionId":"s","uuid":"skills","timestamp":"2026-07-05T10:00:02Z","attachment":{"type":"skill_listing","names":["gavel-runner"]}} +{"type":"queue-operation","sessionId":"s","uuid":"queue","timestamp":"2026-07-05T10:00:03Z","operation":"enqueue","content":{"type":"message"}} +{"type":"attachment","sessionId":"s","uuid":"budget","timestamp":"2026-07-05T10:00:04Z","attachment":{"type":"budget_usd","used":1.25,"total":5,"remaining":3.75}} +{"type":"last-prompt","sessionId":"s","uuid":"prompt","timestamp":"2026-07-05T10:00:05Z","content":"fix it"}` + + entries, err := ReadHistory(strings.NewReader(jsonl)) + if err != nil { + t.Fatalf("ReadHistory failed: %v", err) + } + if len(entries) != 6 { + t.Fatalf("entries = %d, want 6", len(entries)) + } + want := []struct { + typ string + scope string + uuid string + }{ + {"deferred_tools_delta", "session", "tools"}, + {"agent_listing_delta", "session", "agents"}, + {"skill_listing", "session", "skills"}, + {"queue-operation", "turn", "queue"}, + {"budget_usd", "turn", "budget"}, + {"last-prompt", "session", "prompt"}, + } + for i, w := range want { + if entries[i].Event == nil { + t.Fatalf("entry[%d] missing event", i) + } + if entries[i].Event.Type != w.typ || entries[i].Event.Scope != w.scope || entries[i].UUID != w.uuid { + t.Errorf("entry[%d] event = %+v uuid=%q, want %s/%s/%s", i, entries[i].Event, entries[i].UUID, w.typ, w.scope, w.uuid) + } + if len(entries[i].Message.Content) != 0 { + t.Errorf("entry[%d] metadata event should not create message content: %+v", i, entries[i].Message.Content) + } + } +} + // TestReadStreamJSON_PrLinkSurfaced verifies a pr-link line becomes a PrLink // synthetic row carrying the PR fields, rather than being dropped as unhandled. func TestReadStreamJSON_PrLinkSurfaced(t *testing.T) { @@ -319,6 +362,58 @@ func TestReadStreamJSON_ContentSystemSubtypes(t *testing.T) { } } +func TestReadStreamJSON_SystemApiErrorSurfaced(t *testing.T) { + ResetUnhandledStreamTypes() + input := `{"type":"system","subtype":"api_error","error":{"message":"rate limited","status":429},"retryInMs":1000,"retryAttempt":1,"maxRetries":3,"uuid":"api"}` + + entries, err := ReadStreamJSON(strings.NewReader(input)) + if err != nil { + t.Fatalf("ReadStreamJSON failed: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 ApiError entry, got %d", len(entries)) + } + uses := entries[0].Message.GetToolUses() + if len(uses) != 1 || uses[0].Name != "ApiError" { + t.Fatalf("expected ApiError synthetic tool, got %+v", uses) + } + var in map[string]any + if err := json.Unmarshal(uses[0].Input, &in); err != nil { + t.Fatalf("unmarshal ApiError input: %v", err) + } + if in["retryAttempt"] != float64(1) || in["maxRetries"] != float64(3) { + t.Errorf("ApiError input missing retry fields: %v", in) + } + if got := SnapshotUnhandledStreamTypes(); len(got) != 0 { + t.Errorf("system/api_error should be handled, got unhandled: %v", got) + } +} + +func TestReadStreamJSON_WorktreeLifecycleEvents(t *testing.T) { + ResetUnhandledStreamTypes() + input := `{"type":"worktree-state","worktreeSession":{"worktreeName":"feature","worktreePath":"/repo","worktreeBranch":"feature/x"},"uuid":"w"} +{"type":"relocated","relocatedCwd":"/repo/subdir","uuid":"r"} +{"type":"started","cwd":"/repo","uuid":"s"}` + + entries, err := ReadStreamJSON(strings.NewReader(input)) + if err != nil { + t.Fatalf("ReadStreamJSON failed: %v", err) + } + if len(entries) != 3 { + t.Fatalf("expected 3 lifecycle entries, got %d", len(entries)) + } + want := []string{"WorktreeState", "Relocated", "Started"} + for i, name := range want { + uses := entries[i].Message.GetToolUses() + if len(uses) != 1 || uses[0].Name != name { + t.Fatalf("entry[%d] expected %s, got %+v", i, name, uses) + } + } + if got := SnapshotUnhandledStreamTypes(); len(got) != 0 { + t.Errorf("worktree lifecycle events should be handled, got unhandled: %v", got) + } +} + func TestReadHistory_SessionFileEvents(t *testing.T) { // On-disk session files use camelCase fields and a different mix of // types from stream-json. ReadHistory must recognize the same set of diff --git a/pkg/claude/tools/assistant.go b/pkg/claude/tools/assistant.go index 4ff1c0e..940edee 100644 --- a/pkg/claude/tools/assistant.go +++ b/pkg/claude/tools/assistant.go @@ -10,7 +10,7 @@ import ( type AssistantTool struct{ BaseTool } func (t *AssistantTool) Name() string { return "Assistant" } -func (t *AssistantTool) Category() string { return "message" } +func (t *AssistantTool) Category() string { return "chat" } func (t *AssistantTool) FilePath() string { return "" } func (t *AssistantTool) ExtractPath() string { return "" } @@ -30,7 +30,7 @@ func (t *AssistantTool) Detail() api.Textable { return t.BaseTool.Detail() } type ReasoningTool struct{ BaseTool } func (t *ReasoningTool) Name() string { return "Reasoning" } -func (t *ReasoningTool) Category() string { return "message" } +func (t *ReasoningTool) Category() string { return "chat" } func (t *ReasoningTool) FilePath() string { return "" } func (t *ReasoningTool) ExtractPath() string { return "" } diff --git a/pkg/claude/tools/event.go b/pkg/claude/tools/event.go new file mode 100644 index 0000000..7731761 --- /dev/null +++ b/pkg/claude/tools/event.go @@ -0,0 +1,762 @@ +package tools + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/api/icons" +) + +// EventToolName maps raw Codex/Claude event names to concrete synthetic tools. +// The raw event name is still preserved in BaseTool.Input["event"]. +func EventToolName(eventType string) string { + switch strings.TrimSpace(eventType) { + case "token_count": + return "TokenCount" + case "task_started": + return "TaskStarted" + case "task_complete": + return "TaskComplete" + case "turn_aborted": + return "TurnAborted" + case "context_compacted": + return "ContextCompacted" + case "thread_rolled_back": + return "ThreadRolledBack" + case "item_completed": + return "ItemCompleted" + case "exec_command_end": + return "CodexExecCommand" + case "patch_apply_end": + return "CodexPatchApply" + case "mcp_tool_call_end": + return "MCPToolCall" + case "web_search_end": + return "WebSearchEvent" + case "view_image_tool_call": + return "ViewImage" + case "guardian_assessment": + return "GuardianAssessment" + case "entered_review_mode", "exited_review_mode": + return "ReviewMode" + case "collab_agent_spawn_end": + return "CollabAgentSpawn" + case "collab_agent_interaction_end": + return "CollabAgentInteraction" + case "collab_waiting_end": + return "CollabWaiting" + case "collab_close_end": + return "CollabClose" + case "error": + return "ApiError" + case "queue-operation": + return "QueueOperation" + case "deferred_tools_delta": + return "DeferredToolsDelta" + case "agent_listing_delta": + return "AgentListingDelta" + case "skill_listing": + return "SkillListing" + case "budget_usd": + return "Budget" + case "worktree-state": + return "WorktreeState" + case "relocated": + return "Relocated" + case "started": + return "Started" + default: + return "Event" + } +} + +// IsEventToolName reports whether name is a synthetic transcript event row. +func IsEventToolName(name string) bool { + switch name { + case "Event", "TokenCount", "TaskStarted", "TaskComplete", "TurnAborted", + "ContextCompacted", "ThreadRolledBack", "ItemCompleted", + "CodexExecCommand", "CodexPatchApply", "MCPToolCall", + "WebSearchEvent", "ViewImage", "GuardianAssessment", "ReviewMode", + "CollabAgentSpawn", "CollabAgentInteraction", "CollabWaiting", + "CollabClose", "QueueOperation", "DeferredToolsDelta", + "AgentListingDelta", "SkillListing", "Budget", "PrLink", + "CompactBoundary", "LocalCommand", "ScheduledTaskFire", + "Informational", "WorktreeState", "Relocated", "Started": + return true + default: + return false + } +} + +type EventTool struct{ BaseTool } + +func (t *EventTool) Name() string { return "Event" } +func (t *EventTool) Category() string { return "chat" } +func (t *EventTool) FilePath() string { return "" } +func (t *EventTool) ExtractPath() string { return "" } + +func (t *EventTool) Pretty() api.Text { + name := t.Str("event") + if name == "" { + name = "event" + } + text := clicky.Text(""). + Add(icons.Info). + Append(" "+name, "text-slate-500 font-medium") + if msg := t.Str("message"); msg != "" { + text = text.Append(" "+msg, "text-gray-500 max-w-[tw-20ch]") + } + if duration := eventInt(t.Input["duration_ms"]); duration > 0 { + text = text.Append(fmt.Sprintf(" %dms", duration), "text-gray-500") + } + return text +} + +func (t *EventTool) Detail() api.Textable { return t.BaseTool.Detail() } + +type TokenCountTool struct{ BaseTool } + +func (t *TokenCountTool) Name() string { return "TokenCount" } +func (t *TokenCountTool) Category() string { return "chat" } +func (t *TokenCountTool) FilePath() string { return "" } +func (t *TokenCountTool) ExtractPath() string { return "" } +func (t *TokenCountTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *TokenCountTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "◌", Iconify: "mdi:counter", Style: "muted"}, "tokens", "text-slate-500 font-medium") + if total := eventInt(t.Input["total_tokens"]); total > 0 { + text = text.Append(" "+FormatCompactTokens(int(total)), "text-gray-600") + } + if in := eventInt(t.Input["input_tokens"]); in > 0 { + text = text.Append(fmt.Sprintf(" in=%s", FormatCompactTokens(int(in))), "text-gray-500") + } + if out := eventInt(t.Input["output_tokens"]); out > 0 { + text = text.Append(fmt.Sprintf(" out=%s", FormatCompactTokens(int(out))), "text-gray-500") + } + return text +} + +type TaskStartedTool struct{ BaseTool } + +func (t *TaskStartedTool) Name() string { return "TaskStarted" } +func (t *TaskStartedTool) Category() string { return "chat" } +func (t *TaskStartedTool) FilePath() string { return "" } +func (t *TaskStartedTool) ExtractPath() string { return "" } +func (t *TaskStartedTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *TaskStartedTool) Pretty() api.Text { + text := eventText(icons.Play, "task started", "text-green-600 font-medium") + if mode := t.Str("collaboration_mode_kind"); mode != "" { + text = text.Append(" "+mode, "text-gray-500") + } + if window := eventInt(t.Input["model_context_window"]); window > 0 { + text = text.Append(fmt.Sprintf(" ctx=%s", FormatCompactTokens(int(window))), "text-gray-500") + } + return text +} + +type TaskCompleteTool struct{ BaseTool } + +func (t *TaskCompleteTool) Name() string { return "TaskComplete" } +func (t *TaskCompleteTool) Category() string { return "chat" } +func (t *TaskCompleteTool) FilePath() string { return "" } +func (t *TaskCompleteTool) ExtractPath() string { return "" } +func (t *TaskCompleteTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *TaskCompleteTool) Pretty() api.Text { + text := eventText(icons.Check, "task complete", "text-green-600 font-medium") + if duration := eventInt(t.Input["duration_ms"]); duration > 0 { + text = text.Append(" "+formatDurationMS(float64(duration)), "text-gray-500") + } + if msg := eventString(t.Input["last_agent_message"]); msg != "" { + text = text.Append(" "+eventPreview(msg, 90), "text-gray-500 italic") + } + return text +} + +type LifecycleEventTool struct{ BaseTool } + +func (t *LifecycleEventTool) Name() string { return t.RawTool } +func (t *LifecycleEventTool) Category() string { return "chat" } +func (t *LifecycleEventTool) FilePath() string { return "" } +func (t *LifecycleEventTool) ExtractPath() string { return "" } +func (t *LifecycleEventTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *LifecycleEventTool) Pretty() api.Text { + label := strings.TrimSpace(t.Str("event")) + if label == "" { + label = strings.ToLower(t.RawTool) + } + text := eventText(icons.Info, strings.ReplaceAll(label, "_", " "), "text-slate-500 font-medium") + if reason := t.Str("reason"); reason != "" { + text = text.Append(" "+reason, "text-gray-500") + } + if turns := eventInt(t.Input["num_turns"]); turns > 0 { + text = text.Append(fmt.Sprintf(" turns=%d", turns), "text-gray-500") + } + return text +} + +type CodexExecCommandTool struct{ BaseTool } + +func (t *CodexExecCommandTool) Name() string { return "CodexExecCommand" } +func (t *CodexExecCommandTool) Category() string { return "chat" } +func (t *CodexExecCommandTool) FilePath() string { return "" } +func (t *CodexExecCommandTool) ExtractPath() string { + if cwd := t.Str("cwd"); cwd != "" { + return cwd + } + return "" +} +func (t *CodexExecCommandTool) Detail() api.Textable { return commandOutputDetail(t.BaseTool) } +func (t *CodexExecCommandTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "💻", Iconify: "codicon:terminal", Style: "muted"}, "exec", "text-green-500 font-medium") + if cmd := codexCommandString(t.Input["command"]); cmd != "" { + text = text.Append(" "+eventPreview(cmd, 120), "text-gray-700") + } + if status := t.Str("status"); status != "" { + color := "text-green-600" + if status != "completed" && status != "success" { + color = "text-red-500" + } + text = text.Append(" "+status, color) + } + if code := eventInt(t.Input["exit_code"]); code != 0 { + text = text.Append(fmt.Sprintf(" exit=%d", code), "text-red-500") + } + return text +} + +type CodexPatchApplyTool struct{ BaseTool } + +func (t *CodexPatchApplyTool) Name() string { return "CodexPatchApply" } +func (t *CodexPatchApplyTool) Category() string { return "chat" } +func (t *CodexPatchApplyTool) FilePath() string { return "" } +func (t *CodexPatchApplyTool) ExtractPath() string { return "" } +func (t *CodexPatchApplyTool) Detail() api.Textable { return commandOutputDetail(t.BaseTool) } +func (t *CodexPatchApplyTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "✏️", Iconify: "codicon:edit", Style: "muted"}, "patch", "text-orange-500 font-medium") + if success, ok := t.Input["success"].(bool); ok { + if success { + text = text.Append(" applied", "text-green-600") + } else { + text = text.Append(" failed", "text-red-500") + } + } + if n := mapLen(t.Input["changes"]); n > 0 { + text = text.Append(fmt.Sprintf(" files=%d", n), "text-gray-500") + } + return text +} + +type MCPToolCallTool struct{ BaseTool } + +func (t *MCPToolCallTool) Name() string { return "MCPToolCall" } +func (t *MCPToolCallTool) Category() string { return "chat" } +func (t *MCPToolCallTool) FilePath() string { return "" } +func (t *MCPToolCallTool) ExtractPath() string { return "" } +func (t *MCPToolCallTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *MCPToolCallTool) Pretty() api.Text { + server, tool := invocationName(t.Input["invocation"]) + text := eventText(icons.Package, "mcp", "text-indigo-500 font-medium") + if server != "" || tool != "" { + text = text.Append(" "+strings.Trim(server+"."+tool, "."), "text-gray-700") + } + if d := durationString(t.Input["duration"]); d != "" { + text = text.Append(" "+d, "text-gray-500") + } + if resultStatus := resultStatus(t.Input["result"]); resultStatus != "" { + text = text.Append(" "+resultStatus, statusColor(resultStatus)) + } + return text +} + +type WebSearchEventTool struct{ BaseTool } + +func (t *WebSearchEventTool) Name() string { return "WebSearchEvent" } +func (t *WebSearchEventTool) Category() string { return "chat" } +func (t *WebSearchEventTool) FilePath() string { return "" } +func (t *WebSearchEventTool) ExtractPath() string { return "" } +func (t *WebSearchEventTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *WebSearchEventTool) Pretty() api.Text { + text := eventText(icons.Search, "web search", "text-purple-500 font-medium") + if query := firstNonEmptyEvent(t.Str("query"), actionQuery(t.Input["action"])); query != "" { + text = text.Append(" "+eventPreview(query, 100), "text-gray-700") + } + return text +} + +type ViewImageTool struct{ BaseTool } + +func (t *ViewImageTool) Name() string { return "ViewImage" } +func (t *ViewImageTool) Category() string { return "chat" } +func (t *ViewImageTool) FilePath() string { return t.Str("path") } +func (t *ViewImageTool) ExtractPath() string { return t.Str("path") } +func (t *ViewImageTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *ViewImageTool) Pretty() api.Text { + text := eventText(icons.File, "image", "text-cyan-500 font-medium") + if path := t.Str("path"); path != "" { + text = text.Append(" "+ShortenPath(path), "text-gray-700") + } + return text +} + +type GuardianAssessmentTool struct{ BaseTool } + +func (t *GuardianAssessmentTool) Name() string { return "GuardianAssessment" } +func (t *GuardianAssessmentTool) Category() string { return "chat" } +func (t *GuardianAssessmentTool) FilePath() string { return "" } +func (t *GuardianAssessmentTool) ExtractPath() string { return "" } +func (t *GuardianAssessmentTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *GuardianAssessmentTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "🛡", Iconify: "mdi:shield-check", Style: "muted"}, "guardian", "text-amber-600 font-medium") + if status := t.Str("status"); status != "" { + text = text.Append(" "+status, statusColor(status)) + } + if action := actionSummary(t.Input["action"]); action != "" { + text = text.Append(" "+eventPreview(action, 100), "text-gray-600") + } + return text +} + +type ReviewModeTool struct{ BaseTool } + +func (t *ReviewModeTool) Name() string { return "ReviewMode" } +func (t *ReviewModeTool) Category() string { return "chat" } +func (t *ReviewModeTool) FilePath() string { return "" } +func (t *ReviewModeTool) ExtractPath() string { return "" } +func (t *ReviewModeTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *ReviewModeTool) Pretty() api.Text { + action := strings.TrimSuffix(strings.TrimPrefix(t.Str("event"), "entered_"), "_mode") + if action == "" { + action = strings.TrimSuffix(strings.TrimPrefix(t.Str("event"), "exited_"), "_mode") + } + text := eventText(icons.Info, "review "+action, "text-purple-500 font-medium") + if n := findingsCount(t.Input["review_output"]); n > 0 { + text = text.Append(fmt.Sprintf(" findings=%d", n), "text-gray-500") + } + return text +} + +type CollabEventTool struct{ BaseTool } + +func (t *CollabEventTool) Name() string { return t.RawTool } +func (t *CollabEventTool) Category() string { return "chat" } +func (t *CollabEventTool) FilePath() string { return "" } +func (t *CollabEventTool) ExtractPath() string { return "" } +func (t *CollabEventTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *CollabEventTool) Pretty() api.Text { + label := strings.TrimPrefix(strings.TrimPrefix(t.Str("event"), "collab_"), "agent_") + label = strings.TrimSuffix(label, "_end") + if label == "" { + label = strings.ToLower(t.RawTool) + } + text := eventText(icons.Icon{Unicode: "🤝", Iconify: "mdi:handshake", Style: "muted"}, "collab "+strings.ReplaceAll(label, "_", " "), "text-indigo-500 font-medium") + if nick := firstNonEmptyEvent(t.Str("nickname"), nestedString(t.Input["receiver"], "nickname")); nick != "" { + text = text.Append(" "+nick, "text-gray-700") + } + if status := t.Str("status"); status != "" { + text = text.Append(" "+status, statusColor(status)) + } + return text +} + +type QueueOperationTool struct{ BaseTool } + +func (t *QueueOperationTool) Name() string { return "QueueOperation" } +func (t *QueueOperationTool) Category() string { return "chat" } +func (t *QueueOperationTool) FilePath() string { return "" } +func (t *QueueOperationTool) ExtractPath() string { return "" } +func (t *QueueOperationTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *QueueOperationTool) Pretty() api.Text { + op := firstNonEmptyEvent(t.Str("operation"), "queue") + text := eventText(icons.Package, "queue "+op, "text-slate-500 font-medium") + if content := t.Str("content"); content != "" { + text = text.Append(" "+eventPreview(content, 100), "text-gray-500") + } + return text +} + +type DeferredToolsDeltaTool struct{ BaseTool } + +func (t *DeferredToolsDeltaTool) Name() string { return "DeferredToolsDelta" } +func (t *DeferredToolsDeltaTool) Category() string { return "chat" } +func (t *DeferredToolsDeltaTool) FilePath() string { return "" } +func (t *DeferredToolsDeltaTool) ExtractPath() string { return "" } +func (t *DeferredToolsDeltaTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *DeferredToolsDeltaTool) Pretty() api.Text { + text := eventText(icons.Package, "tools", "text-slate-500 font-medium") + appendListCount(&text, "added", t.Input["addedNames"]) + appendListCount(&text, "pending", t.Input["pendingMcpServers"]) + return text +} + +type AgentListingDeltaTool struct{ BaseTool } + +func (t *AgentListingDeltaTool) Name() string { return "AgentListingDelta" } +func (t *AgentListingDeltaTool) Category() string { return "chat" } +func (t *AgentListingDeltaTool) FilePath() string { return "" } +func (t *AgentListingDeltaTool) ExtractPath() string { return "" } +func (t *AgentListingDeltaTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *AgentListingDeltaTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "🤖", Iconify: "mdi:robot", Style: "muted"}, "agents", "text-indigo-500 font-medium") + appendListCount(&text, "added", t.Input["addedTypes"]) + return text +} + +type SkillListingTool struct{ BaseTool } + +func (t *SkillListingTool) Name() string { return "SkillListing" } +func (t *SkillListingTool) Category() string { return "chat" } +func (t *SkillListingTool) FilePath() string { return "" } +func (t *SkillListingTool) ExtractPath() string { return "" } +func (t *SkillListingTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *SkillListingTool) Pretty() api.Text { + text := eventText(icons.Info, "skills", "text-teal-500 font-medium") + appendListCount(&text, "count", t.Input["names"]) + if count := eventInt(t.Input["skillCount"]); count > 0 { + text = text.Append(fmt.Sprintf(" count=%d", count), "text-gray-500") + } + return text +} + +type BudgetTool struct{ BaseTool } + +func (t *BudgetTool) Name() string { return "Budget" } +func (t *BudgetTool) Category() string { return "chat" } +func (t *BudgetTool) FilePath() string { return "" } +func (t *BudgetTool) ExtractPath() string { return "" } +func (t *BudgetTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *BudgetTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "$", Iconify: "mdi:cash", Style: "muted"}, "budget", "text-green-600 font-medium") + if used := eventFloat(t.Input["used"]); used > 0 { + text = text.Append(fmt.Sprintf(" used=$%.2f", used), "text-gray-600") + } + if total := eventFloat(t.Input["total"]); total > 0 { + text = text.Append(fmt.Sprintf(" total=$%.2f", total), "text-gray-500") + } + if remaining := eventFloat(t.Input["remaining"]); remaining > 0 { + text = text.Append(fmt.Sprintf(" remaining=$%.2f", remaining), "text-gray-500") + } + return text +} + +type PrLinkTool struct{ BaseTool } + +func (t *PrLinkTool) Name() string { return "PrLink" } +func (t *PrLinkTool) Category() string { return "chat" } +func (t *PrLinkTool) FilePath() string { return "" } +func (t *PrLinkTool) ExtractPath() string { return "" } +func (t *PrLinkTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *PrLinkTool) Pretty() api.Text { + text := eventText(icons.Git, "pr", "text-cyan-600 font-medium") + if n := eventInt(t.Input["prNumber"]); n > 0 { + text = text.Append(fmt.Sprintf(" #%d", n), "text-gray-700") + } + if repo := t.Str("prRepository"); repo != "" { + text = text.Append(" "+repo, "text-gray-500") + } + return text +} + +type ContentEventTool struct{ BaseTool } + +func (t *ContentEventTool) Name() string { return t.RawTool } +func (t *ContentEventTool) Category() string { return "chat" } +func (t *ContentEventTool) FilePath() string { return "" } +func (t *ContentEventTool) ExtractPath() string { return "" } +func (t *ContentEventTool) Detail() api.Textable { + if d := t.BaseTool.Detail(); d != nil { + return d + } + if c := strings.TrimSpace(t.Str("content")); c != "" { + text := clicky.Text("").Append(c, "") + return &text + } + return nil +} +func (t *ContentEventTool) Pretty() api.Text { + label := strings.TrimSpace(t.Str("event")) + if label == "" { + label = strings.ToLower(t.RawTool) + } + text := eventText(icons.Info, strings.ReplaceAll(label, "_", " "), "text-slate-500 font-medium") + if content := t.Str("content"); content != "" { + text = text.Append(" "+eventPreview(content, 100), "text-gray-500") + } + return text +} + +type WorktreeStateTool struct{ BaseTool } + +func (t *WorktreeStateTool) Name() string { return "WorktreeState" } +func (t *WorktreeStateTool) Category() string { return "chat" } +func (t *WorktreeStateTool) FilePath() string { + return nestedString(t.Input["worktreeSession"], "worktreePath") +} +func (t *WorktreeStateTool) ExtractPath() string { return t.FilePath() } +func (t *WorktreeStateTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *WorktreeStateTool) Pretty() api.Text { + text := eventText(icons.Git, "worktree", "text-green-600 font-medium") + if name := nestedString(t.Input["worktreeSession"], "worktreeName"); name != "" { + text = text.Append(" "+name, "text-gray-700") + } + if branch := nestedString(t.Input["worktreeSession"], "worktreeBranch"); branch != "" { + text = text.Append(" "+branch, "text-gray-500") + } + return text +} + +type RelocatedTool struct{ BaseTool } + +func (t *RelocatedTool) Name() string { return "Relocated" } +func (t *RelocatedTool) Category() string { return "chat" } +func (t *RelocatedTool) FilePath() string { return t.Str("relocatedCwd") } +func (t *RelocatedTool) ExtractPath() string { return t.Str("relocatedCwd") } +func (t *RelocatedTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *RelocatedTool) Pretty() api.Text { + text := eventText(icons.Folder, "relocated", "text-cyan-600 font-medium") + if cwd := t.Str("relocatedCwd"); cwd != "" { + text = text.Append(" "+ShortenPath(cwd), "text-gray-700") + } + return text +} + +type StartedTool struct{ BaseTool } + +func (t *StartedTool) Name() string { return "Started" } +func (t *StartedTool) Category() string { return "chat" } +func (t *StartedTool) FilePath() string { return "" } +func (t *StartedTool) ExtractPath() string { return "" } +func (t *StartedTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *StartedTool) Pretty() api.Text { + return eventText(icons.Play, "started", "text-green-600 font-medium") +} + +func eventInt(value any) int64 { + switch v := value.(type) { + case int: + return int64(v) + case int64: + return v + case float64: + return int64(v) + default: + return 0 + } +} + +func eventFloat(value any) float64 { + switch v := value.(type) { + case int: + return float64(v) + case int64: + return float64(v) + case float64: + return v + default: + return 0 + } +} + +func eventString(value any) string { + switch v := value.(type) { + case string: + return v + default: + return "" + } +} + +func eventText(icon api.Textable, label, color string) api.Text { + return clicky.Text("").Add(icon).Append(" "+label, color) +} + +func eventPreview(s string, max int) string { + s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ") + if max <= 0 || len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + +func firstNonEmptyEvent(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mapLen(value any) int { + switch v := value.(type) { + case map[string]any: + return len(v) + case map[string]map[string]any: + return len(v) + default: + return 0 + } +} + +func listLen(value any) int { + switch v := value.(type) { + case []any: + return len(v) + case []string: + return len(v) + default: + return 0 + } +} + +func appendListCount(text *api.Text, label string, value any) { + if n := listLen(value); n > 0 { + *text = text.Append(fmt.Sprintf(" %s=%d", label, n), "text-gray-500") + } +} + +func statusColor(status string) string { + switch strings.ToLower(status) { + case "completed", "success", "ok", "approved", "pass", "passed": + return "text-green-600" + case "failed", "error", "denied", "blocked", "rejected", "interrupted": + return "text-red-500" + default: + return "text-gray-500" + } +} + +func codexCommandString(value any) string { + switch v := value.(type) { + case string: + return v + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + parts = append(parts, s) + } + } + return strings.Join(parts, " ") + case []string: + return strings.Join(v, " ") + default: + return "" + } +} + +func commandOutputDetail(base BaseTool) api.Textable { + if d := base.Detail(); d != nil { + return d + } + stdout := strings.TrimSpace(base.Str("stdout")) + stderr := strings.TrimSpace(base.Str("stderr")) + if stdout == "" && stderr == "" { + stdout = strings.TrimSpace(base.Str("aggregated_output")) + } + if stdout == "" && stderr == "" { + return nil + } + text := clicky.Text("") + if stdout != "" { + text = text.Append("stdout: ", "font-bold text-gray-600").Append(stdout, "") + } + if stderr != "" { + if stdout != "" { + text = text.NewLine() + } + text = text.Append("stderr: ", "font-bold text-red-500").Append(stderr, "") + } + return &text +} + +func invocationName(value any) (string, string) { + m, ok := value.(map[string]any) + if !ok { + return "", "" + } + return eventString(m["server"]), eventString(m["tool"]) +} + +func durationString(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + if secs := eventFloat(m["secs"]); secs > 0 { + return formatDurationMS(secs * 1000) + } + if nanos := eventFloat(m["nanos"]); nanos > 0 { + return formatDurationMS(nanos / 1_000_000) + } + return "" +} + +func resultStatus(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + for k := range m { + if strings.EqualFold(k, "ok") { + return "ok" + } + if strings.EqualFold(k, "err") || strings.EqualFold(k, "error") { + return "error" + } + } + return "" +} + +func actionQuery(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + if q := eventString(m["query"]); q != "" { + return q + } + if qs, ok := m["queries"].([]any); ok && len(qs) > 0 { + if q, ok := qs[0].(string); ok { + return q + } + } + return "" +} + +func actionSummary(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + parts := make([]string, 0, 3) + for _, key := range []string{"tool", "command", "cwd"} { + if value := eventString(m[key]); value != "" { + if key == "cwd" { + value = filepath.Base(value) + } + parts = append(parts, value) + } + } + return strings.Join(parts, " ") +} + +func findingsCount(value any) int { + m, ok := value.(map[string]any) + if !ok { + return 0 + } + return listLen(m["findings"]) +} + +func nestedString(value any, key string) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + return eventString(m[key]) +} diff --git a/pkg/claude/tools/stream.go b/pkg/claude/tools/stream.go index ab9e5c3..cbcc105 100644 --- a/pkg/claude/tools/stream.go +++ b/pkg/claude/tools/stream.go @@ -156,7 +156,7 @@ func (t *ApiErrorTool) Pretty() api.Text { text := clicky.Text(""). Add(icons.Icon{Unicode: "❌", Iconify: "mdi:alert-circle", Style: "muted"}). Append(" api-error", "text-red-500 font-bold") - if errStr := t.Str("error"); errStr != "" { + if errStr := errorSummary(t.Input["error"]); errStr != "" { text = text.Append(" "+errStr, "text-red-500") } if status := int(t.Float("api_error_status")); status > 0 { @@ -168,6 +168,23 @@ func (t *ApiErrorTool) Pretty() api.Text { return text } +func errorSummary(value any) string { + switch v := value.(type) { + case string: + return v + case map[string]any: + for _, key := range []string{"formatted", "message", "status"} { + if s, ok := v[key].(string); ok && s != "" { + return s + } + if n, ok := v[key].(float64); ok && n != 0 { + return fmt.Sprintf("%.0f", n) + } + } + } + return "" +} + func (t *ApiErrorTool) Detail() api.Textable { if d := t.BaseTool.Detail(); d != nil { return d diff --git a/pkg/claude/tools/stream_test.go b/pkg/claude/tools/stream_test.go index 57f402a..f7e00be 100644 --- a/pkg/claude/tools/stream_test.go +++ b/pkg/claude/tools/stream_test.go @@ -76,7 +76,18 @@ func TestResultSummaryTool_NameCategoryPretty(t *testing.T) { } func TestNewTool_DispatchSyntheticTypes(t *testing.T) { - for _, name := range []string{"SessionInit", "HookStart", "HookResponse", "Result"} { + for _, name := range []string{ + "SessionInit", "HookStart", "HookResponse", "Result", + "TokenCount", "TaskStarted", "TaskComplete", "TurnAborted", + "ContextCompacted", "ThreadRolledBack", "ItemCompleted", + "CodexExecCommand", "CodexPatchApply", "MCPToolCall", + "WebSearchEvent", "ViewImage", "GuardianAssessment", "ReviewMode", + "CollabAgentSpawn", "CollabAgentInteraction", "CollabWaiting", + "CollabClose", "QueueOperation", "DeferredToolsDelta", + "AgentListingDelta", "SkillListing", "Budget", "PrLink", + "CompactBoundary", "LocalCommand", "ScheduledTaskFire", + "Informational", "WorktreeState", "Relocated", "Started", + } { got := NewTool(BaseTool{RawTool: name}) assert.Equal(t, name, got.Name(), "expected NewTool to return the right concrete type for %q", name) } diff --git a/pkg/claude/tools/tool.go b/pkg/claude/tools/tool.go index c08e9f4..c4cca75 100644 --- a/pkg/claude/tools/tool.go +++ b/pkg/claude/tools/tool.go @@ -241,6 +241,52 @@ func NewTool(base BaseTool) Tool { return &AssistantTool{BaseTool: base} case "Reasoning": return &ReasoningTool{BaseTool: base} + case "Event": + return &EventTool{BaseTool: base} + case "TokenCount": + return &TokenCountTool{BaseTool: base} + case "TaskStarted": + return &TaskStartedTool{BaseTool: base} + case "TaskComplete": + return &TaskCompleteTool{BaseTool: base} + case "TurnAborted", "ContextCompacted", "ThreadRolledBack", "ItemCompleted": + return &LifecycleEventTool{BaseTool: base} + case "CodexExecCommand": + return &CodexExecCommandTool{BaseTool: base} + case "CodexPatchApply": + return &CodexPatchApplyTool{BaseTool: base} + case "MCPToolCall": + return &MCPToolCallTool{BaseTool: base} + case "WebSearchEvent": + return &WebSearchEventTool{BaseTool: base} + case "ViewImage": + return &ViewImageTool{BaseTool: base} + case "GuardianAssessment": + return &GuardianAssessmentTool{BaseTool: base} + case "ReviewMode": + return &ReviewModeTool{BaseTool: base} + case "CollabAgentSpawn", "CollabAgentInteraction", "CollabWaiting", "CollabClose": + return &CollabEventTool{BaseTool: base} + case "QueueOperation": + return &QueueOperationTool{BaseTool: base} + case "DeferredToolsDelta": + return &DeferredToolsDeltaTool{BaseTool: base} + case "AgentListingDelta": + return &AgentListingDeltaTool{BaseTool: base} + case "SkillListing": + return &SkillListingTool{BaseTool: base} + case "Budget": + return &BudgetTool{BaseTool: base} + case "PrLink": + return &PrLinkTool{BaseTool: base} + case "CompactBoundary", "LocalCommand", "ScheduledTaskFire", "Informational": + return &ContentEventTool{BaseTool: base} + case "WorktreeState": + return &WorktreeStateTool{BaseTool: base} + case "Relocated": + return &RelocatedTool{BaseTool: base} + case "Started": + return &StartedTool{BaseTool: base} case "Skill": return &SkillTool{BaseTool: base} case "SessionInit": diff --git a/pkg/claude/tools/user.go b/pkg/claude/tools/user.go index 3b5e72a..2aa4b10 100644 --- a/pkg/claude/tools/user.go +++ b/pkg/claude/tools/user.go @@ -8,7 +8,7 @@ import ( type UserTool struct{ BaseTool } func (t *UserTool) Name() string { return "User" } -func (t *UserTool) Category() string { return "" } +func (t *UserTool) Category() string { return "chat" } func (t *UserTool) FilePath() string { return "" } func (t *UserTool) ExtractPath() string { return "" } func (t *UserTool) Detail() api.Textable { return nil } diff --git a/pkg/claude/tooluse.go b/pkg/claude/tooluse.go index e1f0ad1..ccdecf4 100644 --- a/pkg/claude/tooluse.go +++ b/pkg/claude/tooluse.go @@ -96,47 +96,86 @@ const denialCommentSeparator = "the user said:\n" const boilerplatePrefix = "\n\nNote: The user" const askAnswerPrefix = "User has answered your question." -// ExtractToolUses extracts ToolUse records from history entries +// ExtractToolUses extracts history activity rows from transcript entries: +// user/assistant text, reasoning, metadata events, and real tool calls. func ExtractToolUses(entries []HistoryEntry) []ToolUse { var toolUses []ToolUse - // Pass 1: extract tool_use blocks for _, entry := range entries { ts, _ := entry.ParseTimestamp() + var timestamp *time.Time + if !ts.IsZero() { + timestamp = &ts + } - for _, content := range entry.Message.Content { - if content.Type != ContentTypeToolUse { - continue - } - - var inputMap map[string]any - if content.Input != nil { - _ = json.Unmarshal(content.Input, &inputMap) - } + if isVisibleTranscriptEvent(entry.Event) { + toolUses = append(toolUses, ToolUse{ + Tool: tools.EventToolName(entry.Event.Type), + Input: eventInput(entry.Event), + Timestamp: timestamp, + CWD: entry.CWD, + SessionID: entry.SessionID, + ToolUseID: entry.UUID, + RawEntry: entry.RawLine, + }) + } - var timestamp *time.Time - if !ts.IsZero() { - timestamp = &ts - } + for _, content := range entry.Message.Content { + switch content.Type { + case ContentTypeText: + tool := messageTool(entry.Message.Role) + if tool == "" || content.Text == "" { + continue + } + toolUses = append(toolUses, ToolUse{ + Tool: tool, + Input: map[string]any{"text": content.Text}, + Timestamp: timestamp, + CWD: entry.CWD, + SessionID: entry.SessionID, + IsSidechain: entry.IsSidechain, + AgentID: entry.AgentID, + RawEntry: entry.RawLine, + }) + case ContentTypeThinking, ContentTypeRedactedThinking: + if content.Thinking == "" { + continue + } + toolUses = append(toolUses, ToolUse{ + Tool: "Reasoning", + Input: map[string]any{"text": content.Thinking}, + Timestamp: timestamp, + CWD: entry.CWD, + SessionID: entry.SessionID, + IsSidechain: entry.IsSidechain, + AgentID: entry.AgentID, + RawEntry: entry.RawLine, + }) + case ContentTypeToolUse: + var inputMap map[string]any + if content.Input != nil { + _ = json.Unmarshal(content.Input, &inputMap) + } - var cwd string - if inputMap != nil { - if v, ok := inputMap["cwd"].(string); ok { - cwd = v + var cwd string + if inputMap != nil { + if v, ok := inputMap["cwd"].(string); ok { + cwd = v + } } - } - toolUses = append(toolUses, ToolUse{ - Tool: content.Name, - Input: inputMap, - Timestamp: timestamp, - CWD: cwd, - SessionID: entry.SessionID, - ToolUseID: content.ID, - IsSidechain: entry.IsSidechain, - AgentID: entry.AgentID, - RawEntry: entry.RawLine, - }) + toolUses = append(toolUses, ToolUse{ + Tool: content.Name, + Input: inputMap, + Timestamp: timestamp, + CWD: cwd, + SessionID: entry.SessionID, + ToolUseID: content.ID, + IsSidechain: entry.IsSidechain, + AgentID: entry.AgentID, + RawEntry: entry.RawLine, + }) + } } } @@ -156,6 +195,43 @@ func ExtractToolUses(entries []HistoryEntry) []ToolUse { return expandUserRows(toolUses) } +func isVisibleTranscriptEvent(event *TranscriptEvent) bool { + if event == nil { + return false + } + switch event.Type { + case "file-history-snapshot", "last-prompt", "permission-mode", "agent-name": + return false + default: + return true + } +} + +func messageTool(role MessageRole) string { + switch role { + case MessageRoleUser: + return "User" + case MessageRoleAssistant: + return "Assistant" + default: + return "" + } +} + +func eventInput(event *TranscriptEvent) map[string]any { + input := map[string]any{"event": event.Type} + if event.Scope != "" { + input["scope"] = event.Scope + } + if event.Subtype != "" { + input["subtype"] = event.Subtype + } + for k, v := range event.Data { + input[k] = v + } + return input +} + // toolResult holds matched result data for a tool call. type toolResult struct { content json.RawMessage From 798d548bd5f8608d552f5901d4d7b99ab1d22700 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 8 Jul 2026 20:38:21 +0300 Subject: [PATCH 004/131] feat(session): add rich metadata tracking, turn reconstruction, and pretty-printer Introduce turn-based groupings for session events, costs, usage, and context tracking for both Claude and Codex providers. Parse capabilities, budgets, and events from the transcript stream. Add a terminal-optimized Pretty printer to render structured session details, history file indexes, and interactive tool transcripts. --- pkg/session/build.go | 57 +++- pkg/session/build_codex.go | 38 ++- pkg/session/build_codex_test.go | 57 ++++ pkg/session/build_messages.go | 12 +- pkg/session/build_test.go | 155 +++++++++- pkg/session/message.go | 1 + pkg/session/pretty.go | 512 ++++++++++++++++++++++++++++++++ pkg/session/pretty_test.go | 63 ++++ pkg/session/rows.go | 16 +- pkg/session/session.go | 90 +++++- pkg/session/turns.go | 352 ++++++++++++++++++++++ 11 files changed, 1315 insertions(+), 38 deletions(-) create mode 100644 pkg/session/pretty.go create mode 100644 pkg/session/pretty_test.go create mode 100644 pkg/session/turns.go diff --git a/pkg/session/build.go b/pkg/session/build.go index d39c4a2..d2364b3 100644 --- a/pkg/session/build.go +++ b/pkg/session/build.go @@ -6,6 +6,7 @@ import ( "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/claude/tools" ) // Build discovers the in-scope sessions and returns the unified model for each, @@ -25,16 +26,6 @@ func Build(currentDir string, searchAll bool, filter claude.Filter) ([]*Session, // buildSession assembles one Session from a parsed root+sub-agent group. func buildSession(ps claude.ParsedSession) *Session { - h := buildHierarchy(ps) - - s := &Session{ - ID: ps.SessionID, - Source: "claude", - Root: h.root, - Agents: h.agents, - Messages: h.messages, - } - var allEntries []claude.HistoryEntry var allToolUses []claude.ToolUse costs := api.Costs{} @@ -42,6 +33,22 @@ func buildSession(ps claude.ParsedSession) *Session { allEntries = append(allEntries, t.Entries...) allToolUses = append(allToolUses, t.ToolUses...) } + meta := buildSessionMetadata("claude", allEntries) + h := buildHierarchy(ps, meta.turnByEntry) + + s := &Session{ + ID: ps.SessionID, + Source: "claude", + HistoryFile: h.root.HistoryFile, + Context: latestContext(meta.turns), + Budget: meta.budget, + Capabilities: meta.capabilities, + Events: meta.events, + Turns: meta.turns, + Root: h.root, + Agents: h.agents, + Messages: h.messages, + } applyMetadata(s, ps, allEntries) for _, e := range allEntries { @@ -175,7 +182,7 @@ func approvalStats(uses []claude.ToolUse) ApprovalStats { Tool: tu.Tool, Reason: tu.DeniedReason, }) - case tu.Tool == "ExitPlanMode" || tu.Tool == "User": + case isNonApprovalActivity(tu.Tool): // plan/synthetic rows are not approvals default: stats.Approved++ @@ -183,3 +190,31 @@ func approvalStats(uses []claude.ToolUse) ApprovalStats { } return stats } + +func isNonApprovalActivity(tool string) bool { + if tools.IsEventToolName(tool) { + return true + } + switch tool { + case "ExitPlanMode", "User", "Assistant", "Reasoning", "Event", + "ApiError", "ParseError", "Result", "SessionInit", "HookStart", + "HookResponse", "StopHookSummary", "TurnDuration", "AwaySummary", + "SessionTitle": + return true + default: + return false + } +} + +func isChatActivity(tool string) bool { + switch tool { + case "User", "Assistant", "Reasoning": + return true + default: + return false + } +} + +func isOperationalToolActivity(tool string) bool { + return !isNonApprovalActivity(tool) && tool != "" +} diff --git a/pkg/session/build_codex.go b/pkg/session/build_codex.go index 3828206..607f8b5 100644 --- a/pkg/session/build_codex.go +++ b/pkg/session/build_codex.go @@ -7,6 +7,7 @@ import ( "github.com/flanksource/captain/pkg/ai/history" "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/claude/tools" "github.com/flanksource/commons/logger" "github.com/segmentio/encoding/json" ) @@ -29,7 +30,12 @@ func BuildCodex(files []string) []*Session { continue } info, _ := history.ReadCodexSessionInfo(f) - out = append(out, buildCodexSession(uses, info)) + s := buildCodexSession(uses, info) + s.HistoryFile = f + if s.Root != nil { + s.Root.HistoryFile = f + } + out = append(out, s) } return out } @@ -54,6 +60,10 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * if u.Timestamp != nil { extendRange(s, *u.Timestamp) } + if tools.IsEventToolName(u.Tool) || u.Tool == "ApiError" { + s.Events = append(s.Events, codexUseToEvent(u)) + continue + } collectCodexPaths(u, &read, &written) s.Messages = append(s.Messages, codexUseToMessage(u)) } @@ -72,8 +82,8 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * if s.Model == "" { s.Model = info.Model } - if s.StartedAt == nil && info.StartedAt != nil { - s.StartedAt = info.StartedAt + if info.StartedAt != nil { + extendRange(s, *info.StartedAt) } } @@ -171,6 +181,8 @@ func codexUseToMessage(u history.ToolUse) Message { Timestamp: u.Timestamp, } switch u.Tool { + case "User": + return Message{Role: "user", Parts: []Part{{Type: PartText, Text: codexText(u)}}, Provenance: prov} case "Assistant": return Message{Role: "assistant", Parts: []Part{{Type: PartText, Text: codexText(u)}}, Provenance: prov} case "Reasoning": @@ -193,6 +205,26 @@ func codexUseToMessage(u history.ToolUse) Message { } } +func codexUseToEvent(u history.ToolUse) Event { + typ, _ := u.Input["event"].(string) + if typ == "" { + typ = "event" + } + data := make(map[string]any, len(u.Input)) + for k, v := range u.Input { + if k == "event" { + continue + } + data[k] = v + } + return Event{ + Type: typ, + Scope: "session", + Timestamp: u.Timestamp, + Data: data, + } +} + func codexText(u history.ToolUse) string { if t, ok := u.Input["text"].(string); ok { return t diff --git a/pkg/session/build_codex_test.go b/pkg/session/build_codex_test.go index 755da0a..dd2aae2 100644 --- a/pkg/session/build_codex_test.go +++ b/pkg/session/build_codex_test.go @@ -1,6 +1,9 @@ package session import ( + "os" + "path/filepath" + "strings" "testing" "time" @@ -78,3 +81,57 @@ func TestBuildCodexSession_AttachesLatestInlinePlan(t *testing.T) { t.Fatalf("plan metadata = %+v", s.Plan) } } + +func TestBuildCodexSession_MapsUserMessagesAndEvents(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-08T11:19:57.028Z","type":"session_meta","payload":{"id":"sess-rollout","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}}`, + `{"timestamp":"2026-07-08T11:19:57.028Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}`, + `{"timestamp":"2026-07-08T11:19:58.758Z","type":"turn_context","payload":{"model":"gpt-5.5","effort":"high"}}`, + `{"timestamp":"2026-07-08T11:19:58.760Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}`, + `{"timestamp":"2026-07-08T11:19:58.760Z","type":"event_msg","payload":{"type":"user_message","message":"hi"}}`, + `{"timestamp":"2026-07-08T11:20:00.403Z","type":"event_msg","payload":{"type":"agent_message","message":"hello"}}`, + `{"timestamp":"2026-07-08T11:20:00.403Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}`, + `{"timestamp":"2026-07-08T11:20:00.435Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","duration_ms":3519}}`, + }, "\n") + uses, err := history.ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + if err != nil { + t.Fatalf("ExtractCodexToolUsesFromReader: %v", err) + } + + s := buildCodexSession(uses, &history.CodexSessionInfo{ID: "sess-rollout", CWD: "/repo", Model: "gpt-5.5"}) + + if len(s.Messages) != 2 { + t.Fatalf("messages = %d, want 2: %+v", len(s.Messages), s.Messages) + } + if s.Messages[0].Role != "user" || s.Messages[0].Parts[0].Text != "hi" { + t.Fatalf("first message = %+v, want user hi", s.Messages[0]) + } + if s.Messages[1].Role != "assistant" || s.Messages[1].Parts[0].Text != "hello" { + t.Fatalf("second message = %+v, want assistant hello", s.Messages[1]) + } + if len(s.Events) != 2 || s.Events[0].Type != "task_started" || s.Events[1].Type != "task_complete" { + t.Fatalf("events = %+v", s.Events) + } +} + +func TestBuildCodex_AttachesHistoryFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "rollout-2026-07-08T14-19-56-sess-rollout.jsonl") + stream := strings.Join([]string{ + `{"timestamp":"2026-07-08T11:19:57.028Z","type":"session_meta","payload":{"id":"sess-rollout","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}}`, + `{"timestamp":"2026-07-08T11:19:58.760Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}`, + }, "\n") + if err := os.WriteFile(file, []byte(stream), 0o600); err != nil { + t.Fatalf("write codex fixture: %v", err) + } + + sessions := BuildCodex([]string{file}) + if len(sessions) != 1 { + t.Fatalf("sessions = %d, want 1", len(sessions)) + } + if got := sessions[0].HistoryFile; got != file { + t.Fatalf("history file = %q, want %q", got, file) + } + if sessions[0].Root == nil || sessions[0].Root.HistoryFile != file { + t.Fatalf("root history file = %+v, want %q", sessions[0].Root, file) + } +} diff --git a/pkg/session/build_messages.go b/pkg/session/build_messages.go index dd6979a..226745c 100644 --- a/pkg/session/build_messages.go +++ b/pkg/session/build_messages.go @@ -20,7 +20,7 @@ type hierarchy struct { // points at the spawning tool call in its parent), falling back to the root // session when the parent cannot be resolved — fixing the flat-attribution gap // where grandchild agents were mis-attributed to the root. -func buildHierarchy(ps claude.ParsedSession) hierarchy { +func buildHierarchy(ps claude.ParsedSession, turnByEntry map[string]string) hierarchy { // uuid -> owning agent id ("" == root), so a sub-agent's parentUuid can be // resolved to the agent that produced the referenced entry. uuidOwner := map[string]string{} @@ -44,12 +44,15 @@ func buildHierarchy(ps claude.ParsedSession) hierarchy { for _, t := range ps.Transcripts { var node *Agent if t.IsAgent { - node = &Agent{ID: t.AgentID, Type: t.AgentType, Desc: t.AgentDesc} + node = &Agent{ID: t.AgentID, Type: t.AgentType, Desc: t.AgentDesc, HistoryFile: t.Path} node.ParentID = resolveParent(t, uuidOwner, ps.SessionID) byID[t.AgentID] = node agents = append(agents, node) } else { node = root + if node.HistoryFile == "" { + node.HistoryFile = t.Path + } } costs := api.Costs{} @@ -57,7 +60,7 @@ func buildHierarchy(ps claude.ParsedSession) hierarchy { if e.IsAssistantMessage() && e.Message.Usage != nil { costs = append(costs, CostFromUsage(e.Message.Usage, e.Message.Model)) } - if m, ok := entryToMessage(e, node.ID); ok { + if m, ok := entryToMessage(e, node.ID, turnByEntry[e.UUID]); ok { messages = append(messages, m) } } @@ -103,7 +106,7 @@ func resolveParent(t claude.ParsedTranscript, uuidOwner map[string]string, rootI // entryToMessage projects a transcript entry into a canonical Message. It // returns ok=false for entries with no renderable content (e.g. bare // state-tracking lines). -func entryToMessage(e claude.HistoryEntry, agentID string) (Message, bool) { +func entryToMessage(e claude.HistoryEntry, agentID, turnID string) (Message, bool) { parts := partsFromEntry(e) if len(parts) == 0 { return Message{}, false @@ -114,6 +117,7 @@ func entryToMessage(e claude.HistoryEntry, agentID string) (Message, bool) { Parts: parts, Provenance: provenanceFromEntry(e, agentID), AgentID: agentID, + TurnID: turnID, } if len(e.RawLine) > 0 { m.Raw = e.RawLine diff --git a/pkg/session/build_test.go b/pkg/session/build_test.go index 6da45b2..3005e4d 100644 --- a/pkg/session/build_test.go +++ b/pkg/session/build_test.go @@ -53,7 +53,7 @@ func TestBuildHierarchy_MultiLevelParentLinkage(t *testing.T) { Entries: []claude.HistoryEntry{{UUID: "grand-1", ParentUUID: "child-1", Timestamp: "2026-07-05T10:02:00Z", Message: claude.Message{Role: claude.MessageRoleAssistant}}}, } - h := buildHierarchy(claude.ParsedSession{SessionID: "root-sess", Transcripts: []claude.ParsedTranscript{root, child, grandchild}}) + h := buildHierarchy(claude.ParsedSession{SessionID: "root-sess", Transcripts: []claude.ParsedTranscript{root, child, grandchild}}, nil) byID := map[string]*Agent{} for _, a := range h.agents { @@ -62,6 +62,12 @@ func TestBuildHierarchy_MultiLevelParentLinkage(t *testing.T) { if got := byID["child"].ParentID; got != "root-sess" { t.Errorf("child parent = %q, want root-sess", got) } + if got := byID["root-sess"].HistoryFile; got != "/p/root-sess.jsonl" { + t.Errorf("root history file = %q, want /p/root-sess.jsonl", got) + } + if got := byID["child"].HistoryFile; got != "/p/root-sess/subagents/agent-child.jsonl" { + t.Errorf("child history file = %q, want agent transcript path", got) + } if got := byID["grand"].ParentID; got != "child" { t.Errorf("grandchild parent = %q, want child (flattened to root would be the bug)", got) } @@ -135,6 +141,9 @@ func TestBuildSession_CostFilesPlanApprovals(t *testing.T) { s := buildSession(ps) + if got := s.HistoryFile; got != "/p/root-sess.jsonl" { + t.Errorf("history file = %q, want /p/root-sess.jsonl", got) + } if want := 0.0525; math.Abs(s.Cost.Total()-want) > 1e-9 { t.Errorf("session cost = %v, want %v", s.Cost.Total(), want) } @@ -156,6 +165,150 @@ func TestBuildSession_CostFilesPlanApprovals(t *testing.T) { } } +func TestBuildSession_MetadataTurnsCapabilitiesBudget(t *testing.T) { + entries := []claude.HistoryEntry{ + { + UUID: "tools-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:00Z", + Event: &claude.TranscriptEvent{ + Type: "deferred_tools_delta", + Scope: "session", + Data: map[string]any{ + "addedNames": []any{"Read", "Bash"}, + "pendingMcpServers": []any{"github"}, + }, + }, + }, + { + UUID: "agents-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:01Z", + Event: &claude.TranscriptEvent{ + Type: "agent_listing_delta", + Scope: "session", + Data: map[string]any{"addedTypes": []any{"general-purpose"}}, + }, + }, + { + UUID: "skills-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:02Z", + Event: &claude.TranscriptEvent{ + Type: "skill_listing", + Scope: "session", + Data: map[string]any{"content": "- gavel-runner: Run gavel tests\n- iconography: Pick icons"}, + }, + }, + { + UUID: "queue-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:03Z", + Event: &claude.TranscriptEvent{ + Type: "queue-operation", + Scope: "turn", + Data: map[string]any{"operation": "enqueue"}, + }, + }, + { + UUID: "budget-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:04Z", + Event: &claude.TranscriptEvent{ + Type: "budget_usd", + Scope: "turn", + Data: map[string]any{"used": 1.25, "total": 5.0, "remaining": 3.75}, + }, + }, + { + UUID: "user-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:05Z", + Message: claude.Message{ + Role: claude.MessageRoleUser, + Content: []claude.ContentBlock{{Type: claude.ContentTypeText, Text: "fix it"}}, + }, + }, + { + UUID: "assistant-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:06Z", + Message: claude.Message{ + Role: claude.MessageRoleAssistant, + Model: "claude-opus-4", + StopReason: claude.StopReasonEndTurn, + Usage: &claude.Usage{ + InputTokens: 1000, + OutputTokens: 500, + CacheCreationInputTokens: 200, + CacheReadInputTokens: 300, + }, + Content: []claude.ContentBlock{{Type: claude.ContentTypeText, Text: "done"}}, + }, + }, + { + UUID: "prompt-1", + SessionID: "root-sess", + Timestamp: "2026-07-05T10:00:07Z", + Event: &claude.TranscriptEvent{ + Type: "last-prompt", + Scope: "session", + Data: map[string]any{"content": "fix it"}, + }, + }, + } + + s := buildSession(claude.ParsedSession{ + SessionID: "root-sess", + Transcripts: []claude.ParsedTranscript{{Path: "/p/root-sess.jsonl", Entries: entries}}, + }) + + if !equalStrings(s.Capabilities.Tools, []string{"Bash", "Read"}) { + t.Errorf("tools = %v, want Bash/Read", s.Capabilities.Tools) + } + if !equalStrings(s.Capabilities.PendingMCPServers, []string{"github"}) { + t.Errorf("pending MCP = %v, want github", s.Capabilities.PendingMCPServers) + } + if !equalStrings(s.Capabilities.Agents, []string{"general-purpose"}) { + t.Errorf("agents = %v, want general-purpose", s.Capabilities.Agents) + } + if !equalStrings(s.Capabilities.Skills, []string{"gavel-runner", "iconography"}) { + t.Errorf("skills = %v, want extracted skill names", s.Capabilities.Skills) + } + if s.Budget == nil || s.Budget.Used != 1.25 || s.Budget.Total != 5.0 || s.Budget.Remaining != 3.75 { + t.Fatalf("budget = %+v, want transcript budget", s.Budget) + } + if s.Context == nil || s.Context.UsedTokens != 1500 || s.Context.WindowTokens != claudeContextWindow { + t.Fatalf("context = %+v, want input+cache occupancy", s.Context) + } + if len(s.Events) != 4 { + t.Fatalf("session events = %d, want 4", len(s.Events)) + } + if len(s.Turns) != 1 { + t.Fatalf("turns = %d, want 1: %+v", len(s.Turns), s.Turns) + } + turn := s.Turns[0] + if turn.Budget == nil || turn.Budget.Used != 1.25 { + t.Errorf("turn budget = %+v, want budget_usd", turn.Budget) + } + if len(turn.Events) != 2 || turn.Events[0].Type != "queue-operation" || turn.Events[1].Type != "budget_usd" { + t.Errorf("turn events = %+v, want queue-operation and budget_usd", turn.Events) + } + if turn.Model != "claude-opus-4" || turn.StopReason != string(claude.StopReasonEndTurn) { + t.Errorf("turn model/stop = %q/%q", turn.Model, turn.StopReason) + } + if !equalStrings(turn.MessageIDs, []string{"user-1", "assistant-1"}) { + t.Errorf("turn messages = %v, want user and assistant ids", turn.MessageIDs) + } + for _, msg := range s.Messages { + if msg.ID == "user-1" || msg.ID == "assistant-1" { + if msg.TurnID != turn.ID { + t.Errorf("message %s turnId = %q, want %q", msg.ID, msg.TurnID, turn.ID) + } + } + } +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false diff --git a/pkg/session/message.go b/pkg/session/message.go index 28ade01..a5094dd 100644 --- a/pkg/session/message.go +++ b/pkg/session/message.go @@ -83,6 +83,7 @@ type Message struct { ID string `json:"id,omitempty"` Role string `json:"role"` Parts []Part `json:"parts"` + TurnID string `json:"turnId,omitempty"` Provenance *Provenance `json:"provenance,omitempty"` Raw json.RawMessage `json:"raw,omitempty"` diff --git a/pkg/session/pretty.go b/pkg/session/pretty.go new file mode 100644 index 0000000..8c7f626 --- /dev/null +++ b/pkg/session/pretty.go @@ -0,0 +1,512 @@ +package session + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/flanksource/captain/pkg/claude/tools" + "github.com/flanksource/clicky" + clickyapi "github.com/flanksource/clicky/api" +) + +// Pretty renders the detailed session view used by `captain sessions get`. +// JSON/YAML callers still receive the full Session shape; this is the compact +// human-oriented view for terminals. +func (s *Session) Pretty() clickyapi.Text { + if s == nil { + return clickyapi.Text{Content: "(nil session)"} + } + + title := "Session" + if s.ID != "" { + title += " " + shortPrettyID(s.ID) + } + t := clickyapi.Text{}.Append(title, "font-bold text-blue-600") + if s.Source != "" { + t = t.Append(" ", "").Append(strings.ToUpper(s.Source), "text-gray-500") + } + if s.Model != "" { + t = t.Append(" ", "").Append(s.Model, "text-purple-600") + } + + t = t.NewLine().NewLine().Append("Summary", "font-bold") + for _, row := range sessionSummaryRows(s) { + t = t.NewLine().Add(prettyKV(row.label, row.value)) + } + + if rows := sessionHistoryFileRows(s); len(rows) > 0 { + t = t.NewLine().NewLine().Append("History Files", "font-bold"). + NewLine().Add(historyFilesTable(rows)) + } + + if items := sessionTranscriptItems(s); len(items) > 0 { + t = t.NewLine().NewLine().Append("Transcript", "font-bold"). + NewLine().Add(transcriptList(items)) + } + + return t +} + +type prettyKVRow struct { + label string + value string +} + +func sessionSummaryRows(s *Session) []prettyKVRow { + var rows []prettyKVRow + add := func(label, value string) { + value = strings.TrimSpace(value) + if value != "" { + rows = append(rows, prettyKVRow{label: label, value: value}) + } + } + + add("ID", s.ID) + add("Source", s.Source) + add("Project", s.Project) + add("CWD", s.CWD) + add("History", firstNonEmpty(s.HistoryFile, agentHistoryFile(s.Root))) + add("Model", s.Model) + add("Provider", s.Provider) + add("Version", s.Version) + add("Git", prettyGit(s.Git)) + add("Started", prettyTime(s.StartedAt)) + add("Ended", prettyTime(s.EndedAt)) + add("Duration", prettyDuration(s.StartedAt, s.EndedAt)) + add("Counts", fmt.Sprintf("%d messages, %d events, %d agents, %d tool calls", + len(s.Messages), len(s.Events), len(s.Agents), countSessionToolParts(s.Messages))) + if tokens := s.Usage.TotalTokens(); tokens > 0 { + add("Tokens", fmt.Sprintf("%s total (%s input, %s output)", + FormatTokens(tokens), FormatTokens(s.Usage.InputTokens), FormatTokens(s.Usage.OutputTokens))) + } + if cost := s.Cost.Total(); cost > 0 { + add("Cost", FormatCost(cost)) + } + if s.Plan != nil { + add("Plan", firstNonEmpty(s.Plan.Path, s.Plan.Slug, "inline")) + } + if len(s.Files.Read) > 0 || len(s.Files.Written) > 0 { + add("Files", fmt.Sprintf("%d read, %d written", len(s.Files.Read), len(s.Files.Written))) + } + if s.Approvals.Approved > 0 || s.Approvals.Denied > 0 { + add("Approvals", fmt.Sprintf("%d approved, %d denied", s.Approvals.Approved, s.Approvals.Denied)) + } + return rows +} + +func prettyKV(label, value string) clickyapi.Text { + return clickyapi.Text{}. + Append(" "+label+": ", "text-gray-500"). + Append(value, "text-gray-700") +} + +type historyFileRow struct { + scope string + agent string + file string +} + +func sessionHistoryFileRows(s *Session) []historyFileRow { + if s == nil { + return nil + } + rows := make([]historyFileRow, 0, len(s.Agents)+1) + rootFile := firstNonEmpty(s.HistoryFile, agentHistoryFile(s.Root)) + if rootFile != "" { + rows = append(rows, historyFileRow{scope: "root", agent: shortPrettyID(s.ID), file: rootFile}) + } + for _, a := range s.Agents { + if a == nil || a.IsRoot || a.HistoryFile == "" || a.HistoryFile == rootFile { + continue + } + rows = append(rows, historyFileRow{scope: "agent", agent: prettyAgentName(a), file: a.HistoryFile}) + } + return rows +} + +func historyFilesTable(rows []historyFileRow) clickyapi.TextTable { + table := clickyapi.TextTable{ + Headers: clickyapi.TextList{ + textValue("Scope"), + textValue("Agent"), + textValue("File"), + }, + FieldNames: []string{"scope", "agent", "file"}, + } + for _, row := range rows { + table.Rows = append(table.Rows, clickyapi.TableRow{ + "scope": cellValue(row.scope), + "agent": cellValue(row.agent), + "file": cellValue(row.file), + }) + } + return table +} + +type transcriptRow struct { + order int + ts *time.Time + tool tools.Tool +} + +func sessionTranscriptItems(s *Session) []transcriptRow { + if s == nil { + return nil + } + rows := make([]transcriptRow, 0, len(s.Messages)+len(s.Events)) + agents := sessionAgents(s) + order := 0 + for _, m := range s.Messages { + ts := messageTimestamp(m) + agent := agents[messageAgentID(m)] + for _, p := range m.Parts { + tool := partTool(m, p, agent) + if tool == nil { + continue + } + order++ + rows = append(rows, transcriptRow{ + order: order, + ts: ts, + tool: tool, + }) + } + } + for _, e := range s.Events { + tool := eventTool(e) + if tool == nil { + continue + } + order++ + rows = append(rows, transcriptRow{ + order: order, + ts: e.Timestamp, + tool: tool, + }) + } + sort.SliceStable(rows, func(i, j int) bool { + left, right := rows[i].ts, rows[j].ts + switch { + case left != nil && right != nil && !left.Equal(*right): + return left.Before(*right) + case left != nil && right == nil: + return true + case left == nil && right != nil: + return false + default: + return rows[i].order < rows[j].order + } + }) + return rows +} + +func transcriptList(rows []transcriptRow) clickyapi.List { + list := clicky.List() + list.Unstyled = true + list.MaxInline = 1 + for _, row := range rows { + if row.tool == nil { + continue + } + list.Items = append(list.Items, transcriptListItem{text: row.tool.Pretty()}) + } + return list +} + +type transcriptListItem struct { + text clickyapi.Text +} + +func (i transcriptListItem) String() string { return i.text.String() + "\n" } +func (i transcriptListItem) ANSI() string { return i.text.ANSI() } +func (i transcriptListItem) HTML() string { return i.text.HTML() } +func (i transcriptListItem) Markdown() string { return i.text.Markdown() } + +func partTool(m Message, p Part, agent *Agent) tools.Tool { + switch p.Type { + case PartText: + return newPrettyTool(prettyRole(m.Role), map[string]any{"text": p.Text}, m.Provenance, agent) + case PartReasoning: + return newPrettyTool("Reasoning", map[string]any{"text": p.Text}, m.Provenance, agent) + case PartTool: + name := strings.TrimSpace(p.ToolName) + if name == "" { + return nil + } + return newPrettyTool(name, toolPartInput(p), m.Provenance, agent) + case PartFile: + return newPrettyTool("File", map[string]any{ + "filename": p.Filename, + "url": p.URL, + "mediaType": p.MediaType, + }, m.Provenance, agent) + default: + if strings.HasPrefix(p.Type, "tool-") { + name := strings.TrimPrefix(p.Type, "tool-") + if p.ToolName != "" { + name = p.ToolName + } + return newPrettyTool(name, toolPartInput(p), m.Provenance, agent) + } + name := strings.TrimSpace(p.Type) + if name == "" { + return nil + } + return newPrettyTool(name, map[string]any{"text": p.Text}, m.Provenance, agent) + } +} + +func prettyRole(role string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "user": + return "User" + case "assistant": + return "Assistant" + case "system": + return "System" + default: + if role == "" { + return "Message" + } + return strings.ToUpper(role[:1]) + role[1:] + } +} + +func newPrettyTool(rawTool string, input map[string]any, prov *Provenance, agent *Agent) tools.Tool { + base := tools.BaseTool{ + RawTool: rawTool, + Input: cleanToolInput(input), + } + if prov != nil { + base.Timestamp = prov.Timestamp + base.CWD = prov.CWD + base.ProjectRoot = prov.CWD + base.SessionID = prov.SessionID + base.Source = prov.Source + base.ReasoningEffort = prov.ReasoningEffort + } + if agent != nil && !agent.IsRoot { + base.IsSidechain = true + base.AgentID = agent.ID + base.AgentType = agent.Type + base.AgentDesc = agent.Desc + } + return tools.NewTool(base) +} + +func eventTool(e Event) tools.Tool { + input := make(map[string]any, len(e.Data)+3) + for k, v := range e.Data { + input[k] = v + } + input["event"] = firstNonEmpty(e.Type, "event") + if e.Scope != "" { + input["scope"] = e.Scope + } + if e.TurnID != "" { + input["turn_id"] = e.TurnID + } + return tools.NewTool(tools.BaseTool{ + RawTool: tools.EventToolName(e.Type), + Input: cleanToolInput(input), + Timestamp: e.Timestamp, + }) +} + +func toolPartInput(p Part) map[string]any { + if m := rawJSONMap(p.Input); len(m) > 0 { + return m + } + if len(p.Output) > 0 { + return map[string]any{"output": rawJSONValue(p.Output)} + } + if p.State != "" { + return map[string]any{"state": p.State} + } + if p.ToolCallID != "" { + return map[string]any{"tool_call_id": p.ToolCallID} + } + return nil +} + +func rawJSONMap(raw []byte) map[string]any { + if len(raw) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err == nil { + return m + } + return map[string]any{"value": compactWhitespace(string(raw))} +} + +func rawJSONValue(raw []byte) any { + if len(raw) == 0 { + return nil + } + var value any + if err := json.Unmarshal(raw, &value); err == nil { + return value + } + return compactWhitespace(string(raw)) +} + +func cleanToolInput(input map[string]any) map[string]any { + clean := make(map[string]any, len(input)) + for k, v := range input { + switch value := v.(type) { + case string: + if value == "" { + continue + } + case nil: + continue + } + clean[k] = v + } + return clean +} + +func sessionAgents(s *Session) map[string]*Agent { + agents := map[string]*Agent{"": nil} + if s == nil { + return agents + } + if s.Root != nil { + agents[s.Root.ID] = s.Root + } + for _, a := range s.Agents { + if a == nil { + continue + } + agents[a.ID] = a + } + return agents +} + +func messageTimestamp(m Message) *time.Time { + if m.Provenance == nil { + return nil + } + return m.Provenance.Timestamp +} + +func messageAgentID(m Message) string { + if m.AgentID != "" { + return m.AgentID + } + if m.Provenance != nil { + return m.Provenance.AgentID + } + return "" +} + +func prettyAgentName(a *Agent) string { + if a == nil { + return "" + } + if a.Desc != "" { + return truncatePretty(a.Desc, 48) + } + if a.Type != "" { + return a.Type + } + if a.ID != "" { + return shortPrettyID(a.ID) + } + return "agent" +} + +func agentHistoryFile(a *Agent) string { + if a == nil { + return "" + } + return a.HistoryFile +} + +func countSessionToolParts(messages []Message) int { + total := 0 + for _, m := range messages { + for _, p := range m.Parts { + if p.ToolName != "" && (p.Type == PartTool || strings.HasPrefix(p.Type, "tool-")) { + total++ + } + } + } + return total +} + +func prettyGit(g GitState) string { + parts := make([]string, 0, 3) + if g.Branch != "" { + parts = append(parts, g.Branch) + } + if g.Commit != "" { + parts = append(parts, shortPrettyID(g.Commit)) + } + if g.Worktree != "" { + parts = append(parts, g.Worktree) + } + return strings.Join(parts, " ") +} + +func prettyTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func prettyDuration(start, end *time.Time) string { + if start == nil || end == nil || start.IsZero() || end.IsZero() { + return "" + } + d := end.Sub(*start) + if d < 0 { + return "" + } + return d.Round(time.Second).String() +} + +func shortPrettyID(id string) string { + id = strings.TrimSpace(id) + if len(id) <= 12 { + return id + } + return id[:12] +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func truncatePretty(s string, max int) string { + s = compactWhitespace(s) + if max <= 0 || len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + +func compactWhitespace(s string) string { + return strings.Join(strings.Fields(strings.TrimSpace(s)), " ") +} + +func textValue(s string) clickyapi.Textable { + return clickyapi.Text{Content: s} +} + +func cellValue(s string) clickyapi.TypedValue { + return clickyapi.TypedValue{Textable: clickyapi.Text{Content: s}} +} diff --git a/pkg/session/pretty_test.go b/pkg/session/pretty_test.go new file mode 100644 index 0000000..a51912f --- /dev/null +++ b/pkg/session/pretty_test.go @@ -0,0 +1,63 @@ +package session + +import ( + "strings" + "testing" + "time" +) + +func TestSessionPretty_RendersSummaryHistoryFilesAndTranscript(t *testing.T) { + ts := time.Date(2026, 7, 8, 11, 19, 57, 0, time.UTC) + end := ts.Add(3 * time.Second) + s := &Session{ + ID: "sess-rollout", + Source: "codex", + CWD: "/repo", + Model: "gpt-5", + HistoryFile: "/tmp/root.jsonl", + StartedAt: &ts, + EndedAt: &end, + Root: &Agent{ID: "sess-rollout", IsRoot: true, HistoryFile: "/tmp/root.jsonl"}, + Agents: []*Agent{ + {ID: "sess-rollout", IsRoot: true, HistoryFile: "/tmp/root.jsonl"}, + {ID: "agent-1", Type: "explorer", Desc: "inspect sessions", HistoryFile: "/tmp/agent.jsonl"}, + }, + Messages: []Message{ + { + Role: "user", + Parts: []Part{{Type: PartText, Text: "show history"}}, + Provenance: &Provenance{ + Timestamp: &ts, + }, + }, + { + Role: "assistant", + Parts: []Part{{Type: PartText, Text: "done"}}, + Provenance: &Provenance{ + Timestamp: &end, + AgentID: "agent-1", + }, + }, + }, + Events: []Event{{Type: "task_started", Scope: "session", Timestamp: &ts}}, + } + + got := s.Pretty().String() + for _, want := range []string{ + "Summary", + "History Files", + "Transcript", + "/tmp/root.jsonl", + "/tmp/agent.jsonl", + "show history", + "task started", + "inspect sessions", + } { + if !strings.Contains(got, want) { + t.Fatalf("Pretty() missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "│Time") || strings.Contains(got, "│Type") { + t.Fatalf("Pretty() rendered transcript as a table:\n%s", got) + } +} diff --git a/pkg/session/rows.go b/pkg/session/rows.go index bf3b9b3..8e3401b 100644 --- a/pkg/session/rows.go +++ b/pkg/session/rows.go @@ -133,7 +133,7 @@ func rowFromTranscript(sessionID string, t claude.ParsedTranscript, uuidOwner ma r.Usage = usageFromCost(r.Cost) r.Files = changedFiles(t.ToolUses) r.Approvals = approvalStats(t.ToolUses) - r.ToolCalls = len(t.ToolUses) + r.ToolCalls = countOperationalToolUses(t.ToolUses) r.Messages = countEntryMessages(t.Entries) if !t.IsAgent { r.Plan = buildPlan(t.Entries, t.ToolUses) @@ -172,15 +172,25 @@ func CodexRow(path string) (Row, bool) { r.ReasoningEffort = info.ReasoningEffort } for _, u := range uses { - if u.Tool == "Assistant" || u.Tool == "Reasoning" { + if isChatActivity(u.Tool) { r.Messages++ - } else { + } else if isOperationalToolActivity(u.Tool) { r.ToolCalls++ } } return r, true } +func countOperationalToolUses(uses []claude.ToolUse) int { + n := 0 + for _, use := range uses { + if isOperationalToolActivity(use.Tool) { + n++ + } + } + return n +} + func extendRowRange(r *Row, ts time.Time) { if ts.IsZero() { return diff --git a/pkg/session/session.go b/pkg/session/session.go index 92609b2..261cb43 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -11,14 +11,15 @@ import ( // history/sessions commands render, the viewer consumes, and the chat/live // surfaces project from. type Session struct { - ID string `json:"id"` - Source string `json:"source,omitempty"` // "claude" | "codex" - Project string `json:"project,omitempty"` - CWD string `json:"cwd,omitempty"` - Slug string `json:"slug,omitempty"` - Version string `json:"version,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` // primary model + ID string `json:"id"` + Source string `json:"source,omitempty"` // "claude" | "codex" + Project string `json:"project,omitempty"` + CWD string `json:"cwd,omitempty"` + Slug string `json:"slug,omitempty"` + Version string `json:"version,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` // primary model + HistoryFile string `json:"historyFile,omitempty"` Git GitState `json:"git,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"` @@ -27,6 +28,12 @@ type Session struct { Usage api.Usage `json:"usage,omitempty"` Cost api.Cost `json:"cost,omitempty"` ToolCosts api.Costs `json:"toolCosts,omitempty"` // per-model breakdown + Context *Context `json:"context,omitempty"` + Budget *Budget `json:"budget,omitempty"` + + Capabilities Capabilities `json:"capabilities,omitempty"` + Events []Event `json:"events,omitempty"` + Turns []Turn `json:"turns,omitempty"` Root *Agent `json:"root,omitempty"` // agent hierarchy tree Agents []*Agent `json:"agents,omitempty"` // flat index (root first) @@ -55,14 +62,65 @@ type GitState struct { // Agent is one node in the session's agent hierarchy. The root node represents // the top-level session; children are sub-agents (Task/Agent spawns). type Agent struct { - ID string `json:"id,omitempty"` - ParentID string `json:"parentId,omitempty"` - Type string `json:"type,omitempty"` // agentType (from meta.json) - Desc string `json:"desc,omitempty"` // task description - IsRoot bool `json:"isRoot,omitempty"` - Children []*Agent `json:"children,omitempty"` - Usage api.Usage `json:"usage,omitempty"` - Cost api.Cost `json:"cost,omitempty"` + ID string `json:"id,omitempty"` + ParentID string `json:"parentId,omitempty"` + Type string `json:"type,omitempty"` // agentType (from meta.json) + Desc string `json:"desc,omitempty"` // task description + IsRoot bool `json:"isRoot,omitempty"` + HistoryFile string `json:"historyFile,omitempty"` + Children []*Agent `json:"children,omitempty"` + Usage api.Usage `json:"usage,omitempty"` + Cost api.Cost `json:"cost,omitempty"` +} + +// Context is the context-window occupancy for a session or turn. +type Context struct { + UsedTokens int `json:"usedTokens,omitempty"` + WindowTokens int `json:"windowTokens,omitempty"` + FreePercent int `json:"freePercent"` +} + +// Budget is the latest budget state observed in the transcript. +type Budget struct { + Used float64 `json:"used,omitempty"` + Total float64 `json:"total,omitempty"` + Remaining float64 `json:"remaining,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// Capabilities is the session-level discovery state surfaced by Claude Code. +type Capabilities struct { + Tools []string `json:"tools,omitempty"` + PendingMCPServers []string `json:"pendingMcpServers,omitempty"` + Agents []string `json:"agents,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +// Event is a non-message transcript event captured at session or turn scope. +type Event struct { + Type string `json:"type"` + Scope string `json:"scope,omitempty"` + TurnID string `json:"turnId,omitempty"` + Timestamp *time.Time `json:"timestamp,omitempty"` + UUID string `json:"uuid,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +// Turn groups user/assistant messages, tool calls, usage, cost, and contextual +// state for one model turn. +type Turn struct { + ID string `json:"id"` + Index int `json:"index"` + StartedAt *time.Time `json:"startedAt,omitempty"` + EndedAt *time.Time `json:"endedAt,omitempty"` + StopReason string `json:"stopReason,omitempty"` + Model string `json:"model,omitempty"` + MessageIDs []string `json:"messageIds,omitempty"` + Usage api.Usage `json:"usage,omitempty"` + Cost api.Cost `json:"cost,omitempty"` + Context *Context `json:"context,omitempty"` + Budget *Budget `json:"budget,omitempty"` + Events []Event `json:"events,omitempty"` } // ChangedFiles is the read/write file set aggregated across a session, diff --git a/pkg/session/turns.go b/pkg/session/turns.go new file mode 100644 index 0000000..6ae7e7d --- /dev/null +++ b/pkg/session/turns.go @@ -0,0 +1,352 @@ +package session + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/flanksource/captain/pkg/claude" +) + +const ( + claudeContextWindow = 1_000_000 + codexContextWindow = 200_000 +) + +type sessionMetadataBuild struct { + events []Event + capabilities Capabilities + budget *Budget + turns []Turn + turnByEntry map[string]string +} + +func buildSessionMetadata(source string, entries []claude.HistoryEntry) sessionMetadataBuild { + b := sessionMetadataBuild{turnByEntry: map[string]string{}} + var current *Turn + var turnSeq int + + startTurn := func(ts *time.Time) *Turn { + if current != nil { + if current.StartedAt == nil && ts != nil { + current.StartedAt = cloneTime(ts) + } + return current + } + turnSeq++ + current = &Turn{ + ID: fmt.Sprintf("turn-%d", turnSeq), + Index: turnSeq, + } + if ts != nil { + current.StartedAt = cloneTime(ts) + } + return current + } + finishTurn := func(ts *time.Time) { + if current == nil { + return + } + if ts != nil { + current.EndedAt = cloneTime(ts) + } + if current.Context == nil { + setTurnContext(current, source) + } + b.turns = append(b.turns, *current) + current = nil + } + + for _, entry := range entries { + ts := entryTime(entry) + if entry.Event != nil { + ev := eventFromEntry(entry) + switch entry.Event.Scope { + case "turn": + turn := startTurn(ts) + ev.TurnID = turn.ID + turn.Events = append(turn.Events, ev) + if entry.Event.Type == "budget_usd" { + budget := budgetFromData(entry.Event.Data, ts) + if budget != nil { + turn.Budget = budget + b.budget = budget + } + } + default: + b.events = append(b.events, ev) + mergeCapabilities(&b.capabilities, entry.Event) + if entry.Event.Type == "budget_usd" { + budget := budgetFromData(entry.Event.Data, ts) + if budget != nil { + b.budget = budget + } + } + } + continue + } + + if entry.Message.Role == "" && len(entry.Message.Content) == 0 { + continue + } + turn := startTurn(ts) + if entry.UUID != "" { + b.turnByEntry[entry.UUID] = turn.ID + turn.MessageIDs = appendUnique(turn.MessageIDs, entry.UUID) + } + if entry.Message.Model != "" { + turn.Model = entry.Message.Model + } + if entry.IsAssistantMessage() && entry.Message.Usage != nil { + cost := CostFromUsage(entry.Message.Usage, entry.Message.Model) + turn.Cost = turn.Cost.Add(cost) + turn.Usage = usageFromCost(turn.Cost) + turn.Context = contextFromUsage(entry.Message.Usage, source) + } + if entry.Message.StopReason != "" && entry.Message.StopReason != claude.StopReasonToolUse { + turn.StopReason = string(entry.Message.StopReason) + finishTurn(ts) + } + } + finishTurn(nil) + + b.capabilities.Tools = sortedStrings(b.capabilities.Tools) + b.capabilities.PendingMCPServers = sortedStrings(b.capabilities.PendingMCPServers) + b.capabilities.Agents = sortedStrings(b.capabilities.Agents) + b.capabilities.Skills = sortedStrings(b.capabilities.Skills) + return b +} + +func eventFromEntry(entry claude.HistoryEntry) Event { + ev := Event{ + Type: entry.Event.Type, + Scope: entry.Event.Scope, + UUID: entry.UUID, + Data: entry.Event.Data, + } + if ts := entryTime(entry); ts != nil { + ev.Timestamp = cloneTime(ts) + } + return ev +} + +func latestContext(turns []Turn) *Context { + for i := len(turns) - 1; i >= 0; i-- { + if turns[i].Context != nil { + return turns[i].Context + } + } + return nil +} + +func entryTime(entry claude.HistoryEntry) *time.Time { + if ts, err := entry.ParseTimestamp(); err == nil && !ts.IsZero() { + return &ts + } + return nil +} + +func cloneTime(ts *time.Time) *time.Time { + if ts == nil { + return nil + } + v := *ts + return &v +} + +func contextFromUsage(usage *claude.Usage, source string) *Context { + if usage == nil { + return nil + } + used := usage.InputTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens + window := contextWindow(source) + return &Context{ + UsedTokens: used, + WindowTokens: window, + FreePercent: freeContextPercent(used, window), + } +} + +func setTurnContext(turn *Turn, source string) { + if turn == nil || turn.Context != nil { + return + } + if turn.Usage.TotalTokens() == 0 { + return + } + used := turn.Usage.InputTokens + turn.Usage.CacheReadTokens + turn.Usage.CacheWriteTokens + window := contextWindow(source) + turn.Context = &Context{ + UsedTokens: used, + WindowTokens: window, + FreePercent: freeContextPercent(used, window), + } +} + +func budgetFromData(data map[string]any, ts *time.Time) *Budget { + if len(data) == 0 { + return nil + } + b := &Budget{ + Used: floatFromAny(data["used"]), + Total: floatFromAny(data["total"]), + Remaining: floatFromAny(data["remaining"]), + } + if ts != nil { + b.UpdatedAt = cloneTime(ts) + } + if b.Used == 0 && b.Total == 0 && b.Remaining == 0 { + return nil + } + return b +} + +func mergeCapabilities(c *Capabilities, event *claude.TranscriptEvent) { + if event == nil { + return + } + switch event.Type { + case "deferred_tools_delta": + c.Tools = append(c.Tools, stringsFromAny(event.Data["addedNames"])...) + c.PendingMCPServers = append(c.PendingMCPServers, stringsFromAny(event.Data["pendingMcpServers"])...) + case "agent_listing_delta": + c.Agents = append(c.Agents, stringsFromAny(event.Data["addedTypes"])...) + case "skill_listing": + c.Skills = append(c.Skills, stringsFromAny(event.Data["names"])...) + if len(c.Skills) == 0 { + c.Skills = append(c.Skills, skillNamesFromContent(stringFromAny(event.Data["content"]))...) + } + } +} + +func stringsFromAny(value any) []string { + switch v := value.(type) { + case []string: + return append([]string(nil), v...) + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s := stringFromAny(item); s != "" { + out = append(out, s) + } + } + return out + case string: + if v == "" { + return nil + } + return []string{v} + default: + return nil + } +} + +func skillNamesFromContent(content string) []string { + var out []string + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "- ") { + continue + } + name := strings.TrimSpace(strings.TrimPrefix(line, "- ")) + if i := strings.Index(name, ":"); i >= 0 { + name = strings.TrimSpace(name[:i]) + } + if name != "" { + out = append(out, name) + } + } + return out +} + +func stringFromAny(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + default: + return "" + } +} + +func floatFromAny(value any) float64 { + switch v := value.(type) { + case float64: + return v + case float32: + return float64(v) + case int: + return float64(v) + case int64: + return float64(v) + case jsonNumber: + f, _ := v.Float64() + return f + default: + return 0 + } +} + +type jsonNumber interface { + Float64() (float64, error) +} + +func appendUnique(in []string, value string) []string { + if value == "" { + return in + } + for _, existing := range in { + if existing == value { + return in + } + } + return append(in, value) +} + +func sortedStrings(in []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, value := range in { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} + +// ContextWindow returns the default context window used for list/detail +// occupancy calculations for a source. +func ContextWindow(source string) int { + return contextWindow(source) +} + +func contextWindow(source string) int { + if source == "codex" { + return codexContextWindow + } + return claudeContextWindow +} + +func freeContextPercent(used, window int) int { + if window <= 0 { + return 0 + } + if used < 0 { + used = 0 + } + free := 100 - int(float64(used)/float64(window)*100) + if free < 0 { + return 0 + } + if free > 100 { + return 100 + } + return free +} From 5db3db3dd7d81f89df5cb501b91215c4cafb74f2 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 8 Jul 2026 20:38:34 +0300 Subject: [PATCH 005/131] feat(cli): support parallel multi-model prompt runs and codex-agent backend Adds a `--multi-models` / `-M` option to execute prompts in parallel across multiple model/backend variants, displaying consolidated token usage, cost, and execution status in a tabular format. Integrates the `codex-agent` backend and aligns configure/whoami pickers to use exact provider model IDs resolved from Captain's cached model registry instead of prefix-based catalog entries. Updates history tracking to record the path to history files, categorizes interactive tools as 'chat', and permits excluding them via filter selectors. --- pkg/cli/ai.go | 42 ++- pkg/cli/ai_catalog.go | 7 +- pkg/cli/ai_catalog_test.go | 37 ++- pkg/cli/ai_models.go | 2 +- pkg/cli/ai_models_test.go | 26 +- pkg/cli/ai_prompt_file.go | 11 +- pkg/cli/configure.go | 13 +- pkg/cli/configure_test.go | 13 +- pkg/cli/history.go | 49 +++- pkg/cli/prompt_entity.go | 10 + pkg/cli/prompt_run.go | 360 ++++++++++++++++++++++++- pkg/cli/prompt_run_test.go | 243 +++++++++++++++++ pkg/cli/prompt_schema.go | 11 +- pkg/cli/prompt_schema_build.go | 35 --- pkg/cli/prompt_schema_test.go | 43 +-- pkg/cli/prompt_source.go | 1 + pkg/cli/prompt_source_test.go | 4 + pkg/cli/serve.go | 8 +- pkg/cli/stdin_test.go | 43 ++- pkg/cli/summary.go | 11 +- pkg/cli/webapp/src/ChatLayer.tsx | 2 +- pkg/cli/webapp/src/PromptWorkbench.tsx | 10 +- pkg/cli/webapp/src/SessionBrowser.tsx | 8 +- pkg/cli/webapp/src/session.ts | 11 +- pkg/cli/webapp/src/sessionData.ts | 43 +++ pkg/cli/whoami.go | 138 ++++++++-- pkg/cli/whoami_render.go | 2 +- pkg/cli/whoami_test.go | 104 ++++++- 28 files changed, 1122 insertions(+), 165 deletions(-) create mode 100644 pkg/cli/prompt_run_test.go diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 123fb4f..863222e 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -36,7 +36,7 @@ func loadSavedAI() captainconfig.AIDefaults { type AIProviderOptions struct { Model string `flag:"model" help:"Model name(s), e.g. claude-sonnet-5 or a comma-separated primary,fallback list like claude-sonnet-5,gpt-4o (defaults to the value saved by 'captain configure')" short:"m"` Fallback []string `flag:"fallback" help:"Model to try if the primary is unavailable (repeatable; comma-separated allowed)"` - Backend string `flag:"backend" help:"Force backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-cmux|gemini-cli (default: inferred from model or saved by 'captain configure')" short:"b"` + Backend string `flag:"backend" help:"Force backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|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, OPENAI_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY, DEEPSEEK_API_KEY)"` NoCache bool `flag:"no-cache" help:"Disable response caching"` Budget string `flag:"budget" help:"Max spend in USD, 0=unlimited" default:"0"` @@ -70,6 +70,9 @@ func (o AIProviderOptions) ToConfig() (ai.Config, error) { if backend == "" { backend = saved.Backend } + if o.Backend == "" && ai.ContainsRuntimeSelector(model) { + backend = "" + } if backend != "" && !ai.Backend(backend).Valid() { return ai.Config{}, fmt.Errorf("invalid --backend %q (valid: %s)", backend, ai.BackendList()) } @@ -78,8 +81,12 @@ func (o AIProviderOptions) ToConfig() (ai.Config, error) { } m := api.Model{Name: model, Backend: ai.Backend(backend), Fallbacks: fallbackModelsFromFlags(o.Fallback)} + m, err = ai.ResolveModelSelectors(m) + if err != nil { + return ai.Config{}, err + } return ai.Config{ - Model: m.ExpandCSV(), + Model: m, Budget: api.Budget{Cost: budget}, APIKey: o.APIKey, NoCache: o.NoCache || saved.NoCache, @@ -144,6 +151,7 @@ type AIPromptOptions struct { 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"` + MultiModels []string `flag:"multi-models" help:"Run prompt once per runtime selector in parallel, e.g. cli:sonnet-5,cmux:opus (repeatable; comma-separated allowed)" short:"M"` Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"` } @@ -154,6 +162,7 @@ type AIPromptResult struct { Backend string `json:"backend" pretty:"label=Backend"` Dir string `json:"dir,omitempty" pretty:"label=Dir"` SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` Input ai.Request `json:"input" pretty:"-"` InputTokens int `json:"inputTokens" pretty:"label=Input Tokens"` Output int `json:"outputTokens" pretty:"label=Output Tokens"` @@ -279,6 +288,16 @@ func RunAIPrompt(opts AIPromptOptions) (any, error) { if err := req.Validate(); err != nil { return nil, err } + if len(opts.MultiModels) > 0 { + rendered := PromptRenderResult{ + Name: "prompt", + Model: cfg.Model.Name, + Backend: string(cfg.Model.Backend), + Input: req, + Config: cfg, + } + return executeSyncRun(ctx, rendered, opts) + } return executePromptRequest(ctx, req, cfg, runtimeTimeout(req.Budget.Timeout), opts.NoStream) } @@ -430,12 +449,14 @@ func runBuffered(ctx context.Context, p ai.Provider, req ai.Request) (any, error 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) + dir := actualRunDir(input) return AIPromptResult{ Text: resp.Text, Model: model, Backend: backend, - Dir: input.Cwd(), + Dir: dir, SessionID: input.SessionID, + HistoryFile: historyFileForRun(api.Backend(backend), input.SessionID, dir), Input: input, InputTokens: resp.Usage.InputTokens, Output: resp.Usage.OutputTokens, @@ -495,12 +516,14 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) session = loop.Iterations[0].SessionID } input := resolvedPromptInput(req, model, backend, session) + dir := actualRunDir(input) return AIPromptResult{ Text: text, Model: model, Backend: backend, - Dir: input.Cwd(), + Dir: dir, SessionID: input.SessionID, + HistoryFile: historyFileForRun(api.Backend(backend), input.SessionID, dir), Input: input, InputTokens: usage.InputTokens, Output: usage.OutputTokens, @@ -523,6 +546,17 @@ func resolvedPromptInput(req ai.Request, model, backend, sessionID string) ai.Re return out } +func actualRunDir(req ai.Request) string { + if cwd := req.Cwd(); cwd != "" { + return cwd + } + wd, err := os.Getwd() + if err != nil { + return "" + } + return wd +} + // 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_catalog.go b/pkg/cli/ai_catalog.go index 0fdcf93..467d058 100644 --- a/pkg/cli/ai_catalog.go +++ b/pkg/cli/ai_catalog.go @@ -8,11 +8,8 @@ import "github.com/flanksource/captain/pkg/ai" // (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. +// The returned ID is the exact provider model ID Captain sends at runtime. +// CLI/cmux modes share the corresponding agent catalog entries. func agentCatalogModels(b ai.Backend) []ai.ModelDef { return ai.AgentCatalogModels(b) } diff --git a/pkg/cli/ai_catalog_test.go b/pkg/cli/ai_catalog_test.go index 89fa766..21b931a 100644 --- a/pkg/cli/ai_catalog_test.go +++ b/pkg/cli/ai_catalog_test.go @@ -17,9 +17,9 @@ func installTestCatalog(t *testing.T) { if err := ai.SetModelCatalog([]ai.Model{ {ID: "anthropic/claude-sonnet-4-5", Backend: ai.BackendAnthropic, Label: "Claude Sonnet 4.5"}, - {ID: "claude-agent-opus", Backend: ai.BackendClaudeAgent, Label: "Claude Agent · Opus"}, - {ID: "claude-agent-sonnet", Backend: ai.BackendClaudeAgent, Label: "Claude Agent · Sonnet"}, - {ID: "codex-gpt-5-codex", Backend: ai.BackendCodexCLI, AgentModel: "gpt-5-codex", Label: "Codex · GPT-5"}, + {ID: "claude-opus-4-8", Backend: ai.BackendClaudeAgent, Label: "Claude Agent · Opus 4.8"}, + {ID: "claude-sonnet-5", Backend: ai.BackendClaudeAgent, Label: "Claude Agent · Sonnet 5"}, + {ID: "gpt-5.5", Backend: ai.BackendCodexAgent, Label: "Codex Agent · GPT-5.5"}, }); err != nil { t.Fatalf("SetModelCatalog: %v", err) } @@ -33,18 +33,19 @@ func TestAgentCatalogModels(t *testing.T) { 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}}, + want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexCLI}}, + }, + { + backend: ai.BackendCodexAgent, + want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexAgent}}, }, { // 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}, + {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeAgent}, + {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeAgent}, }, }, { @@ -52,10 +53,24 @@ func TestAgentCatalogModels(t *testing.T) { // 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}, + {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeCLI}, + {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeCLI}, }, }, + { + // claude-cmux uses the same local Claude catalog and is re-tagged with + // its own backend. + backend: ai.BackendClaudeCmux, + want: []ai.ModelDef{ + {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeCmux}, + {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeCmux}, + }, + }, + { + // codex-cmux shares the codex-agent runtime model slug. + backend: ai.BackendCodexCmux, + want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexCmux}}, + }, { // No catalog entries for gemini-cli: empty, never an error. backend: ai.BackendGeminiCLI, diff --git a/pkg/cli/ai_models.go b/pkg/cli/ai_models.go index 57e479d..2b2a3d6 100644 --- a/pkg/cli/ai_models.go +++ b/pkg/cli/ai_models.go @@ -13,7 +13,7 @@ import ( type AIModelsOptions struct { Filter string `flag:"filter" help:"Filter models by name substring" short:"f"` - Backend string `flag:"backend" help:"Filter by backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-cmux|gemini-cli" short:"b"` + Backend string `flag:"backend" help:"Filter by backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|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_models_test.go b/pkg/cli/ai_models_test.go index ac59c23..a930cc5 100644 --- a/pkg/cli/ai_models_test.go +++ b/pkg/cli/ai_models_test.go @@ -132,13 +132,29 @@ func TestIsLegacyModelID(t *testing.T) { "gpt-5-nano": true, "gpt-5-codex": true, "gpt-5-pro": true, + "gpt-5.5-pro": true, + "gpt-5.5-nano": true, + "gpt-5.5-mini": true, + "gpt-5.4-mini": true, + "gpt-5.5-chat-latest": true, + "gpt-5.5-2026-04-23": true, + "gpt-realtime-2.1": true, + "gpt-realtime-2.1-mini": true, + "gpt-image-2": true, + "gpt-audio-1.5": true, + "gpt-5.3-codex": true, + "gpt-5.3-code": true, "o1": true, "o1-pro": true, "o3": true, "o3-pro": true, "o3-mini": true, + "o4-mini": true, + "sora-2": true, "codex-mini-latest": true, "dall-e-3": true, + "image-alpha-001": true, + "audio-alpha-001": true, "dall-e-2": true, "whisper-1": true, "tts-1": true, @@ -174,7 +190,8 @@ func TestIsLegacyModelID(t *testing.T) { "gpt-5.1": false, "gpt-5.2": false, "gpt-5.3": false, - "o4-mini": false, + "gpt-5.4": false, + "gpt-5.5": false, "claude-sonnet-4-5": false, "claude-sonnet-4-6": false, "claude-opus-4-5": false, @@ -205,6 +222,11 @@ func TestRunAIModels_HidesLegacyByDefault(t *testing.T) { {"id": "gpt-5.1"}, {"id": "gpt-5-mini"}, {"id": "gpt-5-codex"}, + {"id": "gpt-5.5-pro"}, + {"id": "gpt-realtime-2.1"}, + {"id": "gpt-image-2"}, + {"id": "gpt-audio-1.5"}, + {"id": "sora-2"}, {"id": "gpt-3.5-turbo"}, {"id": "gpt-4"}, {"id": "gpt-4o-mini"}, @@ -227,7 +249,7 @@ func TestRunAIModels_HidesLegacyByDefault(t *testing.T) { } res := got.(AIModelsResult) - want := []string{"gpt-5", "gpt-5.1", "o4-mini"} + want := []string{"gpt-5", "gpt-5.1"} if len(res.Rows) != len(want) { t.Fatalf("rows = %d, want %d (%v)", len(res.Rows), len(want), res.Rows) } diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index c67e4d9..da1e2a7 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -77,7 +77,11 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque } m := bm m.Name = firstNonEmpty(o.Model, bm.Name, saved.Model) - m.Backend = api.Backend(firstNonEmpty(o.Backend, string(bm.Backend), saved.Backend)) + backend := firstNonEmpty(o.Backend, string(bm.Backend), saved.Backend) + if o.Backend == "" && ai.ContainsRuntimeSelector(m.Name) { + backend = "" + } + m.Backend = api.Backend(backend) if temperature != 0 { t := temperature m.Temperature = &t @@ -85,7 +89,10 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque m.Effort = api.Effort(firstNonEmpty(o.Effort, string(bm.Effort), saved.ReasoningEffort)) m.NoCache = o.NoCache || bm.NoCache || saved.NoCache m.Fallbacks = firstFallbacks(o.Fallback, bm.Fallbacks) - req.Model = m.ExpandCSV() + req.Model, err = ai.ResolveModelSelectors(m) + if err != nil { + return base, baseCfg, err + } req.Budget.MaxTokens = firstPositive(o.MaxTokens, base.Budget.MaxTokens, baseCfg.Budget.MaxTokens, saved.MaxTokens, 4096) req.Budget.Cost = firstPositiveFloat(budget, base.Budget.Cost, baseCfg.Budget.Cost, saved.BudgetUSD) diff --git a/pkg/cli/configure.go b/pkg/cli/configure.go index 087a1b0..dbbe80f 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("Honoured by codex-cli and the API backends (thinking budget); CLI wrappers may ignore."). + Description("Honoured by codex-agent and the API backends (thinking budget); CLI wrappers may ignore."). Options( huh.NewOption("low", "low"), huh.NewOption("medium", "medium"), @@ -211,6 +211,7 @@ func backendOptions() []huh.Option[string] { huh.NewOption("Claude Agent (SDK)", string(ai.BackendClaudeAgent)), huh.NewOption("Claude cmux", string(ai.BackendClaudeCmux)), huh.NewOption("Codex CLI", string(ai.BackendCodexCLI)), + huh.NewOption("Codex Agent (app-server)", string(ai.BackendCodexAgent)), huh.NewOption("Codex cmux", string(ai.BackendCodexCmux)), huh.NewOption("Gemini CLI", string(ai.BackendGeminiCLI)), } @@ -265,8 +266,8 @@ func modelHuhOptions(models []ai.ModelDef) []huh.Option[string] { } // 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 +// form. CLI/agent backends use exact provider model IDs from the catalog 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 { @@ -274,13 +275,13 @@ func defaultModelFor(b ai.Backend) string { case ai.BackendAnthropic: return "claude-sonnet-5" case ai.BackendClaudeCLI, ai.BackendClaudeAgent: - return "claude-agent-sonnet" + return "claude-sonnet-5" case ai.BackendOpenAI: return "gpt-5.5" case ai.BackendDeepSeek: return "deepseek-reasoner" - case ai.BackendCodexCLI: - return "gpt-5-codex" + case ai.BackendCodexCLI, ai.BackendCodexAgent: + return "gpt-5.5" case ai.BackendGemini, ai.BackendGeminiCLI: return "gemini-3.5-flash" } diff --git a/pkg/cli/configure_test.go b/pkg/cli/configure_test.go index 2545e53..4e2b659 100644 --- a/pkg/cli/configure_test.go +++ b/pkg/cli/configure_test.go @@ -134,20 +134,19 @@ func TestModelOptionsFor_CLIBackendsUseCatalogWithoutKey(t *testing.T) { 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) + if opts[0].Value != "gpt-5.5" { + t.Errorf("codex-cli option value = %q, want exact model gpt-5.5", opts[0].Value) } } func TestDefaultModelFor_HardcodedPerBackend(t *testing.T) { cases := map[ai.Backend]string{ ai.BackendAnthropic: "claude-sonnet-5", - ai.BackendClaudeCLI: "claude-agent-sonnet", - ai.BackendClaudeAgent: "claude-agent-sonnet", + ai.BackendClaudeCLI: "claude-sonnet-5", + ai.BackendClaudeAgent: "claude-sonnet-5", ai.BackendOpenAI: "gpt-5.5", - ai.BackendCodexCLI: "gpt-5-codex", + ai.BackendCodexCLI: "gpt-5.5", + ai.BackendCodexAgent: "gpt-5.5", ai.BackendGemini: "gemini-3.5-flash", ai.BackendGeminiCLI: "gemini-3.5-flash", } diff --git a/pkg/cli/history.go b/pkg/cli/history.go index 90d0ae2..6cfeb9e 100644 --- a/pkg/cli/history.go +++ b/pkg/cli/history.go @@ -590,6 +590,9 @@ func filterTools(tl []tools.Tool, opts HistoryOptions, classifier *bash.Category categories = append(categories, c) } for _, filter := range opts.Categories { + if strings.HasPrefix(strings.TrimSpace(filter), "!") { + continue + } if similar := captainCollections.FindSimilar(filter, categories, 3); len(similar) > 0 { fmt.Fprintf(os.Stderr, "category %q matched nothing. Did you mean: %s?\n", filter, strings.Join(similar, ", ")) } @@ -647,29 +650,40 @@ func matchesAnyCategoryCandidate(candidates []string, pattern string) bool { func categoryFilterCandidates(t tools.Tool, category string) []string { base := t.Base() - return uniqueNonEmpty( + values := []string{ category, t.Name(), base.RawTool, - messageAlias(t.Name()), - ) + } + values = append(values, chatCategoryAliases(t.Name())...) + values = append(values, chatCategoryAliases(base.RawTool)...) + return uniqueNonEmpty(values...) } func toolUseCategoryFilterCandidates(tu claude.ToolUse, category string) []string { - return uniqueNonEmpty( + values := []string{ category, tu.Tool, tu.DisplayTool(), - messageAlias(tu.DisplayTool()), - ) + } + values = append(values, chatCategoryAliases(tu.Tool)...) + values = append(values, chatCategoryAliases(tu.DisplayTool())...) + return uniqueNonEmpty(values...) } -func messageAlias(tool string) string { +func chatCategoryAliases(tool string) []string { + if tools.IsEventToolName(tool) { + return []string{"chat", "event"} + } switch strings.ToLower(tool) { case "assistant", "reasoning": - return "message" + return []string{"chat", "message"} + case "user": + return []string{"chat", "message"} + case "event": + return []string{"chat", "event"} default: - return "" + return nil } } @@ -747,7 +761,7 @@ func classifyTool(t tools.Tool, classifier *bash.CategoryClassifier) string { func approvedStatus(t tools.Tool) string { base := t.Base() name := t.Name() - if base.RawTool == "ExitPlanMode" || base.RawTool == "User" || name == "Plan" { + if base.RawTool == "ExitPlanMode" || isChatHistoryTool(base.RawTool) || name == "Plan" { return "" } if base.Denied { @@ -846,6 +860,9 @@ func runHistorySummary(toolUses []claude.ToolUse, opts HistoryOptions, classifie } func classifyToolUse(tu claude.ToolUse, classifier *bash.CategoryClassifier) string { + if isChatHistoryTool(tu.Tool) { + return "chat" + } category := classifier.ClassifyToolWithPath(tu.Tool, tu.FilePath()) if category == bash.CategoryOther && tu.Tool == "Bash" { if rawCmd, ok := tu.Input["command"].(string); ok { @@ -855,6 +872,18 @@ func classifyToolUse(tu claude.ToolUse, classifier *bash.CategoryClassifier) str return string(category) } +func isChatHistoryTool(tool string) bool { + if tools.IsEventToolName(tool) { + return true + } + switch tool { + case "User", "Assistant", "Reasoning", "Event": + return true + default: + return false + } +} + func matchesToolUseTextFilter(tu claude.ToolUse, category, filter string) bool { filter = strings.ToLower(strings.TrimSpace(filter)) if filter == "" { diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index b712b04..8b53f3d 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -134,6 +134,7 @@ type PromptActionFlags struct { 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"` Vars string `flag:"vars" help:"JSON object of template variables (HTTP callers)"` + MultiModels []string `flag:"multi-models" help:"Run prompt once per runtime selector in parallel, e.g. cli:sonnet-5,cmux:opus (repeatable; comma-separated allowed)" short:"M"` Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text (CLI)"` } @@ -420,6 +421,15 @@ func finalizeRenderResult(record promptRecord, content string, req ai.Request, c // fallback) at render time, not just on run. req.Model = req.ExpandCSV() cfg.Model = cfg.Model.ExpandCSV() + var err error + req.Model, err = ai.ResolveModelSelectors(req.Model) + if err != nil { + return PromptRenderResult{}, err + } + cfg.Model, err = ai.ResolveModelSelectors(cfg.Model) + if err != nil { + return PromptRenderResult{}, err + } for _, c := range cfg.Model.Candidates() { warnIfLikelyModelTypo(c.Name) } diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index a553786..397cbc0 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -4,11 +4,18 @@ import ( "context" "errors" "fmt" + "path/filepath" + "sort" + "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/history" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/claude" + clickyapi "github.com/flanksource/clicky/api" clickyrpc "github.com/flanksource/clicky/rpc" "github.com/flanksource/clicky/task" flanksourceContext "github.com/flanksource/commons/context" @@ -27,10 +34,128 @@ type PromptRunResult struct { Text string `json:"text,omitempty" pretty:"label=Response"` SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + Dir string `json:"dir,omitempty" pretty:"label=Dir"` + HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` Duration string `json:"duration,omitempty" pretty:"label=Duration"` + + Total int `json:"total,omitempty" pretty:"label=Total"` + Succeeded int `json:"succeeded,omitempty" pretty:"label=Succeeded"` + Failed int `json:"failed,omitempty" pretty:"label=Failed"` + Runs []PromptRunItem `json:"runs,omitempty" pretty:"label=Runs"` +} + +type PromptRunItem struct { + Selector string `json:"selector,omitempty" pretty:"label=Selector"` + Status string `json:"status,omitempty" pretty:"label=Status"` + Model string `json:"model,omitempty" pretty:"label=Model"` + Backend string `json:"backend,omitempty" pretty:"label=Backend"` + Text string `json:"text,omitempty" pretty:"label=Response"` + SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + Dir string `json:"dir,omitempty" pretty:"label=Dir"` + HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` + InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` + OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` + CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` + Duration string `json:"duration,omitempty" pretty:"label=Duration"` + Error string `json:"error,omitempty" pretty:"label=Error"` +} + +func (r PromptRunResult) Pretty() clickyapi.Text { + if len(r.Runs) == 0 { + if r.Text != "" { + return clickyapi.Text{Content: r.Text} + } + return clickyapi.Text{Content: r.Status} + } + t := clickyapi.Text{}. + Append(fmt.Sprintf("Status: %s Total: %d Succeeded: %d Failed: %d Duration: %s", + r.Status, r.Total, r.Succeeded, r.Failed, r.Duration), "font-medium") + table := clickyapi.TextTable{ + Headers: clickyapi.TextList{ + textCell("Selector"), + textCell("Status"), + textCell("Backend"), + textCell("Model"), + textCell("Dir"), + textCell("Session"), + textCell("History"), + textCell("Tokens"), + textCell("Cost"), + textCell("Duration"), + textCell("Response"), + textCell("Error"), + }, + FieldNames: []string{"selector", "status", "backend", "model", "dir", "session", "history", "tokens", "cost", "duration", "response", "error"}, + } + for _, run := range r.Runs { + table.Rows = append(table.Rows, clickyapi.TableRow{ + "selector": cell(run.Selector), + "status": cell(run.Status), + "backend": cell(run.Backend), + "model": cell(run.Model), + "dir": cell(truncatePathCell(run.Dir, 56)), + "session": cell(shortSessionCell(run.SessionID)), + "history": cell(truncatePathCell(run.HistoryFile, 72)), + "tokens": cell(tokenCell(run.InputTokens, run.OutputTokens)), + "cost": cell(costCell(run.CostUSD)), + "duration": cell(run.Duration), + "response": cell(truncateCell(run.Text, 120)), + "error": cell(truncateCell(run.Error, 160)), + }) + } + return t.NewLine().Add(table) +} + +func textCell(s string) clickyapi.Textable { + return clickyapi.Text{Content: s} +} + +func cell(s string) clickyapi.TypedValue { + return clickyapi.TypedValue{Textable: clickyapi.Text{Content: s}} +} + +func truncateCell(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[:max] + "..." +} + +func truncatePathCell(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return "..." + s[len(s)-max+3:] +} + +func shortSessionCell(s string) string { + s = strings.TrimSpace(s) + if len(s) <= 12 { + return s + } + return s[:12] +} + +func tokenCell(input, output int) string { + if input == 0 && output == 0 { + return "" + } + return fmt.Sprintf("%d/%d", input, output) +} + +func costCell(cost float64) string { + if cost <= 0 { + return "" + } + return fmt.Sprintf("$%.4f", cost) } // PromptRunSummary is the terminal payload of a run: the SSE stream ends with it @@ -58,6 +183,9 @@ func runPromptAction(ctx context.Context, id string, flags map[string]string) (P var rendered PromptRenderResult var opts AIPromptOptions if isHTTP { + if strings.TrimSpace(flags["multi-models"]) != "" { + return PromptRunResult{}, errors.New("--multi-models is only supported on the CLI prompt run path") + } req, err := readRenderRequest(ctx, flags) if err != nil { return PromptRunResult{}, err @@ -84,6 +212,8 @@ func runPromptAction(ctx context.Context, id string, flags map[string]string) (P return executeSyncRun(ctx, rendered, opts) } +var executePromptRequestFunc = executePromptRequest + // launchAsyncRun starts the background clicky task + SSE stream and returns the // run handle (the serve/web-UI contract). func launchAsyncRun(id string, rendered PromptRenderResult) PromptRunResult { @@ -95,11 +225,7 @@ func launchAsyncRun(id string, rendered PromptRenderResult) PromptRunResult { "prompt "+rendered.Name, task.WithGroupID(runID), task.WithKind("prompt"), - task.WithLabels(map[string]string{ - "promptId": id, - "model": rendered.Model, - "backend": rendered.Backend, - }), + task.WithLabels(promptTaskLabelsWithID(rendered, id, "")), ) group.Add("execute", func(_ flanksourceContext.Context, t *task.Task) (PromptRunSummary, error) { return runPromptStream(t, rendered, timeout, runID, stream) @@ -110,7 +236,27 @@ func launchAsyncRun(id string, rendered PromptRenderResult) PromptRunResult { // executeSyncRun runs the prompt in-process (CLI) — live output to stderr, final // result returned — and persists the realized prompt for the launched session. func executeSyncRun(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions) (PromptRunResult, error) { - out, err := executePromptRequest(ctx, rendered.Input, rendered.Config, runtimeTimeout(rendered.Input.Budget.Timeout), opts.NoStream) + if len(opts.MultiModels) > 0 { + return executeSyncBatch(ctx, rendered, opts) + } + return executeSyncRunSingle(ctx, rendered, opts) +} + +func executeSyncRunSingle(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions) (PromptRunResult, error) { + group := task.StartGroup[PromptRunResult]( + "prompt "+rendered.Name, + task.WithKind("prompt"), + task.WithLabels(promptTaskLabels(rendered, "")), + ) + run := group.Add("execute "+rendered.Backend+":"+rendered.Model, func(_ flanksourceContext.Context, t *task.Task) (PromptRunResult, error) { + taskCtx := ai.ContextWithLogger(t.Context(), t) + return executeSyncRunSingleDirect(taskCtx, rendered, opts) + }, task.WithModel(rendered.Model), task.WithPrompt(rendered.Input.Prompt.User)) + return run.GetResult() +} + +func executeSyncRunSingleDirect(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions) (PromptRunResult, error) { + out, err := executePromptRequestFunc(ctx, rendered.Input, rendered.Config, runtimeTimeout(rendered.Input.Budget.Timeout), opts.NoStream) if err != nil { return PromptRunResult{}, err } @@ -131,6 +277,8 @@ func executeSyncRun(ctx context.Context, rendered PromptRenderResult, opts AIPro Backend: r.Backend, Text: r.Text, SessionID: r.SessionID, + Dir: r.Dir, + HistoryFile: r.HistoryFile, InputTokens: r.InputTokens, OutputTokens: r.Output, CostUSD: r.CostUSD, @@ -138,6 +286,206 @@ func executeSyncRun(ctx context.Context, rendered PromptRenderResult, opts AIPro }, nil } +func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions) (PromptRunResult, error) { + models, err := ai.ResolveRuntimeSelectors(opts.MultiModels, rendered.Config.Model) + if err != nil { + return PromptRunResult{}, err + } + if len(models) == 0 { + return executeSyncRunSingle(ctx, rendered, opts) + } + if rendered.Input.SessionID != "" && len(models) > 1 { + return PromptRunResult{}, errors.New("--resume cannot be used with multiple --multi-models variants") + } + if forced := api.Backend(strings.TrimSpace(opts.Backend)); forced != "" { + for _, model := range models { + if model.Backend != forced { + return PromptRunResult{}, fmt.Errorf("--multi-models selector %s:%s conflicts with --backend %s", model.Backend, model.Name, forced) + } + } + } + + start := time.Now() + runs := make([]PromptRunItem, len(models)) + group := task.StartGroup[PromptRunItem]( + "prompt "+rendered.Name, + task.WithKind("prompt"), + task.WithLabels(promptTaskLabels(rendered, "multi")), + task.WithConcurrency(len(models)), + ) + tasks := make([]task.TypedTask[PromptRunItem], len(models)) + for i, model := range models { + i, model := i, model + selector := string(model.Backend) + ":" + model.Name + tasks[i] = group.Add(selector, func(_ flanksourceContext.Context, t *task.Task) (PromptRunItem, error) { + variant := renderVariant(rendered, model, fallbackModelsFromFlags(opts.Fallback)) + variantOpts := opts + variantOpts.MultiModels = nil + taskCtx := ai.ContextWithLogger(t.Context(), t) + result, err := executeSyncRunSingleDirect(taskCtx, variant, variantOpts) + item := PromptRunItem{ + Selector: selector, + Model: model.Name, + Backend: string(model.Backend), + Dir: actualRunDir(variant.Input), + } + if err != nil { + item.Status = "failed" + item.HistoryFile = historyFileForRun(model.Backend, item.SessionID, item.Dir) + item.Error = err.Error() + return item, err + } + item.Status = result.Status + item.Model = firstNonEmpty(result.Model, item.Model) + item.Backend = firstNonEmpty(result.Backend, item.Backend) + item.Text = result.Text + item.SessionID = result.SessionID + item.Dir = firstNonEmpty(result.Dir, item.Dir) + item.HistoryFile = firstNonEmpty(result.HistoryFile, historyFileForRun(api.Backend(item.Backend), item.SessionID, item.Dir)) + item.InputTokens = result.InputTokens + item.OutputTokens = result.OutputTokens + item.CostUSD = result.CostUSD + item.Duration = result.Duration + return item, nil + }, task.WithModel(model.Name), task.WithPrompt(rendered.Input.Prompt.User)) + } + for i, task := range tasks { + item, err := task.GetResult() + if err != nil && item.Error == "" { + item.Status = "failed" + item.Error = err.Error() + } + runs[i] = item + } + + result := PromptRunResult{ + Status: "completed", + Total: len(runs), + Runs: runs, + Duration: time.Since(start).Round(time.Millisecond).String(), + } + for _, run := range runs { + if run.Error != "" || run.Status == "failed" { + result.Failed++ + continue + } + result.Succeeded++ + result.InputTokens += run.InputTokens + result.OutputTokens += run.OutputTokens + result.CostUSD += run.CostUSD + } + switch { + case result.Failed == 0: + result.Status = "completed" + case result.Succeeded == 0: + result.Status = "failed" + default: + result.Status = "partial" + } + if result.Succeeded == 0 && result.Failed > 0 { + return result, fmt.Errorf("all %d prompt variants failed", result.Failed) + } + return result, nil +} + +func promptTaskLabels(rendered PromptRenderResult, mode string) map[string]string { + labels := map[string]string{ + "model": rendered.Model, + "backend": rendered.Backend, + } + if mode != "" { + labels["mode"] = mode + } + if rendered.Name != "" { + labels["prompt"] = rendered.Name + } + if source := rendered.Input.Prompt.Source; source != "" { + labels["source"] = source + } + if cwd := rendered.Input.Cwd(); cwd != "" { + labels["cwd"] = cwd + } + return labels +} + +func promptTaskLabelsWithID(rendered PromptRenderResult, id, mode string) map[string]string { + labels := promptTaskLabels(rendered, mode) + if id != "" { + labels["promptId"] = id + } + return labels +} + +func renderVariant(rendered PromptRenderResult, model api.Model, fallbacks []api.Model) PromptRenderResult { + out := rendered + req := rendered.Input + cfg := rendered.Config + req.Model = variantModel(req.Model, model, fallbacks) + cfg.Model = variantModel(cfg.Model, model, fallbacks) + out.Input = req + out.Config = cfg + out.Model = cfg.Model.Name + out.Backend = string(cfg.Model.Backend) + return out +} + +func historyFileForRun(backend api.Backend, sessionID, cwd string) string { + if strings.TrimSpace(sessionID) == "" { + return "" + } + switch backend { + case api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux: + return claudeHistoryFile(sessionID, cwd) + case api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux: + return codexHistoryFile(sessionID) + default: + return "" + } +} + +func claudeHistoryFile(sessionID, cwd string) string { + if cwd == "" { + return "" + } + abs, err := filepath.Abs(cwd) + if err != nil { + return "" + } + return filepath.Join(claude.GetProjectsDir(), claude.NormalizePath(abs), sessionID+".jsonl") +} + +func codexHistoryFile(sessionID string) string { + files, err := history.FindCodexSessionFiles() + if err != nil || len(files) == 0 { + return "" + } + sort.Strings(files) + for _, file := range files { + if strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) == sessionID { + return file + } + meta, err := history.ReadCodexSessionMeta(file) + if err == nil && meta != nil && meta.ID == sessionID { + return file + } + } + for _, file := range files { + if strings.HasPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)), sessionID) { + return file + } + } + return "" +} + +func variantModel(base api.Model, model api.Model, fallbacks []api.Model) api.Model { + out := base + out.Name = model.Name + out.ID = model.ID + out.Backend = model.Backend + out.Fallbacks = fallbacks + return out +} + // runPromptStream drives a single streaming iteration, converting each ai.Event // into a SessionEntry frame (published to stream) while driving the task's live // status. It runs on clicky's worker goroutine and derives its context from the diff --git a/pkg/cli/prompt_run_test.go b/pkg/cli/prompt_run_test.go new file mode 100644 index 0000000..bbee27b --- /dev/null +++ b/pkg/cli/prompt_run_test.go @@ -0,0 +1,243 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + clickyapi "github.com/flanksource/clicky/api" +) + +func TestExecuteSyncRunMultiModelsParallel(t *testing.T) { + old := executePromptRequestFunc + t.Cleanup(func() { executePromptRequestFunc = old }) + + started := make(chan struct{}) + var calls atomic.Int32 + executePromptRequestFunc = func(ctx context.Context, req ai.Request, cfg ai.Config, _ time.Duration, noStream bool) (any, error) { + if noStream { + t.Errorf("multi-model run should preserve streaming unless --no-stream is set") + } + if calls.Add(1) == 2 { + close(started) + } + select { + case <-started: + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Second): + return nil, errors.New("executor was not called concurrently") + } + return AIPromptResult{ + Text: cfg.Model.Name, + Model: cfg.Model.Name, + Backend: string(cfg.Model.Backend), + Dir: req.Cwd(), + SessionID: "session-" + cfg.Model.Name, + HistoryFile: filepath.Join(req.Cwd(), ".history", cfg.Model.Name+".jsonl"), + InputTokens: 1, + Output: 2, + CostUSD: 0.01, + Duration: "1ms", + }, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + rendered := testRenderedPrompt(api.Model{Name: "claude-sonnet-5", Backend: api.BackendAnthropic}) + rendered.Input.SetCwd(t.TempDir()) + got, err := executeSyncRun(ctx, rendered, AIPromptOptions{MultiModels: []string{"cli:sonnet-5,cmux:opus"}}) + if err != nil { + t.Fatalf("executeSyncRun: %v", err) + } + if got.Status != "completed" || got.Total != 2 || got.Succeeded != 2 || got.Failed != 0 { + t.Fatalf("batch status = %s total=%d succeeded=%d failed=%d", got.Status, got.Total, got.Succeeded, got.Failed) + } + want := []struct { + backend api.Backend + model string + }{ + {api.BackendClaudeCLI, "claude-sonnet-5"}, + {api.BackendClaudeCmux, "claude-opus-4-8"}, + } + for i, w := range want { + if got.Runs[i].Backend != string(w.backend) || got.Runs[i].Model != w.model { + t.Fatalf("run[%d] = %s/%s, want %s/%s", i, got.Runs[i].Backend, got.Runs[i].Model, w.backend, w.model) + } + } + if got.InputTokens != 2 || got.OutputTokens != 4 { + t.Fatalf("aggregate tokens = %d/%d, want 2/4", got.InputTokens, got.OutputTokens) + } + if got.Runs[0].Dir == "" || !strings.Contains(got.Runs[0].HistoryFile, ".history") { + t.Fatalf("run metadata missing dir/history: %+v", got.Runs[0]) + } + if got.Runs[0].CostUSD != 0.01 || got.Runs[0].InputTokens != 1 || got.Runs[0].OutputTokens != 2 { + t.Fatalf("run usage/cost missing: %+v", got.Runs[0]) + } +} + +func TestExecuteSyncRunMultiModelsHonorsNoStream(t *testing.T) { + old := executePromptRequestFunc + t.Cleanup(func() { executePromptRequestFunc = old }) + + executePromptRequestFunc = func(_ context.Context, _ ai.Request, cfg ai.Config, _ time.Duration, noStream bool) (any, error) { + if !noStream { + t.Errorf("multi-model run should pass explicit --no-stream through") + } + return AIPromptResult{ + Text: "ok", + Model: cfg.Model.Name, + Backend: string(cfg.Model.Backend), + Duration: "1ms", + }, nil + } + + rendered := testRenderedPrompt(api.Model{Name: "claude-sonnet-5", Backend: api.BackendAnthropic}) + got, err := executeSyncRun(context.Background(), rendered, AIPromptOptions{MultiModels: []string{"cli:sonnet-5"}, NoStream: true}) + if err != nil { + t.Fatalf("executeSyncRun: %v", err) + } + if got.Status != "completed" || got.Succeeded != 1 { + t.Fatalf("batch status = %+v", got) + } +} + +func TestExecuteSyncRunMultiModelsPartialFailure(t *testing.T) { + old := executePromptRequestFunc + t.Cleanup(func() { executePromptRequestFunc = old }) + + executePromptRequestFunc = func(_ context.Context, _ ai.Request, cfg ai.Config, _ time.Duration, _ bool) (any, error) { + if cfg.Model.Backend == api.BackendCodexCmux { + return nil, errors.New("cmux unavailable") + } + return AIPromptResult{ + Text: "ok", + Model: cfg.Model.Name, + Backend: string(cfg.Model.Backend), + InputTokens: 3, + Output: 4, + Duration: "2ms", + }, nil + } + + rendered := testRenderedPrompt(api.Model{Name: "gpt-5.5", Backend: api.BackendOpenAI}) + got, err := executeSyncRun(context.Background(), rendered, AIPromptOptions{MultiModels: []string{"api:gpt-5.5,cmux:gpt-5.5"}}) + if err != nil { + t.Fatalf("executeSyncRun: %v", err) + } + if got.Status != "partial" || got.Succeeded != 1 || got.Failed != 1 { + t.Fatalf("batch status = %s succeeded=%d failed=%d", got.Status, got.Succeeded, got.Failed) + } + if got.Runs[1].Status != "failed" || !strings.Contains(got.Runs[1].Error, "cmux unavailable") { + t.Fatalf("failed run = %+v", got.Runs[1]) + } +} + +func TestExecuteSyncRunMultiModelsRejectsResume(t *testing.T) { + rendered := testRenderedPrompt(api.Model{Name: "gpt-5.5", Backend: api.BackendOpenAI}) + rendered.Input.SessionID = "session-1" + _, err := executeSyncRun(context.Background(), rendered, AIPromptOptions{MultiModels: []string{"api:gpt-5.5,cmux:gpt-5.5"}}) + if err == nil || !strings.Contains(err.Error(), "--resume") { + t.Fatalf("error = %v, want --resume rejection", err) + } +} + +func TestPromptRunResultPrettyIncludesRuntimeColumns(t *testing.T) { + result := PromptRunResult{ + Status: "completed", + Total: 1, + Succeeded: 1, + Runs: []PromptRunItem{{ + Selector: "api:sonnet-5", + Status: "completed", + Backend: "anthropic", + Model: "claude-sonnet-5", + Dir: "/repo", + SessionID: "0123456789abcdef", + HistoryFile: "/repo/.claude/session.jsonl", + InputTokens: 8, + OutputTokens: 14, + CostUSD: 0.0002, + Duration: "1s", + Text: "ok", + }}, + } + + table := firstPrettyTable(t, result.Pretty()) + for _, field := range []string{"dir", "history", "tokens", "cost", "duration"} { + if !slices.Contains(table.FieldNames, field) { + t.Fatalf("field names = %v, want %q", table.FieldNames, field) + } + } + row := table.Rows[0] + if row["tokens"].String() != "8/14" { + t.Fatalf("tokens cell = %q", row["tokens"].String()) + } + if row["cost"].String() != "$0.0002" { + t.Fatalf("cost cell = %q", row["cost"].String()) + } + if row["dir"].String() != "/repo" || row["history"].String() != "/repo/.claude/session.jsonl" { + t.Fatalf("dir/history cells = %q / %q", row["dir"].String(), row["history"].String()) + } +} + +func TestHistoryFileForRun(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cwd := filepath.Join(home, "repo") + if err := os.MkdirAll(cwd, 0o755); err != nil { + t.Fatalf("mkdir cwd: %v", err) + } + + got := historyFileForRun(api.BackendClaudeCLI, "claude-session", cwd) + if want := filepath.Join(home, ".claude", "projects"); !strings.HasPrefix(got, want) || !strings.HasSuffix(got, "claude-session.jsonl") { + t.Fatalf("claude history path = %q, want under %q", got, want) + } + + codexDir := filepath.Join(home, ".codex", "sessions", "2026", "07", "08") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatalf("mkdir codex dir: %v", err) + } + codexFile := filepath.Join(codexDir, "codex-session.jsonl") + if err := os.WriteFile(codexFile, []byte(`{"type":"session_meta","payload":{"id":"codex-session","cwd":"`+cwd+`"}}`+"\n"), 0o644); err != nil { + t.Fatalf("write codex session: %v", err) + } + if got := historyFileForRun(api.BackendCodexAgent, "codex-session", cwd); got != codexFile { + t.Fatalf("codex history path = %q, want %q", got, codexFile) + } +} + +func firstPrettyTable(t *testing.T, text clickyapi.Text) clickyapi.TextTable { + t.Helper() + for _, child := range text.Children { + if table, ok := child.(clickyapi.TextTable); ok { + return table + } + } + t.Fatalf("no table child in %#v", text.Children) + return clickyapi.TextTable{} +} + +func testRenderedPrompt(model api.Model) PromptRenderResult { + req := ai.Request{ + Model: model, + Prompt: api.Prompt{User: "hello"}, + Budget: api.Budget{Timeout: "1s"}, + } + cfg := ai.Config{Model: model} + return PromptRenderResult{ + Name: "test", + Model: model.Name, + Backend: string(model.Backend), + Input: req, + Config: cfg, + } +} diff --git a/pkg/cli/prompt_schema.go b/pkg/cli/prompt_schema.go index ee226ed..a14f014 100644 --- a/pkg/cli/prompt_schema.go +++ b/pkg/cli/prompt_schema.go @@ -153,11 +153,6 @@ func promptSchemaExampleSpec() map[string]any { "user": "Update the prompt runtime editor and keep the payload compact.", "system": "Answer with direct implementation guidance.", "appendSystem": "Prefer existing clicky-ui primitives.", - "source": "storybook/demo.prompt", - "metadata": map[string]any{ - "surface": "prompt-workbench", - "owner": "captain", - }, }, "budget": map[string]any{ "cost": 0.5, @@ -192,9 +187,9 @@ func promptSchemaExampleSpec() map[string]any { "cwd": ".", "baseDir": ".shell", "dotenv": []string{".env", ".env.local"}, - "env": []string{ - "CAPTAIN_RUNTIME=storybook", - "GAVEL_PROFILE=local", + "envVars": []map[string]any{ + {"name": "CAPTAIN_RUNTIME", "value": "storybook"}, + {"name": "GAVEL_PROFILE", "value": "local"}, }, }, "cliArgs": map[string]any{ diff --git a/pkg/cli/prompt_schema_build.go b/pkg/cli/prompt_schema_build.go index 84222fe..b6cb0d3 100644 --- a/pkg/cli/prompt_schema_build.go +++ b/pkg/cli/prompt_schema_build.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" clickyrpc "github.com/flanksource/clicky/rpc" ) @@ -13,8 +12,6 @@ import ( // buildPromptSchemaDocument is the pure assembler (no I/O), taking a probed // adapter set so tests can drive it with a deterministic, network-free stub. func buildPromptSchemaDocument(adapters []AdapterStatus) (map[string]any, error) { - adapters = withCatalogModelFallback(adapters) - reflected, err := reflectedSchemas() if err != nil { return nil, err @@ -164,38 +161,6 @@ func injectSpecConditionals(specMap map[string]any, adapters []AdapterStatus, ar return nil } -// withCatalogModelFallback fills a backend's model list from the static catalog -// when it is unauthenticated and the live probe returned nothing, so the editor -// still offers model choices instead of an empty enum. The catalog answered, so -// the "set your key" hint is cleared. CLI/agent backends already list catalog -// models regardless of auth, so this only affects unauthenticated API backends -// (and no-ops where the catalog has no entry, e.g. the cmux backends). -func withCatalogModelFallback(adapters []AdapterStatus) []AdapterStatus { - out := make([]AdapterStatus, len(adapters)) - for i, a := range adapters { - if !a.Authenticated && len(a.Models) == 0 { - if ids := catalogModelIDs(api.Backend(a.Backend)); len(ids) > 0 { - a.Models = ids - a.ModelError = "" - } - } - out[i] = a - } - return out -} - -// catalogModelIDs returns the catalog's model slugs for a backend, bare (no -// provider prefix) to match how spec.model names models. -func catalogModelIDs(b api.Backend) []string { - var out []string - for _, m := range ai.Catalog() { - if m.Backend == b { - out = append(out, m.BareID()) - } - } - return out -} - // flatModels is a convenience union of every available model across adapters. func flatModels(adapters []AdapterStatus) []map[string]any { out := []map[string]any{} diff --git a/pkg/cli/prompt_schema_test.go b/pkg/cli/prompt_schema_test.go index cec64cb..e74b0e6 100644 --- a/pkg/cli/prompt_schema_test.go +++ b/pkg/cli/prompt_schema_test.go @@ -12,10 +12,10 @@ import ( "github.com/spf13/cobra" ) -// fakeSchemaProbe reports no API keys and no CLI login files but all CLI binaries -// present. This keeps the probe hermetic (fetchAPIModels makes no network call -// without an API key) while CLI/agent backends still list their static catalog -// models, giving a deterministic adapter set. +// fakeSchemaProbe reports no API keys and no CLI login files but all CLI +// binaries present. This keeps the probe hermetic: fetchAPIModels makes no +// network call without an API key, API backends stay key-gated, and CLI-style +// backends project exact IDs from Captain's internal registry. func fakeSchemaProbe() authProbe { return authProbe{ getenv: func(string) string { return "" }, @@ -63,22 +63,20 @@ func TestPromptSchemaDocumentBackendsAndConditionals(t *testing.T) { } } - // codex-cli's models come from the static catalog (one entry, AgentModel - // "gpt-5-codex") - an independent baseline unaffected by API keys. - codexModels := byName[string(api.BackendCodexCLI)]["models"].([]string) - if !contains(codexModels, "gpt-5-codex") { - t.Errorf("codex-cli models = %v, want to contain gpt-5-codex", codexModels) + codexCLIModels, hasModels := byName[string(api.BackendCodexCLI)]["models"].([]string) + if !hasModels || len(codexCLIModels) == 0 { + t.Fatalf("codex-cli should expose exact registry models without API provider data: %+v", byName[string(api.BackendCodexCLI)]) + } + if got := codexCLIModels[0]; got != "gpt-5.5" { + t.Errorf("codex-cli first model = %q, want gpt-5.5", got) } - // anthropic is unauthenticated in the fake probe, so its models fall back to - // the catalog and the "set your key" hint is cleared. anthropic := byName[string(api.BackendAnthropic)] - anthropicModels, _ := anthropic["models"].([]string) - if !contains(anthropicModels, "claude-sonnet-5") { - t.Errorf("unauthenticated anthropic models = %v, want catalog fallback incl claude-sonnet-5", anthropicModels) + if _, hasModels := anthropic["models"]; hasModels { + t.Errorf("anthropic should not synthesize models without live provider data: %+v", anthropic) } - if _, hasErr := anthropic["modelError"]; hasErr { - t.Errorf("anthropic should carry no modelError once the catalog answers") + if errText, _ := anthropic["modelError"].(string); !strings.Contains(errText, "ANTHROPIC_API_KEY") { + t.Errorf("anthropic modelError = %q, want missing-key hint", errText) } spec := doc["spec"].(map[string]any) @@ -152,6 +150,19 @@ func TestPromptSchemaExampleIsPortable(t *testing.T) { if _, err := api.InferBackend(model); err != nil { t.Errorf("example model %q does not resolve to a backend: %v", model, err) } + prompt := ex["prompt"].(map[string]any) + for _, excluded := range []string{"source", "metadata"} { + if _, ok := prompt[excluded]; ok { + t.Errorf("example prompt includes editor-excluded %q", excluded) + } + } + setup := ex["setup"].(map[string]any) + if _, ok := setup["env"]; ok { + t.Errorf("example setup uses stale env key: %+v", setup) + } + if _, ok := setup["envVars"]; !ok { + t.Errorf("example setup missing envVars: %+v", setup) + } // The example selects claude-cmux, so its cliArgs must validate as // ClaudeCmuxOptions — matching the schema's per-backend conditional. diff --git a/pkg/cli/prompt_source.go b/pkg/cli/prompt_source.go index ffd4dbc..3f8ef31 100644 --- a/pkg/cli/prompt_source.go +++ b/pkg/cli/prompt_source.go @@ -161,6 +161,7 @@ func actionFlagsToOptions(f map[string]string) (AIPromptOptions, error) { o.System = f["system"] o.AppendSystem = f["append-system"] o.Var = flagSlice(f["var"]) + o.MultiModels = flagSlice(f["multi-models"]) o.Timeout = f["timeout"] o.NoStream = flagBool(f["no-stream"]) return o, nil diff --git a/pkg/cli/prompt_source_test.go b/pkg/cli/prompt_source_test.go index 2e6ff09..461927d 100644 --- a/pkg/cli/prompt_source_test.go +++ b/pkg/cli/prompt_source_test.go @@ -17,6 +17,7 @@ func TestActionFlagsToOptions_DecodesSlicesBoolsInts(t *testing.T) { "skill-dir": "/a,/b", "max-tokens": "1234", "var": "a=1,b=2", + "multi-models": "cli:sonnet-5,cmux:opus", "prompt": "hi", "permission-mode": "plan", } @@ -42,6 +43,9 @@ func TestActionFlagsToOptions_DecodesSlicesBoolsInts(t *testing.T) { if len(o.Var) != 2 || o.Var[0] != "a=1" { t.Errorf("var = %v", o.Var) } + if len(o.MultiModels) != 2 || o.MultiModels[0] != "cli:sonnet-5" || o.MultiModels[1] != "cmux:opus" { + t.Errorf("multi-models = %v", o.MultiModels) + } if _, err := actionFlagsToOptions(map[string]string{"max-tokens": "nope"}); err == nil { t.Error("expected error on non-numeric max-tokens") } diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index b981371..cadf1f5 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -26,9 +26,11 @@ import ( "github.com/spf13/cobra" ) -// all: keeps the committed dist/.gitkeep placeholder in the embed so the binary -// compiles without a built webapp (dist is gitignored and built on demand or in -// the release workflow). When index.html is absent, serve.go reports it at runtime. +// The built webapp is committed under webapp/dist (see .gitignore) because the +// vite build depends on a local clicky-ui link: dependency that is unavailable +// in CI and in the goreleaser release job, so the binary embeds the checked-in +// dist rather than building it. all: ensures dotfiles are embedded too. When +// index.html is absent, serve.go reports it at runtime. // //go:embed all:webapp/dist var captainWebappFS embed.FS diff --git a/pkg/cli/stdin_test.go b/pkg/cli/stdin_test.go index c38fe4c..ca2d586 100644 --- a/pkg/cli/stdin_test.go +++ b/pkg/cli/stdin_test.go @@ -53,18 +53,19 @@ func TestParseFromReader_ClaudeStreamJSON(t *testing.T) { require.NoError(t, err) assert.Equal(t, claude.FormatClaudeStreamJSON, result.Format) assert.Nil(t, result.CLIOut) - // Stream-json now surfaces system/init and result/* as synthetic tools - // alongside real tool_use blocks. - require.Len(t, result.ToolUses, 4) + // Stream-json now surfaces chat, system/init, and result/* rows alongside + // real tool_use blocks. + require.Len(t, result.ToolUses, 5) names := []string{ result.ToolUses[0].Tool, result.ToolUses[1].Tool, result.ToolUses[2].Tool, result.ToolUses[3].Tool, + result.ToolUses[4].Tool, } - assert.Equal(t, []string{"SessionInit", "Bash", "Read", "Result"}, names) - assert.Equal(t, "ls -la", result.ToolUses[1].Input["command"]) - assert.Equal(t, "/tmp/foo.go", result.ToolUses[2].Input["file_path"]) + assert.Equal(t, []string{"SessionInit", "User", "Bash", "Read", "Result"}, names) + assert.Equal(t, "ls -la", result.ToolUses[2].Input["command"]) + assert.Equal(t, "/tmp/foo.go", result.ToolUses[3].Input["file_path"]) } func TestParseFromReader_ClaudeCLI(t *testing.T) { @@ -271,3 +272,33 @@ func TestRunHistoryFromReader_CategoryAliases(t *testing.T) { assert.Equal(t, "plan", histResult.Results[0].Category) assert.NotContains(t, histResult.Results[0].Summary, `{"plan"`) } + +func TestRunHistoryFromReader_CodexChatRowsAndChatCategoryFilter(t *testing.T) { + data := []byte(`{"timestamp":"2026-07-08T11:19:57.028Z","type":"session_meta","payload":{"id":"sess-rollout","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}} +{"timestamp":"2026-07-08T11:19:57.028Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}} +{"timestamp":"2026-07-08T11:19:58.758Z","type":"turn_context","payload":{"model":"gpt-5.5","effort":"high"}} +{"timestamp":"2026-07-08T11:19:58.760Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}} +{"timestamp":"2026-07-08T11:19:58.760Z","type":"event_msg","payload":{"type":"user_message","message":"hi"}} +{"timestamp":"2026-07-08T11:20:00.403Z","type":"event_msg","payload":{"type":"agent_message","message":"hello"}} +{"timestamp":"2026-07-08T11:20:00.403Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}} +{"timestamp":"2026-07-08T11:20:00.435Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","duration_ms":3519}} +`) + + result, err := runHistoryFromReader(data, HistoryOptions{}) + require.NoError(t, err) + histResult := result.(session.HistoryResult) + require.Len(t, histResult.Results, 4) + assert.Equal(t, []string{"TaskStarted", "User", "Assistant", "TaskComplete"}, []string{ + histResult.Results[0].Tool, + histResult.Results[1].Tool, + histResult.Results[2].Tool, + histResult.Results[3].Tool, + }) + for _, row := range histResult.Results { + assert.Equal(t, "chat", row.Category) + } + + filtered, err := runHistoryFromReader(data, HistoryOptions{Categories: []string{"!chat"}}) + require.NoError(t, err) + assert.Empty(t, filtered.(session.HistoryResult).Results) +} diff --git a/pkg/cli/summary.go b/pkg/cli/summary.go index 50454cd..2c6bf0b 100644 --- a/pkg/cli/summary.go +++ b/pkg/cli/summary.go @@ -77,14 +77,9 @@ func BuildSummary(toolUses []claude.ToolUse, classifier *bash.CategoryClassifier denied++ } - category := classifier.ClassifyToolWithPath(tu.Tool, tu.FilePath()) - if category == bash.CategoryOther && tu.Tool == "Bash" { - if rawCmd, ok := tu.Input["command"].(string); ok { - category = classifier.ClassifyBash(rawCmd) - } - } - categories[string(category)]++ - categoryTokens[string(category)] += tuTokens + category := classifyToolUse(tu, classifier) + categories[category]++ + categoryTokens[category] += tuTokens analysis := AnalyzeToolUseLegacy(tu, tu.ProjectRoot) for _, p := range analysis.ReadPaths { diff --git a/pkg/cli/webapp/src/ChatLayer.tsx b/pkg/cli/webapp/src/ChatLayer.tsx index 0bf49d5..fa092e9 100644 --- a/pkg/cli/webapp/src/ChatLayer.tsx +++ b/pkg/cli/webapp/src/ChatLayer.tsx @@ -22,7 +22,7 @@ export function ChatLayer() { chat={{ api: "/api/chat", modelsApi: "/api/chat/models", - defaultModel: "claude-agent-sonnet", + defaultModel: "claude-sonnet-5", enableAttachments: true, toolApproval: "manual", suggestions: [ diff --git a/pkg/cli/webapp/src/PromptWorkbench.tsx b/pkg/cli/webapp/src/PromptWorkbench.tsx index f6904f4..f0e4cde 100644 --- a/pkg/cli/webapp/src/PromptWorkbench.tsx +++ b/pkg/cli/webapp/src/PromptWorkbench.tsx @@ -1481,7 +1481,8 @@ function inferBackendFromModel(model: string) { return "deepseek"; if (value.startsWith("claude-agent-")) return "claude-agent"; if (value.startsWith("claude-code-")) return "claude-cli"; - if (value.startsWith("codex")) return "codex-cli"; + if (value.startsWith("codex-agent-") || value.startsWith("codex")) + return "codex-agent"; if (value.startsWith("gemini-cli-")) return "gemini-cli"; if (value.startsWith("claude-")) return "anthropic"; if (value.startsWith("gemini-") || value.startsWith("models/gemini-")) @@ -1519,7 +1520,11 @@ function normalizeRuntimeModel(model: string, models: ChatModel[]) { ) { return { model: stripProviderPrefix(id), backend }; } - if (selected.provider === "codex-cli" && id.startsWith("codex-")) { + if ( + (selected.provider === "codex-cli" || + selected.provider === "codex-agent") && + id.startsWith("codex-") + ) { return { model: id.slice("codex-".length), backend }; } return { model: id, backend }; @@ -1535,6 +1540,7 @@ function providerToBackend(provider: string) { case "claude-agent": case "claude-cli": case "codex-cli": + case "codex-agent": case "gemini-cli": return provider; default: diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx index 76ad58b..90e28f8 100644 --- a/pkg/cli/webapp/src/SessionBrowser.tsx +++ b/pkg/cli/webapp/src/SessionBrowser.tsx @@ -8,7 +8,7 @@ import { Switch, type AppShellNavSection, } from "@flanksource/clicky-ui/components"; -import { SessionViewer } from "@flanksource/clicky-ui/ai"; +import { SessionViewer, type SessionInput } from "@flanksource/clicky-ui/ai"; import { CAPTAIN_SIDEBAR_COLLAPSE_KEY } from "./shell"; import { TimingBadge } from "./TimingBadge"; import type { TimingMetric } from "./serverTiming"; @@ -339,7 +339,11 @@ function SessionDetail({ // AppShell's content region is a bounded block (h-full), not a flex parent, so // `h-full` (not `flex-1`) is what gives the viewer a definite height to scroll within.
- +
); } diff --git a/pkg/cli/webapp/src/session.ts b/pkg/cli/webapp/src/session.ts index f26a7dc..af3ccbb 100644 --- a/pkg/cli/webapp/src/session.ts +++ b/pkg/cli/webapp/src/session.ts @@ -8,7 +8,7 @@ export type CommandValues = Parameters< NonNullable >[2]; -const DEFAULT_AGENT_MODEL = "claude-agent-sonnet"; +const DEFAULT_AGENT_MODEL = "claude-sonnet-5"; export function isAgentOperation(op: ResolvedOperation) { const operationId = normalizeCommandName(op.operation.operationId); @@ -49,11 +49,11 @@ export function agentModelFor(values: CommandValues) { model.includes("codex") || model.includes("gpt-5-codex") ) { - return "codex-gpt-5-codex"; + return "gpt-5.5"; } - if (model.includes("opus")) return "claude-agent-opus"; - if (model.includes("haiku")) return "claude-agent-haiku"; - if (model.startsWith("claude-agent-")) return model; + if (model.includes("opus")) return "claude-opus-4-8"; + if (model.includes("haiku")) return "claude-haiku-4-5"; + if (model.startsWith("claude-agent-")) return model.replace(/^claude-agent-/, "claude-"); return DEFAULT_AGENT_MODEL; } @@ -178,4 +178,3 @@ function normalizeCommandName(value: unknown) { function stringValue(value: unknown) { return typeof value === "string" ? value : ""; } - diff --git a/pkg/cli/webapp/src/sessionData.ts b/pkg/cli/webapp/src/sessionData.ts index 6f2dbbe..d3c101b 100644 --- a/pkg/cli/webapp/src/sessionData.ts +++ b/pkg/cli/webapp/src/sessionData.ts @@ -55,6 +55,44 @@ export type UnifiedCost = { cacheWriteCost?: number; }; +export type UnifiedBudget = { + used?: number; + total?: number; + remaining?: number; + updatedAt?: string; +}; + +export type UnifiedCapabilities = { + tools?: string[]; + pendingMcpServers?: string[]; + agents?: string[]; + skills?: string[]; +}; + +export type UnifiedMetadataEvent = { + type: string; + scope?: string; + turnId?: string; + timestamp?: string; + uuid?: string; + data?: Record; +}; + +export type UnifiedTurn = { + id: string; + index: number; + startedAt?: string; + endedAt?: string; + stopReason?: string; + model?: string; + messageIds?: string[]; + usage?: Record; + cost?: Record; + context?: SessionContext; + budget?: UnifiedBudget; + events?: UnifiedMetadataEvent[]; +}; + export type UnifiedSession = { id: string; source: "claude" | "codex"; @@ -69,6 +107,11 @@ export type UnifiedSession = { endedAt?: string; usage?: UnifiedUsage; cost?: UnifiedCost; + context?: SessionContext; + budget?: UnifiedBudget; + capabilities?: UnifiedCapabilities; + events?: UnifiedMetadataEvent[]; + turns?: UnifiedTurn[]; live?: SessionLive; messages?: SessionUIMessage[]; }; diff --git a/pkg/cli/whoami.go b/pkg/cli/whoami.go index 7a293de..5db85c1 100644 --- a/pkg/cli/whoami.go +++ b/pkg/cli/whoami.go @@ -13,7 +13,7 @@ import ( ) type WhoamiOptions struct { - Backend string `flag:"backend" help:"Show only this backend: anthropic|openai|gemini|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-cmux|gemini-cli" short:"b"` + Backend string `flag:"backend" help:"Show only this backend: anthropic|openai|gemini|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|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 after per-prefix filtering (0 = all)" default:"0" short:"l"` } @@ -33,7 +33,7 @@ type AdapterStatus struct { Models []string `json:"models,omitempty"` ModelError string `json:"modelError,omitempty"` - modelDetails []ai.ModelDef + ModelDetails []ai.ModelDef `json:"-"` } // Ready reports whether the adapter can actually run: authenticated, and (for @@ -108,6 +108,10 @@ func cliAdapters() map[ai.Backend]cliAdapter { binary: "codex", logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, }, + ai.BackendCodexAgent: { + binary: "codex", + logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, + }, ai.BackendCodexCmux: { binary: "codex", logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, @@ -223,16 +227,21 @@ type modelFetch struct { err error } -// fetchAPIModels hits each API backend's /v1/models endpoint once, concurrently. -// Only API backends are fetched: CLI/agent backends list from the static -// catalog (see applyModels) since their model menu needs no API key. An API -// backend with no key in the environment is skipped (the listing endpoint -// requires the key). +var resolveModelRows = ai.ResolveModels + +// fetchAPIModels resolves each provider's live /v1/models endpoint once, +// concurrently. The resolver is Captain's cached model path, so repeated whoami +// calls reuse a fresh cache instead of hitting providers every time. CLI/agent +// backends are mapped to their parent API provider before fetching. func fetchAPIModels(backends []ai.Backend, getenv func(string) string) map[ai.Backend]modelFetch { apis := map[ai.Backend]bool{} for _, b := range backends { - if b.Kind() == "api" && firstEnv(ai.AuthEnvVars(b), getenv) != "" { - apis[b] = true + source := modelSourceBackend(b) + if source == "" { + continue + } + if firstEnv(ai.AuthEnvVars(source), getenv) != "" { + apis[source] = true } } @@ -243,7 +252,8 @@ func fetchAPIModels(backends []ai.Backend, getenv func(string) string) map[ai.Ba wg.Add(1) go func(backend ai.Backend) { defer wg.Done() - m, err := ai.ListModels(context.Background(), backend) + rows, err := resolveModelRows(context.Background(), ai.ResolveOptions{Backend: backend, UseTokens: true}) + m := liveModelDefs(rows, backend) mu.Lock() out[backend] = modelFetch{models: m, err: err} mu.Unlock() @@ -253,22 +263,47 @@ func fetchAPIModels(backends []ai.Backend, getenv func(string) string) map[ai.Ba return out } -// applyModels fills in the model listing (or the reason it is unavailable) for a -// single adapter. CLI/agent backends are served from the static catalog without -// an API key; API backends use the pre-fetched live results. +func liveModelDefs(rows []ai.ResolvedModel, backend ai.Backend) []ai.ModelDef { + out := make([]ai.ModelDef, 0, len(rows)) + for _, row := range rows { + if !row.Live { + continue + } + id := row.RuntimeID() + if id == "" { + continue + } + name := row.Label + if name == "" { + name = id + } + out = append(out, ai.ModelDef{ID: id, Name: name, Backend: backend, ReleaseDate: row.ReleaseDate}) + } + return out +} + +// applyModels fills in the model listing (or the reason it is unavailable) for +// a single adapter. All backends use live provider model rows from Captain's +// cached resolver; CLI/agent backends map those provider rows into the runtime +// model slugs their local binaries accept. func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetch, getenv func(string) string) { - if b.Kind() == "cli" { - setModels(st, agentCatalogModels(b)) + source := modelSourceBackend(b) + if source == "" { + st.ModelError = fmt.Sprintf("backend %s has no model listing", b) return } - envVars := ai.AuthEnvVars(b) + envVars := ai.AuthEnvVars(source) if firstEnv(envVars, getenv) == "" { + if b.Kind() == "cli" { + setModels(st, ai.RegistryModelDefs(b)) + return + } st.ModelError = "set " + strings.Join(envVars, " or ") + " to list models" return } - fetch, ok := cache[b] + fetch, ok := cache[source] if !ok { return } @@ -276,7 +311,72 @@ func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetc st.ModelError = fetch.err.Error() return } - setModels(st, fetch.models) + setModels(st, modelsForAdapterBackend(b, fetch.models)) +} + +func modelSourceBackend(backend ai.Backend) ai.Backend { + switch backend { + case ai.BackendAnthropic, ai.BackendClaudeAgent, ai.BackendClaudeCLI, ai.BackendClaudeCmux: + return ai.BackendAnthropic + case ai.BackendOpenAI, ai.BackendCodexAgent, ai.BackendCodexCLI, ai.BackendCodexCmux: + return ai.BackendOpenAI + case ai.BackendGemini, ai.BackendGeminiCLI: + return ai.BackendGemini + case ai.BackendDeepSeek: + return ai.BackendDeepSeek + default: + return "" + } +} + +func modelsForAdapterBackend(backend ai.Backend, models []ai.ModelDef) []ai.ModelDef { + out := make([]ai.ModelDef, 0, len(models)) + positions := map[string]int{} + for _, model := range models { + if model.Backend == ai.BackendOpenAI && ai.IsIgnoredOpenAIModelID(model.ID) { + continue + } + id := modelIDForAdapterBackend(backend, model.ID) + if id == "" { + continue + } + name := model.Name + if name == "" { + name = id + } + next := ai.ModelDef{ID: id, Name: name, Backend: backend, ReleaseDate: model.ReleaseDate} + if idx, ok := positions[id]; ok { + if modelDefNewer(next, out[idx]) { + out[idx] = next + } + continue + } + positions[id] = len(out) + out = append(out, next) + } + return out +} + +func modelIDForAdapterBackend(backend ai.Backend, id string) string { + return ai.NormalizeModelForBackend(backend, bareProviderModelID(id)) +} + +func modelDefNewer(left, right ai.ModelDef) bool { + if left.ReleaseDate == "" { + return false + } + if right.ReleaseDate == "" { + return true + } + return left.ReleaseDate > right.ReleaseDate +} + +func bareProviderModelID(id string) string { + id = strings.TrimSpace(id) + if i := strings.LastIndex(id, "/"); i >= 0 { + return id[i+1:] + } + return id } // setModels filters legacy entries and copies the sorted model list onto the @@ -290,5 +390,5 @@ func setModels(st *AdapterStatus, models []ai.ModelDef) { ids = append(ids, m.ID) } st.Models = ids - st.modelDetails = models + st.ModelDetails = models } diff --git a/pkg/cli/whoami_render.go b/pkg/cli/whoami_render.go index 9487344..7cb4e30 100644 --- a/pkg/cli/whoami_render.go +++ b/pkg/cli/whoami_render.go @@ -104,7 +104,7 @@ func (a AdapterStatus) appendModels(t api.Text, limit int) api.Text { t = t.Append(" ", ""). Append(fmt.Sprintf("%d models", a.ModelCount), "text-blue-600 font-medium") - sample := a.modelDetails + sample := a.ModelDetails if len(sample) == 0 && len(a.Models) > 0 { sample = make([]ai.ModelDef, 0, len(a.Models)) for _, id := range a.Models { diff --git a/pkg/cli/whoami_test.go b/pkg/cli/whoami_test.go index 4c281f8..f465d63 100644 --- a/pkg/cli/whoami_test.go +++ b/pkg/cli/whoami_test.go @@ -1,6 +1,7 @@ package cli import ( + "context" "os" "path/filepath" "strings" @@ -150,6 +151,92 @@ func TestResolveAdapter_EnvKeyPreferredOverLogin(t *testing.T) { } } +func TestProbeAdaptersUsesLiveProviderModelsForClaudeCmux(t *testing.T) { + prev := resolveModelRows + resolveModelRows = func(_ context.Context, opts ai.ResolveOptions) ([]ai.ResolvedModel, error) { + if opts.Backend != ai.BackendAnthropic || !opts.UseTokens { + t.Fatalf("resolve opts = %+v, want anthropic live token resolve", opts) + } + return []ai.ResolvedModel{ + {Model: ai.Model{ID: "anthropic/claude-sonnet-5", Backend: ai.BackendAnthropic, Label: "Claude Sonnet 5", ReleaseDate: "2026-05-20"}, Live: true}, + {Model: ai.Model{ID: "claude-sonnet-4-6", Backend: ai.BackendAnthropic, Label: "Claude Sonnet 4.6", ReleaseDate: "2026-05-01"}, Live: true}, + {Model: ai.Model{ID: "claude-opus-4-8", Backend: ai.BackendAnthropic, Label: "Claude Opus 4.8", ReleaseDate: "2026-04-15"}, Live: true}, + {Model: ai.Model{ID: "anthropic/claude-sonnet-static", Backend: ai.BackendAnthropic, Label: "Static Sonnet"}, Live: false}, + }, nil + } + t.Cleanup(func() { resolveModelRows = prev }) + + adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendClaudeCmux), Models: true}, fakeProbe( + map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test"}, + map[string]string{"claude": "/usr/local/bin/claude"}, + nil, + "/home/u", + )) + if err != nil { + t.Fatalf("ProbeAdapters: %v", err) + } + if len(adapters) != 1 { + t.Fatalf("adapter count = %d, want 1", len(adapters)) + } + got := adapters[0].Models + for _, want := range []string{"claude-sonnet-5", "claude-sonnet-4-6", "claude-opus-4-8"} { + if !stringSliceContains(got, want) { + t.Fatalf("models = %v, want exact runtime model %q from live provider model list", got, want) + } + } + for _, rejected := range []string{"sonnet-5", "sonnet-4-6", "opus-4-8", "claude-agent-sonnet", "claude-agent-opus", "claude-sonnet-static"} { + if stringSliceContains(got, rejected) { + t.Fatalf("models = %v, should not include alias/static id %q after adapter normalization", got, rejected) + } + } +} + +func TestProbeAdaptersFiltersNoisyOpenAIModelsForCodexCmux(t *testing.T) { + prev := resolveModelRows + resolveModelRows = func(_ context.Context, opts ai.ResolveOptions) ([]ai.ResolvedModel, error) { + if opts.Backend != ai.BackendOpenAI || !opts.UseTokens { + t.Fatalf("resolve opts = %+v, want openai live token resolve", opts) + } + return []ai.ResolvedModel{ + {Model: ai.Model{ID: "openai/gpt-5.5", Backend: ai.BackendOpenAI, Label: "GPT-5.5", ReleaseDate: "2026-06-01"}, Live: true}, + {Model: ai.Model{ID: "openai/gpt-5.4", Backend: ai.BackendOpenAI, Label: "GPT-5.4", ReleaseDate: "2026-05-15"}, Live: true}, + {Model: ai.Model{ID: "openai/gpt-realtime-2.1", Backend: ai.BackendOpenAI, Label: "Realtime"}, Live: true}, + {Model: ai.Model{ID: "openai/gpt-image-2", Backend: ai.BackendOpenAI, Label: "Image"}, Live: true}, + {Model: ai.Model{ID: "openai/gpt-audio-1.5", Backend: ai.BackendOpenAI, Label: "Audio"}, Live: true}, + {Model: ai.Model{ID: "openai/gpt-5.3-codex", Backend: ai.BackendOpenAI, Label: "Codex"}, Live: true}, + {Model: ai.Model{ID: "openai/gpt-5.3-chat-latest", Backend: ai.BackendOpenAI, Label: "Chat latest"}, Live: true}, + {Model: ai.Model{ID: "openai/gpt-5.5-pro", Backend: ai.BackendOpenAI, Label: "Pro"}, Live: true}, + {Model: ai.Model{ID: "openai/o4-mini", Backend: ai.BackendOpenAI, Label: "O4 mini"}, Live: true}, + {Model: ai.Model{ID: "openai/sora-2", Backend: ai.BackendOpenAI, Label: "Sora"}, Live: true}, + }, nil + } + t.Cleanup(func() { resolveModelRows = prev }) + + adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendCodexCmux), Models: true}, fakeProbe( + map[string]string{"OPENAI_API_KEY": "sk-test"}, + map[string]string{"codex": "/usr/local/bin/codex"}, + nil, + "/home/u", + )) + if err != nil { + t.Fatalf("ProbeAdapters: %v", err) + } + if len(adapters) != 1 { + t.Fatalf("adapter count = %d, want 1", len(adapters)) + } + got := adapters[0].Models + for _, want := range []string{"gpt-5.5", "gpt-5.4"} { + if !stringSliceContains(got, want) { + t.Fatalf("models = %v, want primary OpenAI model %q", got, want) + } + } + for _, hidden := range []string{"gpt-realtime-2.1", "gpt-image-2", "gpt-audio-1.5", "gpt-5.3-codex", "gpt-5.3-chat-latest", "gpt-5.5-pro", "o4-mini", "sora-2"} { + if stringSliceContains(got, hidden) { + t.Fatalf("models = %v, should hide noisy OpenAI model %q", got, hidden) + } + } +} + func TestWhoamiPrettyKeylessCmuxDoesNotRequestAPIKey(t *testing.T) { got := (WhoamiResult{ Adapters: []AdapterStatus{{ @@ -236,14 +323,14 @@ func TestSetModelsKeepsGeminiProFamily(t *testing.T) { } } -func TestSetModelsKeepsCurrentCLIModelSlugs(t *testing.T) { +func TestSetModelsHidesCodexCodeVariantsForCLI(t *testing.T) { st := AdapterStatus{Backend: string(ai.BackendCodexCLI)} setModels(&st, []ai.ModelDef{ {ID: "gpt-5-codex", Backend: ai.BackendCodexCLI, ReleaseDate: "2025-08-07"}, }) - if st.ModelCount != 1 || len(st.Models) != 1 || st.Models[0] != "gpt-5-codex" { - t.Fatalf("codex CLI model should not be blacklisted: %+v", st) + if st.ModelCount != 0 || len(st.Models) != 0 { + t.Fatalf("codex code variant should be hidden for CLI: %+v", st) } } @@ -255,7 +342,7 @@ func TestWhoamiPrettyRendersModelListItems(t *testing.T) { AuthMethod: "OPENAI_API_KEY (env)", ModelCount: 6, Models: []string{"m6", "m5", "m4", "m3", "m2", "m1"}, - modelDetails: []ai.ModelDef{ + ModelDetails: []ai.ModelDef{ {ID: "m6", ReleaseDate: "2026-06-01"}, {ID: "m5", ReleaseDate: "2026-05-01"}, {ID: "m4", ReleaseDate: "2026-04-01"}, @@ -288,3 +375,12 @@ func TestWhoamiPrettyRendersModelListItems(t *testing.T) { t.Errorf("Pretty() ignored sample limit: %q", got) } } + +func stringSliceContains(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} From 53e8116eab10147f52352aebc24bb88ff25154e7 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 10:19:03 +0300 Subject: [PATCH 006/131] chore(docs): remove unused dependencies and add pnpm workspace config Remove dependencies no longer used in docs. Add workspace configuration for pnpm with minimum release age and trust policy. Rename 'defaultMode' to 'defaultPermission' in runtime spec demo for consistency. --- .gitignore | 1 + docs/package.json | 8 -------- docs/pnpm-workspace.yaml | 5 +++++ docs/src/components/RuntimeSpecDemo.tsx | 10 ++++------ 4 files changed, 10 insertions(+), 14 deletions(-) create mode 100644 docs/pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index c49149d..58baf9e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ pkg/cli/webapp/dist/* !pkg/cli/webapp/dist/index.html .grite/ hack/ +dist/ diff --git a/docs/package.json b/docs/package.json index 96850da..88ae299 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,10 +13,7 @@ "@astrojs/mdx": "^7.0.2", "@astrojs/react": "^6.0.1", "@flanksource/clicky-ui": "link:../../clicky-ui/packages/ui", - "@flanksource/icons": "^1.0.60", "@mdxeditor/editor": "^4.0.4", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-slot": "^1.1.1", "@shikijs/langs": "^1.24.0", "@shikijs/themes": "^1.24.0", "@shikijs/transformers": "^1.24.0", @@ -24,10 +21,6 @@ "@tanstack/react-query": "^5.90.12", "ai": "^6.0.199", "astro": "^7.1.3", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dompurify": "^3.4.7", - "jotai": "^2.16.0", "marked": "^17.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -35,7 +28,6 @@ "recharts": "^3.8.1", "shiki": "^1.24.0", "streamdown": "^2.5.0", - "tailwind-merge": "^2.6.0", "tailwindcss": "^4.3.2" }, "devDependencies": { diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 0000000..2245205 --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . + +minimumReleaseAge: 10080 +trustPolicy: no-downgrade diff --git a/docs/src/components/RuntimeSpecDemo.tsx b/docs/src/components/RuntimeSpecDemo.tsx index 2f42831..7b3803e 100644 --- a/docs/src/components/RuntimeSpecDemo.tsx +++ b/docs/src/components/RuntimeSpecDemo.tsx @@ -8,28 +8,28 @@ const tools: ToolMeta[] = [ name: "Read", label: "Read", group: "Files", - defaultMode: "ask", + defaultPermission: "ask", description: "Read files from the workspace.", }, { name: "Edit", label: "Edit", group: "Files", - defaultMode: "ask", + defaultPermission: "ask", description: "Apply targeted edits.", }, { name: "Bash", label: "Bash", group: "Shell", - defaultMode: "ask", + defaultPermission: "ask", description: "Run shell commands.", }, { name: "WebSearch", label: "Web search", group: "Web", - defaultMode: "disabled", + defaultPermission: "off", description: "Search external documentation.", }, ]; @@ -48,7 +48,6 @@ const initialValue: AISpecRuntimeValue = { prompt: { system: "You are a careful refactoring assistant.", user: "Refactor: {{target}}", - source: "options.prompt", }, permissions: { mode: "acceptEdits", @@ -96,4 +95,3 @@ export default function RuntimeSpecDemo() { ); } - From 711d682ac650076d42e614ddea6825defc0f8b9a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 10:19:09 +0300 Subject: [PATCH 007/131] feat(history): add TurnID, tool_search, world_state, and enhanced token tracking Introduce TurnID propagation across Codex events and tool uses for turn-level grouping. Parse new event types (world_state, tool_search) and convert to tool uses. Extend token usage with reasoning tokens, cumulative totals, and context window. BuildCodex now constructs turn structure, aggregates costs, and captures capabilities (agents, tools, MCP servers). Also includes model registry adjustments and normalization fixes. --- pkg/ai/history/codex_parser.go | 260 +++++++++++++++++++- pkg/ai/history/codex_types.go | 38 ++- pkg/ai/history/types.go | 10 + pkg/ai/internal/gen-model-registry/main.go | 145 +++++------ pkg/ai/middleware/logging.go | 4 +- pkg/ai/provider/genkit/genkit.go | 4 +- pkg/ai/provider/genkit/genkit_test.go | 11 + pkg/ai/provider/genkit/options.go | 8 +- pkg/ai/runtime_selector.go | 75 ------ pkg/session/build_codex.go | 272 ++++++++++++++++++++- 10 files changed, 643 insertions(+), 184 deletions(-) diff --git a/pkg/ai/history/codex_parser.go b/pkg/ai/history/codex_parser.go index f5a8dc2..02184db 100644 --- a/pkg/ai/history/codex_parser.go +++ b/pkg/ai/history/codex_parser.go @@ -163,6 +163,7 @@ func ExtractCodexToolUsesFromReader(r io.Reader) ([]ToolUse, error) { toolUses []ToolUse sessionCWD string sessionID string + currentTurn string currentModel string currentEff string pendingCall = make(map[string]CodexEvent) @@ -170,6 +171,9 @@ func ExtractCodexToolUsesFromReader(r io.Reader) ([]ToolUse, error) { stamp := func(uses []ToolUse) []ToolUse { for i := range uses { + if uses[i].TurnID == "" { + uses[i].TurnID = currentTurn + } if uses[i].Model == "" { uses[i].Model = currentModel } @@ -198,6 +202,9 @@ func ExtractCodexToolUsesFromReader(r io.Reader) ([]ToolUse, error) { sessionID = event.Payload.ID case "turn_context": + if event.Payload.TurnID != "" { + currentTurn = event.Payload.TurnID + } if event.Payload.Model != "" { currentModel = event.Payload.Model } @@ -209,8 +216,14 @@ func ExtractCodexToolUsesFromReader(r io.Reader) ([]ToolUse, error) { toolUses = append(toolUses, stamp(extractResponseItem(event, pendingCall, sessionCWD, sessionID))...) case "event_msg": + if event.Payload.TurnID != "" { + currentTurn = event.Payload.TurnID + } toolUses = append(toolUses, stamp(extractEventMsg(event, sessionCWD, sessionID))...) + case "world_state": + toolUses = append(toolUses, stamp([]ToolUse{buildCodexTopLevelEventUse(event, "world_state", sessionCWD, sessionID)})...) + // --- Newer dotted-name `codex exec --json` schema --- case "thread.started": if event.ThreadID != "" { @@ -245,6 +258,10 @@ func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cw pendingCall[event.Payload.CallID] = event return nil + case "tool_search_call": + pendingCall[event.Payload.CallID] = event + return nil + case "function_call_output": callEvent, ok := pendingCall[event.Payload.CallID] if !ok { @@ -253,6 +270,13 @@ func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cw delete(pendingCall, event.Payload.CallID) return []ToolUse{buildToolUse(callEvent, event, cwd, sessionID)} + case "tool_search_output": + callEvent, ok := pendingCall[event.Payload.CallID] + if ok { + delete(pendingCall, event.Payload.CallID) + } + return buildToolSearchUses(callEvent, event, cwd, sessionID) + case "reasoning": var summaries []CodexReasoningSummary if len(event.Payload.Summary) > 0 { @@ -273,6 +297,7 @@ func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cw Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", RecordType: "response_item.reasoning", }} @@ -302,6 +327,7 @@ func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cw Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", RecordType: "response_item.message", }} @@ -332,6 +358,7 @@ func extractLiveItemCompleted(event CodexEvent, cwd, sessionID string) []ToolUse Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", RecordType: "item.completed", }} @@ -358,6 +385,7 @@ func extractLiveError(event CodexEvent, cwd, sessionID string) []ToolUse { Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", }} } @@ -396,6 +424,7 @@ func extractEventMsg(event CodexEvent, cwd, sessionID string) []ToolUse { Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", RecordType: "event_msg.agent_reasoning", }} @@ -410,6 +439,7 @@ func extractEventMsg(event CodexEvent, cwd, sessionID string) []ToolUse { Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", RecordType: "event_msg.agent_message", }} @@ -428,6 +458,7 @@ func extractEventMsg(event CodexEvent, cwd, sessionID string) []ToolUse { Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", RecordType: "event_msg.user_message", }} @@ -438,6 +469,55 @@ func extractEventMsg(event CodexEvent, cwd, sessionID string) []ToolUse { return []ToolUse{buildCodexEventUse(event, cwd, sessionID)} } +func buildToolSearchUses(callEvent, outputEvent CodexEvent, cwd, sessionID string) []ToolUse { + names := codexToolSearchNames(outputEvent.Payload.Tools) + if len(names) == 0 { + return nil + } + input := map[string]any{ + "event": "deferred_tools_delta", + "addedNames": names, + } + if len(callEvent.Payload.Arguments) > 0 { + for k, v := range extractArgumentsMap(callEvent.Payload.Arguments) { + input[k] = v + } + } + return []ToolUse{{ + Tool: "DeferredToolsDelta", + Input: input, + Timestamp: outputEvent.Time(), + CWD: cwd, + SessionID: sessionID, + TurnID: firstNonEmpty(codexEventTurnID(outputEvent), codexEventTurnID(callEvent)), + ToolUseID: outputEvent.Payload.CallID, + Source: "codex", + RecordType: "response_item.tool_search_output", + }} +} + +func codexToolSearchNames(groups []CodexToolSearchNamespace) []string { + var names []string + seen := map[string]struct{}{} + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + names = append(names, name) + } + for _, group := range groups { + for _, tool := range group.Tools { + add(tool.Name) + } + } + return names +} + func codexContentText(content []CodexContent, accepted ...string) string { accept := make(map[string]struct{}, len(accepted)) for _, typ := range accepted { @@ -460,6 +540,25 @@ func isCodexInternalUserText(text string) bool { strings.HasPrefix(text, "") } +func codexEventTurnID(event CodexEvent) string { + if event.Payload.TurnID != "" { + return event.Payload.TurnID + } + if event.Payload.Metadata != nil { + return event.Payload.Metadata.TurnID + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func buildCodexEventUse(event CodexEvent, cwd, sessionID string) ToolUse { input := make(map[string]any, len(event.Payload.Raw)+1) for k, v := range event.Payload.Raw { @@ -501,19 +600,76 @@ func buildCodexEventUse(event CodexEvent, cwd, sessionID string) ToolUse { input["input_tokens"] = event.Payload.Info.LastTokenUsage.InputTokens input["output_tokens"] = event.Payload.Info.LastTokenUsage.OutputTokens input["cached_input_tokens"] = event.Payload.Info.LastTokenUsage.CachedInputTokens + input["cumulative_total_tokens"] = event.Payload.Info.TotalTokenUsage.TotalTokens + input["cumulative_input_tokens"] = event.Payload.Info.TotalTokenUsage.InputTokens + input["cumulative_output_tokens"] = event.Payload.Info.TotalTokenUsage.OutputTokens + input["cumulative_cached_input_tokens"] = event.Payload.Info.TotalTokenUsage.CachedInputTokens + if event.Payload.Info.LastTokenUsage.ReasoningOutputTokens != 0 { + input["reasoning_output_tokens"] = event.Payload.Info.LastTokenUsage.ReasoningOutputTokens + } if event.Payload.Info.ModelContextWindow != 0 { input["model_context_window"] = event.Payload.Info.ModelContextWindow } } + usage := eventTokenUsage(event) + return ToolUse{ + Tool: tools.EventToolName(event.Payload.Type), + Input: input, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + TurnID: codexEventTurnID(event), + Source: "codex", + InputTokens: codexNonCachedInputTokens(usage), + OutputTokens: usage.OutputTokens, + CacheReadTokens: usage.CachedInputTokens, + TotalTokens: usage.TotalTokens, + ContextWindow: eventContextWindow(event), + RecordType: "event_msg." + event.Payload.Type, + } +} + +func buildCodexTopLevelEventUse(event CodexEvent, eventType, cwd, sessionID string) ToolUse { + input := make(map[string]any, len(event.Payload.Raw)+1) + for k, v := range event.Payload.Raw { + input[k] = v + } + input["event"] = eventType return ToolUse{ - Tool: tools.EventToolName(event.Payload.Type), + Tool: tools.EventToolName(eventType), Input: input, Timestamp: event.Time(), CWD: cwd, SessionID: sessionID, + TurnID: codexEventTurnID(event), Source: "codex", - RecordType: "event_msg." + event.Payload.Type, + RecordType: event.Type, + } +} + +func eventTokenUsage(event CodexEvent) CodexTokenUsage { + if event.Payload.Info == nil { + return CodexTokenUsage{} + } + return event.Payload.Info.LastTokenUsage +} + +func eventContextWindow(event CodexEvent) int { + if event.Payload.Info == nil { + return 0 + } + return event.Payload.Info.ModelContextWindow +} + +func codexNonCachedInputTokens(usage CodexTokenUsage) int { + if usage.CachedInputTokens <= 0 { + return usage.InputTokens + } + input := usage.InputTokens - usage.CachedInputTokens + if input < 0 { + return usage.InputTokens } + return input } func dedupeCodexToolUses(uses []ToolUse) []ToolUse { @@ -598,9 +754,11 @@ func buildToolUse(callEvent, outputEvent CodexEvent, cwd, sessionID string) Tool Timestamp: ts, CWD: cwd, SessionID: sessionID, + TurnID: firstNonEmpty(codexEventTurnID(callEvent), codexEventTurnID(outputEvent)), ToolUseID: callEvent.Payload.CallID, Source: "codex", Response: response, + Namespace: callEvent.Payload.Namespace, } case "request_user_input": return ToolUse{ @@ -609,10 +767,52 @@ func buildToolUse(callEvent, outputEvent CodexEvent, cwd, sessionID string) Tool Timestamp: ts, CWD: cwd, SessionID: sessionID, + TurnID: firstNonEmpty(codexEventTurnID(callEvent), codexEventTurnID(outputEvent)), ToolUseID: callEvent.Payload.CallID, Source: "codex", Response: response, + Namespace: callEvent.Payload.Namespace, } + case "spawn_agent": + args := extractArgumentsMap(callEvent.Payload.Arguments) + agentType, _ := args["agent_type"].(string) + desc, _ := args["message"].(string) + input := map[string]any{} + for k, v := range args { + input[k] = v + } + input["subagent_type"] = agentType + input["description"] = desc + input["prompt"] = desc + agentID, nickname := codexAgentOutput(response) + if agentID != "" { + input["agent_id"] = agentID + } + if nickname != "" { + input["nickname"] = nickname + } + return ToolUse{ + Tool: "Agent", + Input: input, + Timestamp: ts, + CWD: cwd, + SessionID: sessionID, + TurnID: firstNonEmpty(codexEventTurnID(callEvent), codexEventTurnID(outputEvent)), + ToolUseID: callEvent.Payload.CallID, + Source: "codex", + Response: response, + Namespace: callEvent.Payload.Namespace, + AgentID: agentID, + AgentType: agentType, + AgentDesc: desc, + RecordType: "response_item.function_call", + } + case "wait_agent": + return codexNamedCallUse("CollabWaiting", callEvent, outputEvent, ts, cwd, sessionID, response) + case "close_agent": + return codexNamedCallUse("CollabClose", callEvent, outputEvent, ts, cwd, sessionID, response) + case "send_input", "resume_agent": + return codexNamedCallUse("CollabAgentInteraction", callEvent, outputEvent, ts, cwd, sessionID, response) } input := map[string]any{ @@ -624,31 +824,75 @@ func buildToolUse(callEvent, outputEvent CodexEvent, cwd, sessionID string) Tool Timestamp: ts, CWD: cwd, SessionID: sessionID, + TurnID: firstNonEmpty(codexEventTurnID(callEvent), codexEventTurnID(outputEvent)), ToolUseID: callEvent.Payload.CallID, Source: "codex", Response: response, + Namespace: callEvent.Payload.Namespace, + } +} + +func codexNamedCallUse(tool string, callEvent, outputEvent CodexEvent, ts *time.Time, cwd, sessionID, response string) ToolUse { + input := extractArgumentsMap(callEvent.Payload.Arguments) + input["event"] = callEvent.Payload.Name + return ToolUse{ + Tool: tool, + Input: input, + Timestamp: ts, + CWD: cwd, + SessionID: sessionID, + TurnID: firstNonEmpty(codexEventTurnID(callEvent), codexEventTurnID(outputEvent)), + ToolUseID: callEvent.Payload.CallID, + Source: "codex", + Response: response, + Namespace: callEvent.Payload.Namespace, + RecordType: "response_item.function_call", } } -func extractArgumentsMap(argsJSON string) map[string]any { +func codexAgentOutput(response string) (agentID, nickname string) { + var payload struct { + AgentID string `json:"agent_id"` + Nickname string `json:"nickname"` + } + if json.Unmarshal([]byte(strings.TrimSpace(response)), &payload) == nil { + return payload.AgentID, payload.Nickname + } + return "", "" +} + +func extractArgumentsMap(argsJSON json.RawMessage) map[string]any { + raw := normalizeCodexArguments(argsJSON) var args map[string]any - if argsJSON == "" || json.Unmarshal([]byte(argsJSON), &args) != nil { + if raw == "" || json.Unmarshal([]byte(raw), &args) != nil { return map[string]any{} } return args } -func extractCommand(argsJSON string) string { - if argsJSON == "" { +func extractCommand(argsJSON json.RawMessage) string { + raw := normalizeCodexArguments(argsJSON) + if raw == "" { return "" } var args struct { Cmd string `json:"cmd"` } - if json.Unmarshal([]byte(argsJSON), &args) == nil && args.Cmd != "" { + if json.Unmarshal([]byte(raw), &args) == nil && args.Cmd != "" { return args.Cmd } - return argsJSON + return raw +} + +func normalizeCodexArguments(argsJSON json.RawMessage) string { + if len(argsJSON) == 0 || string(argsJSON) == "null" { + return "" + } + var s string + if json.Unmarshal(argsJSON, &s) == nil { + return s + } + return string(argsJSON) } func extractCommandOutput(raw string) string { diff --git a/pkg/ai/history/codex_types.go b/pkg/ai/history/codex_types.go index 70109bc..590d463 100644 --- a/pkg/ai/history/codex_types.go +++ b/pkg/ai/history/codex_types.go @@ -64,12 +64,15 @@ type CodexPayload struct { Git *CodexGitMeta `json:"git,omitempty"` // response_item: function_call - Name string `json:"name,omitempty"` - Arguments string `json:"arguments,omitempty"` - CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + CallID string `json:"call_id,omitempty"` + Metadata *CodexMetadata `json:"internal_chat_message_metadata_passthrough,omitempty"` // response_item: function_call_output - Output string `json:"output,omitempty"` + Output string `json:"output,omitempty"` + Tools []CodexToolSearchNamespace `json:"tools,omitempty"` // response_item: reasoning carries an array of CodexReasoningSummary; // turn_context carries a plain string ("none", "auto", etc.). Use a @@ -125,6 +128,24 @@ type CodexGitMeta struct { RepositoryURL string `json:"repository_url,omitempty"` } +type CodexMetadata struct { + TurnID string `json:"turn_id,omitempty"` +} + +type CodexToolSearchNamespace struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Tools []CodexToolSearchEntry `json:"tools,omitempty"` +} + +type CodexToolSearchEntry struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + type CodexReasoningSummary struct { Type string `json:"type"` Text string `json:"text"` @@ -142,8 +163,9 @@ type CodexTokenInfo struct { } type CodexTokenUsage struct { - InputTokens int `json:"input_tokens"` - CachedInputTokens int `json:"cached_input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` + InputTokens int `json:"input_tokens"` + CachedInputTokens int `json:"cached_input_tokens"` + OutputTokens int `json:"output_tokens"` + ReasoningOutputTokens int `json:"reasoning_output_tokens"` + TotalTokens int `json:"total_tokens"` } diff --git a/pkg/ai/history/types.go b/pkg/ai/history/types.go index 8725ceb..6a19612 100644 --- a/pkg/ai/history/types.go +++ b/pkg/ai/history/types.go @@ -8,10 +8,20 @@ type ToolUse struct { Timestamp *time.Time `json:"timestamp,omitempty"` CWD string `json:"cwd,omitempty"` SessionID string `json:"session_id,omitempty"` + TurnID string `json:"turn_id,omitempty"` ToolUseID string `json:"tool_use_id,omitempty"` Source string `json:"source,omitempty"` // "claude" or "codex" Model string `json:"model,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` + Namespace string `json:"namespace,omitempty"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + AgentID string `json:"agent_id,omitempty"` + AgentType string `json:"agent_type,omitempty"` + AgentDesc string `json:"agent_desc,omitempty"` Response string `json:"response,omitempty"` RecordType string `json:"-"` } diff --git a/pkg/ai/internal/gen-model-registry/main.go b/pkg/ai/internal/gen-model-registry/main.go index 1badd36..f21941d 100644 --- a/pkg/ai/internal/gen-model-registry/main.go +++ b/pkg/ai/internal/gen-model-registry/main.go @@ -1,11 +1,9 @@ package main import ( - "bytes" "encoding/json" "flag" "fmt" - "go/format" "io" "net/http" "os" @@ -41,16 +39,19 @@ type modelsDevLimit struct { Context int `json:"context"` } +// generatedModel mirrors pkg/ai.registryModel field-for-field (and its JSON +// tags), so the emitted JSON round-trips into the registry on load. type generatedModel struct { - ID string - Provider string - Family string - Version string - Label string - ReleaseDate string - Reasoning bool - ContextWindow int - Preferred bool + ID string `json:"id"` + Provider string `json:"provider"` + Family string `json:"family"` + Version string `json:"version"` + Label string `json:"label"` + ReleaseDate string `json:"releaseDate,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + ContextWindow int `json:"contextWindow,omitempty"` + Preferred bool `json:"preferred,omitempty"` + AdaptiveThinking bool `json:"adaptiveThinking,omitempty"` } func main() { @@ -63,8 +64,10 @@ func main() { func run() error { var output string var source string - flag.StringVar(&output, "output", "", "Path to write pkg/ai/model_registry_static.go") + var patchPath string + flag.StringVar(&output, "output", "", "Path to write pkg/ai/model_registry.json") flag.StringVar(&source, "source", modelsDevAPIURL, "models.dev API URL, local file path, or - for stdin") + flag.StringVar(&patchPath, "patches", "", "Path to the per-model JSON merge-patch file") flag.Parse() if output == "" { return fmt.Errorf("--output is required") @@ -74,11 +77,15 @@ func run() error { if err != nil { return err } - models, err := generateModels(data) + patches, err := readPatches(patchPath) if err != nil { return err } - src, err := renderRegistry(models) + models, err := generateModels(data, patches) + if err != nil { + return err + } + src, err := renderJSON(models) if err != nil { return err } @@ -93,6 +100,24 @@ func run() error { return nil } +// readPatches loads the per-model merge-patch file: a JSON object keyed by model +// ID whose values carry only the fields to override on the fetched row (or a +// full row for models not present in the models.dev catalog). +func readPatches(path string) (map[string]json.RawMessage, error) { + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read patches %s: %w", path, err) + } + var patches map[string]json.RawMessage + if err := json.Unmarshal(data, &patches); err != nil { + return nil, fmt.Errorf("parse patches %s: %w", path, err) + } + return patches, nil +} + func readSource(source string) ([]byte, error) { switch { case source == "-": @@ -135,7 +160,7 @@ func readURL(source string) ([]byte, error) { return data, nil } -func generateModels(data []byte) ([]generatedModel, error) { +func generateModels(data []byte, patches map[string]json.RawMessage) ([]generatedModel, error) { var providers map[string]modelsDevProvider if err := json.Unmarshal(data, &providers); err != nil { return nil, fmt.Errorf("parse models.dev catalog: %w", err) @@ -164,8 +189,8 @@ func generateModels(data []byte) ([]generatedModel, error) { } } } - for _, row := range localRegistryOverrides() { - out[row.ID] = row + if err := applyPatches(out, patches); err != nil { + return nil, err } models := make([]generatedModel, 0, len(out)) @@ -278,52 +303,38 @@ func hasTrailingDateID(id string) bool { return true } -func renderRegistry(models []generatedModel) ([]byte, error) { - var buf bytes.Buffer - buf.WriteString("// Code generated by pkg/ai/internal/gen-model-registry; DO NOT EDIT.\n\n") - buf.WriteString("package ai\n\n") - buf.WriteString("var exactModelRegistry = []registryModel{\n") - for i, model := range models { - if i > 0 && models[i-1].Provider != model.Provider { - buf.WriteByte('\n') - } - fmt.Fprintf(&buf, "\t{ID: %q, Provider: %s, Family: %q, Version: %q, Label: %q", - model.ID, providerConst(model.Provider), model.Family, model.Version, model.Label) - if model.ReleaseDate != "" { - fmt.Fprintf(&buf, ", ReleaseDate: %q", model.ReleaseDate) +// applyPatches overlays the per-model merge patches onto the fetched catalog. +// Each patch overwrites only the fields it names; a patch whose ID is absent +// from the catalog is treated as a new full entry and must name provider, +// family, and label. +func applyPatches(out map[string]generatedModel, patches map[string]json.RawMessage) error { + ids := make([]string, 0, len(patches)) + for id := range patches { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + m, existed := out[id] + if m.ID == "" { + m.ID = id } - if model.Reasoning { - buf.WriteString(", Reasoning: true") + if err := json.Unmarshal(patches[id], &m); err != nil { + return fmt.Errorf("patch %q: %w", id, err) } - if model.ContextWindow > 0 { - fmt.Fprintf(&buf, ", ContextWindow: %d", model.ContextWindow) + if !existed && (m.Provider == "" || m.Family == "" || m.Label == "") { + return fmt.Errorf("patch %q adds a new model but is missing provider, family, or label", id) } - if model.Preferred { - buf.WriteString(", Preferred: true") - } - buf.WriteString("},\n") - } - buf.WriteString("}\n") - src, err := format.Source(buf.Bytes()) - if err != nil { - return nil, err + out[id] = m } - return src, nil + return nil } -func providerConst(provider string) string { - switch provider { - case "anthropic": - return "modelProviderAnthropic" - case "openai": - return "modelProviderOpenAI" - case "google": - return "modelProviderGoogle" - case "deepseek": - return "modelProviderDeepSeek" - default: - panic("unhandled provider " + provider) +func renderJSON(models []generatedModel) ([]byte, error) { + data, err := json.MarshalIndent(models, "", " ") + if err != nil { + return nil, err } + return append(data, '\n'), nil } func providerRank(provider string) int { @@ -355,25 +366,3 @@ func titleModelID(id string) string { } return strings.Join(parts, " ") } - -func localRegistryOverrides() []generatedModel { - return []generatedModel{ - {ID: "claude-sonnet-5", Provider: "anthropic", Family: "sonnet", Version: "5", Label: "Claude Sonnet 5", ReleaseDate: "2026-06-29", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "claude-fable-5", Provider: "anthropic", Family: "fable", Version: "5", Label: "Claude Fable 5", ReleaseDate: "2026-06-07", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "claude-opus-4-8", Provider: "anthropic", Family: "opus", Version: "4.8", Label: "Claude Opus 4.8", ReleaseDate: "2026-05-28", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "claude-opus-4-7", Provider: "anthropic", Family: "opus", Version: "4.7", Label: "Claude Opus 4.7", ReleaseDate: "2026-04-14", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "claude-sonnet-4-6", Provider: "anthropic", Family: "sonnet", Version: "4.6", Label: "Claude Sonnet 4.6", ReleaseDate: "2026-02-17", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "claude-haiku-4-5", Provider: "anthropic", Family: "haiku", Version: "4.5", Label: "Claude Haiku 4.5", ReleaseDate: "2025-10-15", Reasoning: true, ContextWindow: 200000, Preferred: true}, - {ID: "claude-haiku-4-5-20251001", Provider: "anthropic", Family: "haiku", Version: "4.5-20251001", Label: "Claude Haiku 4.5", ReleaseDate: "2025-10-15", Reasoning: true, ContextWindow: 200000}, - {ID: "gpt-5.5", Provider: "openai", Family: "gpt", Version: "5.5", Label: "GPT-5.5", ReleaseDate: "2026-04-23", Reasoning: true, ContextWindow: 1050000, Preferred: true}, - {ID: "gpt-5.4", Provider: "openai", Family: "gpt", Version: "5.4", Label: "GPT-5.4", ReleaseDate: "2026-03-05", Reasoning: true, ContextWindow: 1050000, Preferred: true}, - {ID: "gpt-5", Provider: "openai", Family: "gpt", Version: "5", Label: "GPT-5", ReleaseDate: "2025-08-07", Reasoning: true, ContextWindow: 400000, Preferred: true}, - {ID: "gemini-3.5-flash", Provider: "google", Family: "gemini", Version: "3.5-flash", Label: "Gemini 3.5 Flash", ReleaseDate: "2026-05-19", Reasoning: true, ContextWindow: 1048576, Preferred: true}, - {ID: "gemini-2.5-pro", Provider: "google", Family: "gemini", Version: "2.5-pro", Label: "Gemini 2.5 Pro", ReleaseDate: "2025-06-17", Reasoning: true, ContextWindow: 1048576, Preferred: true}, - {ID: "gemini-2.5-flash", Provider: "google", Family: "gemini", Version: "2.5-flash", Label: "Gemini 2.5 Flash", ReleaseDate: "2025-06-17", Reasoning: true, ContextWindow: 1048576, Preferred: true}, - {ID: "deepseek-v4-pro", Provider: "deepseek", Family: "deepseek", Version: "v4-pro", Label: "DeepSeek V4 Pro", ReleaseDate: "2026-04-24", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "deepseek-v4-flash", Provider: "deepseek", Family: "deepseek", Version: "v4-flash", Label: "DeepSeek V4 Flash", ReleaseDate: "2026-04-24", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "deepseek-reasoner", Provider: "deepseek", Family: "deepseek", Version: "reasoner", Label: "DeepSeek Reasoner", ReleaseDate: "2025-12-01", Reasoning: true, ContextWindow: 1000000, Preferred: true}, - {ID: "deepseek-chat", Provider: "deepseek", Family: "deepseek", Version: "chat", Label: "DeepSeek Chat", ReleaseDate: "2025-12-01", ContextWindow: 1000000, Preferred: true}, - } -} diff --git a/pkg/ai/middleware/logging.go b/pkg/ai/middleware/logging.go index c6d4ad3..051b596 100644 --- a/pkg/ai/middleware/logging.go +++ b/pkg/ai/middleware/logging.go @@ -104,11 +104,11 @@ func (l *loggingProvider) ExecuteStream(ctx context.Context, req ai.Request) (<- } func logRuntime(provider ai.Provider, req ai.Request) (api.Backend, string) { - backend := req.Model.Backend + backend := req.Backend if backend == "" { backend = provider.GetBackend() } - model := req.Model.Name + model := req.Name if model == "" { model = provider.GetModel() } diff --git a/pkg/ai/provider/genkit/genkit.go b/pkg/ai/provider/genkit/genkit.go index 8ce81ab..5e3a698 100644 --- a/pkg/ai/provider/genkit/genkit.go +++ b/pkg/ai/provider/genkit/genkit.go @@ -64,6 +64,9 @@ 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/DEEPSEEK_API_KEY)", backend) } + cfg.Model.Backend = backend + cfg.Model.Name = ai.NormalizeModelForBackend(backend, cfg.Model.Name) + ref, err := modelRef(backend, cfg.Model.Name) if err != nil { return nil, err @@ -74,7 +77,6 @@ func New(cfg ai.Config) (*Provider, error) { return nil, err } - cfg.Model.Backend = backend return &Provider{cfg: cfg, backend: backend, g: g, modelRef: ref}, nil } diff --git a/pkg/ai/provider/genkit/genkit_test.go b/pkg/ai/provider/genkit/genkit_test.go index 3159b81..99a8411 100644 --- a/pkg/ai/provider/genkit/genkit_test.go +++ b/pkg/ai/provider/genkit/genkit_test.go @@ -171,6 +171,17 @@ func TestModelRef(t *testing.T) { } } +func TestNewNormalizesBackendModelName(t *testing.T) { + p, err := New(ai.Config{ + Model: api.Model{Backend: ai.BackendAnthropic, Name: "opus-4-8"}, + APIKey: "test-anthropic-key", + }) + require.NoError(t, err) + + assert.Equal(t, "claude-opus-4-8", p.GetModel()) + assert.Equal(t, "anthropic/claude-opus-4-8", p.modelRef) +} + func TestPricingModelID(t *testing.T) { assert.Equal(t, "anthropic/claude-sonnet-4", pricingModelID(ai.BackendAnthropic, "claude-sonnet-4")) assert.Equal(t, "openai/gpt-4o", pricingModelID(ai.BackendOpenAI, "gpt-4o")) diff --git a/pkg/ai/provider/genkit/options.go b/pkg/ai/provider/genkit/options.go index 532299f..ac231aa 100644 --- a/pkg/ai/provider/genkit/options.go +++ b/pkg/ai/provider/genkit/options.go @@ -133,7 +133,7 @@ func effortConfig(backend ai.Backend, req ai.Request) map[string]any { case ai.BackendAnthropic: cfg := map[string]any{"max_tokens": anthropicMaxTokens(req, e)} if e != api.EffortNone { - if usesAnthropicAdaptiveThinking(req.Model.Name) || usesAnthropicAdaptiveThinking(req.Model.ID) { + if usesAnthropicAdaptiveThinking(req.Name) || usesAnthropicAdaptiveThinking(req.ID) { cfg["thinking"] = map[string]any{"type": "adaptive"} cfg["output_config"] = map[string]any{"effort": anthropicOutputEffort(e)} } else { @@ -155,6 +155,12 @@ func effortConfig(backend ai.Backend, req ai.Request) map[string]any { // ExecuteStream rejects structured output before calling this. func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallback) ([]gkai.GenerateOption, error) { opts := []gkai.GenerateOption{gkai.WithModelName(p.modelRef)} + if p.cfg.Model.Name != "" { + req.Name = p.cfg.Model.Name + } + if p.cfg.Model.ID != "" && req.ID == "" { + req.ID = p.cfg.Model.ID + } if req.Prompt.System != "" { opts = append(opts, gkai.WithSystem(req.Prompt.System)) diff --git a/pkg/ai/runtime_selector.go b/pkg/ai/runtime_selector.go index a08bf8a..a2bc3e3 100644 --- a/pkg/ai/runtime_selector.go +++ b/pkg/ai/runtime_selector.go @@ -109,7 +109,6 @@ func resolveSelectorPart(raw, baseName string, forced api.Backend, allowWildcard if isSelectorPrefix(raw) { prefix = strings.ToLower(raw) modelName = strings.TrimSpace(baseName) - hasPrefix = true } else { return []api.Model{{Name: raw, Backend: forced}}, nil } @@ -265,80 +264,6 @@ func NormalizeModelForBackend(backend api.Backend, model string) string { return resolved } -func lookupSelectorCatalogModel(backend api.Backend, model string) (string, bool) { - want := selectorCatalogBackend(backend) - for _, m := range Catalog() { - if m.Backend != want { - continue - } - if !selectorCatalogMatch(m, model, backend) { - continue - } - if m.IsAgent() { - if m.AgentModel != "" { - return m.AgentModel, true - } - return m.ID, true - } - return m.BareID(), true - } - return "", false -} - -func selectorCatalogBackend(backend api.Backend) api.Backend { - switch backend { - case api.BackendClaudeCLI, api.BackendClaudeCmux: - return api.BackendClaudeAgent - case api.BackendCodexCLI, api.BackendCodexCmux: - return api.BackendCodexAgent - default: - return backend - } -} - -func selectorCatalogMatch(m Model, model string, backend api.Backend) bool { - needle := strings.ToLower(strings.TrimSpace(model)) - if i := strings.LastIndex(needle, "/"); i >= 0 { - needle = needle[i+1:] - } - candidates := []string{m.ID, m.BareID(), m.AgentModel} - for _, c := range candidates { - c = strings.ToLower(strings.TrimSpace(c)) - if c == "" { - continue - } - if c == needle { - return true - } - if i := strings.LastIndex(c, "/"); i >= 0 && c[i+1:] == needle { - return true - } - } - catalogBackend := selectorCatalogBackend(backend) - if catalogBackend == api.BackendClaudeAgent || backend == api.BackendAnthropic { - if tier := claudeTier(needle); tier != "" && tier == claudeTier(m.ID) { - if catalogBackend == api.BackendClaudeAgent { - return true - } - if strings.Contains(needle, "-") { - return strings.Contains(strings.ToLower(m.ID), needle) - } - return true - } - } - return false -} - -func claudeTier(model string) string { - m := strings.ToLower(model) - for _, tier := range []string{"fable", "opus", "sonnet", "haiku"} { - if strings.Contains(m, tier) { - return tier - } - } - return "" -} - func mergeModelRuntime(original, resolved api.Model) api.Model { out := original if strings.TrimSpace(resolved.Name) != "" { diff --git a/pkg/session/build_codex.go b/pkg/session/build_codex.go index 607f8b5..2f674c7 100644 --- a/pkg/session/build_codex.go +++ b/pkg/session/build_codex.go @@ -2,10 +2,14 @@ package session import ( "fmt" + "path/filepath" + "sort" "strings" "time" "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/bash" "github.com/flanksource/captain/pkg/claude" "github.com/flanksource/captain/pkg/claude/tools" "github.com/flanksource/commons/logger" @@ -14,10 +18,8 @@ import ( var codexLog = logger.GetLogger("session") -// BuildCodex builds the unified model for each given Codex session file. Codex -// sessions are flat (no sub-agent hierarchy), carry inline plan updates, and -// have no per-message token usage, so Cost is zero. Unreadable files are logged -// at Warn and skipped. +// BuildCodex builds the unified model for each given Codex session file. +// Unreadable files are logged at Warn and skipped. func BuildCodex(files []string) []*Session { out := make([]*Session, 0, len(files)) for _, f := range files { @@ -47,6 +49,11 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * root := &Agent{IsRoot: true} var read, written []string + costs := api.Costs{} + turns := newCodexTurnBuilder() + agents := map[string]*Agent{} + var latestContext *Context + for _, u := range uses { if s.ID == "" && u.SessionID != "" { s.ID = u.SessionID @@ -61,11 +68,39 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * extendRange(s, *u.Timestamp) } if tools.IsEventToolName(u.Tool) || u.Tool == "ApiError" { - s.Events = append(s.Events, codexUseToEvent(u)) + ev := codexUseToEvent(u) + if ev.Scope == "turn" { + turns.addEvent(u, ev) + } else { + s.Events = append(s.Events, ev) + } + mergeCodexCapabilities(&s.Capabilities, u) + if u.Tool == "TokenCount" { + cost := codexCostFromUse(u) + if cost.TotalTokens != 0 { + costs = append(costs, cost) + turns.addUsage(u, cost) + } + if ctx := codexContextFromUse(u); ctx != nil { + latestContext = ctx + } + } continue } + mergeCodexCapabilities(&s.Capabilities, u) + if u.Tool == "Agent" && u.AgentID != "" { + if agents[u.AgentID] == nil { + agents[u.AgentID] = &Agent{ + ID: u.AgentID, + Type: u.AgentType, + Desc: u.AgentDesc, + } + } + } collectCodexPaths(u, &read, &written) - s.Messages = append(s.Messages, codexUseToMessage(u)) + msg := codexUseToMessage(u) + turns.addMessage(u, msg.ID) + s.Messages = append(s.Messages, msg) } if info != nil { @@ -86,15 +121,34 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * extendRange(s, *info.StartedAt) } } + if s.Project == "" && s.CWD != "" { + s.Project = filepath.Base(claude.FindProjectRoot(s.CWD)) + } root.ID = s.ID + root.Cost = costs.Sum() + root.Usage = usageFromCost(root.Cost) s.Root = root s.Agents = []*Agent{root} + for _, agent := range sortedCodexAgents(agents) { + agent.ParentID = root.ID + root.Children = append(root.Children, agent) + s.Agents = append(s.Agents, agent) + } s.Files = ChangedFiles{ Read: sortedUnique(relativizeAll(read, s.CWD)), Written: sortedUnique(relativizeAll(written, s.CWD)), } s.Plan = CodexPlanFromToolUses(uses) + s.Cost = costs.Sum() + s.Usage = usageFromCost(s.Cost) + s.ToolCosts = collapseByModel(costs) + s.Context = latestContext + s.Turns = turns.finish() + s.Capabilities.Tools = sortedStrings(s.Capabilities.Tools) + s.Capabilities.PendingMCPServers = sortedStrings(s.Capabilities.PendingMCPServers) + s.Capabilities.Agents = sortedStrings(s.Capabilities.Agents) + s.Capabilities.Skills = sortedStrings(s.Capabilities.Skills) return s } @@ -172,21 +226,24 @@ func relativizeAll(paths []string, cwd string) []string { // and "Reasoning" become text/reasoning turns, everything else a tool part with // the inline response as its output. func codexUseToMessage(u history.ToolUse) Message { + id := codexMessageID(u) + agentID := u.SessionID prov := &Provenance{ Source: "codex", CWD: u.CWD, Model: u.Model, ReasoningEffort: u.ReasoningEffort, SessionID: u.SessionID, + AgentID: agentID, Timestamp: u.Timestamp, } switch u.Tool { case "User": - return Message{Role: "user", Parts: []Part{{Type: PartText, Text: codexText(u)}}, Provenance: prov} + return Message{ID: id, Role: "user", Parts: []Part{{Type: PartText, Text: codexText(u)}}, TurnID: u.TurnID, Provenance: prov, AgentID: agentID} case "Assistant": - return Message{Role: "assistant", Parts: []Part{{Type: PartText, Text: codexText(u)}}, Provenance: prov} + return Message{ID: id, Role: "assistant", Parts: []Part{{Type: PartText, Text: codexText(u)}}, TurnID: u.TurnID, Provenance: prov, AgentID: agentID} case "Reasoning": - return Message{Role: "assistant", Parts: []Part{{Type: PartReasoning, Text: codexText(u)}}, Provenance: prov} + return Message{ID: id, Role: "assistant", Parts: []Part{{Type: PartReasoning, Text: codexText(u)}}, TurnID: u.TurnID, Provenance: prov, AgentID: agentID} default: part := Part{ Type: PartTool, @@ -201,7 +258,7 @@ func codexUseToMessage(u history.ToolUse) Message { } part.State = ToolStateOutputAvailable } - return Message{Role: "assistant", Parts: []Part{part}, Provenance: prov} + return Message{ID: id, Role: "assistant", Parts: []Part{part}, TurnID: u.TurnID, Provenance: prov, AgentID: agentID} } } @@ -217,14 +274,29 @@ func codexUseToEvent(u history.ToolUse) Event { } data[k] = v } + scope := "session" + if u.TurnID != "" { + scope = "turn" + } return Event{ Type: typ, - Scope: "session", + Scope: scope, + TurnID: u.TurnID, Timestamp: u.Timestamp, Data: data, } } +func codexMessageID(u history.ToolUse) string { + if u.ToolUseID != "" { + return u.ToolUseID + } + if u.Timestamp != nil { + return fmt.Sprintf("codex-%s-%s-%d", u.TurnID, u.Tool, u.Timestamp.UnixNano()) + } + return fmt.Sprintf("codex-%s-%s", u.TurnID, u.Tool) +} + func codexText(u history.ToolUse) string { if t, ok := u.Input["text"].(string); ok { return t @@ -257,5 +329,183 @@ func collectCodexPaths(u history.ToolUse, read, written *[]string) { if p, ok := u.Input["file_path"].(string); ok { *written = append(*written, p) } + case "Bash": + cmd, _ := u.Input["command"].(string) + if cmd == "" { + return + } + result, err := bash.Analyze(cmd) + if err != nil { + return + } + writePaths := map[string]struct{}{} + for _, op := range append(append(result.Created(), result.Modified()...), result.Deleted()...) { + if op.Path == "" { + continue + } + *written = append(*written, op.Path) + writePaths[op.Path] = struct{}{} + } + for _, p := range result.ReferencedPaths { + if p == "" { + continue + } + if _, ok := writePaths[p]; ok { + continue + } + *read = append(*read, p) + } + } +} + +type codexTurnBuilder struct { + order []string + byID map[string]*Turn +} + +func newCodexTurnBuilder() *codexTurnBuilder { + return &codexTurnBuilder{byID: map[string]*Turn{}} +} + +func (b *codexTurnBuilder) ensure(id string, ts *time.Time) *Turn { + if id == "" { + return nil } + if turn := b.byID[id]; turn != nil { + if turn.StartedAt == nil && ts != nil { + turn.StartedAt = cloneTime(ts) + } + return turn + } + turn := &Turn{ + ID: id, + Index: len(b.order) + 1, + } + if ts != nil { + turn.StartedAt = cloneTime(ts) + } + b.byID[id] = turn + b.order = append(b.order, id) + return turn +} + +func (b *codexTurnBuilder) addMessage(u history.ToolUse, messageID string) { + turn := b.ensure(u.TurnID, u.Timestamp) + if turn == nil { + return + } + turn.MessageIDs = appendUnique(turn.MessageIDs, messageID) + if u.Model != "" { + turn.Model = u.Model + } +} + +func (b *codexTurnBuilder) addEvent(u history.ToolUse, ev Event) { + turn := b.ensure(u.TurnID, u.Timestamp) + if turn == nil { + return + } + if u.Tool == "TaskStarted" && u.Timestamp != nil { + turn.StartedAt = cloneTime(u.Timestamp) + } + if u.Tool == "TaskComplete" && u.Timestamp != nil { + turn.EndedAt = cloneTime(u.Timestamp) + } + turn.Events = append(turn.Events, ev) + if u.Model != "" { + turn.Model = u.Model + } +} + +func (b *codexTurnBuilder) addUsage(u history.ToolUse, cost api.Cost) { + turn := b.ensure(u.TurnID, u.Timestamp) + if turn == nil { + return + } + turn.Cost = turn.Cost.Add(cost) + turn.Usage = usageFromCost(turn.Cost) + if ctx := codexContextFromUse(u); ctx != nil { + turn.Context = ctx + } + if u.Model != "" { + turn.Model = u.Model + } +} + +func (b *codexTurnBuilder) finish() []Turn { + turns := make([]Turn, 0, len(b.order)) + for _, id := range b.order { + turn := b.byID[id] + if turn == nil { + continue + } + if turn.EndedAt == nil { + for i := len(turn.Events) - 1; i >= 0; i-- { + if turn.Events[i].Timestamp != nil { + turn.EndedAt = cloneTime(turn.Events[i].Timestamp) + break + } + } + } + turns = append(turns, *turn) + } + return turns +} + +func codexCostFromUse(u history.ToolUse) api.Cost { + if u.TotalTokens == 0 && u.InputTokens == 0 && u.OutputTokens == 0 && u.CacheReadTokens == 0 { + return api.Cost{Model: u.Model} + } + p := claude.PricingFor(u.Model) + total := u.TotalTokens + if total == 0 { + total = u.InputTokens + u.OutputTokens + u.CacheReadTokens + } + return api.Cost{ + Model: u.Model, + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheReadTokens: u.CacheReadTokens, + TotalTokens: total, + InputCost: float64(u.InputTokens) * p.InputPerMTok / 1e6, + OutputCost: float64(u.OutputTokens) * p.OutputPerMTok / 1e6, + CacheReadCost: float64(u.CacheReadTokens) * p.CacheReadPerMTok / 1e6, + } +} + +func codexContextFromUse(u history.ToolUse) *Context { + if u.ContextWindow == 0 { + return nil + } + used := u.InputTokens + u.CacheReadTokens + return &Context{ + UsedTokens: used, + WindowTokens: u.ContextWindow, + FreePercent: freeContextPercent(used, u.ContextWindow), + } +} + +func mergeCodexCapabilities(c *Capabilities, u history.ToolUse) { + switch u.Tool { + case "DeferredToolsDelta": + c.Tools = append(c.Tools, stringsFromAny(u.Input["addedNames"])...) + c.PendingMCPServers = append(c.PendingMCPServers, stringsFromAny(u.Input["pendingMcpServers"])...) + case "Agent": + if u.AgentType != "" { + c.Agents = append(c.Agents, u.AgentType) + } + case "SkillListing": + c.Skills = append(c.Skills, stringsFromAny(u.Input["names"])...) + } +} + +func sortedCodexAgents(agents map[string]*Agent) []*Agent { + out := make([]*Agent, 0, len(agents)) + for _, agent := range agents { + out = append(out, agent) + } + sort.Slice(out, func(i, j int) bool { + return out[i].ID < out[j].ID + }) + return out } From f5eba56831a5a8a3f04cd04cf29c6ce7fcae7334 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 10:19:15 +0300 Subject: [PATCH 008/131] feat(api): add project options API and session throughput analysis Introduce project metadata aggregation from Claude history, Codex sessions, and live processes, exposed via GET /api/captain/projects. Add session throughput analytics with per-model/effort token rates and context utilization, served at GET /api/captain/sessions/throughput. Enhance live and list sessions with explicit --project filter. Support ephemeral prompt rendering when no prompt ID is given. Refactor model flat list to include provider, reasoning, and backends for the web UI. --- pkg/cli/projects.go | 163 +++++++++++++++++++++ pkg/cli/projects_test.go | 78 ++++++++++ pkg/cli/prompt_entity.go | 39 +++++ pkg/cli/prompt_schema_build.go | 121 +++++++++++++++- pkg/cli/prompt_schema_test.go | 46 ++++++ pkg/cli/serve.go | 65 ++++++++- pkg/cli/serve_test.go | 41 ++++++ pkg/cli/session_live.go | 34 +++-- pkg/cli/session_throughput.go | 222 +++++++++++++++++++++++++++++ pkg/cli/session_throughput_test.go | 189 ++++++++++++++++++++++++ pkg/cli/sessions.go | 72 ++++++++-- pkg/cli/sessions_test.go | 63 ++++++++ pkg/cli/stdin.go | 5 + 13 files changed, 1101 insertions(+), 37 deletions(-) create mode 100644 pkg/cli/session_throughput.go create mode 100644 pkg/cli/session_throughput_test.go diff --git a/pkg/cli/projects.go b/pkg/cli/projects.go index 0d0d64a..8344edd 100644 --- a/pkg/cli/projects.go +++ b/pkg/cli/projects.go @@ -6,8 +6,10 @@ import ( "path/filepath" "regexp" "sort" + "strings" "time" + "github.com/flanksource/captain/pkg/ai/history" "github.com/flanksource/captain/pkg/claude" "github.com/timberio/go-datemath" ) @@ -26,6 +28,27 @@ type ProjectsListResult struct { Rows []ProjectRow `json:"rows"` } +type ProjectOption struct { + Value string `json:"value"` + Label string `json:"label"` + Path string `json:"path"` + Sources []string `json:"sources,omitempty"` + Sessions int `json:"sessions,omitempty"` + LastUsed *time.Time `json:"lastUsed,omitempty"` +} + +type ProjectOptionsResult struct { + Total int `json:"total"` + Projects []ProjectOption `json:"projects"` +} + +type projectOptionAccumulator struct { + path string + sources map[string]bool + sessions int + lastUsed time.Time +} + func RunProjectsList(_ ProjectsListOptions) (any, error) { projects, err := scanProjects() if err != nil { @@ -49,6 +72,146 @@ func RunProjectsList(_ ProjectsListOptions) (any, error) { return ProjectsListResult{Total: len(projects), Rows: rows}, nil } +func RunProjectOptions() (ProjectOptionsResult, error) { + accs := map[string]*projectOptionAccumulator{} + + projects, err := scanProjects() + if err != nil { + return ProjectOptionsResult{}, err + } + for _, project := range projects { + addProjectOption(accs, projectOptionPath(project), "claude", project.sessions, project.lastUsed) + } + + if codexFiles, err := history.FindCodexSessionFiles(); err == nil { + for _, file := range codexFiles { + meta, err := history.ReadCodexSessionMeta(file) + if err != nil || meta == nil || strings.TrimSpace(meta.CWD) == "" { + continue + } + lastUsed := time.Time{} + if info, err := os.Stat(file); err == nil { + lastUsed = info.ModTime() + } + if meta.StartedAt != nil && meta.StartedAt.After(lastUsed) { + lastUsed = *meta.StartedAt + } + addProjectOption(accs, sessionProjectRoot(meta.CWD), "codex", 1, lastUsed) + } + } + + if processes, err := discoverSessionProcesses(); err == nil { + for _, proc := range processes { + if strings.TrimSpace(proc.CWD) == "" { + continue + } + lastUsed := time.Time{} + if proc.StartedAt != nil { + lastUsed = *proc.StartedAt + } + addProjectOption(accs, sessionProjectRoot(proc.CWD), "live", 0, lastUsed) + } + } + + projectsOut := make([]ProjectOption, 0, len(accs)) + for _, acc := range accs { + sources := make([]string, 0, len(acc.sources)) + for source := range acc.sources { + sources = append(sources, source) + } + sort.Strings(sources) + var lastUsed *time.Time + if !acc.lastUsed.IsZero() { + value := acc.lastUsed + lastUsed = &value + } + projectsOut = append(projectsOut, ProjectOption{ + Value: acc.path, + Label: projectOptionLabel(acc.path), + Path: acc.path, + Sources: sources, + Sessions: acc.sessions, + LastUsed: lastUsed, + }) + } + + sort.Slice(projectsOut, func(i, j int) bool { + left, right := projectsOut[i], projectsOut[j] + if left.LastUsed != nil && right.LastUsed != nil && !left.LastUsed.Equal(*right.LastUsed) { + return left.LastUsed.After(*right.LastUsed) + } + if left.LastUsed != nil && right.LastUsed == nil { + return true + } + if left.LastUsed == nil && right.LastUsed != nil { + return false + } + return left.Label < right.Label + }) + + return ProjectOptionsResult{Total: len(projectsOut), Projects: projectsOut}, nil +} + +func addProjectOption(accs map[string]*projectOptionAccumulator, path, source string, sessions int, lastUsed time.Time) { + path = normalizeSessionProject(path) + if path == "" { + return + } + acc := accs[path] + if acc == nil { + acc = &projectOptionAccumulator{path: path, sources: map[string]bool{}} + accs[path] = acc + } + if source != "" { + acc.sources[source] = true + } + acc.sessions += sessions + if lastUsed.After(acc.lastUsed) { + acc.lastUsed = lastUsed + } +} + +func projectOptionPath(project projectDirInfo) string { + sessions, _ := filepath.Glob(filepath.Join(project.dirPath, "*.jsonl")) + sort.Slice(sessions, func(i, j int) bool { + left, leftErr := os.Stat(sessions[i]) + right, rightErr := os.Stat(sessions[j]) + if leftErr != nil || rightErr != nil { + return sessions[i] < sessions[j] + } + return left.ModTime().After(right.ModTime()) + }) + for _, sessionFile := range sessions { + entries, err := claude.ReadHistoryFileWithOptions(sessionFile, claude.ReadOptions{}) + if err != nil { + continue + } + for _, entry := range entries { + if strings.TrimSpace(entry.CWD) != "" { + return sessionProjectRoot(entry.CWD) + } + } + } + return project.name +} + +func projectOptionLabel(path string) string { + parts := strings.Split(filepath.ToSlash(path), "/") + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + filtered = append(filtered, part) + } + } + if len(filtered) >= 2 { + return strings.Join(filtered[len(filtered)-2:], "/") + } + if len(filtered) == 1 { + return filtered[0] + } + return path +} + type ProjectsCleanOptions struct { Since string `flag:"since" help:"Delete sessions not accessed within this period (e.g. 30d, now-30d)" default:"30d" short:"s"` DryRun bool `flag:"dry-run" help:"Preview without deleting" short:"n"` diff --git a/pkg/cli/projects_test.go b/pkg/cli/projects_test.go index 319e2fc..f296260 100644 --- a/pkg/cli/projects_test.go +++ b/pkg/cli/projects_test.go @@ -1,8 +1,13 @@ package cli import ( + "os" + "path/filepath" + "slices" "testing" "time" + + "github.com/flanksource/captain/pkg/claude" ) func TestFormatBytes(t *testing.T) { @@ -46,6 +51,79 @@ func TestUuidStem(t *testing.T) { } } +func TestRunProjectOptionsMergesClaudeCodexAndLive(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + claudeProject := filepath.Join(home, "work", "claude-project") + codexProject := filepath.Join(home, "work", "codex-project") + liveProject := filepath.Join(home, "work", "live-project") + for _, dir := range []string{claudeProject, codexProject, liveProject} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + markProjectRoot(t, dir) + } + + writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(claudeProject), "sess-claude.jsonl"), + map[string]any{ + "type": "assistant", + "sessionId": "sess-claude", + "timestamp": "2026-06-01T10:00:00Z", + "cwd": claudeProject, + "message": map[string]any{ + "role": "assistant", + "content": []any{map[string]any{"type": "text", "text": "ok"}}, + }, + }, + ) + writeCodexSession(t, filepath.Join(home, ".codex", "sessions", "2026", "06", "rollout-codex.jsonl"), "codex-project", codexProject) + + started := time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC) + orig := discoverSessionProcesses + discoverSessionProcesses = func() ([]agentProcess, error) { + return []agentProcess{{ + Source: "codex", + PID: 101, + Active: true, + StartedAt: &started, + CWD: liveProject, + Command: "codex", + }}, nil + } + t.Cleanup(func() { discoverSessionProcesses = orig }) + + result, err := RunProjectOptions() + if err != nil { + t.Fatalf("RunProjectOptions: %v", err) + } + if result.Total != 3 { + t.Fatalf("projects = %+v", result) + } + assertProjectOption(t, result.Projects, claudeProject, "claude") + assertProjectOption(t, result.Projects, codexProject, "codex") + assertProjectOption(t, result.Projects, liveProject, "live") +} + +func assertProjectOption(t *testing.T, projects []ProjectOption, path, source string) { + t.Helper() + for _, project := range projects { + if project.Value != path { + continue + } + if project.Path != path { + t.Fatalf("project path = %q, want %q", project.Path, path) + } + if project.Label == "" { + t.Fatalf("empty label for %+v", project) + } + if !slices.Contains(project.Sources, source) { + t.Fatalf("sources for %q = %v, want %q", path, project.Sources, source) + } + return + } + t.Fatalf("project %q not found in %+v", path, projects) +} + func TestShellQuote(t *testing.T) { tests := []struct { input string diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index 8b53f3d..bbaca39 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -364,6 +364,9 @@ func renderPromptAction(ctx context.Context, id string, flags map[string]string) // renderPrompt is the HTTP/Spec render path: overlay a structured api.Spec (the // web UI's rich runtime overrides) onto the rendered template. func renderPrompt(ctx context.Context, id string, renderReq PromptRenderRequest) (PromptRenderResult, error) { + if strings.TrimSpace(id) == "" { + return renderEphemeralPrompt(renderReq) + } record, err := resolvePromptRecord(ctx, id) if err != nil { return PromptRenderResult{}, err @@ -395,6 +398,42 @@ func renderPrompt(ctx context.Context, id string, renderReq PromptRenderRequest) return finalizeRenderResult(record, content, req, cfg) } +func renderEphemeralPrompt(renderReq PromptRenderRequest) (PromptRenderResult, error) { + record := promptRecord{ + Source: promptSource{Kind: "ephemeral", ID: "ephemeral", Label: "Ephemeral"}, + ID: "", + Path: "", + Rel: "scratch.prompt", + } + content := ephemeralPromptContent() + var req ai.Request + var cfg ai.Config + if renderReq.Spec != nil { + overlayRuntimeSpec(&req, &cfg, *renderReq.Spec) + } + if req.Prompt.Source == "" { + req.Prompt.Source = "" + } + applyPromptDefaults(&req, &cfg) + cwd, err := os.Getwd() + if err != nil { + return PromptRenderResult{}, fmt.Errorf("get working directory: %w", err) + } + if err := normalizePromptContextDir(&req, cwd); err != nil { + return PromptRenderResult{}, err + } + return finalizeRenderResult(record, content, req, cfg) +} + +func ephemeralPromptContent() string { + return `--- +name: Scratch Prompt +description: Ephemeral prompt +--- +{{role "user"}} +` +} + // renderPromptCLI is the CLI render path: load from id | .prompt filepath | -p | // stdin and overlay the flat CLI flags (overlayCLI). func renderPromptCLI(ctx context.Context, id string, opts AIPromptOptions, varsJSON, stdin string) (PromptRenderResult, error) { diff --git a/pkg/cli/prompt_schema_build.go b/pkg/cli/prompt_schema_build.go index b6cb0d3..59caea4 100644 --- a/pkg/cli/prompt_schema_build.go +++ b/pkg/cli/prompt_schema_build.go @@ -161,21 +161,128 @@ func injectSpecConditionals(specMap map[string]any, adapters []AdapterStatus, ar return nil } -// flatModels is a convenience union of every available model across adapters. +// flatModels is a convenience union of every available model across adapters, +// shaped like clicky-ui's ChatModel catalog while retaining the legacy backend +// and ready fields for older consumers. func flatModels(adapters []AdapterStatus) []map[string]any { - out := []map[string]any{} + type entry struct { + data map[string]any + backends []string + } + out := []entry{} + positions := map[string]int{} for _, a := range adapters { - for _, id := range a.Models { - out = append(out, map[string]any{ - "id": id, - "backend": a.Backend, - "ready": a.Ready(), + provider := modelProviderForBackend(api.Backend(a.Backend)) + for _, model := range flatModelDetails(a) { + id := strings.TrimSpace(model.id) + if id == "" { + continue + } + key := provider + "\x00" + id + if idx, ok := positions[key]; ok { + if !containsString(out[idx].backends, a.Backend) { + out[idx].backends = append(out[idx].backends, a.Backend) + out[idx].data["backends"] = out[idx].backends + } + if a.Ready() { + out[idx].data["configured"] = true + out[idx].data["ready"] = true + } + continue + } + label := strings.TrimSpace(model.label) + if label == "" { + label = id + } + backends := []string{a.Backend} + positions[key] = len(out) + out = append(out, entry{ + backends: backends, + data: map[string]any{ + "id": id, + "label": label, + "provider": provider, + "reasoning": modelSupportsReasoning(id), + "configured": a.Ready(), + "backends": backends, + "backend": a.Backend, + "ready": a.Ready(), + }, }) } } + flat := make([]map[string]any, 0, len(out)) + for _, item := range out { + flat = append(flat, item.data) + } + return flat +} + +type flatModelDetail struct { + id string + label string +} + +func flatModelDetails(adapter AdapterStatus) []flatModelDetail { + if len(adapter.ModelDetails) > 0 { + out := make([]flatModelDetail, 0, len(adapter.ModelDetails)) + for _, model := range adapter.ModelDetails { + out = append(out, flatModelDetail{id: model.ID, label: model.Name}) + } + return out + } + out := make([]flatModelDetail, 0, len(adapter.Models)) + for _, id := range adapter.Models { + out = append(out, flatModelDetail{id: id, label: id}) + } return out } +func modelProviderForBackend(backend api.Backend) string { + switch backend { + case api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux: + return "claude-agent" + case api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux: + return "codex-cli" + case api.BackendGemini, api.BackendGeminiCLI: + return "googleai" + default: + return string(backend) + } +} + +func modelSupportsReasoning(id string) bool { + token := strings.ToLower(strings.TrimSpace(id)) + if slash := strings.LastIndex(token, "/"); slash >= 0 { + token = token[slash+1:] + } + token = strings.TrimPrefix(token, "models/") + switch { + case strings.HasPrefix(token, "claude-"), + strings.HasPrefix(token, "opus"), + strings.HasPrefix(token, "sonnet"), + strings.HasPrefix(token, "haiku"), + strings.HasPrefix(token, "gpt-"), + strings.HasPrefix(token, "o1"), + strings.HasPrefix(token, "o3"), + strings.HasPrefix(token, "o4"), + strings.HasPrefix(token, "gemini-"), + strings.HasPrefix(token, "deepseek"): + return true + default: + return false + } +} + +func containsString(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + // argDefName is the $defs key for a backend's args schema, e.g. // "claude-cmux" -> "claudeCmuxArgs". func argDefName(b api.Backend) string { diff --git a/pkg/cli/prompt_schema_test.go b/pkg/cli/prompt_schema_test.go index e74b0e6..323a838 100644 --- a/pkg/cli/prompt_schema_test.go +++ b/pkg/cli/prompt_schema_test.go @@ -70,6 +70,21 @@ func TestPromptSchemaDocumentBackendsAndConditionals(t *testing.T) { if got := codexCLIModels[0]; got != "gpt-5.5" { t.Errorf("codex-cli first model = %q, want gpt-5.5", got) } + flat := doc["models"].([]map[string]any) + codexModel := schemaModelForBackend(t, flat, "gpt-5.5", string(api.BackendCodexCLI)) + if got := codexModel["provider"]; got != "codex-cli" { + t.Errorf("flat model provider = %v, want codex-cli", got) + } + if got, ok := codexModel["label"].(string); !ok || got == "" { + t.Errorf("flat model label = %#v, want non-empty string", codexModel["label"]) + } + if _, ok := codexModel["reasoning"].(bool); !ok { + t.Errorf("flat model reasoning = %#v, want bool", codexModel["reasoning"]) + } + if got, ok := codexModel["configured"].(bool); !ok || got { + t.Errorf("flat model configured = %#v, want false for fake unauthenticated CLI", codexModel["configured"]) + } + assertSchemaModelBackends(t, codexModel, string(api.BackendCodexCLI), string(api.BackendCodexAgent), string(api.BackendCodexCmux)) anthropic := byName[string(api.BackendAnthropic)] if _, hasModels := anthropic["models"]; hasModels { @@ -132,6 +147,37 @@ func TestPromptSchemaDocumentBackendsAndConditionals(t *testing.T) { } } +func schemaModelForBackend(t *testing.T, models []map[string]any, id, backend string) map[string]any { + t.Helper() + for _, model := range models { + if model["id"] != id { + continue + } + backends, ok := model["backends"].([]string) + if !ok { + t.Fatalf("model %s backends = %T, want []string", id, model["backends"]) + } + if containsString(backends, backend) { + return model + } + } + t.Fatalf("models[] missing id %q with backend %q: %+v", id, backend, models) + return nil +} + +func assertSchemaModelBackends(t *testing.T, model map[string]any, want ...string) { + t.Helper() + backends, ok := model["backends"].([]string) + if !ok { + t.Fatalf("model backends = %T, want []string", model["backends"]) + } + for _, backend := range want { + if !containsString(backends, backend) { + t.Errorf("model backends = %v, missing %s", backends, backend) + } + } +} + func TestPromptSchemaExampleIsPortable(t *testing.T) { ex := promptSchemaExampleSpec() diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index cadf1f5..4210511 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -160,7 +160,9 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve mux := http.NewServeMux() rpcServer.RegisterRoutes(mux) mux.HandleFunc("POST /api/captain/chat/threads/from-agent", handleThreadFromAgent(threadStore)) + mux.HandleFunc("GET /api/captain/projects", handleProjects()) mux.HandleFunc("GET /api/captain/sessions/live", handleSessionsLive()) + mux.HandleFunc("GET /api/captain/sessions/throughput", handleSessionsThroughput()) mux.HandleFunc("GET /api/captain/sessions/{id}", handleSessionGet()) mux.HandleFunc("GET /api/captain/ai/permissions/catalog", handlePermissionCatalog(cwd)) mux.HandleFunc("GET /api/captain/ai/prompt/schema", handlePromptSchema()) @@ -249,9 +251,10 @@ func handleSessionsLive() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() opts := SessionLiveOptions{ - Source: strings.TrimSpace(query.Get("source")), - Query: strings.TrimSpace(query.Get("q")), - Limit: 100, + Source: strings.TrimSpace(query.Get("source")), + Project: strings.TrimSpace(query.Get("project")), + Query: strings.TrimSpace(query.Get("q")), + Limit: 100, } if opts.Source == "" { opts.Source = "all" @@ -259,6 +262,12 @@ func handleSessionsLive() http.HandlerFunc { if raw := strings.TrimSpace(query.Get("all")); raw != "" { opts.All, _ = strconv.ParseBool(raw) } + if strings.EqualFold(opts.Project, "all") { + opts.Project = "" + opts.All = true + } else if opts.Project != "" { + opts.All = false + } if raw := strings.TrimSpace(query.Get("limit")); raw != "" { limit, err := strconv.Atoi(raw) if err != nil { @@ -277,6 +286,56 @@ func handleSessionsLive() http.HandlerFunc { } } +func handleSessionsThroughput() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + opts := SessionThroughputOptions{ + Source: strings.TrimSpace(query.Get("source")), + Project: strings.TrimSpace(query.Get("project")), + Query: strings.TrimSpace(query.Get("q")), + Limit: defaultSessionThroughputLimit, + } + if opts.Source == "" { + opts.Source = "all" + } + if raw := strings.TrimSpace(query.Get("all")); raw != "" { + opts.All, _ = strconv.ParseBool(raw) + } + if strings.EqualFold(opts.Project, "all") { + opts.Project = "" + opts.All = true + } else if opts.Project != "" { + opts.All = false + } + if raw := strings.TrimSpace(query.Get("limit")); raw != "" { + limit, err := strconv.Atoi(raw) + if err != nil { + http.Error(w, fmt.Sprintf("invalid limit %q", raw), http.StatusBadRequest) + return + } + opts.Limit = limit + } + + result, err := RunSessionThroughput(r.Context(), opts) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeServeJSON(w, http.StatusOK, result) + } +} + +func handleProjects() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := RunProjectOptions() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeServeJSON(w, http.StatusOK, result) + } +} + // handleSessionGet serves the unified session model for one session id at // GET /api/captain/sessions/{id}, so the web UI can render the same model the // CLI `sessions get` returns. diff --git a/pkg/cli/serve_test.go b/pkg/cli/serve_test.go index 139c165..abc3db5 100644 --- a/pkg/cli/serve_test.go +++ b/pkg/cli/serve_test.go @@ -102,3 +102,44 @@ func TestHandleSessionGetReturnsUnifiedModel(t *testing.T) { t.Fatalf("messages = %+v", got.Messages) } } + +func TestHandleProjectsReturnsOptions(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) + } + markProjectRoot(t, project) + writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-web.jsonl"), + map[string]any{ + "type": "assistant", + "sessionId": "sess-web", + "timestamp": "2026-07-06T10:00:00Z", + "cwd": project, + "message": map[string]any{ + "role": "assistant", + "content": []any{map[string]any{"type": "text", "text": "hello"}}, + }, + }, + ) + + orig := discoverSessionProcesses + discoverSessionProcesses = func() ([]agentProcess, error) { return nil, nil } + t.Cleanup(func() { discoverSessionProcesses = orig }) + + req := httptest.NewRequest(http.MethodGet, "/api/captain/projects", nil) + rec := httptest.NewRecorder() + handleProjects()(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got ProjectOptionsResult + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, rec.Body.String()) + } + if got.Total != 1 || got.Projects[0].Value != project { + t.Fatalf("projects = %+v, want %q", got, project) + } +} diff --git a/pkg/cli/session_live.go b/pkg/cli/session_live.go index c117028..14712ef 100644 --- a/pkg/cli/session_live.go +++ b/pkg/cli/session_live.go @@ -25,23 +25,20 @@ func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveRe return SessionLiveResult{}, err } - scope := "current" - if opts.All { - scope = "all" - } + scope, projectRoot, searchAll := resolveSessionScope(cwd, opts.All, opts.Project) limit := opts.Limit if limit <= 0 && !opts.Full { limit = defaultSessionLiveLimit } - records, err := discoverLiveSessionRecords(ctx, cwd, opts.All, source, limit, opts.Full) + records, err := discoverLiveSessionRecords(ctx, cwd, searchAll, source, limit, opts.Full, projectRoot) if err != nil { return SessionLiveResult{}, err } stopEnrich := rpchttp.Track(ctx, "enrich") processes, _ := discoverSessionProcesses() - if !opts.All { - processes = filterAgentProcessesByProject(processes, sessionProjectRoot(cwd)) + if projectRoot != "" { + processes = filterAgentProcessesByProject(processes, projectRoot) } records = enrichSessionsWithLive(records, processes) filtered := make([]SessionRecord, 0, len(records)) @@ -63,17 +60,23 @@ func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveRe Total: total, Source: source, Scope: scope, + Project: projectResultValue(scope, projectRoot), Summary: summary, }, nil } -func discoverLiveSessionRecords(ctx context.Context, cwd string, searchAll bool, source string, limit int, full bool) ([]SessionRecord, error) { +func discoverLiveSessionRecords(ctx context.Context, cwd string, searchAll bool, source string, limit int, full bool, projectRoot string) ([]SessionRecord, error) { if full { + project := "" + if searchAll && projectRoot != "" { + project = projectRoot + } list, err := RunSessionList(ctx, SessionListOptions{ - Source: source, - All: searchAll, - Query: "", - Limit: 0, + Source: source, + All: searchAll, + Project: project, + Query: "", + Limit: 0, }) if err != nil { return nil, err @@ -81,10 +84,15 @@ func discoverLiveSessionRecords(ctx context.Context, cwd string, searchAll bool, return list.Sessions, nil } - candidates, err := discoverLiveSessionCandidates(ctx, cwd, searchAll, source, limit) + candidateLimit := limit + if searchAll && projectRoot != "" { + candidateLimit = 0 + } + candidates, err := discoverLiveSessionCandidates(ctx, cwd, searchAll, source, candidateLimit) if err != nil { return nil, err } + candidates = filterSessionCandidatesByProject(candidates, projectRoot) records := make([]SessionRecord, 0, len(candidates)) for _, candidate := range candidates { records = append(records, candidate.record) diff --git a/pkg/cli/session_throughput.go b/pkg/cli/session_throughput.go new file mode 100644 index 0000000..8b206db --- /dev/null +++ b/pkg/cli/session_throughput.go @@ -0,0 +1,222 @@ +package cli + +import ( + "context" + "os" + "sort" + "strings" + "time" +) + +const defaultSessionThroughputLimit = 500 + +type SessionThroughputOptions struct { + Source string + All bool + Project string + Query string + Limit int +} + +type SessionThroughputResult struct { + Groups []SessionThroughputGroup `json:"groups"` + Total int `json:"total"` + Skipped int `json:"skipped"` + Source string `json:"source"` + Scope string `json:"scope"` + Project string `json:"project,omitempty"` +} + +type SessionThroughputGroup struct { + Key string `json:"key"` + Source string `json:"source"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoningEffort"` + Sessions int `json:"sessions"` + DurationSeconds float64 `json:"durationSeconds"` + InputTokens int `json:"inputTokens,omitempty"` + OutputTokens int `json:"outputTokens,omitempty"` + CacheReadTokens int `json:"cacheReadTokens,omitempty"` + CacheCreationTokens int `json:"cacheCreationTokens,omitempty"` + TotalTokens int `json:"totalTokens,omitempty"` + ContextTokens int `json:"contextTokens,omitempty"` + ContextSamples int `json:"contextSamples,omitempty"` + OutputTokensPerSecond float64 `json:"outputTokensPerSecond"` + TotalTokensPerSecond float64 `json:"totalTokensPerSecond"` + ContextTokensPerSecond float64 `json:"contextTokensPerSecond,omitempty"` + AvgContextUsedPercent float64 `json:"avgContextUsedPercent,omitempty"` + Points []SessionThroughputPoint `json:"points,omitempty"` +} + +type SessionThroughputPoint struct { + At time.Time `json:"at"` + SessionID string `json:"sessionId"` + OutputTokensPerSecond float64 `json:"outputTokensPerSecond"` + TotalTokensPerSecond float64 `json:"totalTokensPerSecond"` + ContextTokensPerSecond float64 `json:"contextTokensPerSecond,omitempty"` + ContextUsedPercent float64 `json:"contextUsedPercent,omitempty"` +} + +type sessionThroughputAccumulator struct { + group SessionThroughputGroup + contextPercentSum float64 +} + +func RunSessionThroughput(ctx context.Context, opts SessionThroughputOptions) (SessionThroughputResult, error) { + source, err := normalizeSessionSource(opts.Source) + if err != nil { + return SessionThroughputResult{}, err + } + cwd, err := os.Getwd() + if err != nil { + return SessionThroughputResult{}, err + } + limit := opts.Limit + if limit <= 0 { + limit = defaultSessionThroughputLimit + } + scope, projectRoot, searchAll := resolveSessionScope(cwd, opts.All, opts.Project) + + candidateLimit := limit + if searchAll && projectRoot != "" { + candidateLimit = 0 + } + candidates, err := discoverLiveSessionCandidates(ctx, cwd, searchAll, source, candidateLimit) + if err != nil { + return SessionThroughputResult{}, err + } + candidates = filterSessionCandidatesByProject(candidates, projectRoot) + records := make([]SessionRecord, 0, len(candidates)) + for _, candidate := range candidates { + if sessionMatchesQuery(candidate.record, opts.Query) { + records = append(records, candidate.record) + } + } + sortSessionRecords(records) + if limit > 0 && len(records) > limit { + records = records[:limit] + } + + groups, skipped := aggregateSessionThroughput(records) + return SessionThroughputResult{ + Groups: groups, + Total: len(records), + Skipped: skipped, + Source: source, + Scope: scope, + Project: projectResultValue(scope, projectRoot), + }, nil +} + +func aggregateSessionThroughput(records []SessionRecord) ([]SessionThroughputGroup, int) { + accs := map[string]*sessionThroughputAccumulator{} + skipped := 0 + + for _, record := range records { + duration := sessionDurationSeconds(record) + if duration <= 0 || record.Tokens == nil { + skipped++ + continue + } + totalTokens := record.Tokens.TotalTokens + if totalTokens == 0 { + totalTokens = record.Tokens.InputTokens + record.Tokens.OutputTokens + record.Tokens.CacheReadTokens + record.Tokens.CacheCreationTokens + } + if totalTokens <= 0 { + skipped++ + continue + } + + source := strings.TrimSpace(record.Source) + if source == "" { + source = "unknown" + } + model := strings.TrimSpace(record.Model) + if model == "" { + model = "unknown" + } + effort := strings.TrimSpace(record.ReasoningEffort) + if effort == "" { + effort = "default" + } + key := source + "|" + model + "|" + effort + + acc := accs[key] + if acc == nil { + acc = &sessionThroughputAccumulator{group: SessionThroughputGroup{ + Key: key, + Source: source, + Model: model, + ReasoningEffort: effort, + }} + accs[key] = acc + } + + group := &acc.group + group.Sessions++ + group.DurationSeconds += duration + group.InputTokens += record.Tokens.InputTokens + group.OutputTokens += record.Tokens.OutputTokens + group.CacheReadTokens += record.Tokens.CacheReadTokens + group.CacheCreationTokens += record.Tokens.CacheCreationTokens + group.TotalTokens += totalTokens + + point := SessionThroughputPoint{ + At: *record.EndedAt, + SessionID: record.ID, + OutputTokensPerSecond: float64(record.Tokens.OutputTokens) / duration, + TotalTokensPerSecond: float64(totalTokens) / duration, + } + if record.Context != nil && record.Context.UsedTokens > 0 { + group.ContextTokens += record.Context.UsedTokens + point.ContextTokensPerSecond = float64(record.Context.UsedTokens) / duration + if record.Context.WindowTokens > 0 { + percent := float64(record.Context.UsedTokens) / float64(record.Context.WindowTokens) * 100 + group.ContextSamples++ + acc.contextPercentSum += percent + point.ContextUsedPercent = percent + } + } + group.Points = append(group.Points, point) + } + + groups := make([]SessionThroughputGroup, 0, len(accs)) + for _, acc := range accs { + group := acc.group + if group.DurationSeconds > 0 { + group.OutputTokensPerSecond = float64(group.OutputTokens) / group.DurationSeconds + group.TotalTokensPerSecond = float64(group.TotalTokens) / group.DurationSeconds + if group.ContextTokens > 0 { + group.ContextTokensPerSecond = float64(group.ContextTokens) / group.DurationSeconds + } + } + if group.ContextSamples > 0 { + group.AvgContextUsedPercent = acc.contextPercentSum / float64(group.ContextSamples) + } + sort.Slice(group.Points, func(i, j int) bool { + return group.Points[i].At.Before(group.Points[j].At) + }) + groups = append(groups, group) + } + + sort.Slice(groups, func(i, j int) bool { + if groups[i].OutputTokensPerSecond != groups[j].OutputTokensPerSecond { + return groups[i].OutputTokensPerSecond > groups[j].OutputTokensPerSecond + } + if groups[i].Sessions != groups[j].Sessions { + return groups[i].Sessions > groups[j].Sessions + } + if groups[i].Model != groups[j].Model { + return groups[i].Model < groups[j].Model + } + return groups[i].ReasoningEffort < groups[j].ReasoningEffort + }) + return groups, skipped +} + +func sessionDurationSeconds(record SessionRecord) float64 { + if record.StartedAt == nil || record.EndedAt == nil || !record.EndedAt.After(*record.StartedAt) { + return 0 + } + return record.EndedAt.Sub(*record.StartedAt).Seconds() +} diff --git a/pkg/cli/session_throughput_test.go b/pkg/cli/session_throughput_test.go new file mode 100644 index 0000000..b047976 --- /dev/null +++ b/pkg/cli/session_throughput_test.go @@ -0,0 +1,189 @@ +package cli + +import ( + "context" + "math" + "os" + "path/filepath" + "testing" + "time" + + "github.com/flanksource/captain/pkg/claude" +) + +func TestAggregateSessionThroughputSeparatesModelEffort(t *testing.T) { + base := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC) + records := []SessionRecord{ + throughputRecord("a", "claude", "claude-sonnet-4-6", "low", base, 10*time.Second, 100, 50, 25, 0, 500, 1000), + throughputRecord("b", "claude", "claude-sonnet-4-6", "low", base.Add(time.Minute), 20*time.Second, 200, 100, 0, 0, 250, 1000), + throughputRecord("c", "claude", "claude-sonnet-4-6", "high", base.Add(2*time.Minute), 10*time.Second, 100, 200, 0, 0, 800, 1000), + } + + groups, skipped := aggregateSessionThroughput(records) + if skipped != 0 { + t.Fatalf("skipped = %d, want 0", skipped) + } + if len(groups) != 2 { + t.Fatalf("groups = %d, want 2", len(groups)) + } + + high := findThroughputGroup(t, groups, "claude|claude-sonnet-4-6|high") + low := findThroughputGroup(t, groups, "claude|claude-sonnet-4-6|low") + + assertFloat(t, high.OutputTokensPerSecond, 20) + assertFloat(t, high.TotalTokensPerSecond, 30) + assertFloat(t, high.ContextTokensPerSecond, 80) + assertFloat(t, high.AvgContextUsedPercent, 80) + + assertFloat(t, low.OutputTokensPerSecond, 5) + assertFloat(t, low.TotalTokensPerSecond, 475.0/30.0) + assertFloat(t, low.ContextTokensPerSecond, 25) + assertFloat(t, low.AvgContextUsedPercent, 37.5) + if low.Sessions != 2 { + t.Fatalf("low sessions = %d, want 2", low.Sessions) + } + if len(low.Points) != 2 || !low.Points[0].At.Before(low.Points[1].At) { + t.Fatalf("low points are not sorted oldest-first: %+v", low.Points) + } +} + +func TestAggregateSessionThroughputSkipsIncompleteSessions(t *testing.T) { + base := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC) + valid := throughputRecord("valid", "codex", "gpt-5", "", base, 10*time.Second, 10, 20, 0, 0, 0, 0) + noEnd := valid + noEnd.ID = "no-end" + noEnd.EndedAt = nil + noTokens := valid + noTokens.ID = "no-tokens" + noTokens.Tokens = nil + zeroDuration := valid + zeroDuration.ID = "zero-duration" + zeroDuration.EndedAt = zeroDuration.StartedAt + + groups, skipped := aggregateSessionThroughput([]SessionRecord{valid, noEnd, noTokens, zeroDuration}) + if skipped != 3 { + t.Fatalf("skipped = %d, want 3", skipped) + } + if len(groups) != 1 { + t.Fatalf("groups = %d, want 1", len(groups)) + } + group := groups[0] + if group.Key != "codex|gpt-5|default" { + t.Fatalf("group key = %q, want default effort key", group.Key) + } + assertFloat(t, group.OutputTokensPerSecond, 2) + assertFloat(t, group.TotalTokensPerSecond, 3) +} + +func TestRunSessionThroughputRestrictsExplicitProject(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "project") + otherProject := filepath.Join(home, "work", "other") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(otherProject, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + markProjectRoot(t, project) + markProjectRoot(t, otherProject) + + writeThroughputClaudeSession(t, home, project, "sess-current", "2026-06-01T10:00:00Z", "2026-06-01T10:00:10Z") + writeThroughputClaudeSession(t, home, otherProject, "sess-other", "2026-06-01T10:01:00Z", "2026-06-01T10:01:10Z") + + result, err := RunSessionThroughput(context.Background(), SessionThroughputOptions{ + Source: "claude", + Project: otherProject, + Limit: 10, + }) + if err != nil { + t.Fatalf("RunSessionThroughput: %v", err) + } + if result.Scope != "project" || result.Project != otherProject { + t.Fatalf("scope/project = %q/%q, want project/%q", result.Scope, result.Project, otherProject) + } + if result.Total != 1 { + t.Fatalf("total = %d, want 1 (result=%+v)", result.Total, result) + } + group := findThroughputGroup(t, result.Groups, "claude|claude-sonnet-4|default") + if group.Sessions != 1 || len(group.Points) != 1 || group.Points[0].SessionID != "sess-other" { + t.Fatalf("throughput group = %+v", group) + } +} + +func writeThroughputClaudeSession(t *testing.T, home, project, id, started, ended string) { + t.Helper() + writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), id+".jsonl"), + map[string]any{ + "type": "user", + "sessionId": id, + "timestamp": started, + "cwd": project, + "message": map[string]any{ + "role": "user", + "content": []any{map[string]any{"type": "text", "text": "measure"}}, + }, + }, + map[string]any{ + "type": "assistant", + "sessionId": id, + "timestamp": ended, + "cwd": project, + "message": map[string]any{ + "role": "assistant", + "model": "claude-sonnet-4", + "content": []any{map[string]any{"type": "text", "text": "done"}}, + "usage": map[string]any{ + "input_tokens": 100, + "output_tokens": 50, + }, + }, + }, + ) +} + +func findThroughputGroup(t *testing.T, groups []SessionThroughputGroup, key string) SessionThroughputGroup { + t.Helper() + for _, group := range groups { + if group.Key == key { + return group + } + } + t.Fatalf("group %q not found in %+v", key, groups) + return SessionThroughputGroup{} +} + +func throughputRecord(id, source, model, effort string, start time.Time, duration time.Duration, input, output, cacheRead, cacheCreate, contextUsed, contextWindow int) SessionRecord { + end := start.Add(duration) + rec := SessionRecord{ + ID: id, + Source: source, + Model: model, + ReasoningEffort: effort, + StartedAt: &start, + EndedAt: &end, + Tokens: &SessionTokensWire{ + InputTokens: input, + OutputTokens: output, + CacheReadTokens: cacheRead, + CacheCreationTokens: cacheCreate, + TotalTokens: input + output + cacheRead + cacheCreate, + }, + } + if contextUsed > 0 || contextWindow > 0 { + rec.Context = &SessionContextWire{ + UsedTokens: contextUsed, + WindowTokens: contextWindow, + } + } + return rec +} + +func assertFloat(t *testing.T, got, want float64) { + t.Helper() + if math.Abs(got-want) > 0.000001 { + t.Fatalf("got %f, want %f", got, want) + } +} diff --git a/pkg/cli/sessions.go b/pkg/cli/sessions.go index 41850c6..ff953b3 100644 --- a/pkg/cli/sessions.go +++ b/pkg/cli/sessions.go @@ -19,10 +19,11 @@ import ( ) 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"` + Source string `flag:"source" help:"Filter source: all, claude, codex" default:"all"` + All bool `flag:"all" help:"Include sessions from all projects" short:"a"` + Project string `flag:"project" help:"Restrict sessions to an explicit project path"` + 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 { @@ -37,14 +38,16 @@ type SessionListResult struct { Total int `json:"total"` Source string `json:"source"` Scope string `json:"scope"` + Project string `json:"project,omitempty"` } type SessionLiveOptions 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, provider, pid, or health"` - Limit int `flag:"limit" help:"Maximum sessions to return" default:"25" short:"l"` - Full bool `flag:"full" help:"Parse all matching history exactly; ignores --limit"` + Source string `flag:"source" help:"Filter source: all, claude, codex" default:"all"` + All bool `flag:"all" help:"Include sessions from all projects" short:"a"` + Project string `flag:"project" help:"Restrict sessions to an explicit project path"` + Query string `flag:"q" help:"Search session id, model, cwd, branch, provider, pid, or health"` + Limit int `flag:"limit" help:"Maximum sessions to return" default:"25" short:"l"` + Full bool `flag:"full" help:"Parse all matching history exactly; ignores --limit"` } type SessionLiveResult struct { @@ -52,6 +55,7 @@ type SessionLiveResult struct { Total int `json:"total"` Source string `json:"source"` Scope string `json:"scope"` + Project string `json:"project,omitempty"` Summary SessionDashboardWire `json:"summary"` } @@ -215,10 +219,12 @@ func RunSessionList(ctx context.Context, opts SessionListOptions) (SessionListRe return SessionListResult{}, err } - candidates, err := discoverSessionCandidates(ctx, cwd, opts.All, source) + scope, projectRoot, searchAll := resolveSessionScope(cwd, opts.All, opts.Project) + candidates, err := discoverSessionCandidates(ctx, cwd, searchAll, source) if err != nil { return SessionListResult{}, err } + candidates = filterSessionCandidatesByProject(candidates, projectRoot) records := make([]SessionRecord, 0, len(candidates)) for _, candidate := range candidates { if sessionMatchesQuery(candidate.record, opts.Query) { @@ -231,15 +237,12 @@ func RunSessionList(ctx context.Context, opts SessionListOptions) (SessionListRe records = records[:opts.Limit] } - scope := "current" - if opts.All { - scope = "all" - } return SessionListResult{ Sessions: records, Total: total, Source: source, Scope: scope, + Project: projectResultValue(scope, projectRoot), }, nil } @@ -379,6 +382,47 @@ func normalizeSessionSource(source string) (string, error) { } } +func normalizeSessionProject(project string) string { + project = strings.TrimSpace(project) + if project == "" || strings.EqualFold(project, "all") { + return "" + } + if abs, err := filepath.Abs(project); err == nil { + project = abs + } + return filepath.Clean(project) +} + +func resolveSessionScope(cwd string, all bool, project string) (scope string, projectRoot string, searchAll bool) { + if project = normalizeSessionProject(project); project != "" { + return "project", sessionProjectRoot(project), true + } + if all { + return "all", "", true + } + return "current", sessionProjectRoot(cwd), false +} + +func projectResultValue(scope, projectRoot string) string { + if scope == "project" { + return projectRoot + } + return "" +} + +func filterSessionCandidatesByProject(candidates []sessionCandidate, projectRoot string) []sessionCandidate { + if projectRoot == "" { + return candidates + } + filtered := make([]sessionCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if sessionRecordMatchesProject(candidate.record, projectRoot) { + filtered = append(filtered, candidate) + } + } + return filtered +} + func discoverSessionCandidates(ctx context.Context, cwd string, searchAll bool, source string) ([]sessionCandidate, error) { var candidates []sessionCandidate if source == "all" || source == "claude" { diff --git a/pkg/cli/sessions_test.go b/pkg/cli/sessions_test.go index 14989b0..9e3cc60 100644 --- a/pkg/cli/sessions_test.go +++ b/pkg/cli/sessions_test.go @@ -306,6 +306,69 @@ func TestRunSessionLiveScopesUnmatchedProcessesToCurrentProject(t *testing.T) { } } +func TestRunSessionLiveRestrictsExplicitProject(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "work", "project") + otherProject := filepath.Join(home, "work", "other") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(otherProject, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + markProjectRoot(t, project) + markProjectRoot(t, otherProject) + + writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-current.jsonl"), + map[string]any{ + "type": "assistant", + "sessionId": "sess-current", + "timestamp": "2026-06-01T10:00:00Z", + "cwd": project, + "message": map[string]any{ + "role": "assistant", + "model": "claude-sonnet-4", + "content": []any{map[string]any{"type": "text", "text": "current"}}, + }, + }, + ) + writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(otherProject), "sess-other.jsonl"), + map[string]any{ + "type": "assistant", + "sessionId": "sess-other", + "timestamp": "2026-06-01T10:00:01Z", + "cwd": otherProject, + "message": map[string]any{ + "role": "assistant", + "model": "claude-sonnet-4", + "content": []any{map[string]any{"type": "text", "text": "other"}}, + }, + }, + ) + + orig := discoverSessionProcesses + discoverSessionProcesses = func() ([]agentProcess, error) { return nil, nil } + t.Cleanup(func() { discoverSessionProcesses = orig }) + + result, err := RunSessionLive(context.Background(), SessionLiveOptions{ + Source: "claude", + All: true, + Project: otherProject, + Limit: 10, + }) + if err != nil { + t.Fatalf("RunSessionLive: %v", err) + } + if result.Scope != "project" || result.Project != otherProject { + t.Fatalf("scope/project = %q/%q, want project/%q", result.Scope, result.Project, otherProject) + } + if result.Total != 1 || len(result.Sessions) != 1 || result.Sessions[0].ID != "sess-other" { + t.Fatalf("project-scoped sessions = %+v", result) + } +} + func TestRunSessionLiveRecordsPhaseTimings(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/pkg/cli/stdin.go b/pkg/cli/stdin.go index ee0077b..802e7d5 100644 --- a/pkg/cli/stdin.go +++ b/pkg/cli/stdin.go @@ -188,6 +188,11 @@ func codexToClaudeToolUses(uses []history.ToolUse) []claude.ToolUse { Source: source, Model: cu.Model, ReasoningEffort: cu.ReasoningEffort, + InputTokens: cu.InputTokens + cu.CacheReadTokens, + OutputTokens: cu.OutputTokens, + AgentID: cu.AgentID, + AgentType: cu.AgentType, + AgentDesc: cu.AgentDesc, Response: cu.Response, } } From 65572b768b0f39531b887ec12547ae1606428cc3 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 10:19:28 +0300 Subject: [PATCH 009/131] feat(fe): add project scope support and refactor state management Introduce project scope filtering across the UI: users can now filter sessions and dashboards by a specific project using a new combobox in the shell bar. The previous 'All projects' switch is removed. State management in PromptWorkbench is refactored from multiple useState+useEffect to a useReducer for better maintainability. Unused dependencies (class-variance-authority, clsx, tailwind-merge, @radix-ui/react-*) are removed as they are now provided by clicky-ui. Navigation links and data fetching functions are updated to include project scope via URL parameter. SessionBrowser replaces SessionViewer with SessionInspector for improved session display. Prompt run stream hook is refactored to useReducer. Added pnpm-workspace.yaml for dependency management. --- pkg/cli/webapp/package.json | 7 +- pkg/cli/webapp/pnpm-workspace.yaml | 5 + pkg/cli/webapp/src/App.tsx | 61 ++- pkg/cli/webapp/src/HomeDashboard.tsx | 301 ++++++++++- pkg/cli/webapp/src/PromptRunStream.tsx | 22 +- pkg/cli/webapp/src/PromptWorkbench.tsx | 498 +++++++++++------- pkg/cli/webapp/src/RunningPrompts.tsx | 6 +- pkg/cli/webapp/src/SessionBrowser.tsx | 79 ++- .../webapp/src/hooks/usePromptRunStream.ts | 124 +++-- pkg/cli/webapp/src/serverTiming.ts | 8 +- pkg/cli/webapp/src/sessionData.ts | 174 +++++- pkg/cli/webapp/src/shell.tsx | 64 ++- pkg/cli/webapp/src/shellHelpers.ts | 136 +++++ 13 files changed, 1135 insertions(+), 350 deletions(-) create mode 100644 pkg/cli/webapp/pnpm-workspace.yaml create mode 100644 pkg/cli/webapp/src/shellHelpers.ts diff --git a/pkg/cli/webapp/package.json b/pkg/cli/webapp/package.json index 95d4b9e..5298b87 100644 --- a/pkg/cli/webapp/package.json +++ b/pkg/cli/webapp/package.json @@ -11,22 +11,17 @@ "dependencies": { "@ai-sdk/react": "^3.0.201", "@flanksource/clicky-ui": "link:../../../../clicky-ui/packages/ui", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-slot": "^1.1.1", "@shikijs/langs": "^1.24.0", "@shikijs/themes": "^1.24.0", "@shikijs/transformers": "^1.24.0", "@tanstack/react-query": "^5.80.7", "ai": "^6.0.199", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "marked": "^15.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-rnd": "^10.5.3", "shiki": "^1.24.0", - "streamdown": "^2.5.0", - "tailwind-merge": "^2.6.0" + "streamdown": "^2.5.0" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", diff --git a/pkg/cli/webapp/pnpm-workspace.yaml b/pkg/cli/webapp/pnpm-workspace.yaml new file mode 100644 index 0000000..2245205 --- /dev/null +++ b/pkg/cli/webapp/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . + +minimumReleaseAge: 10080 +trustPolicy: no-downgrade diff --git a/pkg/cli/webapp/src/App.tsx b/pkg/cli/webapp/src/App.tsx index 442fa9c..1c16d73 100644 --- a/pkg/cli/webapp/src/App.tsx +++ b/pkg/cli/webapp/src/App.tsx @@ -1,6 +1,6 @@ -import { useMemo, type ReactNode } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { AppShell } from "@flanksource/clicky-ui/components"; +import { AppShell, type AppShellProps } from "@flanksource/clicky-ui/components"; import { RouterProvider, useBrowserRouter, @@ -14,12 +14,17 @@ import { ChatRoute } from "./ChatRoute"; import { HomeDashboard } from "./HomeDashboard"; import { PromptWorkbench } from "./PromptWorkbench"; import { SessionBrowser } from "./SessionBrowser"; +import { ShellActions } from "./shell"; import { - CAPTAIN_SIDEBAR_COLLAPSE_KEY, - ShellActions, captainNavSections, + CAPTAIN_SIDEBAR_COLLAPSE_KEY, + getProjectScopeSnapshot, + setProjectScopeInLocation, + subscribeProjectScope, + withProjectScope, type PrimaryRoute, -} from "./shell"; +} from "./shellHelpers"; +import type { ProjectScope } from "./sessionData"; export function App() { const queryClient = useMemo( @@ -31,6 +36,18 @@ export function App() { ); const router = useBrowserRouter(); const route = parseRoute(router.pathname, window.location.search); + const projectScope = useSyncExternalStore( + subscribeProjectScope, + getProjectScopeSnapshot, + getProjectScopeSnapshot, + ); + + const setProjectScope = (scope: ProjectScope) => { + setProjectScopeInLocation(scope, router.navigate, router.pathname, window.location.search); + }; + const shellActions = ( + + ); return ( @@ -40,20 +57,26 @@ export function App() { } + navSections={captainNavSections("sessions", projectScope)} + actions={shellActions} + projectScope={projectScope} /> ) : route.kind === "prompts" ? ( } + navSections={captainNavSections("prompts", projectScope)} + actions={shellActions} /> ) : ( - + {route.kind === "dashboard" ? ( - + ) : route.kind === "operations" ? ( void; - children: ReactNode; + projectScope: ProjectScope; + actions: AppShellProps["actions"]; + children: AppShellProps["children"]; }) { return ( } - navSections={captainNavSections(active)} + brand={} + navSections={captainNavSections(active, projectScope)} collapsedStorageKey={CAPTAIN_SIDEBAR_COLLAPSE_KEY} - actions={} + actions={actions} contentClassName="p-0 overflow-hidden" > {children} @@ -105,14 +132,16 @@ function CaptainShell({ function ShellBrand({ onNavigate, + projectScope, }: { onNavigate: (to: string, opts?: { replace?: boolean }) => void; + projectScope: ProjectScope; }) { return ( diff --git a/pkg/cli/webapp/src/HomeDashboard.tsx b/pkg/cli/webapp/src/HomeDashboard.tsx index cd530d3..ffb68b6 100644 --- a/pkg/cli/webapp/src/HomeDashboard.tsx +++ b/pkg/cli/webapp/src/HomeDashboard.tsx @@ -4,11 +4,11 @@ import { Button, SearchInput, SegmentedControl, - Switch, } from "@flanksource/clicky-ui/components"; import { Icon, ProgressBars, + TimeseriesPanel, UiActivity, UiArrowDown, UiArrowUp, @@ -23,12 +23,15 @@ import { UiRobotAi, UiSparkles, UiTerminal, + type TimeseriesResponse, + type TimeseriesSeries, } from "@flanksource/clicky-ui/data"; import { SOURCE_OPTIONS, commandLabel, errorMessage, fetchLiveSessions, + fetchSessionThroughput, formatCompactNumber, formatCost, formatTime, @@ -40,8 +43,12 @@ import { type SessionDashboard, type SessionLive, type SessionRecord, + type SessionThroughputGroup, + type SessionThroughputResult, + type ProjectScope, type SourceFilter, } from "./sessionData"; +import { withProjectScope } from "./shellHelpers"; type DashboardView = "list" | "cards"; type DashboardSort = "model" | "health" | "context" | "cpu" | "memory" | "tokens" | "recent"; @@ -55,6 +62,11 @@ type ProjectSessionGroup = { detail?: string; sessions: LiveSessionRecord[]; }; +type ThroughputMetric = "outputTokensPerSecond" | "contextTokensPerSecond"; +type ThroughputChartModel = { + series: TimeseriesSeries[]; + responses: Record; +}; const PERCENT_UNIT = { perBar: 25, @@ -92,22 +104,35 @@ const SESSION_COLUMNS = [ { label: "Actions" }, ] satisfies Array<{ label: string; sort?: DashboardSort }>; -export function HomeDashboard({ onNavigate }: { onNavigate: Navigate }) { +const EMPTY_SESSIONS: SessionRecord[] = []; +const EMPTY_THROUGHPUT_GROUPS: SessionThroughputGroup[] = []; + +export function HomeDashboard({ + onNavigate, + projectScope, +}: { + onNavigate: Navigate; + projectScope: ProjectScope; +}) { const [source, setSource] = useState("all"); - const [allProjects, setAllProjects] = useState(false); const [query, setQuery] = useState(""); const [view, setView] = useState("list"); const [sort, setSort] = useState("health"); const [sortDirection, setSortDirection] = useState("desc"); const sessionsQuery = useQuery({ - queryKey: ["sessions-dashboard", source, allProjects, query], - queryFn: () => fetchLiveSessions({ source, allProjects, query, limit: 200 }), + queryKey: ["sessions-dashboard", source, projectScope, query], + queryFn: () => fetchLiveSessions({ source, project: projectScope, query, limit: 200 }), refetchInterval: 5000, refetchIntervalInBackground: false, }); + const throughputQuery = useQuery({ + queryKey: ["sessions-throughput", source, projectScope, query], + queryFn: () => fetchSessionThroughput({ source, project: projectScope, query, limit: 500 }), + refetchInterval: false, + }); - const sessions = sessionsQuery.data?.sessions ?? []; + const sessions = sessionsQuery.data?.sessions ?? EMPTY_SESSIONS; const liveSessions = useMemo( () => sessions @@ -137,7 +162,7 @@ export function HomeDashboard({ onNavigate }: { onNavigate: Navigate }) { const openSession = (session: SessionRecord) => { if (session.detailAvailable === false) return; - onNavigate(`/sessions/${encodeURIComponent(session.key)}`); + onNavigate(withProjectScope(`/sessions/${encodeURIComponent(session.key)}`, projectScope)); }; const selectSort = (nextSort: DashboardSort) => { @@ -158,16 +183,17 @@ export function HomeDashboard({ onNavigate }: { onNavigate: Navigate }) { void sessionsQuery.refetch()} + loading={sessionsQuery.isFetching || throughputQuery.isFetching} + onRefresh={() => { + void sessionsQuery.refetch(); + void throughputQuery.refetch(); + }} onNewAgent={() => onNavigate("/agent")} /> @@ -183,6 +209,11 @@ export function HomeDashboard({ onNavigate }: { onNavigate: Navigate }) { liveCount={liveSessions.length} loading={sessionsQuery.isLoading} /> +
@@ -223,8 +254,6 @@ export function HomeDashboard({ onNavigate }: { onNavigate: Navigate }) { function DashboardToolbar({ source, onSourceChange, - allProjects, - onAllProjectsChange, query, onQueryChange, view, @@ -237,8 +266,6 @@ function DashboardToolbar({ }: { source: SourceFilter; onSourceChange: (source: SourceFilter) => void; - allProjects: boolean; - onAllProjectsChange: (enabled: boolean) => void; query: string; onQueryChange: (query: string) => void; view: DashboardView; @@ -270,7 +297,7 @@ function DashboardToolbar({
-
+
- buildThroughputChart(groups, "outputTokensPerSecond"), + [groups], + ); + const contextChart = useMemo( + () => buildThroughputChart(groups, "contextTokensPerSecond"), + [groups], + ); + + return ( +
+
+
+
Token Throughput
+
+ {loading ? "Loading completed sessions..." : `${result?.total ?? 0} completed sessions sampled`} +
+
+ {result?.skipped ? ( +
{result.skipped} skipped
+ ) : null} +
+ + {error ? ( +
+ {errorMessage(error)} +
+ ) : loading ? ( +
+ Loading throughput... +
+ ) : groups.length === 0 ? ( +
+ No completed sessions with duration and token usage. +
+ ) : ( + <> +
+ + +
+ + + )} +
+ ); +} + +function StaticThroughputChart({ + title, + icon, + model, + empty, +}: { + title: string; + icon: DashboardIcon; + model: ThroughputChartModel; + empty: string; +}) { + const fetcher = useMemo( + () => staticThroughputFetcher(model.responses), + [model.responses], + ); + if (model.series.length === 0) { + return ( +
+ {empty} +
+ ); + } + return ( + + ); +} + +function ThroughputTable({ groups }: { groups: SessionThroughputGroup[] }) { + return ( +
+
+ + + + + + + + + + + + + {groups.map((group) => ( + + + + + + + + + ))} + +
ModelSessionsOutputTotalContextContext Used
+
+ + + + + {group.model} + + {group.source} / {effortLabel(group.reasoningEffort) ?? "default"} + + +
+
{group.sessions}{formatRate(group.outputTokensPerSecond)}{formatRate(group.totalTokensPerSecond)}{formatOptionalRate(group.contextTokensPerSecond)}{formatOptionalPercent(group.avgContextUsedPercent)}
+
+
+ ); +} + function SessionTable({ groups, sort, @@ -488,7 +659,7 @@ function SessionHeaderCell({ return ( )} - {detail && promptOps.update && ( + {detail && !scratch && promptOps.update && (
@@ -974,27 +1060,27 @@ function Field({ label, children }: { label: string; children: ReactNode }) { } function RunnerOutput({ - renderResult, + previewResult, activeRunID, }: { - renderResult?: PromptRenderResult; + previewResult?: PromptPreviewResult; activeRunID?: string; }) { if (activeRunID) { return ; } - if (renderResult) { + if (previewResult) { return (
- {renderResult.validationError && ( + {previewResult.validationError && (
- {renderResult.validationError} + {previewResult.validationError}
)} - +
); @@ -1070,13 +1156,26 @@ function SchemaPanel({ function CreatePromptModal({ open, + ...props +}: { + open: boolean; + onClose: () => void; + sources: Array<{ id: string; label: string }>; + createOp?: ResolvedOperation; + seedContent?: string; + onCreated: (prompt: PromptDetail) => void; +}) { + if (!open) return null; + return ; +} + +function CreatePromptModalForm({ onClose, sources, createOp, seedContent, onCreated, }: { - open: boolean; onClose: () => void; sources: Array<{ id: string; label: string }>; createOp?: ResolvedOperation; @@ -1085,20 +1184,13 @@ function CreatePromptModal({ }) { const [name, setName] = useState(""); const [relPath, setRelPath] = useState(""); - const [target, setTarget] = useState(""); - const [content, setContent] = useState(defaultPromptContent("")); + const [target, setTarget] = useState(() => sources[0]?.id ?? ""); + const [content, setContent] = useState( + () => seedContent || defaultPromptContent(""), + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - useEffect(() => { - if (!open) return; - setName(""); - setRelPath(""); - setTarget(sources[0]?.id ?? ""); - setContent(seedContent || defaultPromptContent("")); - setError(undefined); - }, [open, seedContent, sources]); - async function submit() { if (!createOp) return; setLoading(true); @@ -1124,7 +1216,7 @@ function CreatePromptModal({ return ( ; +}) { + return `${sources[0]?.id ?? ""}:${seedContent ?? ""}`; +} + function resolvePromptOps(operations: ResolvedOperation[]): PromptOps { return { list: findPromptOperation(operations, "list"), @@ -1203,7 +1305,7 @@ function resolvePromptOps(operations: ResolvedOperation[]): PromptOps { create: findPromptOperation(operations, "create"), update: findPromptOperation(operations, "update"), delete: findPromptOperation(operations, "delete"), - render: findPromptOperation(operations, "action", "render"), + preview: findPromptOperation(operations, "action", "render"), run: findPromptOperation(operations, "action", "run"), }; } @@ -1248,17 +1350,6 @@ async function fetchPromptList( return unwrapResponse(response); } -async function fetchChatModels() { - const response = await fetch("/api/chat/models", { - headers: { Accept: "application/json" }, - }); - if (!response.ok) { - const message = await response.text(); - throw new Error(message || `Model catalog failed with ${response.status}`); - } - return (await response.json()) as ChatModel[]; -} - async function fetchPermissionCatalog() { const response = await fetch("/api/captain/ai/permissions/catalog", { headers: { Accept: "application/json" }, @@ -1289,6 +1380,7 @@ type PromptSchemaBackend = { type PromptSchemaDoc = { schemaVersion: number; backends?: PromptSchemaBackend[]; + models?: ChatModel[]; spec?: JsonSchemaObject; }; @@ -1381,9 +1473,14 @@ function unwrapResponse(response: ExecutionResponse): T { function resolveOperationPath(path: string, params: Record) { let next = path; for (const [key, value] of Object.entries(params)) { - next = next.replace(`{${key}}`, encodeURIComponent(value)); + if (value) { + next = next.replace(`{${key}}`, encodeURIComponent(value)); + } else { + next = next.replace(new RegExp(`/\\{${key}\\}`, "g"), ""); + next = next.replace(`{${key}}`, ""); + } } - return next; + return next.replace(/\/{2,}/g, "/"); } function paramsWithPathValues( @@ -1410,6 +1507,14 @@ function uniqueWritableSources(prompts: PromptSummary[]) { return out; } +function isScratchPrompt(detail: PromptDetail | undefined) { + return detail?.id === SCRATCH_PROMPT_ID; +} + +function promptActionParams(detail: PromptDetail) { + return { id: isScratchPrompt(detail) ? "" : detail.id }; +} + function normalizeObjectSchema( schema: Record | undefined, ): JsonSchemaObject | undefined { @@ -1435,18 +1540,24 @@ function runtimePayload(runtime: AISpecRuntimeValue, models: ChatModel[]) { return normalizeSpecRuntimePayload( buildAISpecRuntimePayload(runtime), models, + runtime.backend, ); } function normalizeSpecRuntimePayload( payload: Record, models: ChatModel[], + backend?: string, ) { const spec = payload.spec; if (!spec || typeof spec !== "object" || Array.isArray(spec)) return payload; const specRecord = { ...(spec as Record) }; if (typeof specRecord.model === "string") { - const selected = normalizeRuntimeModel(specRecord.model, models); + const selected = normalizeRuntimeModel( + specRecord.model, + models, + typeof specRecord.backend === "string" ? specRecord.backend : backend, + ); if (selected.model && selected.model !== specRecord.model) { if (typeof specRecord.id !== "string" || !specRecord.id.trim()) { specRecord.id = specRecord.model; @@ -1504,30 +1615,51 @@ function promptSelectableModels(models: ChatModel[]) { ); } -function normalizeRuntimeModel(model: string, models: ChatModel[]) { +function normalizeRuntimeModel( + model: string, + models: ChatModel[], + backend?: string, +) { const id = model.trim(); if (!id) return { model: "", backend: "" }; - const selected = models.find((entry) => entry.id === id); + const selected = + models.find((entry) => entry.id === id && modelSupportsBackend(entry, backend)) ?? + models.find((entry) => entry.id === id); if (!selected) return { model: id, backend: "" }; - const backend = providerToBackend(selected.provider); + const selectedBackend = backendForModel(selected, backend); if ( selected.provider === "anthropic" || selected.provider === "openai" || selected.provider === "googleai" || selected.provider === "deepseek" ) { - return { model: stripProviderPrefix(id), backend }; + return { model: stripProviderPrefix(id), backend: selectedBackend }; } if ( (selected.provider === "codex-cli" || selected.provider === "codex-agent") && id.startsWith("codex-") ) { - return { model: id.slice("codex-".length), backend }; + return { model: id.slice("codex-".length), backend: selectedBackend }; } - return { model: id, backend }; + return { model: id, backend: selectedBackend }; +} + +function backendForModel(model: ChatModel, preferredBackend?: string) { + if (preferredBackend && modelSupportsBackend(model, preferredBackend)) { + return preferredBackend; + } + const backend = model.backends?.find((candidate) => candidate.trim()); + return backend ?? providerToBackend(model.provider); +} + +function modelSupportsBackend(model: ChatModel, backend?: string) { + if (!backend) return true; + const backends = model.backends?.filter(Boolean); + if (!backends || backends.length === 0) return true; + return backends.includes(backend); } function providerToBackend(provider: string) { diff --git a/pkg/cli/webapp/src/RunningPrompts.tsx b/pkg/cli/webapp/src/RunningPrompts.tsx index c01eace..e590792 100644 --- a/pkg/cli/webapp/src/RunningPrompts.tsx +++ b/pkg/cli/webapp/src/RunningPrompts.tsx @@ -9,7 +9,7 @@ const TASK_BASE = "/api/captain"; * of kind "prompt". Badge is a compact header indicator; RunsTab is the full * cross-run list backed by clicky's TaskManager. Both share the same task API. */ -function Badge({ onSelectRun }: { onSelectRun: (id: string) => void }) { +export function RunningPromptsBadge({ onSelectRun }: { onSelectRun: (id: string) => void }) { const { runs } = useTaskRuns({ kind: "prompt", status: "running", basePath: TASK_BASE }); const [open, setOpen] = useState(false); @@ -47,7 +47,7 @@ function Badge({ onSelectRun }: { onSelectRun: (id: string) => void }) { ); } -function RunsTab({ +export function RunningPromptsRunsTab({ activeRunID, onSelectRun, }: { @@ -65,5 +65,3 @@ function RunsTab({ ); } - -export const RunningPrompts = { Badge, RunsTab }; diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx index 90e28f8..7c68c5c 100644 --- a/pkg/cli/webapp/src/SessionBrowser.tsx +++ b/pkg/cli/webapp/src/SessionBrowser.tsx @@ -1,15 +1,15 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { AppShell, Button, SearchInput, SegmentedControl, - Switch, + type AppShellProps, type AppShellNavSection, } from "@flanksource/clicky-ui/components"; -import { SessionViewer, type SessionInput } from "@flanksource/clicky-ui/ai"; -import { CAPTAIN_SIDEBAR_COLLAPSE_KEY } from "./shell"; +import { SessionInspector } from "@flanksource/clicky-ui/ai"; +import { CAPTAIN_SIDEBAR_COLLAPSE_KEY, withProjectScope } from "./shellHelpers"; import { TimingBadge } from "./TimingBadge"; import type { TimingMetric } from "./serverTiming"; import { @@ -27,6 +27,7 @@ import { unifiedSessionTitle, type SessionDashboard, type SessionRecord, + type ProjectScope, type SourceFilter, type UnifiedSession, } from "./sessionData"; @@ -37,7 +38,8 @@ type SessionBrowserProps = { selectedId?: string; onNavigate: Navigate; navSections: AppShellNavSection[]; - actions: ReactNode; + actions: AppShellProps["actions"]; + projectScope: ProjectScope; }; export function SessionBrowser({ @@ -45,6 +47,7 @@ export function SessionBrowser({ onNavigate, navSections, actions, + projectScope, }: SessionBrowserProps) { return selectedId ? ( ) : ( - + ); } @@ -66,11 +75,13 @@ function SessionDetailPage({ onNavigate, navSections, actions, + projectScope, }: { selectedId: string; onNavigate: Navigate; navSections: AppShellNavSection[]; - actions: ReactNode; + actions: AppShellProps["actions"]; + projectScope: ProjectScope; }) { const detailQuery = useQuery({ queryKey: ["session", selectedId], @@ -93,7 +104,7 @@ function SessionDetailPage({ } bodyActions={
- - ); -} - function SessionCardGrid({ groups, onOpen, @@ -692,8 +571,8 @@ function SessionCardGrid({ key={session.key} role={session.detailAvailable === false ? undefined : "button"} tabIndex={session.detailAvailable === false ? undefined : 0} - onClick={() => activateSession(session, onOpen)} - onKeyDown={(event) => handleSessionKeyDown(event, session, onOpen)} + onClick={() => session.detailAvailable !== false && onOpen(session)} + onKeyDown={(event) => handleCardKeyDown(event, session, onOpen)} className="min-w-0 cursor-pointer rounded border border-border bg-card p-density-3 transition-colors hover:border-muted-foreground/40 hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[disabled=true]:cursor-default data-[disabled=true]:hover:border-border data-[disabled=true]:hover:bg-card" data-disabled={session.detailAvailable === false ? "true" : undefined} > @@ -702,7 +581,7 @@ function SessionCardGrid({
- + @@ -723,7 +602,7 @@ function SessionCardGrid({ value={ @@ -735,9 +614,11 @@ function SessionCardGrid({
-
- {commandLabel(session.live.command)} -
+ {session.live?.command ? ( +
+ {commandLabel(session.live.command)} +
+ ) : null}
))} @@ -747,168 +628,15 @@ function SessionCardGrid({ ); } -function ProjectGroupHeader({ group }: { group: ProjectSessionGroup }) { - return ( -
-
-
{group.label}
- {group.detail ? ( -
{group.detail}
- ) : null} -
-
- {group.sessions.length} -
-
- ); -} - -function UsageBarsCell({ - title, - value, - icon, - thresholds, -}: { - title: string; - value: number | undefined; - icon: DashboardIcon; - thresholds: [warning: number, danger: number]; -}) { - return ( -
- - - {formatPercent(value)} - -
- ); -} - -function SessionIdentity({ session }: { session: SessionRecord }) { - const model = modelLabel(session); - const effort = effortLabel(session.reasoningEffort); - return ( -
-
- - - - {model} - - {session.source} - -
-
- {effort ? ( - - - {effort} - - ) : null} - {sessionTitle(session)} -
-
- {session.live?.pid ? `pid ${session.live.pid} - ` : ""} - {commandLabel(session.live?.command)} -
-
- ); -} - -function StatusCell({ session }: { session: SessionRecord & { live: SessionLive } }) { - const signal = session.health?.[0]; - return ( -
-
- - {session.live.status ?? "active"} -
-
- ); -} - -function ContextCell({ - session, - expanded = false, -}: { - session: SessionRecord; - expanded?: boolean; -}) { - const percent = session.context?.freePercent; - if (percent === undefined) { - return ; - } - return ( -
-
- {percent}% free - {expanded && session.context?.windowTokens ? ( - - {formatCompactNumber(session.context.windowTokens)} - - ) : null} -
-
-
-
-
- ); -} - -function SessionActions({ - session, - onOpen, - compact = false, -}: { - session: SessionRecord; - onOpen: (session: SessionRecord) => void; - compact?: boolean; -}) { - return ( -
- - -
- ); +function handleCardKeyDown( + event: React.KeyboardEvent, + session: SessionRecord, + onOpen: (session: SessionRecord) => void, +) { + if (event.key !== "Enter" && event.key !== " ") return; + if (session.detailAvailable === false) return; + event.preventDefault(); + onOpen(session); } function HealthPanel({ @@ -1041,89 +769,6 @@ function MetricBox({ label, value }: { label: string; value: ReactNode }) { ); } -function MetricText({ value }: { value: ReactNode }) { - return
{value}
; -} - -function hasLiveProcess(session: SessionRecord): session is LiveSessionRecord { - return Boolean(session.live); -} - -function compareSessions( - left: LiveSessionRecord, - right: LiveSessionRecord, - sort: DashboardSort, - direction: SortDirection, -) { - if (sort === "model") { - return directionalCompare( - modelLabel(left).localeCompare(modelLabel(right)) || - sessionSortTime(right) - sessionSortTime(left), - direction, - "asc", - ); - } - if (sort === "context") { - return directionalCompare( - (left.context?.freePercent ?? 101) - (right.context?.freePercent ?? 101) || - sessionSortTime(right) - sessionSortTime(left), - direction, - "asc", - ); - } - if (sort === "cpu") { - return directionalCompare( - (right.live.cpuPercent ?? -1) - (left.live.cpuPercent ?? -1) || - sessionSortTime(right) - sessionSortTime(left), - direction, - "desc", - ); - } - if (sort === "memory") { - return directionalCompare( - (right.live.memoryPercent ?? -1) - (left.live.memoryPercent ?? -1) || - sessionSortTime(right) - sessionSortTime(left), - direction, - "desc", - ); - } - if (sort === "tokens") { - return directionalCompare( - tokenTotal(right) - tokenTotal(left) || - sessionSortTime(right) - sessionSortTime(left), - direction, - "desc", - ); - } - if (sort === "recent") { - return directionalCompare( - sessionSortTime(right) - sessionSortTime(left), - direction, - "desc", - ); - } - return directionalCompare( - healthRank(right) - healthRank(left) || - (left.context?.freePercent ?? 101) - (right.context?.freePercent ?? 101) || - (right.live.cpuPercent ?? -1) - (left.live.cpuPercent ?? -1) || - sessionSortTime(right) - sessionSortTime(left), - direction, - "desc", - ); -} - -function directionalCompare(value: number, direction: SortDirection, natural: SortDirection) { - return direction === natural ? value : -value; -} - -function defaultSortDirection(sort: DashboardSort): SortDirection { - return sort === "model" || sort === "context" ? "asc" : "desc"; -} - -function tokenTotal(session: SessionRecord) { - return session.tokens?.totalTokens ?? 0; -} - function buildThroughputChart( groups: SessionThroughputGroup[], metric: ThroughputMetric, @@ -1188,76 +833,6 @@ function hashThroughputGroup(group: SessionThroughputGroup) { return hash.toString(36); } -function groupSessionsByProject(sessions: LiveSessionRecord[]): ProjectSessionGroup[] { - const groups = new Map(); - - for (const session of sessions) { - const cwd = session.live.cwd ?? session.cwd; - const key = cwd || "unknown"; - const label = projectLabel(cwd); - const existing = groups.get(key); - - if (existing) { - existing.sessions.push(session); - continue; - } - - groups.set(key, { - key, - label, - detail: cwd && cwd !== label ? cwd : undefined, - sessions: [session], - }); - } - - return [...groups.values()]; -} - -function activateSession(session: SessionRecord, onOpen: (session: SessionRecord) => void) { - if (session.detailAvailable === false) return; - onOpen(session); -} - -function handleSessionKeyDown( - event: KeyboardEvent, - session: SessionRecord, - onOpen: (session: SessionRecord) => void, -) { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - activateSession(session, onOpen); -} - -function modelLabel(session: { model?: string; provider?: string; source?: string }) { - return session.model || session.provider || session.source || ""; -} - -function modelIcon(session: { model?: string; provider?: string; source?: string }): DashboardIcon { - const value = `${session.provider ?? ""} ${session.model ?? ""} ${session.source}`.toLowerCase(); - if (value.includes("claude") || value.includes("anthropic")) return UiSparkles; - if (value.includes("codex") || value.includes("openai") || value.includes("gpt")) return UiRobotAi; - return UiTerminal; -} - -function effortLabel(value: string | undefined) { - return value ? value.replace(/_/g, " ") : undefined; -} - -function formatPercent(value: number | undefined) { - return value === undefined ? "--" : `${value.toFixed(1)}%`; -} - -function formatRelativeTime(value: string | undefined) { - if (!value) return "--"; - const time = new Date(value).getTime(); - if (Number.isNaN(time)) return value; - const delta = Date.now() - time; - if (delta < 60_000) return "now"; - if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`; - if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h ago`; - return `${Math.round(delta / 86_400_000)}d ago`; -} - function formatRate(value: number) { if (!Number.isFinite(value) || value <= 0) return "0/s"; if (value >= 1000) return `${formatCompactNumber(Math.round(value))}/s`; @@ -1273,21 +848,3 @@ function formatOptionalPercent(value: number | undefined) { if (value === undefined || !Number.isFinite(value) || value <= 0) return "--"; return `${value.toFixed(value >= 10 ? 0 : 1)}%`; } - -function contextTone(percent: number) { - if (percent <= 10) return "font-medium text-destructive"; - if (percent <= 25) return "font-medium text-amber-700"; - return "font-medium text-emerald-700"; -} - -function contextBarTone(percent: number) { - if (percent <= 10) return "bg-destructive"; - if (percent <= 25) return "bg-amber-500"; - return "bg-emerald-500"; -} - -function copySessionRef(session: SessionRecord) { - if (!navigator.clipboard) return; - const value = session.live?.pid ? `${session.source}:${session.live.pid}` : session.key; - void navigator.clipboard.writeText(value).catch(() => undefined); -} diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx index 7c68c5c..814608d 100644 --- a/pkg/cli/webapp/src/SessionBrowser.tsx +++ b/pkg/cli/webapp/src/SessionBrowser.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { AppShell, @@ -12,6 +12,14 @@ import { SessionInspector } from "@flanksource/clicky-ui/ai"; import { CAPTAIN_SIDEBAR_COLLAPSE_KEY, withProjectScope } from "./shellHelpers"; import { TimingBadge } from "./TimingBadge"; import type { TimingMetric } from "./serverTiming"; +import { + SessionTable, + compareSessions, + defaultSortDirection, + groupSessionsByProject, + type DashboardSort, + type SortDirection, +} from "./SessionTable"; import { SOURCE_OPTIONS, errorMessage, @@ -19,10 +27,7 @@ import { fetchSession, formatCompactNumber, formatCost, - formatTime, - healthClassName, sessionCostTotal, - sessionTitle, sessionToolCount, unifiedSessionTitle, type SessionDashboard, @@ -200,6 +205,26 @@ function SessionList({ error: unknown; onSelect: (session: SessionRecord) => void; }) { + const [sort, setSort] = useState("recent"); + const [sortDirection, setSortDirection] = useState("desc"); + + const groups = useMemo( + () => + groupSessionsByProject( + [...sessions].sort((left, right) => compareSessions(left, right, sort, sortDirection)), + ), + [sessions, sort, sortDirection], + ); + + const toggleSort = (nextSort: DashboardSort) => { + if (sort === nextSort) { + setSortDirection((direction) => (direction === "asc" ? "desc" : "asc")); + return; + } + setSort(nextSort); + setSortDirection(defaultSortDirection(nextSort)); + }; + return (
@@ -225,46 +250,19 @@ function SessionList({
-
+
{error ? ( -
{errorMessage(error)}
+
{errorMessage(error)}
) : sessions.length === 0 && !loading ? ( -
No sessions found.
+
No sessions found.
) : ( -
- {sessions.map((session) => { - const detailAvailable = session.detailAvailable !== false; - return ( - - ); - })} -
+ )}
@@ -405,33 +403,3 @@ function SessionSummary({ ); } -function SessionBadges({ session }: { session: SessionRecord }) { - const health = session.health ?? []; - const badges = [ - session.live - ? { - key: "live", - label: session.live.status || "live", - className: "border-emerald-500/40 text-emerald-700", - } - : null, - ...health.slice(0, 2).map((signal) => ({ - key: signal.kind, - label: signal.kind.replace(/_/g, " "), - className: healthClassName(signal.severity), - })), - ].filter(Boolean) as Array<{ key: string; label: string; className: string }>; - if (badges.length === 0) return null; - return ( -
- {badges.map((badge) => ( - - {badge.label} - - ))} -
- ); -} diff --git a/pkg/cli/webapp/src/SessionTable.tsx b/pkg/cli/webapp/src/SessionTable.tsx new file mode 100644 index 0000000..c79394e --- /dev/null +++ b/pkg/cli/webapp/src/SessionTable.tsx @@ -0,0 +1,537 @@ +import type { ComponentProps, KeyboardEvent, ReactNode } from "react"; +import { Button } from "@flanksource/clicky-ui/components"; +import { + CopyBadge, + Icon, + ProgressBars, + UiArrowDown, + UiArrowUp, + UiBrain, + UiChip, + UiCopy, + UiHistory, + UiLogoClaude, + UiLogoDeepseek, + UiLogoGemini, + UiLogoMistral, + UiLogoOpenai, + UiMemoryStick, + UiTerminal, +} from "@flanksource/clicky-ui/data"; +import { + commandLabel, + formatCompactNumber, + healthDotClassName, + projectLabel, + sessionSortTime, + sessionTitle, + shortID, + type SessionLive, + type SessionRecord, +} from "./sessionData"; + +export type DashboardSort = + | "model" + | "health" + | "context" + | "cpu" + | "memory" + | "tokens" + | "recent"; +export type SortDirection = "asc" | "desc"; +export type SessionIcon = NonNullable["icon"]>; +export type LiveSessionRecord = SessionRecord & { live: SessionLive }; +export type ProjectSessionGroup = { + key: string; + label: string; + detail?: string; + sessions: SessionRecord[]; +}; + +const PERCENT_UNIT = { + perBar: 25, + label: "%", + barLabel: "25%", + format: (units: number) => `${Math.round(units * 25)}`, +}; + +const SESSION_GRID_CLASS = + "grid grid-cols-[minmax(14rem,1.6fr)_5.25rem_6.25rem] sm:grid-cols-[minmax(16rem,1.7fr)_5.25rem_6.25rem_6.25rem] lg:grid-cols-[minmax(20rem,2fr)_5.5rem_7rem_7rem_7rem_6rem_7rem_5.5rem]"; + +const SESSION_COLUMNS = [ + { label: "Model", sort: "model" }, + { label: "Status", sort: "health" }, + { label: "CPU", sort: "cpu" }, + { label: "Memory", sort: "memory" }, + { label: "Context", sort: "context" }, + { label: "Tokens", sort: "tokens" }, + { label: "Updated", sort: "recent" }, + { label: "Actions" }, +] satisfies Array<{ label: string; sort?: DashboardSort }>; + +export function SessionTable({ + groups, + sort, + sortDirection, + onSortChange, + onOpen, +}: { + groups: ProjectSessionGroup[]; + sort: DashboardSort; + sortDirection: SortDirection; + onSortChange: (sort: DashboardSort) => void; + onOpen: (session: SessionRecord) => void; +}) { + return ( +
+
+ {SESSION_COLUMNS.map((column) => ( + + ))} +
+
+ {groups.map((group) => ( +
+ +
+ {group.sessions.map((session) => ( +
activateSession(session, onOpen)} + onKeyDown={(event) => handleSessionKeyDown(event, session, onOpen)} + className={`${SESSION_GRID_CLASS} cursor-pointer items-center gap-density-2 px-density-3 py-density-2 text-sm transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[disabled=true]:cursor-default data-[disabled=true]:hover:bg-transparent`} + data-disabled={session.detailAvailable === false ? "true" : undefined} + > + + + + + + + + +
+ ))} +
+
+ ))} +
+
+ ); +} + +function SessionHeaderCell({ + column, + sort, + sortDirection, + onSortChange, +}: { + column: (typeof SESSION_COLUMNS)[number]; + sort: DashboardSort; + sortDirection: SortDirection; + onSortChange: (sort: DashboardSort) => void; +}) { + if (!column.sort) { + return
{column.label}
; + } + + const active = sort === column.sort; + return ( + + ); +} + +export function ProjectGroupHeader({ group }: { group: ProjectSessionGroup }) { + return ( +
+
+
{group.label}
+ {group.detail ? ( +
{group.detail}
+ ) : null} +
+
+ {group.sessions.length} +
+
+ ); +} + +export function UsageBarsCell({ + title, + value, + icon, + thresholds, +}: { + title: string; + value: number | undefined; + icon: SessionIcon; + thresholds: [warning: number, danger: number]; +}) { + return ( +
+ + {formatPercent(value)} +
+ ); +} + +export function SessionIdentity({ session }: { session: SessionRecord }) { + const model = modelLabel(session); + const effort = effortLabel(session.reasoningEffort); + const id = shortID(session.id) || session.key; + return ( +
+
+ + + + {model} +
+
+ {effort ? ( + + + {effort} + + ) : null} + {sessionTitle(session)} +
+
event.stopPropagation()} + > + {id ? : null} + {session.live?.pid ? ( + + ) : null} +
+ {session.live?.command ? ( +
+ {commandLabel(session.live.command)} +
+ ) : null} +
+ ); +} + +function StatusCell({ session }: { session: SessionRecord }) { + const signal = session.health?.[0]; + const status = session.live?.status ?? (session.endedAt ? "ended" : "idle"); + const dot = signal + ? healthDotClassName(signal.severity) + : session.live + ? "bg-emerald-500" + : "bg-muted-foreground"; + return ( +
+
+ + {status} +
+
+ ); +} + +export function ContextCell({ + session, + expanded = false, +}: { + session: SessionRecord; + expanded?: boolean; +}) { + const percent = session.context?.freePercent; + if (percent === undefined) { + return ; + } + return ( +
+
+ {percent}% free + {expanded && session.context?.windowTokens ? ( + + {formatCompactNumber(session.context.windowTokens)} + + ) : null} +
+
+
+
+
+ ); +} + +export function SessionActions({ + session, + onOpen, + compact = false, +}: { + session: SessionRecord; + onOpen: (session: SessionRecord) => void; + compact?: boolean; +}) { + return ( +
+ + +
+ ); +} + +export function MetricText({ value }: { value: ReactNode }) { + return
{value}
; +} + +export function hasLiveProcess(session: SessionRecord): session is LiveSessionRecord { + return Boolean(session.live); +} + +export function compareSessions( + left: SessionRecord, + right: SessionRecord, + sort: DashboardSort, + direction: SortDirection, +) { + if (sort === "model") { + return directionalCompare( + modelLabel(left).localeCompare(modelLabel(right)) || + sessionSortTime(right) - sessionSortTime(left), + direction, + "asc", + ); + } + if (sort === "context") { + return directionalCompare( + (left.context?.freePercent ?? 101) - (right.context?.freePercent ?? 101) || + sessionSortTime(right) - sessionSortTime(left), + direction, + "asc", + ); + } + if (sort === "cpu") { + return directionalCompare( + (right.live?.cpuPercent ?? -1) - (left.live?.cpuPercent ?? -1) || + sessionSortTime(right) - sessionSortTime(left), + direction, + "desc", + ); + } + if (sort === "memory") { + return directionalCompare( + (right.live?.memoryPercent ?? -1) - (left.live?.memoryPercent ?? -1) || + sessionSortTime(right) - sessionSortTime(left), + direction, + "desc", + ); + } + if (sort === "tokens") { + return directionalCompare( + tokenTotal(right) - tokenTotal(left) || sessionSortTime(right) - sessionSortTime(left), + direction, + "desc", + ); + } + if (sort === "recent") { + return directionalCompare( + sessionSortTime(right) - sessionSortTime(left), + direction, + "desc", + ); + } + return directionalCompare( + healthRank(right) - healthRank(left) || + (left.context?.freePercent ?? 101) - (right.context?.freePercent ?? 101) || + (right.live?.cpuPercent ?? -1) - (left.live?.cpuPercent ?? -1) || + sessionSortTime(right) - sessionSortTime(left), + direction, + "desc", + ); +} + +function healthRank(session: SessionRecord) { + return Math.max(0, ...(session.health ?? []).map((signal) => severityRank(signal.severity))); +} + +function severityRank(severity: string | undefined) { + if (severity === "critical") return 3; + if (severity === "warning") return 2; + if (severity === "info") return 1; + return 0; +} + +function directionalCompare(value: number, direction: SortDirection, natural: SortDirection) { + return direction === natural ? value : -value; +} + +export function defaultSortDirection(sort: DashboardSort): SortDirection { + return sort === "model" || sort === "context" ? "asc" : "desc"; +} + +function tokenTotal(session: SessionRecord) { + return session.tokens?.totalTokens ?? 0; +} + +export function groupSessionsByProject(sessions: SessionRecord[]): ProjectSessionGroup[] { + const groups = new Map(); + + for (const session of sessions) { + const cwd = session.live?.cwd ?? session.cwd; + const key = cwd || "unknown"; + const label = projectLabel(cwd); + const existing = groups.get(key); + + if (existing) { + existing.sessions.push(session); + continue; + } + + groups.set(key, { + key, + label, + detail: cwd && cwd !== label ? cwd : undefined, + sessions: [session], + }); + } + + return [...groups.values()]; +} + +function activateSession(session: SessionRecord, onOpen: (session: SessionRecord) => void) { + if (session.detailAvailable === false) return; + onOpen(session); +} + +function handleSessionKeyDown( + event: KeyboardEvent, + session: SessionRecord, + onOpen: (session: SessionRecord) => void, +) { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activateSession(session, onOpen); +} + +export function modelLabel(session: { model?: string; provider?: string; source?: string }) { + return session.model || session.provider || session.source || ""; +} + +export function modelIcon(session: { + model?: string; + provider?: string; + source?: string; +}): SessionIcon { + const value = `${session.provider ?? ""} ${session.model ?? ""} ${session.source ?? ""}`.toLowerCase(); + if (value.includes("claude") || value.includes("anthropic")) return UiLogoClaude; + if (value.includes("codex") || value.includes("openai") || value.includes("gpt")) { + return UiLogoOpenai; + } + if (value.includes("gemini") || value.includes("google")) return UiLogoGemini; + if (value.includes("deepseek")) return UiLogoDeepseek; + if (value.includes("mistral")) return UiLogoMistral; + return UiTerminal; +} + +export function effortLabel(value: string | undefined) { + return value ? value.replace(/_/g, " ") : undefined; +} + +function formatPercent(value: number | undefined) { + return value === undefined ? "--" : `${value.toFixed(1)}%`; +} + +export function formatRelativeTime(value: string | undefined) { + if (!value) return "--"; + const time = new Date(value).getTime(); + if (Number.isNaN(time)) return value; + const delta = Date.now() - time; + if (delta < 60_000) return "now"; + if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`; + if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h ago`; + return `${Math.round(delta / 86_400_000)}d ago`; +} + +function contextTone(percent: number) { + if (percent <= 10) return "font-medium text-destructive"; + if (percent <= 25) return "font-medium text-amber-700"; + return "font-medium text-emerald-700"; +} + +function contextBarTone(percent: number) { + if (percent <= 10) return "bg-destructive"; + if (percent <= 25) return "bg-amber-500"; + return "bg-emerald-500"; +} + +function copySessionRef(session: SessionRecord) { + if (!navigator.clipboard) return; + const value = session.live?.pid ? `${session.source}:${session.live.pid}` : session.key; + void navigator.clipboard.writeText(value).catch(() => undefined); +} diff --git a/pkg/cli/webapp/src/sessionData.ts b/pkg/cli/webapp/src/sessionData.ts index 6855281..34e3ed3 100644 --- a/pkg/cli/webapp/src/sessionData.ts +++ b/pkg/cli/webapp/src/sessionData.ts @@ -19,6 +19,10 @@ export type SessionRecord = { key: string; id: string; source: "claude" | "codex"; + project?: string; + slug?: string; + title?: string; + initialPrompt?: string; startedAt?: string; endedAt?: string; model?: string; @@ -153,6 +157,8 @@ export type UnifiedSession = { project?: string; cwd?: string; slug?: string; + title?: string; + initialPrompt?: string; version?: string; provider?: string; model?: string; @@ -385,6 +391,8 @@ export function sessionToolCount(messages: SessionUIMessage[] | undefined): numb } export function unifiedSessionTitle(session: UnifiedSession): string { + if (session.title) return session.title; + if (session.slug) return session.slug; if (session.git?.branch) return `${session.git.branch} - ${shortID(session.id)}`; if (session.model) return `${session.model} - ${shortID(session.id)}`; return shortID(session.id); @@ -394,6 +402,8 @@ export function sessionTitle(session: SessionRecord) { if (session.detailAvailable === false && session.live?.pid) { return `${session.source} process ${session.live.pid}`; } + if (session.title) return session.title; + if (session.slug) return session.slug; if (session.gitBranch) return `${session.gitBranch} - ${shortID(session.id)}`; if (session.model) return `${session.model} - ${shortID(session.id)}`; return shortID(session.id) || session.key; diff --git a/pkg/session/build.go b/pkg/session/build.go index d2364b3..a7f1ffc 100644 --- a/pkg/session/build.go +++ b/pkg/session/build.go @@ -2,6 +2,7 @@ package session import ( "path/filepath" + "strings" "time" "github.com/flanksource/captain/pkg/api" @@ -51,6 +52,15 @@ func buildSession(ps claude.ParsedSession) *Session { } applyMetadata(s, ps, allEntries) + if len(ps.Transcripts) > 0 { + root := ps.Transcripts[0] + s.Title = latestClaudeSessionTitle(root.ToolUses) + s.InitialPrompt = firstClaudeUserPrompt(root.Entries) + } + if s.Title == "" { + s.Title = s.Slug + } + applySessionIdentity(s) for _, e := range allEntries { if e.IsAssistantMessage() && e.Message.Usage != nil { costs = append(costs, CostFromUsage(e.Message.Usage, e.Message.Model)) @@ -150,9 +160,10 @@ func relPath(tu claude.ToolUse, path string) string { // buildPlan recovers the session plan and its lifecycle events. func buildPlan(entries []claude.HistoryEntry, uses []claude.ToolUse) *Plan { + tagged := taggedClaudePlan(uses) sp := claude.PlanFromEntries(entries) if sp == nil { - return nil + return tagged } plan := &Plan{Path: sp.Path, Slug: sp.Slug, Content: sp.Content, Explicit: sp.Explicit} for _, tu := range uses { @@ -167,9 +178,36 @@ func buildPlan(entries []claude.HistoryEntry, uses []claude.ToolUse) *Plan { } plan.Events = append(plan.Events, PlanEvent{Kind: kind, Timestamp: tu.Timestamp, Reason: reason}) } + if tagged != nil { + plan.Events = append(plan.Events, tagged.Events...) + if !sp.Explicit && strings.TrimSpace(sp.Content) == "" { + plan.Content = tagged.Content + plan.Explicit = true + } + } return plan } +func taggedClaudePlan(uses []claude.ToolUse) *Plan { + var content string + var events []PlanEvent + for _, use := range uses { + if use.Tool != "Plan" { + continue + } + value, _ := use.Input["content"].(string) + if value = strings.TrimSpace(value); value == "" { + continue + } + content = value + events = append(events, PlanEvent{Kind: PlanWrite, Timestamp: use.Timestamp}) + } + if content == "" { + return nil + } + return &Plan{Content: content, Explicit: true, Events: events} +} + // approvalStats counts approvals/denials across the session's tool uses. func approvalStats(uses []claude.ToolUse) ApprovalStats { var stats ApprovalStats @@ -196,7 +234,7 @@ func isNonApprovalActivity(tool string) bool { return true } switch tool { - case "ExitPlanMode", "User", "Assistant", "Reasoning", "Event", + case "ExitPlanMode", "Plan", "System", "User", "Assistant", "Reasoning", "Event", "ApiError", "ParseError", "Result", "SessionInit", "HookStart", "HookResponse", "StopHookSummary", "TurnDuration", "AwaySummary", "SessionTitle": @@ -208,7 +246,7 @@ func isNonApprovalActivity(tool string) bool { func isChatActivity(tool string) bool { switch tool { - case "User", "Assistant", "Reasoning": + case "System", "User", "Assistant", "Reasoning": return true default: return false diff --git a/pkg/session/build_codex.go b/pkg/session/build_codex.go index 2f674c7..fdd7f34 100644 --- a/pkg/session/build_codex.go +++ b/pkg/session/build_codex.go @@ -69,7 +69,10 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * } if tools.IsEventToolName(u.Tool) || u.Tool == "ApiError" { ev := codexUseToEvent(u) - if ev.Scope == "turn" { + if u.Tool == "MemoryCitation" { + ev.Scope = "session" + s.Events = append(s.Events, ev) + } else if ev.Scope == "turn" { turns.addEvent(u, ev) } else { s.Events = append(s.Events, ev) @@ -149,6 +152,7 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * s.Capabilities.PendingMCPServers = sortedStrings(s.Capabilities.PendingMCPServers) s.Capabilities.Agents = sortedStrings(s.Capabilities.Agents) s.Capabilities.Skills = sortedStrings(s.Capabilities.Skills) + applySessionIdentity(s) return s } @@ -158,7 +162,17 @@ func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) * func CodexPlanFromToolUses(uses []history.ToolUse) *Plan { var latest []any var ts *time.Time + var taggedContent string + var taggedEvents []PlanEvent for _, use := range uses { + if use.Tool == "Plan" { + content, _ := use.Input["content"].(string) + if content = strings.TrimSpace(content); content != "" { + taggedContent = content + taggedEvents = append(taggedEvents, PlanEvent{Kind: PlanWrite, Timestamp: use.Timestamp}) + } + continue + } if use.Tool != "TodoWrite" { continue } @@ -169,6 +183,13 @@ func CodexPlanFromToolUses(uses []history.ToolUse) *Plan { latest = todos ts = use.Timestamp } + if taggedContent != "" { + return &Plan{ + Content: taggedContent, + Explicit: true, + Events: taggedEvents, + } + } if len(latest) == 0 { return nil } @@ -238,6 +259,8 @@ func codexUseToMessage(u history.ToolUse) Message { Timestamp: u.Timestamp, } switch u.Tool { + case "System": + return Message{ID: id, Role: "system", Parts: []Part{{Type: PartText, Text: codexText(u)}}, TurnID: u.TurnID, Provenance: prov, AgentID: agentID} case "User": return Message{ID: id, Role: "user", Parts: []Part{{Type: PartText, Text: codexText(u)}}, TurnID: u.TurnID, Provenance: prov, AgentID: agentID} case "Assistant": diff --git a/pkg/session/build_codex_test.go b/pkg/session/build_codex_test.go index e434580..cb1fb2e 100644 --- a/pkg/session/build_codex_test.go +++ b/pkg/session/build_codex_test.go @@ -82,6 +82,47 @@ func TestBuildCodexSession_AttachesLatestInlinePlan(t *testing.T) { } } +func TestBuildCodexSession_TaggedPlanPrecedesTodosAndCitationIsMetadata(t *testing.T) { + ts := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + uses := []history.ToolUse{ + {Tool: "TodoWrite", Input: map[string]any{"todos": []any{ + map[string]any{"step": "short checklist", "status": "in_progress"}, + }}, Timestamp: &ts, SessionID: "cx-tagged", TurnID: "turn-1", Source: "codex"}, + {Tool: "Plan", Input: map[string]any{"content": "# Detailed plan\n\nShip it", "tag": "proposed_plan"}, Timestamp: &ts, SessionID: "cx-tagged", TurnID: "turn-1", Source: "codex"}, + {Tool: "Assistant", Input: map[string]any{"text": "Source used: https://example.com"}, Timestamp: &ts, SessionID: "cx-tagged", TurnID: "turn-1", Source: "codex"}, + {Tool: "MemoryCitation", Input: map[string]any{ + "event": "memory_citation", + "source": "codex", + "citation_entries": []string{"MEMORY.md:10-12|note=[parser seam]"}, + "rollout_ids": []string{"019f3754-ecfa-7323-a76b-a0205ea30bbe"}, + }, Timestamp: &ts, SessionID: "cx-tagged", TurnID: "turn-1", Source: "codex"}, + } + + s := buildCodexSession(uses, &history.CodexSessionInfo{ID: "cx-tagged", CWD: "/repo"}) + if s.Plan == nil || s.Plan.Content != "# Detailed plan\n\nShip it" || !s.Plan.Explicit { + t.Fatalf("plan = %+v", s.Plan) + } + if len(s.Plan.Events) != 1 || s.Plan.Events[0].Kind != PlanWrite { + t.Fatalf("plan events = %+v", s.Plan.Events) + } + if len(s.Events) != 1 || s.Events[0].Type != "memory_citation" || s.Events[0].Scope != "session" || s.Events[0].TurnID != "turn-1" { + t.Fatalf("session events = %+v", s.Events) + } + if got := s.Events[0].Data["citation_entries"]; len(got.([]string)) != 1 { + t.Fatalf("citation entries = %#v", got) + } + var foundPlan, foundAssistant bool + for _, message := range s.Messages { + for _, part := range message.Parts { + foundPlan = foundPlan || part.ToolName == "Plan" + foundAssistant = foundAssistant || part.Text == "Source used: https://example.com" + } + } + if !foundPlan || !foundAssistant { + t.Fatalf("messages = %+v", s.Messages) + } +} + func TestBuildCodexSession_MapsUserMessagesAndEvents(t *testing.T) { stream := strings.Join([]string{ `{"timestamp":"2026-07-08T11:19:57.028Z","type":"session_meta","payload":{"id":"sess-rollout","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}}`, @@ -120,6 +161,29 @@ func TestBuildCodexSession_MapsUserMessagesAndEvents(t *testing.T) { } } +func TestBuildCodexSession_DerivesIdentityAfterSystemInstructions(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-10T09:49:37.000Z","type":"session_meta","payload":{"id":"sess-identity","cwd":"/repo"}}`, + `{"timestamp":"2026-07-10T09:49:37.100Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions for /repo\n\nAlways test."}]}}`, + `{"timestamp":"2026-07-10T09:49:37.200Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Improve the Codex session parser\nwith a useful title"}]}}`, + }, "\n") + uses, err := history.ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + if err != nil { + t.Fatalf("ExtractCodexToolUsesFromReader: %v", err) + } + + s := buildCodexSession(uses, &history.CodexSessionInfo{ID: "sess-identity", CWD: "/repo"}) + if len(s.Messages) != 2 || s.Messages[0].Role != "system" || s.Messages[1].Role != "user" { + t.Fatalf("message roles = %+v, want system then user", s.Messages) + } + if got, want := s.InitialPrompt, "Improve the Codex session parser\nwith a useful title"; got != want { + t.Fatalf("initial prompt = %q, want %q", got, want) + } + if got, want := s.Title, "Improve the Codex session parser with a useful title"; got != want { + t.Fatalf("title = %q, want %q", got, want) + } +} + func TestBuildCodexSession_RichCodexMetadata(t *testing.T) { stream := strings.Join([]string{ `{"timestamp":"2026-07-09T06:13:17.184Z","type":"session_meta","payload":{"id":"rich-codex","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}}`, diff --git a/pkg/session/build_messages.go b/pkg/session/build_messages.go index 226745c..46640a4 100644 --- a/pkg/session/build_messages.go +++ b/pkg/session/build_messages.go @@ -3,6 +3,7 @@ package session import ( "sort" + "github.com/flanksource/captain/pkg/ai/assistanttags" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/claude" ) @@ -130,7 +131,22 @@ func partsFromEntry(e claude.HistoryEntry) []Part { for _, b := range e.Message.Content { switch b.Type { case claude.ContentTypeText: - if b.Text != "" { + if e.IsAssistantMessage() { + for _, segment := range assistanttags.Parse(b.Text) { + switch segment.Kind { + case assistanttags.SegmentText: + parts = append(parts, Part{Type: PartText, Text: segment.Text}) + case assistanttags.SegmentPlan: + parts = append(parts, Part{ + Type: PartTool, + ToolName: "Plan", + ToolCallID: e.UUID + "-plan", + State: ToolStateInputAvailable, + Input: marshalInput(map[string]any{"content": segment.Text, "tag": "proposed_plan"}), + }) + } + } + } else if b.Text != "" { parts = append(parts, Part{Type: PartText, Text: b.Text}) } case claude.ContentTypeThinking, claude.ContentTypeRedactedThinking: diff --git a/pkg/session/build_test.go b/pkg/session/build_test.go index 3005e4d..d506286 100644 --- a/pkg/session/build_test.go +++ b/pkg/session/build_test.go @@ -95,11 +95,44 @@ func TestChangedFiles_ReadWriteSplit(t *testing.T) { } } +func TestBuildSessionIdentityPrefersLatestRootTitle(t *testing.T) { + root := claude.ParsedTranscript{ + Path: "/p/root-sess.jsonl", + Entries: []claude.HistoryEntry{{ + SessionID: "root-sess", + Slug: "fallback-title-slug", + Message: claude.Message{ + Role: claude.MessageRoleUser, + Content: []claude.ContentBlock{{Type: claude.ContentTypeText, Text: "Inspect the session viewer"}}, + }, + }}, + ToolUses: []claude.ToolUse{ + {Tool: "SessionTitle", Input: map[string]any{"aiTitle": "Initial generated title"}}, + {Tool: "SessionTitle", Input: map[string]any{"aiTitle": "Updated generated title"}}, + }, + } + + s := buildSession(claude.ParsedSession{SessionID: "root-sess", Transcripts: []claude.ParsedTranscript{root}}) + if s.Title != "Updated generated title" { + t.Fatalf("title = %q, want latest root title", s.Title) + } + if s.InitialPrompt != "Inspect the session viewer" { + t.Fatalf("initial prompt = %q", s.InitialPrompt) + } + + rows := Rows(claude.ParsedSession{SessionID: "root-sess", Transcripts: []claude.ParsedTranscript{root}}) + if len(rows) != 1 || rows[0].Title != s.Title || rows[0].InitialPrompt != s.InitialPrompt { + t.Fatalf("row identity = %+v, session identity = %+v", rows, s) + } +} + func TestApprovalStats(t *testing.T) { uses := []claude.ToolUse{ {Tool: "Write", ToolUseID: "1"}, {Tool: "Bash", ToolUseID: "2", Denied: true, DeniedReason: "no rm -rf"}, {Tool: "ExitPlanMode", ToolUseID: "3"}, + {Tool: "Plan", ToolUseID: "4"}, + {Tool: "MemoryCitation", ToolUseID: "5"}, } got := approvalStats(uses) if got.Approved != 1 { @@ -127,6 +160,7 @@ func TestBuildSession_CostFilesPlanApprovals(t *testing.T) { e := assistantEntry("a1", "", "claude-opus-4", &claude.Usage{InputTokens: 1000, OutputTokens: 500}, claude.ContentBlock{Type: claude.ContentTypeText, Text: "hello"}, + claude.ContentBlock{Type: claude.ContentTypeText, Text: "tagged fallback"}, writeBlock, planBlock, ) // stamp ProjectRoot so changed-files relativizes. @@ -153,7 +187,7 @@ func TestBuildSession_CostFilesPlanApprovals(t *testing.T) { if want := []string{"x.go"}; !equalStrings(s.Files.Written, want) { t.Errorf("written = %v, want %v", s.Files.Written, want) } - if s.Plan == nil || !s.Plan.Explicit || s.Plan.Path != "/home/u/.claude/plans/foo.md" { + if s.Plan == nil || !s.Plan.Explicit || s.Plan.Path != "/home/u/.claude/plans/foo.md" || s.Plan.Content != "do X" { t.Errorf("plan = %+v, want explicit foo.md", s.Plan) } if len(s.Messages) == 0 || s.Messages[0].Role != "assistant" { @@ -165,6 +199,47 @@ func TestBuildSession_CostFilesPlanApprovals(t *testing.T) { } } +func TestBuildSession_TaggedPlanFallbackAndMemoryCitation(t *testing.T) { + entry := assistantEntry("tagged-1", "", "claude-opus-4", nil, + claude.ContentBlock{Type: claude.ContentTypeText, Text: ` +# Shared fallback + + +After the plan. + + + +MEMORY.md:10-12|note=[shared parser] + + +019f3754-ecfa-7323-a76b-a0205ea30bbe + +`}, + ) + uses := claude.ExtractToolUses([]claude.HistoryEntry{entry}) + s := buildSession(claude.ParsedSession{ + SessionID: "root-sess", + Transcripts: []claude.ParsedTranscript{{ + Path: "/p/root-sess.jsonl", + Entries: []claude.HistoryEntry{entry}, + ToolUses: uses, + }}, + }) + + if s.Plan == nil || s.Plan.Content != "# Shared fallback" || !s.Plan.Explicit { + t.Fatalf("plan = %+v", s.Plan) + } + if len(s.Events) != 1 || s.Events[0].Type != "memory_citation" || s.Events[0].TurnID == "" { + t.Fatalf("events = %+v", s.Events) + } + if len(s.Messages) != 1 || len(s.Messages[0].Parts) != 2 { + t.Fatalf("messages = %+v", s.Messages) + } + if s.Messages[0].Parts[0].ToolName != "Plan" || s.Messages[0].Parts[1].Text != "After the plan." { + t.Fatalf("parts = %+v", s.Messages[0].Parts) + } +} + func TestBuildSession_MetadataTurnsCapabilitiesBudget(t *testing.T) { entries := []claude.HistoryEntry{ { diff --git a/pkg/session/identity.go b/pkg/session/identity.go new file mode 100644 index 0000000..30a1eab --- /dev/null +++ b/pkg/session/identity.go @@ -0,0 +1,92 @@ +package session + +import ( + "strings" + "unicode" + + "github.com/flanksource/captain/pkg/claude" +) + +const derivedSessionTitleMaxRunes = 96 + +// applySessionIdentity fills the user-facing session identity from canonical +// messages after source-specific metadata (for example Claude's title/slug) +// has been applied. +func applySessionIdentity(s *Session) { + if s == nil { + return + } + if s.InitialPrompt == "" { + s.InitialPrompt = firstUserPrompt(s.Messages) + } + if s.Title == "" { + s.Title = deriveSessionTitle(s.InitialPrompt) + } +} + +func firstClaudeUserPrompt(entries []claude.HistoryEntry) string { + for _, entry := range entries { + if !entry.IsUserMessage() { + continue + } + for _, block := range entry.Message.Content { + if block.Type != claude.ContentTypeText { + continue + } + if text := strings.TrimSpace(block.Text); text != "" { + return text + } + } + } + return "" +} + +func latestClaudeSessionTitle(uses []claude.ToolUse) string { + var title string + for _, use := range uses { + if use.Tool != "SessionTitle" { + continue + } + if value, _ := use.Input["aiTitle"].(string); strings.TrimSpace(value) != "" { + title = strings.TrimSpace(value) + } + } + return title +} + +func firstUserPrompt(messages []Message) string { + for _, message := range messages { + if message.Role != "user" { + continue + } + for _, part := range message.Parts { + if part.Type != PartText { + continue + } + if text := strings.TrimSpace(part.Text); text != "" { + return text + } + } + } + return "" +} + +func deriveSessionTitle(prompt string) string { + collapsed := strings.Join(strings.Fields(prompt), " ") + if collapsed == "" { + return "" + } + runes := []rune(collapsed) + if len(runes) <= derivedSessionTitleMaxRunes { + return collapsed + } + + cut := derivedSessionTitleMaxRunes + for i := derivedSessionTitleMaxRunes - 1; i >= derivedSessionTitleMaxRunes/2; i-- { + if unicode.IsSpace(runes[i]) { + cut = i + break + } + } + return strings.TrimSpace(string(runes[:cut])) + "…" +} diff --git a/pkg/session/identity_test.go b/pkg/session/identity_test.go new file mode 100644 index 0000000..ab05af1 --- /dev/null +++ b/pkg/session/identity_test.go @@ -0,0 +1,20 @@ +package session + +import ( + "strings" + "testing" +) + +func TestDeriveSessionTitleCollapsesAndTruncatesAtWordBoundary(t *testing.T) { + prompt := " Improve\n\tthe parser " + strings.Repeat("carefully ", 20) + title := deriveSessionTitle(prompt) + if strings.ContainsAny(title, "\n\t") { + t.Fatalf("title contains uncollapsed whitespace: %q", title) + } + if got := len([]rune(strings.TrimSuffix(title, "…"))); got > derivedSessionTitleMaxRunes { + t.Fatalf("title has %d runes, want at most %d: %q", got, derivedSessionTitleMaxRunes, title) + } + if !strings.HasSuffix(title, "…") { + t.Fatalf("title = %q, want ellipsis", title) + } +} diff --git a/pkg/session/rows.go b/pkg/session/rows.go index 8e3401b..c9bcada 100644 --- a/pkg/session/rows.go +++ b/pkg/session/rows.go @@ -46,6 +46,8 @@ type Row struct { Model string Provider string ReasoningEffort string + Title string + InitialPrompt string Git GitState StartedAt *time.Time EndedAt *time.Time @@ -130,6 +132,16 @@ func rowFromTranscript(sessionID string, t claude.ParsedTranscript, uuidOwner ma } } r.Cost = costs.Sum() + if !t.IsAgent { + r.Title = latestClaudeSessionTitle(t.ToolUses) + r.InitialPrompt = firstClaudeUserPrompt(t.Entries) + if r.Title == "" { + r.Title = r.Slug + } + if r.Title == "" { + r.Title = deriveSessionTitle(r.InitialPrompt) + } + } r.Usage = usageFromCost(r.Cost) r.Files = changedFiles(t.ToolUses) r.Approvals = approvalStats(t.ToolUses) @@ -151,22 +163,24 @@ func CodexRow(path string) (Row, bool) { info, _ := history.ReadCodexSessionInfo(path) s := buildCodexSession(uses, info) r := Row{ - Path: path, - ID: s.ID, - Source: "codex", - Project: s.Project, - CWD: s.CWD, - Version: s.Version, - Model: s.Model, - Provider: s.Provider, - Git: s.Git, - StartedAt: s.StartedAt, - EndedAt: s.EndedAt, - Cost: s.Cost, - Usage: s.Usage, - Files: s.Files, - Slug: s.Slug, - Plan: s.Plan, + Path: path, + ID: s.ID, + Source: "codex", + Project: s.Project, + CWD: s.CWD, + Version: s.Version, + Model: s.Model, + Provider: s.Provider, + Title: s.Title, + InitialPrompt: s.InitialPrompt, + Git: s.Git, + StartedAt: s.StartedAt, + EndedAt: s.EndedAt, + Cost: s.Cost, + Usage: s.Usage, + Files: s.Files, + Slug: s.Slug, + Plan: s.Plan, } if info != nil { r.ReasoningEffort = info.ReasoningEffort diff --git a/pkg/session/session.go b/pkg/session/session.go index 261cb43..bbdb189 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -11,15 +11,17 @@ import ( // history/sessions commands render, the viewer consumes, and the chat/live // surfaces project from. type Session struct { - ID string `json:"id"` - Source string `json:"source,omitempty"` // "claude" | "codex" - Project string `json:"project,omitempty"` - CWD string `json:"cwd,omitempty"` - Slug string `json:"slug,omitempty"` - Version string `json:"version,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` // primary model - HistoryFile string `json:"historyFile,omitempty"` + ID string `json:"id"` + Source string `json:"source,omitempty"` // "claude" | "codex" + Project string `json:"project,omitempty"` + CWD string `json:"cwd,omitempty"` + Slug string `json:"slug,omitempty"` + Title string `json:"title,omitempty"` + InitialPrompt string `json:"initialPrompt,omitempty"` + Version string `json:"version,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` // primary model + HistoryFile string `json:"historyFile,omitempty"` Git GitState `json:"git,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"` diff --git a/pkg/session/turns.go b/pkg/session/turns.go index 6ae7e7d..1a362e5 100644 --- a/pkg/session/turns.go +++ b/pkg/session/turns.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/flanksource/captain/pkg/ai/assistanttags" "github.com/flanksource/captain/pkg/claude" ) @@ -98,6 +99,30 @@ func buildSessionMetadata(source string, entries []claude.HistoryEntry) sessionM if entry.Message.Model != "" { turn.Model = entry.Message.Model } + if entry.IsAssistantMessage() { + for _, block := range entry.Message.Content { + if block.Type != claude.ContentTypeText { + continue + } + for _, segment := range assistanttags.Parse(block.Text) { + if segment.Kind != assistanttags.SegmentMemoryCitation || segment.Citation == nil { + continue + } + b.events = append(b.events, Event{ + Type: "memory_citation", + Scope: "session", + TurnID: turn.ID, + Timestamp: cloneTime(ts), + UUID: entry.UUID, + Data: map[string]any{ + "source": "claude", + "citation_entries": segment.Citation.CitationEntries, + "rollout_ids": segment.Citation.RolloutIDs, + }, + }) + } + } + } if entry.IsAssistantMessage() && entry.Message.Usage != nil { cost := CostFromUsage(entry.Message.Usage, entry.Message.Model) turn.Cost = turn.Cost.Add(cost) From 47062b4c808c9a3f3b5afe5b707d7886b2cfd67a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 10:50:17 +0300 Subject: [PATCH 025/131] fix(ai): align model metadata and provider schema handling Align runtime model catalogs with actual adapter capabilities and preserve effort-qualified identities in AI logs. Normalize referenced schemas for OpenAI strict outputs while retaining root definitions and rejecting semantic siblings. Update Genkit and model registry metadata, including Fable support, and add regression coverage. --- .gitignore | 1 + README.md | 41 ++++++++ pkg/ai/catalog_resolve.go | 17 ++-- .../internal/gen-model-registry/patches.json | 1 + pkg/ai/middleware/logging.go | 32 +++++- pkg/ai/middleware/logging_test.go | 89 +++++++++++++++++ pkg/ai/model_registry.go | 19 ++-- pkg/ai/model_registry_test.go | 24 ++++- pkg/ai/models_codex.go | 5 + pkg/ai/models_remote.go | 20 ++-- pkg/ai/schema.go | 51 ++++++++++ pkg/ai/schema_test.go | 68 +++++++++++++ pkg/cli/ai_catalog_test.go | 18 ++-- pkg/cli/whoami.go | 98 ++++++++++++------- pkg/cli/whoami_test.go | 89 +++++++++-------- 15 files changed, 462 insertions(+), 111 deletions(-) diff --git a/.gitignore b/.gitignore index 58baf9e..84ab15a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ pkg/cli/webapp/dist/* .grite/ hack/ dist/ +.ok/ diff --git a/README.md b/README.md index e3a63c4..2426739 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,46 @@ It provides tools to: - run a web UI for launching and chatting with AI agents - configure default backend, model, and AI safety toggles +```xml h=185px w=358px preview +
+ +
$2,500
+ +

Drag to adjust — the figure updates live.

+ +
+``` + +```html preview +
+
+ +
+``` + ## What it does From the codebase, Captain is organized around a few core capabilities: @@ -611,3 +651,4 @@ Start the web UI: - 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/pkg/ai/catalog_resolve.go b/pkg/ai/catalog_resolve.go index f53253f..b2dd23e 100644 --- a/pkg/ai/catalog_resolve.go +++ b/pkg/ai/catalog_resolve.go @@ -242,13 +242,16 @@ func AgentCatalogModels(b Backend) []ModelDef { label = id } out = append(out, ModelDef{ - ID: id, - Name: label, - Backend: b, - ReleaseDate: m.ReleaseDate, - SupportedEfforts: append([]api.Effort(nil), m.SupportedEfforts...), - DefaultEffort: m.DefaultEffort, - Priority: m.Priority, + ID: id, + Name: label, + Backend: b, + ReleaseDate: m.ReleaseDate, + CapabilitiesKnown: true, + Reasoning: m.Reasoning, + Temperature: m.Temperature, + SupportedEfforts: append([]api.Effort(nil), m.SupportedEfforts...), + DefaultEffort: m.DefaultEffort, + Priority: m.Priority, }) } sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) diff --git a/pkg/ai/internal/gen-model-registry/patches.json b/pkg/ai/internal/gen-model-registry/patches.json index 3b55074..0ae589b 100644 --- a/pkg/ai/internal/gen-model-registry/patches.json +++ b/pkg/ai/internal/gen-model-registry/patches.json @@ -1,5 +1,6 @@ { "claude-sonnet-5": { "preferred": true }, + "claude-fable-5": { "preferred": true }, "claude-opus-4-8": { "preferred": true }, "claude-sonnet-4-6": { "preferred": true }, "claude-haiku-4-5": { "preferred": true }, diff --git a/pkg/ai/middleware/logging.go b/pkg/ai/middleware/logging.go index 051b596..4e60d6f 100644 --- a/pkg/ai/middleware/logging.go +++ b/pkg/ai/middleware/logging.go @@ -27,10 +27,11 @@ func (l *loggingProvider) GetBackend() ai.Backend { return l.provider.GetBackend func (l *loggingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { start := time.Now() backend, model := logRuntime(l.provider, req) + identity := runtimeLogIdentity(backend, model, req.Effort) dispatch := clicky.Text(""). Add(icons.AI). - Append(fmt.Sprintf(" %s/%s", backend, model), "text-purple-600 font-medium") + Append(" "+identity, "text-purple-600 font-medium") if req.Prompt.Source != "" { dispatch = dispatch.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") } @@ -50,14 +51,14 @@ func (l *loggingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp if err != nil { log.Errorf("%v", clicky.Text(""). Add(icons.Error). - Append(fmt.Sprintf(" %s/%s", backend, model), "text-red-600 font-medium"). + Append(" "+identity, "text-red-600 font-medium"). Append(fmt.Sprintf(" failed after %v: %v", duration.Round(time.Millisecond), err), "text-red-500")) return resp, err } log.Infof("%v", clicky.Text(""). Add(icons.Check). - Append(fmt.Sprintf(" %s/%s", backend, model), "text-green-600 font-medium"). + Append(" "+identity, "text-green-600 font-medium"). Append(fmt.Sprintf(" %v", duration.Round(time.Millisecond)), "text-gray-500"). Append(fmt.Sprintf(" (tokens: %d in / %d out)", resp.Usage.InputTokens, resp.Usage.OutputTokens), "text-gray-400")) @@ -87,10 +88,11 @@ func (l *loggingProvider) ExecuteStream(ctx context.Context, req ai.Request) (<- return nil, fmt.Errorf("provider %s/%s does not support streaming", l.provider.GetBackend(), l.provider.GetModel()) } backend, model := logRuntime(l.provider, req) + identity := runtimeLogIdentity(backend, model, req.Effort) dispatch := clicky.Text(""). Add(icons.AI). - Append(fmt.Sprintf(" %s/%s (stream)", backend, model), "text-purple-600 font-medium") + Append(" "+identity+" (stream)", "text-purple-600 font-medium") if req.Prompt.Source != "" { dispatch = dispatch.Append(fmt.Sprintf(" [%s]", req.Prompt.Source), "text-gray-500") } @@ -115,6 +117,28 @@ func logRuntime(provider ai.Provider, req ai.Request) (api.Backend, string) { return backend, model } +// runtimeLogIdentity renders the same compact selector notation accepted by +// Captain's model flags: mode:model[:effort]. The effort suffix is omitted when +// the request leaves effort at the backend/model default. +func runtimeLogIdentity(backend api.Backend, model string, effort api.Effort) string { + prefix := string(backend) + switch backend { + case api.BackendClaudeAgent, api.BackendCodexAgent: + prefix = "agent" + case api.BackendClaudeCLI, api.BackendCodexCLI, api.BackendGeminiCLI: + prefix = "cli" + case api.BackendClaudeCmux, api.BackendCodexCmux: + prefix = "cmux" + case api.BackendAnthropic, api.BackendGemini, api.BackendOpenAI, api.BackendDeepSeek: + prefix = "api" + } + identity := prefix + ":" + model + if effort != api.EffortNone { + identity += ":" + string(effort) + } + return identity +} + func WithLogging() Option { return func(p ai.Provider) (ai.Provider, error) { return &loggingProvider{provider: p}, nil diff --git a/pkg/ai/middleware/logging_test.go b/pkg/ai/middleware/logging_test.go index fedb257..76a5b09 100644 --- a/pkg/ai/middleware/logging_test.go +++ b/pkg/ai/middleware/logging_test.go @@ -28,6 +28,95 @@ func (s stubProvider) Execute(context.Context, ai.Request) (*ai.Response, error) func (s stubProvider) GetModel() string { return "test-model" } func (s stubProvider) GetBackend() ai.Backend { return ai.BackendAnthropic } +type streamingStubProvider struct{ stubProvider } + +func (s streamingStubProvider) ExecuteStream(context.Context, ai.Request) (<-chan ai.Event, error) { + events := make(chan ai.Event) + close(events) + return events, nil +} + +func TestRuntimeLogIdentity(t *testing.T) { + tests := []struct { + name string + backend api.Backend + model string + effort api.Effort + want string + }{ + {"agent without effort", api.BackendCodexAgent, "gpt-5.6-luna", api.EffortNone, "agent:gpt-5.6-luna"}, + {"agent with effort", api.BackendCodexAgent, "gpt-5.6-sol", api.EffortMax, "agent:gpt-5.6-sol:max"}, + {"cli", api.BackendClaudeCLI, "claude-sonnet-5", api.EffortHigh, "cli:claude-sonnet-5:high"}, + {"cmux", api.BackendCodexCmux, "gpt-5.6-terra", api.EffortUltra, "cmux:gpt-5.6-terra:ultra"}, + {"api", api.BackendOpenAI, "gpt-5.6", api.EffortLow, "api:gpt-5.6:low"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := runtimeLogIdentity(tt.backend, tt.model, tt.effort); got != tt.want { + t.Fatalf("runtimeLogIdentity() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoggingProvider_UsesEffortQualifiedRuntimeIdentity(t *testing.T) { + prev := logger.GetOutput() + t.Cleanup(func() { logger.SetOutput(prev) }) + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.GetLogger("ai").SetLogLevel("info") + + p, err := WithLogging()(stubProvider{resp: &ai.Response{Model: "gpt-5.6-sol"}}) + if err != nil { + t.Fatalf("WithLogging: %v", err) + } + if _, err := p.Execute(context.Background(), ai.Request{ + Model: api.Model{Name: "gpt-5.6-sol", Backend: api.BackendCodexAgent, Effort: api.EffortMax}, + Prompt: api.Prompt{Source: "commit-grouping.prompt"}, + }); err != nil { + t.Fatalf("Execute: %v", err) + } + + got := ansi.ReplaceAllString(buf.String(), "") + for _, want := range []string{ + "✨ agent:gpt-5.6-sol:max [commit-grouping.prompt]", + "✓ agent:gpt-5.6-sol:max", + } { + if !strings.Contains(got, want) { + t.Errorf("logging middleware output missing %q\n--- output ---\n%s", want, got) + } + } +} + +func TestLoggingProvider_StreamOmitsEmptyEffort(t *testing.T) { + prev := logger.GetOutput() + t.Cleanup(func() { logger.SetOutput(prev) }) + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.GetLogger("ai").SetLogLevel("info") + + p, err := WithLogging()(streamingStubProvider{stubProvider{resp: &ai.Response{}}}) + if err != nil { + t.Fatalf("WithLogging: %v", err) + } + streamer := p.(ai.StreamingProvider) + if _, err := streamer.ExecuteStream(context.Background(), ai.Request{ + Model: api.Model{Name: "gpt-5.6-luna", Backend: api.BackendCodexAgent}, + Prompt: api.Prompt{Source: "commit-grouping.prompt"}, + }); err != nil { + t.Fatalf("ExecuteStream: %v", err) + } + + got := ansi.ReplaceAllString(buf.String(), "") + want := "✨ agent:gpt-5.6-luna (stream) [commit-grouping.prompt]" + if !strings.Contains(got, want) { + t.Errorf("stream logging output missing %q\n--- output ---\n%s", want, got) + } + if strings.Contains(got, "luna:") { + t.Errorf("stream logging unexpectedly added an effort suffix\n--- output ---\n%s", got) + } +} + func TestSchemaInJSON(t *testing.T) { if got := schemaInJSON(api.Prompt{}); got != "" { t.Fatalf("text-mode (no schema): want empty, got %q", got) diff --git a/pkg/ai/model_registry.go b/pkg/ai/model_registry.go index 8878ddb..aa35bcb 100644 --- a/pkg/ai/model_registry.go +++ b/pkg/ai/model_registry.go @@ -98,13 +98,16 @@ func registryModelAvailableForBackend(model registryModel, backend Backend) bool func registryModelDef(model registryModel, backend Backend) ModelDef { return ModelDef{ - ID: model.ID, - Name: model.Label, - Backend: backend, - ReleaseDate: model.ReleaseDate, - SupportedEfforts: append([]api.Effort(nil), model.SupportedEfforts...), - DefaultEffort: model.DefaultEffort, - Priority: model.Priority, + ID: model.ID, + Name: model.Label, + Backend: backend, + ReleaseDate: model.ReleaseDate, + CapabilitiesKnown: true, + Reasoning: model.Reasoning, + Temperature: model.Temperature, + SupportedEfforts: append([]api.Effort(nil), model.SupportedEfforts...), + DefaultEffort: model.DefaultEffort, + Priority: model.Priority, } } @@ -161,7 +164,7 @@ func registryCatalogModels() []Model { } switch m.Provider { case modelProviderAnthropic: - if m.Family == "fable" || !registryModelAvailableForBackend(m, BackendClaudeAgent) { + if !registryModelAvailableForBackend(m, BackendClaudeAgent) { continue } out = append(out, Model{ diff --git a/pkg/ai/model_registry_test.go b/pkg/ai/model_registry_test.go index f5d03ad..9d3e207 100644 --- a/pkg/ai/model_registry_test.go +++ b/pkg/ai/model_registry_test.go @@ -38,7 +38,7 @@ func TestRegistryDerivedCapabilities(t *testing.T) { wantAdaptive bool }{ {"claude-sonnet-5", true, false, true, true}, - {"claude-fable-5", true, false, false, true}, + {"claude-fable-5", true, false, true, true}, {"claude-opus-4-8", true, false, true, true}, {"claude-opus-4-7", true, false, false, true}, {"claude-sonnet-4-6", true, true, true, false}, @@ -66,6 +66,28 @@ func TestRegistryDerivedCapabilities(t *testing.T) { } } +func TestRegistryModelDefsIncludeFableCapabilities(t *testing.T) { + for _, backend := range []Backend{BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux} { + defs := RegistryModelDefs(backend) + var fable *ModelDef + for i := range defs { + if defs[i].ID == "claude-fable-5" { + fable = &defs[i] + break + } + } + if fable == nil { + t.Fatalf("%s registry models omit claude-fable-5: %+v", backend, defs) + } + if !fable.CapabilitiesKnown || !fable.Reasoning || fable.Temperature { + t.Fatalf("%s fable capabilities = %+v", backend, *fable) + } + if len(fable.SupportedEfforts) != 5 || fable.SupportedEfforts[0] != "low" || fable.SupportedEfforts[4] != "max" { + t.Fatalf("%s fable efforts = %v", backend, fable.SupportedEfforts) + } + } +} + func TestModelUsesAdaptiveThinking(t *testing.T) { cases := []struct { model string diff --git a/pkg/ai/models_codex.go b/pkg/ai/models_codex.go index 343a7f1..67c42da 100644 --- a/pkg/ai/models_codex.go +++ b/pkg/ai/models_codex.go @@ -66,6 +66,9 @@ func ParseCodexDebugModels(data []byte) ([]ModelDef, error) { } if registry, ok := RegistryModelDef(BackendCodexAgent, def.ID); ok { def.ReleaseDate = registry.ReleaseDate + def.CapabilitiesKnown = registry.CapabilitiesKnown + def.Reasoning = registry.Reasoning + def.Temperature = registry.Temperature // models.dev is the source of truth for known model effort metadata. // Codex debug output remains useful for the locally visible models, // display names, and priority, but cannot override this catalog. @@ -80,6 +83,8 @@ func ParseCodexDebugModels(data []byte) ([]ModelDef, error) { def.SupportedEfforts = append(def.SupportedEfforts, level.Effort) } } + def.CapabilitiesKnown = true + def.Reasoning = len(def.SupportedEfforts) > 0 } models = append(models, def) } diff --git a/pkg/ai/models_remote.go b/pkg/ai/models_remote.go index fb27387..0c1d689 100644 --- a/pkg/ai/models_remote.go +++ b/pkg/ai/models_remote.go @@ -17,13 +17,16 @@ import ( // and context data live in pkg/ai/pricing (sourced from OpenRouter) and are // looked up by id at render time. type ModelDef struct { - ID string `json:"id"` - Name string `json:"label,omitempty"` - Backend Backend `json:"backend,omitempty"` - ReleaseDate string `json:"releaseDate,omitempty"` - SupportedEfforts []api.Effort `json:"supportedEfforts,omitempty"` - DefaultEffort api.Effort `json:"defaultEffort,omitempty"` - Priority int `json:"priority,omitempty"` + ID string `json:"id"` + Name string `json:"label,omitempty"` + Backend Backend `json:"backend,omitempty"` + ReleaseDate string `json:"releaseDate,omitempty"` + CapabilitiesKnown bool `json:"capabilitiesKnown,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Temperature bool `json:"temperature"` + SupportedEfforts []api.Effort `json:"supportedEfforts,omitempty"` + DefaultEffort api.Effort `json:"defaultEffort,omitempty"` + Priority int `json:"priority,omitempty"` } // remoteModelsTimeout caps each /v1/models call. The configure wizard is an @@ -157,6 +160,9 @@ func doModelsRequest(req *http.Request, backend Backend) ([]ModelDef, error) { } def := ModelDef{ID: id, Name: name, Backend: backend, ReleaseDate: releaseDate} if registry, ok := RegistryModelDef(backend, id); ok { + def.CapabilitiesKnown = registry.CapabilitiesKnown + def.Reasoning = registry.Reasoning + def.Temperature = registry.Temperature def.SupportedEfforts = registry.SupportedEfforts def.DefaultEffort = registry.DefaultEffort def.Priority = registry.Priority diff --git a/pkg/ai/schema.go b/pkg/ai/schema.go index 5915336..63c549d 100644 --- a/pkg/ai/schema.go +++ b/pkg/ai/schema.go @@ -221,6 +221,13 @@ func normalizeOpenAISchema(v any, path string) error { if !ok { return nil } + refOnly, err := normalizeOpenAIRef(node, path) + if err != nil { + return err + } + if refOnly { + return nil + } if isObjectSchema(node) { if additional, exists := node["additionalProperties"]; exists { @@ -275,6 +282,50 @@ func normalizeOpenAISchema(v any, path string) error { return nil } +var openAIRefAnnotationKeywords = map[string]bool{ + "$comment": true, + "default": true, + "deprecated": true, + "description": true, + "examples": true, + "readOnly": true, + "title": true, + "writeOnly": true, +} + +// normalizeOpenAIRef makes referenced nodes conform to OpenAI's strict schema +// subset. A nested $ref must stand alone; invopop/jsonschema commonly attaches +// field annotations such as description alongside it, which OpenAI rejects. +// Root schema metadata and definitions are retained so the reference remains +// resolvable, while unexpected semantic siblings fail loudly rather than being +// silently discarded. +func normalizeOpenAIRef(node map[string]any, path string) (bool, error) { + rawRef, hasRef := node["$ref"] + if !hasRef { + return false, nil + } + ref, ok := rawRef.(string) + if !ok || strings.TrimSpace(ref) == "" { + return false, fmt.Errorf("openai schema transform: %s.$ref must be a non-empty string", path) + } + + for key := range node { + if key == "$ref" { + continue + } + if openAIRefAnnotationKeywords[key] { + delete(node, key) + continue + } + if path == "$" && (key == "$schema" || key == "$id" || key == "$defs" || key == "definitions") { + continue + } + return false, fmt.Errorf("openai schema transform: %s.$ref has unsupported sibling %q", path, key) + } + + return path != "$", nil +} + // AnthropicCompatibleSchema returns a copy of schema with constraints that // Claude structured outputs do not accept removed from the provider-facing // payload. The original schema must still be used for local validation. diff --git a/pkg/ai/schema_test.go b/pkg/ai/schema_test.go index 6e30cf9..e324cf7 100644 --- a/pkg/ai/schema_test.go +++ b/pkg/ai/schema_test.go @@ -180,6 +180,74 @@ func TestOpenAICompatibleSchema_RecursesWithoutMutatingInput(t *testing.T) { } } +func TestOpenAICompatibleSchema_RemovesAnnotationsFromReferences(t *testing.T) { + raw := json.RawMessage(`{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://example.com/plan-envelope", + "$ref":"#/$defs/PlanEnvelope", + "$defs":{ + "PlanEnvelope":{ + "type":"object", + "properties":{ + "summary":{"type":"string","description":"What the session did"}, + "plan":{"$ref":"#/$defs/PlanResult","description":"The plan this session produced"} + } + }, + "PlanResult":{ + "type":"object", + "properties":{ + "status":{"type":"string"}, + "path":{"type":"string"} + } + } + } + }`) + original := append(json.RawMessage(nil), raw...) + + got, err := OpenAICompatibleSchema(raw) + if err != nil { + t.Fatalf("OpenAICompatibleSchema: %v", err) + } + if string(raw) != string(original) { + t.Fatal("OpenAICompatibleSchema mutated its input") + } + + root := decodeSchemaObject(t, got) + if root["$ref"] != "#/$defs/PlanEnvelope" || root["$schema"] == nil || root["$id"] == nil || root["$defs"] == nil { + t.Fatalf("root reference metadata was not preserved: %#v", root) + } + defs := root["$defs"].(map[string]any) + planEnvelope := defs["PlanEnvelope"].(map[string]any) + plan := planEnvelope["properties"].(map[string]any)["plan"].(map[string]any) + if !reflect.DeepEqual(plan, map[string]any{"$ref": "#/$defs/PlanResult"}) { + t.Fatalf("plan reference = %#v, want a standalone $ref", plan) + } + summary := planEnvelope["properties"].(map[string]any)["summary"].(map[string]any) + if summary["description"] != "What the session did" { + t.Fatalf("ordinary property description was removed: %#v", summary) + } + for name, object := range map[string]map[string]any{ + "PlanEnvelope": planEnvelope, + "PlanResult": defs["PlanResult"].(map[string]any), + } { + if object["additionalProperties"] != false { + t.Errorf("%s additionalProperties = %v, want false", name, object["additionalProperties"]) + } + } +} + +func TestOpenAICompatibleSchema_RejectsSemanticReferenceSiblings(t *testing.T) { + raw := json.RawMessage(`{ + "type":"object", + "properties":{"value":{"$ref":"#/$defs/Value","minLength":1}}, + "$defs":{"Value":{"type":"string"}} + }`) + _, err := OpenAICompatibleSchema(raw) + if err == nil || !strings.Contains(err.Error(), `$.properties.value.$ref has unsupported sibling "minLength"`) { + t.Fatalf("error = %v, want path-aware unsupported sibling error", err) + } +} + func TestOpenAICompatibleSchema_RejectsOpenEndedObjects(t *testing.T) { type labels struct { Values map[string]string `json:"values"` diff --git a/pkg/cli/ai_catalog_test.go b/pkg/cli/ai_catalog_test.go index 21b931a..1394dfd 100644 --- a/pkg/cli/ai_catalog_test.go +++ b/pkg/cli/ai_catalog_test.go @@ -34,18 +34,18 @@ func TestAgentCatalogModels(t *testing.T) { }{ { backend: ai.BackendCodexCLI, - want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexCLI}}, + want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexCLI, CapabilitiesKnown: true}}, }, { backend: ai.BackendCodexAgent, - want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexAgent}}, + want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexAgent, CapabilitiesKnown: true}}, }, { // Sorted by ID; genkit (API) anthropic entry excluded. backend: ai.BackendClaudeAgent, want: []ai.ModelDef{ - {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeAgent}, - {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeAgent}, + {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeAgent, CapabilitiesKnown: true}, + {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeAgent, CapabilitiesKnown: true}, }, }, { @@ -53,8 +53,8 @@ func TestAgentCatalogModels(t *testing.T) { // its own backend. backend: ai.BackendClaudeCLI, want: []ai.ModelDef{ - {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeCLI}, - {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeCLI}, + {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeCLI, CapabilitiesKnown: true}, + {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeCLI, CapabilitiesKnown: true}, }, }, { @@ -62,14 +62,14 @@ func TestAgentCatalogModels(t *testing.T) { // its own backend. backend: ai.BackendClaudeCmux, want: []ai.ModelDef{ - {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeCmux}, - {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeCmux}, + {ID: "claude-opus-4-8", Name: "Claude Agent · Opus 4.8", Backend: ai.BackendClaudeCmux, CapabilitiesKnown: true}, + {ID: "claude-sonnet-5", Name: "Claude Agent · Sonnet 5", Backend: ai.BackendClaudeCmux, CapabilitiesKnown: true}, }, }, { // codex-cmux shares the codex-agent runtime model slug. backend: ai.BackendCodexCmux, - want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexCmux}}, + want: []ai.ModelDef{{ID: "gpt-5.5", Name: "Codex Agent · GPT-5.5", Backend: ai.BackendCodexCmux, CapabilitiesKnown: true}}, }, { // No catalog entries for gemini-cli: empty, never an error. diff --git a/pkg/cli/whoami.go b/pkg/cli/whoami.go index 5f797b5..a86051c 100644 --- a/pkg/cli/whoami.go +++ b/pkg/cli/whoami.go @@ -234,13 +234,18 @@ type modelFetch struct { var resolveModelRows = ai.ResolveModels -// fetchAPIModels resolves each provider's live /v1/models endpoint once, -// concurrently. The resolver is Captain's cached model path, so repeated whoami -// calls reuse a fresh cache instead of hitting providers every time. CLI/agent -// backends are mapped to their parent API provider before fetching. +// fetchAPIModels resolves each direct provider backend's live /v1/models +// endpoint once, concurrently. Local CLI/agent/cmux adapters deliberately do +// not participate: their model catalogs must describe the runtime they execute, +// independent of whether the parent provider's API key happens to be present. +// The resolver is Captain's cached model path, so repeated whoami calls reuse a +// fresh cache instead of hitting providers every time. func fetchAPIModels(backends []ai.Backend, getenv func(string) string) map[ai.Backend]modelFetch { apis := map[ai.Backend]bool{} for _, b := range backends { + if b.Kind() != "api" { + continue + } source := modelSourceBackend(b) if source == "" { continue @@ -269,7 +274,7 @@ func fetchAPIModels(backends []ai.Backend, getenv func(string) string) map[ai.Ba } func fetchCodexModels(backends []ai.Backend, probe authProbe) modelFetch { - if probe.codexModels == nil || firstEnv(ai.AuthEnvVars(ai.BackendOpenAI), probe.getenv) != "" { + if probe.codexModels == nil { return modelFetch{} } wanted := false @@ -314,32 +319,28 @@ func liveModelDefs(rows []ai.ResolvedModel, backend ai.Backend) []ai.ModelDef { name = id } out = append(out, ai.ModelDef{ - ID: id, - Name: name, - Backend: backend, - ReleaseDate: row.ReleaseDate, - SupportedEfforts: append([]api.Effort(nil), row.SupportedEfforts...), - DefaultEffort: row.DefaultEffort, - Priority: row.Priority, + ID: id, + Name: name, + Backend: backend, + ReleaseDate: row.ReleaseDate, + CapabilitiesKnown: true, + Reasoning: row.Reasoning, + Temperature: row.Temperature, + SupportedEfforts: append([]api.Effort(nil), row.SupportedEfforts...), + DefaultEffort: row.DefaultEffort, + Priority: row.Priority, }) } return out } // applyModels fills in the model listing (or the reason it is unavailable) for -// a single adapter. All backends use live provider model rows from Captain's -// cached resolver; CLI/agent backends map those provider rows into the runtime -// model slugs their local binaries accept. +// a single adapter. Direct API backends use live provider rows. Local adapters +// use the catalog of the runtime they execute: Codex's installed catalog when +// available, otherwise Captain's backend-specific registry projection. func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetch, codex modelFetch, getenv func(string) string) { - source := modelSourceBackend(b) - if source == "" { - st.ModelError = fmt.Sprintf("backend %s has no model listing", b) - return - } - - envVars := ai.AuthEnvVars(source) - if firstEnv(envVars, getenv) == "" { - if isCodexBackend(b) && codex.err == nil && len(codex.models) > 0 { + if isCodexBackend(b) { + if codex.err == nil && len(codex.models) > 0 { models := make([]ai.ModelDef, len(codex.models)) for i, model := range codex.models { model.Backend = b @@ -348,10 +349,22 @@ func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetc setModels(st, models, true) return } - if b.Kind() == "cli" { - setModels(st, ai.RegistryModelDefs(b), true) - return - } + setRegistryModels(st, b, codex.err) + return + } + if b.Kind() == "cli" { + setRegistryModels(st, b, nil) + return + } + + source := modelSourceBackend(b) + if source == "" { + st.ModelError = fmt.Sprintf("backend %s has no model listing", b) + return + } + + envVars := ai.AuthEnvVars(source) + if firstEnv(envVars, getenv) == "" { st.ModelError = "set " + strings.Join(envVars, " or ") + " to list models" return } @@ -367,6 +380,18 @@ func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetc setModels(st, modelsForAdapterBackend(b, fetch.models), false) } +func setRegistryModels(st *AdapterStatus, backend ai.Backend, discoveryErr error) { + setModels(st, ai.RegistryModelDefs(backend), true) + if len(st.Models) > 0 { + return + } + if discoveryErr != nil { + st.ModelError = fmt.Sprintf("runtime model discovery failed: %v; registry has no models for %s", discoveryErr, backend) + return + } + st.ModelError = fmt.Sprintf("registry has no models for %s", backend) +} + func modelSourceBackend(backend ai.Backend) ai.Backend { switch backend { case ai.BackendAnthropic, ai.BackendClaudeAgent, ai.BackendClaudeCLI, ai.BackendClaudeCmux: @@ -405,13 +430,16 @@ func modelsForAdapterBackend(backend ai.Backend, models []ai.ModelDef) []ai.Mode name = id } next := ai.ModelDef{ - ID: id, - Name: name, - Backend: backend, - ReleaseDate: model.ReleaseDate, - SupportedEfforts: append([]api.Effort(nil), model.SupportedEfforts...), - DefaultEffort: model.DefaultEffort, - Priority: model.Priority, + ID: id, + Name: name, + Backend: backend, + ReleaseDate: model.ReleaseDate, + CapabilitiesKnown: model.CapabilitiesKnown, + Reasoning: model.Reasoning, + Temperature: model.Temperature, + SupportedEfforts: append([]api.Effort(nil), model.SupportedEfforts...), + DefaultEffort: model.DefaultEffort, + Priority: model.Priority, } if idx, ok := positions[id]; ok { if modelDefNewer(next, out[idx]) { diff --git a/pkg/cli/whoami_test.go b/pkg/cli/whoami_test.go index 25e7d02..7d3d799 100644 --- a/pkg/cli/whoami_test.go +++ b/pkg/cli/whoami_test.go @@ -153,47 +153,52 @@ func TestResolveAdapter_EnvKeyPreferredOverLogin(t *testing.T) { } } -func TestProbeAdaptersUsesLiveProviderModelsForClaudeCmux(t *testing.T) { +func TestProbeAdaptersUsesRegistryModelsForClaudeCmuxRegardlessOfAPIKey(t *testing.T) { prev := resolveModelRows resolveModelRows = func(_ context.Context, opts ai.ResolveOptions) ([]ai.ResolvedModel, error) { - if opts.Backend != ai.BackendAnthropic || !opts.UseTokens { - t.Fatalf("resolve opts = %+v, want anthropic live token resolve", opts) - } - return []ai.ResolvedModel{ - {Model: ai.Model{ID: "anthropic/claude-sonnet-5", Backend: ai.BackendAnthropic, Label: "Claude Sonnet 5", ReleaseDate: "2026-05-20"}, Live: true}, - {Model: ai.Model{ID: "claude-sonnet-4-6", Backend: ai.BackendAnthropic, Label: "Claude Sonnet 4.6", ReleaseDate: "2026-05-01"}, Live: true}, - {Model: ai.Model{ID: "claude-opus-4-8", Backend: ai.BackendAnthropic, Label: "Claude Opus 4.8", ReleaseDate: "2026-04-15"}, Live: true}, - {Model: ai.Model{ID: "anthropic/claude-sonnet-static", Backend: ai.BackendAnthropic, Label: "Static Sonnet"}, Live: false}, - }, nil + t.Fatalf("local Claude adapter should not resolve provider API models: %+v", opts) + return nil, nil } t.Cleanup(func() { resolveModelRows = prev }) - adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendClaudeCmux), Models: true}, fakeProbe( - map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test"}, - map[string]string{"claude": "/usr/local/bin/claude"}, - nil, - "/home/u", - )) - if err != nil { - t.Fatalf("ProbeAdapters: %v", err) - } - if len(adapters) != 1 { - t.Fatalf("adapter count = %d, want 1", len(adapters)) - } - got := adapters[0].Models - for _, want := range []string{"claude-sonnet-5", "claude-sonnet-4-6", "claude-opus-4-8"} { - if !stringSliceContains(got, want) { - t.Fatalf("models = %v, want exact runtime model %q from live provider model list", got, want) + var withoutKey []string + for _, env := range []map[string]string{nil, map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test"}} { + adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendClaudeCmux), Models: true}, fakeProbe( + env, + map[string]string{"claude": "/usr/local/bin/claude"}, + nil, + "/home/u", + )) + if err != nil { + t.Fatalf("ProbeAdapters: %v", err) } - } - for _, rejected := range []string{"sonnet-5", "sonnet-4-6", "opus-4-8", "claude-agent-sonnet", "claude-agent-opus", "claude-sonnet-static"} { - if stringSliceContains(got, rejected) { - t.Fatalf("models = %v, should not include alias/static id %q after adapter normalization", got, rejected) + if len(adapters) != 1 || len(adapters[0].Models) == 0 { + t.Fatalf("adapters = %+v, want one adapter with registry models", adapters) + } + if !stringSliceContains(adapters[0].Models, "claude-fable-5") { + t.Fatalf("models = %v, want preferred Fable model", adapters[0].Models) + } + var fable *ai.ModelDef + for i := range adapters[0].ModelDetails { + if adapters[0].ModelDetails[i].ID == "claude-fable-5" { + fable = &adapters[0].ModelDetails[i] + break + } + } + if fable == nil || !fable.CapabilitiesKnown || !fable.Reasoning || fable.Temperature || len(fable.SupportedEfforts) != 5 { + t.Fatalf("fable model details = %+v", fable) + } + if withoutKey == nil { + withoutKey = append([]string(nil), adapters[0].Models...) + continue + } + if strings.Join(adapters[0].Models, "\x00") != strings.Join(withoutKey, "\x00") { + t.Fatalf("models with key = %v, without key = %v", adapters[0].Models, withoutKey) } } } -func TestProbeAdaptersFiltersNoisyOpenAIModelsForCodexCmux(t *testing.T) { +func TestProbeAdaptersFiltersNoisyOpenAIModelsForDirectAPI(t *testing.T) { prev := resolveModelRows resolveModelRows = func(_ context.Context, opts ai.ResolveOptions) ([]ai.ResolvedModel, error) { if opts.Backend != ai.BackendOpenAI || !opts.UseTokens { @@ -214,7 +219,7 @@ func TestProbeAdaptersFiltersNoisyOpenAIModelsForCodexCmux(t *testing.T) { } t.Cleanup(func() { resolveModelRows = prev }) - adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendCodexCmux), Models: true}, fakeProbe( + adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendOpenAI), Models: true}, fakeProbe( map[string]string{"OPENAI_API_KEY": "sk-test"}, map[string]string{"codex": "/usr/local/bin/codex"}, nil, @@ -256,8 +261,8 @@ func TestWhoamiPrettyKeylessCmuxDoesNotRequestAPIKey(t *testing.T) { } } -func TestProbeAdaptersUsesCodexDebugModelsOnceWithoutAPIKey(t *testing.T) { - probe := fakeProbe(nil, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") +func TestProbeAdaptersUsesCodexDebugModelsOnceRegardlessOfAPIKey(t *testing.T) { + probe := fakeProbe(map[string]string{"OPENAI_API_KEY": "sk-test"}, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") calls := 0 probe.codexModels = func(_ context.Context, binary string) ([]ai.ModelDef, error) { calls++ @@ -287,15 +292,19 @@ func TestProbeAdaptersUsesCodexDebugModelsOnceWithoutAPIKey(t *testing.T) { } } -func TestFetchCodexModelsSkipsDebugWithAPIKey(t *testing.T) { +func TestFetchCodexModelsUsesDebugWithAPIKey(t *testing.T) { probe := fakeProbe(map[string]string{"OPENAI_API_KEY": "sk-test"}, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") - probe.codexModels = func(context.Context, string) ([]ai.ModelDef, error) { - t.Fatal("codex debug should not run when OPENAI_API_KEY is set") - return nil, nil + calls := 0 + probe.codexModels = func(_ context.Context, binary string) ([]ai.ModelDef, error) { + calls++ + if binary != "/usr/local/bin/codex" { + t.Fatalf("binary = %q", binary) + } + return []ai.ModelDef{{ID: "gpt-5.6-sol"}}, nil } got := fetchCodexModels([]ai.Backend{ai.BackendCodexAgent}, probe) - if got.err != nil || len(got.models) != 0 { - t.Fatalf("fetch = %+v, want empty", got) + if got.err != nil || len(got.models) != 1 || calls != 1 { + t.Fatalf("fetch = %+v calls = %d, want one discovered model", got, calls) } } From 116e60d6b9b2f0231ca6bd956cce8e672cbdb793 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 10:50:17 +0300 Subject: [PATCH 026/131] chore: update generated and lock files --- go.mod | 1 - go.sum | 42 +++++++++++++++----------------------- pkg/ai/model_registry.json | 1 + 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index bfce2d1..e105494 100644 --- a/go.mod +++ b/go.mod @@ -117,7 +117,6 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/conpty v0.1.1 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/go.sum b/go.sum index b6741a8..cdf3b72 100644 --- a/go.sum +++ b/go.sum @@ -260,36 +260,16 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s= github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= -github.com/firebase/genkit/go v1.8.0 h1:jIL9xS3ZxW9sTWN2SG9RyupPd0srjXmfB1749FPIuaY= -github.com/firebase/genkit/go v1.8.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs= github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= -github.com/flanksource/clicky v1.21.37-0.20260707073215-39f9301d5c01 h1:IWoEd2O0TUwjKHcr9/gzEphYiTF3sOsduMG/FGyGD9E= -github.com/flanksource/clicky v1.21.37-0.20260707073215-39f9301d5c01/go.mod h1:832IbjMZgsqGYF2fwskgUZ6zrvf7qfxpFv3mfLO2DWg= -github.com/flanksource/clicky v1.21.42 h1:MC0uklJWq4KDg2Inq9OxT4IE3SJVqVA3Zio8DftSvK0= -github.com/flanksource/clicky v1.21.42/go.mod h1:wxgvRVic4B8cWCxQ7EO57Q6zBnV49VUoGJ0i9SMdVt4= -github.com/flanksource/clicky v1.21.43 h1:xQxHUDyKQdRFTQfhGAk5G6j2r9Du2Qq1Gfn+T7dGs98= -github.com/flanksource/clicky v1.21.43/go.mod h1:wxgvRVic4B8cWCxQ7EO57Q6zBnV49VUoGJ0i9SMdVt4= github.com/flanksource/clicky v1.21.44 h1:EwDaWXOPF7Tu5UeL/TE168n89LZqjRMUptcg1kw6884= github.com/flanksource/clicky v1.21.44/go.mod h1:wxgvRVic4B8cWCxQ7EO57Q6zBnV49VUoGJ0i9SMdVt4= -github.com/flanksource/clicky/aichat v1.21.37-0.20260707073215-39f9301d5c01 h1:3ZprIIQL7Wi1V+oKeX7cUNhwcAj0vwuxWvtGKRGp5XY= -github.com/flanksource/clicky/aichat v1.21.37-0.20260707073215-39f9301d5c01/go.mod h1:mSxTai8EkfsTngtUzeQLSMy25xV3E/D4bOaC0sF+ZGk= -github.com/flanksource/clicky/aichat v1.21.42 h1:+fPgSOVuasuZ24/wKblQ3xf4Xjzki1/lQotj5WVotcw= -github.com/flanksource/clicky/aichat v1.21.42/go.mod h1:cSgNqW+CY88qxbCNXT2fuCj4A0kpCj9dFQ1qSREVDXY= -github.com/flanksource/clicky/aichat v1.21.43 h1:374U9rSWrieiTq4vMJRCq1+iAmzJOhv6L4lS4MM9woI= -github.com/flanksource/clicky/aichat v1.21.43/go.mod h1:nXCQBU3FQfUYlMuFsyJkSUhMkR35wP5BFeQ9d2t3Fes= github.com/flanksource/clicky/aichat v1.21.44 h1:3i4oUIGK2GELNa+KjQObHBV9t8C4ee5dcTOEIV1mdSI= github.com/flanksource/clicky/aichat v1.21.44/go.mod h1:2KnxIZE6/Wu0RlaAo3qiMy8XAxBFkS01VCppojX7I8Y= 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/commons-db v0.1.14 h1:XXTRrFmHEQQKlUVjUx1rq9lAE3CGhoVEQhevd6lfN3A= -github.com/flanksource/commons-db v0.1.14/go.mod h1:EGQjugWW+QYvZcBhleb9QzDs1xhNrezf9V1NKsofQ6g= -github.com/flanksource/commons-db v0.1.20 h1:wk0ezM7oq0mQcKA+DW6DKKTuDHqgmCQkAu7ddxhd7M8= -github.com/flanksource/commons-db v0.1.20/go.mod h1:Td/fCQmw3S0hBVBHI/6pjE/xk4KB91/FI/V1QMcHV8U= github.com/flanksource/commons-db v0.1.21 h1:CC9Qxsg/jI6vnK5u61G/nuYkfbBwr3718eJqpgHfS88= github.com/flanksource/commons-db v0.1.21/go.mod h1:Td/fCQmw3S0hBVBHI/6pjE/xk4KB91/FI/V1QMcHV8U= -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/gomplate/v3 v3.24.84 h1:UOE0yCJsczTIKRaHUvhD6tjCYrbNvOugAizuy0FVlhE= github.com/flanksource/gomplate/v3 v3.24.84/go.mod h1:NMMZkFsjbLy/8iY8Fip5N86Y0PP6lZeq+kmPwpVVIL0= github.com/flanksource/is-healthy v1.0.88 h1:ATQuKoNdp8Qfzf41/eMFajmT0qzOmZlZNG5eLK41RFo= @@ -314,6 +294,10 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= @@ -417,8 +401,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -580,6 +562,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ohler55/ojg v1.28.1 h1:Xy93DelhLSZNeWv8GPKtP6qMqkUlZlAxBP/AQcC5RfY= github.com/ohler55/ojg v1.28.1/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= @@ -592,8 +576,6 @@ github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4= github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= -github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= @@ -641,6 +623,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -776,8 +760,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= -github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= @@ -1060,6 +1042,14 @@ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0x k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= diff --git a/pkg/ai/model_registry.json b/pkg/ai/model_registry.json index 78b5e44..53456bd 100644 --- a/pkg/ai/model_registry.json +++ b/pkg/ai/model_registry.json @@ -27,6 +27,7 @@ "releaseDate": "2026-06-07", "reasoning": true, "contextWindow": 1000000, + "preferred": true, "adaptiveThinking": true, "supportedEfforts": [ "low", From 258e91a0602912032d6310f017c42ca1239ab2de Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 11:14:05 +0300 Subject: [PATCH 027/131] feat(db): add session views, triggers, and deferred provenance constraints Add repeat-safe PostgreSQL views and triggers for session lifecycle state, timestamps, provenance-aware outbox events, notifications, and PostgREST session inspection surfaces. Defer cyclic provenance constraints so aggregate deletes remain atomic while standalone referenced deletions fail safely. BREAKING CHANGE: provenance foreign keys now use deferred NO ACTION instead of SET NULL. --- .gitignore | 1 + migrations/20_prompt_runs_and_plans.pg.hcl | 4 +- migrations/30_execution.pg.hcl | 4 +- migrations/50_views_and_triggers.sql | 1285 ++++++++++++++++++++ 4 files changed, 1290 insertions(+), 4 deletions(-) create mode 100644 migrations/50_views_and_triggers.sql diff --git a/.gitignore b/.gitignore index 84ab15a..7ba4f56 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ pkg/cli/webapp/dist/* hack/ dist/ .ok/ +.okignore diff --git a/migrations/20_prompt_runs_and_plans.pg.hcl b/migrations/20_prompt_runs_and_plans.pg.hcl index 177315b..db63bfe 100644 --- a/migrations/20_prompt_runs_and_plans.pg.hcl +++ b/migrations/20_prompt_runs_and_plans.pg.hcl @@ -137,7 +137,7 @@ table "captain_prompt_runs" { columns = [column.input_plan_id] ref_columns = [table.captain_plans.column.id] on_update = NO_ACTION - on_delete = SET_NULL + on_delete = NO_ACTION } foreign_key "captain_prompt_runs_input_plan_revision_id_fkey" { columns = [ @@ -374,7 +374,7 @@ table "captain_plans" { columns = [column.source_prompt_run_id] ref_columns = [table.captain_prompt_runs.column.id] on_update = NO_ACTION - on_delete = SET_NULL + on_delete = NO_ACTION } foreign_key "captain_plans_source_iteration_id_fkey" { columns = [ diff --git a/migrations/30_execution.pg.hcl b/migrations/30_execution.pg.hcl index 7873f2a..4d6062f 100644 --- a/migrations/30_execution.pg.hcl +++ b/migrations/30_execution.pg.hcl @@ -255,7 +255,7 @@ table "captain_model_calls" { columns = [column.prompt_run_id] ref_columns = [table.captain_prompt_runs.column.id] on_update = NO_ACTION - on_delete = SET_NULL + on_delete = NO_ACTION } foreign_key "captain_model_calls_iteration_id_fkey" { columns = [ @@ -512,7 +512,7 @@ table "captain_events" { columns = [column.prompt_run_id] ref_columns = [table.captain_prompt_runs.column.id] on_update = NO_ACTION - on_delete = SET_NULL + on_delete = NO_ACTION } foreign_key "captain_events_iteration_id_fkey" { columns = [ diff --git a/migrations/50_views_and_triggers.sql b/migrations/50_views_and_triggers.sql new file mode 100644 index 0000000..234c1b7 --- /dev/null +++ b/migrations/50_views_and_triggers.sql @@ -0,0 +1,1285 @@ +-- phase: post +-- runs: always + +-- This file is applied after the Atlas HCL realm by commons-db/migrate. Keep +-- every definition repeat-safe: the script deliberately runs on every apply so +-- views and triggers are restored after table reconciliation. + +-- Paired provenance foreign keys form intentional cycles across runs, plans, +-- iterations, calls and events. Deferring their NO ACTION checks allows a +-- complete session aggregate to cascade in one statement while a standalone +-- deletion of referenced provenance still fails at transaction commit. Atlas +-- OSS does not model this PostgreSQL constraint property, so the post-HCL SQL +-- phase owns it. +ALTER TABLE public.captain_prompt_runs + ALTER CONSTRAINT captain_prompt_runs_input_plan_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_prompt_runs + ALTER CONSTRAINT captain_prompt_runs_input_plan_revision_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_plans + ALTER CONSTRAINT captain_plans_source_prompt_run_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_plans + ALTER CONSTRAINT captain_plans_source_iteration_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_plans + ALTER CONSTRAINT captain_plans_approved_revision_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_model_calls + ALTER CONSTRAINT captain_model_calls_prompt_run_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_model_calls + ALTER CONSTRAINT captain_model_calls_iteration_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_events + ALTER CONSTRAINT captain_events_prompt_run_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_events + ALTER CONSTRAINT captain_events_iteration_id_fkey + DEFERRABLE INITIALLY DEFERRED; + +CREATE OR REPLACE FUNCTION public.captain_set_updated_at() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + NEW.updated_at := clock_timestamp(); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_session_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF TG_OP = 'UPDATE' THEN + IF ROW( + NEW.lifecycle_status, + NEW.activity_state, + NEW.health_state, + NEW.state_reason + ) IS DISTINCT FROM ROW( + OLD.lifecycle_status, + OLD.activity_state, + OLD.health_state, + OLD.state_reason + ) THEN + NEW.state_version := OLD.state_version + 1; + NEW.state_observed_at := observed_at; + ELSE + NEW.state_version := OLD.state_version; + NEW.state_observed_at := OLD.state_observed_at; + END IF; + END IF; + + IF NEW.lifecycle_status = 'running' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.ended_at := NULL; + ELSIF NEW.lifecycle_status IN ('succeeded', 'failed', 'cancelled', 'interrupted') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); + NEW.ended_at := COALESCE(NEW.ended_at, observed_at); + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_prompt_run_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF TG_OP = 'UPDATE' THEN + IF ROW( + NEW.phase, + NEW.state, + NEW.current_iteration, + NEW.result_text, + NEW.result_json, + NEW.error + ) IS DISTINCT FROM ROW( + OLD.phase, + OLD.state, + OLD.current_iteration, + OLD.result_text, + OLD.result_json, + OLD.error + ) THEN + NEW.version := OLD.version + 1; + ELSE + NEW.version := OLD.version; + END IF; + END IF; + + IF NEW.state IN ('running', 'waiting') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.finished_at := NULL; + ELSIF NEW.state IN ('succeeded', 'failed', 'cancelled') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.finished_at, NEW.created_at, observed_at); + NEW.finished_at := COALESCE(NEW.finished_at, observed_at); + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_prompt_iteration_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF NEW.state = 'running' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.finished_at := NULL; + ELSIF NEW.state IN ('succeeded', 'failed', 'cancelled') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.finished_at, NEW.created_at, observed_at); + NEW.finished_at := COALESCE(NEW.finished_at, observed_at); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_turn_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF NEW.status = 'open' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.ended_at := NULL; + ELSIF NEW.status IN ('ended', 'error', 'interrupted') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); + NEW.ended_at := COALESCE(NEW.ended_at, observed_at); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_model_call_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF NEW.status = 'running' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.ended_at := NULL; + ELSIF NEW.status IN ('succeeded', 'failed', 'cancelled') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); + NEW.ended_at := COALESCE(NEW.ended_at, observed_at); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_turn_request_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF ROW( + NEW.state, + NEW.response, + NEW.resolved_by, + NEW.reason + ) IS DISTINCT FROM ROW( + OLD.state, + OLD.response, + OLD.resolved_by, + OLD.reason + ) THEN + NEW.version := OLD.version + 1; + ELSE + NEW.version := OLD.version; + END IF; + END IF; + + IF NEW.state = 'pending' THEN + NEW.resolved_at := NULL; + ELSE + NEW.resolved_at := COALESCE(NEW.resolved_at, clock_timestamp()); + IF NEW.resolved_at < NEW.created_at THEN + NEW.created_at := NEW.resolved_at; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_plan_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.approval_state IN ('approved', 'rejected') THEN + NEW.approval_created_at := COALESCE(NEW.approval_created_at, clock_timestamp()); + ELSIF NEW.approval_state = 'revision_requested' THEN + NEW.feedback_at := COALESCE(NEW.feedback_at, clock_timestamp()); + END IF; + ELSIF NEW.approval_state IS DISTINCT FROM OLD.approval_state THEN + IF NEW.approval_state IN ('approved', 'rejected') THEN + NEW.approval_created_at := COALESCE(NEW.approval_created_at, clock_timestamp()); + ELSIF NEW.approval_state = 'revision_requested' THEN + NEW.feedback_at := COALESCE(NEW.feedback_at, clock_timestamp()); + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_sync_prompt_run_iteration() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + UPDATE public.captain_prompt_runs + SET current_iteration = GREATEST(current_iteration, NEW.iteration) + WHERE id = NEW.prompt_run_id + AND current_iteration < NEW.iteration; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_emit_session_change() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + row_data jsonb; + session_id_value uuid; + record_id_value uuid; + activity_at timestamptz := clock_timestamp(); +BEGIN + -- Nested updates are maintenance performed by another trigger. Cascading + -- deletes are represented by the originating session mutation instead of a + -- separate outbox row for every child. + IF pg_trigger_depth() > 1 THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + row_data := to_jsonb(OLD); + ELSE + row_data := to_jsonb(NEW); + END IF; + + record_id_value := NULLIF(row_data ->> 'id', '')::uuid; + + CASE TG_TABLE_NAME + WHEN 'captain_sessions' THEN + session_id_value := record_id_value; + WHEN 'captain_prompt_run_iterations' THEN + SELECT r.session_id + INTO session_id_value + FROM public.captain_prompt_runs r + WHERE r.id = NULLIF(row_data ->> 'prompt_run_id', '')::uuid; + WHEN 'captain_plan_revisions' THEN + SELECT p.source_session_id + INTO session_id_value + FROM public.captain_plans p + WHERE p.id = NULLIF(row_data ->> 'plan_id', '')::uuid; + WHEN 'captain_model_calls' THEN + SELECT t.session_id + INTO session_id_value + FROM public.captain_turns t + WHERE t.id = NULLIF(row_data ->> 'turn_id', '')::uuid; + ELSE + session_id_value := NULLIF( + COALESCE(row_data ->> 'session_id', row_data ->> 'source_session_id'), + '' + )::uuid; + END CASE; + + -- A direct delete of an already-detached child can legitimately have no + -- surviving aggregate. There is nothing useful to publish in that case. + IF session_id_value IS NULL THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + -- Referential actions do not reliably increase pg_trigger_depth(). Suppress + -- their child rows by observing that the owning aggregate has already gone. + -- A directly deleted child session still publishes while its root exists. + IF TG_OP = 'DELETE' THEN + IF TG_TABLE_NAME = 'captain_sessions' THEN + IF NULLIF(row_data ->> 'root_session_id', '') IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM public.captain_sessions root + WHERE root.id = NULLIF(row_data ->> 'root_session_id', '')::uuid + ) THEN + RETURN OLD; + END IF; + ELSIF NOT EXISTS ( + SELECT 1 + FROM public.captain_sessions aggregate + WHERE aggregate.id = session_id_value + ) THEN + RETURN OLD; + END IF; + END IF; + + activity_at := COALESCE( + NULLIF(row_data ->> 'occurred_at', '')::timestamptz, + NULLIF(row_data ->> 'state_observed_at', '')::timestamptz, + NULLIF(row_data ->> 'started_at', '')::timestamptz, + NULLIF(row_data ->> 'resolved_at', '')::timestamptz, + NULLIF(row_data ->> 'updated_at', '')::timestamptz, + NULLIF(row_data ->> 'recorded_at', '')::timestamptz, + NULLIF(row_data ->> 'created_at', '')::timestamptz, + clock_timestamp() + ); + + IF TG_OP <> 'DELETE' AND TG_TABLE_NAME <> 'captain_sessions' THEN + UPDATE public.captain_sessions + SET last_activity_at = CASE + WHEN last_activity_at IS NULL OR activity_at > last_activity_at THEN activity_at + ELSE last_activity_at + END + WHERE id = session_id_value; + END IF; + + INSERT INTO public.captain_outbox ( + topic, + aggregate_type, + aggregate_id, + event_type, + payload + ) VALUES ( + 'captain.session.changed', + 'session', + session_id_value, + TG_TABLE_NAME || '.' || lower(TG_OP), + jsonb_strip_nulls(jsonb_build_object( + 'table', TG_TABLE_NAME, + 'operation', lower(TG_OP), + 'record_id', record_id_value, + 'session_id', session_id_value, + 'turn_id', CASE + WHEN TG_TABLE_NAME = 'captain_turns' THEN record_id_value + ELSE NULLIF(row_data ->> 'turn_id', '')::uuid + END, + 'prompt_run_id', CASE + WHEN TG_TABLE_NAME = 'captain_prompt_runs' THEN record_id_value + ELSE NULLIF(row_data ->> 'prompt_run_id', '')::uuid + END, + 'iteration_id', CASE + WHEN TG_TABLE_NAME = 'captain_prompt_run_iterations' THEN record_id_value + ELSE NULLIF(row_data ->> 'iteration_id', '')::uuid + END, + 'plan_id', CASE + WHEN TG_TABLE_NAME = 'captain_plans' THEN record_id_value + ELSE NULLIF(row_data ->> 'plan_id', '')::uuid + END, + 'model_call_id', CASE + WHEN TG_TABLE_NAME = 'captain_model_calls' THEN record_id_value + ELSE NULLIF(row_data ->> 'model_call_id', '')::uuid + END, + 'state', row_data ->> 'state', + 'status', COALESCE(row_data ->> 'status', row_data ->> 'lifecycle_status'), + 'phase', row_data ->> 'phase', + 'occurred_at', activity_at + )) + ); + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_notify_outbox() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + PERFORM pg_notify('captain_outbox', NEW.sequence::text); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS captain_sessions_state_before ON public.captain_sessions; +CREATE TRIGGER captain_sessions_state_before +BEFORE INSERT OR UPDATE ON public.captain_sessions +FOR EACH ROW EXECUTE FUNCTION public.captain_set_session_state(); + +DROP TRIGGER IF EXISTS captain_prompt_runs_state_before ON public.captain_prompt_runs; +CREATE TRIGGER captain_prompt_runs_state_before +BEFORE INSERT OR UPDATE ON public.captain_prompt_runs +FOR EACH ROW EXECUTE FUNCTION public.captain_set_prompt_run_state(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_state_before ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_state_before +BEFORE INSERT OR UPDATE ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_set_prompt_iteration_state(); + +DROP TRIGGER IF EXISTS captain_turns_state_before ON public.captain_turns; +CREATE TRIGGER captain_turns_state_before +BEFORE INSERT OR UPDATE ON public.captain_turns +FOR EACH ROW EXECUTE FUNCTION public.captain_set_turn_state(); + +DROP TRIGGER IF EXISTS captain_model_calls_state_before ON public.captain_model_calls; +CREATE TRIGGER captain_model_calls_state_before +BEFORE INSERT OR UPDATE ON public.captain_model_calls +FOR EACH ROW EXECUTE FUNCTION public.captain_set_model_call_state(); + +DROP TRIGGER IF EXISTS captain_turn_requests_state_before ON public.captain_turn_requests; +CREATE TRIGGER captain_turn_requests_state_before +BEFORE INSERT OR UPDATE ON public.captain_turn_requests +FOR EACH ROW EXECUTE FUNCTION public.captain_set_turn_request_state(); + +DROP TRIGGER IF EXISTS captain_plans_state_before ON public.captain_plans; +CREATE TRIGGER captain_plans_state_before +BEFORE INSERT OR UPDATE ON public.captain_plans +FOR EACH ROW EXECUTE FUNCTION public.captain_set_plan_state(); + +DROP TRIGGER IF EXISTS captain_sessions_updated_at_before ON public.captain_sessions; +CREATE TRIGGER captain_sessions_updated_at_before +BEFORE UPDATE ON public.captain_sessions +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_session_processes_updated_at_before ON public.captain_session_processes; +CREATE TRIGGER captain_session_processes_updated_at_before +BEFORE UPDATE ON public.captain_session_processes +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_prompt_runs_updated_at_before ON public.captain_prompt_runs; +CREATE TRIGGER captain_prompt_runs_updated_at_before +BEFORE UPDATE ON public.captain_prompt_runs +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_updated_at_before ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_updated_at_before +BEFORE UPDATE ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_plans_updated_at_before ON public.captain_plans; +CREATE TRIGGER captain_plans_updated_at_before +BEFORE UPDATE ON public.captain_plans +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_turns_updated_at_before ON public.captain_turns; +CREATE TRIGGER captain_turns_updated_at_before +BEFORE UPDATE ON public.captain_turns +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_model_calls_updated_at_before ON public.captain_model_calls; +CREATE TRIGGER captain_model_calls_updated_at_before +BEFORE UPDATE ON public.captain_model_calls +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_session_sources_updated_at_before ON public.captain_session_sources; +CREATE TRIGGER captain_session_sources_updated_at_before +BEFORE UPDATE ON public.captain_session_sources +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_sync_after ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_sync_after +AFTER INSERT OR UPDATE OF iteration, prompt_run_id ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_sync_prompt_run_iteration(); + +DROP TRIGGER IF EXISTS captain_sessions_emit_after ON public.captain_sessions; +CREATE TRIGGER captain_sessions_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_sessions +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_session_processes_emit_after ON public.captain_session_processes; +CREATE TRIGGER captain_session_processes_emit_after +AFTER INSERT OR DELETE OR UPDATE OF + status, + command, + cwd, + surface, + lease_owner, + lease_token, + lease_expires_at, + ended_at +ON public.captain_session_processes +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_prompt_runs_emit_after ON public.captain_prompt_runs; +CREATE TRIGGER captain_prompt_runs_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_prompt_runs +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_emit_after ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_plans_emit_after ON public.captain_plans; +CREATE TRIGGER captain_plans_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_plans +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_plan_revisions_emit_after ON public.captain_plan_revisions; +CREATE TRIGGER captain_plan_revisions_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_plan_revisions +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_turns_emit_after ON public.captain_turns; +CREATE TRIGGER captain_turns_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_turns +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_model_calls_emit_after ON public.captain_model_calls; +CREATE TRIGGER captain_model_calls_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_model_calls +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_messages_emit_after ON public.captain_messages; +CREATE TRIGGER captain_messages_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_messages +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_events_emit_after ON public.captain_events; +CREATE TRIGGER captain_events_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_events +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_turn_requests_emit_after ON public.captain_turn_requests; +CREATE TRIGGER captain_turn_requests_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_turn_requests +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_artifacts_emit_after ON public.captain_artifacts; +CREATE TRIGGER captain_artifacts_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_artifacts +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_session_sources_emit_after ON public.captain_session_sources; +CREATE TRIGGER captain_session_sources_emit_after +AFTER INSERT OR DELETE OR UPDATE OF path, source_identity, parser_version +ON public.captain_session_sources +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_outbox_notify_after ON public.captain_outbox; +CREATE TRIGGER captain_outbox_notify_after +AFTER INSERT ON public.captain_outbox +FOR EACH ROW EXECUTE FUNCTION public.captain_notify_outbox(); + +-- Internal trigger functions are not PostgREST RPC endpoints. +REVOKE ALL ON FUNCTION public.captain_set_updated_at() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_session_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_prompt_run_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_prompt_iteration_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_turn_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_model_call_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_turn_request_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_plan_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_sync_prompt_run_iteration() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_emit_session_change() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_notify_outbox() FROM PUBLIC; + +CREATE OR REPLACE VIEW public.captain_session_overview +WITH (security_barrier = true) +AS +SELECT + s.id, + s.provider_session_id, + s.source, + s.provider, + s.host_id, + s.parent_session_id, + s.root_session_id, + COALESCE(s.root_session_id, s.id) AS thread_id, + s.path, + COALESCE(source.path, s.path) AS history_file, + source.source_kind, + source.parser_version, + s.project, + s.cwd, + s.title, + s.initial_prompt, + s.slug, + s.agent_type, + s.description, + s.cli_version, + s.lifecycle_status, + s.activity_state, + s.health_state, + s.state_reason, + s.state_version, + s.state_observed_at, + s.git, + s.metadata, + s.started_at, + s.ended_at, + s.last_activity_at, + s.created_at, + s.updated_at, + CASE + WHEN s.started_at IS NULL THEN NULL + ELSE EXTRACT(EPOCH FROM COALESCE(s.ended_at, clock_timestamp()) - s.started_at) + END AS duration_seconds, + process.id AS process_id, + process.pid, + process.status AS process_status, + process.command, + process.cwd AS process_cwd, + process.surface, + process.process_started_at, + process.last_heartbeat_at, + process.lease_owner, + process.lease_expires_at, + process.ended_at AS process_ended_at, + process.id IS NOT NULL AND process.ended_at IS NULL AS process_active, + COALESCE(message_stats.message_count, 0) AS message_count, + COALESCE(message_stats.tool_call_count, 0) AS tool_call_count, + COALESCE(event_stats.event_count, 0) AS event_count, + COALESCE(call_stats.turn_count, 0) AS turn_count, + COALESCE(call_stats.model_call_count, 0) AS model_call_count, + COALESCE(agent_stats.agent_count, 1) AS agent_count, + COALESCE(prompt_stats.prompt_run_count, 0) AS prompt_run_count, + COALESCE(plan_stats.plan_count, 0) AS plan_count, + COALESCE(request_stats.pending_request_count, 0) AS pending_request_count, + COALESCE(request_stats.approved_request_count, 0) AS approved_request_count, + COALESCE(request_stats.denied_request_count, 0) AS denied_request_count, + COALESCE(file_stats.file_read_count, 0) AS file_read_count, + COALESCE(file_stats.file_written_count, 0) AS file_written_count, + latest_call.model, + latest_call.backend, + latest_call.effort, + latest_call.context_tokens, + latest_call.context_window_tokens, + CASE + WHEN latest_call.context_window_tokens > 0 THEN + GREATEST( + 0, + LEAST( + 100, + round( + (1 - latest_call.context_tokens::numeric / latest_call.context_window_tokens::numeric) * 100 + )::integer + ) + ) + ELSE NULL + END AS context_free_percent, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd +FROM public.captain_sessions s +LEFT JOIN LATERAL ( + SELECT p.* + FROM public.captain_session_processes p + WHERE p.session_id = s.id + AND p.ended_at IS NULL + ORDER BY COALESCE(p.last_heartbeat_at, p.process_started_at) DESC, p.id DESC + LIMIT 1 +) process ON true +LEFT JOIN LATERAL ( + SELECT src.path, src.source_kind, src.parser_version + FROM public.captain_session_sources src + WHERE src.session_id = s.id + ORDER BY src.updated_at DESC, src.id DESC + LIMIT 1 +) source ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS message_count, + COALESCE(sum(( + SELECT count(*) + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(m.parts) = 'array' THEN m.parts + ELSE '[]'::jsonb + END + ) part + WHERE ( + part ->> 'type' = 'dynamic-tool' + OR part ->> 'type' LIKE 'tool-%' + ) + AND COALESCE(part ->> 'toolName', '') <> '' + )), 0)::bigint AS tool_call_count + FROM public.captain_messages m + WHERE m.session_id = s.id +) message_stats ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS event_count + FROM public.captain_events e + WHERE e.session_id = s.id +) event_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(DISTINCT t.id)::bigint AS turn_count, + count(c.id)::bigint AS model_call_count, + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_turns t + LEFT JOIN public.captain_model_calls c ON c.turn_id = t.id + WHERE t.session_id = s.id +) call_stats ON true +LEFT JOIN LATERAL ( + SELECT + c.model, + c.backend, + c.effort, + c.context_tokens, + c.context_window_tokens + FROM public.captain_turns t + JOIN public.captain_model_calls c ON c.turn_id = t.id + WHERE t.session_id = s.id + ORDER BY COALESCE(c.ended_at, c.started_at, c.created_at) DESC, c.call_index DESC + LIMIT 1 +) latest_call ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint + 1 AS agent_count + FROM public.captain_sessions child + WHERE child.root_session_id = s.id +) agent_stats ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS prompt_run_count + FROM public.captain_prompt_runs r + WHERE r.session_id = s.id +) prompt_stats ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS plan_count + FROM public.captain_plans p + WHERE p.source_session_id = s.id +) plan_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*) FILTER (WHERE r.state = 'pending')::bigint AS pending_request_count, + count(*) FILTER (WHERE r.state IN ('approved', 'answered'))::bigint AS approved_request_count, + count(*) FILTER (WHERE r.state = 'denied')::bigint AS denied_request_count + FROM public.captain_turn_requests r + WHERE r.session_id = s.id +) request_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*) FILTER (WHERE a.kind = 'file.read')::bigint AS file_read_count, + count(*) FILTER (WHERE a.kind IN ('file.write', 'file.edit', 'file.delete'))::bigint AS file_written_count + FROM public.captain_artifacts a + WHERE a.session_id = s.id + AND a.kind LIKE 'file.%' +) file_stats ON true; + +COMMENT ON VIEW public.captain_session_overview IS + 'One row per session for PostgREST list, metadata, health, live-process, usage and cost surfaces.'; + +CREATE OR REPLACE VIEW public.captain_session_transcript +WITH (security_barrier = true) +AS +SELECT + m.id, + m.session_id, + m.turn_id, + m.model_call_id, + m.provider_message_id, + m.sequence, + m.role, + m.parts, + m.raw, + m.schema_version, + m.occurred_at, + m.recorded_at, + c.model, + c.backend, + c.effort, + c.status AS model_call_status +FROM public.captain_messages m +LEFT JOIN public.captain_model_calls c ON c.id = m.model_call_id; + +COMMENT ON VIEW public.captain_session_transcript IS + 'Message rows for the SessionInspector transcript tab; order by sequence through PostgREST.'; + +CREATE OR REPLACE VIEW public.captain_session_turns +WITH (security_barrier = true) +AS +SELECT + t.id, + t.session_id, + t.provider_turn_id, + t.turn_index, + t.description, + t.status, + t.stop_reason, + t.error, + t.started_at, + t.ended_at, + t.created_at, + t.updated_at, + CASE + WHEN t.started_at IS NULL THEN NULL + ELSE EXTRACT(EPOCH FROM COALESCE(t.ended_at, clock_timestamp()) - t.started_at) + END AS duration_seconds, + latest_call.model, + latest_call.backend, + latest_call.effort, + latest_call.context_tokens, + latest_call.context_window_tokens, + CASE + WHEN latest_call.context_window_tokens > 0 THEN + GREATEST( + 0, + LEAST( + 100, + round( + (1 - latest_call.context_tokens::numeric / latest_call.context_window_tokens::numeric) * 100 + )::integer + ) + ) + ELSE NULL + END AS context_free_percent, + COALESCE(call_stats.model_call_count, 0) AS model_call_count, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, + COALESCE(message_stats.message_count, 0) AS message_count, + COALESCE(message_stats.message_ids, ARRAY[]::uuid[]) AS message_ids, + COALESCE(event_stats.event_count, 0) AS event_count, + COALESCE(event_stats.event_ids, ARRAY[]::uuid[]) AS event_ids +FROM public.captain_turns t +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS model_call_count, + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_model_calls c + WHERE c.turn_id = t.id +) call_stats ON true +LEFT JOIN LATERAL ( + SELECT c.model, c.backend, c.effort, c.context_tokens, c.context_window_tokens + FROM public.captain_model_calls c + WHERE c.turn_id = t.id + ORDER BY COALESCE(c.ended_at, c.started_at, c.created_at) DESC, c.call_index DESC + LIMIT 1 +) latest_call ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS message_count, + array_agg(m.id ORDER BY m.sequence) AS message_ids + FROM public.captain_messages m + WHERE m.turn_id = t.id +) message_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS event_count, + array_agg(e.id ORDER BY COALESCE(e.occurred_at, e.recorded_at), e.id) AS event_ids + FROM public.captain_events e + WHERE e.turn_id = t.id +) event_stats ON true; + +COMMENT ON VIEW public.captain_session_turns IS + 'Per-turn usage, cost, context and message/event attribution for the SessionInspector turns tab.'; + +CREATE OR REPLACE VIEW public.captain_session_agents +WITH (security_barrier = true) +AS +SELECT + s.id, + s.id AS session_id, + s.parent_session_id, + s.root_session_id, + COALESCE(s.root_session_id, s.id) AS thread_id, + s.parent_session_id IS NULL AS is_root, + s.agent_type, + s.description, + s.path AS history_file, + s.source, + s.provider, + s.lifecycle_status, + s.activity_state, + s.health_state, + s.started_at, + s.ended_at, + COALESCE(child_stats.child_count, 0) AS child_count, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd +FROM public.captain_sessions s +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS child_count + FROM public.captain_sessions child + WHERE child.parent_session_id = s.id +) child_stats ON true +LEFT JOIN LATERAL ( + SELECT + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_turns t + JOIN public.captain_model_calls c ON c.turn_id = t.id + WHERE t.session_id = s.id +) call_stats ON true; + +COMMENT ON VIEW public.captain_session_agents IS + 'Session hierarchy rows with usage and cost for the SessionInspector agents tab.'; + +CREATE OR REPLACE VIEW public.captain_session_files +WITH (security_barrier = true) +AS +SELECT + a.id, + a.session_id, + a.turn_id, + a.model_call_id, + a.prompt_run_id, + a.kind, + split_part(a.kind, '.', 2) AS operation, + a.path, + a.digest, + a.content_type, + a.metadata, + a.occurred_at, + a.created_at +FROM public.captain_artifacts a +WHERE a.kind LIKE 'file.%' + AND a.path IS NOT NULL; + +COMMENT ON VIEW public.captain_session_files IS + 'Normalized file.* artifact rows for the SessionInspector files tab.'; + +CREATE OR REPLACE VIEW public.captain_session_plans +WITH (security_barrier = true) +AS +SELECT + p.id, + p.source_session_id AS session_id, + p.source_prompt_run_id, + p.source_iteration_id, + p.source_turn_id, + p.title, + p.slug, + p.path, + p.variant, + p.spec_profile, + p.approval_state, + p.approved_revision_id, + p.approval_comment, + p.approved_by, + p.approval_created_at, + p.feedback_at, + p.created_at, + p.updated_at, + latest_revision.id AS latest_revision_id, + latest_revision.revision AS latest_revision, + latest_revision.plan_markdown AS latest_plan_markdown, + latest_revision.content_hash AS latest_content_hash, + latest_revision.feedback AS latest_feedback, + latest_revision.created_by AS latest_created_by, + latest_revision.created_at AS latest_created_at, + approved_revision.revision AS approved_revision, + approved_revision.plan_markdown AS approved_plan_markdown, + approved_revision.content_hash AS approved_content_hash, + COALESCE(approved_revision.id, latest_revision.id) AS current_revision_id, + COALESCE(approved_revision.revision, latest_revision.revision) AS current_revision, + COALESCE(approved_revision.plan_markdown, latest_revision.plan_markdown) AS plan_markdown, + COALESCE(approved_revision.content_hash, latest_revision.content_hash) AS content_hash +FROM public.captain_plans p +LEFT JOIN LATERAL ( + SELECT r.* + FROM public.captain_plan_revisions r + WHERE r.plan_id = p.id + ORDER BY r.revision DESC, r.created_at DESC, r.id DESC + LIMIT 1 +) latest_revision ON true +LEFT JOIN public.captain_plan_revisions approved_revision + ON approved_revision.id = p.approved_revision_id; + +COMMENT ON VIEW public.captain_session_plans IS + 'Plan rows with latest and approved immutable revisions for the SessionInspector plan tab.'; + +CREATE OR REPLACE VIEW public.captain_session_approvals +WITH (security_barrier = true) +AS +SELECT + r.id, + r.session_id, + r.turn_id, + r.prompt_run_id, + r.plan_id, + r.model_call_id, + r.tool_call_id, + r.kind, + r.state, + r.request, + r.response, + r.idempotency_key, + r.requested_by, + r.resolved_by, + r.reason, + r.version, + r.expires_at, + r.created_at, + r.resolved_at +FROM public.captain_turn_requests r +WHERE r.kind IN ('tool_approval', 'plan_exit_approval'); + +COMMENT ON VIEW public.captain_session_approvals IS + 'Tool and plan-exit approval rows for the SessionInspector approvals tab.'; + +CREATE OR REPLACE VIEW public.captain_session_costs +WITH (security_barrier = true) +AS +SELECT + concat_ws( + ':', + t.session_id::text, + c.model, + c.backend, + COALESCE(c.effort, 'default'), + upper(c.currency) + ) AS id, + t.session_id, + c.model, + c.backend, + c.effort, + upper(c.currency) AS currency, + count(*)::bigint AS model_call_count, + sum(c.input_tokens)::bigint AS input_tokens, + sum(c.output_tokens)::bigint AS output_tokens, + sum(c.reasoning_tokens)::bigint AS reasoning_tokens, + sum(c.cache_read_tokens)::bigint AS cache_read_tokens, + sum(c.cache_write_tokens)::bigint AS cache_write_tokens, + ( + sum(c.input_tokens) + + sum(c.output_tokens) + + sum(c.cache_read_tokens) + + sum(c.cache_write_tokens) + )::bigint AS total_tokens, + sum(c.input_cost) AS input_cost, + sum(c.output_cost) AS output_cost, + sum(c.reasoning_cost) AS reasoning_cost, + sum(c.cache_read_cost) AS cache_read_cost, + sum(c.cache_write_cost) AS cache_write_cost, + sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) AS total_cost, + min(c.started_at) AS first_call_at, + max(c.ended_at) AS last_call_at +FROM public.captain_turns t +JOIN public.captain_model_calls c ON c.turn_id = t.id +GROUP BY + t.session_id, + c.model, + c.backend, + c.effort, + upper(c.currency); + +COMMENT ON VIEW public.captain_session_costs IS + 'Per-session model/backend/effort token and cost totals for the SessionInspector costs tab.'; + +CREATE OR REPLACE VIEW public.captain_session_events +WITH (security_barrier = true) +AS +SELECT + e.id, + e.session_id, + e.turn_id, + e.prompt_run_id, + e.iteration_id, + e.model_call_id, + e.parent_event_id, + e.event_key, + e.stream, + e.sequence, + e.kind, + e.scope, + e.payload, + e.schema_version, + e.occurred_at, + e.recorded_at +FROM public.captain_events e; + +COMMENT ON VIEW public.captain_session_events IS + 'Durable session and turn event rows for SessionInspector metadata and raw-event surfaces.'; + +CREATE OR REPLACE VIEW public.captain_prompt_run_overview +WITH (security_barrier = true) +AS +SELECT + r.id, + r.session_id, + r.root_session_id, + r.batch_id, + r.parent_run_id, + r.input_plan_id, + r.input_plan_revision_id, + r.origin, + r.spec_profile, + r.admission_key, + r.rendered_spec, + r.prompt_markdown, + r.verification_markdown, + r.phase, + r.state, + r.current_iteration, + r.result_text, + r.result_json, + r.error, + r.version, + r.queued_at, + r.started_at, + r.finished_at, + r.created_at, + r.updated_at, + CASE + WHEN r.started_at IS NULL THEN NULL + ELSE EXTRACT(EPOCH FROM COALESCE(r.finished_at, clock_timestamp()) - r.started_at) + END AS duration_seconds, + COALESCE(iteration_stats.iteration_count, 0) AS iteration_count, + COALESCE(iteration_stats.succeeded_iteration_count, 0) AS succeeded_iteration_count, + COALESCE(iteration_stats.failed_iteration_count, 0) AS failed_iteration_count, + latest_iteration.id AS latest_iteration_id, + latest_iteration.iteration AS latest_iteration, + latest_iteration.state AS latest_iteration_state, + latest_iteration.feedback AS latest_iteration_feedback, + latest_iteration.verification_result AS latest_verification_result, + latest_iteration.error AS latest_iteration_error, + latest_iteration.started_at AS latest_iteration_started_at, + latest_iteration.finished_at AS latest_iteration_finished_at, + COALESCE(call_stats.model_call_count, 0) AS model_call_count, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, + COALESCE(plan_stats.plan_count, 0) AS plan_count, + plan_stats.latest_plan_id, + plan_stats.latest_plan_approval_state, + plan_stats.latest_plan_revision +FROM public.captain_prompt_runs r +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS iteration_count, + count(*) FILTER (WHERE i.state = 'succeeded')::bigint AS succeeded_iteration_count, + count(*) FILTER (WHERE i.state = 'failed')::bigint AS failed_iteration_count + FROM public.captain_prompt_run_iterations i + WHERE i.prompt_run_id = r.id +) iteration_stats ON true +LEFT JOIN LATERAL ( + SELECT i.* + FROM public.captain_prompt_run_iterations i + WHERE i.prompt_run_id = r.id + ORDER BY i.iteration DESC, i.created_at DESC, i.id DESC + LIMIT 1 +) latest_iteration ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS model_call_count, + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_model_calls c + WHERE c.prompt_run_id = r.id +) call_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS plan_count, + (array_agg(p.id ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_id, + (array_agg(p.approval_state ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_approval_state, + (array_agg(pr.revision ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_revision + FROM public.captain_plans p + LEFT JOIN LATERAL ( + SELECT revision.revision + FROM public.captain_plan_revisions revision + WHERE revision.plan_id = p.id + ORDER BY revision.revision DESC, revision.created_at DESC, revision.id DESC + LIMIT 1 + ) pr ON true + WHERE p.source_prompt_run_id = r.id +) plan_stats ON true; + +COMMENT ON VIEW public.captain_prompt_run_overview IS + 'Prompt-run control-plane state with iteration, plan, usage, cost and verification summaries.'; From 893a61574df5abd1c3eb089665030d9b53d0ecb3 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 11:14:11 +0300 Subject: [PATCH 028/131] feat(ai): Broaden model fallback handling Allow explicit model fallbacks to recover from unavailable models, missing API keys, and missing CLIs in addition to transient failures. Enrich terminal model-selection errors with backend-scoped available-model recommendations while preserving actionable primary errors. --- pkg/ai/client.go | 4 +- pkg/ai/errors.go | 77 +++++++++++++ pkg/ai/errors_test.go | 30 +++++ pkg/ai/fallback.go | 40 +++++-- pkg/ai/fallback_test.go | 90 +++++++++++++++ pkg/ai/model_error.go | 153 ++++++++++++++++++++++++++ pkg/ai/model_error_test.go | 115 +++++++++++++++++++ pkg/ai/provider/genkit/genkit.go | 2 +- pkg/ai/provider/genkit/genkit_test.go | 1 + pkg/ai/provider/genkit/instance.go | 2 +- 10 files changed, 499 insertions(+), 15 deletions(-) create mode 100644 pkg/ai/model_error.go create mode 100644 pkg/ai/model_error_test.go diff --git a/pkg/ai/client.go b/pkg/ai/client.go index d808dba..66ec8a3 100644 --- a/pkg/ai/client.go +++ b/pkg/ai/client.go @@ -26,7 +26,7 @@ func RegisterProvider(backend Backend, factory ProviderFactory) { // name is unrecognized, the error is enriched with the closest known model names // ("did you mean …"). When cfg.Model resolves to more than one candidate (a // comma-separated Name or a Fallbacks list), a fallback provider is returned that -// tries each in order on a retryable failure. +// tries each in order on a fallback-eligible failure. func NewProvider(cfg Config) (Provider, error) { resolved, err := ResolveModelSelectors(cfg.Model) if err != nil { @@ -53,7 +53,7 @@ func newResolvedProvider(cfg Config) (Provider, error) { if err != nil { return nil, err } - return withEffortValidation(p, cfg.Model.Effort), nil + return withModelErrorRecommendations(withEffortValidation(p, cfg.Model.Effort)), nil } func normalizeProviderModel(model api.Model) api.Model { diff --git a/pkg/ai/errors.go b/pkg/ai/errors.go index 9df99fd..cd040e9 100644 --- a/pkg/ai/errors.go +++ b/pkg/ai/errors.go @@ -12,6 +12,7 @@ var ( ErrTimeout = errors.New("operation timed out") ErrSchemaValidation = errors.New("schema validation failed") ErrModelNotFound = errors.New("model not found in pricing registry") + ErrModelUnavailable = errors.New("model unavailable") ErrNoAPIKey = errors.New("API key not found") ) @@ -34,3 +35,79 @@ func IsRetryable(err error) bool { strings.Contains(msg, "overloaded") || strings.Contains(msg, "timeout") } + +// IsModelUnavailable reports provider-confirmed model selection failures. It is +// intentionally separate from IsRetryable: retry middleware must not repeat an +// invalid model on the same provider, while an explicit fallback model may +// still recover the request. +func IsModelUnavailable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrModelNotFound) || errors.Is(err, ErrSchemaValidation) { + return false + } + if errors.Is(err, ErrModelUnavailable) { + return true + } + msg := strings.ToLower(err.Error()) + for _, signal := range []string{ + "model is not supported", + "model is unsupported", + "unsupported model", + "unknown model", + "model not found", + "model was not found", + "model does not exist", + "model is unavailable", + "model_not_found", + "do not have access to model", + "don't have access to model", + } { + if strings.Contains(msg, signal) { + return true + } + } + if strings.Contains(msg, "invalid model") && + !strings.Contains(msg, "model output") && + !strings.Contains(msg, "model response") && + !strings.Contains(msg, "model schema") { + return true + } + return strings.Contains(msg, "model") && + (strings.Contains(msg, "not supported") || + strings.Contains(msg, "not found") || + strings.Contains(msg, "does not exist") || + strings.Contains(msg, "is unavailable")) +} + +// IsMissingAPIKey reports missing credentials without treating rejected or +// invalid credentials as recoverable. Cross-backend fallbacks can therefore +// skip an unconfigured API provider without hiding a bad key. +func IsMissingAPIKey(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrNoAPIKey) { + return true + } + msg := strings.ToLower(err.Error()) + for _, signal := range []string{ + "missing api key", + "no api key", + "api key is not set", + "api_key is not set", + } { + if strings.Contains(msg, signal) { + return true + } + } + return false +} + +// IsFallbackEligible reports failures for which trying a different explicitly +// configured model may help. This includes transient provider failures, model +// availability mismatches, missing credentials, and a missing local CLI. +func IsFallbackEligible(err error) bool { + return IsRetryable(err) || IsModelUnavailable(err) || IsMissingAPIKey(err) || errors.Is(err, ErrCLINotFound) +} diff --git a/pkg/ai/errors_test.go b/pkg/ai/errors_test.go index 28b478d..7c17c43 100644 --- a/pkg/ai/errors_test.go +++ b/pkg/ai/errors_test.go @@ -30,3 +30,33 @@ func TestIsRetryable(t *testing.T) { }) } } + +func TestIsFallbackEligible(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"retryable", errors.New("429 rate limit"), true}, + {"typed unavailable", fmt.Errorf("codex: %w", ErrModelUnavailable), true}, + {"codex chatgpt unsupported", errors.New("The 'gpt-5.6-line' model is not supported when using Codex with a ChatGPT account."), true}, + {"unknown model", errors.New("provider rejected unknown model gpt-future"), true}, + {"invalid model name", errors.New("invalid model name gpt-future"), true}, + {"typed missing key", fmt.Errorf("openai: %w", ErrNoAPIKey), true}, + {"missing key text", errors.New("genkit provider: no API key for backend openai"), true}, + {"missing cli", fmt.Errorf("start codex: %w", ErrCLINotFound), true}, + {"bad key remains terminal", errors.New("authentication failed: invalid API key"), false}, + {"pricing miss remains terminal", fmt.Errorf("price lookup: %w", ErrModelNotFound), false}, + {"reasoning effort remains terminal", errors.New("model gpt-5.5 does not support reasoning effort max"), false}, + {"malformed request", errors.New("invalid request: missing user"), false}, + {"schema validation", ErrSchemaValidation, false}, + {"invalid model output", errors.New("invalid model output: missing body"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsFallbackEligible(tc.err); got != tc.want { + t.Errorf("IsFallbackEligible(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/pkg/ai/fallback.go b/pkg/ai/fallback.go index 0aacf33..d18887c 100644 --- a/pkg/ai/fallback.go +++ b/pkg/ai/fallback.go @@ -14,7 +14,7 @@ import ( var fallbackLog = logger.GetLogger("ai") // fallbackProvider tries an ordered list of candidate models, advancing to the -// next when the current one fails with a retryable error or its provider cannot be +// next when the current one fails with a fallback-eligible error or its provider cannot be // constructed. Providers are built lazily and cached. GetModel/GetBackend report // the active (last-selected) candidate. It implements StreamingProvider; a // streaming candidate is abandoned in favour of the next only while it has not yet @@ -60,7 +60,7 @@ func (f *fallbackProvider) providerAt(i int) (Provider, error) { } p, err := f.build(f.cfgFor(i)) if err != nil { - return nil, err + return nil, suggestModelName(err, f.candidates[i].Name) } f.built[i] = p return p, nil @@ -89,11 +89,12 @@ func (f *fallbackProvider) GetBackend() Backend { return b } -// Execute runs the primary and, on a retryable failure or a construction error, +// Execute runs the primary and, on a fallback-eligible failure or a construction error, // each fallback in turn. A non-retryable runtime error stops immediately (another // model will not fix a malformed request). When nothing succeeds the primary's // error is returned, as the most actionable one. func (f *fallbackProvider) Execute(ctx context.Context, req Request) (*Response, error) { + log := LoggerFromContext(ctx, fallbackLog) var firstErr error record := func(err error) { if firstErr == nil { @@ -103,7 +104,7 @@ func (f *fallbackProvider) Execute(ctx context.Context, req Request) (*Response, for i := range f.candidates { p, err := f.providerAt(i) if err != nil { - fallbackLog.Warnf("fallback: model %s unavailable (%v)", f.candidates[i].Name, err) + logFallbackTransition(log, f.candidates, i, err) record(err) continue } @@ -115,11 +116,11 @@ func (f *fallbackProvider) Execute(ctx context.Context, req Request) (*Response, return resp, nil } record(err) - if !IsRetryable(err) { + if !IsFallbackEligible(err) { return resp, err } if i < len(f.candidates)-1 { - fallbackLog.Warnf("fallback: model %s failed (%v); trying %s", + log.Warnf("fallback: model %s failed (%v); trying %s", f.candidates[i].Name, err, f.candidates[i+1].Name) } } @@ -135,6 +136,7 @@ func (f *fallbackProvider) ExecuteStream(ctx context.Context, req Request) (<-ch out := make(chan Event) go func() { defer close(out) + log := LoggerFromContext(ctx, fallbackLog) var firstErr error record := func(err error) { if firstErr == nil { @@ -148,7 +150,7 @@ func (f *fallbackProvider) ExecuteStream(ctx context.Context, req Request) (<-ch p, err := f.providerAt(i) if err != nil { - fallbackLog.Warnf("fallback: model %s unavailable (%v)", f.candidates[i].Name, err) + logFallbackTransition(log, f.candidates, i, err) record(err) continue } @@ -164,11 +166,15 @@ func (f *fallbackProvider) ExecuteStream(ctx context.Context, req Request) (<-ch ch, err := sp.ExecuteStream(ctx, candidateReq) if err != nil { record(err) - if IsRetryable(err) && !last { - fallbackLog.Warnf("fallback: model %s failed (%v); trying %s", + eligible := IsFallbackEligible(err) + if eligible && !last { + log.Warnf("fallback: model %s failed (%v); trying %s", f.candidates[i].Name, err, f.candidates[i+1].Name) continue } + if eligible && firstErr != nil { + err = firstErr + } emit(err) return } @@ -181,11 +187,15 @@ func (f *fallbackProvider) ExecuteStream(ctx context.Context, req Request) (<-ch streamErr = fmt.Errorf("model %s produced no output", f.candidates[i].Name) } record(streamErr) - if IsRetryable(streamErr) && !last { - fallbackLog.Warnf("fallback: model %s failed pre-output (%v); trying %s", + eligible := IsFallbackEligible(streamErr) + if eligible && !last { + log.Warnf("fallback: model %s failed pre-output (%v); trying %s", f.candidates[i].Name, streamErr, f.candidates[i+1].Name) continue } + if eligible && firstErr != nil { + streamErr = firstErr + } emit(streamErr) return } @@ -196,6 +206,14 @@ func (f *fallbackProvider) ExecuteStream(ctx context.Context, req Request) (<-ch return out, nil } +func logFallbackTransition(log logger.Logger, candidates []api.Model, i int, err error) { + if i < len(candidates)-1 { + log.Warnf("fallback: model %s unavailable (%v); trying %s", candidates[i].Name, err, candidates[i+1].Name) + return + } + log.Warnf("fallback: model %s unavailable (%v)", candidates[i].Name, err) +} + // pump forwards a candidate's stream to out. Leading non-content events (e.g. // EventSystem session metadata) are buffered until the first content event, at // which point the buffer is flushed and the stream is "committed" — from then on diff --git a/pkg/ai/fallback_test.go b/pkg/ai/fallback_test.go index 311d1aa..168700d 100644 --- a/pkg/ai/fallback_test.go +++ b/pkg/ai/fallback_test.go @@ -3,9 +3,11 @@ package ai import ( "context" "errors" + "fmt" "testing" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/commons/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -93,6 +95,26 @@ func TestFallback_Execute_RetryableAdvances(t *testing.T) { assert.Equal(t, 1, fallback.execCalls) } +func TestFallback_Execute_UnsupportedModelAdvancesAndWarns(t *testing.T) { + primary := &fakeProv{model: "gpt-5.6-line", execErr: errors.New("The 'gpt-5.6-line' model is not supported when using Codex with a ChatGPT account.")} + fallback := &fakeProv{model: "gpt-5.6-sol", execResp: &Response{Text: "ok", Model: "gpt-5.6-sol"}} + fp := newTestFallback([]string{"gpt-5.6-line", "gpt-5.6-sol"}, map[string]*fakeProv{ + "gpt-5.6-line": primary, + "gpt-5.6-sol": fallback, + }) + logs := logger.NewBufferedLogger(10) + ctx := ContextWithLogger(context.Background(), logs) + + resp, err := fp.Execute(ctx, Request{}) + require.NoError(t, err) + assert.Equal(t, "ok", resp.Text) + assert.Equal(t, 1, fallback.execCalls) + warnings := logs.GetLogsByLevel(logger.Warn) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0].Message, "gpt-5.6-line") + assert.Contains(t, warnings[0].Message, "trying gpt-5.6-sol") +} + func TestFallback_Execute_NonRetryableStops(t *testing.T) { primary := &fakeProv{model: "primary", execErr: errors.New("invalid request: missing user")} fallback := &fakeProv{model: "fallback", execResp: &Response{Text: "ok"}} @@ -115,6 +137,31 @@ func TestFallback_Execute_BuildFailureAdvances(t *testing.T) { assert.Equal(t, 1, fallback.execCalls) } +func TestFallback_Execute_MissingKeyBuildFailureAdvancesAndWarns(t *testing.T) { + fallback := &fakeProv{model: "fallback", execResp: &Response{Text: "ok"}} + models := []api.Model{{Name: "primary", Backend: BackendOpenAI}, {Name: "fallback", Backend: BackendCodexAgent}} + fp := &fallbackProvider{ + candidates: models, + built: make([]Provider, len(models)), + build: func(cfg Config) (Provider, error) { + if cfg.Model.Name == "primary" { + return nil, fmt.Errorf("%w: no API key for openai", ErrNoAPIKey) + } + return fallback, nil + }, + } + logs := logger.NewBufferedLogger(10) + ctx := ContextWithLogger(context.Background(), logs) + + resp, err := fp.Execute(ctx, Request{}) + require.NoError(t, err) + assert.Equal(t, "ok", resp.Text) + warnings := logs.GetLogsByLevel(logger.Warn) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0].Message, "no API key for openai") + assert.Contains(t, warnings[0].Message, "trying fallback") +} + func TestFallback_Execute_AllFailReturnsPrimaryError(t *testing.T) { primary := &fakeProv{model: "primary", execErr: errors.New("primary 429 overloaded")} fallback := &fakeProv{model: "fallback", execErr: errors.New("fallback 503 overloaded")} @@ -151,6 +198,49 @@ func TestFallback_Stream_PreContentAdvances(t *testing.T) { assert.Equal(t, 1, fallback.streamCalls) } +func TestFallback_Stream_UnsupportedModelPreContentAdvances(t *testing.T) { + primary := &fakeProv{model: "gpt-5.6-line", streamEvents: []Event{ + {Kind: EventSystem, SessionID: "discarded"}, + {Kind: EventError, Error: "unknown model gpt-5.6-line"}, + }} + fallback := &fakeProv{model: "gpt-5.6-sol", streamEvents: []Event{ + {Kind: EventText, Text: "fallback output"}, + {Kind: EventResult, Success: true}, + }} + fp := newTestFallback([]string{"gpt-5.6-line", "gpt-5.6-sol"}, map[string]*fakeProv{ + "gpt-5.6-line": primary, + "gpt-5.6-sol": fallback, + }) + + ch, err := fp.ExecuteStream(context.Background(), Request{}) + require.NoError(t, err) + events := drain(ch) + require.Len(t, events, 2) + assert.Equal(t, "fallback output", events[0].Text) + assert.Equal(t, 1, fallback.streamCalls) +} + +func TestFallback_Stream_AllEligibleFailuresReturnPrimaryError(t *testing.T) { + primary := &fakeProv{model: "gpt-5.6-line", streamEvents: []Event{ + {Kind: EventError, Error: "unknown model gpt-5.6-line; did you mean gpt-5.6-luna?"}, + }} + fallback := &fakeProv{model: "fallback", streamEvents: []Event{ + {Kind: EventError, Error: "503 overloaded"}, + }} + fp := newTestFallback([]string{"gpt-5.6-line", "fallback"}, map[string]*fakeProv{ + "gpt-5.6-line": primary, + "fallback": fallback, + }) + + ch, err := fp.ExecuteStream(context.Background(), Request{}) + require.NoError(t, err) + events := drain(ch) + require.Len(t, events, 1) + assert.Equal(t, EventError, events[0].Kind) + assert.Contains(t, events[0].Error, "unknown model gpt-5.6-line") + assert.NotContains(t, events[0].Error, "503 overloaded") +} + func TestFallback_Stream_CommittedErrorSurfaces(t *testing.T) { primary := &fakeProv{model: "primary", streamEvents: []Event{ {Kind: EventText, Text: "partial"}, diff --git a/pkg/ai/model_error.go b/pkg/ai/model_error.go new file mode 100644 index 0000000..77347ad --- /dev/null +++ b/pkg/ai/model_error.go @@ -0,0 +1,153 @@ +package ai + +import ( + "context" + "fmt" + "os/exec" + "strings" + "sync" + "time" + + "github.com/flanksource/captain/pkg/collections" +) + +const modelRecommendationLimit = 5 +const modelRecommendationTimeout = 2 * time.Second + +// modelAvailabilityResolver is replaceable in tests so model-error handling +// never needs a real provider account or installed CLI. +var modelAvailabilityResolver = availableModelsForBackend + +// availableModelsForBackend returns models executable by the selected backend. +// Codex's installed catalog is the strongest source for local Codex adapters; +// direct API backends use their authenticated model endpoint. The embedded +// registry keeps recommendations useful when discovery is unavailable. +func availableModelsForBackend(ctx context.Context, backend Backend) []ModelDef { + discoveryCtx, cancel := context.WithTimeout(ctx, modelRecommendationTimeout) + defer cancel() + if isCodexBackend(backend) { + if binary, err := exec.LookPath("codex"); err == nil { + if models, err := FetchCodexDebugModels(discoveryCtx, binary); err == nil && len(models) > 0 { + for i := range models { + models[i].Backend = backend + } + return CurrentCuratedModelsByReleaseDate(models) + } + } + } + if backend.Kind() == "api" && GetAPIKeyFromEnv(backend) != "" { + if models, err := ListModels(discoveryCtx, backend); err == nil && len(models) > 0 { + return CurrentModelsByReleaseDate(models) + } + } + return CurrentCuratedModelsByReleaseDate(RegistryModelDefs(backend)) +} + +func isCodexBackend(backend Backend) bool { + switch backend { + case BackendCodexCLI, BackendCodexAgent, BackendCodexCmux: + return true + default: + return false + } +} + +// recommendModelError preserves err while adding a backend-scoped replacement +// and a compact available-model list. It runs only after a provider has +// confirmed that the attempted model is unavailable. +func recommendModelError(ctx context.Context, backend Backend, attempted string, err error) error { + if !IsModelUnavailable(err) || strings.Contains(strings.ToLower(err.Error()), "available models for") { + return err + } + return recommendModelErrorFromModels(backend, attempted, err, modelAvailabilityResolver(ctx, backend)) +} + +func recommendModelErrorFromModels(backend Backend, attempted string, err error, models []ModelDef) error { + available := make([]string, 0, len(models)) + seen := map[string]bool{} + for _, model := range models { + id := strings.TrimSpace(model.ID) + if id == "" || strings.EqualFold(id, attempted) || seen[id] { + continue + } + seen[id] = true + available = append(available, id) + } + if len(available) == 0 { + return err + } + + closest := collections.FindSimilar(attempted, available, 1)[0] + shown := available + more := 0 + if len(shown) > modelRecommendationLimit { + more = len(shown) - modelRecommendationLimit + shown = shown[:modelRecommendationLimit] + } + list := strings.Join(shown, ", ") + if more > 0 { + list += fmt.Sprintf(" (+%d more)", more) + } + return fmt.Errorf("%w; did you mean %q? available models for %s: %s", err, closest, backend, list) +} + +type modelErrorProvider struct { + provider Provider + + modelsOnce sync.Once + models []ModelDef +} + +func (p *modelErrorProvider) GetModel() string { return p.provider.GetModel() } +func (p *modelErrorProvider) GetBackend() Backend { return p.provider.GetBackend() } + +func (p *modelErrorProvider) recommend(ctx context.Context, err error) error { + if !IsModelUnavailable(err) || strings.Contains(strings.ToLower(err.Error()), "available models for") { + return err + } + p.modelsOnce.Do(func() { + p.models = modelAvailabilityResolver(ctx, p.GetBackend()) + }) + return recommendModelErrorFromModels(p.GetBackend(), p.GetModel(), err, p.models) +} + +func (p *modelErrorProvider) Execute(ctx context.Context, req Request) (*Response, error) { + resp, err := p.provider.Execute(ctx, req) + if err != nil { + err = p.recommend(ctx, err) + } + return resp, err +} + +type modelErrorStreamingProvider struct { + *modelErrorProvider + streamer StreamingProvider +} + +func (p *modelErrorStreamingProvider) ExecuteStream(ctx context.Context, req Request) (<-chan Event, error) { + ch, err := p.streamer.ExecuteStream(ctx, req) + if err != nil { + return nil, p.recommend(ctx, err) + } + out := make(chan Event) + go func() { + defer close(out) + for ev := range ch { + if ev.Kind == EventError && ev.Error != "" { + ev.Error = p.recommend(ctx, fmt.Errorf("%s", ev.Error)).Error() + } + if !sendEvent(ctx, out, ev) { + return + } + } + }() + return out, nil +} + +func withModelErrorRecommendations(provider Provider) Provider { + base := &modelErrorProvider{provider: provider} + if streamer, ok := provider.(StreamingProvider); ok { + return &modelErrorStreamingProvider{modelErrorProvider: base, streamer: streamer} + } + return base +} diff --git a/pkg/ai/model_error_test.go b/pkg/ai/model_error_test.go new file mode 100644 index 0000000..0d3b7a8 --- /dev/null +++ b/pkg/ai/model_error_test.go @@ -0,0 +1,115 @@ +package ai + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type modelErrorTestProvider struct { + backend Backend + model string + err error + events []Event +} + +func (p *modelErrorTestProvider) GetModel() string { return p.model } +func (p *modelErrorTestProvider) GetBackend() Backend { return p.backend } +func (p *modelErrorTestProvider) Execute(context.Context, Request) (*Response, error) { + return nil, p.err +} +func (p *modelErrorTestProvider) ExecuteStream(context.Context, Request) (<-chan Event, error) { + if p.err != nil { + return nil, p.err + } + ch := make(chan Event, len(p.events)) + for _, ev := range p.events { + ch <- ev + } + close(ch) + return ch, nil +} + +func stubModelRecommendations(t *testing.T) { + t.Helper() + previous := modelAvailabilityResolver + modelAvailabilityResolver = func(context.Context, Backend) []ModelDef { + return []ModelDef{ + {ID: "gpt-5.6-sol"}, + {ID: "gpt-5.6-terra"}, + {ID: "gpt-5.6-luna"}, + {ID: "gpt-5.5"}, + {ID: "gpt-5.4"}, + {ID: "gpt-5.4-mini"}, + } + } + t.Cleanup(func() { modelAvailabilityResolver = previous }) +} + +func TestRecommendModelErrorUsesAvailableBackendModels(t *testing.T) { + stubModelRecommendations(t) + base := fmtModelUnavailable("The 'gpt-5.6-line' model is not supported when using Codex with a ChatGPT account.") + err := recommendModelError(context.Background(), BackendCodexAgent, "gpt-5.6-line", base) + + require.Error(t, err) + assert.Contains(t, err.Error(), `did you mean "gpt-5.6-luna"?`) + assert.Contains(t, err.Error(), "available models for codex-agent: gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4 (+1 more)") + assert.ErrorIs(t, err, ErrModelUnavailable) +} + +func TestModelErrorProviderEnrichesBufferedFailure(t *testing.T) { + stubModelRecommendations(t) + base := &modelErrorTestProvider{ + backend: BackendCodexCLI, + model: "gpt-5.6-line", + err: errors.New("unsupported model gpt-5.6-line"), + } + p := withModelErrorRecommendations(base) + _, err := p.Execute(context.Background(), api.Spec{}) + require.Error(t, err) + assert.Contains(t, err.Error(), `did you mean "gpt-5.6-luna"?`) +} + +func TestModelErrorProviderEnrichesStreamingFailure(t *testing.T) { + previous := modelAvailabilityResolver + resolveCalls := 0 + modelAvailabilityResolver = func(context.Context, Backend) []ModelDef { + resolveCalls++ + return []ModelDef{{ID: "gpt-5.6-luna"}, {ID: "gpt-5.6-sol"}} + } + t.Cleanup(func() { modelAvailabilityResolver = previous }) + base := &modelErrorTestProvider{ + backend: BackendCodexAgent, + model: "gpt-5.6-line", + events: []Event{ + {Kind: EventError, Error: "unknown model gpt-5.6-line"}, + {Kind: EventError, Error: "unknown model gpt-5.6-line"}, + }, + } + p := withModelErrorRecommendations(base).(StreamingProvider) + ch, err := p.ExecuteStream(context.Background(), api.Spec{}) + require.NoError(t, err) + events := drain(ch) + require.Len(t, events, 2) + assert.Contains(t, events[0].Error, `did you mean "gpt-5.6-luna"?`) + assert.Contains(t, events[1].Error, `did you mean "gpt-5.6-luna"?`) + assert.Equal(t, 1, resolveCalls, "repeated terminal events should reuse one availability snapshot") +} + +func TestRecommendModelErrorLeavesOtherFailuresUnchanged(t *testing.T) { + stubModelRecommendations(t) + base := errors.New("authentication failed: invalid API key") + err := recommendModelError(context.Background(), BackendOpenAI, "gpt-5.6", base) + assert.Same(t, base, err) + assert.False(t, strings.Contains(err.Error(), "available models")) +} + +func fmtModelUnavailable(message string) error { + return fmt.Errorf("%w: %s", ErrModelUnavailable, message) +} diff --git a/pkg/ai/provider/genkit/genkit.go b/pkg/ai/provider/genkit/genkit.go index 5e3a698..4c0b829 100644 --- a/pkg/ai/provider/genkit/genkit.go +++ b/pkg/ai/provider/genkit/genkit.go @@ -61,7 +61,7 @@ func New(cfg ai.Config) (*Provider, error) { apiKey = ai.GetAPIKeyFromEnv(backend) } if apiKey == "" { - 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/DEEPSEEK_API_KEY)", backend) + return nil, fmt.Errorf("%w: genkit provider has no API key for backend %q (set the provider's API key, e.g. ANTHROPIC_API_KEY/OPENAI_API_KEY/GEMINI_API_KEY/DEEPSEEK_API_KEY)", ai.ErrNoAPIKey, backend) } cfg.Model.Backend = backend diff --git a/pkg/ai/provider/genkit/genkit_test.go b/pkg/ai/provider/genkit/genkit_test.go index c0df886..2a59823 100644 --- a/pkg/ai/provider/genkit/genkit_test.go +++ b/pkg/ai/provider/genkit/genkit_test.go @@ -93,6 +93,7 @@ func TestNewMissingAPIKey(t *testing.T) { _, err := New(ai.Config{Model: api.Model{Backend: backend, Name: "some-model"}}) require.Error(t, err) assert.Contains(t, err.Error(), "no API key") + assert.ErrorIs(t, err, ai.ErrNoAPIKey) }) } } diff --git a/pkg/ai/provider/genkit/instance.go b/pkg/ai/provider/genkit/instance.go index ec5a0bf..204f343 100644 --- a/pkg/ai/provider/genkit/instance.go +++ b/pkg/ai/provider/genkit/instance.go @@ -40,7 +40,7 @@ var instances sync.Map // instanceKey -> *instanceEntry // it on first use. A missing API key is a loud error. func getInstance(ctx context.Context, backend ai.Backend, apiKey string) (*gk.Genkit, error) { if apiKey == "" { - return nil, fmt.Errorf("genkit provider: missing API key for backend %q", backend) + return nil, fmt.Errorf("%w: genkit provider is missing an API key for backend %q", ai.ErrNoAPIKey, backend) } v, _ := instances.LoadOrStore(instanceKey{backend: backend, apiKey: apiKey}, &instanceEntry{}) From 3ed2cfac1ecdf5f56c7588d436471a3478943bc9 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 20:53:57 +0300 Subject: [PATCH 029/131] feat(database): add native persistence API Gavel-Issue-Id: e2a3b8c2d0f7c9a98b400dc78e8a94a5 --- migrations/concurrency_integration_test.go | 81 ++ migrations/migrations.go | 197 +++++ migrations/migrations_test.go | 267 ++++++ pkg/database/database.go | 134 +++ pkg/database/database_test.go | 115 +++ pkg/database/migration_integration_test.go | 149 ++++ .../plan_revision_store_integration_test.go | 134 +++ pkg/database/plan_store.go | 564 ++++++++++++ pkg/database/plan_store_test.go | 23 + .../prompt_run_store_integration_test.go | 78 ++ pkg/database/session_prompt_store.go | 819 ++++++++++++++++++ pkg/database/store_integration_test.go | 447 ++++++++++ 12 files changed, 3008 insertions(+) create mode 100644 migrations/concurrency_integration_test.go create mode 100644 migrations/migrations.go create mode 100644 migrations/migrations_test.go create mode 100644 pkg/database/database.go create mode 100644 pkg/database/database_test.go create mode 100644 pkg/database/migration_integration_test.go create mode 100644 pkg/database/plan_revision_store_integration_test.go create mode 100644 pkg/database/plan_store.go create mode 100644 pkg/database/plan_store_test.go create mode 100644 pkg/database/prompt_run_store_integration_test.go create mode 100644 pkg/database/session_prompt_store.go create mode 100644 pkg/database/store_integration_test.go diff --git a/migrations/concurrency_integration_test.go b/migrations/concurrency_integration_test.go new file mode 100644 index 0000000..9546f6c --- /dev/null +++ b/migrations/concurrency_integration_test.go @@ -0,0 +1,81 @@ +package migrations + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/stretchr/testify/require" +) + +func TestConcurrentApplySerializesCaptainMigrations(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres migration tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_concurrent_migrations", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + // Hold the same session lock before releasing a group of Apply calls. This + // proves every caller enters through the advisory-lock boundary rather than + // racing the Atlas inspect/diff/apply window. + blocker, err := acquireMigrationLock(t.Context(), dsn) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, blocker.Close()) }) + + const callers = 6 + results := make(chan error, callers) + start := make(chan struct{}) + var ready sync.WaitGroup + ready.Add(callers) + for range callers { + go func() { + ready.Done() + <-start + results <- Apply(t.Context(), dsn) + }() + } + ready.Wait() + close(start) + + select { + case err := <-results: + t.Fatalf("Apply completed while the Captain migration lock was held: %v", err) + case <-time.After(250 * time.Millisecond): + } + + require.NoError(t, blocker.Close()) + for range callers { + select { + case err := <-results: + require.NoError(t, err) + case <-time.After(90 * time.Second): + t.Fatal("timed out waiting for serialized Captain migrations") + } + } + + db, err := commonsdb.NewDB(dsn) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + var sessionsTable *string + require.NoError(t, db.QueryRowContext(t.Context(), + `SELECT to_regclass('public.captain_sessions')::text`).Scan(&sessionsTable)) + require.NotNil(t, sessionsTable) + require.Equal(t, "captain_sessions", *sessionsTable) + + // All Apply calls returned, so their session locks must have been released. + // Bound the reacquisition to catch a leaked dedicated connection cleanly. + reacquireCtx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + reacquired, err := acquireMigrationLock(reacquireCtx, dsn) + require.NoError(t, err) + require.NoError(t, reacquired.Close()) +} diff --git a/migrations/migrations.go b/migrations/migrations.go new file mode 100644 index 0000000..673b825 --- /dev/null +++ b/migrations/migrations.go @@ -0,0 +1,197 @@ +// Package migrations embeds and applies Captain's authoritative PostgreSQL +// schema. Consumers that share a database with Captain should call Apply before +// creating cross-schema references to Captain-owned tables. +package migrations + +import ( + "context" + "database/sql" + "embed" + "errors" + "fmt" + "strings" + "sync" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + commonsmigrate "github.com/flanksource/commons-db/migrate" +) + +const Scope = "captain" + +const ( + // captainMigrationLockNamespace and captainMigrationLockKey are stable, + // Captain-specific PostgreSQL advisory-lock identifiers ("CAPT", "MIGR"). + // Gavel uses a different outer lifecycle key, so a host can safely hold its + // own lock while Captain serializes this independently owned migration scope. + captainMigrationLockNamespace int32 = 0x43415054 + captainMigrationLockKey int32 = 0x4d494752 + + migrationUnlockTimeout = 5 * time.Second +) + +// ErrLegacySessionSchema is returned before reconciliation when the database +// still contains the GORM-managed session summary cache that predates Captain's +// native schema. Callers can use errors.Is to direct users to an explicit +// backfill/cutover instead of allowing Atlas to reshape the table implicitly. +var ErrLegacySessionSchema = errors.New("legacy Captain session cache schema detected") + +// schemaFS contains the complete Captain-owned schema. SQL files are colocated +// with the HCL so commons-db/migrate applies their declared pre/post phases in +// the same migration scope. +// +//go:embed *.hcl *.sql +var schemaFS embed.FS + +type migrationLockHandle interface { + Close() error +} + +type applyDependencies struct { + acquireLock func(context.Context, string) (migrationLockHandle, error) + rejectLegacy func(context.Context, string) error + migrate func(context.Context, string) error +} + +var defaultApplyDependencies = applyDependencies{ + acquireLock: acquireMigrationLock, + rejectLegacy: rejectLegacySessionSchema, + migrate: func(ctx context.Context, connection string) error { + return commonsmigrate.Apply(ctx, connection, schemaFS, + commonsmigrate.WithName(Scope), + commonsmigrate.WithExclude("todo_*"), + ) + }, +} + +// Apply reconciles the checked-in HCL schema, then applies the colocated SQL +// migrations. A Captain-specific session advisory lock serializes the legacy +// preflight and the complete migration bundle across processes. It is safe to +// call repeatedly and uses a stable scope so Captain can share a database with +// other independently migrated applications. +func Apply(ctx context.Context, connection string) error { + return apply(ctx, connection, defaultApplyDependencies) +} + +func apply(ctx context.Context, connection string, deps applyDependencies) (resultErr error) { + if strings.TrimSpace(connection) == "" { + return errors.New("Captain migration connection string is empty") + } + + lock, err := deps.acquireLock(ctx, connection) + if err != nil { + return fmt.Errorf("acquire Captain migration lock: %w", err) + } + defer func() { + if err := lock.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("release Captain migration lock: %w", err)) + } + }() + + if err := deps.rejectLegacy(ctx, connection); err != nil { + return err + } + if err := deps.migrate(ctx, connection); err != nil { + return fmt.Errorf("migrate Captain database: %w", err) + } + return nil +} + +type migrationLock struct { + db *sql.DB + conn *sql.Conn + + once sync.Once + err error +} + +func acquireMigrationLock(ctx context.Context, connection string) (migrationLockHandle, error) { + db, err := commonsdb.NewDB(connection) + if err != nil { + return nil, fmt.Errorf("open advisory-lock database: %w", err) + } + conn, err := db.Conn(ctx) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("reserve advisory-lock connection: %w", err) + } + if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1, $2)`, + captainMigrationLockNamespace, captainMigrationLockKey); err != nil { + _ = conn.Close() + _ = db.Close() + return nil, fmt.Errorf("lock Captain migration scope: %w", err) + } + return &migrationLock{db: db, conn: conn}, nil +} + +func (lock *migrationLock) Close() error { + if lock == nil { + return nil + } + lock.once.Do(func() { + var cleanupErrors []error + if lock.conn != nil { + ctx, cancel := context.WithTimeout(context.Background(), migrationUnlockTimeout) + var unlocked bool + if err := lock.conn.QueryRowContext(ctx, `SELECT pg_advisory_unlock($1, $2)`, + captainMigrationLockNamespace, captainMigrationLockKey).Scan(&unlocked); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("unlock Captain migration scope: %w", err)) + } else if !unlocked { + cleanupErrors = append(cleanupErrors, errors.New("Captain migration advisory lock was not held")) + } + cancel() + if err := lock.conn.Close(); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("close Captain migration lock connection: %w", err)) + } + } + if lock.db != nil { + if err := lock.db.Close(); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("close Captain migration lock database: %w", err)) + } + } + lock.err = errors.Join(cleanupErrors...) + }) + return lock.err +} + +func rejectLegacySessionSchema(ctx context.Context, connection string) error { + db, err := commonsdb.NewDB(connection) + if err != nil { + return fmt.Errorf("open Captain migration preflight database: %w", err) + } + defer db.Close() + + rows, err := db.QueryContext(ctx, `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'captain_sessions'`) + if err != nil { + return fmt.Errorf("inspect Captain session schema: %w", err) + } + defer rows.Close() + + columns := map[string]string{} + for rows.Next() { + var name, dataType string + if err := rows.Scan(&name, &dataType); err != nil { + return fmt.Errorf("read Captain session schema: %w", err) + } + columns[name] = dataType + } + if err := rows.Err(); err != nil { + return fmt.Errorf("read Captain session schema: %w", err) + } + if isLegacySessionSchema(columns) { + return fmt.Errorf("%w: public.captain_sessions still uses the legacy path-keyed summary shape; run the explicit legacy session backfill/cutover before applying Captain HCL", ErrLegacySessionSchema) + } + return nil +} + +func isLegacySessionSchema(columns map[string]string) bool { + if _, hasPath := columns["path"]; !hasPath { + return false + } + _, hasLifecycle := columns["lifecycle_status"] + _, hasActivity := columns["activity_state"] + _, hasHealth := columns["health_state"] + return columns["id"] != "uuid" || !hasLifecycle || !hasActivity || !hasHealth +} diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go new file mode 100644 index 0000000..00d1b7f --- /dev/null +++ b/migrations/migrations_test.go @@ -0,0 +1,267 @@ +package migrations + +import ( + "context" + "errors" + "io/fs" + "reflect" + "strings" + "testing" +) + +func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { + t.Parallel() + + expectedFiles := []string{ + "00_types.pg.hcl", + "10_sessions.pg.hcl", + "20_prompt_runs_and_plans.pg.hcl", + "30_execution.pg.hcl", + "40_artifacts_and_outbox.pg.hcl", + "50_views_and_triggers.sql", + } + for _, name := range expectedFiles { + if _, err := fs.Stat(schemaFS, name); err != nil { + t.Errorf("embedded migration %s: %v", name, err) + } + } + + assertContainsAll(t, "10_sessions.pg.hcl", + `table "captain_sessions"`, + `column "id"`, + `column "lifecycle_status"`, + `column "activity_state"`, + `column "health_state"`, + `column "state_version"`, + ) + assertContainsAll(t, "20_prompt_runs_and_plans.pg.hcl", + `table "captain_prompt_runs"`, + `column "session_id"`, + `column "root_session_id"`, + `column "phase"`, + `column "state"`, + `table "captain_prompt_run_iterations"`, + `column "prompt_run_id"`, + `table "captain_plans"`, + `column "approved_revision_id"`, + `table "captain_plan_revisions"`, + `column "plan_id"`, + ) + assertContainsAll(t, "30_execution.pg.hcl", + `table "captain_turns"`, + `column "session_id"`, + `table "captain_model_calls"`, + `column "turn_id"`, + `column "prompt_run_id"`, + `column "iteration_id"`, + `table "captain_events"`, + `table "captain_turn_requests"`, + `column "state"`, + `column "version"`, + ) + assertContainsAll(t, "50_views_and_triggers.sql", + "-- phase: post", + "-- runs: always", + "CREATE OR REPLACE VIEW public.captain_session_overview", + "CREATE OR REPLACE VIEW public.captain_session_turns", + "CREATE OR REPLACE VIEW public.captain_session_plans", + "CREATE OR REPLACE VIEW public.captain_session_costs", + "CREATE OR REPLACE VIEW public.captain_session_events", + "CREATE OR REPLACE VIEW public.captain_prompt_run_overview", + ) +} + +func TestLegacySessionSchemaDetection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + columns map[string]string + legacy bool + }{ + {name: "fresh database", columns: map[string]string{}}, + {name: "unrelated table shape", columns: map[string]string{"id": "text"}}, + { + name: "authoritative schema", + columns: map[string]string{ + "id": "uuid", "path": "text", "lifecycle_status": "USER-DEFINED", + "activity_state": "USER-DEFINED", "health_state": "USER-DEFINED", + }, + }, + { + name: "legacy GORM cache", + columns: map[string]string{"id": "text", "path": "text", "mod_unix": "bigint"}, + legacy: true, + }, + { + name: "partial native conversion", + columns: map[string]string{"id": "uuid", "path": "text", "lifecycle_status": "USER-DEFINED"}, + legacy: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isLegacySessionSchema(tt.columns); got != tt.legacy { + t.Fatalf("isLegacySessionSchema() = %v, want %v", got, tt.legacy) + } + }) + } +} + +func TestApplyRejectsEmptyConnectionBeforePreflight(t *testing.T) { + t.Parallel() + if err := Apply(t.Context(), " "); err == nil { + t.Fatal("Apply unexpectedly accepted an empty connection string") + } +} + +func TestApplyHoldsMigrationLockAcrossPreflightAndMigration(t *testing.T) { + t.Parallel() + + var events []string + err := apply(t.Context(), "postgres://captain", applyDependencies{ + acquireLock: func(context.Context, string) (migrationLockHandle, error) { + events = append(events, "lock") + return &recordingMigrationLock{events: &events}, nil + }, + rejectLegacy: func(context.Context, string) error { + events = append(events, "preflight") + return nil + }, + migrate: func(context.Context, string) error { + events = append(events, "migrate") + return nil + }, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + assertEventsEqual(t, events, []string{"lock", "preflight", "migrate", "unlock"}) +} + +func TestApplyReleasesMigrationLockOnEveryFailurePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + preflight error + migration error + wantEvents []string + }{ + { + name: "legacy preflight", + preflight: ErrLegacySessionSchema, + wantEvents: []string{"lock", "preflight", "unlock"}, + }, + { + name: "schema migration", + migration: errors.New("atlas failed"), + wantEvents: []string{"lock", "preflight", "migrate", "unlock"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var events []string + err := apply(t.Context(), "postgres://captain", applyDependencies{ + acquireLock: func(context.Context, string) (migrationLockHandle, error) { + events = append(events, "lock") + return &recordingMigrationLock{events: &events}, nil + }, + rejectLegacy: func(context.Context, string) error { + events = append(events, "preflight") + return tt.preflight + }, + migrate: func(context.Context, string) error { + events = append(events, "migrate") + return tt.migration + }, + }) + wantErr := tt.preflight + if wantErr == nil { + wantErr = tt.migration + } + if !errors.Is(err, wantErr) { + t.Fatalf("apply error = %v, want errors.Is(_, %v)", err, wantErr) + } + assertEventsEqual(t, events, tt.wantEvents) + }) + } +} + +func TestApplyReportsLockAcquisitionAndReleaseErrors(t *testing.T) { + t.Parallel() + + t.Run("acquire", func(t *testing.T) { + t.Parallel() + wantErr := errors.New("lock unavailable") + err := apply(t.Context(), "postgres://captain", applyDependencies{ + acquireLock: func(context.Context, string) (migrationLockHandle, error) { + return nil, wantErr + }, + }) + if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "acquire Captain migration lock") { + t.Fatalf("apply error = %v, want acquisition error", err) + } + }) + + t.Run("release joined with migration error", func(t *testing.T) { + t.Parallel() + migrationErr := errors.New("migration failed") + releaseErr := errors.New("unlock failed") + err := apply(t.Context(), "postgres://captain", applyDependencies{ + acquireLock: func(context.Context, string) (migrationLockHandle, error) { + return &recordingMigrationLock{err: releaseErr}, nil + }, + rejectLegacy: func(context.Context, string) error { return nil }, + migrate: func(context.Context, string) error { return migrationErr }, + }) + if !errors.Is(err, migrationErr) || !errors.Is(err, releaseErr) { + t.Fatalf("apply error = %v, want joined migration and release errors", err) + } + }) +} + +func TestCaptainMigrationLockKeyIsStable(t *testing.T) { + t.Parallel() + if captainMigrationLockNamespace != 0x43415054 || captainMigrationLockKey != 0x4d494752 { + t.Fatalf("Captain migration lock key changed: namespace=%#x key=%#x", + captainMigrationLockNamespace, captainMigrationLockKey) + } +} + +type recordingMigrationLock struct { + events *[]string + err error +} + +func (lock *recordingMigrationLock) Close() error { + if lock.events != nil { + *lock.events = append(*lock.events, "unlock") + } + return lock.err +} + +func assertEventsEqual(t *testing.T, got, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("events = %v, want %v", got, want) + } +} + +func assertContainsAll(t *testing.T, name string, expected ...string) { + t.Helper() + data, err := fs.ReadFile(schemaFS, name) + if err != nil { + t.Fatalf("read embedded migration %s: %v", name, err) + } + content := string(data) + for _, value := range expected { + if !strings.Contains(content, value) { + t.Errorf("%s does not contain %q", name, value) + } + } +} diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 0000000..de9ae83 --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,134 @@ +// Package database provides Captain's public database integration seam. +// +// Captain owns its migrations and schema. A host such as Gavel may provide its +// existing GORM pool through Config.Gorm while also supplying the pool's DSN so +// Captain can apply its own migration bundle first. Standalone consumers can +// omit Config.Gorm and Captain will open a pool after applying the same bundle. +// Hosts that also invoke pkg/cli session APIs must pass the resulting Gorm +// connection to cli.ConfigureNativeDatabase before those APIs initialize. +package database + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/flanksource/captain/migrations" + commonsdb "github.com/flanksource/commons-db/db" + "gorm.io/gorm" +) + +// Config selects an injected or Captain-opened database connection. +type Config struct { + // Gorm is an optional shared application pool. Captain never closes an + // injected pool. + Gorm *gorm.DB + // DSN is required when Captain should apply its migrations. It is also used + // to open a standalone pool when Gorm is nil. + DSN string +} + +// DB is a Captain database handle. It records pool ownership so a host can +// safely share its application pool without Captain closing it. +type DB struct { + gorm *gorm.DB + owned bool +} + +type dependencies struct { + migrate func(context.Context, string) error + open func(string, *gorm.Config) (*gorm.DB, error) +} + +var defaultDependencies = dependencies{ + migrate: migrations.Apply, + open: commonsdb.NewGorm, +} + +// Open applies Captain's migrations when a DSN is supplied, then reuses the +// injected GORM pool or opens a Captain-owned pool. Migration always precedes +// opening a standalone pool, making the ordering explicit for hosts that apply +// several independently owned schemas to one database. +// +// An injected pool may be used without a DSN when the host has already called +// Migrate. Supplying neither a pool nor a DSN is an error. +func Open(ctx context.Context, config Config) (*DB, error) { + return open(ctx, config, defaultDependencies) +} + +func open(ctx context.Context, config Config, deps dependencies) (*DB, error) { + dsn := strings.TrimSpace(config.DSN) + if config.Gorm == nil && dsn == "" { + return nil, errors.New("Captain database requires an injected GORM pool or DSN") + } + + if dsn != "" { + if err := deps.migrate(ctx, dsn); err != nil { + return nil, err + } + } + if config.Gorm != nil { + return &DB{gorm: config.Gorm}, nil + } + + gormDB, err := deps.open(dsn, commonsdb.DefaultGormConfig()) + if err != nil { + return nil, fmt.Errorf("open Captain database: %w", err) + } + return &DB{gorm: gormDB, owned: true}, nil +} + +// Use wraps an already migrated shared GORM pool. The returned handle does not +// own or close the pool. Use Open with both Gorm and DSN when Captain should +// apply its schema before reusing the pool. +func Use(gormDB *gorm.DB) (*DB, error) { + if gormDB == nil { + return nil, errors.New("Captain database GORM pool is nil") + } + return &DB{gorm: gormDB}, nil +} + +// Migrate applies Captain's authoritative HCL and SQL migration bundle without +// opening another application pool. +func Migrate(ctx context.Context, dsn string) error { + return migrations.Apply(ctx, dsn) +} + +// Gorm returns the application pool supplied to or opened by Captain. +func (db *DB) Gorm() *gorm.DB { + if db == nil { + return nil + } + return db.gorm +} + +// Transaction runs fn with a Captain handle backed by the same GORM +// transaction. The scoped handle never owns or closes the underlying pool, so +// hosts can atomically update Captain rows and their own rows in one database. +func (db *DB) Transaction(ctx context.Context, fn func(*DB) error) error { + if db == nil || db.gorm == nil { + return errors.New("Captain database is not initialized") + } + if fn == nil { + return errors.New("Captain database transaction callback is nil") + } + return db.gorm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return fn(&DB{gorm: tx}) + }) +} + +// Close releases a pool opened by Captain. It is a no-op for injected pools. +func (db *DB) Close() error { + if db == nil || db.gorm == nil || !db.owned { + return nil + } + sqlDB, err := db.gorm.DB() + if err != nil { + return fmt.Errorf("access Captain SQL pool: %w", err) + } + if err := sqlDB.Close(); err != nil { + return fmt.Errorf("close Captain database: %w", err) + } + return nil +} diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go new file mode 100644 index 0000000..6ee2f1a --- /dev/null +++ b/pkg/database/database_test.go @@ -0,0 +1,115 @@ +package database + +import ( + "context" + "errors" + "reflect" + "testing" + + "gorm.io/gorm" +) + +func TestOpenMigratesThenOpensStandalonePool(t *testing.T) { + t.Parallel() + + var calls []string + opened := &gorm.DB{} + db, err := open(t.Context(), Config{DSN: " postgres://captain "}, dependencies{ + migrate: func(_ context.Context, dsn string) error { + calls = append(calls, "migrate:"+dsn) + return nil + }, + open: func(dsn string, _ *gorm.Config) (*gorm.DB, error) { + calls = append(calls, "open:"+dsn) + return opened, nil + }, + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + if db.Gorm() != opened || !db.owned { + t.Fatalf("database = %+v, want owned standalone pool", db) + } + want := []string{"migrate:postgres://captain", "open:postgres://captain"} + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %v, want %v", calls, want) + } +} + +func TestOpenMigratesThenReusesInjectedPool(t *testing.T) { + t.Parallel() + + shared := &gorm.DB{} + var calls []string + db, err := open(t.Context(), Config{Gorm: shared, DSN: "postgres://shared"}, dependencies{ + migrate: func(_ context.Context, dsn string) error { + calls = append(calls, "migrate:"+dsn) + return nil + }, + open: func(string, *gorm.Config) (*gorm.DB, error) { + t.Fatal("injected pool must not open another application pool") + return nil, nil + }, + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + if db.Gorm() != shared || db.owned { + t.Fatalf("database = %+v, want non-owning injected pool", db) + } + if want := []string{"migrate:postgres://shared"}; !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %v, want %v", calls, want) + } + if err := db.Close(); err != nil { + t.Fatalf("Close injected database: %v", err) + } +} + +func TestOpenUsesPreMigratedInjectedPoolWithoutDSN(t *testing.T) { + t.Parallel() + + shared := &gorm.DB{} + db, err := open(t.Context(), Config{Gorm: shared}, dependencies{ + migrate: func(context.Context, string) error { + t.Fatal("pre-migrated injected pool must not run migrations") + return nil + }, + open: func(string, *gorm.Config) (*gorm.DB, error) { + t.Fatal("injected pool must not be reopened") + return nil, nil + }, + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + if db.Gorm() != shared { + t.Fatal("Open did not retain injected pool identity") + } +} + +func TestOpenStopsWhenMigrationFails(t *testing.T) { + t.Parallel() + + wantErr := errors.New("migration failed") + _, err := open(t.Context(), Config{DSN: "postgres://captain"}, dependencies{ + migrate: func(context.Context, string) error { return wantErr }, + open: func(string, *gorm.Config) (*gorm.DB, error) { + t.Fatal("pool must not open after migration failure") + return nil, nil + }, + }) + if !errors.Is(err, wantErr) { + t.Fatalf("Open error = %v, want %v", err, wantErr) + } +} + +func TestOpenRequiresPoolOrDSN(t *testing.T) { + t.Parallel() + + if _, err := Open(t.Context(), Config{}); err == nil { + t.Fatal("Open unexpectedly accepted an empty config") + } + if _, err := Use(nil); err == nil { + t.Fatal("Use unexpectedly accepted a nil pool") + } +} diff --git a/pkg/database/migration_integration_test.go b/pkg/database/migration_integration_test.go new file mode 100644 index 0000000..ed272be --- /dev/null +++ b/pkg/database/migration_integration_test.go @@ -0,0 +1,149 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/migrations" + commonsdb "github.com/flanksource/commons-db/db" + "github.com/stretchr/testify/require" +) + +func TestCaptainMigrationsAreIdempotentAndShareOnePool(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres migration tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_contract", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + shared, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) + require.NoError(t, err) + sharedSQL, err := shared.DB() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sharedSQL.Close()) }) + + first, err := Open(t.Context(), Config{Gorm: shared, DSN: dsn}) + require.NoError(t, err) + require.Same(t, shared, first.Gorm()) + require.NoError(t, first.Close(), "closing an injected handle must not close the shared pool") + require.NoError(t, sharedSQL.PingContext(t.Context())) + + second, err := Open(t.Context(), Config{Gorm: shared, DSN: dsn}) + require.NoError(t, err, "applying the Captain bundle twice must be idempotent") + require.Same(t, shared, second.Gorm()) + + for _, table := range []string{ + "captain_sessions", + "captain_prompt_runs", + "captain_prompt_run_iterations", + "captain_plans", + "captain_plan_revisions", + "captain_turns", + "captain_model_calls", + "captain_events", + "captain_turn_requests", + } { + require.True(t, shared.Migrator().HasTable(table), "%s should exist", table) + } + + for table, columns := range map[string][]string{ + "captain_sessions": {"id", "lifecycle_status", "activity_state", "health_state", "state_version"}, + "captain_prompt_runs": {"id", "session_id", "root_session_id", "phase", "state", "version"}, + "captain_prompt_run_iterations": {"id", "prompt_run_id", "state"}, + "captain_plans": {"id", "source_session_id", "approved_revision_id", "approval_state"}, + "captain_plan_revisions": {"id", "plan_id", "revision"}, + "captain_turns": {"id", "session_id", "status"}, + "captain_model_calls": {"id", "turn_id", "prompt_run_id", "iteration_id", "status"}, + "captain_events": {"id", "session_id", "turn_id", "prompt_run_id", "iteration_id", "kind"}, + "captain_turn_requests": {"id", "session_id", "turn_id", "prompt_run_id", "plan_id", "state", "version"}, + } { + for _, column := range columns { + require.True(t, shared.Migrator().HasColumn(table, column), "%s.%s should exist", table, column) + } + } + + for _, view := range []string{ + "captain_session_overview", + "captain_session_turns", + "captain_session_plans", + "captain_session_costs", + "captain_session_events", + "captain_prompt_run_overview", + } { + var exists bool + require.NoError(t, shared.Raw(`SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_views WHERE schemaname = 'public' AND viewname = ? + )`, view).Scan(&exists).Error) + require.True(t, exists, "%s should exist", view) + } +} + +func TestCaptainMigrationsRejectLegacySessionCacheWithoutMutation(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres migration tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_legacy_preflight", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + legacy, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) + require.NoError(t, err) + legacySQL, err := legacy.DB() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, legacySQL.Close()) }) + + require.NoError(t, legacy.Exec(`CREATE TABLE public.captain_sessions ( + path text PRIMARY KEY, + id text, + source text, + mod_unix bigint, + title text + )`).Error) + require.NoError(t, legacy.Exec(`INSERT INTO public.captain_sessions + (path, id, source, mod_unix, title) + VALUES ('/tmp/session.jsonl', 'legacy-session', 'codex', 42, 'preserve me')`).Error) + + _, err = Open(t.Context(), Config{DSN: dsn}) + require.ErrorIs(t, err, migrations.ErrLegacySessionSchema) + require.ErrorContains(t, err, "explicit legacy session backfill/cutover") + + var preserved struct { + Path string + ID string + Source string + ModUnix int64 + Title string + } + require.NoError(t, legacy.Raw(`SELECT path, id, source, mod_unix, title + FROM public.captain_sessions WHERE path = '/tmp/session.jsonl'`).Scan(&preserved).Error) + require.Equal(t, "/tmp/session.jsonl", preserved.Path) + require.Equal(t, "legacy-session", preserved.ID) + require.Equal(t, "codex", preserved.Source) + require.EqualValues(t, 42, preserved.ModUnix) + require.Equal(t, "preserve me", preserved.Title) + require.False(t, legacy.Migrator().HasColumn("captain_sessions", "lifecycle_status")) + require.False(t, legacy.Migrator().HasTable("captain_prompt_runs")) + + var idType string + require.NoError(t, legacy.Raw(`SELECT data_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'captain_sessions' + AND column_name = 'id'`).Scan(&idType).Error) + require.Equal(t, "text", idType) + + var migrationMetadataExists bool + require.NoError(t, legacy.Raw(`SELECT to_regclass('public.schema_migration_scripts') IS NOT NULL`).Scan(&migrationMetadataExists).Error) + require.False(t, migrationMetadataExists, "preflight must fail before commons-db/migrate creates metadata") + +} diff --git a/pkg/database/plan_revision_store_integration_test.go b/pkg/database/plan_revision_store_integration_test.go new file mode 100644 index 0000000..e952d2f --- /dev/null +++ b/pkg/database/plan_revision_store_integration_test.go @@ -0,0 +1,134 @@ +package database + +import ( + "os" + "path/filepath" + "sync" + "testing" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppendPlanRevisionWithResultIsAuthoritativeUnderConcurrency(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres store tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_plan_revision_result", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + db, err := Open(t.Context(), Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + session, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{Source: "test", Provider: "test"}) + require.NoError(t, err) + plan, err := db.CreateOrGetPlan(t.Context(), CreatePlanInput{ + ID: uuid.New(), SourceSessionID: session.ID, Variant: "concurrent", + }) + require.NoError(t, err) + + type result struct { + revision *PlanRevision + created bool + err error + } + const callers = 8 + results := make(chan result, callers) + start := make(chan struct{}) + var wait sync.WaitGroup + for range callers { + wait.Add(1) + go func() { + defer wait.Done() + <-start + revision, created, err := db.AppendPlanRevisionWithResult(t.Context(), AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: "# Shared plan\n\n- preserve data\n", CreatedBy: "test", + }) + results <- result{revision: revision, created: created, err: err} + }() + } + close(start) + wait.Wait() + close(results) + + createdCount := 0 + var revisionID uuid.UUID + for result := range results { + require.NoError(t, result.err) + require.NotNil(t, result.revision) + if revisionID == uuid.Nil { + revisionID = result.revision.ID + } + assert.Equal(t, revisionID, result.revision.ID) + assert.Equal(t, 1, result.revision.Revision) + if result.created { + createdCount++ + } + } + assert.Equal(t, 1, createdCount, "exactly one locked caller must report creating the revision") + + replayed, created, err := db.AppendPlanRevisionWithResult(t.Context(), AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: "\r\n# Shared plan\r\n\r\n- preserve data\r\n", + }) + require.NoError(t, err) + assert.False(t, created) + assert.Equal(t, revisionID, replayed.ID) + + compatible, err := db.AppendPlanRevision(t.Context(), AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: "# Shared plan\n\n- preserve data", + }) + require.NoError(t, err) + assert.Equal(t, revisionID, compatible.ID) + + revisions, err := db.ListPlanRevisions(t.Context(), plan.ID) + require.NoError(t, err) + require.Len(t, revisions, 1) + assert.Equal(t, revisionID, revisions[0].ID) + + linkedPlan, err := db.CreateOrGetPlan(t.Context(), CreatePlanInput{ + ID: uuid.New(), SourceSessionID: session.ID, Variant: "foreign-key-linked", + }) + require.NoError(t, err) + keyShare := db.Gorm().Begin() + require.NoError(t, keyShare.Error) + defer keyShare.Rollback() + var lockedID string + require.NoError(t, keyShare.Raw( + `SELECT id::text FROM captain_plans WHERE id = ? FOR KEY SHARE`, linkedPlan.ID, + ).Scan(&lockedID).Error) + assert.Equal(t, linkedPlan.ID.String(), lockedID) + + type appendResult struct { + revision *PlanRevision + created bool + err error + } + appended := make(chan appendResult, 1) + go func() { + revision, created, err := db.AppendPlanRevisionWithResult(t.Context(), AppendPlanRevisionInput{ + PlanID: linkedPlan.ID, PlanMarkdown: "# Referenced plan", + }) + appended <- appendResult{revision: revision, created: created, err: err} + }() + select { + case result := <-appended: + require.NoError(t, result.err) + require.NotNil(t, result.revision) + assert.True(t, result.created) + case <-time.After(2 * time.Second): + require.NoError(t, keyShare.Rollback().Error) + result := <-appended + require.NoError(t, result.err) + t.Fatal("plan revision append blocked behind a compatible foreign-key KEY SHARE lock") + } + require.NoError(t, keyShare.Commit().Error) +} diff --git a/pkg/database/plan_store.go b/pkg/database/plan_store.go new file mode 100644 index 0000000..71c1076 --- /dev/null +++ b/pkg/database/plan_store.go @@ -0,0 +1,564 @@ +package database + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInvalidPlan = errors.New("invalid Captain plan") + ErrPlanNotFound = errors.New("Captain plan not found") + ErrPlanRevisionNotFound = errors.New("Captain plan revision not found") + ErrPlanConflict = errors.New("Captain plan conflict") +) + +// PlanApprovalState is the durable approval state of a plan. +type PlanApprovalState string + +const ( + PlanApprovalPending PlanApprovalState = "pending" + PlanApprovalApproved PlanApprovalState = "approved" + PlanApprovalRejected PlanApprovalState = "rejected" + PlanApprovalRevisionRequested PlanApprovalState = "revision_requested" +) + +// Plan is a durable plan variant and its current immutable revisions. Paths are +// retained as source metadata only; callers should render revision content. +type Plan struct { + ID uuid.UUID `json:"id"` + SourceSessionID uuid.UUID `json:"sourceSessionId"` + SourcePromptRunID *uuid.UUID `json:"sourcePromptRunId,omitempty"` + SourceIterationID *uuid.UUID `json:"sourceIterationId,omitempty"` + SourceTurnID *uuid.UUID `json:"sourceTurnId,omitempty"` + Title string `json:"title,omitempty"` + Slug string `json:"slug,omitempty"` + Path string `json:"path,omitempty"` + Variant string `json:"variant,omitempty"` + SpecProfile string `json:"specProfile,omitempty"` + ApprovalState PlanApprovalState `json:"approvalState"` + ApprovedRevisionID *uuid.UUID `json:"approvedRevisionId,omitempty"` + ApprovalComment string `json:"approvalComment,omitempty"` + ApprovedBy string `json:"approvedBy,omitempty"` + ApprovalCreatedAt *time.Time `json:"approvalCreatedAt,omitempty"` + FeedbackAt *time.Time `json:"feedbackAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LatestRevision *PlanRevision `json:"latestRevision,omitempty"` + ApprovedRevision *PlanRevision `json:"approvedRevision,omitempty"` +} + +// PlanRevision is one immutable, content-addressed plan revision. +type PlanRevision struct { + ID uuid.UUID `json:"id"` + PlanID uuid.UUID `json:"planId"` + Revision int `json:"revision"` + PlanMarkdown string `json:"planMarkdown"` + ContentHash string `json:"contentHash"` + Feedback string `json:"feedback,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// CreatePlanInput identifies a new plan variant. When SourcePromptRunID and +// Variant are both supplied, CreateOrGetPlan is idempotent on that pair. +// Supplying ID also makes retries idempotent on that ID. +type CreatePlanInput struct { + ID uuid.UUID + SourceSessionID uuid.UUID + SourcePromptRunID *uuid.UUID + SourceIterationID *uuid.UUID + SourceTurnID *uuid.UUID + Title string + Slug string + Path string + Variant string + SpecProfile string +} + +// PlanFilter limits ListPlans. Nil fields are not filtered. +type PlanFilter struct { + SourceSessionID *uuid.UUID + SourcePromptRunID *uuid.UUID + ApprovalState *PlanApprovalState + Variant *string +} + +type AppendPlanRevisionInput struct { + PlanID uuid.UUID + PlanMarkdown string + Feedback string + CreatedBy string +} + +type ApprovePlanRevisionInput struct { + PlanID uuid.UUID + RevisionID uuid.UUID + ApprovedBy string + Comment string +} + +type planRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + SourceSessionID uuid.UUID `gorm:"column:source_session_id;type:uuid"` + SourcePromptRunID *uuid.UUID `gorm:"column:source_prompt_run_id;type:uuid"` + SourceIterationID *uuid.UUID `gorm:"column:source_iteration_id;type:uuid"` + SourceTurnID *uuid.UUID `gorm:"column:source_turn_id;type:uuid"` + Title *string `gorm:"column:title"` + Slug *string `gorm:"column:slug"` + Path *string `gorm:"column:path"` + Variant *string `gorm:"column:variant"` + SpecProfile *string `gorm:"column:spec_profile"` + ApprovalState PlanApprovalState `gorm:"column:approval_state"` + ApprovedRevisionID *uuid.UUID `gorm:"column:approved_revision_id;type:uuid"` + ApprovalComment *string `gorm:"column:approval_comment"` + ApprovedBy *string `gorm:"column:approved_by"` + ApprovalCreatedAt *time.Time `gorm:"column:approval_created_at"` + FeedbackAt *time.Time `gorm:"column:feedback_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (planRecord) TableName() string { return "captain_plans" } + +type planRevisionRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + PlanID uuid.UUID `gorm:"column:plan_id;type:uuid"` + Revision int `gorm:"column:revision"` + PlanMarkdown string `gorm:"column:plan_markdown"` + ContentHash string `gorm:"column:content_hash"` + Feedback *string `gorm:"column:feedback"` + CreatedBy *string `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (planRevisionRecord) TableName() string { return "captain_plan_revisions" } + +// CreateOrGetPlan creates a plan or returns the existing plan for the same +// prompt-run variant/caller-supplied ID. It never overwrites an existing plan. +func (db *DB) CreateOrGetPlan(ctx context.Context, input CreatePlanInput) (*Plan, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if input.SourceSessionID == uuid.Nil { + return nil, fmt.Errorf("%w: source session ID is required", ErrInvalidPlan) + } + if _, err := db.GetSession(ctx, input.SourceSessionID); err != nil { + return nil, err + } + if input.SourceIterationID != nil && input.SourcePromptRunID == nil { + return nil, fmt.Errorf("%w: source iteration requires a prompt run", ErrInvalidPlan) + } + if input.SourcePromptRunID != nil { + if *input.SourcePromptRunID == uuid.Nil { + return nil, fmt.Errorf("%w: source prompt run ID cannot be nil UUID", ErrInvalidPlan) + } + run, err := db.GetPromptRun(ctx, *input.SourcePromptRunID) + if err != nil { + return nil, err + } + if run.SessionID != input.SourceSessionID { + return nil, fmt.Errorf("%w: prompt run %s belongs to session %s", ErrPlanConflict, run.ID, run.SessionID) + } + } + if input.SourceTurnID != nil { + if *input.SourceTurnID == uuid.Nil { + return nil, fmt.Errorf("%w: source turn ID cannot be nil UUID", ErrInvalidPlan) + } + var turn struct { + SessionID uuid.UUID + } + result := db.gorm.WithContext(ctx).Raw( + `SELECT session_id FROM captain_turns WHERE id = ?`, *input.SourceTurnID, + ).Scan(&turn) + if result.Error != nil { + return nil, fmt.Errorf("validate Captain plan source turn: %w", result.Error) + } + if result.RowsAffected == 0 { + return nil, fmt.Errorf("%w: source turn %s does not exist", ErrPlanConflict, *input.SourceTurnID) + } + if turn.SessionID != input.SourceSessionID { + return nil, fmt.Errorf("%w: source turn %s belongs to session %s", ErrPlanConflict, *input.SourceTurnID, turn.SessionID) + } + } + callerSuppliedID := input.ID != uuid.Nil + if !callerSuppliedID { + input.ID = uuid.New() + } + variant := nullableTrimmed(input.Variant) + record := planRecord{ + ID: input.ID, + SourceSessionID: input.SourceSessionID, + SourcePromptRunID: input.SourcePromptRunID, + SourceIterationID: input.SourceIterationID, + SourceTurnID: input.SourceTurnID, + Title: nullableTrimmed(input.Title), + Slug: nullableTrimmed(input.Slug), + Path: nullableTrimmed(input.Path), + Variant: variant, + SpecProfile: nullableTrimmed(input.SpecProfile), + ApprovalState: PlanApprovalPending, + } + result := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&record) + if result.Error != nil { + return nil, fmt.Errorf("create Captain plan: %w", result.Error) + } + if result.RowsAffected == 1 { + return db.GetPlan(ctx, record.ID) + } + + var existing planRecord + query := db.gorm.WithContext(ctx) + if input.SourcePromptRunID != nil && variant != nil { + query = query.Where("source_prompt_run_id = ? AND variant = ?", *input.SourcePromptRunID, *variant) + } else { + query = query.Where("id = ?", input.ID) + } + if err := query.First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: create was rejected by a conflicting identity", ErrPlanConflict) + } + return nil, fmt.Errorf("read existing Captain plan: %w", err) + } + if existing.SourceSessionID != input.SourceSessionID { + return nil, fmt.Errorf("%w: existing plan belongs to session %s", ErrPlanConflict, existing.SourceSessionID) + } + if callerSuppliedID && existing.ID != input.ID { + return nil, fmt.Errorf("%w: prompt variant already belongs to plan %s, not caller-supplied ID %s", ErrPlanConflict, existing.ID, input.ID) + } + return db.GetPlan(ctx, existing.ID) +} + +// AppendPlanRevision appends a monotonically numbered revision while holding a +// row lock on the plan. Equivalent LF/CRLF and surrounding whitespace content +// is idempotent because its normalized SHA-256 hash is unique per plan. +func (db *DB) AppendPlanRevision(ctx context.Context, input AppendPlanRevisionInput) (*PlanRevision, error) { + revision, _, err := db.AppendPlanRevisionWithResult(ctx, input) + return revision, err +} + +// AppendPlanRevisionWithResult appends or resolves an idempotent plan revision. +// Created is true only when this call inserted the revision while holding the +// plan row lock; an equivalent existing content hash returns false. +func (db *DB) AppendPlanRevisionWithResult(ctx context.Context, input AppendPlanRevisionInput) (*PlanRevision, bool, error) { + if err := db.requireGorm(); err != nil { + return nil, false, err + } + if input.PlanID == uuid.Nil { + return nil, false, fmt.Errorf("%w: plan ID is required", ErrInvalidPlan) + } + markdown := normalizePlanMarkdown(input.PlanMarkdown) + if markdown == "" { + return nil, false, fmt.Errorf("%w: plan markdown is empty", ErrInvalidPlan) + } + hash := planContentHash(markdown) + var revision planRevisionRecord + created := false + err := db.gorm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var plan planRecord + // NO KEY UPDATE serializes revision allocation without conflicting with + // the KEY SHARE locks held by foreign keys that already reference this + // plan in the caller's transaction. + if err := tx.Clauses(clause.Locking{Strength: "NO KEY UPDATE"}).First(&plan, "id = ?", input.PlanID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPlanNotFound + } + return err + } + if err := tx.Where("plan_id = ? AND content_hash = ?", input.PlanID, hash).First(&revision).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var latest int + if err := tx.Model(&planRevisionRecord{}). + Where("plan_id = ?", input.PlanID). + Select("COALESCE(MAX(revision), 0)").Scan(&latest).Error; err != nil { + return err + } + revision = planRevisionRecord{ + ID: uuid.New(), + PlanID: input.PlanID, + Revision: latest + 1, + PlanMarkdown: markdown, + ContentHash: hash, + Feedback: nullableTrimmed(input.Feedback), + CreatedBy: nullableTrimmed(input.CreatedBy), + } + if err := tx.Create(&revision).Error; err != nil { + return err + } + created = true + return nil + }) + if err != nil { + if errors.Is(err, ErrPlanNotFound) { + return nil, false, fmt.Errorf("%w: %s", ErrPlanNotFound, input.PlanID) + } + return nil, false, fmt.Errorf("append Captain plan revision: %w", err) + } + out := revisionFromRecord(revision) + return &out, created, nil +} + +// ApprovePlanRevision atomically verifies the revision belongs to the plan and +// selects it as the durable approved content. +func (db *DB) ApprovePlanRevision(ctx context.Context, input ApprovePlanRevisionInput) (*Plan, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if input.PlanID == uuid.Nil || input.RevisionID == uuid.Nil { + return nil, fmt.Errorf("%w: plan and revision IDs are required", ErrInvalidPlan) + } + err := db.gorm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var plan planRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&plan, "id = ?", input.PlanID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPlanNotFound + } + return err + } + var revision planRevisionRecord + if err := tx.First(&revision, "id = ? AND plan_id = ?", input.RevisionID, input.PlanID).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var count int64 + if err := tx.Model(&planRevisionRecord{}).Where("id = ?", input.RevisionID).Count(&count).Error; err != nil { + return err + } + if count != 0 { + return fmt.Errorf("%w: revision belongs to another plan", ErrPlanConflict) + } + return ErrPlanRevisionNotFound + } + approvedBy := nullableTrimmed(input.ApprovedBy) + comment := nullableTrimmed(input.Comment) + if plan.ApprovalState == PlanApprovalApproved && plan.ApprovedRevisionID != nil && + *plan.ApprovedRevisionID == input.RevisionID && equalOptionalString(plan.ApprovedBy, approvedBy) && + equalOptionalString(plan.ApprovalComment, comment) { + return nil + } + return tx.Model(&planRecord{}).Where("id = ?", input.PlanID).Updates(map[string]any{ + "approval_state": PlanApprovalApproved, + "approved_revision_id": input.RevisionID, + "approved_by": approvedBy, + "approval_comment": comment, + "approval_created_at": gorm.Expr("clock_timestamp()"), + }).Error + }) + if err != nil { + switch { + case errors.Is(err, ErrPlanNotFound): + return nil, fmt.Errorf("%w: %s", ErrPlanNotFound, input.PlanID) + case errors.Is(err, ErrPlanRevisionNotFound): + return nil, fmt.Errorf("%w: %s", ErrPlanRevisionNotFound, input.RevisionID) + default: + return nil, fmt.Errorf("approve Captain plan revision: %w", err) + } + } + return db.GetPlan(ctx, input.PlanID) +} + +func (db *DB) GetPlan(ctx context.Context, id uuid.UUID) (*Plan, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if id == uuid.Nil { + return nil, fmt.Errorf("%w: plan ID is required", ErrInvalidPlan) + } + var record planRecord + if err := db.gorm.WithContext(ctx).First(&record, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrPlanNotFound, id) + } + return nil, fmt.Errorf("get Captain plan: %w", err) + } + plans, err := db.hydratePlans(ctx, []planRecord{record}) + if err != nil { + return nil, err + } + return &plans[0], nil +} + +func (db *DB) ListPlans(ctx context.Context, filter PlanFilter) ([]Plan, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + query := db.gorm.WithContext(ctx).Order("created_at DESC, id DESC") + if filter.SourceSessionID != nil { + query = query.Where("source_session_id = ?", *filter.SourceSessionID) + } + if filter.SourcePromptRunID != nil { + query = query.Where("source_prompt_run_id = ?", *filter.SourcePromptRunID) + } + if filter.ApprovalState != nil { + if !validPlanApprovalState(*filter.ApprovalState) { + return nil, fmt.Errorf("%w: unknown approval state %q", ErrInvalidPlan, *filter.ApprovalState) + } + query = query.Where("approval_state = ?", *filter.ApprovalState) + } + if filter.Variant != nil { + query = query.Where("variant = ?", strings.TrimSpace(*filter.Variant)) + } + var records []planRecord + if err := query.Find(&records).Error; err != nil { + return nil, fmt.Errorf("list Captain plans: %w", err) + } + return db.hydratePlans(ctx, records) +} + +func (db *DB) GetPlanRevision(ctx context.Context, planID, revisionID uuid.UUID) (*PlanRevision, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + var record planRevisionRecord + if err := db.gorm.WithContext(ctx).First(&record, "plan_id = ? AND id = ?", planID, revisionID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrPlanRevisionNotFound, revisionID) + } + return nil, fmt.Errorf("get Captain plan revision: %w", err) + } + out := revisionFromRecord(record) + return &out, nil +} + +func (db *DB) GetApprovedPlanRevision(ctx context.Context, planID uuid.UUID) (*PlanRevision, error) { + plan, err := db.GetPlan(ctx, planID) + if err != nil { + return nil, err + } + if plan.ApprovedRevision == nil { + return nil, fmt.Errorf("%w: plan %s has no approved revision", ErrPlanRevisionNotFound, planID) + } + return plan.ApprovedRevision, nil +} + +func (db *DB) ListPlanRevisions(ctx context.Context, planID uuid.UUID) ([]PlanRevision, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + var exists int64 + if err := db.gorm.WithContext(ctx).Model(&planRecord{}).Where("id = ?", planID).Count(&exists).Error; err != nil { + return nil, fmt.Errorf("check Captain plan: %w", err) + } + if exists == 0 { + return nil, fmt.Errorf("%w: %s", ErrPlanNotFound, planID) + } + var records []planRevisionRecord + if err := db.gorm.WithContext(ctx).Where("plan_id = ?", planID).Order("revision ASC").Find(&records).Error; err != nil { + return nil, fmt.Errorf("list Captain plan revisions: %w", err) + } + out := make([]PlanRevision, len(records)) + for i := range records { + out[i] = revisionFromRecord(records[i]) + } + return out, nil +} + +func (db *DB) hydratePlans(ctx context.Context, records []planRecord) ([]Plan, error) { + plans := make([]Plan, len(records)) + if len(records) == 0 { + return plans, nil + } + ids := make([]uuid.UUID, len(records)) + index := make(map[uuid.UUID]int, len(records)) + for i := range records { + plans[i] = planFromRecord(records[i]) + ids[i] = records[i].ID + index[records[i].ID] = i + } + var revisions []planRevisionRecord + if err := db.gorm.WithContext(ctx).Where("plan_id IN ?", ids).Order("plan_id, revision DESC").Find(&revisions).Error; err != nil { + return nil, fmt.Errorf("load Captain plan revisions: %w", err) + } + for _, record := range revisions { + i := index[record.PlanID] + revision := revisionFromRecord(record) + if plans[i].LatestRevision == nil { + latest := revision + plans[i].LatestRevision = &latest + } + if plans[i].ApprovedRevisionID != nil && record.ID == *plans[i].ApprovedRevisionID { + approved := revision + plans[i].ApprovedRevision = &approved + } + } + return plans, nil +} + +func (db *DB) requireGorm() error { + if db == nil || db.gorm == nil { + return errors.New("Captain database is not initialized") + } + return nil +} + +func planFromRecord(record planRecord) Plan { + return Plan{ + ID: record.ID, SourceSessionID: record.SourceSessionID, + SourcePromptRunID: record.SourcePromptRunID, SourceIterationID: record.SourceIterationID, + SourceTurnID: record.SourceTurnID, Title: optionalString(record.Title), Slug: optionalString(record.Slug), + Path: optionalString(record.Path), Variant: optionalString(record.Variant), SpecProfile: optionalString(record.SpecProfile), + ApprovalState: record.ApprovalState, ApprovedRevisionID: record.ApprovedRevisionID, + ApprovalComment: optionalString(record.ApprovalComment), ApprovedBy: optionalString(record.ApprovedBy), + ApprovalCreatedAt: record.ApprovalCreatedAt, FeedbackAt: record.FeedbackAt, + CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, + } +} + +func revisionFromRecord(record planRevisionRecord) PlanRevision { + return PlanRevision{ + ID: record.ID, PlanID: record.PlanID, Revision: record.Revision, + PlanMarkdown: record.PlanMarkdown, ContentHash: record.ContentHash, + Feedback: optionalString(record.Feedback), CreatedBy: optionalString(record.CreatedBy), CreatedAt: record.CreatedAt, + } +} + +func normalizePlanMarkdown(markdown string) string { + markdown = strings.ReplaceAll(markdown, "\r\n", "\n") + markdown = strings.ReplaceAll(markdown, "\r", "\n") + return strings.TrimSpace(markdown) +} + +func planContentHash(normalized string) string { + sum := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(sum[:]) +} + +func nullableTrimmed(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +func optionalString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func equalOptionalString(left, right *string) bool { + return optionalString(left) == optionalString(right) +} + +func validPlanApprovalState(value PlanApprovalState) bool { + switch value { + case PlanApprovalPending, PlanApprovalApproved, PlanApprovalRejected, PlanApprovalRevisionRequested: + return true + default: + return false + } +} diff --git a/pkg/database/plan_store_test.go b/pkg/database/plan_store_test.go new file mode 100644 index 0000000..f817871 --- /dev/null +++ b/pkg/database/plan_store_test.go @@ -0,0 +1,23 @@ +package database + +import ( + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestNormalizePlanMarkdownProducesStableHash(t *testing.T) { + left := normalizePlanMarkdown("\r\n# Plan\r\n\r\nstep\r\n") + right := normalizePlanMarkdown("# Plan\n\nstep\n") + assert.Equal(t, right, left) + assert.Equal(t, planContentHash(right), planContentHash(left)) +} + +func TestPlanStoreRejectsInvalidInputBeforeDatabaseAccess(t *testing.T) { + db := &DB{gorm: nil} + _, err := db.AppendPlanRevision(t.Context(), AppendPlanRevisionInput{PlanID: uuid.New(), PlanMarkdown: "x"}) + assert.Error(t, err) + assert.False(t, errors.Is(err, ErrPlanNotFound)) +} diff --git a/pkg/database/prompt_run_store_integration_test.go b/pkg/database/prompt_run_store_integration_test.go new file mode 100644 index 0000000..5e569fc --- /dev/null +++ b/pkg/database/prompt_run_store_integration_test.go @@ -0,0 +1,78 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListPromptRunsFiltersAndOrdersDeterministically(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres store tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_prompt_run_list", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + db, err := Open(t.Context(), Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + session, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{Source: "codex", Provider: "openai"}) + require.NoError(t, err) + otherSession, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{Source: "claude", Provider: "anthropic"}) + require.NoError(t, err) + + firstID := uuid.MustParse("10000000-0000-0000-0000-000000000001") + first, err := db.CreatePromptRun(t.Context(), CreatePromptRunInput{ID: firstID, SessionID: session.ID}) + require.NoError(t, err) + finished := PromptRunPhaseFinished + succeeded := PromptRunStateSucceeded + _, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: first.ID, ExpectedVersion: first.Version, Phase: &finished, State: &succeeded, + }) + require.NoError(t, err) + + secondID := uuid.MustParse("10000000-0000-0000-0000-000000000002") + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ID: secondID, SessionID: session.ID}) + require.NoError(t, err) + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + ID: uuid.MustParse("10000000-0000-0000-0000-000000000003"), SessionID: otherSession.ID, + }) + require.NoError(t, err) + + sharedQueuedAt := time.Date(2026, time.July, 12, 10, 0, 0, 0, time.UTC) + require.NoError(t, db.Gorm().Exec( + `UPDATE captain_prompt_runs SET queued_at = ? WHERE id IN (?, ?)`, sharedQueuedAt, firstID, secondID, + ).Error) + + listed, err := db.ListPromptRuns(t.Context(), PromptRunFilter{SessionID: &session.ID}) + require.NoError(t, err) + require.Len(t, listed, 2) + assert.Equal(t, secondID, listed[0].ID) + assert.Equal(t, firstID, listed[1].ID) + + pending := PromptRunStatePending + listed, err = db.ListPromptRuns(t.Context(), PromptRunFilter{SessionID: &session.ID, State: &pending}) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, secondID, listed[0].ID) + + unknown := PromptRunState("unknown") + _, err = db.ListPromptRuns(t.Context(), PromptRunFilter{State: &unknown}) + assert.ErrorIs(t, err, ErrInvalidPromptRun) + + emptySessionID := uuid.Nil + _, err = db.ListPromptRuns(t.Context(), PromptRunFilter{SessionID: &emptySessionID}) + assert.ErrorIs(t, err, ErrInvalidPromptRun) +} diff --git a/pkg/database/session_prompt_store.go b/pkg/database/session_prompt_store.go new file mode 100644 index 0000000..618e18c --- /dev/null +++ b/pkg/database/session_prompt_store.go @@ -0,0 +1,819 @@ +package database + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInvalidSession = errors.New("invalid Captain session") + ErrSessionNotFound = errors.New("Captain session not found") + ErrSessionConflict = errors.New("Captain session conflict") + ErrInvalidPromptRun = errors.New("invalid Captain prompt run") + ErrPromptRunNotFound = errors.New("Captain prompt run not found") + ErrPromptRunConflict = errors.New("Captain prompt run conflict") +) + +type SessionLifecycleStatus string + +const ( + SessionLifecycleCreated SessionLifecycleStatus = "created" + SessionLifecycleRunning SessionLifecycleStatus = "running" + SessionLifecycleSucceeded SessionLifecycleStatus = "succeeded" + SessionLifecycleFailed SessionLifecycleStatus = "failed" + SessionLifecycleCancelled SessionLifecycleStatus = "cancelled" + SessionLifecycleInterrupted SessionLifecycleStatus = "interrupted" +) + +type SessionActivityState string + +const ( + SessionActivityIdle SessionActivityState = "idle" + SessionActivityThinking SessionActivityState = "thinking" + SessionActivityWorking SessionActivityState = "working" + SessionActivityAsk SessionActivityState = "ask" + SessionActivityApproval SessionActivityState = "approval" +) + +type SessionHealthState string + +const ( + SessionHealthHealthy SessionHealthState = "healthy" + SessionHealthStalled SessionHealthState = "stalled" + SessionHealthZombie SessionHealthState = "zombie" +) + +// Session is Captain's authoritative provider-session identity. +type Session struct { + ID uuid.UUID `json:"id"` + ProviderSessionID string `json:"providerSessionId,omitempty"` + Source string `json:"source"` + Provider string `json:"provider"` + HostID string `json:"hostId"` + ParentSessionID *uuid.UUID `json:"parentSessionId,omitempty"` + RootSessionID *uuid.UUID `json:"rootSessionId,omitempty"` + Path string `json:"path,omitempty"` + Project string `json:"project,omitempty"` + CWD string `json:"cwd,omitempty"` + Title string `json:"title,omitempty"` + InitialPrompt string `json:"initialPrompt,omitempty"` + Slug string `json:"slug,omitempty"` + AgentType string `json:"agentType,omitempty"` + Description string `json:"description,omitempty"` + CLIVersion string `json:"cliVersion,omitempty"` + LifecycleStatus SessionLifecycleStatus `json:"lifecycleStatus"` + ActivityState SessionActivityState `json:"activityState"` + HealthState SessionHealthState `json:"healthState"` + StateReason string `json:"stateReason,omitempty"` + StateVersion int64 `json:"stateVersion"` + StateObservedAt time.Time `json:"stateObservedAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + EndedAt *time.Time `json:"endedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreateSessionInput struct { + ID uuid.UUID + ProviderSessionID string + Source string + Provider string + HostID string + ParentSessionID *uuid.UUID + RootSessionID *uuid.UUID + Path string + Project string + CWD string + Title string + InitialPrompt string + Slug string + AgentType string + Description string + CLIVersion string +} + +type sessionRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + ProviderSessionID *string `gorm:"column:provider_session_id"` + Source string `gorm:"column:source"` + Provider string `gorm:"column:provider"` + HostID string `gorm:"column:host_id"` + ParentSessionID *uuid.UUID `gorm:"column:parent_session_id;type:uuid"` + RootSessionID *uuid.UUID `gorm:"column:root_session_id;type:uuid"` + Path *string `gorm:"column:path"` + Project *string `gorm:"column:project"` + CWD *string `gorm:"column:cwd"` + Title *string `gorm:"column:title"` + InitialPrompt *string `gorm:"column:initial_prompt"` + Slug *string `gorm:"column:slug"` + AgentType *string `gorm:"column:agent_type"` + Description *string `gorm:"column:description"` + CLIVersion *string `gorm:"column:cli_version"` + LifecycleStatus SessionLifecycleStatus `gorm:"column:lifecycle_status"` + ActivityState SessionActivityState `gorm:"column:activity_state"` + HealthState SessionHealthState `gorm:"column:health_state"` + StateReason *string `gorm:"column:state_reason"` + StateVersion int64 `gorm:"column:state_version"` + StateObservedAt time.Time `gorm:"column:state_observed_at"` + StartedAt *time.Time `gorm:"column:started_at"` + EndedAt *time.Time `gorm:"column:ended_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (sessionRecord) TableName() string { return "captain_sessions" } + +func (db *DB) CreateOrGetSession(ctx context.Context, input CreateSessionInput) (*Session, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + input.Source = strings.TrimSpace(input.Source) + if input.Source == "" { + return nil, fmt.Errorf("%w: source is required", ErrInvalidSession) + } + input.Provider = strings.TrimSpace(input.Provider) + input.HostID = strings.TrimSpace(input.HostID) + if input.HostID == "" { + input.HostID = "local" + } + callerSuppliedID := input.ID != uuid.Nil + if !callerSuppliedID { + input.ID = uuid.New() + } + if input.ParentSessionID != nil { + if *input.ParentSessionID == uuid.Nil || *input.ParentSessionID == input.ID { + return nil, fmt.Errorf("%w: session cannot have an empty or self parent", ErrInvalidSession) + } + parent, err := db.GetSession(ctx, *input.ParentSessionID) + if err != nil { + return nil, err + } + derivedRoot := parent.ID + if parent.RootSessionID != nil { + derivedRoot = *parent.RootSessionID + } + if input.RootSessionID != nil && *input.RootSessionID != derivedRoot { + return nil, fmt.Errorf("%w: root session %s does not match parent aggregate root %s", ErrSessionConflict, *input.RootSessionID, derivedRoot) + } + input.RootSessionID = &derivedRoot + } else if input.RootSessionID != nil { + if *input.RootSessionID == uuid.Nil { + return nil, fmt.Errorf("%w: root session ID cannot be empty", ErrInvalidSession) + } + if *input.RootSessionID == input.ID { + return nil, fmt.Errorf("%w: session cannot be its own root reference", ErrInvalidSession) + } + root, err := db.GetSession(ctx, *input.RootSessionID) + if err != nil { + return nil, err + } + if root.ParentSessionID != nil || root.RootSessionID != nil { + return nil, fmt.Errorf("%w: root session %s is not a canonical aggregate root", ErrSessionConflict, root.ID) + } + } + if input.RootSessionID != nil && *input.RootSessionID == input.ID { + return nil, fmt.Errorf("%w: session cannot be its own root reference", ErrInvalidSession) + } + now := time.Now().UTC() + record := sessionRecord{ + ID: input.ID, ProviderSessionID: nullableTrimmed(input.ProviderSessionID), Source: input.Source, + Provider: input.Provider, HostID: input.HostID, ParentSessionID: input.ParentSessionID, + RootSessionID: input.RootSessionID, Path: nullableTrimmed(input.Path), Project: nullableTrimmed(input.Project), + CWD: nullableTrimmed(input.CWD), Title: nullableTrimmed(input.Title), InitialPrompt: nullableTrimmed(input.InitialPrompt), + Slug: nullableTrimmed(input.Slug), AgentType: nullableTrimmed(input.AgentType), Description: nullableTrimmed(input.Description), + CLIVersion: nullableTrimmed(input.CLIVersion), LifecycleStatus: SessionLifecycleCreated, + ActivityState: SessionActivityIdle, HealthState: SessionHealthHealthy, StateObservedAt: now, + } + result := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&record) + if result.Error != nil { + return nil, fmt.Errorf("create Captain session: %w", result.Error) + } + if result.RowsAffected == 1 { + return db.GetSession(ctx, record.ID) + } + + var existing sessionRecord + query := db.gorm.WithContext(ctx) + if record.ProviderSessionID != nil { + query = query.Where("source = ? AND provider = ? AND host_id = ? AND provider_session_id = ?", + record.Source, record.Provider, record.HostID, *record.ProviderSessionID) + } else { + query = query.Where("id = ?", record.ID) + } + if err := query.First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: create was rejected by a conflicting identity", ErrSessionConflict) + } + return nil, fmt.Errorf("read existing Captain session: %w", err) + } + if existing.Source != record.Source || existing.Provider != record.Provider || existing.HostID != record.HostID { + return nil, fmt.Errorf("%w: existing session has a different provider identity", ErrSessionConflict) + } + if callerSuppliedID && existing.ID != input.ID { + return nil, fmt.Errorf("%w: provider identity already belongs to session %s, not caller-supplied ID %s", ErrSessionConflict, existing.ID, input.ID) + } + if !equalOptionalUUID(existing.ParentSessionID, record.ParentSessionID) || + !equalOptionalUUID(existing.RootSessionID, record.RootSessionID) { + return nil, fmt.Errorf("%w: existing session has a different hierarchy", ErrSessionConflict) + } + return db.GetSession(ctx, existing.ID) +} + +type UpdateSessionStateInput struct { + ID uuid.UUID + ExpectedVersion int64 + ProviderSessionID *string + LifecycleStatus *SessionLifecycleStatus + ActivityState *SessionActivityState + HealthState *SessionHealthState + StateReason *string +} + +// UpdateSessionState applies session identity/state projection changes only at +// ExpectedVersion. ProviderSessionID is a set-once binding: concurrent binders +// may set NULL to one non-empty value, while exact retries remain idempotent. +// Captain's trigger advances StateVersion for state changes. +func (db *DB) UpdateSessionState(ctx context.Context, input UpdateSessionStateInput) (*Session, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if input.ID == uuid.Nil || input.ExpectedVersion < 0 { + return nil, fmt.Errorf("%w: ID and a nonnegative expected version are required", ErrInvalidSession) + } + updates := map[string]any{} + var distinctPredicates []string + var distinctArgs []any + query := db.gorm.WithContext(ctx).Model(&sessionRecord{}). + Where("id = ? AND state_version = ?", input.ID, input.ExpectedVersion) + if input.ProviderSessionID != nil { + providerSessionID := strings.TrimSpace(*input.ProviderSessionID) + if providerSessionID == "" { + return nil, fmt.Errorf("%w: provider session ID cannot be cleared or empty", ErrInvalidSession) + } + updates["provider_session_id"] = providerSessionID + query = query.Where("(provider_session_id IS NULL OR provider_session_id = ?)", providerSessionID) + distinctPredicates = append(distinctPredicates, "provider_session_id IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, providerSessionID) + } + if input.LifecycleStatus != nil { + if !validSessionLifecycle(*input.LifecycleStatus) { + return nil, fmt.Errorf("%w: unknown lifecycle status %q", ErrInvalidSession, *input.LifecycleStatus) + } + updates["lifecycle_status"] = *input.LifecycleStatus + distinctPredicates = append(distinctPredicates, "lifecycle_status IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.LifecycleStatus) + } + if input.ActivityState != nil { + if !validSessionActivity(*input.ActivityState) { + return nil, fmt.Errorf("%w: unknown activity state %q", ErrInvalidSession, *input.ActivityState) + } + updates["activity_state"] = *input.ActivityState + distinctPredicates = append(distinctPredicates, "activity_state IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.ActivityState) + } + if input.HealthState != nil { + if !validSessionHealth(*input.HealthState) { + return nil, fmt.Errorf("%w: unknown health state %q", ErrInvalidSession, *input.HealthState) + } + updates["health_state"] = *input.HealthState + distinctPredicates = append(distinctPredicates, "health_state IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.HealthState) + } + if input.StateReason != nil { + stateReason := nullableTrimmed(*input.StateReason) + updates["state_reason"] = stateReason + distinctPredicates = append(distinctPredicates, "state_reason IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, stateReason) + } + if len(updates) == 0 { + return nil, fmt.Errorf("%w: no update fields supplied", ErrInvalidSession) + } + query = query.Where("("+strings.Join(distinctPredicates, " OR ")+")", distinctArgs...) + result := query.Updates(updates) + if result.Error != nil { + if isUniqueViolation(result.Error) { + return nil, fmt.Errorf("%w: provider session identity is already bound", ErrSessionConflict) + } + return nil, fmt.Errorf("update Captain session state: %w", result.Error) + } + if result.RowsAffected == 0 { + var current sessionRecord + if err := db.gorm.WithContext(ctx).First(¤t, "id = ?", input.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, input.ID) + } + return nil, fmt.Errorf("check Captain session: %w", err) + } + if input.ProviderSessionID != nil && current.ProviderSessionID != nil && + *current.ProviderSessionID != strings.TrimSpace(*input.ProviderSessionID) { + return nil, fmt.Errorf("%w: provider session ID is already bound to %q", ErrSessionConflict, *current.ProviderSessionID) + } + if current.StateVersion != input.ExpectedVersion { + return nil, fmt.Errorf("%w: session %s is no longer at state version %d", ErrSessionConflict, input.ID, input.ExpectedVersion) + } + out := sessionFromRecord(current) + return &out, nil + } + return db.GetSession(ctx, input.ID) +} + +func (db *DB) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + var record sessionRecord + if err := db.gorm.WithContext(ctx).First(&record, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, id) + } + return nil, fmt.Errorf("get Captain session: %w", err) + } + out := sessionFromRecord(record) + return &out, nil +} + +// GetSessionByIdentity resolves either an authoritative UUID or a provider +// session ID. Provider IDs are required to be unambiguous across the supplied +// optional source/provider/host filters. +func (db *DB) GetSessionByIdentity(ctx context.Context, identity, source, provider, hostID string) (*Session, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + identity = strings.TrimSpace(identity) + if identity == "" { + return nil, fmt.Errorf("%w: identity is required", ErrInvalidSession) + } + source = strings.TrimSpace(source) + provider = strings.TrimSpace(provider) + hostID = strings.TrimSpace(hostID) + if parsed, err := uuid.Parse(identity); err == nil { + var record sessionRecord + err := filterSessionIdentity(db.gorm.WithContext(ctx), source, provider, hostID). + First(&record, "id = ?", parsed).Error + if err == nil { + out := sessionFromRecord(record) + return &out, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("resolve Captain session UUID: %w", err) + } + } + query := filterSessionIdentity(db.gorm.WithContext(ctx), source, provider, hostID). + Where("provider_session_id = ?", identity) + var records []sessionRecord + if err := query.Limit(2).Find(&records).Error; err != nil { + return nil, fmt.Errorf("resolve Captain session identity: %w", err) + } + if len(records) == 0 { + return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, identity) + } + if len(records) > 1 { + return nil, fmt.Errorf("%w: provider session ID %q is ambiguous", ErrSessionConflict, identity) + } + out := sessionFromRecord(records[0]) + return &out, nil +} + +func filterSessionIdentity(query *gorm.DB, source, provider, hostID string) *gorm.DB { + if source != "" { + query = query.Where("source = ?", source) + } + if provider != "" { + query = query.Where("provider = ?", provider) + } + if hostID != "" { + query = query.Where("host_id = ?", hostID) + } + return query +} + +type PromptRunPhase string + +const ( + PromptRunPhaseQueued PromptRunPhase = "queued" + PromptRunPhasePreRun PromptRunPhase = "pre_run" + PromptRunPhaseGenerate PromptRunPhase = "generate" + PromptRunPhaseVerify PromptRunPhase = "verify" + PromptRunPhaseFeedback PromptRunPhase = "feedback" + PromptRunPhasePostRun PromptRunPhase = "post_run" + PromptRunPhaseOutput PromptRunPhase = "output" + PromptRunPhaseFinished PromptRunPhase = "finished" +) + +type PromptRunState string + +const ( + PromptRunStatePending PromptRunState = "pending" + PromptRunStateRunning PromptRunState = "running" + PromptRunStateWaiting PromptRunState = "waiting" + PromptRunStateSucceeded PromptRunState = "succeeded" + PromptRunStateFailed PromptRunState = "failed" + PromptRunStateCancelled PromptRunState = "cancelled" +) + +type PromptRun struct { + ID uuid.UUID `json:"id"` + SessionID uuid.UUID `json:"sessionId"` + RootSessionID uuid.UUID `json:"rootSessionId"` + BatchID *uuid.UUID `json:"batchId,omitempty"` + ParentRunID *uuid.UUID `json:"parentRunId,omitempty"` + InputPlanID *uuid.UUID `json:"inputPlanId,omitempty"` + InputPlanRevisionID *uuid.UUID `json:"inputPlanRevisionId,omitempty"` + Origin string `json:"origin,omitempty"` + SpecProfile string `json:"specProfile,omitempty"` + AdmissionKey string `json:"admissionKey,omitempty"` + RenderedSpec map[string]any `json:"renderedSpec,omitempty"` + PromptMarkdown string `json:"promptMarkdown,omitempty"` + VerificationMarkdown string `json:"verificationMarkdown,omitempty"` + Phase PromptRunPhase `json:"phase"` + State PromptRunState `json:"state"` + CurrentIteration int `json:"currentIteration"` + ResultText string `json:"resultText,omitempty"` + ResultJSON map[string]any `json:"resultJson,omitempty"` + Error string `json:"error,omitempty"` + Version int64 `json:"version"` + QueuedAt time.Time `json:"queuedAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PromptRunFilter limits ListPromptRuns. Nil fields are not filtered. +type PromptRunFilter struct { + SessionID *uuid.UUID + State *PromptRunState +} + +type CreatePromptRunInput struct { + ID uuid.UUID + SessionID uuid.UUID + RootSessionID *uuid.UUID + BatchID *uuid.UUID + ParentRunID *uuid.UUID + InputPlanID *uuid.UUID + InputPlanRevisionID *uuid.UUID + Origin string + SpecProfile string + AdmissionKey string + RenderedSpec map[string]any + PromptMarkdown string + VerificationMarkdown string +} + +type UpdatePromptRunInput struct { + ID uuid.UUID + ExpectedVersion int64 + Phase *PromptRunPhase + State *PromptRunState + CurrentIteration *int + ResultText *string + ResultJSON *map[string]any + Error *string +} + +type promptRunRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` + RootSessionID uuid.UUID `gorm:"column:root_session_id;type:uuid"` + BatchID *uuid.UUID `gorm:"column:batch_id;type:uuid"` + ParentRunID *uuid.UUID `gorm:"column:parent_run_id;type:uuid"` + InputPlanID *uuid.UUID `gorm:"column:input_plan_id;type:uuid"` + InputPlanRevisionID *uuid.UUID `gorm:"column:input_plan_revision_id;type:uuid"` + Origin *string `gorm:"column:origin"` + SpecProfile *string `gorm:"column:spec_profile"` + AdmissionKey *string `gorm:"column:admission_key"` + RenderedSpec map[string]any `gorm:"column:rendered_spec;serializer:json;type:jsonb"` + PromptMarkdown *string `gorm:"column:prompt_markdown"` + VerificationMarkdown *string `gorm:"column:verification_markdown"` + Phase PromptRunPhase `gorm:"column:phase"` + State PromptRunState `gorm:"column:state"` + CurrentIteration int `gorm:"column:current_iteration"` + ResultText *string `gorm:"column:result_text"` + ResultJSON map[string]any `gorm:"column:result_json;serializer:json;type:jsonb"` + Error *string `gorm:"column:error"` + Version int64 `gorm:"column:version"` + QueuedAt time.Time `gorm:"column:queued_at"` + StartedAt *time.Time `gorm:"column:started_at"` + FinishedAt *time.Time `gorm:"column:finished_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (promptRunRecord) TableName() string { return "captain_prompt_runs" } + +// CreatePromptRun creates a run and returns its authoritative UUID. AdmissionKey +// or a caller-supplied ID makes retries idempotent. +func (db *DB) CreatePromptRun(ctx context.Context, input CreatePromptRunInput) (*PromptRun, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if input.SessionID == uuid.Nil { + return nil, fmt.Errorf("%w: session ID is required", ErrInvalidPromptRun) + } + if input.InputPlanRevisionID != nil && input.InputPlanID == nil { + return nil, fmt.Errorf("%w: input revision requires an input plan", ErrInvalidPromptRun) + } + session, err := db.GetSession(ctx, input.SessionID) + if err != nil { + return nil, err + } + callerSuppliedID := input.ID != uuid.Nil + if !callerSuppliedID { + input.ID = uuid.New() + } + rootID := session.ID + if session.RootSessionID != nil { + rootID = *session.RootSessionID + } + if input.RootSessionID != nil { + if *input.RootSessionID != rootID { + return nil, fmt.Errorf("%w: root session %s does not match session aggregate root %s", ErrInvalidPromptRun, *input.RootSessionID, rootID) + } + } + if input.ParentRunID != nil { + if *input.ParentRunID == uuid.Nil || *input.ParentRunID == input.ID { + return nil, fmt.Errorf("%w: parent run cannot be empty or self", ErrInvalidPromptRun) + } + parent, err := db.GetPromptRun(ctx, *input.ParentRunID) + if err != nil { + return nil, err + } + if parent.RootSessionID != rootID { + return nil, fmt.Errorf("%w: parent run %s belongs to root %s, not %s", ErrPromptRunConflict, parent.ID, parent.RootSessionID, rootID) + } + } + now := time.Now().UTC() + record := promptRunRecord{ + ID: input.ID, SessionID: input.SessionID, RootSessionID: rootID, BatchID: input.BatchID, + ParentRunID: input.ParentRunID, InputPlanID: input.InputPlanID, InputPlanRevisionID: input.InputPlanRevisionID, + Origin: nullableTrimmed(input.Origin), SpecProfile: nullableTrimmed(input.SpecProfile), + AdmissionKey: nullableTrimmed(input.AdmissionKey), RenderedSpec: input.RenderedSpec, + PromptMarkdown: nullableTrimmed(input.PromptMarkdown), VerificationMarkdown: nullableTrimmed(input.VerificationMarkdown), + Phase: PromptRunPhaseQueued, State: PromptRunStatePending, QueuedAt: now, + } + if record.RenderedSpec == nil { + record.RenderedSpec = map[string]any{} + } + result := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&record) + if result.Error != nil { + return nil, fmt.Errorf("create Captain prompt run: %w", result.Error) + } + if result.RowsAffected == 1 { + return db.GetPromptRun(ctx, record.ID) + } + var existing promptRunRecord + query := db.gorm.WithContext(ctx) + if record.AdmissionKey != nil { + query = query.Where("admission_key = ?", *record.AdmissionKey) + } else { + query = query.Where("id = ?", record.ID) + } + if err := query.First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: another active run exists for root session %s", ErrPromptRunConflict, rootID) + } + return nil, fmt.Errorf("read existing Captain prompt run: %w", err) + } + if existing.SessionID != input.SessionID { + return nil, fmt.Errorf("%w: existing run belongs to session %s", ErrPromptRunConflict, existing.SessionID) + } + if callerSuppliedID && existing.ID != input.ID { + return nil, fmt.Errorf("%w: admission identity already belongs to run %s, not caller-supplied ID %s", ErrPromptRunConflict, existing.ID, input.ID) + } + return db.GetPromptRun(ctx, existing.ID) +} + +func (db *DB) GetPromptRun(ctx context.Context, id uuid.UUID) (*PromptRun, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + var record promptRunRecord + if err := db.gorm.WithContext(ctx).First(&record, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrPromptRunNotFound, id) + } + return nil, fmt.Errorf("get Captain prompt run: %w", err) + } + out := promptRunFromRecord(record) + return &out, nil +} + +// ListPromptRuns returns prompt runs newest-first. The UUID tie-breaker keeps +// ordering stable when several runs share the same queued timestamp. +func (db *DB) ListPromptRuns(ctx context.Context, filter PromptRunFilter) ([]PromptRun, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + query := db.gorm.WithContext(ctx).Order("queued_at DESC, id DESC") + if filter.SessionID != nil { + if *filter.SessionID == uuid.Nil { + return nil, fmt.Errorf("%w: session ID filter cannot be empty", ErrInvalidPromptRun) + } + query = query.Where("session_id = ?", *filter.SessionID) + } + if filter.State != nil { + if !validPromptRunState(*filter.State) { + return nil, fmt.Errorf("%w: unknown state %q", ErrInvalidPromptRun, *filter.State) + } + query = query.Where("state = ?", *filter.State) + } + var records []promptRunRecord + if err := query.Find(&records).Error; err != nil { + return nil, fmt.Errorf("list Captain prompt runs: %w", err) + } + runs := make([]PromptRun, len(records)) + for i := range records { + runs[i] = promptRunFromRecord(records[i]) + } + return runs, nil +} + +// UpdatePromptRun applies phase/state/result changes only when ExpectedVersion +// matches. Captain's trigger advances Version exactly when durable state changes. +func (db *DB) UpdatePromptRun(ctx context.Context, input UpdatePromptRunInput) (*PromptRun, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if input.ID == uuid.Nil || input.ExpectedVersion < 0 { + return nil, fmt.Errorf("%w: ID and a nonnegative expected version are required", ErrInvalidPromptRun) + } + updates := map[string]any{} + var distinctPredicates []string + var distinctArgs []any + if input.Phase != nil { + if !validPromptRunPhase(*input.Phase) { + return nil, fmt.Errorf("%w: unknown phase %q", ErrInvalidPromptRun, *input.Phase) + } + updates["phase"] = *input.Phase + distinctPredicates = append(distinctPredicates, "phase IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.Phase) + } + if input.State != nil { + if !validPromptRunState(*input.State) { + return nil, fmt.Errorf("%w: unknown state %q", ErrInvalidPromptRun, *input.State) + } + updates["state"] = *input.State + distinctPredicates = append(distinctPredicates, "state IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.State) + } + if input.CurrentIteration != nil { + if *input.CurrentIteration < 0 { + return nil, fmt.Errorf("%w: current iteration cannot be negative", ErrInvalidPromptRun) + } + updates["current_iteration"] = *input.CurrentIteration + distinctPredicates = append(distinctPredicates, "current_iteration IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.CurrentIteration) + } + if input.ResultText != nil { + updates["result_text"] = *input.ResultText + distinctPredicates = append(distinctPredicates, "result_text IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.ResultText) + } + if input.ResultJSON != nil { + if *input.ResultJSON == nil { + updates["result_json"] = nil + distinctPredicates = append(distinctPredicates, "result_json IS NOT NULL") + } else { + encoded, err := json.Marshal(*input.ResultJSON) + if err != nil { + return nil, fmt.Errorf("%w: encode result JSON: %v", ErrInvalidPromptRun, err) + } + updates["result_json"] = *input.ResultJSON + distinctPredicates = append(distinctPredicates, "result_json IS DISTINCT FROM CAST(? AS jsonb)") + distinctArgs = append(distinctArgs, string(encoded)) + } + } + if input.Error != nil { + updates["error"] = *input.Error + distinctPredicates = append(distinctPredicates, "error IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.Error) + } + if len(updates) == 0 { + return nil, fmt.Errorf("%w: no update fields supplied", ErrInvalidPromptRun) + } + result := db.gorm.WithContext(ctx).Model(&promptRunRecord{}). + Where("id = ? AND version = ?", input.ID, input.ExpectedVersion). + Where("("+strings.Join(distinctPredicates, " OR ")+")", distinctArgs...).Updates(updates) + if result.Error != nil { + return nil, fmt.Errorf("update Captain prompt run: %w", result.Error) + } + if result.RowsAffected == 0 { + var current promptRunRecord + if err := db.gorm.WithContext(ctx).First(¤t, "id = ?", input.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrPromptRunNotFound, input.ID) + } + return nil, fmt.Errorf("check Captain prompt run: %w", err) + } + if current.Version != input.ExpectedVersion { + return nil, fmt.Errorf("%w: prompt run %s is no longer at version %d", ErrPromptRunConflict, input.ID, input.ExpectedVersion) + } + out := promptRunFromRecord(current) + return &out, nil + } + return db.GetPromptRun(ctx, input.ID) +} + +func sessionFromRecord(record sessionRecord) Session { + return Session{ + ID: record.ID, ProviderSessionID: optionalString(record.ProviderSessionID), Source: record.Source, + Provider: record.Provider, HostID: record.HostID, ParentSessionID: record.ParentSessionID, + RootSessionID: record.RootSessionID, Path: optionalString(record.Path), Project: optionalString(record.Project), + CWD: optionalString(record.CWD), Title: optionalString(record.Title), InitialPrompt: optionalString(record.InitialPrompt), + Slug: optionalString(record.Slug), AgentType: optionalString(record.AgentType), Description: optionalString(record.Description), + CLIVersion: optionalString(record.CLIVersion), LifecycleStatus: record.LifecycleStatus, + ActivityState: record.ActivityState, HealthState: record.HealthState, StateReason: optionalString(record.StateReason), + StateVersion: record.StateVersion, StateObservedAt: record.StateObservedAt, StartedAt: record.StartedAt, + EndedAt: record.EndedAt, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, + } +} + +func validSessionLifecycle(value SessionLifecycleStatus) bool { + switch value { + case SessionLifecycleCreated, SessionLifecycleRunning, SessionLifecycleSucceeded, + SessionLifecycleFailed, SessionLifecycleCancelled, SessionLifecycleInterrupted: + return true + default: + return false + } +} + +func validSessionActivity(value SessionActivityState) bool { + switch value { + case SessionActivityIdle, SessionActivityThinking, SessionActivityWorking, + SessionActivityAsk, SessionActivityApproval: + return true + default: + return false + } +} + +func validSessionHealth(value SessionHealthState) bool { + switch value { + case SessionHealthHealthy, SessionHealthStalled, SessionHealthZombie: + return true + default: + return false + } +} + +func promptRunFromRecord(record promptRunRecord) PromptRun { + return PromptRun{ + ID: record.ID, SessionID: record.SessionID, RootSessionID: record.RootSessionID, BatchID: record.BatchID, + ParentRunID: record.ParentRunID, InputPlanID: record.InputPlanID, InputPlanRevisionID: record.InputPlanRevisionID, + Origin: optionalString(record.Origin), SpecProfile: optionalString(record.SpecProfile), AdmissionKey: optionalString(record.AdmissionKey), + RenderedSpec: record.RenderedSpec, PromptMarkdown: optionalString(record.PromptMarkdown), + VerificationMarkdown: optionalString(record.VerificationMarkdown), Phase: record.Phase, State: record.State, + CurrentIteration: record.CurrentIteration, ResultText: optionalString(record.ResultText), ResultJSON: record.ResultJSON, + Error: optionalString(record.Error), Version: record.Version, QueuedAt: record.QueuedAt, StartedAt: record.StartedAt, + FinishedAt: record.FinishedAt, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, + } +} + +func validPromptRunPhase(value PromptRunPhase) bool { + switch value { + case PromptRunPhaseQueued, PromptRunPhasePreRun, PromptRunPhaseGenerate, PromptRunPhaseVerify, + PromptRunPhaseFeedback, PromptRunPhasePostRun, PromptRunPhaseOutput, PromptRunPhaseFinished: + return true + default: + return false + } +} + +func validPromptRunState(value PromptRunState) bool { + switch value { + case PromptRunStatePending, PromptRunStateRunning, PromptRunStateWaiting, + PromptRunStateSucceeded, PromptRunStateFailed, PromptRunStateCancelled: + return true + default: + return false + } +} + +func equalOptionalUUID(left, right *uuid.UUID) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +type sqlStateError interface { + SQLState() string +} + +func isUniqueViolation(err error) bool { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + var sqlState sqlStateError + return errors.As(err, &sqlState) && sqlState.SQLState() == "23505" +} diff --git a/pkg/database/store_integration_test.go b/pkg/database/store_integration_test.go new file mode 100644 index 0000000..f7c1b01 --- /dev/null +++ b/pkg/database/store_integration_test.go @@ -0,0 +1,447 @@ +package database + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "testing" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDurableSessionPromptRunAndPlanStores(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres store tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_durable_stores", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + db, err := Open(t.Context(), Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + session, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: "provider-session-1", + Source: "codex", + Provider: "openai", + HostID: "test-host", + CWD: "/tmp/project", + Title: "Durable plan", + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, session.ID) + require.False(t, session.StateObservedAt.IsZero()) + assert.WithinDuration(t, time.Now().UTC(), session.StateObservedAt, 5*time.Second) + assert.Equal(t, SessionLifecycleCreated, session.LifecycleStatus) + assert.Equal(t, SessionActivityIdle, session.ActivityState) + assert.Equal(t, SessionHealthHealthy, session.HealthState) + + replayedSession, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: "provider-session-1", + Source: "codex", + Provider: "openai", + HostID: "test-host", + Title: "a retry must not overwrite metadata", + }) + require.NoError(t, err) + assert.Equal(t, session.ID, replayedSession.ID) + assert.Equal(t, "Durable plan", replayedSession.Title) + _, err = db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ID: uuid.New(), ProviderSessionID: "provider-session-1", + Source: "codex", Provider: "openai", HostID: "test-host", + }) + assert.ErrorIs(t, err, ErrSessionConflict) + + byProviderID, err := db.GetSessionByIdentity(t.Context(), "provider-session-1", "codex", "openai", "test-host") + require.NoError(t, err) + assert.Equal(t, session.ID, byProviderID.ID) + byUUID, err := db.GetSessionByIdentity(t.Context(), session.ID.String(), "", "", "") + require.NoError(t, err) + assert.Equal(t, session.ID, byUUID.ID) + for name, filters := range map[string][3]string{ + "source": {"claude", "", ""}, + "provider": {"", "anthropic", ""}, + "host": {"", "", "another-host"}, + } { + t.Run("UUID identity enforces "+name+" filter", func(t *testing.T) { + _, err := db.GetSessionByIdentity(t.Context(), session.ID.String(), filters[0], filters[1], filters[2]) + assert.ErrorIs(t, err, ErrSessionNotFound) + }) + } + + running := SessionLifecycleRunning + working := SessionActivityWorking + updatedSession, err := db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: session.ID, ExpectedVersion: session.StateVersion, + LifecycleStatus: &running, ActivityState: &working, + }) + require.NoError(t, err) + assert.EqualValues(t, 1, updatedSession.StateVersion) + assert.NotNil(t, updatedSession.StartedAt) + updatedProviderID := "provider-session-1" + sessionUpdatedAtBeforeReplay := updatedSession.UpdatedAt + sessionObservedAtBeforeReplay := updatedSession.StateObservedAt + var sessionActivityBeforeReplay sql.NullTime + require.NoError(t, db.Gorm().Raw(`SELECT last_activity_at FROM captain_sessions WHERE id = ?`, session.ID). + Scan(&sessionActivityBeforeReplay).Error) + var sessionOutboxBeforeReplay int64 + require.NoError(t, db.Gorm().Table("captain_outbox").Count(&sessionOutboxBeforeReplay).Error) + updatedSession, err = db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: updatedSession.ID, ExpectedVersion: updatedSession.StateVersion, + ProviderSessionID: &updatedProviderID, LifecycleStatus: &running, ActivityState: &working, + }) + require.NoError(t, err) + assert.Equal(t, updatedProviderID, updatedSession.ProviderSessionID) + assert.EqualValues(t, 1, updatedSession.StateVersion, "idempotent identity updates must not masquerade as state changes") + assert.Equal(t, sessionUpdatedAtBeforeReplay, updatedSession.UpdatedAt) + assert.Equal(t, sessionObservedAtBeforeReplay, updatedSession.StateObservedAt) + var sessionActivityAfterReplay sql.NullTime + require.NoError(t, db.Gorm().Raw(`SELECT last_activity_at FROM captain_sessions WHERE id = ?`, session.ID). + Scan(&sessionActivityAfterReplay).Error) + assert.Equal(t, sessionActivityBeforeReplay, sessionActivityAfterReplay) + var sessionOutboxAfterReplay int64 + require.NoError(t, db.Gorm().Table("captain_outbox").Count(&sessionOutboxAfterReplay).Error) + assert.Equal(t, sessionOutboxBeforeReplay, sessionOutboxAfterReplay, "exact session replay must not emit an outbox event") + replacementProviderID := "provider-session-1-replacement" + _, err = db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: updatedSession.ID, ExpectedVersion: updatedSession.StateVersion, + ProviderSessionID: &replacementProviderID, + }) + assert.ErrorIs(t, err, ErrSessionConflict) + emptyProviderID := " " + _, err = db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: updatedSession.ID, ExpectedVersion: updatedSession.StateVersion, + ProviderSessionID: &emptyProviderID, + }) + assert.ErrorIs(t, err, ErrInvalidSession) + _, err = db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: session.ID, ExpectedVersion: session.StateVersion, LifecycleStatus: &running, + }) + assert.ErrorIs(t, err, ErrSessionConflict) + + t.Run("provider identity binding is set-once under concurrency", func(t *testing.T) { + unbound, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "codex", Provider: "openai", HostID: "binding-race", + }) + require.NoError(t, err) + candidates := []string{"provider-race-a", "provider-race-b"} + type bindingResult struct { + value string + err error + } + start := make(chan struct{}) + results := make(chan bindingResult, len(candidates)) + var wg sync.WaitGroup + for _, candidate := range candidates { + wg.Add(1) + go func(candidate string) { + defer wg.Done() + <-start + _, err := db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: unbound.ID, ExpectedVersion: unbound.StateVersion, + ProviderSessionID: &candidate, + }) + results <- bindingResult{value: candidate, err: err} + }(candidate) + } + close(start) + wg.Wait() + close(results) + var winner string + var conflicts int + for result := range results { + if result.err == nil { + winner = result.value + continue + } + assert.ErrorIs(t, result.err, ErrSessionConflict) + conflicts++ + } + assert.NotEmpty(t, winner) + assert.Equal(t, 1, conflicts) + bound, err := db.GetSession(t.Context(), unbound.ID) + require.NoError(t, err) + assert.Equal(t, winner, bound.ProviderSessionID) + assert.EqualValues(t, unbound.StateVersion, bound.StateVersion) + _, err = db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: bound.ID, ExpectedVersion: bound.StateVersion, ProviderSessionID: &winner, + }) + require.NoError(t, err, "an exact binding replay must be idempotent") + }) + + collisionOwner, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: "provider-binding-collision", Source: "codex", Provider: "openai", HostID: "collision-host", + }) + require.NoError(t, err) + require.NotNil(t, collisionOwner) + collisionTarget, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "codex", Provider: "openai", HostID: "collision-host", + }) + require.NoError(t, err) + collisionID := "provider-binding-collision" + _, err = db.UpdateSessionState(t.Context(), UpdateSessionStateInput{ + ID: collisionTarget.ID, ExpectedVersion: collisionTarget.StateVersion, ProviderSessionID: &collisionID, + }) + assert.ErrorIs(t, err, ErrSessionConflict) + + otherRoot, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{Source: "host", Provider: "test"}) + require.NoError(t, err) + child, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "codex", Provider: "openai", ParentSessionID: &session.ID, + }) + require.NoError(t, err) + require.NotNil(t, child.RootSessionID) + assert.Equal(t, session.ID, *child.RootSessionID) + grandchild, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "codex", Provider: "openai", ParentSessionID: &child.ID, + }) + require.NoError(t, err) + require.NotNil(t, grandchild.RootSessionID) + assert.Equal(t, session.ID, *grandchild.RootSessionID) + _, err = db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "codex", Provider: "openai", RootSessionID: &child.ID, + }) + assert.ErrorIs(t, err, ErrSessionConflict, "an explicit aggregate root must identify a canonical root") + explicitRootMember, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "codex", Provider: "openai", RootSessionID: &session.ID, + }) + require.NoError(t, err) + require.NotNil(t, explicitRootMember.RootSessionID) + assert.Equal(t, session.ID, *explicitRootMember.RootSessionID) + _, err = db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "codex", Provider: "openai", ParentSessionID: &child.ID, RootSessionID: &otherRoot.ID, + }) + assert.ErrorIs(t, err, ErrSessionConflict) + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: grandchild.ID, RootSessionID: &otherRoot.ID, + }) + assert.ErrorIs(t, err, ErrInvalidPromptRun) + otherParentRun, err := db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: otherRoot.ID, AdmissionKey: "unrelated-parent-run", + }) + require.NoError(t, err) + finished := PromptRunPhaseFinished + succeeded := PromptRunStateSucceeded + _, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: otherParentRun.ID, ExpectedVersion: otherParentRun.Version, Phase: &finished, State: &succeeded, + }) + require.NoError(t, err) + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: grandchild.ID, ParentRunID: &otherParentRun.ID, + }) + assert.ErrorIs(t, err, ErrPromptRunConflict) + emptyParentRunID := uuid.Nil + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: grandchild.ID, ParentRunID: &emptyParentRunID, + }) + assert.ErrorIs(t, err, ErrInvalidPromptRun) + selfRunID := uuid.New() + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + ID: selfRunID, SessionID: grandchild.ID, ParentRunID: &selfRunID, + }) + assert.ErrorIs(t, err, ErrInvalidPromptRun) + childRun, err := db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: grandchild.ID, AdmissionKey: "nested-child-run", + }) + require.NoError(t, err) + assert.Equal(t, session.ID, childRun.RootSessionID, "child runs must use their aggregate root") + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: explicitRootMember.ID, AdmissionKey: "same-root-active-run", + }) + assert.ErrorIs(t, err, ErrPromptRunConflict, "all members of an aggregate share the active-run uniqueness boundary") + _, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: childRun.ID, ExpectedVersion: childRun.Version, Phase: &finished, State: &succeeded, + }) + require.NoError(t, err) + + run, err := db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: session.ID, ParentRunID: &childRun.ID, AdmissionKey: "issue-1:planning", Origin: "test", + RenderedSpec: map[string]any{"goal": "persist plans"}, + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, run.ID) + require.False(t, run.QueuedAt.IsZero()) + assert.WithinDuration(t, time.Now().UTC(), run.QueuedAt, 5*time.Second) + assert.Equal(t, session.ID, run.RootSessionID) + require.NotNil(t, run.ParentRunID) + assert.Equal(t, childRun.ID, *run.ParentRunID) + + replayedRun, err := db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: session.ID, AdmissionKey: "issue-1:planning", + }) + require.NoError(t, err) + assert.Equal(t, run.ID, replayedRun.ID) + _, err = db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + ID: uuid.New(), SessionID: session.ID, AdmissionKey: "issue-1:planning", + }) + assert.ErrorIs(t, err, ErrPromptRunConflict) + + generate := PromptRunPhaseGenerate + runState := PromptRunStateRunning + resultJSON := map[string]any{"attempt": float64(1)} + run, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: run.ID, ExpectedVersion: run.Version, Phase: &generate, State: &runState, ResultJSON: &resultJSON, + }) + require.NoError(t, err) + assert.EqualValues(t, 1, run.Version) + assert.NotNil(t, run.StartedAt) + assert.Equal(t, float64(1), run.ResultJSON["attempt"]) + runUpdatedAtBeforeReplay := run.UpdatedAt + var runSessionActivityBeforeReplay sql.NullTime + require.NoError(t, db.Gorm().Raw(`SELECT last_activity_at FROM captain_sessions WHERE id = ?`, session.ID). + Scan(&runSessionActivityBeforeReplay).Error) + var runOutboxBeforeReplay int64 + require.NoError(t, db.Gorm().Table("captain_outbox").Count(&runOutboxBeforeReplay).Error) + replayedRunState, err := db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: run.ID, ExpectedVersion: run.Version, Phase: &generate, State: &runState, ResultJSON: &resultJSON, + }) + require.NoError(t, err) + assert.Equal(t, runUpdatedAtBeforeReplay, replayedRunState.UpdatedAt) + assert.Equal(t, run.Version, replayedRunState.Version) + var runSessionActivityAfterReplay sql.NullTime + require.NoError(t, db.Gorm().Raw(`SELECT last_activity_at FROM captain_sessions WHERE id = ?`, session.ID). + Scan(&runSessionActivityAfterReplay).Error) + assert.Equal(t, runSessionActivityBeforeReplay, runSessionActivityAfterReplay) + var runOutboxAfterReplay int64 + require.NoError(t, db.Gorm().Table("captain_outbox").Count(&runOutboxAfterReplay).Error) + assert.Equal(t, runOutboxBeforeReplay, runOutboxAfterReplay, "exact prompt-run replay must not emit an outbox event") + _, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: run.ID, ExpectedVersion: 0, State: &runState, + }) + assert.ErrorIs(t, err, ErrPromptRunConflict) + + validTurnID := uuid.New() + require.NoError(t, db.Gorm().Exec(`INSERT INTO captain_turns (id, session_id, turn_index) VALUES (?, ?, 0)`, + validTurnID, session.ID).Error) + crossSessionTurnID := uuid.New() + require.NoError(t, db.Gorm().Exec(`INSERT INTO captain_turns (id, session_id, turn_index) VALUES (?, ?, 0)`, + crossSessionTurnID, otherRoot.ID).Error) + _, err = db.CreateOrGetPlan(t.Context(), CreatePlanInput{ + SourceSessionID: session.ID, SourcePromptRunID: &run.ID, + SourceTurnID: &crossSessionTurnID, Variant: "cross-session-turn", + }) + assert.ErrorIs(t, err, ErrPlanConflict) + plan, err := db.CreateOrGetPlan(t.Context(), CreatePlanInput{ + SourceSessionID: session.ID, SourcePromptRunID: &run.ID, + SourceTurnID: &validTurnID, Variant: "safe", Title: "Safe migration", Path: "/tmp/deleted-plan.md", + }) + require.NoError(t, err) + require.NotNil(t, plan.SourceTurnID) + assert.Equal(t, validTurnID, *plan.SourceTurnID) + replayedPlan, err := db.CreateOrGetPlan(t.Context(), CreatePlanInput{ + SourceSessionID: session.ID, SourcePromptRunID: &run.ID, Variant: "safe", + Title: "retry must not overwrite metadata", + }) + require.NoError(t, err) + assert.Equal(t, plan.ID, replayedPlan.ID) + assert.Equal(t, "Safe migration", replayedPlan.Title) + _, err = db.CreateOrGetPlan(t.Context(), CreatePlanInput{ + ID: uuid.New(), SourceSessionID: session.ID, SourcePromptRunID: &run.ID, Variant: "safe", + }) + assert.ErrorIs(t, err, ErrPlanConflict) + + first, err := db.AppendPlanRevision(t.Context(), AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: "\r\n# Plan\r\n\r\n- preserve data\r\n", CreatedBy: "test", + }) + require.NoError(t, err) + assert.Equal(t, 1, first.Revision) + replayedRevision, err := db.AppendPlanRevision(t.Context(), AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: "# Plan\n\n- preserve data\n", + }) + require.NoError(t, err) + assert.Equal(t, first.ID, replayedRevision.ID) + + const concurrentRevisions = 4 + var wg sync.WaitGroup + results := make(chan *PlanRevision, concurrentRevisions) + errorsCh := make(chan error, concurrentRevisions) + for i := range concurrentRevisions { + wg.Add(1) + go func(i int) { + defer wg.Done() + revision, err := db.AppendPlanRevision(t.Context(), AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: fmt.Sprintf("# Plan\n\nvariant %d", i), + }) + if err != nil { + errorsCh <- err + return + } + results <- revision + }(i) + } + wg.Wait() + close(results) + close(errorsCh) + for err := range errorsCh { + require.NoError(t, err) + } + var revisionNumbers []int + var latest *PlanRevision + for revision := range results { + revisionNumbers = append(revisionNumbers, revision.Revision) + if latest == nil || revision.Revision > latest.Revision { + latest = revision + } + } + sort.Ints(revisionNumbers) + assert.Equal(t, []int{2, 3, 4, 5}, revisionNumbers) + require.NotNil(t, latest) + + otherPlan, err := db.CreateOrGetPlan(t.Context(), CreatePlanInput{ + SourceSessionID: session.ID, SourcePromptRunID: &run.ID, Variant: "fast", + }) + require.NoError(t, err) + otherRevision, err := db.AppendPlanRevision(t.Context(), AppendPlanRevisionInput{ + PlanID: otherPlan.ID, PlanMarkdown: "# Other plan", + }) + require.NoError(t, err) + _, err = db.ApprovePlanRevision(t.Context(), ApprovePlanRevisionInput{ + PlanID: plan.ID, RevisionID: otherRevision.ID, + }) + assert.ErrorIs(t, err, ErrPlanConflict) + + approved, err := db.ApprovePlanRevision(t.Context(), ApprovePlanRevisionInput{ + PlanID: plan.ID, RevisionID: latest.ID, ApprovedBy: "reviewer", Comment: "ship it", + }) + require.NoError(t, err) + require.NotNil(t, approved.ApprovedRevision) + assert.Equal(t, latest.ID, approved.ApprovedRevision.ID) + assert.Equal(t, latest.PlanMarkdown, approved.ApprovedRevision.PlanMarkdown) + assert.Equal(t, PlanApprovalApproved, approved.ApprovalState) + + approvedRevision, err := db.GetApprovedPlanRevision(t.Context(), plan.ID) + require.NoError(t, err) + assert.Equal(t, latest.ID, approvedRevision.ID) + listedPlans, err := db.ListPlans(t.Context(), PlanFilter{SourcePromptRunID: &run.ID}) + require.NoError(t, err) + assert.Len(t, listedPlans, 2) + + rollbackErr := errors.New("rollback host link") + err = db.Transaction(t.Context(), func(tx *DB) error { + _, err := tx.CreateOrGetPlan(t.Context(), CreatePlanInput{ + SourceSessionID: session.ID, SourcePromptRunID: &run.ID, Variant: "rolled-back", + }) + require.NoError(t, err) + return rollbackErr + }) + assert.ErrorIs(t, err, rollbackErr) + rolledBack := "rolled-back" + plansAfterRollback, err := db.ListPlans(t.Context(), PlanFilter{ + SourcePromptRunID: &run.ID, Variant: &rolledBack, + }) + require.NoError(t, err) + assert.Empty(t, plansAfterRollback) +} From 2bbc51574bc8e979bf514620b7aa0afbe7eee644 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 20:56:25 +0300 Subject: [PATCH 030/131] feat(cli): resolve sessions and plans from native storage Gavel-Issue-Id: 742bbbcc140904d722ed1d95e46f59ab --- pkg/cli/plan.go | 86 +++++++++++- pkg/cli/plan_native_integration_test.go | 66 ++++++++++ pkg/cli/session_store.go | 124 +++++++++++++++++- .../session_store_native_integration_test.go | 86 ++++++++++++ pkg/cli/session_store_test.go | 94 ++++++++++++- 5 files changed, 439 insertions(+), 17 deletions(-) create mode 100644 pkg/cli/plan_native_integration_test.go create mode 100644 pkg/cli/session_store_native_integration_test.go diff --git a/pkg/cli/plan.go b/pkg/cli/plan.go index 5a02138..792e348 100644 --- a/pkg/cli/plan.go +++ b/pkg/cli/plan.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "os" "sort" @@ -9,10 +10,12 @@ import ( "github.com/flanksource/captain/pkg/ai/history" "github.com/flanksource/captain/pkg/claude" + captaindb "github.com/flanksource/captain/pkg/database" captainsession "github.com/flanksource/captain/pkg/session" "github.com/flanksource/clicky" "github.com/flanksource/clicky/api" "github.com/flanksource/clicky/api/icons" + "gorm.io/gorm" ) type PlanOptions struct { @@ -26,12 +29,15 @@ 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"` + SessionID string `json:"sessionId" pretty:"label=Session"` + PlanID string `json:"planId,omitempty" pretty:"label=Plan ID"` + RevisionID string `json:"revisionId,omitempty"` + Revision int `json:"revision,omitempty"` + 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 } @@ -48,6 +54,16 @@ func RunPlan(opts PlanOptions) (PlanResult, error) { id := strings.TrimSpace(opts.SessionID) if id != "" { + if native := sessionStores.nativeDatabase(); native != nil { + persisted, ok, err := resolveNativePlan(context.Background(), native, id, source) + if err != nil { + return PlanResult{}, err + } + if ok { + persisted.pathOnly = opts.PathOnly + return *persisted, nil + } + } if candidate, ok, err := findSessionCandidateByID(id, source); err != nil { return PlanResult{}, err } else if ok { @@ -101,6 +117,64 @@ func RunPlan(opts PlanOptions) (PlanResult, error) { return PlanResult{}, fmt.Errorf("no session with a plan found in %s", scopeLabel(cwd, opts.All)) } +// resolveNativePlan resolves persisted plan content without consulting the +// transcript or source plan path. Approved content wins; otherwise the latest +// immutable revision of the newest plan variant is returned. +func resolveNativePlan(ctx context.Context, gormDB *gorm.DB, identity, source string) (*PlanResult, bool, error) { + db, err := captaindb.Use(gormDB) + if err != nil { + return nil, false, err + } + sourceFilter := source + if sourceFilter == "all" { + sourceFilter = "" + } + session, err := db.GetSessionByIdentity(ctx, identity, sourceFilter, "", "") + if err != nil { + if errors.Is(err, captaindb.ErrSessionNotFound) { + return nil, false, nil + } + return nil, false, fmt.Errorf("resolve persisted Captain session %q: %w", identity, err) + } + plans, err := db.ListPlans(ctx, captaindb.PlanFilter{SourceSessionID: &session.ID}) + if err != nil { + return nil, false, fmt.Errorf("list persisted plans for session %s: %w", session.ID, err) + } + var selected *captaindb.Plan + for i := range plans { + if plans[i].ApprovedRevision != nil { + selected = &plans[i] + break + } + if selected == nil && plans[i].LatestRevision != nil { + selected = &plans[i] + } + } + if selected == nil { + return nil, false, nil + } + revision := selected.ApprovedRevision + if revision == nil { + revision = selected.LatestRevision + } + onDisk := false + if selected.Path != "" { + _, err := os.Stat(selected.Path) + onDisk = err == nil + } + return &PlanResult{ + SessionID: session.ID.String(), + PlanID: selected.ID.String(), + RevisionID: revision.ID.String(), + Revision: revision.Revision, + Source: session.Source, + Path: selected.Path, + OnDisk: onDisk, + Slug: selected.Slug, + Content: revision.PlanMarkdown, + }, true, nil +} + // 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) { diff --git a/pkg/cli/plan_native_integration_test.go b/pkg/cli/plan_native_integration_test.go new file mode 100644 index 0000000..81a9583 --- /dev/null +++ b/pkg/cli/plan_native_integration_test.go @@ -0,0 +1,66 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + captaindb "github.com/flanksource/captain/pkg/database" + commonsdb "github.com/flanksource/commons-db/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveNativePlanUsesPersistedApprovedContentWithoutSourceFile(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres plan lookup tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_native_plan_lookup", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + db, err := captaindb.Open(t.Context(), captaindb.Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + session, err := db.CreateOrGetSession(t.Context(), captaindb.CreateSessionInput{ + ProviderSessionID: "provider-plan-session", Source: "codex", Provider: "openai", + }) + require.NoError(t, err) + deletedPath := filepath.Join(t.TempDir(), "deleted-plan.md") + require.NoError(t, os.WriteFile(deletedPath, []byte("stale disk content"), 0o600)) + plan, err := db.CreateOrGetPlan(t.Context(), captaindb.CreatePlanInput{ + SourceSessionID: session.ID, Variant: "approved", Path: deletedPath, Slug: "durable", + }) + require.NoError(t, err) + first, err := db.AppendPlanRevision(t.Context(), captaindb.AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: "# Approved durable plan", + }) + require.NoError(t, err) + _, err = db.AppendPlanRevision(t.Context(), captaindb.AppendPlanRevisionInput{ + PlanID: plan.ID, PlanMarkdown: "# Newer but unapproved plan", + }) + require.NoError(t, err) + _, err = db.ApprovePlanRevision(t.Context(), captaindb.ApprovePlanRevisionInput{ + PlanID: plan.ID, RevisionID: first.ID, + }) + require.NoError(t, err) + require.NoError(t, os.Remove(deletedPath)) + + result, ok, err := resolveNativePlan(t.Context(), db.Gorm(), "provider-plan-session", "all") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, session.ID.String(), result.SessionID) + assert.Equal(t, plan.ID.String(), result.PlanID) + assert.Equal(t, first.ID.String(), result.RevisionID) + assert.Equal(t, "# Approved durable plan", result.Content) + assert.False(t, result.OnDisk) + + byUUID, ok, err := resolveNativePlan(t.Context(), db.Gorm(), session.ID.String(), "codex") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, result.Content, byUUID.Content) +} diff --git a/pkg/cli/session_store.go b/pkg/cli/session_store.go index b1b66c0..09a7d91 100644 --- a/pkg/cli/session_store.go +++ b/pkg/cli/session_store.go @@ -93,13 +93,23 @@ func (StoredPrompt) TableName() string { return "captain_session_prompts" } // sessionDB wraps the gorm handle; a nil *sessionDB means "no store — uncached". type sessionDB struct{ gdb *gorm.DB } -var ( - storeOnce sync.Once - store *sessionDB -) +// sessionStoreState serializes the choice between the legacy summary cache and +// Captain's authoritative native database. The two schemas both use the +// captain_sessions name but have intentionally different models, so they must +// never be active on the same connection. +type sessionStoreState struct { + once sync.Once + mu sync.RWMutex + + legacy *sessionDB + native *gorm.DB +} + +var sessionStores sessionStoreState const ( captainSessionEnvDSN = "CAPTAIN_SESSION_DB_URL" + gavelDBEnvDSN = "GAVEL_DB_DSN" gavelCacheEnvDSN = "GAVEL_GITHUB_CACHE_DSN" gavelDBModeDSN = "dsn" @@ -110,8 +120,62 @@ const ( // nil (and logs a single Warn) when the DB is unavailable, so callers degrade to // uncached summarization. func sessionStore() *sessionDB { - storeOnce.Do(func() { store = openSessionStore() }) - return store + return sessionStores.get(openSessionStore) +} + +// ConfigureNativeDatabase records the authoritative Captain connection used by +// the host application. It must be called before the legacy session cache has +// opened. Once configured, session summary and realized-prompt callers degrade +// to their existing uncached paths instead of running the incompatible legacy +// AutoMigrate or querying native Captain tables. +// +// Native session repository wiring is deliberately separate from this guard; +// callers should use database.Open to migrate/reuse the shared pool, then call +// ConfigureNativeDatabase with that handle's Gorm connection. +func ConfigureNativeDatabase(gormDB *gorm.DB) error { + return sessionStores.configureNative(gormDB) +} + +func (s *sessionStoreState) configureNative(gormDB *gorm.DB) error { + if gormDB == nil { + return errors.New("native Captain database GORM pool is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.legacy != nil { + return errors.New("legacy Captain session cache is already initialized") + } + if s.native != nil { + if s.native == gormDB { + return nil + } + return errors.New("native Captain database is already configured with a different pool") + } + s.native = gormDB + return nil +} + +func (s *sessionStoreState) nativeDatabase() *gorm.DB { + s.mu.RLock() + defer s.mu.RUnlock() + return s.native +} + +func (s *sessionStoreState) get(opener func() *sessionDB) *sessionDB { + s.once.Do(func() { + s.mu.Lock() + defer s.mu.Unlock() + if s.native == nil { + s.legacy = opener() + } + }) + + s.mu.RLock() + defer s.mu.RUnlock() + if s.native != nil { + return nil + } + return s.legacy } func openSessionStore() *sessionDB { @@ -139,11 +203,37 @@ func openSessionStore() *sessionDB { source = "captain embedded session DB" } log.Debugf("session store using %s", source) - gdb, _, err := commonsdb.SetupDB(dsn, "session-cache") + gdb, pool, err := commonsdb.SetupDB(dsn, "session-cache") if err != nil { log.Warnf("session store unavailable: %v; continuing uncached", err) return nil } + // SetupDB uses the pgx pool to validate connectivity but GORM owns a separate + // database/sql pool. The legacy cache only retains the latter. + if pool != nil { + pool.Close() + } + return openOwnedLegacySessionStore(gdb) +} + +func openOwnedLegacySessionStore(gdb *gorm.DB) *sessionDB { + store := openLegacySessionStore(gdb) + if store != nil { + return store + } + if sqlDB, err := gdb.DB(); err != nil { + log.Warnf("access unused legacy session database: %v", err) + } else if err := sqlDB.Close(); err != nil { + log.Warnf("close unused legacy session database: %v", err) + } + return nil +} + +func openLegacySessionStore(gdb *gorm.DB) *sessionDB { + if hasAuthoritativeSessionSchema(gdb) { + log.Warnf("authoritative Captain database detected; legacy session cache disabled") + return nil + } if err := gdb.AutoMigrate(&StoredSession{}, &StoredPrompt{}); err != nil { log.Warnf("session store migrate failed: %v; continuing uncached", err) return nil @@ -151,7 +241,24 @@ func openSessionStore() *sessionDB { return &sessionDB{gdb: gdb} } +// hasAuthoritativeSessionSchema uses lifecycle columns that never existed in +// the legacy summary cache as an unambiguous guard. Metadata inspection is safe; +// no native Captain rows are queried. +func hasAuthoritativeSessionSchema(gdb *gorm.DB) bool { + if gdb == nil { + return false + } + migrator := gdb.Migrator() + return migrator.HasColumn("captain_sessions", "lifecycle_status") && + migrator.HasColumn("captain_sessions", "activity_state") && + migrator.HasColumn("captain_sessions", "health_state") +} + func configuredSessionDSN() (dsn string, source string, disabled bool, err error) { + if dsn := strings.TrimSpace(os.Getenv(gavelDBEnvDSN)); dsn != "" { + return dsn, gavelDBEnvDSN, false, nil + } + if dsn := strings.TrimSpace(os.Getenv(gavelCacheEnvDSN)); dsn != "" { return dsn, gavelCacheEnvDSN, false, nil } @@ -182,6 +289,9 @@ type gavelDBConfig struct { } func gavelSessionDSN() (string, string, error) { + if dsn := strings.TrimSpace(os.Getenv(gavelDBEnvDSN)); dsn != "" { + return dsn, gavelDBEnvDSN, nil + } if dsn := strings.TrimSpace(os.Getenv(gavelCacheEnvDSN)); dsn != "" { return dsn, gavelCacheEnvDSN, nil } diff --git a/pkg/cli/session_store_native_integration_test.go b/pkg/cli/session_store_native_integration_test.go new file mode 100644 index 0000000..5ed298d --- /dev/null +++ b/pkg/cli/session_store_native_integration_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/migrations" + commonsdb "github.com/flanksource/commons-db/db" +) + +func TestLegacySessionStoreDoesNotMutateAuthoritativeSchema(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres migration tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_legacy_guard", + }) + if err != nil { + t.Fatalf("start embedded postgres: %v", err) + } + t.Cleanup(func() { + if err := stop(); err != nil { + t.Errorf("stop embedded postgres: %v", err) + } + }) + + if err := migrations.Apply(t.Context(), dsn); err != nil { + t.Fatalf("apply Captain migrations: %v", err) + } + gormDB, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) + if err != nil { + t.Fatalf("open GORM: %v", err) + } + sqlDB, err := gormDB.DB() + if err != nil { + t.Fatalf("access SQL pool: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close SQL pool: %v", err) + } + }) + + if !hasAuthoritativeSessionSchema(gormDB) { + t.Fatal("authoritative schema signature was not detected") + } + if store := openLegacySessionStore(gormDB); store != nil { + t.Fatalf("legacy session store = %+v, want nil", store) + } + if err := sqlDB.PingContext(t.Context()); err != nil { + t.Fatalf("non-owning legacy guard closed its caller's pool: %v", err) + } + if gormDB.Migrator().HasTable((StoredPrompt{}).TableName()) { + t.Fatalf("legacy table %s was created in the authoritative database", (StoredPrompt{}).TableName()) + } + + var idType string + if err := gormDB.Raw(`SELECT data_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'captain_sessions' + AND column_name = 'id'`).Scan(&idType).Error; err != nil { + t.Fatalf("read captain_sessions.id type: %v", err) + } + if idType != "uuid" { + t.Fatalf("captain_sessions.id type = %q, want uuid", idType) + } + + owned, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) + if err != nil { + t.Fatalf("open owned GORM pool: %v", err) + } + ownedSQL, err := owned.DB() + if err != nil { + t.Fatalf("access owned SQL pool: %v", err) + } + if store := openOwnedLegacySessionStore(owned); store != nil { + t.Fatalf("owned legacy session store = %+v, want nil", store) + } + if err := ownedSQL.PingContext(t.Context()); err == nil { + t.Fatal("unused owned legacy session pool remains open") + } +} diff --git a/pkg/cli/session_store_test.go b/pkg/cli/session_store_test.go index f53f33b..db1c7b1 100644 --- a/pkg/cli/session_store_test.go +++ b/pkg/cli/session_store_test.go @@ -8,8 +8,60 @@ import ( "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/session" commonsdb "github.com/flanksource/commons-db/db" + "gorm.io/gorm" ) +func TestNativeDatabaseConfigurationSkipsLegacySessionStore(t *testing.T) { + shared := &gorm.DB{} + var state sessionStoreState + opened := false + + if err := state.configureNative(shared); err != nil { + t.Fatalf("configureNative: %v", err) + } + if got := state.get(func() *sessionDB { + opened = true + return &sessionDB{gdb: &gorm.DB{}} + }); got != nil { + t.Fatalf("session store = %+v, want nil with native database configured", got) + } + if opened { + t.Fatal("legacy session store opener ran after native database configuration") + } +} + +func TestNativeDatabaseConfigurationRejectsInitializedLegacyStore(t *testing.T) { + legacy := &sessionDB{gdb: &gorm.DB{}} + var state sessionStoreState + if got := state.get(func() *sessionDB { return legacy }); got != legacy { + t.Fatalf("session store = %+v, want legacy store", got) + } + if err := state.configureNative(&gorm.DB{}); err == nil { + t.Fatal("configureNative accepted an already initialized legacy store") + } +} + +func TestNativeDatabaseConfigurationRejectsNil(t *testing.T) { + var state sessionStoreState + if err := state.configureNative(nil); err == nil { + t.Fatal("configureNative accepted a nil database") + } +} + +func TestNativeDatabaseConfigurationRejectsReplacementPool(t *testing.T) { + var state sessionStoreState + first := &gorm.DB{} + if err := state.configureNative(first); err != nil { + t.Fatalf("configure first native database: %v", err) + } + if err := state.configureNative(first); err != nil { + t.Fatalf("reconfiguring the same native database should be idempotent: %v", err) + } + if err := state.configureNative(&gorm.DB{}); err == nil { + t.Fatal("configureNative accepted a replacement native pool") + } +} + // TestRecordFromRow_ProjectsRichFields verifies the Row→SessionRecord projection // carries git/provider/cost/tokens and derives the context-free percent. func TestRecordFromRow_ProjectsRichFields(t *testing.T) { @@ -76,19 +128,51 @@ func TestStoredBase_PersistsInlinePlan(t *testing.T) { } } -func TestGavelSessionDSNPrefersEnv(t *testing.T) { - t.Setenv(gavelCacheEnvDSN, "postgres://env-dsn") +func TestGavelSessionDSNPrefersPrimaryEnv(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "postgres://primary-dsn") + t.Setenv(gavelCacheEnvDSN, "postgres://legacy-dsn") dsn, source, err := gavelSessionDSN() if err != nil { t.Fatalf("gavelSessionDSN: %v", err) } - if dsn != "postgres://env-dsn" || source != gavelCacheEnvDSN { + if dsn != "postgres://primary-dsn" || source != gavelDBEnvDSN { + t.Fatalf("dsn/source = %q/%q", dsn, source) + } +} + +func TestGavelSessionDSNFallsBackToLegacyEnv(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "") + t.Setenv(gavelCacheEnvDSN, "postgres://legacy-dsn") + + dsn, source, err := gavelSessionDSN() + if err != nil { + t.Fatalf("gavelSessionDSN: %v", err) + } + if dsn != "postgres://legacy-dsn" || source != gavelCacheEnvDSN { + t.Fatalf("dsn/source = %q/%q", dsn, source) + } +} + +func TestConfiguredSessionDSNPrefersPrimaryGavelEnv(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "postgres://primary-dsn") + t.Setenv(gavelCacheEnvDSN, "postgres://gavel-dsn") + t.Setenv(captainSessionEnvDSN, "postgres://captain-dsn") + + dsn, source, disabled, err := configuredSessionDSN() + if err != nil { + t.Fatalf("configuredSessionDSN: %v", err) + } + if disabled { + t.Fatal("configuredSessionDSN unexpectedly disabled") + } + if dsn != "postgres://primary-dsn" || source != gavelDBEnvDSN { t.Fatalf("dsn/source = %q/%q", dsn, source) } } -func TestConfiguredSessionDSNPrefersGavelEnvOverCaptainEnv(t *testing.T) { +func TestConfiguredSessionDSNFallsBackToLegacyGavelEnv(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "") t.Setenv(gavelCacheEnvDSN, "postgres://gavel-dsn") t.Setenv(captainSessionEnvDSN, "postgres://captain-dsn") @@ -105,6 +189,7 @@ func TestConfiguredSessionDSNPrefersGavelEnvOverCaptainEnv(t *testing.T) { } func TestConfiguredSessionDSNFallsBackToCaptainEnv(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "") t.Setenv(gavelCacheEnvDSN, "") t.Setenv(captainSessionEnvDSN, "postgres://captain-dsn") @@ -123,6 +208,7 @@ func TestConfiguredSessionDSNFallsBackToCaptainEnv(t *testing.T) { func TestGavelSessionDSNFromDBConfig(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv(gavelDBEnvDSN, "") t.Setenv(gavelCacheEnvDSN, "") dir := filepath.Join(home, ".config", "gavel") if err := os.MkdirAll(dir, 0o755); err != nil { From 628893c6ec5ff1a3cb565411e771fdabeb70f93b Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 23:06:23 +0300 Subject: [PATCH 031/131] feat(database): add durable plan review transitions --- .../plan_review_state_integration_test.go | 90 +++++++++++++++++++ pkg/database/plan_store.go | 70 +++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 pkg/database/plan_review_state_integration_test.go diff --git a/pkg/database/plan_review_state_integration_test.go b/pkg/database/plan_review_state_integration_test.go new file mode 100644 index 0000000..648b770 --- /dev/null +++ b/pkg/database/plan_review_state_integration_test.go @@ -0,0 +1,90 @@ +package database + +import ( + "errors" + "os" + "path/filepath" + "testing" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetPlanReviewStatePersistsNonApprovalDecisions(t *testing.T) { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres store tests") + } + + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_plan_review_state", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + db, err := Open(t.Context(), Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + session, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{Source: "test", Provider: "test"}) + require.NoError(t, err) + plan, err := db.CreateOrGetPlan(t.Context(), CreatePlanInput{SourceSessionID: session.ID, Variant: "review"}) + require.NoError(t, err) + revision, err := db.AppendPlanRevision(t.Context(), AppendPlanRevisionInput{PlanID: plan.ID, PlanMarkdown: "# Durable plan"}) + require.NoError(t, err) + + revisionRequested, err := db.SetPlanReviewState(t.Context(), SetPlanReviewStateInput{ + PlanID: plan.ID, State: PlanApprovalRevisionRequested, Actor: "reviewer", Comment: "add rollback coverage", + }) + require.NoError(t, err) + assert.Equal(t, PlanApprovalRevisionRequested, revisionRequested.ApprovalState) + assert.Equal(t, "reviewer", revisionRequested.ApprovedBy) + assert.Equal(t, "add rollback coverage", revisionRequested.ApprovalComment) + assert.Nil(t, revisionRequested.ApprovedRevisionID) + assert.NotNil(t, revisionRequested.FeedbackAt) + + replayed, err := db.SetPlanReviewState(t.Context(), SetPlanReviewStateInput{ + PlanID: plan.ID, State: PlanApprovalRevisionRequested, Actor: "reviewer", Comment: "add rollback coverage", + }) + require.NoError(t, err) + assert.Equal(t, revisionRequested.UpdatedAt, replayed.UpdatedAt) + + approved, err := db.ApprovePlanRevision(t.Context(), ApprovePlanRevisionInput{ + PlanID: plan.ID, RevisionID: revision.ID, ApprovedBy: "reviewer", + }) + require.NoError(t, err) + require.NotNil(t, approved.ApprovedRevisionID) + + rejected, err := db.SetPlanReviewState(t.Context(), SetPlanReviewStateInput{ + PlanID: plan.ID, State: PlanApprovalRejected, Actor: "reviewer", Comment: "wrong approach", + }) + require.NoError(t, err) + assert.Equal(t, PlanApprovalRejected, rejected.ApprovalState) + assert.Nil(t, rejected.ApprovedRevisionID) + assert.Nil(t, rejected.ApprovedRevision) + require.NotNil(t, rejected.ApprovalCreatedAt) + assert.Nil(t, rejected.FeedbackAt) + + replayedRejection, err := db.SetPlanReviewState(t.Context(), SetPlanReviewStateInput{ + PlanID: plan.ID, State: PlanApprovalRejected, Actor: "reviewer", Comment: "wrong approach", + }) + require.NoError(t, err) + assert.Equal(t, rejected.UpdatedAt, replayedRejection.UpdatedAt) + assert.Equal(t, rejected.ApprovalCreatedAt, replayedRejection.ApprovalCreatedAt) + + updatedRejection, err := db.SetPlanReviewState(t.Context(), SetPlanReviewStateInput{ + PlanID: plan.ID, State: PlanApprovalRejected, Actor: "second-reviewer", Comment: "still the wrong approach", + }) + require.NoError(t, err) + require.NotNil(t, updatedRejection.ApprovalCreatedAt) + assert.True(t, updatedRejection.ApprovalCreatedAt.After(*rejected.ApprovalCreatedAt)) + assert.Equal(t, "second-reviewer", updatedRejection.ApprovedBy) + assert.Equal(t, "still the wrong approach", updatedRejection.ApprovalComment) + + _, err = db.SetPlanReviewState(t.Context(), SetPlanReviewStateInput{PlanID: plan.ID, State: PlanApprovalApproved}) + assert.ErrorIs(t, err, ErrInvalidPlan) + _, err = db.SetPlanReviewState(t.Context(), SetPlanReviewStateInput{PlanID: uuid.New(), State: PlanApprovalRejected}) + assert.True(t, errors.Is(err, ErrPlanNotFound)) +} diff --git a/pkg/database/plan_store.go b/pkg/database/plan_store.go index 71c1076..061b3f4 100644 --- a/pkg/database/plan_store.go +++ b/pkg/database/plan_store.go @@ -106,6 +106,16 @@ type ApprovePlanRevisionInput struct { Comment string } +// SetPlanReviewStateInput records a non-approval review decision. Approval is +// intentionally kept on ApprovePlanRevision because it must select an exact +// immutable revision. +type SetPlanReviewStateInput struct { + PlanID uuid.UUID + State PlanApprovalState + Actor string + Comment string +} + type planRecord struct { ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` SourceSessionID uuid.UUID `gorm:"column:source_session_id;type:uuid"` @@ -369,6 +379,66 @@ func (db *DB) ApprovePlanRevision(ctx context.Context, input ApprovePlanRevision return db.GetPlan(ctx, input.PlanID) } +// SetPlanReviewState records rejection, revision feedback, or a reset to +// pending without selecting mutable/path-backed content. Exact retries are +// mutation-free. +func (db *DB) SetPlanReviewState(ctx context.Context, input SetPlanReviewStateInput) (*Plan, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if input.PlanID == uuid.Nil { + return nil, fmt.Errorf("%w: plan ID is required", ErrInvalidPlan) + } + switch input.State { + case PlanApprovalPending, PlanApprovalRejected, PlanApprovalRevisionRequested: + case PlanApprovalApproved: + return nil, fmt.Errorf("%w: approval must select a revision through ApprovePlanRevision", ErrInvalidPlan) + default: + return nil, fmt.Errorf("%w: unknown approval state %q", ErrInvalidPlan, input.State) + } + + actor := nullableTrimmed(input.Actor) + comment := nullableTrimmed(input.Comment) + err := db.gorm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var plan planRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&plan, "id = ?", input.PlanID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPlanNotFound + } + return err + } + if plan.ApprovalState == input.State && plan.ApprovedRevisionID == nil && + equalOptionalString(plan.ApprovedBy, actor) && equalOptionalString(plan.ApprovalComment, comment) { + return nil + } + updates := map[string]any{ + "approval_state": input.State, + "approved_revision_id": nil, + "approved_by": actor, + "approval_comment": comment, + "approval_created_at": nil, + "feedback_at": nil, + } + switch input.State { + case PlanApprovalRejected: + // The plan-state trigger only fills this timestamp when the state + // changes. A new rejection decision while already rejected must not + // clear it merely because only the actor or comment changed. + updates["approval_created_at"] = gorm.Expr("clock_timestamp()") + case PlanApprovalRevisionRequested: + updates["feedback_at"] = gorm.Expr("clock_timestamp()") + } + return tx.Model(&planRecord{}).Where("id = ?", input.PlanID).Updates(updates).Error + }) + if err != nil { + if errors.Is(err, ErrPlanNotFound) { + return nil, fmt.Errorf("%w: %s", ErrPlanNotFound, input.PlanID) + } + return nil, fmt.Errorf("set Captain plan review state: %w", err) + } + return db.GetPlan(ctx, input.PlanID) +} + func (db *DB) GetPlan(ctx context.Context, id uuid.UUID) (*Plan, error) { if err := db.requireGorm(); err != nil { return nil, err From d22d831cd0e96070e1fb6cf1a75126dbab96e064 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 04:58:11 +0300 Subject: [PATCH 032/131] feat(database): migrate legacy session cache --- migrations/10_sessions.pg.hcl | 86 ++ migrations/50_views_and_triggers.sql | 10 + migrations/legacy_cutover.go | 1309 +++++++++++++++++ migrations/legacy_cutover_integration_test.go | 591 ++++++++ migrations/migrations_test.go | 3 + pkg/database/database.go | 8 + 6 files changed, 2007 insertions(+) create mode 100644 migrations/legacy_cutover.go create mode 100644 migrations/legacy_cutover_integration_test.go diff --git a/migrations/10_sessions.pg.hcl b/migrations/10_sessions.pg.hcl index fb4ea05..bb1fa6f 100644 --- a/migrations/10_sessions.pg.hcl +++ b/migrations/10_sessions.pg.hcl @@ -293,3 +293,89 @@ table "captain_session_processes" { expr = "ended_at IS NULL OR ended_at >= process_started_at" } } + +// One durable validation row is retained for the explicit legacy cache +// cutover. The source tables themselves are copied to versioned archive tables +// outside the managed HCL realm so they remain a directly usable rollback +// artifact rather than being reshaped by Atlas. +table "captain_legacy_session_cutovers" { + schema = schema.public + + column "cutover_key" { + null = false + type = text + } + column "legacy_sessions_table" { + null = false + type = text + } + column "legacy_prompts_table" { + null = true + type = text + } + column "legacy_session_rows" { + null = false + type = bigint + } + column "legacy_prompt_rows" { + null = false + type = bigint + } + column "imported_session_rows" { + null = false + type = bigint + } + column "imported_prompt_run_rows" { + null = false + type = bigint + } + column "legacy_sessions_checksum" { + null = false + type = text + } + column "legacy_prompts_checksum" { + null = true + type = text + } + column "native_sessions_checksum" { + null = false + type = text + } + column "native_prompt_runs_checksum" { + null = true + type = text + } + column "details" { + null = false + type = jsonb + default = sql("'{}'::jsonb") + } + column "started_at" { + null = false + type = timestamptz + default = sql("now()") + } + column "completed_at" { + null = false + type = timestamptz + } + column "updated_at" { + null = false + type = timestamptz + default = sql("now()") + } + + primary_key { + columns = [column.cutover_key] + } + + check "captain_legacy_session_cutovers_session_count" { + expr = "legacy_session_rows = imported_session_rows" + } + check "captain_legacy_session_cutovers_prompt_count" { + expr = "legacy_prompt_rows = imported_prompt_run_rows" + } + check "captain_legacy_session_cutovers_nonnegative" { + expr = "legacy_session_rows >= 0 AND legacy_prompt_rows >= 0" + } +} diff --git a/migrations/50_views_and_triggers.sql b/migrations/50_views_and_triggers.sql index 234c1b7..57c458c 100644 --- a/migrations/50_views_and_triggers.sql +++ b/migrations/50_views_and_triggers.sql @@ -275,6 +275,16 @@ DECLARE record_id_value uuid; activity_at timestamptz := clock_timestamp(); BEGIN + -- Explicit historical backfills validate and checksum the final rows + -- themselves. Suppress activity/outbox projection for those transaction-local + -- writes so imported timestamps remain archive-derived and deterministic. + IF current_setting('captain.suppress_session_change', true) = 'on' THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + -- Nested updates are maintenance performed by another trigger. Cascading -- deletes are represented by the originating session mutation instead of a -- separate outbox row for every child. diff --git a/migrations/legacy_cutover.go b/migrations/legacy_cutover.go new file mode 100644 index 0000000..a82ae31 --- /dev/null +++ b/migrations/legacy_cutover.go @@ -0,0 +1,1309 @@ +package migrations + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" +) + +const ( + legacyCutoverKey = "captain-session-cache-v1" + legacySessionsTable = "captain_sessions" + legacyPromptsTable = "captain_session_prompts" + archiveSessionsTable = "captain_sessions_legacy_v1" + archivePromptsTable = "captain_session_prompts_legacy_v1" + legacyCutoverStateNote = "imported from the legacy Captain session cache" +) + +var ( + legacySessionNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://github.com/flanksource/captain/legacy-session-cache/v1")) + legacyPromptNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://github.com/flanksource/captain/legacy-session-prompt/v1")) + legacySourceNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://github.com/flanksource/captain/legacy-session-source/v1")) +) + +// LegacySessionCutoverReport is the durable validation result for the explicit +// legacy path-keyed session-cache cutover. The same values are stored in +// captain_legacy_session_cutovers; the versioned archive tables remain intact +// as the rollback artifact. +type LegacySessionCutoverReport struct { + CutoverKey string `json:"cutoverKey"` + LegacySessionsTable string `json:"legacySessionsTable"` + LegacyPromptsTable *string `json:"legacyPromptsTable,omitempty"` + LegacySessionRows int64 `json:"legacySessionRows"` + LegacyPromptRows int64 `json:"legacyPromptRows"` + ImportedSessionRows int64 `json:"importedSessionRows"` + ImportedPromptRunRows int64 `json:"importedPromptRunRows"` + LegacySessionsChecksum string `json:"legacySessionsChecksum"` + LegacyPromptsChecksum *string `json:"legacyPromptsChecksum,omitempty"` + NativeSessionsChecksum string `json:"nativeSessionsChecksum"` + NativePromptRunsChecksum *string `json:"nativePromptRunsChecksum,omitempty"` + Details json.RawMessage `json:"details"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type archiveState struct { + hasArchive bool + hasPromptArchive bool +} + +type legacySessionRow struct { + Raw json.RawMessage + Data map[string]any + Path string + ID string + ParentID string + Source string + StartedAt *time.Time + EndedAt *time.Time + UpdatedAt *time.Time + NativeID uuid.UUID + Parent *legacySessionRow + Root *legacySessionRow + Depth int +} + +type legacyPromptRow struct { + Raw json.RawMessage + Data map[string]any + SessionID string + RunID string + NativeID uuid.UUID +} + +// ApplyWithLegacySessionCutover is the explicit host opt-in for replacing the +// old GORM session summary cache with Captain's authoritative HCL schema. It: +// +// 1. validates and copies the legacy tables to versioned archive tables, +// 2. verifies the copies byte-for-byte before dropping the conflicting names, +// 3. applies Captain's commons-db/migrate HCL bundle, +// 4. backfills native sessions and prompt runs, and +// 5. persists and returns a checksum/count validation report. +// +// Every phase is idempotent. A crash after archiving resumes from the archive; +// a crash after migration resumes the upserts. Apply remains conservative and +// continues to reject the legacy shape unless a host calls this API explicitly. +func ApplyWithLegacySessionCutover(ctx context.Context, connection string) (_ *LegacySessionCutoverReport, resultErr error) { + connection = strings.TrimSpace(connection) + if connection == "" { + return nil, errors.New("Captain migration connection string is empty") + } + + lock, err := acquireMigrationLock(ctx, connection) + if err != nil { + return nil, fmt.Errorf("acquire Captain migration lock: %w", err) + } + defer func() { + if err := lock.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("release Captain migration lock: %w", err)) + } + }() + + db, err := commonsdb.NewDB(connection) + if err != nil { + return nil, fmt.Errorf("open Captain legacy cutover database: %w", err) + } + state, err := archiveLegacySessionCache(ctx, db) + if closeErr := db.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close Captain legacy cutover database: %w", closeErr)) + } + if err != nil { + return nil, err + } + + if err := defaultApplyDependencies.migrate(ctx, connection); err != nil { + return nil, fmt.Errorf("migrate Captain database after legacy archive: %w", err) + } + + db, err = commonsdb.NewDB(connection) + if err != nil { + return nil, fmt.Errorf("open migrated Captain cutover database: %w", err) + } + defer func() { + if err := db.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close migrated Captain cutover database: %w", err)) + } + }() + + if !state.hasArchive { + exists, err := storedCutoverExists(ctx, db) + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("Captain legacy cutover report exists but rollback table public.%s is missing", archiveSessionsTable) + } + return nil, nil + } + + return backfillLegacySessionCache(ctx, db, state) +} + +func archiveLegacySessionCache(ctx context.Context, db *sql.DB) (archiveState, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return archiveState{}, fmt.Errorf("begin Captain legacy archive transaction: %w", err) + } + defer tx.Rollback() + + sessionsExists, err := relationExists(ctx, tx, legacySessionsTable) + if err != nil { + return archiveState{}, err + } + sessionsArchiveExists, err := relationExists(ctx, tx, archiveSessionsTable) + if err != nil { + return archiveState{}, err + } + promptsExists, err := relationExists(ctx, tx, legacyPromptsTable) + if err != nil { + return archiveState{}, err + } + promptsArchiveExists, err := relationExists(ctx, tx, archivePromptsTable) + if err != nil { + return archiveState{}, err + } + + if promptsArchiveExists && !sessionsArchiveExists { + return archiveState{}, fmt.Errorf("orphaned Captain rollback table public.%s exists without public.%s", archivePromptsTable, archiveSessionsTable) + } + if sessionsArchiveExists { + if sessionsExists { + columns, err := relationColumns(ctx, tx, legacySessionsTable) + if err != nil { + return archiveState{}, err + } + if isLegacySessionSchema(columns) { + return archiveState{}, fmt.Errorf("both live legacy table public.%s and rollback table public.%s exist; refusing to choose or overwrite either", legacySessionsTable, archiveSessionsTable) + } + if !isAuthoritativeSessionSchema(columns) { + return archiveState{}, fmt.Errorf("unknown public.%s shape exists beside the rollback archive; refusing to migrate it", legacySessionsTable) + } + } + if promptsExists { + return archiveState{}, fmt.Errorf("legacy prompt table public.%s still exists beside an already archived session cache", legacyPromptsTable) + } + return archiveState{hasArchive: true, hasPromptArchive: promptsArchiveExists}, tx.Commit() + } + + if !sessionsExists { + if promptsExists { + return archiveState{}, fmt.Errorf("legacy prompt table public.%s exists without public.%s", legacyPromptsTable, legacySessionsTable) + } + return archiveState{}, tx.Commit() + } + + columns, err := relationColumns(ctx, tx, legacySessionsTable) + if err != nil { + return archiveState{}, err + } + if !isLegacySessionSchema(columns) { + if !isAuthoritativeSessionSchema(columns) { + return archiveState{}, fmt.Errorf("unknown public.%s shape; expected the legacy path-keyed cache or Captain's authoritative UUID schema", legacySessionsTable) + } + if promptsExists { + return archiveState{}, fmt.Errorf("stray legacy table public.%s exists beside Captain's authoritative session schema", legacyPromptsTable) + } + return archiveState{}, tx.Commit() + } + // Freeze both cache tables before the first snapshot. Without this lock a + // legacy cache writer could commit between checksum validation and DROP. + if _, err := tx.ExecContext(ctx, `LOCK TABLE public.captain_sessions IN ACCESS EXCLUSIVE MODE`); err != nil { + return archiveState{}, fmt.Errorf("freeze Captain legacy session cache: %w", err) + } + if promptsExists { + if _, err := tx.ExecContext(ctx, `LOCK TABLE public.captain_session_prompts IN ACCESS EXCLUSIVE MODE`); err != nil { + return archiveState{}, fmt.Errorf("freeze Captain legacy prompt cache: %w", err) + } + } + + sessions, sessionRaw, err := loadLegacySessions(ctx, tx, legacySessionsTable) + if err != nil { + return archiveState{}, err + } + prompts, promptRaw, err := loadLegacyPromptsIfPresent(ctx, tx, promptsExists, legacyPromptsTable) + if err != nil { + return archiveState{}, err + } + if err := validateLegacyDataset(sessions, prompts); err != nil { + return archiveState{}, fmt.Errorf("validate Captain legacy session cache: %w", err) + } + + if _, err := tx.ExecContext(ctx, `CREATE TABLE public.captain_sessions_legacy_v1 + (LIKE public.captain_sessions INCLUDING ALL)`); err != nil { + return archiveState{}, fmt.Errorf("create Captain legacy session rollback table: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_sessions_legacy_v1 + SELECT * FROM public.captain_sessions`); err != nil { + return archiveState{}, fmt.Errorf("copy Captain legacy session rollback data: %w", err) + } + if promptsExists { + if _, err := tx.ExecContext(ctx, `CREATE TABLE public.captain_session_prompts_legacy_v1 + (LIKE public.captain_session_prompts INCLUDING ALL)`); err != nil { + return archiveState{}, fmt.Errorf("create Captain legacy prompt rollback table: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_session_prompts_legacy_v1 + SELECT * FROM public.captain_session_prompts`); err != nil { + return archiveState{}, fmt.Errorf("copy Captain legacy prompt rollback data: %w", err) + } + } + + _, archivedSessionRaw, err := loadLegacySessions(ctx, tx, archiveSessionsTable) + if err != nil { + return archiveState{}, err + } + if checksumJSONRows(sessionRaw) != checksumJSONRows(archivedSessionRaw) || len(sessionRaw) != len(archivedSessionRaw) { + return archiveState{}, errors.New("Captain legacy session rollback copy failed count/checksum validation") + } + if promptsExists { + _, archivedPromptRaw, err := loadLegacyPromptsIfPresent(ctx, tx, true, archivePromptsTable) + if err != nil { + return archiveState{}, err + } + if checksumJSONRows(promptRaw) != checksumJSONRows(archivedPromptRaw) || len(promptRaw) != len(archivedPromptRaw) { + return archiveState{}, errors.New("Captain legacy prompt rollback copy failed count/checksum validation") + } + } + + if promptsExists { + if _, err := tx.ExecContext(ctx, `DROP TABLE public.captain_session_prompts`); err != nil { + return archiveState{}, fmt.Errorf("remove validated legacy prompt table name: %w", err) + } + } + if _, err := tx.ExecContext(ctx, `DROP TABLE public.captain_sessions`); err != nil { + return archiveState{}, fmt.Errorf("remove validated legacy session table name: %w", err) + } + if err := tx.Commit(); err != nil { + return archiveState{}, fmt.Errorf("commit Captain legacy rollback archive: %w", err) + } + return archiveState{hasArchive: true, hasPromptArchive: promptsExists}, nil +} + +func isAuthoritativeSessionSchema(columns map[string]string) bool { + return columns["id"] == "uuid" && + columns["lifecycle_status"] == "USER-DEFINED" && + columns["activity_state"] == "USER-DEFINED" && + columns["health_state"] == "USER-DEFINED" +} + +func backfillLegacySessionCache(ctx context.Context, db *sql.DB, state archiveState) (*LegacySessionCutoverReport, error) { + startedAt := time.Now().UTC() + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin Captain legacy backfill transaction: %w", err) + } + defer tx.Rollback() + + sessions, sessionRaw, err := loadLegacySessions(ctx, tx, archiveSessionsTable) + if err != nil { + return nil, err + } + prompts, promptRaw, err := loadLegacyPromptsIfPresent(ctx, tx, state.hasPromptArchive, archivePromptsTable) + if err != nil { + return nil, err + } + if err := validateLegacyDataset(sessions, prompts); err != nil { + return nil, fmt.Errorf("revalidate Captain legacy rollback archive: %w", err) + } + if err := setLegacyBackfillEmitTriggers(ctx, tx, false); err != nil { + return nil, err + } + + sort.Slice(sessions, func(i, j int) bool { + if sessions[i].Depth == sessions[j].Depth { + return sessions[i].Path < sessions[j].Path + } + return sessions[i].Depth < sessions[j].Depth + }) + + nativeSessionLines := make([]string, 0, len(sessions)) + nativeSourceLines := make([]string, 0, len(sessions)) + for _, session := range sessions { + line, err := upsertAndValidateNativeSession(ctx, tx, session) + if err != nil { + return nil, err + } + nativeSessionLines = append(nativeSessionLines, string(line)) + sourceLine, err := upsertAndValidateNativeSessionSource(ctx, tx, session) + if err != nil { + return nil, err + } + nativeSourceLines = append(nativeSourceLines, string(sourceLine)) + } + sort.Strings(nativeSessionLines) + sort.Strings(nativeSourceLines) + + nativePromptLines := make([]string, 0, len(prompts)) + for _, prompt := range prompts { + line, err := upsertAndValidateNativePromptRun(ctx, tx, prompt, sessions) + if err != nil { + return nil, err + } + nativePromptLines = append(nativePromptLines, string(line)) + } + sort.Strings(nativePromptLines) + if err := setLegacyBackfillEmitTriggers(ctx, tx, true); err != nil { + return nil, err + } + + // Re-select after every insert and trigger has completed. The durable report + // hashes database state, not the intended inputs returned by the upsert + // helpers, so later drift cannot masquerade as a successful replay. + nativeSessionLines = nativeSessionLines[:0] + nativeSourceLines = nativeSourceLines[:0] + for _, session := range sessions { + line, err := readPersistedNativeRow(ctx, tx, "captain_sessions", session.NativeID) + if err != nil { + return nil, err + } + nativeSessionLines = append(nativeSessionLines, string(line)) + sourceID := uuid.NewSHA1(legacySourceNamespace, []byte(session.Path)) + sourceLine, err := readPersistedNativeRow(ctx, tx, "captain_session_sources", sourceID) + if err != nil { + return nil, err + } + nativeSourceLines = append(nativeSourceLines, string(sourceLine)) + } + nativePromptLines = nativePromptLines[:0] + for _, prompt := range prompts { + line, err := readPersistedNativeRow(ctx, tx, "captain_prompt_runs", prompt.NativeID) + if err != nil { + return nil, err + } + nativePromptLines = append(nativePromptLines, string(line)) + } + sort.Strings(nativeSessionLines) + sort.Strings(nativeSourceLines) + sort.Strings(nativePromptLines) + + nativeSessionStateLines := make([]string, 0, len(nativeSessionLines)+len(nativeSourceLines)) + for _, line := range nativeSessionLines { + nativeSessionStateLines = append(nativeSessionStateLines, "session\x00"+line) + } + for _, line := range nativeSourceLines { + nativeSessionStateLines = append(nativeSessionStateLines, "source\x00"+line) + } + sort.Strings(nativeSessionStateLines) + + completedAt := time.Now().UTC() + sessionMappings := make([]map[string]any, 0, len(sessions)) + providerFallbacks := 0 + legacyPlans := 0 + for _, session := range sessions { + if nestedString(session.Data, "provider", "name") == "" { + providerFallbacks++ + } + if plan, ok := session.Data["plan"].(map[string]any); ok && len(plan) > 0 { + legacyPlans++ + } + mapping := map[string]any{ + "path": session.Path, "legacyId": session.ID, "nativeId": session.NativeID, + "source": session.Source, "provider": legacyProvider(session), "hostId": "local", + } + if session.Parent != nil { + mapping["parentNativeId"] = session.Parent.NativeID + mapping["rootNativeId"] = session.Root.NativeID + } + sessionMappings = append(sessionMappings, mapping) + } + promptMappings := make([]map[string]any, 0, len(prompts)) + for _, prompt := range prompts { + promptMappings = append(promptMappings, map[string]any{ + "legacySessionId": prompt.SessionID, "legacyRunId": prompt.RunID, "nativePromptRunId": prompt.NativeID, + }) + } + archiveSessionColumns, err := relationColumns(ctx, tx, archiveSessionsTable) + if err != nil { + return nil, err + } + archivePromptColumns := map[string]string{} + if state.hasPromptArchive { + archivePromptColumns, err = relationColumns(ctx, tx, archivePromptsTable) + if err != nil { + return nil, err + } + } + details, err := json.Marshal(map[string]any{ + "archiveValidation": "source and rollback rows matched before the conflicting legacy table names were removed", + "identityMapping": "valid legacy UUIDs are preserved; other IDs use UUIDv5 in Captain's fixed legacy-session namespace", + "lifecycleMapping": "ended sessions become succeeded, started sessions without an end become interrupted, otherwise created", + "mappingLedger": map[string]any{ + "sessions": sessionMappings, + "prompts": promptMappings, + }, + "nativeSessionSources": map[string]any{ + "rows": len(nativeSourceLines), + "checksum": checksumStrings(nativeSourceLines), + }, + "schemaFingerprints": map[string]any{ + "sessions": checksumColumns(archiveSessionColumns), + "prompts": checksumColumns(archivePromptColumns), + }, + "warnings": map[string]any{ + "providerFallbackRows": providerFallbacks, + "legacyPlanRows": legacyPlans, + "legacyPlanHandling": "preserved losslessly in native session metadata and rollback archive; no synthetic execution plan was fabricated", + "summaryOnly": "aggregate cost, usage, files, approvals, and message counts remain in native session metadata and the rollback archive", + }, + "rollback": map[string]any{ + "sessionsTable": archiveSessionsTable, + "promptsTable": optionalArchiveName(state.hasPromptArchive), + }, + }) + if err != nil { + return nil, fmt.Errorf("marshal Captain legacy cutover details: %w", err) + } + + report := &LegacySessionCutoverReport{ + CutoverKey: legacyCutoverKey, + LegacySessionsTable: archiveSessionsTable, + LegacyPromptsTable: optionalStringPointer(optionalArchiveName(state.hasPromptArchive)), + LegacySessionRows: int64(len(sessions)), + LegacyPromptRows: int64(len(prompts)), + ImportedSessionRows: int64(len(nativeSessionLines)), + ImportedPromptRunRows: int64(len(nativePromptLines)), + LegacySessionsChecksum: checksumJSONRows(sessionRaw), + LegacyPromptsChecksum: optionalChecksum(state.hasPromptArchive, promptRaw), + NativeSessionsChecksum: checksumStrings(nativeSessionStateLines), + NativePromptRunsChecksum: optionalChecksum(state.hasPromptArchive, stringsToRaw(nativePromptLines)), + Details: details, + StartedAt: startedAt, + CompletedAt: completedAt, + UpdatedAt: completedAt, + } + + existing, exists, err := readCutoverReport(ctx, tx) + if err != nil { + return nil, err + } + if exists { + if !sameCutoverValidation(existing, report) { + return nil, errors.New("Captain legacy cutover validation no longer matches the durable completed report") + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit repeated Captain legacy session validation: %w", err) + } + return existing, nil + } + if err := insertCutoverReport(ctx, tx, report); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit Captain legacy session backfill: %w", err) + } + return report, nil +} + +func upsertAndValidateNativeSessionSource(ctx context.Context, tx *sql.Tx, row *legacySessionRow) (string, error) { + sourceID := uuid.NewSHA1(legacySourceNamespace, []byte(row.Path)) + observedSize := jsonInt64(row.Data, "size") + byteOffset := observedSize + parserVersion := jsonInt64(row.Data, "summary_version") + if parserVersion < 1 { + parserVersion = 1 + } + var observedModTime any + if modUnix := jsonInt64(row.Data, "mod_unix"); modUnix > 0 { + observedModTime = time.Unix(0, modUnix).UTC() + } + createdAt := firstTime(row.StartedAt, row.UpdatedAt) + updatedAt := firstTime(row.UpdatedAt, row.EndedAt, row.StartedAt) + + _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_session_sources ( + id, session_id, source_kind, path, source_identity, parser_version, + byte_offset, observed_size, observed_mod_time, created_at, updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT (id) DO NOTHING`, + sourceID, row.NativeID, row.Source, row.Path, row.ID, parserVersion, + byteOffset, observedSize, observedModTime, createdAt, updatedAt, + ) + if err != nil { + return "", fmt.Errorf("backfill native Captain session source %q: %w", row.Path, err) + } + + actual, err := validatePersistedNativeRow(ctx, tx, "captain_session_sources", sourceID, map[string]any{ + "id": sourceID, "session_id": row.NativeID, "source_kind": row.Source, + "path": row.Path, "source_identity": row.ID, "parser_version": parserVersion, + "byte_offset": byteOffset, "observed_size": observedSize, + "observed_mod_time": observedModTime, "last_event_key": nil, + "created_at": createdAt, "updated_at": updatedAt, + }) + if err != nil { + return "", fmt.Errorf("native Captain session source %s conflicts with legacy path %q: %w", sourceID, row.Path, err) + } + return actual, nil +} + +func upsertAndValidateNativeSession(ctx context.Context, tx *sql.Tx, row *legacySessionRow) (string, error) { + provider := legacyProvider(row) + + var parentID, rootID any + if row.Parent != nil { + parentID = row.Parent.NativeID + rootID = row.Root.NativeID + } + + lifecycle := "created" + if row.EndedAt != nil { + lifecycle = "succeeded" + } else if row.StartedAt != nil { + lifecycle = "interrupted" + } + observedAt := firstTime(row.UpdatedAt, row.EndedAt, row.StartedAt) + createdAt := firstTime(row.StartedAt, row.UpdatedAt) + updatedAt := firstTime(row.UpdatedAt, row.EndedAt, row.StartedAt) + + git := row.Data["git"] + if _, ok := git.(map[string]any); !ok { + git = map[string]any{} + } + gitJSON, err := json.Marshal(git) + if err != nil { + return "", fmt.Errorf("marshal legacy git for session %q: %w", row.ID, err) + } + metadata := map[string]any{ + "legacy_cache": row.Data, + "legacy_cutover": map[string]any{"key": legacyCutoverKey, "archive_table": archiveSessionsTable}, + } + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("marshal legacy metadata for session %q: %w", row.ID, err) + } + + _, err = tx.ExecContext(ctx, `INSERT INTO public.captain_sessions ( + id, provider_session_id, source, provider, host_id, + parent_session_id, root_session_id, path, project, cwd, title, + initial_prompt, slug, agent_type, description, cli_version, + lifecycle_status, activity_state, health_state, state_reason, + state_version, state_observed_at, git, metadata, started_at, + ended_at, last_activity_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, 'local', + $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, 'idle', 'healthy', $17, + 0, $18, $19::jsonb, $20::jsonb, $21, + $22, $23, $24, $25 + ) ON CONFLICT (id) DO NOTHING`, + row.NativeID, row.ID, row.Source, provider, + parentID, rootID, row.Path, nullableJSONText(row.Data, "project"), nullableJSONText(row.Data, "cwd"), nullableJSONText(row.Data, "title"), + nullableJSONText(row.Data, "initial_prompt"), nullableJSONText(row.Data, "slug"), nullableJSONText(row.Data, "agent_type"), nullableJSONText(row.Data, "agent_desc"), nestedNullableText(row.Data, "provider", "version"), + lifecycle, legacyCutoverStateNote, observedAt, string(gitJSON), string(metadataJSON), row.StartedAt, + row.EndedAt, observedAt, createdAt, updatedAt, + ) + if err != nil { + return "", fmt.Errorf("backfill native Captain session %q: %w", row.ID, err) + } + + actual, err := validatePersistedNativeRow(ctx, tx, "captain_sessions", row.NativeID, map[string]any{ + "id": row.NativeID, "provider_session_id": row.ID, "source": row.Source, + "provider": provider, "host_id": "local", "parent_session_id": parentID, + "root_session_id": rootID, "path": row.Path, + "project": nullableJSONText(row.Data, "project"), "cwd": nullableJSONText(row.Data, "cwd"), + "title": nullableJSONText(row.Data, "title"), "initial_prompt": nullableJSONText(row.Data, "initial_prompt"), + "slug": nullableJSONText(row.Data, "slug"), "agent_type": nullableJSONText(row.Data, "agent_type"), + "description": nullableJSONText(row.Data, "agent_desc"), + "cli_version": nestedNullableText(row.Data, "provider", "version"), + "lifecycle_status": lifecycle, "activity_state": "idle", "health_state": "healthy", + "state_reason": legacyCutoverStateNote, "state_version": int64(0), "state_observed_at": observedAt, + "git": git, "metadata": metadata, "started_at": row.StartedAt, "ended_at": row.EndedAt, + "last_activity_at": observedAt, "created_at": createdAt, "updated_at": updatedAt, + }) + if err != nil { + return "", fmt.Errorf("native Captain session %s conflicts with legacy identity %q: %w", row.NativeID, row.ID, err) + } + return actual, nil +} + +func upsertAndValidateNativePromptRun(ctx context.Context, tx *sql.Tx, row *legacyPromptRow, sessions []*legacySessionRow) (string, error) { + byID := make(map[string]*legacySessionRow, len(sessions)) + for _, session := range sessions { + byID[session.ID] = session + } + session := byID[row.SessionID] + rootID := session.NativeID + if session.Root != nil { + rootID = session.Root.NativeID + } + + rendered := row.Data["realized"] + if _, ok := rendered.(map[string]any); !ok { + rendered = map[string]any{} + } + renderedMap := rendered.(map[string]any) + renderedMap["x-legacy-cache"] = map[string]any{ + "runId": row.RunID, "model": jsonString(row.Data, "model"), "backend": jsonString(row.Data, "backend"), + } + renderedJSON, err := json.Marshal(renderedMap) + if err != nil { + return "", fmt.Errorf("marshal legacy realized prompt for session %q: %w", row.SessionID, err) + } + createdAt := jsonTime(row.Data, "created_at") + if createdAt == nil { + createdAt = firstTimePointer(session.UpdatedAt, session.EndedAt, session.StartedAt) + } + if createdAt == nil { + now := time.Now().UTC() + createdAt = &now + } + + _, err = tx.ExecContext(ctx, `INSERT INTO public.captain_prompt_runs ( + id, session_id, root_session_id, origin, rendered_spec, prompt_markdown, + phase, state, current_iteration, queued_at, started_at, finished_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, 'legacy-session-cache', $4::jsonb, $5, + 'finished', 'succeeded', 0, $6, $6, $6, $6, $6 + ) ON CONFLICT (id) DO NOTHING`, + row.NativeID, session.NativeID, rootID, string(renderedJSON), promptMarkdown(renderedMap), *createdAt, + ) + if err != nil { + return "", fmt.Errorf("backfill native Captain prompt run %q: %w", row.RunID, err) + } + + actual, err := validatePersistedNativeRow(ctx, tx, "captain_prompt_runs", row.NativeID, map[string]any{ + "id": row.NativeID, "session_id": session.NativeID, "root_session_id": rootID, + "batch_id": nil, "parent_run_id": nil, "input_plan_id": nil, "input_plan_revision_id": nil, + "origin": "legacy-session-cache", "spec_profile": nil, "admission_key": nil, + "rendered_spec": renderedMap, "prompt_markdown": promptMarkdown(renderedMap), + "verification_markdown": nil, "phase": "finished", "state": "succeeded", + "current_iteration": 0, "result_text": nil, "result_json": nil, "error": nil, + "version": int64(0), "queued_at": createdAt, "started_at": createdAt, + "finished_at": createdAt, "created_at": createdAt, "updated_at": createdAt, + }) + if err != nil { + return "", fmt.Errorf("native Captain prompt run %s conflicts with legacy run %q: %w", row.NativeID, row.RunID, err) + } + return actual, nil +} + +func setLegacyBackfillEmitTriggers(ctx context.Context, tx *sql.Tx, enabled bool) error { + value := "on" + if enabled { + value = "off" + } + if _, err := tx.ExecContext(ctx, `SELECT set_config('captain.suppress_session_change', $1, true)`, value); err != nil { + return fmt.Errorf("configure Captain legacy backfill emit suppression: %w", err) + } + return nil +} + +func validatePersistedNativeRow( + ctx context.Context, + tx *sql.Tx, + table string, + id uuid.UUID, + expected map[string]any, +) (string, error) { + if !allowedNativeCutoverRelation(table) { + return "", fmt.Errorf("unsupported native Captain cutover relation %q", table) + } + raw, err := readPersistedNativeRow(ctx, tx, table, id) + if err != nil { + return "", err + } + actual, err := decodeJSONMap(raw) + if err != nil { + return "", fmt.Errorf("decode persisted public.%s row %s: %w", table, id, err) + } + actual = normalizePersistedRow(actual) + expected = normalizePersistedRow(expected) + + keys := make(map[string]struct{}, len(actual)+len(expected)) + for key := range actual { + keys[key] = struct{}{} + } + for key := range expected { + keys[key] = struct{}{} + } + var mismatches []string + for key := range keys { + actualValue, actualExists := actual[key] + expectedValue, expectedExists := expected[key] + if !actualExists || !expectedExists || canonicalJSONValue(actualValue) != canonicalJSONValue(expectedValue) { + mismatches = append(mismatches, key) + } + } + if len(mismatches) != 0 { + sort.Strings(mismatches) + return "", fmt.Errorf("persisted columns differ from archive-derived values: %s", strings.Join(mismatches, ", ")) + } + return string(raw), nil +} + +func readPersistedNativeRow(ctx context.Context, tx *sql.Tx, table string, id uuid.UUID) ([]byte, error) { + if !allowedNativeCutoverRelation(table) { + return nil, fmt.Errorf("unsupported native Captain cutover relation %q", table) + } + var raw []byte + if err := tx.QueryRowContext(ctx, fmt.Sprintf( + `SELECT to_jsonb(row_value)::text FROM public.%s AS row_value WHERE id = $1`, table, + ), id).Scan(&raw); err != nil { + return nil, fmt.Errorf("read persisted public.%s row %s: %w", table, id, err) + } + return raw, nil +} + +func allowedNativeCutoverRelation(table string) bool { + switch table { + case "captain_sessions", "captain_session_sources", "captain_prompt_runs": + return true + default: + return false + } +} + +func normalizePersistedRow(row map[string]any) map[string]any { + normalized := make(map[string]any, len(row)) + for key, value := range row { + normalized[key] = normalizePersistedValue(key, value) + } + return normalized +} + +func normalizePersistedValue(key string, value any) any { + switch typed := value.(type) { + case uuid.UUID: + return typed.String() + case *uuid.UUID: + if typed == nil { + return nil + } + return typed.String() + case time.Time: + return canonicalCutoverTimestamp(typed) + case *time.Time: + if typed == nil { + return nil + } + return canonicalCutoverTimestamp(*typed) + case string: + if isCutoverTimestampColumn(key) { + if parsed, err := time.Parse(time.RFC3339Nano, typed); err == nil { + return canonicalCutoverTimestamp(parsed) + } + } + } + return value +} + +func canonicalCutoverTimestamp(value time.Time) string { + // PostgreSQL timestamptz stores microseconds by truncating sub-microsecond + // precision. Mirror that behavior when comparing archive-derived values to + // rows read back from PostgreSQL; rounding can advance the expected value by + // one microsecond and reject an otherwise exact persisted row. + return value.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano) +} + +func isCutoverTimestampColumn(column string) bool { + switch column { + case "state_observed_at", "started_at", "ended_at", "last_activity_at", "created_at", "updated_at", + "observed_mod_time", "queued_at", "finished_at": + return true + default: + return false + } +} + +func canonicalJSONValue(value any) string { + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("", value, err) + } + return string(encoded) +} + +func loadLegacySessions(ctx context.Context, query queryer, table string) ([]*legacySessionRow, []json.RawMessage, error) { + raw, err := loadJSONRows(ctx, query, table, "path") + if err != nil { + return nil, nil, err + } + rows := make([]*legacySessionRow, 0, len(raw)) + for _, value := range raw { + data, err := decodeJSONMap(value) + if err != nil { + return nil, nil, fmt.Errorf("decode public.%s row: %w", table, err) + } + rows = append(rows, &legacySessionRow{ + Raw: value, Data: data, Path: jsonString(data, "path"), ID: jsonString(data, "id"), + ParentID: jsonString(data, "parent_id"), Source: jsonString(data, "source"), + StartedAt: jsonTime(data, "started_at"), EndedAt: jsonTime(data, "ended_at"), UpdatedAt: jsonTime(data, "updated_at"), + }) + } + return rows, raw, nil +} + +func loadLegacyPromptsIfPresent(ctx context.Context, query queryer, exists bool, table string) ([]*legacyPromptRow, []json.RawMessage, error) { + if !exists { + return nil, nil, nil + } + raw, err := loadJSONRows(ctx, query, table, "session_id") + if err != nil { + return nil, nil, err + } + rows := make([]*legacyPromptRow, 0, len(raw)) + for _, value := range raw { + data, err := decodeJSONMap(value) + if err != nil { + return nil, nil, fmt.Errorf("decode public.%s row: %w", table, err) + } + rows = append(rows, &legacyPromptRow{ + Raw: value, Data: data, SessionID: jsonString(data, "session_id"), RunID: jsonString(data, "run_id"), + }) + } + return rows, raw, nil +} + +func validateLegacyDataset(sessions []*legacySessionRow, prompts []*legacyPromptRow) error { + byID := make(map[string]*legacySessionRow, len(sessions)) + byNativeID := make(map[uuid.UUID]string, len(sessions)) + paths := make(map[string]struct{}, len(sessions)) + for _, row := range sessions { + if row.Path == "" || row.ID == "" || row.Source == "" { + return errors.New("each legacy session requires non-empty path, id, and source") + } + if _, exists := paths[row.Path]; exists { + return fmt.Errorf("duplicate legacy session path %q", row.Path) + } + paths[row.Path] = struct{}{} + if _, exists := byID[row.ID]; exists { + return fmt.Errorf("duplicate legacy session id %q", row.ID) + } + byID[row.ID] = row + row.NativeID = legacyNativeSessionID(row.ID) + if previous, exists := byNativeID[row.NativeID]; exists { + return fmt.Errorf("legacy session UUID mapping collision between %q and %q", previous, row.ID) + } + byNativeID[row.NativeID] = row.ID + if row.StartedAt != nil && row.EndedAt != nil && row.EndedAt.Before(*row.StartedAt) { + return fmt.Errorf("legacy session %q ends before it starts", row.ID) + } + } + for _, row := range sessions { + if row.ParentID == "" { + row.Root = row + continue + } + parent := byID[row.ParentID] + if parent == nil { + return fmt.Errorf("legacy session %q references missing parent %q", row.ID, row.ParentID) + } + if parent.Source != row.Source { + return fmt.Errorf("legacy session %q source %q differs from parent %q source %q", row.ID, row.Source, parent.ID, parent.Source) + } + row.Parent = parent + } + + visiting := map[string]bool{} + visited := map[string]bool{} + var resolve func(*legacySessionRow) error + resolve = func(row *legacySessionRow) error { + if visited[row.ID] { + return nil + } + if visiting[row.ID] { + return fmt.Errorf("legacy session parent cycle includes %q", row.ID) + } + visiting[row.ID] = true + if row.Parent == nil { + row.Root, row.Depth = row, 0 + } else { + if err := resolve(row.Parent); err != nil { + return err + } + row.Root, row.Depth = row.Parent.Root, row.Parent.Depth+1 + } + visiting[row.ID] = false + visited[row.ID] = true + return nil + } + for _, row := range sessions { + if err := resolve(row); err != nil { + return err + } + } + + promptIDs := make(map[uuid.UUID]string, len(prompts)) + for _, row := range prompts { + if row.SessionID == "" { + return errors.New("legacy prompt row has an empty session_id") + } + if byID[row.SessionID] == nil { + return fmt.Errorf("legacy prompt for session %q has no archived session", row.SessionID) + } + row.NativeID = legacyNativePromptID(row.SessionID, row.RunID) + if previous, exists := promptIDs[row.NativeID]; exists { + return fmt.Errorf("legacy prompt run identity collision between %q and %q", previous, row.RunID) + } + promptIDs[row.NativeID] = row.RunID + } + return nil +} + +func legacyNativeSessionID(id string) uuid.UUID { + if parsed, err := uuid.Parse(strings.TrimSpace(id)); err == nil { + return parsed + } + return uuid.NewSHA1(legacySessionNamespace, []byte(strings.TrimSpace(id))) +} + +func legacyNativePromptID(sessionID, runID string) uuid.UUID { + if parsed, err := uuid.Parse(strings.TrimSpace(runID)); err == nil { + return parsed + } + return uuid.NewSHA1(legacyPromptNamespace, []byte(strings.TrimSpace(sessionID)+"\x00"+strings.TrimSpace(runID))) +} + +type queryer interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} + +func loadJSONRows(ctx context.Context, query queryer, table, orderColumn string) ([]json.RawMessage, error) { + if !allowedLegacyRelation(table) || (orderColumn != "path" && orderColumn != "session_id") { + return nil, fmt.Errorf("unsupported Captain legacy relation %q", table) + } + rows, err := query.QueryContext(ctx, fmt.Sprintf(`SELECT to_jsonb(t)::text + FROM public.%s t ORDER BY %s`, table, orderColumn)) + if err != nil { + return nil, fmt.Errorf("read public.%s: %w", table, err) + } + defer rows.Close() + var result []json.RawMessage + for rows.Next() { + var value []byte + if err := rows.Scan(&value); err != nil { + return nil, fmt.Errorf("scan public.%s row: %w", table, err) + } + result = append(result, append(json.RawMessage(nil), value...)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read public.%s rows: %w", table, err) + } + return result, nil +} + +func relationExists(ctx context.Context, query interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +}, table string) (bool, error) { + if !allowedLegacyRelation(table) { + return false, fmt.Errorf("unsupported Captain relation %q", table) + } + var exists bool + if err := query.QueryRowContext(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, table).Scan(&exists); err != nil { + return false, fmt.Errorf("inspect public.%s: %w", table, err) + } + return exists, nil +} + +func relationColumns(ctx context.Context, query queryer, table string) (map[string]string, error) { + if !allowedLegacyRelation(table) { + return nil, fmt.Errorf("unsupported Captain relation %q", table) + } + rows, err := query.QueryContext(ctx, `SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, table) + if err != nil { + return nil, fmt.Errorf("inspect public.%s columns: %w", table, err) + } + defer rows.Close() + columns := map[string]string{} + for rows.Next() { + var name, dataType string + if err := rows.Scan(&name, &dataType); err != nil { + return nil, fmt.Errorf("scan public.%s columns: %w", table, err) + } + columns[name] = dataType + } + return columns, rows.Err() +} + +func allowedLegacyRelation(table string) bool { + switch table { + case legacySessionsTable, legacyPromptsTable, archiveSessionsTable, archivePromptsTable: + return true + default: + return false + } +} + +func storedCutoverExists(ctx context.Context, db *sql.DB) (bool, error) { + _, exists, err := readCutoverReport(ctx, db) + return exists, err +} + +type rowQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func readCutoverReport(ctx context.Context, query rowQueryer) (*LegacySessionCutoverReport, bool, error) { + var report LegacySessionCutoverReport + var promptsTable, promptsChecksum, nativePromptsChecksum sql.NullString + var details []byte + err := query.QueryRowContext(ctx, `SELECT + cutover_key, legacy_sessions_table, legacy_prompts_table, + legacy_session_rows, legacy_prompt_rows, imported_session_rows, imported_prompt_run_rows, + legacy_sessions_checksum, legacy_prompts_checksum, native_sessions_checksum, native_prompt_runs_checksum, + details, started_at, completed_at, updated_at + FROM public.captain_legacy_session_cutovers WHERE cutover_key = $1`, legacyCutoverKey).Scan( + &report.CutoverKey, &report.LegacySessionsTable, &promptsTable, + &report.LegacySessionRows, &report.LegacyPromptRows, &report.ImportedSessionRows, &report.ImportedPromptRunRows, + &report.LegacySessionsChecksum, &promptsChecksum, &report.NativeSessionsChecksum, &nativePromptsChecksum, + &details, &report.StartedAt, &report.CompletedAt, &report.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("read Captain legacy cutover report: %w", err) + } + report.LegacyPromptsTable = nullableStringPointer(promptsTable) + report.LegacyPromptsChecksum = nullableStringPointer(promptsChecksum) + report.NativePromptRunsChecksum = nullableStringPointer(nativePromptsChecksum) + report.Details = append(json.RawMessage(nil), details...) + report.StartedAt = report.StartedAt.UTC() + report.CompletedAt = report.CompletedAt.UTC() + report.UpdatedAt = report.UpdatedAt.UTC() + return &report, true, nil +} + +func sameCutoverValidation(left, right *LegacySessionCutoverReport) bool { + if left == nil || right == nil { + return false + } + return left.CutoverKey == right.CutoverKey && + left.LegacySessionsTable == right.LegacySessionsTable && + equalOptionalString(left.LegacyPromptsTable, right.LegacyPromptsTable) && + left.LegacySessionRows == right.LegacySessionRows && + left.LegacyPromptRows == right.LegacyPromptRows && + left.ImportedSessionRows == right.ImportedSessionRows && + left.ImportedPromptRunRows == right.ImportedPromptRunRows && + left.LegacySessionsChecksum == right.LegacySessionsChecksum && + equalOptionalString(left.LegacyPromptsChecksum, right.LegacyPromptsChecksum) && + left.NativeSessionsChecksum == right.NativeSessionsChecksum && + equalOptionalString(left.NativePromptRunsChecksum, right.NativePromptRunsChecksum) && + equalJSON(left.Details, right.Details) +} + +func equalJSON(left, right []byte) bool { + var leftValue, rightValue any + leftDecoder := json.NewDecoder(strings.NewReader(string(left))) + leftDecoder.UseNumber() + if err := leftDecoder.Decode(&leftValue); err != nil { + return false + } + rightDecoder := json.NewDecoder(strings.NewReader(string(right))) + rightDecoder.UseNumber() + if err := rightDecoder.Decode(&rightValue); err != nil { + return false + } + leftCanonical, err := json.Marshal(leftValue) + if err != nil { + return false + } + rightCanonical, err := json.Marshal(rightValue) + return err == nil && string(leftCanonical) == string(rightCanonical) +} + +func insertCutoverReport(ctx context.Context, tx *sql.Tx, report *LegacySessionCutoverReport) error { + _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_legacy_session_cutovers ( + cutover_key, legacy_sessions_table, legacy_prompts_table, + legacy_session_rows, legacy_prompt_rows, imported_session_rows, imported_prompt_run_rows, + legacy_sessions_checksum, legacy_prompts_checksum, native_sessions_checksum, native_prompt_runs_checksum, + details, started_at, completed_at, updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15)`, + report.CutoverKey, report.LegacySessionsTable, report.LegacyPromptsTable, + report.LegacySessionRows, report.LegacyPromptRows, report.ImportedSessionRows, report.ImportedPromptRunRows, + report.LegacySessionsChecksum, report.LegacyPromptsChecksum, report.NativeSessionsChecksum, report.NativePromptRunsChecksum, + string(report.Details), report.StartedAt, report.CompletedAt, report.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("store Captain legacy cutover report: %w", err) + } + return nil +} + +func nullableStringPointer(value sql.NullString) *string { + if !value.Valid { + return nil + } + copy := value.String + return © +} + +func equalOptionalString(left, right *string) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + +func checksumJSONRows(rows []json.RawMessage) string { + values := make([]string, len(rows)) + for i := range rows { + values[i] = string(rows[i]) + } + return checksumStrings(values) +} + +func checksumStrings(values []string) string { + hash := sha256.New() + for _, value := range values { + hash.Write([]byte(value)) + hash.Write([]byte{'\n'}) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +func checksumColumns(columns map[string]string) string { + values := make([]string, 0, len(columns)) + for name, dataType := range columns { + values = append(values, name+"\x00"+dataType) + } + sort.Strings(values) + return checksumStrings(values) +} + +func stringsToRaw(values []string) []json.RawMessage { + result := make([]json.RawMessage, len(values)) + for i := range values { + result[i] = json.RawMessage(values[i]) + } + return result +} + +func optionalChecksum(present bool, rows []json.RawMessage) *string { + if !present { + return nil + } + value := checksumJSONRows(rows) + return &value +} + +func optionalArchiveName(present bool) string { + if present { + return archivePromptsTable + } + return "" +} + +func optionalStringPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + +func jsonString(data map[string]any, key string) string { + value, _ := data[key].(string) + return strings.TrimSpace(value) +} + +func jsonInt64(data map[string]any, key string) int64 { + switch value := data[key].(type) { + case json.Number: + parsed, _ := value.Int64() + return parsed + case float64: + return int64(value) + case int64: + return value + default: + return 0 + } +} + +func decodeJSONMap(raw []byte) (map[string]any, error) { + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + var data map[string]any + if err := decoder.Decode(&data); err != nil { + return nil, err + } + return data, nil +} + +func nestedString(data map[string]any, outer, inner string) string { + nested, _ := data[outer].(map[string]any) + return jsonString(nested, inner) +} + +func legacyProvider(row *legacySessionRow) string { + if provider := nestedString(row.Data, "provider", "name"); provider != "" { + return provider + } + switch row.Source { + case "codex": + return "openai" + case "claude": + return "anthropic" + default: + return row.Source + } +} + +func nullableJSONText(data map[string]any, key string) any { + if value := jsonString(data, key); value != "" { + return value + } + return nil +} + +func nestedNullableText(data map[string]any, outer, inner string) any { + if value := nestedString(data, outer, inner); value != "" { + return value + } + return nil +} + +func jsonTime(data map[string]any, key string) *time.Time { + value := jsonString(data, key) + if value == "" { + return nil + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil + } + parsed = parsed.UTC() + return &parsed +} + +func firstTime(values ...*time.Time) time.Time { + if value := firstTimePointer(values...); value != nil { + return *value + } + return time.Now().UTC() +} + +func firstTimePointer(values ...*time.Time) *time.Time { + for _, value := range values { + if value != nil { + copy := value.UTC() + return © + } + } + return nil +} + +func promptMarkdown(rendered map[string]any) any { + if input, ok := rendered["input"].(map[string]any); ok { + if prompt, ok := input["prompt"].(map[string]any); ok { + if value := jsonString(prompt, "user"); value != "" { + return value + } + } + } + if value := jsonString(rendered, "user"); value != "" { + return value + } + return nil +} diff --git a/migrations/legacy_cutover_integration_test.go b/migrations/legacy_cutover_integration_test.go new file mode 100644 index 0000000..ce40b96 --- /dev/null +++ b/migrations/legacy_cutover_integration_test.go @@ -0,0 +1,591 @@ +package migrations + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyWithLegacySessionCutoverPreservesAndBackfillsHistoricalCache(t *testing.T) { + dsn := legacyCutoverTestDSN(t) + ctx := t.Context() + db, err := commonsdb.NewDB(dsn) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + require.NoError(t, createHistoricalLegacySessionCache(ctx, db)) + require.NoError(t, createUnrelatedSentinel(ctx, db)) + + legacySessions := readJSONRows(t, ctx, db, legacySessionsTable, "path") + legacyPrompts := readJSONRows(t, ctx, db, legacyPromptsTable, "session_id") + legacySessionColumns := readColumnSignatures(t, ctx, db, legacySessionsTable) + legacyPromptColumns := readColumnSignatures(t, ctx, db, legacyPromptsTable) + assert.Equal(t, []string{"path"}, readPrimaryKeyColumns(t, ctx, db, legacySessionsTable)) + assert.Equal(t, []string{"session_id"}, readPrimaryKeyColumns(t, ctx, db, legacyPromptsTable)) + + first, err := ApplyWithLegacySessionCutover(ctx, dsn) + require.NoError(t, err) + require.NotNil(t, first) + assert.Equal(t, legacyCutoverKey, first.CutoverKey) + assert.Equal(t, archiveSessionsTable, first.LegacySessionsTable) + require.NotNil(t, first.LegacyPromptsTable) + assert.Equal(t, archivePromptsTable, *first.LegacyPromptsTable) + assert.EqualValues(t, 2, first.LegacySessionRows) + assert.EqualValues(t, 1, first.LegacyPromptRows) + assert.Equal(t, first.LegacySessionRows, first.ImportedSessionRows) + assert.Equal(t, first.LegacyPromptRows, first.ImportedPromptRunRows) + assert.Equal(t, checksumStrings(legacySessions), first.LegacySessionsChecksum) + promptChecksum := checksumStrings(legacyPrompts) + require.NotNil(t, first.LegacyPromptsChecksum) + assert.Equal(t, promptChecksum, *first.LegacyPromptsChecksum) + assert.NotEmpty(t, first.NativeSessionsChecksum) + require.NotNil(t, first.NativePromptRunsChecksum) + assert.NotEmpty(t, *first.NativePromptRunsChecksum) + actualNativeSessions := readJSONRows(t, ctx, db, "captain_sessions", "id") + actualNativeSources := readJSONRows(t, ctx, db, "captain_session_sources", "id") + actualNativeSessionState := make([]string, 0, len(actualNativeSessions)+len(actualNativeSources)) + for _, row := range actualNativeSessions { + actualNativeSessionState = append(actualNativeSessionState, "session\x00"+row) + } + for _, row := range actualNativeSources { + actualNativeSessionState = append(actualNativeSessionState, "source\x00"+row) + } + sort.Strings(actualNativeSessionState) + assert.Equal(t, checksumStrings(actualNativeSessionState), first.NativeSessionsChecksum) + actualNativePrompts := readJSONRows(t, ctx, db, "captain_prompt_runs", "id") + sort.Strings(actualNativePrompts) + assert.Equal(t, checksumStrings(actualNativePrompts), *first.NativePromptRunsChecksum) + + details, err := decodeJSONMap(first.Details) + require.NoError(t, err) + mappingLedger, ok := details["mappingLedger"].(map[string]any) + require.True(t, ok) + sessionMappings, ok := mappingLedger["sessions"].([]any) + require.True(t, ok) + require.Len(t, sessionMappings, 2) + promptMappings, ok := mappingLedger["prompts"].([]any) + require.True(t, ok) + require.Len(t, promptMappings, 1) + rootMapping, ok := sessionMappings[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", rootMapping["legacyId"]) + assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", rootMapping["nativeId"]) + childMapping, ok := sessionMappings[1].(map[string]any) + require.True(t, ok) + assert.Equal(t, "opaque-child-session", childMapping["legacyId"]) + assert.Equal(t, legacyNativeSessionID("opaque-child-session").String(), childMapping["nativeId"]) + assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", childMapping["parentNativeId"]) + assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", childMapping["rootNativeId"]) + promptMapping, ok := promptMappings[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, legacyNativePromptID("opaque-child-session", "opaque-prompt-run").String(), promptMapping["nativePromptRunId"]) + + schemaFingerprints, ok := details["schemaFingerprints"].(map[string]any) + require.True(t, ok) + archiveSessionColumnTypes, err := relationColumns(ctx, db, archiveSessionsTable) + require.NoError(t, err) + archivePromptColumnTypes, err := relationColumns(ctx, db, archivePromptsTable) + require.NoError(t, err) + assert.Equal(t, checksumColumns(archiveSessionColumnTypes), schemaFingerprints["sessions"]) + assert.Equal(t, checksumColumns(archivePromptColumnTypes), schemaFingerprints["prompts"]) + warnings, ok := details["warnings"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "1", fmt.Sprint(warnings["providerFallbackRows"])) + assert.Equal(t, "0", fmt.Sprint(warnings["legacyPlanRows"])) + assert.NotEmpty(t, warnings["legacyPlanHandling"]) + assert.NotEmpty(t, warnings["summaryOnly"]) + nativeSessionSources, ok := details["nativeSessionSources"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "2", fmt.Sprint(nativeSessionSources["rows"])) + sort.Strings(actualNativeSources) + assert.Equal(t, checksumStrings(actualNativeSources), nativeSessionSources["checksum"]) + var durableDetails []byte + require.NoError(t, db.QueryRowContext(ctx, `SELECT details + FROM public.captain_legacy_session_cutovers WHERE cutover_key = $1`, legacyCutoverKey).Scan(&durableDetails)) + assert.JSONEq(t, string(first.Details), string(durableDetails)) + + assert.False(t, relationExistsForTest(t, ctx, db, legacyPromptsTable)) + assert.Equal(t, legacySessions, readJSONRows(t, ctx, db, archiveSessionsTable, "path")) + assert.Equal(t, legacyPrompts, readJSONRows(t, ctx, db, archivePromptsTable, "session_id")) + assert.Equal(t, legacySessionColumns, readColumnSignatures(t, ctx, db, archiveSessionsTable)) + assert.Equal(t, legacyPromptColumns, readColumnSignatures(t, ctx, db, archivePromptsTable)) + assert.Equal(t, []string{"path"}, readPrimaryKeyColumns(t, ctx, db, archiveSessionsTable)) + assert.Equal(t, []string{"session_id"}, readPrimaryKeyColumns(t, ctx, db, archivePromptsTable)) + assert.False(t, columnExistsForTest(t, ctx, db, archiveSessionsTable, "summary_version")) + assert.False(t, columnExistsForTest(t, ctx, db, archiveSessionsTable, "title")) + assert.False(t, columnExistsForTest(t, ctx, db, archiveSessionsTable, "initial_prompt")) + + rootProviderID := "71d6e735-e02c-46ef-a1bd-e4aff85a27fe" + rootNativeID := uuid.MustParse(rootProviderID) + childProviderID := "opaque-child-session" + childNativeID := legacyNativeSessionID(childProviderID) + + root := readNativeSession(t, ctx, db, rootNativeID) + assert.Equal(t, rootProviderID, root.ProviderSessionID) + assert.Equal(t, "codex", root.Source) + assert.Equal(t, "openai", root.Provider) + assert.Equal(t, "local", root.HostID) + assert.Equal(t, "/legacy/root.jsonl", root.Path) + assert.False(t, root.ParentID.Valid) + assert.False(t, root.RootID.Valid) + assert.Equal(t, "succeeded", root.LifecycleStatus) + assert.Equal(t, legacyCutoverStateNote, root.StateReason) + assert.Equal(t, rootProviderID, nestedMetadataString(t, root.Metadata, "legacy_cache", "id")) + assert.Equal(t, "captain-session-cache-v1", nestedMetadataString(t, root.Metadata, "legacy_cutover", "key")) + + child := readNativeSession(t, ctx, db, childNativeID) + assert.Equal(t, childProviderID, child.ProviderSessionID) + assert.True(t, child.ParentID.Valid) + assert.Equal(t, rootNativeID, child.ParentID.UUID) + assert.True(t, child.RootID.Valid) + assert.Equal(t, rootNativeID, child.RootID.UUID) + assert.Equal(t, "openai", child.Provider) + assert.Equal(t, "database child", nestedMetadataString(t, child.Metadata, "legacy_cache", "agent_desc")) + assert.Equal(t, "legacy-model", nestedMetadataString(t, child.Metadata, "legacy_cache", "model")) + + sources := readNativeSessionSources(t, ctx, db) + require.Len(t, sources, 2) + rootSource := sources["/legacy/root.jsonl"] + assert.Equal(t, uuid.NewSHA1(legacySourceNamespace, []byte(rootSource.Path)), rootSource.ID) + assert.Equal(t, rootNativeID, rootSource.SessionID) + assert.Equal(t, "codex", rootSource.SourceKind) + assert.Equal(t, rootProviderID, rootSource.SourceIdentity) + assert.Equal(t, 1, rootSource.ParserVersion) + assert.Equal(t, int64(101), rootSource.ByteOffset) + assert.Equal(t, int64(101), rootSource.ObservedSize) + assert.True(t, time.Unix(0, 1_000_000_501).UTC().Truncate(time.Microsecond).Equal(rootSource.ObservedModTime)) + childSource := sources["/legacy/child.jsonl"] + assert.Equal(t, uuid.NewSHA1(legacySourceNamespace, []byte(childSource.Path)), childSource.ID) + assert.Equal(t, childNativeID, childSource.SessionID) + assert.Equal(t, "codex", childSource.SourceKind) + assert.Equal(t, childProviderID, childSource.SourceIdentity) + assert.Equal(t, 1, childSource.ParserVersion) + assert.Equal(t, int64(202), childSource.ByteOffset) + assert.Equal(t, int64(202), childSource.ObservedSize) + assert.True(t, time.Unix(0, 2_000_000_999).UTC().Truncate(time.Microsecond).Equal(childSource.ObservedModTime)) + + promptNativeID := legacyNativePromptID(childProviderID, "opaque-prompt-run") + var prompt struct { + SessionID uuid.UUID + RootSessionID uuid.UUID + Origin sql.NullString + PromptMarkdown sql.NullString + Phase string + State string + RenderedSpec []byte + } + require.NoError(t, db.QueryRowContext(ctx, `SELECT session_id, root_session_id, origin, + prompt_markdown, phase, state, rendered_spec + FROM public.captain_prompt_runs WHERE id = $1`, promptNativeID).Scan( + &prompt.SessionID, &prompt.RootSessionID, &prompt.Origin, &prompt.PromptMarkdown, + &prompt.Phase, &prompt.State, &prompt.RenderedSpec, + )) + assert.Equal(t, childNativeID, prompt.SessionID) + assert.Equal(t, rootNativeID, prompt.RootSessionID) + assert.Equal(t, "legacy-session-cache", prompt.Origin.String) + assert.Equal(t, "preserve this realized prompt", prompt.PromptMarkdown.String) + assert.Equal(t, "finished", prompt.Phase) + assert.Equal(t, "succeeded", prompt.State) + var rendered map[string]any + require.NoError(t, json.Unmarshal(prompt.RenderedSpec, &rendered)) + legacyPromptMetadata, ok := rendered["x-legacy-cache"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "opaque-prompt-run", legacyPromptMetadata["runId"]) + assert.Equal(t, "legacy-model", legacyPromptMetadata["model"]) + assert.Equal(t, "codex_cli", legacyPromptMetadata["backend"]) + + second, err := ApplyWithLegacySessionCutover(ctx, dsn) + require.NoError(t, err) + require.NotNil(t, second) + assert.Equal(t, first.LegacySessionsChecksum, second.LegacySessionsChecksum) + assert.Equal(t, first.LegacyPromptsChecksum, second.LegacyPromptsChecksum) + assert.Equal(t, first.NativeSessionsChecksum, second.NativeSessionsChecksum) + assert.Equal(t, first.NativePromptRunsChecksum, second.NativePromptRunsChecksum) + assert.True(t, first.StartedAt.Equal(second.StartedAt), "started_at changed: %s != %s", first.StartedAt, second.StartedAt) + assert.True(t, first.CompletedAt.Equal(second.CompletedAt), "completed_at changed: %s != %s", first.CompletedAt, second.CompletedAt) + assert.True(t, first.UpdatedAt.Equal(second.UpdatedAt), "updated_at changed: %s != %s", first.UpdatedAt, second.UpdatedAt) + assert.JSONEq(t, string(first.Details), string(second.Details)) + require.NoError(t, Apply(ctx, dsn)) + + assert.Equal(t, legacySessions, readJSONRows(t, ctx, db, archiveSessionsTable, "path")) + assert.Equal(t, legacyPrompts, readJSONRows(t, ctx, db, archivePromptsTable, "session_id")) + var sentinel string + require.NoError(t, db.QueryRowContext(ctx, `SELECT payload FROM public.unrelated_gavel_data WHERE id = 1`).Scan(&sentinel)) + assert.Equal(t, "preserve unrelated data", sentinel) + var sessionCount, promptCount, reportCount int + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM public.captain_sessions`).Scan(&sessionCount)) + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM public.captain_prompt_runs`).Scan(&promptCount)) + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM public.captain_legacy_session_cutovers WHERE cutover_key = $1`, legacyCutoverKey).Scan(&reportCount)) + assert.Equal(t, 2, sessionCount) + assert.Equal(t, 1, promptCount) + assert.Equal(t, 1, reportCount) + + // Simulate a crash-resume database containing a conflicting row that owns + // the deterministic native ID. The retry must reject every material prompt + // mismatch instead of accepting the old identity-only columns. + tamperTx, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + _, err = tamperTx.ExecContext(ctx, `SELECT set_config('captain.suppress_session_change', 'on', true)`) + require.NoError(t, err) + _, err = tamperTx.ExecContext(ctx, `UPDATE public.captain_prompt_runs + SET rendered_spec = '{}'::jsonb WHERE id = $1`, promptNativeID) + require.NoError(t, err) + require.NoError(t, tamperTx.Commit()) + _, err = ApplyWithLegacySessionCutover(ctx, dsn) + require.Error(t, err) + assert.ErrorContains(t, err, "rendered_spec") + storedAfterFailure, exists, readErr := readCutoverReport(ctx, db) + require.NoError(t, readErr) + require.True(t, exists) + assert.True(t, second.StartedAt.Equal(storedAfterFailure.StartedAt)) + assert.True(t, second.CompletedAt.Equal(storedAfterFailure.CompletedAt)) + assert.True(t, second.UpdatedAt.Equal(storedAfterFailure.UpdatedAt)) +} + +func TestValidateLegacyDatasetRejectsUnsafeIdentityGraphs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sessions func() []*legacySessionRow + prompts func() []*legacyPromptRow + want string + }{ + { + name: "duplicate session id", + sessions: func() []*legacySessionRow { + return []*legacySessionRow{ + {Path: "/one", ID: "duplicate", Source: "codex"}, + {Path: "/two", ID: "duplicate", Source: "claude"}, + } + }, + prompts: func() []*legacyPromptRow { return nil }, + want: `duplicate legacy session id "duplicate"`, + }, + { + name: "native UUID mapping collision", + sessions: func() []*legacySessionRow { + opaque := "opaque" + return []*legacySessionRow{ + {Path: "/opaque", ID: opaque, Source: "codex"}, + {Path: "/uuid", ID: legacyNativeSessionID(opaque).String(), Source: "codex"}, + } + }, + prompts: func() []*legacyPromptRow { return nil }, + want: "UUID mapping collision", + }, + { + name: "orphan parent", + sessions: func() []*legacySessionRow { + return []*legacySessionRow{{Path: "/child", ID: "child", ParentID: "missing", Source: "codex"}} + }, + prompts: func() []*legacyPromptRow { return nil }, + want: `references missing parent "missing"`, + }, + { + name: "parent cycle", + sessions: func() []*legacySessionRow { + return []*legacySessionRow{ + {Path: "/one", ID: "one", ParentID: "two", Source: "codex"}, + {Path: "/two", ID: "two", ParentID: "one", Source: "codex"}, + } + }, + prompts: func() []*legacyPromptRow { return nil }, + want: "parent cycle", + }, + { + name: "cross-source parent", + sessions: func() []*legacySessionRow { + return []*legacySessionRow{ + {Path: "/root", ID: "root", Source: "claude"}, + {Path: "/child", ID: "child", ParentID: "root", Source: "codex"}, + } + }, + prompts: func() []*legacyPromptRow { return nil }, + want: `source "codex" differs from parent "root" source "claude"`, + }, + { + name: "orphan prompt", + sessions: func() []*legacySessionRow { + return []*legacySessionRow{{Path: "/root", ID: "root", Source: "codex"}} + }, + prompts: func() []*legacyPromptRow { + return []*legacyPromptRow{{SessionID: "missing", RunID: "run"}} + }, + want: `legacy prompt for session "missing" has no archived session`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateLegacyDataset(tt.sessions(), tt.prompts()) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +type cutoverColumnSignature struct { + Name string + DataType string + Nullable string + DefaultSQL string +} + +type cutoverNativeSession struct { + ProviderSessionID string + Source string + Provider string + HostID string + Path string + ParentID uuid.NullUUID + RootID uuid.NullUUID + LifecycleStatus string + StateReason string + Metadata []byte +} + +type cutoverNativeSessionSource struct { + ID uuid.UUID + SessionID uuid.UUID + SourceKind string + Path string + SourceIdentity string + ParserVersion int + ByteOffset int64 + ObservedSize int64 + ObservedModTime time.Time +} + +func legacyCutoverTestDSN(t *testing.T) string { + t.Helper() + if dsn := os.Getenv("CAPTAIN_DB_TEST_DSN"); dsn != "" { + return dsn + } + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres legacy cutover tests") + } + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_legacy_cutover", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + return dsn +} + +func createHistoricalLegacySessionCache(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `CREATE TABLE public.captain_sessions ( + path text PRIMARY KEY, + id text, + parent_id text, + source text, + is_agent boolean, + agent_type text, + agent_desc text, + mod_unix bigint, + size bigint, + project text, + cwd text, + model text, + git jsonb, + provider jsonb, + started_at timestamptz, + ended_at timestamptz, + cost jsonb, + usage jsonb, + files jsonb, + approvals jsonb, + tool_calls bigint, + message_count bigint, + context_tokens bigint, + slug text, + plan_path text, + plan_slug text, + plan jsonb, + updated_at timestamptz + ); + CREATE INDEX idx_captain_sessions_id ON public.captain_sessions(id); + CREATE INDEX idx_captain_sessions_parent_id ON public.captain_sessions(parent_id); + CREATE TABLE public.captain_session_prompts ( + session_id text PRIMARY KEY, + run_id text, + model text, + backend text, + realized jsonb, + created_at timestamptz + )`) + if err != nil { + return err + } + + rootID := "71d6e735-e02c-46ef-a1bd-e4aff85a27fe" + started := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC) + ended := started.Add(5 * time.Minute) + updated := ended.Add(time.Second) + _, err = db.ExecContext(ctx, `INSERT INTO public.captain_sessions ( + path, id, parent_id, source, is_agent, agent_type, agent_desc, + mod_unix, size, project, cwd, model, git, provider, started_at, ended_at, + cost, usage, files, approvals, tool_calls, message_count, context_tokens, + slug, plan_path, plan_slug, plan, updated_at + ) VALUES + ($1,$2,NULL,'codex',false,'root','root session',1000000501,101,'gavel','/repo','codex-root', + '{"branch":"main"}'::jsonb,'{}'::jsonb,$3,$4, + '{"inputCost":1.25,"outputCost":0.5}'::jsonb,'{"inputTokens":100,"outputTokens":20}'::jsonb, + '{"read":["go.mod"]}'::jsonb,'{"approved":1,"denied":0}'::jsonb,2,4,120,'root-slug',NULL,NULL,NULL,$5), + ($6,$7,$2,'codex',true,'worker','database child',2000000999,202,'gavel','/repo','legacy-model', + '{"branch":"feature"}'::jsonb,'{"name":"openai","version":"1.2.3"}'::jsonb,$3,$4, + '{"inputCost":0.25,"outputCost":0.1}'::jsonb,'{"inputTokens":30,"outputTokens":10}'::jsonb, + '{}'::jsonb,'{"approved":0,"denied":1}'::jsonb,1,2,40,'child-slug',NULL,NULL,NULL,$5)`, + "/legacy/root.jsonl", rootID, started, ended, updated, + "/legacy/child.jsonl", "opaque-child-session", + ) + if err != nil { + return err + } + _, err = db.ExecContext(ctx, `INSERT INTO public.captain_session_prompts + (session_id, run_id, model, backend, realized, created_at) + VALUES ($1, 'opaque-prompt-run', 'legacy-model', 'codex_cli', + '{"id":"legacy-prompt","name":"Legacy Prompt","input":{"prompt":{"user":"preserve this realized prompt"}}}'::jsonb, + $2)`, "opaque-child-session", updated) + return err +} + +func createUnrelatedSentinel(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `CREATE TABLE public.unrelated_gavel_data ( + id bigint PRIMARY KEY, + payload text NOT NULL + ); + INSERT INTO public.unrelated_gavel_data(id, payload) VALUES (1, 'preserve unrelated data')`) + return err +} + +func readJSONRows(t *testing.T, ctx context.Context, db *sql.DB, table, orderColumn string) []string { + t.Helper() + rows, err := db.QueryContext(ctx, fmt.Sprintf(`SELECT to_jsonb(row_value)::text + FROM public.%s AS row_value ORDER BY %s`, table, orderColumn)) + require.NoError(t, err) + defer rows.Close() + var values []string + for rows.Next() { + var value string + require.NoError(t, rows.Scan(&value)) + values = append(values, value) + } + require.NoError(t, rows.Err()) + return values +} + +func readColumnSignatures(t *testing.T, ctx context.Context, db *sql.DB, table string) []cutoverColumnSignature { + t.Helper() + rows, err := db.QueryContext(ctx, `SELECT column_name, data_type, is_nullable, + COALESCE(column_default, '') + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position`, table) + require.NoError(t, err) + defer rows.Close() + var signatures []cutoverColumnSignature + for rows.Next() { + var signature cutoverColumnSignature + require.NoError(t, rows.Scan(&signature.Name, &signature.DataType, &signature.Nullable, &signature.DefaultSQL)) + signatures = append(signatures, signature) + } + require.NoError(t, rows.Err()) + return signatures +} + +func readPrimaryKeyColumns(t *testing.T, ctx context.Context, db *sql.DB, table string) []string { + t.Helper() + rows, err := db.QueryContext(ctx, `SELECT attribute.attname + FROM pg_catalog.pg_index index + JOIN pg_catalog.pg_class relation ON relation.oid = index.indrelid + JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace + JOIN LATERAL unnest(index.indkey) WITH ORDINALITY AS key(attnum, ordinal) ON true + JOIN pg_catalog.pg_attribute attribute ON attribute.attrelid = relation.oid AND attribute.attnum = key.attnum + WHERE namespace.nspname = 'public' AND relation.relname = $1 AND index.indisprimary + ORDER BY key.ordinal`, table) + require.NoError(t, err) + defer rows.Close() + var columns []string + for rows.Next() { + var column string + require.NoError(t, rows.Scan(&column)) + columns = append(columns, column) + } + require.NoError(t, rows.Err()) + return columns +} + +func readNativeSession(t *testing.T, ctx context.Context, db *sql.DB, id uuid.UUID) cutoverNativeSession { + t.Helper() + var session cutoverNativeSession + require.NoError(t, db.QueryRowContext(ctx, `SELECT provider_session_id, source, provider, host_id, + path, parent_session_id, root_session_id, lifecycle_status, state_reason, metadata + FROM public.captain_sessions WHERE id = $1`, id).Scan( + &session.ProviderSessionID, &session.Source, &session.Provider, &session.HostID, + &session.Path, &session.ParentID, &session.RootID, &session.LifecycleStatus, + &session.StateReason, &session.Metadata, + )) + return session +} + +func readNativeSessionSources(t *testing.T, ctx context.Context, db *sql.DB) map[string]cutoverNativeSessionSource { + t.Helper() + rows, err := db.QueryContext(ctx, `SELECT id, session_id, source_kind, path, source_identity, + parser_version, byte_offset, observed_size, observed_mod_time + FROM public.captain_session_sources ORDER BY path`) + require.NoError(t, err) + defer rows.Close() + sources := map[string]cutoverNativeSessionSource{} + for rows.Next() { + var source cutoverNativeSessionSource + require.NoError(t, rows.Scan( + &source.ID, &source.SessionID, &source.SourceKind, &source.Path, &source.SourceIdentity, + &source.ParserVersion, &source.ByteOffset, &source.ObservedSize, &source.ObservedModTime, + )) + sources[source.Path] = source + } + require.NoError(t, rows.Err()) + return sources +} + +func nestedMetadataString(t *testing.T, raw []byte, object, key string) string { + t.Helper() + var metadata map[string]any + require.NoError(t, json.Unmarshal(raw, &metadata)) + nested, ok := metadata[object].(map[string]any) + require.True(t, ok, "metadata object %q: %s", object, raw) + value, ok := nested[key].(string) + require.True(t, ok, "metadata string %q.%q: %s", object, key, raw) + return value +} + +func relationExistsForTest(t *testing.T, ctx context.Context, db *sql.DB, table string) bool { + t.Helper() + var exists bool + require.NoError(t, db.QueryRowContext(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, table).Scan(&exists)) + return exists +} + +func columnExistsForTest(t *testing.T, ctx context.Context, db *sql.DB, table, column string) bool { + t.Helper() + var exists bool + require.NoError(t, db.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 + )`, table, column).Scan(&exists)) + return exists +} diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index 00d1b7f..9cd8ebb 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -33,6 +33,9 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { `column "activity_state"`, `column "health_state"`, `column "state_version"`, + `table "captain_legacy_session_cutovers"`, + `column "legacy_sessions_checksum"`, + `column "native_sessions_checksum"`, ) assertContainsAll(t, "20_prompt_runs_and_plans.pg.hcl", `table "captain_prompt_runs"`, diff --git a/pkg/database/database.go b/pkg/database/database.go index de9ae83..869ac72 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -95,6 +95,14 @@ func Migrate(ctx context.Context, dsn string) error { return migrations.Apply(ctx, dsn) } +// MigrateWithLegacySessionCutover explicitly archives and backfills the +// path-keyed session summary cache before applying Captain's authoritative HCL +// schema. Normal Migrate remains fail-closed when it encounters the legacy +// shape so hosts must opt into the data conversion deliberately. +func MigrateWithLegacySessionCutover(ctx context.Context, dsn string) (*migrations.LegacySessionCutoverReport, error) { + return migrations.ApplyWithLegacySessionCutover(ctx, dsn) +} + // Gorm returns the application pool supplied to or opened by Captain. func (db *DB) Gorm() *gorm.DB { if db == nil { From b43845aacbaac8dd88294e905c7cf69500629762 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 09:17:24 +0300 Subject: [PATCH 033/131] feat(database): add process metrics and message source-line columns --- migrations/10_sessions.pg.hcl | 26 ++++++++++++++++++++++++++ migrations/30_execution.pg.hcl | 7 +++++++ migrations/50_views_and_triggers.sql | 6 ++++++ 3 files changed, 39 insertions(+) diff --git a/migrations/10_sessions.pg.hcl b/migrations/10_sessions.pg.hcl index bb1fa6f..344f6ee 100644 --- a/migrations/10_sessions.pg.hcl +++ b/migrations/10_sessions.pg.hcl @@ -227,6 +227,29 @@ table "captain_session_processes" { type = jsonb default = sql("'{}'::jsonb") } + column "source" { + null = false + type = text + default = "" + } + column "cpu_percent" { + null = false + type = numeric(6, 2) + default = 0 + } + column "memory_percent" { + null = false + type = numeric(6, 2) + default = 0 + } + column "memory_rss_bytes" { + null = true + type = bigint + } + column "sampled_at" { + null = true + type = timestamptz + } column "last_heartbeat_at" { null = true type = timestamptz @@ -292,6 +315,9 @@ table "captain_session_processes" { check "captain_session_processes_time_order" { expr = "ended_at IS NULL OR ended_at >= process_started_at" } + check "captain_session_processes_metrics_nonnegative" { + expr = "cpu_percent >= 0 AND memory_percent >= 0 AND (memory_rss_bytes IS NULL OR memory_rss_bytes >= 0)" + } } // One durable validation row is retained for the explicit legacy cache diff --git a/migrations/30_execution.pg.hcl b/migrations/30_execution.pg.hcl index 4d6062f..fa41e8d 100644 --- a/migrations/30_execution.pg.hcl +++ b/migrations/30_execution.pg.hcl @@ -353,6 +353,10 @@ table "captain_messages" { null = true type = jsonb } + column "source_line" { + null = true + type = bigint + } column "schema_version" { null = false type = integer @@ -416,6 +420,9 @@ table "captain_messages" { check "captain_messages_schema_version_positive" { expr = "schema_version > 0" } + check "captain_messages_source_line_positive" { + expr = "source_line IS NULL OR source_line > 0" + } } table "captain_events" { diff --git a/migrations/50_views_and_triggers.sql b/migrations/50_views_and_triggers.sql index 57c458c..491d016 100644 --- a/migrations/50_views_and_triggers.sql +++ b/migrations/50_views_and_triggers.sql @@ -660,6 +660,11 @@ SELECT process.command, process.cwd AS process_cwd, process.surface, + process.source AS process_source, + process.cpu_percent, + process.memory_percent, + process.memory_rss_bytes, + process.sampled_at AS process_sampled_at, process.process_started_at, process.last_heartbeat_at, process.lease_owner, @@ -829,6 +834,7 @@ SELECT m.role, m.parts, m.raw, + m.source_line, m.schema_version, m.occurred_at, m.recorded_at, From 55c681deef2a285f1f3064c2b6071887e27a43ca Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 09:26:29 +0300 Subject: [PATCH 034/131] feat(database): add transcript ingest, process, and overview read stores --- pkg/database/session_ingest_store.go | 463 ++++++++++++++++++ .../session_ingest_store_integration_test.go | 267 ++++++++++ pkg/database/session_process_store.go | 126 +++++ pkg/database/session_read_store.go | 243 +++++++++ 4 files changed, 1099 insertions(+) create mode 100644 pkg/database/session_ingest_store.go create mode 100644 pkg/database/session_ingest_store_integration_test.go create mode 100644 pkg/database/session_process_store.go create mode 100644 pkg/database/session_read_store.go diff --git a/pkg/database/session_ingest_store.go b/pkg/database/session_ingest_store.go new file mode 100644 index 0000000..bec3178 --- /dev/null +++ b/pkg/database/session_ingest_store.go @@ -0,0 +1,463 @@ +package database + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm/clause" +) + +var ErrInvalidIngest = errors.New("invalid Captain transcript ingest") + +type TurnStatus string + +const ( + TurnStatusOpen TurnStatus = "open" + TurnStatusEnded TurnStatus = "ended" + TurnStatusError TurnStatus = "error" + TurnStatusInterrupted TurnStatus = "interrupted" +) + +// SessionSourceState is the ingest bookkeeping for one transcript file, used to +// decide whether a file changed since it was last parsed and where to resume. +type SessionSourceState struct { + SessionID uuid.UUID `json:"sessionId"` + SourceKind string `json:"sourceKind"` + Path string `json:"path"` + ParserVersion int `json:"parserVersion"` + ByteOffset int64 `json:"byteOffset"` + ObservedSize int64 `json:"observedSize"` + ObservedModTime *time.Time `json:"observedModTime,omitempty"` + LastEventKey string `json:"lastEventKey,omitempty"` +} + +type sessionSourceRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()"` + SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` + SourceKind string `gorm:"column:source_kind"` + Path string `gorm:"column:path"` + SourceIdentity *string `gorm:"column:source_identity"` + ParserVersion int `gorm:"column:parser_version"` + ByteOffset int64 `gorm:"column:byte_offset"` + ObservedSize int64 `gorm:"column:observed_size"` + ObservedModTime *time.Time `gorm:"column:observed_mod_time"` + LastEventKey *string `gorm:"column:last_event_key"` +} + +func (sessionSourceRecord) TableName() string { return "captain_session_sources" } + +type turnRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()"` + SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` + ProviderTurnID *string `gorm:"column:provider_turn_id"` + TurnIndex int `gorm:"column:turn_index"` + Description *string `gorm:"column:description"` + Status TurnStatus `gorm:"column:status"` + StopReason *string `gorm:"column:stop_reason"` + StartedAt *time.Time `gorm:"column:started_at"` + EndedAt *time.Time `gorm:"column:ended_at"` +} + +func (turnRecord) TableName() string { return "captain_turns" } + +type modelCallRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()"` + TurnID uuid.UUID `gorm:"column:turn_id;type:uuid"` + CallIndex int `gorm:"column:call_index"` + Model string `gorm:"column:model"` + Backend string `gorm:"column:backend"` + Effort *string `gorm:"column:effort"` + Status string `gorm:"column:status"` + StopReason *string `gorm:"column:stop_reason"` + InputTokens int64 `gorm:"column:input_tokens"` + OutputTokens int64 `gorm:"column:output_tokens"` + ReasoningTokens int64 `gorm:"column:reasoning_tokens"` + CacheReadTokens int64 `gorm:"column:cache_read_tokens"` + CacheWriteTokens int64 `gorm:"column:cache_write_tokens"` + ContextTokens int64 `gorm:"column:context_tokens"` + ContextWindowTokens int64 `gorm:"column:context_window_tokens"` + InputCost float64 `gorm:"column:input_cost"` + OutputCost float64 `gorm:"column:output_cost"` + CacheReadCost float64 `gorm:"column:cache_read_cost"` + CacheWriteCost float64 `gorm:"column:cache_write_cost"` + StartedAt *time.Time `gorm:"column:started_at"` + EndedAt *time.Time `gorm:"column:ended_at"` +} + +func (modelCallRecord) TableName() string { return "captain_model_calls" } + +type messageRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()"` + SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` + TurnID *uuid.UUID `gorm:"column:turn_id;type:uuid"` + ProviderMessageID *string `gorm:"column:provider_message_id"` + Sequence int64 `gorm:"column:sequence"` + Role string `gorm:"column:role"` + Parts []byte `gorm:"column:parts;type:jsonb"` + Raw []byte `gorm:"column:raw;type:jsonb"` + SourceLine *int64 `gorm:"column:source_line"` + OccurredAt *time.Time `gorm:"column:occurred_at"` +} + +func (messageRecord) TableName() string { return "captain_messages" } + +// IngestSessionInput identifies and projects the session one transcript file +// belongs to. Agent transcripts are child sessions of the root transcript's +// session via ParentSessionID. +type IngestSessionInput struct { + ProviderSessionID string + Source string + HostID string + ParentSessionID *uuid.UUID + Path string + Project string + CWD string + Title string + InitialPrompt string + Slug string + AgentType string + Description string + CLIVersion string + StartedAt *time.Time + LastActivityAt *time.Time + Git map[string]any + Metadata map[string]any +} + +// IngestSourceInput is the post-batch bookkeeping for the transcript file. +type IngestSourceInput struct { + SourceKind string + Path string + SourceIdentity string + ParserVersion int + ByteOffset int64 + ObservedSize int64 + ObservedModTime time.Time + LastEventKey string +} + +type IngestModelCall struct { + Model string + Backend string + Effort string + StopReason string + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 + ContextTokens int64 + ContextWindowTokens int64 + InputCost float64 + OutputCost float64 + CacheReadCost float64 + CacheWriteCost float64 + StartedAt *time.Time + EndedAt *time.Time +} + +// IngestTurn is one model turn. Call carries the turn-aggregate usage/cost as a +// single model call at call_index 0; re-ingesting an index updates it in place +// so an append-extended final turn converges. +type IngestTurn struct { + Index int + ProviderTurnID string + Description string + Status TurnStatus + StopReason string + StartedAt *time.Time + EndedAt *time.Time + Call *IngestModelCall +} + +// IngestMessage is one transcript message. Sequence must be stable and unique +// per session; the transcript line number satisfies both and doubles as the +// file seek reference surfaced to the UI. +type IngestMessage struct { + Sequence int64 + ProviderMessageID string + Role string + TurnIndex *int + PartsJSON []byte + RawJSON []byte + SourceLine int64 + OccurredAt *time.Time +} + +type IngestTranscriptInput struct { + Session IngestSessionInput + Source IngestSourceInput + Turns []IngestTurn + Messages []IngestMessage +} + +// ListSessionSources returns every transcript bookkeeping row keyed by path so +// a scanner can diff os.Stat results in one round trip. +func (db *DB) ListSessionSources(ctx context.Context) (map[string]SessionSourceState, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + var records []sessionSourceRecord + if err := db.gorm.WithContext(ctx).Find(&records).Error; err != nil { + return nil, fmt.Errorf("list Captain session sources: %w", err) + } + out := make(map[string]SessionSourceState, len(records)) + for _, r := range records { + out[r.Path] = SessionSourceState{ + SessionID: r.SessionID, SourceKind: r.SourceKind, Path: r.Path, + ParserVersion: r.ParserVersion, ByteOffset: r.ByteOffset, ObservedSize: r.ObservedSize, + ObservedModTime: r.ObservedModTime, LastEventKey: optionalString(r.LastEventKey), + } + } + return out, nil +} + +// IngestTranscript transactionally persists one parsed transcript batch: the +// owning session row, the file's bookkeeping row, and idempotent turn, model +// call, and message rows. Re-running the same batch changes nothing; an +// extended batch only adds or converges rows. +func (db *DB) IngestTranscript(ctx context.Context, input IngestTranscriptInput) (*Session, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if err := validateIngest(input); err != nil { + return nil, err + } + var session *Session + err := db.Transaction(ctx, func(tx *DB) error { + var err error + session, err = tx.CreateOrGetSession(ctx, CreateSessionInput{ + ProviderSessionID: input.Session.ProviderSessionID, + Source: input.Session.Source, + HostID: input.Session.HostID, + ParentSessionID: input.Session.ParentSessionID, + Path: input.Session.Path, + Project: input.Session.Project, + CWD: input.Session.CWD, + Title: input.Session.Title, + InitialPrompt: input.Session.InitialPrompt, + Slug: input.Session.Slug, + AgentType: input.Session.AgentType, + Description: input.Session.Description, + CLIVersion: input.Session.CLIVersion, + }) + if err != nil { + return err + } + if err := tx.projectSessionColumns(ctx, session.ID, input.Session); err != nil { + return err + } + if err := tx.upsertSessionSource(ctx, session.ID, input.Source); err != nil { + return err + } + turnIDs, err := tx.upsertTurns(ctx, session.ID, input.Turns) + if err != nil { + return err + } + return tx.insertMessages(ctx, session.ID, turnIDs, input.Messages) + }) + if err != nil { + return nil, err + } + return session, nil +} + +func validateIngest(input IngestTranscriptInput) error { + if strings.TrimSpace(input.Session.ProviderSessionID) == "" { + return fmt.Errorf("%w: provider session ID is required", ErrInvalidIngest) + } + if strings.TrimSpace(input.Source.Path) == "" || strings.TrimSpace(input.Source.SourceKind) == "" { + return fmt.Errorf("%w: source kind and path are required", ErrInvalidIngest) + } + if input.Source.ParserVersion <= 0 { + return fmt.Errorf("%w: parser version must be positive", ErrInvalidIngest) + } + for _, m := range input.Messages { + if m.Sequence < 0 || strings.TrimSpace(m.Role) == "" { + return fmt.Errorf("%w: message sequence %d needs a nonnegative sequence and role", ErrInvalidIngest, m.Sequence) + } + } + return nil +} + +// projectSessionColumns overwrites the monitor-owned projection columns with +// the freshly parsed values. Identity, hierarchy, and state-machine columns are +// never touched here. +func (db *DB) projectSessionColumns(ctx context.Context, id uuid.UUID, input IngestSessionInput) error { + updates := map[string]any{} + for column, value := range map[string]string{ + "path": input.Path, "project": input.Project, "cwd": input.CWD, "title": input.Title, + "initial_prompt": input.InitialPrompt, "slug": input.Slug, "agent_type": input.AgentType, + "description": input.Description, "cli_version": input.CLIVersion, + } { + if trimmed := strings.TrimSpace(value); trimmed != "" { + updates[column] = trimmed + } + } + if input.StartedAt != nil { + updates["started_at"] = *input.StartedAt + } + if input.LastActivityAt != nil { + updates["last_activity_at"] = *input.LastActivityAt + } + if input.Git != nil { + updates["git"] = jsonbValue(input.Git) + } + if input.Metadata != nil { + updates["metadata"] = jsonbValue(input.Metadata) + } + if len(updates) == 0 { + return nil + } + if err := db.gorm.WithContext(ctx).Model(&sessionRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return fmt.Errorf("project Captain session columns: %w", err) + } + return nil +} + +func (db *DB) upsertSessionSource(ctx context.Context, sessionID uuid.UUID, input IngestSourceInput) error { + record := sessionSourceRecord{ + ID: uuid.New(), SessionID: sessionID, SourceKind: strings.TrimSpace(input.SourceKind), + Path: strings.TrimSpace(input.Path), SourceIdentity: nullableTrimmed(input.SourceIdentity), + ParserVersion: input.ParserVersion, ByteOffset: input.ByteOffset, ObservedSize: input.ObservedSize, + LastEventKey: nullableTrimmed(input.LastEventKey), + } + if !input.ObservedModTime.IsZero() { + modTime := input.ObservedModTime.UTC() + record.ObservedModTime = &modTime + } + err := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_kind"}, {Name: "path"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "session_id", "source_identity", "parser_version", "byte_offset", + "observed_size", "observed_mod_time", "last_event_key", + }), + }).Create(&record).Error + if err != nil { + return fmt.Errorf("upsert Captain session source %s: %w", record.Path, err) + } + return nil +} + +// upsertTurns converges turn and per-turn aggregate model-call rows and returns +// the turn UUID for each ingested turn index so messages can reference turns. +func (db *DB) upsertTurns(ctx context.Context, sessionID uuid.UUID, turns []IngestTurn) (map[int]uuid.UUID, error) { + ids := make(map[int]uuid.UUID, len(turns)) + for _, turn := range turns { + if turn.Index < 0 { + return nil, fmt.Errorf("%w: turn index %d is negative", ErrInvalidIngest, turn.Index) + } + status := turn.Status + if status == "" { + status = TurnStatusEnded + } + record := turnRecord{ + ID: uuid.New(), SessionID: sessionID, TurnIndex: turn.Index, + ProviderTurnID: nullableTrimmed(turn.ProviderTurnID), Description: nullableTrimmed(turn.Description), + Status: status, StopReason: nullableTrimmed(turn.StopReason), + StartedAt: turn.StartedAt, EndedAt: turn.EndedAt, + } + err := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "session_id"}, {Name: "turn_index"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "provider_turn_id", "description", "status", "stop_reason", "started_at", "ended_at", + }), + }).Create(&record).Error + if err != nil { + return nil, fmt.Errorf("upsert Captain turn %d: %w", turn.Index, err) + } + var persisted turnRecord + if err := db.gorm.WithContext(ctx). + First(&persisted, "session_id = ? AND turn_index = ?", sessionID, turn.Index).Error; err != nil { + return nil, fmt.Errorf("read Captain turn %d: %w", turn.Index, err) + } + ids[turn.Index] = persisted.ID + if turn.Call != nil { + if err := db.upsertTurnCall(ctx, persisted.ID, *turn.Call); err != nil { + return nil, err + } + } + } + return ids, nil +} + +func (db *DB) upsertTurnCall(ctx context.Context, turnID uuid.UUID, call IngestModelCall) error { + model := strings.TrimSpace(call.Model) + if model == "" { + model = "unknown" + } + backend := strings.TrimSpace(call.Backend) + if backend == "" { + backend = "unknown" + } + record := modelCallRecord{ + ID: uuid.New(), TurnID: turnID, CallIndex: 0, Model: model, Backend: backend, + Effort: nullableTrimmed(call.Effort), Status: "succeeded", StopReason: nullableTrimmed(call.StopReason), + InputTokens: call.InputTokens, OutputTokens: call.OutputTokens, ReasoningTokens: call.ReasoningTokens, + CacheReadTokens: call.CacheReadTokens, CacheWriteTokens: call.CacheWriteTokens, + ContextTokens: call.ContextTokens, ContextWindowTokens: call.ContextWindowTokens, + InputCost: call.InputCost, OutputCost: call.OutputCost, + CacheReadCost: call.CacheReadCost, CacheWriteCost: call.CacheWriteCost, + StartedAt: call.StartedAt, EndedAt: call.EndedAt, + } + err := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "turn_id"}, {Name: "call_index"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "model", "backend", "effort", "stop_reason", + "input_tokens", "output_tokens", "reasoning_tokens", "cache_read_tokens", "cache_write_tokens", + "context_tokens", "context_window_tokens", + "input_cost", "output_cost", "cache_read_cost", "cache_write_cost", + "started_at", "ended_at", + }), + }).Create(&record).Error + if err != nil { + return fmt.Errorf("upsert Captain model call for turn %s: %w", turnID, err) + } + return nil +} + +// insertMessages appends message rows. Messages are immutable, so replays and +// overlapping batches are dropped by the (session_id, sequence) key. +func (db *DB) insertMessages(ctx context.Context, sessionID uuid.UUID, turnIDs map[int]uuid.UUID, messages []IngestMessage) error { + for _, message := range messages { + record := messageRecord{ + ID: uuid.New(), SessionID: sessionID, + ProviderMessageID: nullableTrimmed(message.ProviderMessageID), + Sequence: message.Sequence, Role: strings.TrimSpace(message.Role), + Parts: message.PartsJSON, Raw: message.RawJSON, OccurredAt: message.OccurredAt, + } + if len(record.Parts) == 0 { + record.Parts = []byte("[]") + } + if message.SourceLine > 0 { + line := message.SourceLine + record.SourceLine = &line + } + if message.TurnIndex != nil { + if turnID, ok := turnIDs[*message.TurnIndex]; ok { + record.TurnID = &turnID + } + } + err := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "session_id"}, {Name: "sequence"}}, + DoNothing: true, + }).Create(&record).Error + if err != nil { + return fmt.Errorf("insert Captain message seq %d: %w", message.Sequence, err) + } + } + return nil +} + +func jsonbValue(value map[string]any) any { + encoded, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(encoded) +} diff --git a/pkg/database/session_ingest_store_integration_test.go b/pkg/database/session_ingest_store_integration_test.go new file mode 100644 index 0000000..e0e0688 --- /dev/null +++ b/pkg/database/session_ingest_store_integration_test.go @@ -0,0 +1,267 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testProviderSessionID = "0195c1de-4ab8-7000-8000-0123456789ab" + testTranscriptPath = "/home/dev/.claude/projects/example/session.jsonl" + testParserVersion = 1 +) + +func openIngestTestDB(t *testing.T) *DB { + t.Helper() + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres store tests") + } + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_ingest_stores", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + db, err := Open(t.Context(), Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return db +} + +func testIngestBatch(modTime time.Time, byteOffset int64) IngestTranscriptInput { + started := modTime.Add(-10 * time.Minute) + turn0End := started.Add(1 * time.Minute) + turnIdx0, turnIdx1 := 0, 1 + return IngestTranscriptInput{ + Session: IngestSessionInput{ + ProviderSessionID: testProviderSessionID, + Source: "claude", + HostID: "test-host", + Path: testTranscriptPath, + Project: "example", + CWD: "/home/dev/example", + Title: "Ingest fixture session", + Slug: "ingest-fixture", + CLIVersion: "2.1.0", + StartedAt: &started, + LastActivityAt: &modTime, + Git: map[string]any{"branch": "main"}, + Metadata: map[string]any{"providerName": "Claude Code"}, + }, + Source: IngestSourceInput{ + SourceKind: "claude", Path: testTranscriptPath, SourceIdentity: testProviderSessionID, + ParserVersion: testParserVersion, ByteOffset: byteOffset, ObservedSize: byteOffset, + ObservedModTime: modTime, LastEventKey: "uuid-7", + }, + Turns: []IngestTurn{ + { + Index: 0, ProviderTurnID: "turn-0", Status: TurnStatusEnded, + StartedAt: &started, EndedAt: &turn0End, + Call: &IngestModelCall{ + Model: "claude-sonnet-5", Backend: "claude", InputTokens: 1000, OutputTokens: 200, + CacheReadTokens: 5000, ContextTokens: 40000, ContextWindowTokens: 200000, + InputCost: 0.003, OutputCost: 0.003, + }, + }, + { + Index: 1, ProviderTurnID: "turn-1", Status: TurnStatusEnded, + StartedAt: &turn0End, EndedAt: &modTime, + Call: &IngestModelCall{ + Model: "claude-sonnet-5", Backend: "claude", InputTokens: 2000, OutputTokens: 400, + CacheReadTokens: 8000, ContextTokens: 60000, ContextWindowTokens: 200000, + InputCost: 0.006, OutputCost: 0.006, + }, + }, + }, + Messages: []IngestMessage{ + {Sequence: 1, ProviderMessageID: "uuid-1", Role: "user", TurnIndex: &turnIdx0, + PartsJSON: []byte(`[{"type":"text","text":"hello"}]`), SourceLine: 1, OccurredAt: &started}, + {Sequence: 3, ProviderMessageID: "uuid-3", Role: "assistant", TurnIndex: &turnIdx0, + PartsJSON: []byte(`[{"type":"text","text":"hi"},{"type":"dynamic-tool","toolName":"Read","toolCallId":"t1"}]`), + SourceLine: 3, OccurredAt: &turn0End}, + {Sequence: 5, ProviderMessageID: "uuid-5", Role: "user", TurnIndex: &turnIdx1, + PartsJSON: []byte(`[{"type":"text","text":"next"}]`), SourceLine: 5}, + {Sequence: 7, ProviderMessageID: "uuid-7", Role: "assistant", TurnIndex: &turnIdx1, + PartsJSON: []byte(`[{"type":"text","text":"done"}]`), SourceLine: 7, OccurredAt: &modTime}, + }, + } +} + +func TestIngestTranscriptAndReadStores(t *testing.T) { + db := openIngestTestDB(t) + modTime := time.Now().UTC().Truncate(time.Second) + + session, err := db.IngestTranscript(t.Context(), testIngestBatch(modTime, 4096)) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, session.ID) + + t.Run("overview aggregates from ingested turns and messages", func(t *testing.T) { + overview, err := db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + require.NoError(t, err) + assert.Equal(t, session.ID, overview.ID) + assert.EqualValues(t, 4, overview.MessageCount) + assert.EqualValues(t, 1, overview.ToolCallCount) + assert.EqualValues(t, 2, overview.TurnCount) + assert.EqualValues(t, 3000, overview.InputTokens) + assert.EqualValues(t, 600, overview.OutputTokens) + assert.EqualValues(t, 13000, overview.CacheReadTokens) + assert.InDelta(t, 0.018, overview.CostUSD, 1e-9) + require.NotNil(t, overview.ContextFreePercent) + assert.Equal(t, 70, *overview.ContextFreePercent, "latest call: 1-60000/200000 = 70%") + require.NotNil(t, overview.Model) + assert.Equal(t, "claude-sonnet-5", *overview.Model) + require.NotNil(t, overview.HistoryFile) + assert.Equal(t, testTranscriptPath, *overview.HistoryFile) + assert.False(t, overview.ProcessActive) + }) + + t.Run("prefix identity resolves and full re-ingest is idempotent", func(t *testing.T) { + byPrefix, err := db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID[:8]) + require.NoError(t, err) + assert.Equal(t, session.ID, byPrefix.ID) + + replayed, err := db.IngestTranscript(t.Context(), testIngestBatch(modTime, 4096)) + require.NoError(t, err) + assert.Equal(t, session.ID, replayed.ID) + overview, err := db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + require.NoError(t, err) + assert.EqualValues(t, 4, overview.MessageCount, "replay must not duplicate messages") + assert.EqualValues(t, 2, overview.TurnCount, "replay must not duplicate turns") + assert.EqualValues(t, 3000, overview.InputTokens, "replay must not duplicate model calls") + }) + + t.Run("session sources bookkeeping is listed by path", func(t *testing.T) { + sources, err := db.ListSessionSources(t.Context()) + require.NoError(t, err) + state, ok := sources[testTranscriptPath] + require.True(t, ok) + assert.Equal(t, session.ID, state.SessionID) + assert.EqualValues(t, 4096, state.ByteOffset) + assert.Equal(t, testParserVersion, state.ParserVersion) + require.NotNil(t, state.ObservedModTime) + assert.WithinDuration(t, modTime, *state.ObservedModTime, time.Second) + }) + + t.Run("extended batch converges the final turn and appends messages", func(t *testing.T) { + later := modTime.Add(2 * time.Minute) + extended := testIngestBatch(later, 8192) + extended.Turns[1].EndedAt = &later + extended.Turns[1].Call.InputTokens = 2500 + extended.Turns[1].Call.OutputTokens = 700 + turnIdx1 := 1 + extended.Messages = append(extended.Messages, IngestMessage{ + Sequence: 9, ProviderMessageID: "uuid-9", Role: "assistant", TurnIndex: &turnIdx1, + PartsJSON: []byte(`[{"type":"text","text":"appended"}]`), SourceLine: 9, OccurredAt: &later, + }) + _, err := db.IngestTranscript(t.Context(), extended) + require.NoError(t, err) + + overview, err := db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + require.NoError(t, err) + assert.EqualValues(t, 5, overview.MessageCount) + assert.EqualValues(t, 2, overview.TurnCount) + assert.EqualValues(t, 3500, overview.InputTokens, "final turn call converges in place") + assert.EqualValues(t, 900, overview.OutputTokens) + + sources, err := db.ListSessionSources(t.Context()) + require.NoError(t, err) + assert.EqualValues(t, 8192, sources[testTranscriptPath].ByteOffset) + }) + + t.Run("transcript paging by offset, limit, and tail with source lines", func(t *testing.T) { + page, err := db.ListTranscriptMessages(t.Context(), TranscriptPage{SessionID: session.ID, Offset: 1, Limit: 2}) + require.NoError(t, err) + require.Len(t, page, 2) + assert.EqualValues(t, 3, page[0].Sequence) + assert.EqualValues(t, 5, page[1].Sequence) + require.NotNil(t, page[0].SourceLine) + assert.EqualValues(t, 3, *page[0].SourceLine) + + tail, err := db.ListTranscriptMessages(t.Context(), TranscriptPage{SessionID: session.ID, Tail: 2}) + require.NoError(t, err) + require.Len(t, tail, 2) + assert.EqualValues(t, 7, tail[0].Sequence) + assert.EqualValues(t, 9, tail[1].Sequence) + }) + + t.Run("agent transcript ingests as child session", func(t *testing.T) { + agentPath := filepath.Join(filepath.Dir(testTranscriptPath), "subagents", "agent-1.jsonl") + child := IngestTranscriptInput{ + Session: IngestSessionInput{ + ProviderSessionID: "agent-1", Source: "claude", HostID: "test-host", + ParentSessionID: &session.ID, Path: agentPath, AgentType: "Explore", + Description: "search the codebase", + }, + Source: IngestSourceInput{ + SourceKind: "claude", Path: agentPath, ParserVersion: testParserVersion, + ByteOffset: 100, ObservedSize: 100, ObservedModTime: modTime, + }, + Messages: []IngestMessage{ + {Sequence: 1, Role: "user", PartsJSON: []byte(`[{"type":"text","text":"task"}]`), SourceLine: 1}, + }, + } + childSession, err := db.IngestTranscript(t.Context(), child) + require.NoError(t, err) + require.NotNil(t, childSession.ParentSessionID) + assert.Equal(t, session.ID, *childSession.ParentSessionID) + + overview, err := db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + require.NoError(t, err) + assert.EqualValues(t, 2, overview.AgentCount) + + roots, err := db.ListSessionOverviews(t.Context(), SessionOverviewFilter{RootsOnly: true}) + require.NoError(t, err) + require.Len(t, roots, 1) + assert.Equal(t, session.ID, roots[0].ID) + }) + + t.Run("process lifecycle: upsert refreshes metrics, vanish closes", func(t *testing.T) { + processStart := modTime.Add(-5 * time.Minute) + input := SessionProcessInput{ + SessionID: session.ID, HostID: "test-host", BootID: "boot-1", PID: 4242, + ProcessStartedAt: processStart, Status: "running", Command: "claude --resume", + CWD: "/home/dev/example", Source: "claude", CPUPercent: 12.5, MemoryPercent: 3.25, + SampledAt: modTime, + } + require.NoError(t, db.UpsertSessionProcess(t.Context(), input)) + input.CPUPercent = 55.75 + require.NoError(t, db.UpsertSessionProcess(t.Context(), input)) + + overview, err := db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + require.NoError(t, err) + assert.True(t, overview.ProcessActive) + require.NotNil(t, overview.PID) + assert.EqualValues(t, 4242, *overview.PID) + require.NotNil(t, overview.CPUPercent) + assert.InDelta(t, 55.75, *overview.CPUPercent, 1e-9) + + live, err := db.ListSessionOverviews(t.Context(), SessionOverviewFilter{LiveOnly: true}) + require.NoError(t, err) + require.Len(t, live, 1) + + closed, err := db.EndVanishedProcesses(t.Context(), "test-host", []int64{9999}) + require.NoError(t, err) + assert.EqualValues(t, 1, closed) + overview, err = db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + require.NoError(t, err) + assert.False(t, overview.ProcessActive) + }) + + t.Run("project aggregates group sessions and live processes", func(t *testing.T) { + projects, err := db.ListProjectAggregates(t.Context()) + require.NoError(t, err) + require.Len(t, projects, 1) + assert.Equal(t, "example", projects[0].Project) + assert.EqualValues(t, 1, projects[0].SessionCount) + assert.EqualValues(t, 0, projects[0].LiveCount) + assert.Equal(t, "claude", projects[0].Sources) + }) +} diff --git a/pkg/database/session_process_store.go b/pkg/database/session_process_store.go new file mode 100644 index 0000000..eac710c --- /dev/null +++ b/pkg/database/session_process_store.go @@ -0,0 +1,126 @@ +package database + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm/clause" +) + +var ErrInvalidSessionProcess = errors.New("invalid Captain session process") + +// SessionProcessInput is one live-process observation. The identity key is +// (HostID, BootID, PID, ProcessStartedAt); repeated polls converge on one row. +type SessionProcessInput struct { + SessionID uuid.UUID + HostID string + BootID string + PID int64 + ProcessStartedAt time.Time + Status string + Command string + CWD string + Source string + CPUPercent float64 + MemoryPercent float64 + MemoryRSSBytes *int64 + SampledAt time.Time +} + +type sessionProcessRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()"` + SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` + HostID string `gorm:"column:host_id"` + BootID string `gorm:"column:boot_id"` + PID int64 `gorm:"column:pid"` + ProcessStartedAt time.Time `gorm:"column:process_started_at"` + Status string `gorm:"column:status"` + Command *string `gorm:"column:command"` + CWD *string `gorm:"column:cwd"` + Source string `gorm:"column:source"` + CPUPercent float64 `gorm:"column:cpu_percent"` + MemoryPercent float64 `gorm:"column:memory_percent"` + MemoryRSSBytes *int64 `gorm:"column:memory_rss_bytes"` + SampledAt *time.Time `gorm:"column:sampled_at"` + LastHeartbeatAt *time.Time `gorm:"column:last_heartbeat_at"` + EndedAt *time.Time `gorm:"column:ended_at"` +} + +func (sessionProcessRecord) TableName() string { return "captain_session_processes" } + +// UpsertSessionProcess records a live-process sample, creating the process row +// on first sight and refreshing metrics/status/heartbeat afterwards. +func (db *DB) UpsertSessionProcess(ctx context.Context, input SessionProcessInput) error { + if err := db.requireGorm(); err != nil { + return err + } + if input.SessionID == uuid.Nil { + return fmt.Errorf("%w: session ID is required", ErrInvalidSessionProcess) + } + if input.PID <= 0 || input.ProcessStartedAt.IsZero() { + return fmt.Errorf("%w: PID and process start time are required", ErrInvalidSessionProcess) + } + hostID := strings.TrimSpace(input.HostID) + if hostID == "" { + hostID = "local" + } + bootID := strings.TrimSpace(input.BootID) + if bootID == "" { + bootID = "unknown" + } + sampledAt := input.SampledAt + if sampledAt.IsZero() { + sampledAt = time.Now().UTC() + } + record := sessionProcessRecord{ + ID: uuid.New(), SessionID: input.SessionID, HostID: hostID, BootID: bootID, + PID: input.PID, ProcessStartedAt: input.ProcessStartedAt.UTC(), + Status: strings.TrimSpace(input.Status), Command: nullableTrimmed(input.Command), + CWD: nullableTrimmed(input.CWD), Source: strings.TrimSpace(input.Source), + CPUPercent: input.CPUPercent, MemoryPercent: input.MemoryPercent, + MemoryRSSBytes: input.MemoryRSSBytes, SampledAt: &sampledAt, LastHeartbeatAt: &sampledAt, + } + if record.Status == "" { + record.Status = "running" + } + err := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "host_id"}, {Name: "boot_id"}, {Name: "pid"}, {Name: "process_started_at"}, + }, + DoUpdates: clause.AssignmentColumns([]string{ + "status", "command", "cwd", "source", "cpu_percent", "memory_percent", + "memory_rss_bytes", "sampled_at", "last_heartbeat_at", + }), + }).Create(&record).Error + if err != nil { + return fmt.Errorf("upsert Captain session process pid %d: %w", input.PID, err) + } + return nil +} + +// EndVanishedProcesses marks every still-open process row on the host whose PID +// is absent from alivePIDs as ended, and returns how many rows were closed. +func (db *DB) EndVanishedProcesses(ctx context.Context, hostID string, alivePIDs []int64) (int64, error) { + if err := db.requireGorm(); err != nil { + return 0, err + } + hostID = strings.TrimSpace(hostID) + if hostID == "" { + hostID = "local" + } + now := time.Now().UTC() + query := db.gorm.WithContext(ctx).Model(&sessionProcessRecord{}). + Where("host_id = ? AND ended_at IS NULL", hostID) + if len(alivePIDs) > 0 { + query = query.Where("pid NOT IN ?", alivePIDs) + } + result := query.Updates(map[string]any{"ended_at": now, "status": "exited"}) + if result.Error != nil { + return 0, fmt.Errorf("end vanished Captain session processes: %w", result.Error) + } + return result.RowsAffected, nil +} diff --git a/pkg/database/session_read_store.go b/pkg/database/session_read_store.go new file mode 100644 index 0000000..18cff3b --- /dev/null +++ b/pkg/database/session_read_store.go @@ -0,0 +1,243 @@ +package database + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// SessionOverview is one row of the captain_session_overview view: the session +// identity/state plus its active process and usage/cost aggregates. +type SessionOverview struct { + ID uuid.UUID `gorm:"column:id" json:"id"` + ProviderSessionID *string `gorm:"column:provider_session_id" json:"providerSessionId,omitempty"` + Source string `gorm:"column:source" json:"source"` + Provider string `gorm:"column:provider" json:"provider,omitempty"` + HostID string `gorm:"column:host_id" json:"hostId"` + ParentSessionID *uuid.UUID `gorm:"column:parent_session_id" json:"parentSessionId,omitempty"` + RootSessionID *uuid.UUID `gorm:"column:root_session_id" json:"rootSessionId,omitempty"` + Path *string `gorm:"column:path" json:"path,omitempty"` + HistoryFile *string `gorm:"column:history_file" json:"historyFile,omitempty"` + Project *string `gorm:"column:project" json:"project,omitempty"` + CWD *string `gorm:"column:cwd" json:"cwd,omitempty"` + Title *string `gorm:"column:title" json:"title,omitempty"` + InitialPrompt *string `gorm:"column:initial_prompt" json:"initialPrompt,omitempty"` + Slug *string `gorm:"column:slug" json:"slug,omitempty"` + AgentType *string `gorm:"column:agent_type" json:"agentType,omitempty"` + Description *string `gorm:"column:description" json:"description,omitempty"` + CLIVersion *string `gorm:"column:cli_version" json:"cliVersion,omitempty"` + LifecycleStatus string `gorm:"column:lifecycle_status" json:"lifecycleStatus"` + ActivityState string `gorm:"column:activity_state" json:"activityState"` + HealthState string `gorm:"column:health_state" json:"healthState"` + Git json.RawMessage `gorm:"column:git" json:"git,omitempty"` + Metadata json.RawMessage `gorm:"column:metadata" json:"metadata,omitempty"` + StartedAt *time.Time `gorm:"column:started_at" json:"startedAt,omitempty"` + EndedAt *time.Time `gorm:"column:ended_at" json:"endedAt,omitempty"` + LastActivityAt *time.Time `gorm:"column:last_activity_at" json:"lastActivityAt,omitempty"` + DurationSeconds *float64 `gorm:"column:duration_seconds" json:"durationSeconds,omitempty"` + PID *int64 `gorm:"column:pid" json:"pid,omitempty"` + ProcessStatus *string `gorm:"column:process_status" json:"processStatus,omitempty"` + ProcessCommand *string `gorm:"column:command" json:"processCommand,omitempty"` + ProcessCWD *string `gorm:"column:process_cwd" json:"processCwd,omitempty"` + ProcessStartedAt *time.Time `gorm:"column:process_started_at" json:"processStartedAt,omitempty"` + ProcessActive bool `gorm:"column:process_active" json:"processActive"` + CPUPercent *float64 `gorm:"column:cpu_percent" json:"cpuPercent,omitempty"` + MemoryPercent *float64 `gorm:"column:memory_percent" json:"memoryPercent,omitempty"` + MemoryRSSBytes *int64 `gorm:"column:memory_rss_bytes" json:"memoryRssBytes,omitempty"` + MessageCount int64 `gorm:"column:message_count" json:"messageCount"` + ToolCallCount int64 `gorm:"column:tool_call_count" json:"toolCallCount"` + TurnCount int64 `gorm:"column:turn_count" json:"turnCount"` + AgentCount int64 `gorm:"column:agent_count" json:"agentCount"` + PromptRunCount int64 `gorm:"column:prompt_run_count" json:"promptRunCount"` + PlanCount int64 `gorm:"column:plan_count" json:"planCount"` + Model *string `gorm:"column:model" json:"model,omitempty"` + Backend *string `gorm:"column:backend" json:"backend,omitempty"` + Effort *string `gorm:"column:effort" json:"effort,omitempty"` + ContextTokens *int64 `gorm:"column:context_tokens" json:"contextTokens,omitempty"` + ContextWindowTokens *int64 `gorm:"column:context_window_tokens" json:"contextWindowTokens,omitempty"` + ContextFreePercent *int `gorm:"column:context_free_percent" json:"contextFreePercent,omitempty"` + InputTokens int64 `gorm:"column:input_tokens" json:"inputTokens"` + OutputTokens int64 `gorm:"column:output_tokens" json:"outputTokens"` + ReasoningTokens int64 `gorm:"column:reasoning_tokens" json:"reasoningTokens"` + CacheReadTokens int64 `gorm:"column:cache_read_tokens" json:"cacheReadTokens"` + CacheWriteTokens int64 `gorm:"column:cache_write_tokens" json:"cacheWriteTokens"` + TotalTokens int64 `gorm:"column:total_tokens" json:"totalTokens"` + CostUSD float64 `gorm:"column:cost_usd" json:"costUsd"` +} + +func (SessionOverview) TableName() string { return "captain_session_overview" } + +// SessionOverviewFilter narrows ListSessionOverviews. Zero values do not filter. +type SessionOverviewFilter struct { + Source string + Project string + RootsOnly bool + LiveOnly bool + Limit int +} + +func (db *DB) ListSessionOverviews(ctx context.Context, filter SessionOverviewFilter) ([]SessionOverview, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + query := db.gorm.WithContext(ctx).Model(&SessionOverview{}). + Order("COALESCE(last_activity_at, started_at, created_at) DESC, id DESC") + if source := strings.TrimSpace(filter.Source); source != "" { + query = query.Where("source = ?", source) + } + if project := strings.TrimSpace(filter.Project); project != "" { + query = query.Where("project = ?", project) + } + if filter.RootsOnly { + query = query.Where("parent_session_id IS NULL") + } + if filter.LiveOnly { + query = query.Where("process_active") + } + if filter.Limit > 0 { + query = query.Limit(filter.Limit) + } + var rows []SessionOverview + if err := query.Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain session overviews: %w", err) + } + return rows, nil +} + +// GetSessionOverviewByIdentity resolves a session UUID or a provider session ID +// prefix (the CLI accepts short IDs) to its overview row. Ambiguous prefixes +// are an error rather than an arbitrary pick. +func (db *DB) GetSessionOverviewByIdentity(ctx context.Context, identity string) (*SessionOverview, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + identity = strings.TrimSpace(identity) + if identity == "" { + return nil, fmt.Errorf("%w: identity is required", ErrInvalidSession) + } + if parsed, err := uuid.Parse(identity); err == nil { + var row SessionOverview + err := db.gorm.WithContext(ctx).First(&row, "id = ?", parsed).Error + if err == nil { + return &row, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("resolve Captain session overview UUID: %w", err) + } + } + var rows []SessionOverview + err := db.gorm.WithContext(ctx). + Where("provider_session_id LIKE ?", escapeLike(identity)+"%"). + Limit(3).Find(&rows).Error + if err != nil { + return nil, fmt.Errorf("resolve Captain session overview identity: %w", err) + } + if len(rows) == 0 { + return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, identity) + } + for i := range rows { + if optionalString(rows[i].ProviderSessionID) == identity { + return &rows[i], nil + } + } + if len(rows) > 1 { + return nil, fmt.Errorf("%w: session ID prefix %q is ambiguous", ErrSessionConflict, identity) + } + return &rows[0], nil +} + +// TranscriptMessage is one row of the captain_session_transcript view. +type TranscriptMessage struct { + ID uuid.UUID `gorm:"column:id" json:"id"` + SessionID uuid.UUID `gorm:"column:session_id" json:"sessionId"` + TurnID *uuid.UUID `gorm:"column:turn_id" json:"turnId,omitempty"` + ProviderMessageID *string `gorm:"column:provider_message_id" json:"providerMessageId,omitempty"` + Sequence int64 `gorm:"column:sequence" json:"sequence"` + Role string `gorm:"column:role" json:"role"` + Parts json.RawMessage `gorm:"column:parts" json:"parts"` + SourceLine *int64 `gorm:"column:source_line" json:"sourceLine,omitempty"` + OccurredAt *time.Time `gorm:"column:occurred_at" json:"occurredAt,omitempty"` + Model *string `gorm:"column:model" json:"model,omitempty"` +} + +func (TranscriptMessage) TableName() string { return "captain_session_transcript" } + +// TranscriptPage selects a message window: Offset/Limit from the start, or the +// last Tail messages when Tail > 0 (Tail wins over Offset). +type TranscriptPage struct { + SessionID uuid.UUID + Offset int + Limit int + Tail int +} + +func (db *DB) ListTranscriptMessages(ctx context.Context, page TranscriptPage) ([]TranscriptMessage, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + if page.SessionID == uuid.Nil { + return nil, fmt.Errorf("%w: session ID is required", ErrInvalidSession) + } + query := db.gorm.WithContext(ctx).Model(&TranscriptMessage{}).Where("session_id = ?", page.SessionID) + var rows []TranscriptMessage + if page.Tail > 0 { + if err := query.Order("sequence DESC").Limit(page.Tail).Find(&rows).Error; err != nil { + return nil, fmt.Errorf("tail Captain transcript messages: %w", err) + } + for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 { + rows[left], rows[right] = rows[right], rows[left] + } + return rows, nil + } + query = query.Order("sequence ASC").Offset(page.Offset) + if page.Limit > 0 { + query = query.Limit(page.Limit) + } + if err := query.Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain transcript messages: %w", err) + } + return rows, nil +} + +// ProjectAggregate summarizes one project's sessions for pickers/dashboards. +type ProjectAggregate struct { + Project string `gorm:"column:project" json:"project"` + SessionCount int64 `gorm:"column:session_count" json:"sessionCount"` + LiveCount int64 `gorm:"column:live_count" json:"liveCount"` + Sources string `gorm:"column:sources" json:"sources"` + LastActivityAt *time.Time `gorm:"column:last_activity_at" json:"lastActivityAt,omitempty"` +} + +func (db *DB) ListProjectAggregates(ctx context.Context) ([]ProjectAggregate, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + var rows []ProjectAggregate + err := db.gorm.WithContext(ctx).Raw(` + SELECT + s.project, + count(*) FILTER (WHERE s.parent_session_id IS NULL) AS session_count, + count(p.id) AS live_count, + string_agg(DISTINCT s.source, ',') AS sources, + max(s.last_activity_at) AS last_activity_at + FROM captain_sessions s + LEFT JOIN captain_session_processes p ON p.session_id = s.id AND p.ended_at IS NULL + WHERE s.project IS NOT NULL AND s.project <> '' + GROUP BY s.project + ORDER BY max(s.last_activity_at) DESC NULLS LAST`).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("list Captain project aggregates: %w", err) + } + return rows, nil +} + +func escapeLike(value string) string { + replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return replacer.Replace(value) +} From 5675183474ef260c6694706ade75f858d85f92e7 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 09:42:09 +0300 Subject: [PATCH 035/131] feat(monitor): add live session monitor with fsnotify tailing and incremental ingest --- go.mod | 2 +- pkg/claude/history.go | 3 + pkg/claude/reader.go | 2 + pkg/database/session_process_store.go | 63 ++++ pkg/monitor/backfill.go | 101 +++++++ pkg/monitor/ingest.go | 277 ++++++++++++++++++ pkg/monitor/monitor.go | 195 +++++++++++++ pkg/monitor/monitor_integration_test.go | 132 +++++++++ pkg/monitor/monitor_test.go | 105 +++++++ pkg/monitor/oneshot.go | 34 +++ pkg/monitor/process.go | 370 ++++++++++++++++++++++++ pkg/monitor/watch.go | 142 +++++++++ pkg/session/build_messages.go | 1 + pkg/session/message.go | 3 + pkg/session/transcript.go | 78 +++++ pkg/session/transcript_test.go | 74 +++++ 16 files changed, 1581 insertions(+), 1 deletion(-) create mode 100644 pkg/monitor/backfill.go create mode 100644 pkg/monitor/ingest.go create mode 100644 pkg/monitor/monitor.go create mode 100644 pkg/monitor/monitor_integration_test.go create mode 100644 pkg/monitor/monitor_test.go create mode 100644 pkg/monitor/oneshot.go create mode 100644 pkg/monitor/process.go create mode 100644 pkg/monitor/watch.go create mode 100644 pkg/session/transcript.go create mode 100644 pkg/session/transcript_test.go diff --git a/go.mod b/go.mod index e105494..8e1b7d9 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/flanksource/clicky/aichat v1.21.44 github.com/flanksource/commons v1.53.1 github.com/flanksource/sandbox-runtime v1.0.2 + github.com/fsnotify/fsnotify v1.9.0 github.com/google/dotprompt/go v0.0.0-20260502013637-5cd4a8405ca3 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 @@ -143,7 +144,6 @@ require ( github.com/flanksource/gomplate/v3 v3.24.84 // indirect github.com/flanksource/is-healthy v1.0.88 // indirect github.com/flanksource/kubectl-neat v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/geoffgarside/ber v1.1.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect diff --git a/pkg/claude/history.go b/pkg/claude/history.go index ea7c52c..5b6229e 100644 --- a/pkg/claude/history.go +++ b/pkg/claude/history.go @@ -27,6 +27,9 @@ type HistoryEntry struct { PlanFilePath string `json:"-"` Event *TranscriptEvent `json:"-"` RawLine json.RawMessage `json:"-"` + // Line is the 1-based JSONL line number the entry was read from, so + // downstream consumers can seek back into the transcript file. + Line int `json:"-"` } // TranscriptEvent is a non-message, non-tool transcript line. It lets callers diff --git a/pkg/claude/reader.go b/pkg/claude/reader.go index f5c1393..cd651ca 100644 --- a/pkg/claude/reader.go +++ b/pkg/claude/reader.go @@ -99,6 +99,7 @@ func readJSONL(r io.Reader, fallbackToHistoryEntry bool, opts ReadOptions) ([]Hi if opts.KeepRaw { entry.RawLine = append(json.RawMessage(nil), line...) } + entry.Line = lineNo entries = append(entries, entry) continue } @@ -110,6 +111,7 @@ func readJSONL(r io.Reader, fallbackToHistoryEntry bool, opts ReadOptions) ([]Hi if opts.KeepRaw { entry.RawLine = append(json.RawMessage(nil), line...) } + entry.Line = lineNo entries = append(entries, entry) } } diff --git a/pkg/database/session_process_store.go b/pkg/database/session_process_store.go index eac710c..2d5661d 100644 --- a/pkg/database/session_process_store.go +++ b/pkg/database/session_process_store.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -102,6 +103,68 @@ func (db *DB) UpsertSessionProcess(ctx context.Context, input SessionProcessInpu return nil } +// FindProcessSessionID resolves the session a process identity was previously +// bound to, so monitor restarts reuse provisional sessions instead of minting +// new ones. Returns uuid.Nil when the process has never been recorded. +func (db *DB) FindProcessSessionID(ctx context.Context, hostID, bootID string, pid int64, startedAt time.Time) (uuid.UUID, error) { + if err := db.requireGorm(); err != nil { + return uuid.Nil, err + } + var record sessionProcessRecord + err := db.gorm.WithContext(ctx). + Where("host_id = ? AND boot_id = ? AND pid = ? AND process_started_at = ?", + hostID, bootID, pid, startedAt.UTC()). + First(&record).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return uuid.Nil, nil + } + return uuid.Nil, fmt.Errorf("find Captain session process: %w", err) + } + return record.SessionID, nil +} + +// EndOtherSessionProcesses closes open process rows for a session except the +// given PID, satisfying the one-active-process-per-session constraint before a +// new process binds to the session (e.g. resumed under a new PID). +func (db *DB) EndOtherSessionProcesses(ctx context.Context, sessionID uuid.UUID, keepPID int64) error { + if err := db.requireGorm(); err != nil { + return err + } + err := db.gorm.WithContext(ctx).Model(&sessionProcessRecord{}). + Where("session_id = ? AND ended_at IS NULL AND pid <> ?", sessionID, keepPID). + Updates(map[string]any{"ended_at": time.Now().UTC(), "status": "exited"}).Error + if err != nil { + return fmt.Errorf("end superseded Captain session processes: %w", err) + } + return nil +} + +// FindSessionIDByCWD resolves the most recently active session for a working +// directory and source — the process-to-session heuristic when the command +// line carries no session id. +func (db *DB) FindSessionIDByCWD(ctx context.Context, source, cwd string) (uuid.UUID, error) { + if err := db.requireGorm(); err != nil { + return uuid.Nil, err + } + cwd = strings.TrimRight(strings.TrimSpace(cwd), "/") + if cwd == "" { + return uuid.Nil, nil + } + var record sessionRecord + err := db.gorm.WithContext(ctx). + Where("source = ? AND parent_session_id IS NULL AND rtrim(cwd, '/') = ?", source, cwd). + Order("COALESCE(last_activity_at, started_at, created_at) DESC"). + First(&record).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return uuid.Nil, nil + } + return uuid.Nil, fmt.Errorf("find Captain session by cwd: %w", err) + } + return record.ID, nil +} + // EndVanishedProcesses marks every still-open process row on the host whose PID // is absent from alivePIDs as ended, and returns how many rows were closed. func (db *DB) EndVanishedProcesses(ctx context.Context, hostID string, alivePIDs []int64) (int64, error) { diff --git a/pkg/monitor/backfill.go b/pkg/monitor/backfill.go new file mode 100644 index 0000000..ca46ba5 --- /dev/null +++ b/pkg/monitor/backfill.go @@ -0,0 +1,101 @@ +package monitor + +import ( + "context" + "os" + "runtime" + "sync" + + "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/claude" +) + +type transcriptRef struct { + source string + path string +} + +// backfill is the incremental scan over every known transcript: files whose +// mtime/size/parser version match their bookkeeping row are skipped, changed +// ones are re-ingested with a bounded worker pool. Root transcripts are +// ingested before agent transcripts so children always find their parent. +func (m *Monitor) backfill(ctx context.Context, ingestor *ingestor) { + if err := ingestor.refreshSourceStates(ctx); err != nil { + log.Warnf("refresh transcript bookkeeping: %v", err) + return + } + roots, agents := discoverTranscripts() + ingestChanged(ctx, ingestor, roots) + ingestChanged(ctx, ingestor, agents) +} + +func discoverTranscripts() (roots, agents []transcriptRef) { + projectsDir := claude.GetProjectsDir() + if rootFiles, err := claude.FindSessionFiles(projectsDir, "", true); err == nil { + for _, path := range rootFiles { + roots = append(roots, transcriptRef{source: "claude", path: path}) + } + } else { + log.Warnf("discover claude transcripts: %v", err) + } + if codexFiles, err := history.FindCodexSessionFiles(); err == nil { + for _, path := range codexFiles { + roots = append(roots, transcriptRef{source: "codex", path: path}) + } + } else { + log.Warnf("discover codex transcripts: %v", err) + } + if agentFiles, err := claude.FindAgentTranscripts(projectsDir, "", true); err == nil { + for _, path := range agentFiles { + agents = append(agents, transcriptRef{source: "claude", path: path}) + } + } else { + log.Warnf("discover claude agent transcripts: %v", err) + } + return roots, agents +} + +func ingestChanged(ctx context.Context, ingestor *ingestor, refs []transcriptRef) { + changed := make([]transcriptRef, 0) + for _, ref := range refs { + info, err := os.Stat(ref.path) + if err != nil { + continue + } + if ingestor.needsIngest(ref.path, info) { + changed = append(changed, ref) + } + } + if len(changed) == 0 { + return + } + log.Infof("ingesting %d changed transcripts", len(changed)) + workers := runtime.GOMAXPROCS(0) + if workers > len(changed) { + workers = len(changed) + } + queue := make(chan transcriptRef) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + for ref := range queue { + if ctx.Err() != nil { + return + } + if err := ingestor.ingestFile(ctx, ref.source, ref.path); err != nil { + log.Warnf("ingest %s: %v", ref.path, err) + } + } + }() + } + for _, ref := range changed { + if ctx.Err() != nil { + break + } + queue <- ref + } + close(queue) + wg.Wait() +} diff --git a/pkg/monitor/ingest.go b/pkg/monitor/ingest.go new file mode 100644 index 0000000..51c2ae9 --- /dev/null +++ b/pkg/monitor/ingest.go @@ -0,0 +1,277 @@ +package monitor + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/session" +) + +// parserVersion invalidates every ingested transcript when the parsing or +// mapping logic changes shape. +const parserVersion = 1 + +// ingestor turns changed transcript files into native database rows, skipping +// files whose recorded mtime/size/parser version still match the disk state. +type ingestor struct { + monitor *Monitor + db *database.DB + // watchSubagents arms the watcher on a root transcript's subagents + // directory; nil outside watched (one-shot) runs. + watchSubagents func(rootTranscriptPath string) + + mu sync.Mutex + sources map[string]database.SessionSourceState +} + +func newIngestor(m *Monitor) *ingestor { + return &ingestor{monitor: m, db: m.db, sources: map[string]database.SessionSourceState{}} +} + +func (ing *ingestor) refreshSourceStates(ctx context.Context) error { + sources, err := ing.db.ListSessionSources(ctx) + if err != nil { + return err + } + ing.mu.Lock() + ing.sources = sources + ing.mu.Unlock() + return nil +} + +func (ing *ingestor) sourceState(path string) (database.SessionSourceState, bool) { + ing.mu.Lock() + defer ing.mu.Unlock() + state, ok := ing.sources[path] + return state, ok +} + +func (ing *ingestor) recordSourceState(state database.SessionSourceState) { + ing.mu.Lock() + ing.sources[state.Path] = state + ing.mu.Unlock() +} + +// needsIngest compares the on-disk stat with the recorded bookkeeping. +func (ing *ingestor) needsIngest(path string, info os.FileInfo) bool { + state, ok := ing.sourceState(path) + if !ok { + return true + } + return state.ParserVersion != parserVersion || + state.ObservedSize != info.Size() || + state.ObservedModTime == nil || + !state.ObservedModTime.Equal(info.ModTime().UTC()) +} + +// ingestFile parses one transcript and persists it. Claude sub-agent files are +// child sessions of the root transcript's session; root ingests also arm the +// watcher on the session's subagents directory. +func (ing *ingestor) ingestFile(ctx context.Context, source, path string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + ing.monitor.untrackTranscript(path) + return nil + } + return err + } + if !ing.needsIngest(path, info) { + return nil + } + var input database.IngestTranscriptInput + switch source { + case "claude": + input, err = ing.claudeIngestInput(ctx, path) + case "codex": + input, err = codexIngestInput(path) + default: + return fmt.Errorf("unknown transcript source %q for %s", source, path) + } + if err != nil { + return err + } + input.Session.HostID = ing.monitor.cfg.HostID + input.Source.SourceKind = source + input.Source.Path = path + input.Source.ParserVersion = parserVersion + input.Source.ObservedSize = info.Size() + input.Source.ByteOffset = info.Size() + input.Source.ObservedModTime = info.ModTime().UTC() + + persisted, err := ing.db.IngestTranscript(ctx, input) + if err != nil { + return err + } + if source == "claude" && input.Session.ParentSessionID == nil && ing.watchSubagents != nil { + ing.watchSubagents(path) + } + modTime := input.Source.ObservedModTime + ing.recordSourceState(database.SessionSourceState{ + SessionID: persisted.ID, SourceKind: source, Path: path, ParserVersion: parserVersion, + ByteOffset: input.Source.ByteOffset, ObservedSize: input.Source.ObservedSize, ObservedModTime: &modTime, + }) + return nil +} + +func (ing *ingestor) claudeIngestInput(ctx context.Context, path string) (database.IngestTranscriptInput, error) { + s, info, err := session.BuildTranscriptFile(path) + if err != nil { + return database.IngestTranscriptInput{}, err + } + input := unifiedIngestInput(s, "claude", claudeSequence) + input.Source.SourceIdentity = info.RootSessionID + if info.IsAgent { + root, err := ing.db.CreateOrGetSession(ctx, database.CreateSessionInput{ + ProviderSessionID: info.RootSessionID, Source: "claude", HostID: ing.monitor.cfg.HostID, + }) + if err != nil { + return database.IngestTranscriptInput{}, err + } + input.Session.ProviderSessionID = info.AgentID + input.Session.ParentSessionID = &root.ID + input.Session.AgentType = info.AgentType + input.Session.Description = info.AgentDesc + input.Session.Path = path + input.Turns = nil // turns belong to the root session + } else { + input.Session.ProviderSessionID = info.RootSessionID + } + return input, nil +} + +func codexIngestInput(path string) (database.IngestTranscriptInput, error) { + sessions := session.BuildCodex([]string{path}) + if len(sessions) == 0 { + return database.IngestTranscriptInput{}, fmt.Errorf("codex transcript %s is not parseable", path) + } + s := sessions[0] + input := unifiedIngestInput(s, "codex", func(m session.Message, index int) int64 { + return int64(index + 1) + }) + input.Session.ProviderSessionID = s.ID + input.Source.SourceIdentity = s.ID + return input, nil +} + +// claudeSequence keys messages by their transcript line: stable across +// re-parses and the seek reference the UI uses against the raw file. +func claudeSequence(m session.Message, _ int) int64 { return m.SourceLine } + +// unifiedIngestInput maps the unified session model onto the native ingest +// batch: turns become turn rows with one aggregate model call each, and +// conversational messages become message rows keyed by sequence. +func unifiedIngestInput(s *session.Session, backend string, sequence func(session.Message, int) int64) database.IngestTranscriptInput { + input := database.IngestTranscriptInput{ + Session: database.IngestSessionInput{ + Source: backend, Path: s.HistoryFile, Project: s.Project, CWD: s.CWD, + Title: s.Title, InitialPrompt: s.InitialPrompt, Slug: s.Slug, CLIVersion: s.Version, + StartedAt: s.StartedAt, LastActivityAt: s.EndedAt, + Git: gitMetadata(s), + Metadata: sessionMetadata(s), + }, + } + turnIndexByID := map[string]int{} + for _, turn := range s.Turns { + turnIndexByID[turn.ID] = turn.Index + ingestTurn := database.IngestTurn{ + Index: turn.Index, ProviderTurnID: turn.ID, Status: database.TurnStatusEnded, + StopReason: turn.StopReason, StartedAt: turn.StartedAt, EndedAt: turn.EndedAt, + Call: &database.IngestModelCall{ + Model: turn.Model, Backend: backend, + InputTokens: int64(turn.Usage.InputTokens), + OutputTokens: int64(turn.Usage.OutputTokens), ReasoningTokens: int64(turn.Usage.ReasoningTokens), + CacheReadTokens: int64(turn.Usage.CacheReadTokens), CacheWriteTokens: int64(turn.Usage.CacheWriteTokens), + InputCost: turn.Cost.InputCost, OutputCost: turn.Cost.OutputCost, + CacheReadCost: turn.Cost.CacheReadCost, CacheWriteCost: turn.Cost.CacheWriteCost, + StartedAt: turn.StartedAt, EndedAt: turn.EndedAt, + }, + } + if turn.Context != nil { + ingestTurn.Call.ContextTokens = int64(turn.Context.UsedTokens) + ingestTurn.Call.ContextWindowTokens = int64(turn.Context.WindowTokens) + } + input.Turns = append(input.Turns, ingestTurn) + } + seen := map[int64]bool{} + for i, m := range s.Messages { + if !session.IsConversationalMessage(m) { + continue + } + seq := sequence(m, i) + if seq <= 0 || seen[seq] { + continue + } + seen[seq] = true + parts, err := json.Marshal(m.Parts) + if err != nil { + continue + } + message := database.IngestMessage{ + Sequence: seq, ProviderMessageID: m.ID, Role: m.Role, PartsJSON: parts, SourceLine: m.SourceLine, + } + if m.Provenance != nil { + message.OccurredAt = m.Provenance.Timestamp + } + if index, ok := turnIndexByID[m.TurnID]; ok && m.TurnID != "" { + turnIndex := index + message.TurnIndex = &turnIndex + } + input.Messages = append(input.Messages, message) + } + return input +} + +func gitMetadata(s *session.Session) map[string]any { + if s.Git.Branch == "" && s.Git.Commit == "" && s.Git.Worktree == "" { + return nil + } + git := map[string]any{} + if s.Git.Branch != "" { + git["branch"] = s.Git.Branch + } + if s.Git.Commit != "" { + git["commit"] = s.Git.Commit + } + if s.Git.Worktree != "" { + git["worktree"] = s.Git.Worktree + } + return git +} + +// sessionMetadata is the monitor-owned dashboard projection that is not +// derivable from turn/message rows: model/provider labels, changed files, +// approval stats, and the transcript-recovered plan reference. +func sessionMetadata(s *session.Session) map[string]any { + metadata := map[string]any{} + if s.Model != "" { + metadata["model"] = s.Model + } + if s.Provider != "" { + metadata["provider"] = s.Provider + } + if len(s.Files.Read) > 0 || len(s.Files.Written) > 0 { + metadata["files"] = s.Files + } + if s.Approvals.Approved > 0 || s.Approvals.Denied > 0 { + metadata["approvals"] = s.Approvals + } + if s.Plan != nil { + metadata["plan"] = s.Plan + } + if len(metadata) == 0 { + return nil + } + return metadata +} + +// subagentsDir is where Claude Code stores a session's sub-agent transcripts. +func subagentsDir(rootTranscriptPath string) string { + return filepath.Join(strings.TrimSuffix(rootTranscriptPath, ".jsonl"), "subagents") +} diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go new file mode 100644 index 0000000..aa7f49f --- /dev/null +++ b/pkg/monitor/monitor.go @@ -0,0 +1,195 @@ +// Package monitor is Captain's live session monitor: the single writer that +// keeps the native database current. It discovers claude/codex agent processes +// via ps and samples their CPU/RAM, tails the transcripts of live and +// captain-launched sessions with fsnotify (debounced), and incrementally +// backfills older transcripts by mtime/size. Every read surface (dashboard, +// CLI, API) reads the database this monitor writes. +package monitor + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + "time" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/commons/logger" +) + +var log = logger.GetLogger("monitor") + +// monitorLockNamespace/monitorLockKey ("CAPT", "MONI") serialize DB writers: +// exactly one monitor per database writes at a time; others idle or no-op. +const ( + monitorLockNamespace int32 = 0x43415054 + monitorLockKey int32 = 0x4d4f4e49 +) + +type Config struct { + DB *database.DB + HostID string + // ProcessInterval is the ps poll cadence (default 5s). + ProcessInterval time.Duration + // Debounce delays transcript ingest after an fsnotify event (default 750ms). + Debounce time.Duration + // BackfillInterval is the periodic incremental scan cadence (default 5m). + BackfillInterval time.Duration +} + +type Monitor struct { + cfg Config + db *database.DB + + mu sync.Mutex + tracked map[string]string // transcript path -> source kind, the live tail set +} + +func New(cfg Config) (*Monitor, error) { + if cfg.DB == nil { + return nil, errors.New("monitor requires a database") + } + if cfg.HostID == "" { + cfg.HostID = "local" + } + if cfg.ProcessInterval <= 0 { + cfg.ProcessInterval = 5 * time.Second + } + if cfg.Debounce <= 0 { + cfg.Debounce = 750 * time.Millisecond + } + if cfg.BackfillInterval <= 0 { + cfg.BackfillInterval = 5 * time.Minute + } + return &Monitor{cfg: cfg, db: cfg.DB, tracked: map[string]string{}}, nil +} + +// TrackTranscript adds a transcript to the live tail set before its session is +// discoverable via ps — e.g. immediately after captain launches a prompt run. +func (m *Monitor) TrackTranscript(path, source string) { + if path == "" { + return + } + if source == "" { + source = "claude" + } + m.mu.Lock() + m.tracked[path] = source + m.mu.Unlock() +} + +func (m *Monitor) trackedPaths() map[string]string { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[string]string, len(m.tracked)) + for path, source := range m.tracked { + out[path] = source + } + return out +} + +func (m *Monitor) untrackTranscript(path string) { + m.mu.Lock() + delete(m.tracked, path) + m.mu.Unlock() +} + +// Run acquires the single-writer advisory lock and drives the three loops: +// process polling, fsnotify tailing, and periodic incremental backfill. When +// another monitor holds the lock, Run keeps retrying the lock instead of +// double-writing, so a serve restart takes over cleanly. +func (m *Monitor) Run(ctx context.Context) error { + for { + conn, acquired, err := m.tryAcquireWriterLock(ctx) + if err != nil { + return err + } + if acquired { + err := m.runLocked(ctx, conn) + _ = conn.Close() + if ctx.Err() != nil { + return nil + } + if err != nil { + return err + } + continue + } + log.Infof("another Captain monitor holds the writer lock; standing by") + select { + case <-ctx.Done(): + return nil + case <-time.After(m.cfg.ProcessInterval * 4): + } + } +} + +func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { + ingestor := newIngestor(m) + if err := ingestor.refreshSourceStates(ctx); err != nil { + return err + } + watcher, err := newTranscriptWatcher(m, ingestor) + if err != nil { + return err + } + defer watcher.close() + + if err := m.pollProcesses(ctx, watcher); err != nil { + log.Warnf("process poll: %v", err) + } + go m.backfill(ctx, ingestor) + + processTicker := time.NewTicker(m.cfg.ProcessInterval) + backfillTicker := time.NewTicker(m.cfg.BackfillInterval) + defer processTicker.Stop() + defer backfillTicker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-processTicker.C: + if err := m.pollProcesses(ctx, watcher); err != nil { + log.Warnf("process poll: %v", err) + } + case <-backfillTicker.C: + m.backfill(ctx, ingestor) + case event, ok := <-watcher.events(): + if !ok { + return errors.New("transcript watcher closed unexpectedly") + } + watcher.handle(ctx, event) + case err, ok := <-watcher.errors(): + if ok && err != nil { + log.Warnf("transcript watcher: %v", err) + } + } + } +} + +// tryAcquireWriterLock takes the monitor advisory lock on a dedicated +// connection so it is held for the monitor's lifetime, not a pooled statement. +func (m *Monitor) tryAcquireWriterLock(ctx context.Context) (*sql.Conn, bool, error) { + sqlDB, err := m.db.Gorm().DB() + if err != nil { + return nil, false, fmt.Errorf("access Captain SQL pool: %w", err) + } + conn, err := sqlDB.Conn(ctx) + if err != nil { + return nil, false, fmt.Errorf("open Captain monitor lock connection: %w", err) + } + var acquired bool + err = conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1, $2)", + monitorLockNamespace, monitorLockKey).Scan(&acquired) + if err != nil { + _ = conn.Close() + return nil, false, fmt.Errorf("acquire Captain monitor lock: %w", err) + } + if !acquired { + _ = conn.Close() + return nil, false, nil + } + return conn, true, nil +} diff --git a/pkg/monitor/monitor_integration_test.go b/pkg/monitor/monitor_integration_test.go new file mode 100644 index 0000000..c4c86ef --- /dev/null +++ b/pkg/monitor/monitor_integration_test.go @@ -0,0 +1,132 @@ +package monitor + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/flanksource/captain/pkg/database" + commonsdb "github.com/flanksource/commons-db/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + fixtureSessionID = "0195c1de-4ab8-7000-8000-0123456789ab" + fixtureCWD = "/home/dev/example" +) + +const fixtureTranscript = `{"type":"user","uuid":"u-1","sessionId":"` + fixtureSessionID + `","timestamp":"2026-07-05T10:00:00Z","cwd":"` + fixtureCWD + `","message":{"role":"user","content":"hello"}} +{"type":"assistant","uuid":"a-1","sessionId":"` + fixtureSessionID + `","timestamp":"2026-07-05T10:00:05Z","message":{"role":"assistant","model":"claude-sonnet-5","usage":{"input_tokens":100,"output_tokens":20},"content":[{"type":"text","text":"hi there"}]}} +` + +const fixtureAppendLine = `{"type":"assistant","uuid":"a-2","sessionId":"` + fixtureSessionID + `","timestamp":"2026-07-05T10:01:00Z","message":{"role":"assistant","model":"claude-sonnet-5","usage":{"input_tokens":150,"output_tokens":30},"content":[{"type":"text","text":"appended"}]}} +` + +func openMonitorTestDB(t *testing.T) *database.DB { + t.Helper() + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres monitor tests") + } + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_monitor", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + db, err := database.Open(t.Context(), database.Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return db +} + +// writeFixtureHome points HOME at a temp dir holding one claude transcript so +// discovery, ingest, and re-ingest run against a controlled filesystem. +func writeFixtureHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".claude", "projects", "-home-dev-example") + require.NoError(t, os.MkdirAll(dir, 0o755)) + path := filepath.Join(dir, fixtureSessionID+".jsonl") + require.NoError(t, os.WriteFile(path, []byte(fixtureTranscript), 0o644)) + return path +} + +func TestRunOnceIngestsAndIsIncremental(t *testing.T) { + db := openMonitorTestDB(t) + path := writeFixtureHome(t) + + processStart := time.Now().Add(-time.Minute).Truncate(time.Second) + discoverProcesses = func() ([]agentProcess, error) { + return []agentProcess{{ + Source: "claude", PID: 4242, Status: "sleeping", CPUPercent: 20.5, MemoryPercent: 1.5, + MemoryRSSKB: 1024, StartedAt: &processStart, CWD: fixtureCWD, + Command: "claude --resume " + fixtureSessionID, + }}, nil + } + t.Cleanup(func() { discoverProcesses = discoverAgentProcesses }) + + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.EqualValues(t, 2, overview.MessageCount) + assert.EqualValues(t, 120, overview.InputTokens+overview.OutputTokens) + assert.True(t, overview.ProcessActive, "live process must be recorded") + require.NotNil(t, overview.CPUPercent) + assert.InDelta(t, 20.5, *overview.CPUPercent, 1e-9) + require.NotNil(t, overview.HistoryFile) + assert.Equal(t, path, *overview.HistoryFile) + + t.Run("unchanged file is skipped, appended file re-ingests", func(t *testing.T) { + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.EqualValues(t, 2, overview.MessageCount, "unchanged transcript must not re-ingest") + + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = f.WriteString(fixtureAppendLine) + require.NoError(t, err) + require.NoError(t, f.Close()) + + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + overview, err = db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.EqualValues(t, 3, overview.MessageCount, "appended message must ingest incrementally") + + messages, err := db.ListTranscriptMessages(t.Context(), database.TranscriptPage{SessionID: overview.ID, Tail: 1}) + require.NoError(t, err) + require.Len(t, messages, 1) + require.NotNil(t, messages[0].SourceLine) + assert.EqualValues(t, 3, *messages[0].SourceLine) + }) + + t.Run("process vanish closes the process row", func(t *testing.T) { + discoverProcesses = func() ([]agentProcess, error) { return nil, nil } + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.False(t, overview.ProcessActive) + }) + + t.Run("writer lock excludes a second monitor", func(t *testing.T) { + m, err := New(Config{DB: db, HostID: "test-host"}) + require.NoError(t, err) + conn, acquired, err := m.tryAcquireWriterLock(t.Context()) + require.NoError(t, err) + require.True(t, acquired) + defer func() { require.NoError(t, conn.Close()) }() + + second, err := New(Config{DB: db, HostID: "test-host"}) + require.NoError(t, err) + conn2, acquired2, err := second.tryAcquireWriterLock(t.Context()) + require.NoError(t, err) + assert.False(t, acquired2, "second monitor must not acquire the writer lock") + if conn2 != nil { + _ = conn2.Close() + } + }) +} diff --git a/pkg/monitor/monitor_test.go b/pkg/monitor/monitor_test.go new file mode 100644 index 0000000..2ba8ab9 --- /dev/null +++ b/pkg/monitor/monitor_test.go @@ -0,0 +1,105 @@ +package monitor + +import ( + "context" + "testing" + "time" + + "github.com/fsnotify/fsnotify" +) + +func TestParseAgentProcessLine(t *testing.T) { + line := "4242 12.5 3.2 204800 S+ Sun Jul 12 09:00:00 2026 /usr/local/bin/claude --resume 0195c1de-4ab8-7000-8000-0123456789ab" + proc, ok := parseAgentProcessLine(line) + if !ok { + t.Fatal("line should parse") + } + if proc.PID != 4242 || proc.Source != "claude" { + t.Fatalf("pid=%d source=%q", proc.PID, proc.Source) + } + if proc.CPUPercent != 12.5 || proc.MemoryPercent != 3.2 { + t.Fatalf("cpu=%v mem=%v", proc.CPUPercent, proc.MemoryPercent) + } + if proc.MemoryRSSKB != 204800 { + t.Fatalf("rss=%d", proc.MemoryRSSKB) + } + if proc.Status != "sleeping" { + t.Fatalf("status=%q", proc.Status) + } + if proc.StartedAt == nil || proc.StartedAt.Year() != 2026 { + t.Fatalf("startedAt=%v", proc.StartedAt) + } + if got := parseClaudeSessionIDFromCommand(proc.Command); got != "0195c1de-4ab8-7000-8000-0123456789ab" { + t.Fatalf("session id from command = %q", got) + } +} + +func TestParseAgentProcessLine_SkipsNonAgents(t *testing.T) { + for _, line := range []string{ + "77 0.0 0.1 1024 S Sun Jul 12 09:00:00 2026 /usr/bin/captain serve", + "78 0.0 0.1 1024 S Sun Jul 12 09:00:00 2026 /opt/codex/mcp-server --port 1", + "79 0.0 0.1 1024 S Sun Jul 12 09:00:00 2026 /bin/zsh -l", + } { + if _, ok := parseAgentProcessLine(line); ok { + t.Errorf("line should be skipped: %s", line) + } + } +} + +// TestWatcherDebounce verifies a burst of write events produces one ingest +// after the quiet period rather than one per event. +func TestWatcherDebounce(t *testing.T) { + m := &Monitor{cfg: Config{Debounce: 30 * time.Millisecond}, tracked: map[string]string{}} + path := "/tmp/x/session.jsonl" + fired := make(chan string, 10) + w := &transcriptWatcher{ + monitor: m, + dirs: map[string]string{}, tracked: map[string]string{path: "claude"}, + timers: map[string]*time.Timer{}, debounce: m.cfg.Debounce, + ingest: func(_ context.Context, _, p string) { fired <- p }, + } + + for range 5 { + w.handle(t.Context(), fsnotify.Event{Name: path, Op: fsnotify.Write}) + } + + select { + case got := <-fired: + if got != path { + t.Fatalf("ingested %q, want %q", got, path) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("debounced ingest never fired") + } + select { + case <-fired: + t.Fatal("burst fired more than once") + case <-time.After(3 * w.debounce): + } +} + +func TestWatcherClassify(t *testing.T) { + m := &Monitor{cfg: Config{Debounce: time.Millisecond}, tracked: map[string]string{}} + w := &transcriptWatcher{ + monitor: m, + dirs: map[string]string{"/proj/-home-dev-example": "claude"}, + tracked: map[string]string{"/other/tracked.jsonl": "codex"}, + timers: map[string]*time.Timer{}, + } + cases := []struct { + path string + source string + ok bool + }{ + {"/other/tracked.jsonl", "codex", true}, + {"/proj/-home-dev-example/new-session.jsonl", "claude", true}, + {"/proj/-home-dev-example/notes.txt", "", false}, + {"/unwatched/dir/session.jsonl", "", false}, + } + for _, c := range cases { + source, ok := w.classify(c.path) + if ok != c.ok || source != c.source { + t.Errorf("classify(%s) = (%q, %v), want (%q, %v)", c.path, source, ok, c.source, c.ok) + } + } +} diff --git a/pkg/monitor/oneshot.go b/pkg/monitor/oneshot.go new file mode 100644 index 0000000..7380820 --- /dev/null +++ b/pkg/monitor/oneshot.go @@ -0,0 +1,34 @@ +package monitor + +import ( + "context" +) + +// RunOnce freshens the database exactly once: one process poll and one +// incremental backfill pass, guarded by the same single-writer advisory lock as +// Run. When a live monitor already holds the lock the database is being kept +// current continuously and RunOnce is a no-op. +func RunOnce(ctx context.Context, cfg Config) error { + m, err := New(cfg) + if err != nil { + return err + } + conn, acquired, err := m.tryAcquireWriterLock(ctx) + if err != nil { + return err + } + if !acquired { + return nil + } + defer func() { _ = conn.Close() }() + + ingestor := newIngestor(m) + if err := ingestor.refreshSourceStates(ctx); err != nil { + return err + } + if err := m.pollProcesses(ctx, nil); err != nil { + log.Warnf("one-shot process poll: %v", err) + } + m.backfill(ctx, ingestor) + return nil +} diff --git a/pkg/monitor/process.go b/pkg/monitor/process.go new file mode 100644 index 0000000..d92aeae --- /dev/null +++ b/pkg/monitor/process.go @@ -0,0 +1,370 @@ +package monitor + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/database" + "github.com/google/uuid" +) + +// agentProcess is one live claude/codex OS process observed via ps. +type agentProcess struct { + Source string + PID int + Status string + CPUPercent float64 + MemoryPercent float64 + MemoryRSSKB int64 + StartedAt *time.Time + CWD string + Command string +} + +// pollProcesses is one monitor tick: sample agent processes, bind each to a +// session, persist the metrics snapshot, close vanished process rows, and feed +// the live transcript locations to the watcher (nil in one-shot runs). +// discoverProcesses is indirected so tests can substitute fake process lists. +var discoverProcesses = discoverAgentProcesses + +func (m *Monitor) pollProcesses(ctx context.Context, watcher *transcriptWatcher) error { + processes, err := discoverProcesses() + if err != nil { + return err + } + sampledAt := time.Now().UTC() + alive := make([]int64, 0, len(processes)) + for _, proc := range processes { + alive = append(alive, int64(proc.PID)) + sessionID, err := m.resolveProcessSession(ctx, proc) + if err != nil { + log.Warnf("resolve session for pid %d: %v", proc.PID, err) + continue + } + if err := m.persistProcess(ctx, sessionID, proc, sampledAt); err != nil { + log.Warnf("persist process pid %d: %v", proc.PID, err) + continue + } + if watcher != nil { + m.watchLiveSession(ctx, watcher, proc, sessionID) + } + } + if _, err := m.db.EndVanishedProcesses(ctx, m.cfg.HostID, alive); err != nil { + return err + } + if watcher != nil { + for path, source := range m.trackedPaths() { + watcher.track(path, source) + } + } + return nil +} + +// resolveProcessSession binds a process to a session: a previously recorded +// binding first, then the argv session id, then the newest session in the same +// working directory, finally a provisional session that the transcript ingest +// later fills in. +func (m *Monitor) resolveProcessSession(ctx context.Context, proc agentProcess) (uuid.UUID, error) { + startedAt := processStartOrNow(proc) + existing, err := m.db.FindProcessSessionID(ctx, m.cfg.HostID, bootID(), int64(proc.PID), startedAt) + if err != nil { + return uuid.Nil, err + } + if existing != uuid.Nil { + return existing, nil + } + if providerSessionID := parseClaudeSessionIDFromCommand(proc.Command); providerSessionID != "" { + session, err := m.db.CreateOrGetSession(ctx, database.CreateSessionInput{ + ProviderSessionID: providerSessionID, Source: proc.Source, HostID: m.cfg.HostID, CWD: proc.CWD, + }) + if err != nil { + return uuid.Nil, err + } + return session.ID, nil + } + if byCWD, err := m.db.FindSessionIDByCWD(ctx, proc.Source, proc.CWD); err != nil { + return uuid.Nil, err + } else if byCWD != uuid.Nil { + return byCWD, nil + } + session, err := m.db.CreateOrGetSession(ctx, database.CreateSessionInput{ + Source: proc.Source, HostID: m.cfg.HostID, CWD: proc.CWD, + Description: "provisional session for live process", + }) + if err != nil { + return uuid.Nil, err + } + return session.ID, nil +} + +func (m *Monitor) persistProcess(ctx context.Context, sessionID uuid.UUID, proc agentProcess, sampledAt time.Time) error { + input := database.SessionProcessInput{ + SessionID: sessionID, HostID: m.cfg.HostID, BootID: bootID(), + PID: int64(proc.PID), ProcessStartedAt: processStartOrNow(proc), + Status: proc.Status, Command: proc.Command, CWD: proc.CWD, Source: proc.Source, + CPUPercent: proc.CPUPercent, MemoryPercent: proc.MemoryPercent, SampledAt: sampledAt, + } + if proc.MemoryRSSKB > 0 { + rss := proc.MemoryRSSKB * 1024 + input.MemoryRSSBytes = &rss + } + err := m.db.UpsertSessionProcess(ctx, input) + if err != nil && strings.Contains(err.Error(), "captain_session_processes_active_session_key") { + // The session was resumed under a new PID; close the superseded row and retry. + if endErr := m.db.EndOtherSessionProcesses(ctx, sessionID, int64(proc.PID)); endErr != nil { + return endErr + } + err = m.db.UpsertSessionProcess(ctx, input) + } + return err +} + +// watchLiveSession points the watcher at wherever this live session's +// transcript lives (or will appear): the claude project directory for the +// process cwd, or codex's per-day rollout directory. +func (m *Monitor) watchLiveSession(ctx context.Context, watcher *transcriptWatcher, proc agentProcess, sessionID uuid.UUID) { + switch proc.Source { + case "claude": + if proc.CWD != "" { + watcher.watchDir(filepath.Join(claude.GetProjectsDir(), claude.NormalizePath(proc.CWD)), "claude") + } + case "codex": + watcher.watchDir(codexDayDir(time.Now()), "codex") + } + if session, err := m.db.GetSession(ctx, sessionID); err == nil && session.Path != "" { + watcher.track(session.Path, proc.Source) + } +} + +func codexDayDir(now time.Time) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".codex", "sessions", now.Format("2006/01/02")) +} + +func processStartOrNow(proc agentProcess) time.Time { + if proc.StartedAt != nil { + return proc.StartedAt.UTC() + } + return time.Now().UTC() +} + +// bootID is a stable-enough boot discriminator for the process identity key. +// PID + start time disambiguate across reboots in practice; a real boot id can +// replace this without a schema change. +func bootID() string { return "boot" } + +// parseClaudeSessionIDFromCommand extracts the session id claude was launched +// with, from its "--session-id " / "--resume " argv (either the +// space-separated or "=" form). Returns "" when absent. +func parseClaudeSessionIDFromCommand(command string) string { + fields := strings.Fields(command) + for i, field := range fields { + for _, flag := range []string{"--session-id", "--resume"} { + if field == flag && i+1 < len(fields) { + return fields[i+1] + } + if value, ok := strings.CutPrefix(field, flag+"="); ok { + return value + } + } + } + return "" +} + +func discoverAgentProcesses() ([]agentProcess, error) { + if runtime.GOOS == "windows" { + return nil, nil + } + out, err := exec.Command("ps", "-eo", "pid=,pcpu=,pmem=,rss=,stat=,lstart=,command=").Output() + if err != nil { + return nil, err + } + lines := bytes.Split(out, []byte{'\n'}) + processes := make([]agentProcess, 0) + for _, raw := range lines { + line := strings.TrimSpace(string(raw)) + if line == "" { + continue + } + proc, ok := parseAgentProcessLine(line) + if !ok { + continue + } + processes = append(processes, proc) + } + cwds := processCWDs(processIDs(processes)) + for i := range processes { + processes[i].CWD = cwds[processes[i].PID] + } + return processes, nil +} + +func parseAgentProcessLine(line string) (agentProcess, bool) { + fields := strings.Fields(line) + if len(fields) < 11 { + return agentProcess{}, false + } + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 0 { + return agentProcess{}, false + } + command := strings.Join(fields[10:], " ") + source := processSource(command) + if source == "" { + return agentProcess{}, false + } + cpu, _ := strconv.ParseFloat(fields[1], 64) + mem, _ := strconv.ParseFloat(fields[2], 64) + rss, _ := strconv.ParseInt(fields[3], 10, 64) + stat := fields[4] + start := parseProcessStart(strings.Join(fields[5:10], " ")) + status, _ := processStatus(stat) + return agentProcess{ + Source: source, + PID: pid, + Status: status, + CPUPercent: cpu, + MemoryPercent: mem, + MemoryRSSKB: rss, + StartedAt: start, + Command: command, + }, true +} + +func processSource(command string) string { + lower := strings.ToLower(command) + if strings.Contains(lower, "captain") || strings.Contains(lower, "ctop") || strings.Contains(lower, "claude-manager") { + return "" + } + if strings.Contains(lower, "claude.app") { + return "" + } + if commandNameMatches(lower, "claude") { + return "claude" + } + if strings.Contains(lower, "codex-darwin") || + strings.Contains(lower, "codex-linux") || + strings.Contains(lower, "codex-win") || + commandNameMatches(lower, "codex") { + // mcp-server / app-server are codex's tool/IPC servers, not interactive + // sessions — they never hold a rollout transcript open. + if commandNameMatches(lower, "mcp-server") || commandNameMatches(lower, "app-server") { + return "" + } + return "codex" + } + return "" +} + +func commandNameMatches(command, name string) bool { + fields := strings.Fields(command) + for _, field := range fields { + base := field + if idx := strings.LastIndex(base, "/"); idx >= 0 { + base = base[idx+1:] + } + base = strings.Trim(base, `"'`) + if base == name { + return true + } + } + return false +} + +func processStatus(stat string) (string, bool) { + switch { + case strings.Contains(stat, "Z"): + return "zombie", false + case strings.Contains(stat, "T"): + return "stopped", false + case strings.Contains(stat, "S"): + return "sleeping", true + default: + return "active", true + } +} + +func parseProcessStart(value string) *time.Time { + if value == "" { + return nil + } + t, err := time.Parse("Mon Jan 2 15:04:05 2006", value) + if err != nil { + return nil + } + return &t +} + +func processIDs(processes []agentProcess) []int { + pids := make([]int, 0, len(processes)) + for _, proc := range processes { + if proc.PID > 0 { + pids = append(pids, proc.PID) + } + } + return pids +} + +func processCWDs(pids []int) map[int]string { + cwds := make(map[int]string, len(pids)) + if runtime.GOOS == "linux" { + for _, pid := range pids { + if pid <= 0 { + continue + } + cwd, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd") + if err == nil { + cwds[pid] = cwd + } + } + return cwds + } + var pidList []string + for _, pid := range pids { + if pid > 0 { + pidList = append(pidList, strconv.Itoa(pid)) + } + } + if len(pidList) == 0 { + return cwds + } + out, err := exec.Command("lsof", "-a", "-d", "cwd", "-F", "pn", "-p", strings.Join(pidList, ",")).Output() + if err != nil { + return cwds + } + return parseLsofCWDs(out) +} + +func parseLsofCWDs(out []byte) map[int]string { + cwds := make(map[int]string) + currentPID := 0 + for _, raw := range bytes.Split(out, []byte{'\n'}) { + line := strings.TrimSpace(string(raw)) + if line == "" { + continue + } + switch line[0] { + case 'p': + pid, err := strconv.Atoi(strings.TrimPrefix(line, "p")) + if err == nil { + currentPID = pid + } + case 'n': + if currentPID > 0 { + cwds[currentPID] = strings.TrimPrefix(line, "n") + } + } + } + return cwds +} diff --git a/pkg/monitor/watch.go b/pkg/monitor/watch.go new file mode 100644 index 0000000..3d8905d --- /dev/null +++ b/pkg/monitor/watch.go @@ -0,0 +1,142 @@ +package monitor + +import ( + "context" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// transcriptWatcher tails live transcripts: it watches directories (per-file +// watches break across rename/recreate), filters events to JSONL transcripts, +// and debounces bursts of appends into one ingest per quiet period. +type transcriptWatcher struct { + monitor *Monitor + watcher *fsnotify.Watcher + // ingest runs after the debounce window; ingestor.ingestFile in production. + ingest func(ctx context.Context, source, path string) + + mu sync.Mutex + dirs map[string]string // watched directory -> source kind + tracked map[string]string // explicitly tracked file -> source kind + timers map[string]*time.Timer + debounce time.Duration +} + +func newTranscriptWatcher(m *Monitor, ingestor *ingestor) (*transcriptWatcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + w := &transcriptWatcher{ + monitor: m, watcher: watcher, + dirs: map[string]string{}, tracked: map[string]string{}, timers: map[string]*time.Timer{}, + debounce: m.cfg.Debounce, + } + w.ingest = func(ctx context.Context, source, path string) { + if err := ingestor.ingestFile(ctx, source, path); err != nil { + log.Warnf("ingest %s: %v", path, err) + } + } + ingestor.watchSubagents = func(rootTranscriptPath string) { + w.watchDir(subagentsDir(rootTranscriptPath), "claude") + } + return w, nil +} + +func (w *transcriptWatcher) events() chan fsnotify.Event { return w.watcher.Events } +func (w *transcriptWatcher) errors() chan error { return w.watcher.Errors } + +func (w *transcriptWatcher) close() { + w.mu.Lock() + for _, timer := range w.timers { + timer.Stop() + } + w.timers = map[string]*time.Timer{} + w.mu.Unlock() + _ = w.watcher.Close() +} + +// track registers one transcript file and watches its directory (and, for +// claude root transcripts, the session's subagents directory when it appears). +func (w *transcriptWatcher) track(path, source string) { + if path == "" { + return + } + w.mu.Lock() + _, known := w.tracked[path] + w.tracked[path] = source + w.mu.Unlock() + if !known { + w.watchDir(filepath.Dir(path), source) + } +} + +func (w *transcriptWatcher) watchDir(dir, source string) { + if dir == "" { + return + } + w.mu.Lock() + _, known := w.dirs[dir] + if !known { + w.dirs[dir] = source + } + w.mu.Unlock() + if known { + return + } + if err := w.watcher.Add(dir); err != nil { + log.Debugf("watch %s: %v", dir, err) + w.mu.Lock() + delete(w.dirs, dir) + w.mu.Unlock() + } +} + +// handle debounces one fsnotify event into a future ingest of the file. +func (w *transcriptWatcher) handle(ctx context.Context, event fsnotify.Event) { + if !event.Op.Has(fsnotify.Write) && !event.Op.Has(fsnotify.Create) { + return + } + path := event.Name + source, ok := w.classify(path) + if !ok { + return + } + w.mu.Lock() + defer w.mu.Unlock() + if timer, ok := w.timers[path]; ok { + timer.Reset(w.debounce) + return + } + w.timers[path] = time.AfterFunc(w.debounce, func() { + w.mu.Lock() + delete(w.timers, path) + w.mu.Unlock() + if ctx.Err() != nil { + return + } + w.ingest(ctx, source, path) + }) +} + +// classify decides whether an event path is a transcript worth ingesting and +// which source parser owns it: explicitly tracked files always qualify; other +// JSONL files qualify when they live in a watched directory. +func (w *transcriptWatcher) classify(path string) (string, bool) { + w.mu.Lock() + defer w.mu.Unlock() + if source, ok := w.tracked[path]; ok { + return source, true + } + if !strings.HasSuffix(path, ".jsonl") { + return "", false + } + if source, ok := w.dirs[filepath.Dir(path)]; ok { + return source, true + } + return "", false +} diff --git a/pkg/session/build_messages.go b/pkg/session/build_messages.go index 46640a4..c1a6617 100644 --- a/pkg/session/build_messages.go +++ b/pkg/session/build_messages.go @@ -119,6 +119,7 @@ func entryToMessage(e claude.HistoryEntry, agentID, turnID string) (Message, boo Provenance: provenanceFromEntry(e, agentID), AgentID: agentID, TurnID: turnID, + SourceLine: int64(e.Line), } if len(e.RawLine) > 0 { m.Raw = e.RawLine diff --git a/pkg/session/message.go b/pkg/session/message.go index a5094dd..16d3772 100644 --- a/pkg/session/message.go +++ b/pkg/session/message.go @@ -86,6 +86,9 @@ type Message struct { TurnID string `json:"turnId,omitempty"` Provenance *Provenance `json:"provenance,omitempty"` Raw json.RawMessage `json:"raw,omitempty"` + // SourceLine is the 1-based JSONL line the message came from (0 when the + // source format has no line mapping, e.g. codex tool-use projection). + SourceLine int64 `json:"sourceLine,omitempty"` AgentID string `json:"-"` } diff --git a/pkg/session/transcript.go b/pkg/session/transcript.go new file mode 100644 index 0000000..4d55058 --- /dev/null +++ b/pkg/session/transcript.go @@ -0,0 +1,78 @@ +package session + +import ( + "fmt" + + "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/claude/tools" +) + +// TranscriptInfo identifies one transcript file relative to its session: the +// root session id derived from the path/content and, for sub-agent transcripts, +// the agent identity that becomes a child session. +type TranscriptInfo struct { + Path string + RootSessionID string + IsAgent bool + AgentID string + AgentType string + AgentDesc string +} + +// BuildTranscriptFile builds the unified model for a single Claude transcript +// file (root or sub-agent) without directory discovery, for ingest pipelines +// that already know exactly which file changed. +func BuildTranscriptFile(path string) (*Session, TranscriptInfo, error) { + t, rootID, err := claude.ParseTranscript(path) + if err != nil { + return nil, TranscriptInfo{}, err + } + if len(t.Entries) == 0 { + return nil, TranscriptInfo{}, fmt.Errorf("transcript %s has no entries", path) + } + info := TranscriptInfo{ + Path: path, RootSessionID: rootID, + IsAgent: t.IsAgent, AgentID: t.AgentID, AgentType: t.AgentType, AgentDesc: t.AgentDesc, + } + ps := claude.ParsedSession{SessionID: rootID, Transcripts: []claude.ParsedTranscript{t}} + return buildSession(ps), info, nil +} + +// IsSyntheticEventTool reports whether a tool part name is a synthetic +// event/lifecycle projection (session init, hooks, result summaries, API/parse +// errors) rather than conversational content. Messages whose parts are all +// synthetic are transcript events, not messages. +func IsSyntheticEventTool(name string) bool { + if name == "" { + return false + } + if tools.IsEventToolName(name) { + return true + } + switch name { + case "ApiError", "ParseError", "Result", "SessionInit", "HookStart", "HookResponse", + "StopHookSummary", "TurnDuration", "AwaySummary", "SessionTitle", "CompactBoundary", + "LocalCommand", "ScheduledTaskFire", "Informational", "PrLink", "WorktreeState", + "Relocated", "Started", "Event": + return true + default: + return false + } +} + +// IsConversationalMessage reports whether a message carries conversational +// content worth persisting as a transcript message: any text/reasoning/file +// part, or a tool part that is a real tool call rather than a synthetic event. +func IsConversationalMessage(m Message) bool { + for _, p := range m.Parts { + switch p.Type { + case PartText, PartReasoning, PartFile: + return true + default: + if !IsSyntheticEventTool(p.ToolName) { + return true + } + } + } + return false +} diff --git a/pkg/session/transcript_test.go b/pkg/session/transcript_test.go new file mode 100644 index 0000000..7523673 --- /dev/null +++ b/pkg/session/transcript_test.go @@ -0,0 +1,74 @@ +package session + +import ( + "os" + "path/filepath" + "testing" +) + +const transcriptFixture = `{"type":"user","uuid":"u-1","sessionId":"0195c1de-4ab8-7000-8000-0123456789ab","timestamp":"2026-07-05T10:00:00Z","cwd":"/home/dev/example","message":{"role":"user","content":"hello"}} +{"type":"system","subtype":"init","uuid":"sys-1","sessionId":"0195c1de-4ab8-7000-8000-0123456789ab","timestamp":"2026-07-05T10:00:01Z","cwd":"/home/dev/example","model":"claude-sonnet-5"} +{"type":"assistant","uuid":"a-1","sessionId":"0195c1de-4ab8-7000-8000-0123456789ab","timestamp":"2026-07-05T10:00:05Z","message":{"role":"assistant","model":"claude-sonnet-5","usage":{"input_tokens":100,"output_tokens":20},"content":[{"type":"text","text":"hi there"}]}} +{"type":"assistant","uuid":"a-2","sessionId":"0195c1de-4ab8-7000-8000-0123456789ab","timestamp":"2026-07-05T10:00:08Z","message":{"role":"assistant","model":"claude-sonnet-5","usage":{"input_tokens":150,"output_tokens":30},"content":[{"type":"tool_use","id":"t-1","name":"Read","input":{"file_path":"main.go"}}]}} +` + +func writeTranscriptFixture(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "projects", "-home-dev-example") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "0195c1de-4ab8-7000-8000-0123456789ab.jsonl") + if err := os.WriteFile(path, []byte(transcriptFixture), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// TestBuildTranscriptFile_SourceLines verifies each message carries the 1-based +// JSONL line it came from, so consumers can seek back into the transcript, and +// that the synthetic system/init line is classified as non-conversational. +func TestBuildTranscriptFile_SourceLines(t *testing.T) { + path := writeTranscriptFixture(t) + s, info, err := BuildTranscriptFile(path) + if err != nil { + t.Fatal(err) + } + if info.RootSessionID != "0195c1de-4ab8-7000-8000-0123456789ab" { + t.Fatalf("root session id = %q", info.RootSessionID) + } + if info.IsAgent { + t.Fatal("root transcript misclassified as agent") + } + + linesByUUID := map[string]int64{} + conversational := 0 + for _, m := range s.Messages { + linesByUUID[m.ID] = m.SourceLine + if IsConversationalMessage(m) { + conversational++ + } + } + expected := map[string]int64{"u-1": 1, "a-1": 3, "a-2": 4} + for id, line := range expected { + if linesByUUID[id] != line { + t.Errorf("message %s source line = %d, want %d", id, linesByUUID[id], line) + } + } + if conversational != 3 { + t.Errorf("conversational messages = %d, want 3 (system/init must be filtered)", conversational) + } +} + +func TestIsSyntheticEventTool(t *testing.T) { + for _, name := range []string{"SessionInit", "HookStart", "Result", "ApiError", "ParseError", "TurnDuration"} { + if !IsSyntheticEventTool(name) { + t.Errorf("%s should be synthetic", name) + } + } + for _, name := range []string{"Read", "Bash", "ExitPlanMode", "Plan", ""} { + if IsSyntheticEventTool(name) { + t.Errorf("%s should not be synthetic", name) + } + } +} From 5b22108659f315a7f830939e9b4cdf99620819de Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 09:44:20 +0300 Subject: [PATCH 036/131] feat(cli): bootstrap native database and run the session monitor in serve --- pkg/cli/database.go | 122 ++++++++++++++++++++++++++++++++++++++++++++ pkg/cli/serve.go | 16 ++++++ 2 files changed, 138 insertions(+) create mode 100644 pkg/cli/database.go diff --git a/pkg/cli/database.go b/pkg/cli/database.go new file mode 100644 index 0000000..0c48b1c --- /dev/null +++ b/pkg/cli/database.go @@ -0,0 +1,122 @@ +package cli + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/monitor" + commonsdb "github.com/flanksource/commons-db/db" +) + +// captainDBState memoizes the process-wide native database handle. The +// database is mandatory: session/plan/prompt surfaces read it exclusively, so +// failing to open it is a loud error rather than a degraded mode. +var captainDBState struct { + once sync.Once + db *database.DB + err error +} + +// captainDB opens (once) the native Captain database: resolve the configured +// DSN (gavel-shared or explicit env) or start the shared embedded postgres, +// run migrations — including the idempotent legacy session-cache cutover — and +// wrap the pool. +func captainDB(ctx context.Context) (*database.DB, error) { + captainDBState.once.Do(func() { + captainDBState.db, captainDBState.err = openCaptainDB(ctx) + }) + return captainDBState.db, captainDBState.err +} + +func openCaptainDB(ctx context.Context) (*database.DB, error) { + dsn, source, err := captainDSN() + if err != nil { + return nil, err + } + log.Debugf("captain database using %s", source) + report, err := database.MigrateWithLegacySessionCutover(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("migrate captain database (%s): %w", source, err) + } + if report != nil { + log.Infof("migrated legacy session cache: %d sessions, %d prompt runs", + report.ImportedSessionRows, report.ImportedPromptRunRows) + } + gormDB, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) + if err != nil { + return nil, fmt.Errorf("open captain database (%s): %w", source, err) + } + return database.Use(gormDB) +} + +// serveMonitorState holds the serve process's live monitor so prompt-run code +// can register freshly launched transcripts for immediate tailing. +var serveMonitorState struct { + mu sync.RWMutex + mon *monitor.Monitor +} + +func setServeMonitor(mon *monitor.Monitor) { + serveMonitorState.mu.Lock() + serveMonitorState.mon = mon + serveMonitorState.mu.Unlock() +} + +func serveMonitor() *monitor.Monitor { + serveMonitorState.mu.RLock() + defer serveMonitorState.mu.RUnlock() + return serveMonitorState.mon +} + +func captainHostID() string { + host, err := os.Hostname() + if err != nil || host == "" { + return "local" + } + return host +} + +// freshenSessionDB runs a one-shot monitor pass (ps poll + incremental +// transcript scan) before a CLI read when no live monitor holds the writer +// lock. With serve running it is a fast no-op. +func freshenSessionDB(ctx context.Context) (*database.DB, error) { + db, err := captainDB(ctx) + if err != nil { + return nil, err + } + if err := monitor.RunOnce(ctx, monitor.Config{DB: db, HostID: captainHostID()}); err != nil { + return nil, fmt.Errorf("refresh session database: %w", err) + } + return db, nil +} + +// captainDSN resolves the database connection: explicit env DSNs first, then a +// gavel-shared database, finally captain's own shared embedded postgres. +func captainDSN() (dsn, source string, err error) { + for _, env := range []string{gavelDBEnvDSN, gavelCacheEnvDSN, captainSessionEnvDSN} { + if dsn := strings.TrimSpace(os.Getenv(env)); dsn != "" { + return dsn, env, nil + } + } + dsn, source, err = gavelConfiguredSessionDSN() + if err != nil { + return "", "", fmt.Errorf("resolve gavel database: %w", err) + } + if dsn != "" { + return dsn, source, nil + } + dir, err := sessionDBDir() + if err != nil { + return "", "", fmt.Errorf("resolve captain database directory: %w", err) + } + // Shared embedded-postgres daemon: leave running for other captain processes. + dsn, _, err = commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{DataDir: dir}) + if err != nil { + return "", "", fmt.Errorf("start captain embedded database: %w", err) + } + return dsn, "captain embedded database", nil +} diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index 4210511..64611dc 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -19,6 +19,7 @@ import ( "syscall" "time" + "github.com/flanksource/captain/pkg/monitor" "github.com/flanksource/clicky/aichat" "github.com/flanksource/clicky/rpc" rpchttp "github.com/flanksource/clicky/rpc/http" @@ -196,6 +197,21 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() + db, err := captainDB(ctx) + if err != nil { + return err + } + mon, err := monitor.New(monitor.Config{DB: db, HostID: captainHostID()}) + if err != nil { + return err + } + setServeMonitor(mon) + go func() { + if err := mon.Run(ctx); err != nil { + log.Errorf("session monitor stopped: %v", err) + } + }() + go prunePromptRuns(ctx, promptRuns) errCh := make(chan error, 1) From 16e5980e69a99788815bc1755ef4777e3bb8b321 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 10:11:27 +0300 Subject: [PATCH 037/131] feat(cli): serve session, project, and plan reads from the native database --- migrations/50_views_and_triggers.sql | 20 ++- pkg/cli/database.go | 32 +++- pkg/cli/database_test.go | 39 +++++ pkg/cli/plan.go | 81 ++++----- pkg/cli/plan_native_integration_test.go | 4 +- pkg/cli/plan_test.go | 5 + pkg/cli/projects.go | 87 ++++------ pkg/cli/projects_test.go | 39 +---- pkg/cli/serve.go | 21 ++- pkg/cli/serve_test.go | 6 +- pkg/cli/session_live.go | 148 ++-------------- pkg/cli/session_process.go | 16 ++ pkg/cli/session_ps.go | 14 +- pkg/cli/session_record_db.go | 213 ++++++++++++++++++++++++ pkg/cli/session_throughput.go | 20 +-- pkg/cli/session_throughput_test.go | 1 + pkg/cli/sessions.go | 133 +++++++-------- pkg/cli/sessions_test.go | 97 ++--------- pkg/cli/webapp/src/sessionData.ts | 7 +- pkg/database/session_process_store.go | 2 +- pkg/monitor/monitor.go | 12 +- pkg/monitor/monitor_integration_test.go | 15 +- pkg/monitor/oneshot.go | 7 +- pkg/monitor/process.go | 75 +++++---- 24 files changed, 569 insertions(+), 525 deletions(-) create mode 100644 pkg/cli/database_test.go create mode 100644 pkg/cli/session_record_db.go diff --git a/migrations/50_views_and_triggers.sql b/migrations/50_views_and_triggers.sql index 491d016..fba4f88 100644 --- a/migrations/50_views_and_triggers.sql +++ b/migrations/50_views_and_triggers.sql @@ -660,11 +660,6 @@ SELECT process.command, process.cwd AS process_cwd, process.surface, - process.source AS process_source, - process.cpu_percent, - process.memory_percent, - process.memory_rss_bytes, - process.sampled_at AS process_sampled_at, process.process_started_at, process.last_heartbeat_at, process.lease_owner, @@ -711,7 +706,14 @@ SELECT + COALESCE(call_stats.output_tokens, 0) + COALESCE(call_stats.cache_read_tokens, 0) + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, - COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, + -- Appended after initial release: CREATE OR REPLACE VIEW only allows adding + -- columns at the end, so later additions must stay below this line. + process.source AS process_source, + process.cpu_percent, + process.memory_percent, + process.memory_rss_bytes, + process.sampled_at AS process_sampled_at FROM public.captain_sessions s LEFT JOIN LATERAL ( SELECT p.* @@ -834,14 +836,16 @@ SELECT m.role, m.parts, m.raw, - m.source_line, m.schema_version, m.occurred_at, m.recorded_at, c.model, c.backend, c.effort, - c.status AS model_call_status + c.status AS model_call_status, + -- Appended after initial release: CREATE OR REPLACE VIEW only allows adding + -- columns at the end, so later additions must stay below this line. + m.source_line FROM public.captain_messages m LEFT JOIN public.captain_model_calls c ON c.id = m.model_call_id; diff --git a/pkg/cli/database.go b/pkg/cli/database.go index 0c48b1c..a302942 100644 --- a/pkg/cli/database.go +++ b/pkg/cli/database.go @@ -16,9 +16,10 @@ import ( // database is mandatory: session/plan/prompt surfaces read it exclusively, so // failing to open it is a loud error rather than a degraded mode. var captainDBState struct { - once sync.Once - db *database.DB - err error + mu sync.Mutex + opened bool + db *database.DB + err error } // captainDB opens (once) the native Captain database: resolve the configured @@ -26,12 +27,26 @@ var captainDBState struct { // run migrations — including the idempotent legacy session-cache cutover — and // wrap the pool. func captainDB(ctx context.Context) (*database.DB, error) { - captainDBState.once.Do(func() { + captainDBState.mu.Lock() + defer captainDBState.mu.Unlock() + if !captainDBState.opened { captainDBState.db, captainDBState.err = openCaptainDB(ctx) - }) + captainDBState.opened = true + } return captainDBState.db, captainDBState.err } +// setCaptainDBForTest injects (or, with nil, resets) the process-wide handle +// so tests run against their own embedded database instead of a configured +// DSN. Production code never calls this. +func setCaptainDBForTest(db *database.DB) { + captainDBState.mu.Lock() + defer captainDBState.mu.Unlock() + captainDBState.db = db + captainDBState.err = nil + captainDBState.opened = db != nil +} + func openCaptainDB(ctx context.Context) (*database.DB, error) { dsn, source, err := captainDSN() if err != nil { @@ -80,6 +95,10 @@ func captainHostID() string { return host } +// monitorDiscoverProcesses is indirected so cli tests can fake live-process +// discovery; nil selects the monitor's real ps-based discovery. +var monitorDiscoverProcesses func() ([]monitor.Process, error) + // freshenSessionDB runs a one-shot monitor pass (ps poll + incremental // transcript scan) before a CLI read when no live monitor holds the writer // lock. With serve running it is a fast no-op. @@ -88,7 +107,8 @@ func freshenSessionDB(ctx context.Context) (*database.DB, error) { if err != nil { return nil, err } - if err := monitor.RunOnce(ctx, monitor.Config{DB: db, HostID: captainHostID()}); err != nil { + config := monitor.Config{DB: db, HostID: captainHostID(), DiscoverProcesses: monitorDiscoverProcesses} + if err := monitor.RunOnce(ctx, config); err != nil { return nil, fmt.Errorf("refresh session database: %w", err) } return db, nil diff --git a/pkg/cli/database_test.go b/pkg/cli/database_test.go new file mode 100644 index 0000000..38b8586 --- /dev/null +++ b/pkg/cli/database_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/monitor" + commonsdb "github.com/flanksource/commons-db/db" + "github.com/stretchr/testify/require" +) + +// withTestCaptainDB starts an isolated embedded postgres, injects it as the +// process-wide captain database, and fakes live-process discovery so tests +// never touch a configured DSN or the host's real processes. +func withTestCaptainDB(t *testing.T, processes ...monitor.Process) *database.DB { + t.Helper() + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres cli tests") + } + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_cli", + }) + require.NoError(t, err) + db, err := database.Open(t.Context(), database.Config{DSN: dsn}) + require.NoError(t, err) + + setCaptainDBForTest(db) + monitorDiscoverProcesses = func() ([]monitor.Process, error) { return processes, nil } + t.Cleanup(func() { + setCaptainDBForTest(nil) + monitorDiscoverProcesses = nil + require.NoError(t, db.Close()) + require.NoError(t, stop()) + }) + return db +} diff --git a/pkg/cli/plan.go b/pkg/cli/plan.go index 792e348..09c2f18 100644 --- a/pkg/cli/plan.go +++ b/pkg/cli/plan.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "sort" "strings" "github.com/flanksource/captain/pkg/ai/history" @@ -15,7 +14,6 @@ import ( "github.com/flanksource/clicky" "github.com/flanksource/clicky/api" "github.com/flanksource/clicky/api/icons" - "gorm.io/gorm" ) type PlanOptions struct { @@ -43,6 +41,7 @@ type PlanResult struct { } func RunPlan(opts PlanOptions) (PlanResult, error) { + ctx := context.Background() source, err := normalizeSessionSource(opts.Source) if err != nil { return PlanResult{}, err @@ -51,42 +50,28 @@ func RunPlan(opts PlanOptions) (PlanResult, error) { if err != nil { return PlanResult{}, err } + db, err := freshenSessionDB(ctx) + if err != nil { + return PlanResult{}, err + } id := strings.TrimSpace(opts.SessionID) if id != "" { - if native := sessionStores.nativeDatabase(); native != nil { - persisted, ok, err := resolveNativePlan(context.Background(), native, id, source) - if err != nil { - return PlanResult{}, err - } - if ok { - persisted.pathOnly = opts.PathOnly - return *persisted, nil - } - } - if candidate, ok, err := findSessionCandidateByID(id, source); err != nil { + persisted, ok, err := resolveNativePlan(ctx, db, id, source) + if err != nil { return PlanResult{}, err - } else if ok { - 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 } - - // A hashed session key cannot be found from the transcript filename, so - // keep the older all-project scan as a fallback for that less-common form. - candidates, err := discoverSessionCandidates(context.Background(), "", true, source) + if ok { + persisted.pathOnly = opts.PathOnly + return *persisted, nil + } + overview, err := db.GetSessionOverviewByIdentity(ctx, id) if err != nil { return PlanResult{}, err } - candidate, ok := matchPlanCandidate(candidates, id) - if !ok { - return PlanResult{}, fmt.Errorf("session %q not found", id) + candidate := candidateFromOverview(*overview) + if candidate.path == "" { + return PlanResult{}, fmt.Errorf("session %q has no transcript recorded on this host", id) } plan, err := resolveSessionPlan(candidate) if err != nil { @@ -99,14 +84,23 @@ func RunPlan(opts PlanOptions) (PlanResult, error) { return *plan, nil } - candidates, err := discoverSessionCandidates(context.Background(), cwd, opts.All, source) + _, projectRoot, _ := resolveSessionScope(cwd, opts.All, "") + filter := captaindb.SessionOverviewFilter{RootsOnly: true} + if source != "all" { + filter.Source = source + } + overviews, err := db.ListSessionOverviews(ctx, filter) 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 { + for _, overview := range overviews { + candidate := candidateFromOverview(overview) + if candidate.path == "" { + continue + } + if projectRoot != "" && !sessionRecordMatchesProject(SessionRecord{CWD: stringOr(overview.CWD, "")}, projectRoot) { + continue + } plan, err := resolveSessionPlan(candidate) if err != nil || plan == nil { continue @@ -120,11 +114,7 @@ func RunPlan(opts PlanOptions) (PlanResult, error) { // resolveNativePlan resolves persisted plan content without consulting the // transcript or source plan path. Approved content wins; otherwise the latest // immutable revision of the newest plan variant is returned. -func resolveNativePlan(ctx context.Context, gormDB *gorm.DB, identity, source string) (*PlanResult, bool, error) { - db, err := captaindb.Use(gormDB) - if err != nil { - return nil, false, err - } +func resolveNativePlan(ctx context.Context, db *captaindb.DB, identity, source string) (*PlanResult, bool, error) { sourceFilter := source if sourceFilter == "all" { sourceFilter = "" @@ -175,17 +165,6 @@ func resolveNativePlan(ctx context.Context, gormDB *gorm.DB, identity, source st }, true, nil } -// 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) { diff --git a/pkg/cli/plan_native_integration_test.go b/pkg/cli/plan_native_integration_test.go index 81a9583..e581fa5 100644 --- a/pkg/cli/plan_native_integration_test.go +++ b/pkg/cli/plan_native_integration_test.go @@ -50,7 +50,7 @@ func TestResolveNativePlanUsesPersistedApprovedContentWithoutSourceFile(t *testi require.NoError(t, err) require.NoError(t, os.Remove(deletedPath)) - result, ok, err := resolveNativePlan(t.Context(), db.Gorm(), "provider-plan-session", "all") + result, ok, err := resolveNativePlan(t.Context(), db, "provider-plan-session", "all") require.NoError(t, err) require.True(t, ok) assert.Equal(t, session.ID.String(), result.SessionID) @@ -59,7 +59,7 @@ func TestResolveNativePlanUsesPersistedApprovedContentWithoutSourceFile(t *testi assert.Equal(t, "# Approved durable plan", result.Content) assert.False(t, result.OnDisk) - byUUID, ok, err := resolveNativePlan(t.Context(), db.Gorm(), session.ID.String(), "codex") + byUUID, ok, err := resolveNativePlan(t.Context(), db, session.ID.String(), "codex") require.NoError(t, err) require.True(t, ok) assert.Equal(t, result.Content, byUUID.Content) diff --git a/pkg/cli/plan_test.go b/pkg/cli/plan_test.go index 138d705..72d64c8 100644 --- a/pkg/cli/plan_test.go +++ b/pkg/cli/plan_test.go @@ -37,6 +37,7 @@ func claudePlanSession(t *testing.T, home, project, id, slug, planPath, inlineBo func TestRunPlanClaudePrefersDiskContent(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "proj") require.NoError(t, os.MkdirAll(project, 0o755)) t.Chdir(project) @@ -59,6 +60,7 @@ func TestRunPlanClaudePrefersDiskContent(t *testing.T) { func TestRunPlanClaudeDefaultSourceUsesDirectSessionLookup(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "proj") require.NoError(t, os.MkdirAll(project, 0o755)) t.Chdir(project) @@ -76,6 +78,7 @@ func TestRunPlanClaudeDefaultSourceUsesDirectSessionLookup(t *testing.T) { func TestRunPlanClaudeInlineWhenMissingOnDisk(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "proj") require.NoError(t, os.MkdirAll(project, 0o755)) t.Chdir(project) @@ -93,6 +96,7 @@ func TestRunPlanClaudeInlineWhenMissingOnDisk(t *testing.T) { func TestRunPlanClaudeNoPlanErrors(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "proj") require.NoError(t, os.MkdirAll(project, 0o755)) t.Chdir(project) @@ -118,6 +122,7 @@ func TestRunPlanClaudeNoPlanErrors(t *testing.T) { func TestRunPlanCodexChecklist(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "proj") require.NoError(t, os.MkdirAll(project, 0o755)) t.Chdir(project) diff --git a/pkg/cli/projects.go b/pkg/cli/projects.go index 8344edd..6eba8d5 100644 --- a/pkg/cli/projects.go +++ b/pkg/cli/projects.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "os" "path/filepath" @@ -9,8 +10,8 @@ import ( "strings" "time" - "github.com/flanksource/captain/pkg/ai/history" "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/database" "github.com/timberio/go-datemath" ) @@ -72,44 +73,38 @@ func RunProjectsList(_ ProjectsListOptions) (any, error) { return ProjectsListResult{Total: len(projects), Rows: rows}, nil } -func RunProjectOptions() (ProjectOptionsResult, error) { - accs := map[string]*projectOptionAccumulator{} - - projects, err := scanProjects() +func RunProjectOptions(ctx context.Context) (ProjectOptionsResult, error) { + db, err := freshenSessionDB(ctx) if err != nil { return ProjectOptionsResult{}, err } - for _, project := range projects { - addProjectOption(accs, projectOptionPath(project), "claude", project.sessions, project.lastUsed) + overviews, err := db.ListSessionOverviews(ctx, database.SessionOverviewFilter{RootsOnly: true}) + if err != nil { + return ProjectOptionsResult{}, err } + return projectOptionsFromOverviews(overviews), nil +} - if codexFiles, err := history.FindCodexSessionFiles(); err == nil { - for _, file := range codexFiles { - meta, err := history.ReadCodexSessionMeta(file) - if err != nil || meta == nil || strings.TrimSpace(meta.CWD) == "" { - continue - } - lastUsed := time.Time{} - if info, err := os.Stat(file); err == nil { - lastUsed = info.ModTime() - } - if meta.StartedAt != nil && meta.StartedAt.After(lastUsed) { - lastUsed = *meta.StartedAt - } - addProjectOption(accs, sessionProjectRoot(meta.CWD), "codex", 1, lastUsed) +// projectOptionsFromOverviews groups sessions by project root (derived from +// each session's working directory) into picker options, flagging projects +// with a live process as source "live". +func projectOptionsFromOverviews(overviews []database.SessionOverview) ProjectOptionsResult { + accs := map[string]*projectOptionAccumulator{} + for _, overview := range overviews { + cwd := stringOr(overview.CWD, stringOr(overview.ProcessCWD, "")) + if strings.TrimSpace(cwd) == "" { + continue } - } - - if processes, err := discoverSessionProcesses(); err == nil { - for _, proc := range processes { - if strings.TrimSpace(proc.CWD) == "" { - continue - } - lastUsed := time.Time{} - if proc.StartedAt != nil { - lastUsed = *proc.StartedAt - } - addProjectOption(accs, sessionProjectRoot(proc.CWD), "live", 0, lastUsed) + lastUsed := time.Time{} + if overview.LastActivityAt != nil { + lastUsed = *overview.LastActivityAt + } else if overview.StartedAt != nil { + lastUsed = *overview.StartedAt + } + root := sessionProjectRoot(cwd) + addProjectOption(accs, root, overview.Source, 1, lastUsed) + if overview.ProcessActive { + addProjectOption(accs, root, "live", 0, lastUsed) } } @@ -149,7 +144,7 @@ func RunProjectOptions() (ProjectOptionsResult, error) { return left.Label < right.Label }) - return ProjectOptionsResult{Total: len(projectsOut), Projects: projectsOut}, nil + return ProjectOptionsResult{Total: len(projectsOut), Projects: projectsOut} } func addProjectOption(accs map[string]*projectOptionAccumulator, path, source string, sessions int, lastUsed time.Time) { @@ -171,30 +166,6 @@ func addProjectOption(accs map[string]*projectOptionAccumulator, path, source st } } -func projectOptionPath(project projectDirInfo) string { - sessions, _ := filepath.Glob(filepath.Join(project.dirPath, "*.jsonl")) - sort.Slice(sessions, func(i, j int) bool { - left, leftErr := os.Stat(sessions[i]) - right, rightErr := os.Stat(sessions[j]) - if leftErr != nil || rightErr != nil { - return sessions[i] < sessions[j] - } - return left.ModTime().After(right.ModTime()) - }) - for _, sessionFile := range sessions { - entries, err := claude.ReadHistoryFileWithOptions(sessionFile, claude.ReadOptions{}) - if err != nil { - continue - } - for _, entry := range entries { - if strings.TrimSpace(entry.CWD) != "" { - return sessionProjectRoot(entry.CWD) - } - } - } - return project.name -} - func projectOptionLabel(path string) string { parts := strings.Split(filepath.ToSlash(path), "/") filtered := make([]string, 0, len(parts)) diff --git a/pkg/cli/projects_test.go b/pkg/cli/projects_test.go index f296260..7af096c 100644 --- a/pkg/cli/projects_test.go +++ b/pkg/cli/projects_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/database" ) func TestFormatBytes(t *testing.T) { @@ -51,7 +51,7 @@ func TestUuidStem(t *testing.T) { } } -func TestRunProjectOptionsMergesClaudeCodexAndLive(t *testing.T) { +func TestProjectOptionsFromOverviewsMergesClaudeCodexAndLive(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) claudeProject := filepath.Join(home, "work", "claude-project") @@ -64,44 +64,21 @@ func TestRunProjectOptionsMergesClaudeCodexAndLive(t *testing.T) { markProjectRoot(t, dir) } - writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(claudeProject), "sess-claude.jsonl"), - map[string]any{ - "type": "assistant", - "sessionId": "sess-claude", - "timestamp": "2026-06-01T10:00:00Z", - "cwd": claudeProject, - "message": map[string]any{ - "role": "assistant", - "content": []any{map[string]any{"type": "text", "text": "ok"}}, - }, - }, - ) - writeCodexSession(t, filepath.Join(home, ".codex", "sessions", "2026", "06", "rollout-codex.jsonl"), "codex-project", codexProject) - started := time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC) - orig := discoverSessionProcesses - discoverSessionProcesses = func() ([]agentProcess, error) { - return []agentProcess{{ - Source: "codex", - PID: 101, - Active: true, - StartedAt: &started, - CWD: liveProject, - Command: "codex", - }}, nil + overviews := []database.SessionOverview{ + {Source: "claude", CWD: &claudeProject, LastActivityAt: &started}, + {Source: "codex", CWD: &codexProject, StartedAt: &started}, + {Source: "codex", CWD: &liveProject, StartedAt: &started, ProcessActive: true}, } - t.Cleanup(func() { discoverSessionProcesses = orig }) - result, err := RunProjectOptions() - if err != nil { - t.Fatalf("RunProjectOptions: %v", err) - } + result := projectOptionsFromOverviews(overviews) if result.Total != 3 { t.Fatalf("projects = %+v", result) } assertProjectOption(t, result.Projects, claudeProject, "claude") assertProjectOption(t, result.Projects, codexProject, "codex") assertProjectOption(t, result.Projects, liveProject, "live") + assertProjectOption(t, result.Projects, liveProject, "codex") } func assertProjectOption(t *testing.T, projects []ProjectOption, path, source string) { diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index 64611dc..c9571c9 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -343,7 +343,7 @@ func handleSessionsThroughput() http.HandlerFunc { func handleProjects() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - result, err := RunProjectOptions() + result, err := RunProjectOptions(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -362,11 +362,14 @@ func handleSessionGet() http.HandlerFunc { http.Error(w, "session id is required", http.StatusBadRequest) return } - source := strings.TrimSpace(r.URL.Query().Get("source")) - if source == "" { - source = "all" + query := r.URL.Query() + opts := SessionGetOptions{ + ID: id, + Offset: queryInt(query.Get("offset")), + Limit: queryInt(query.Get("limit")), + Tail: queryInt(query.Get("tail")), } - s, err := RunSessionGet(r.Context(), SessionGetOptions{ID: id, Source: source}) + s, err := RunSessionGet(r.Context(), opts) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return @@ -375,6 +378,14 @@ func handleSessionGet() http.HandlerFunc { } } +func queryInt(value string) int { + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || n < 0 { + return 0 + } + return n +} + func handleThreadFromAgent(store aichat.ThreadStore) http.HandlerFunc { type request struct { Title string `json:"title"` diff --git a/pkg/cli/serve_test.go b/pkg/cli/serve_test.go index abc3db5..88908ef 100644 --- a/pkg/cli/serve_test.go +++ b/pkg/cli/serve_test.go @@ -65,6 +65,7 @@ func TestHandleThreadFromAgentRequiresProviderSession(t *testing.T) { func TestHandleSessionGetReturnsUnifiedModel(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) @@ -106,6 +107,7 @@ func TestHandleSessionGetReturnsUnifiedModel(t *testing.T) { func TestHandleProjectsReturnsOptions(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) @@ -124,10 +126,6 @@ func TestHandleProjectsReturnsOptions(t *testing.T) { }, ) - orig := discoverSessionProcesses - discoverSessionProcesses = func() ([]agentProcess, error) { return nil, nil } - t.Cleanup(func() { discoverSessionProcesses = orig }) - req := httptest.NewRequest(http.MethodGet, "/api/captain/projects", nil) rec := httptest.NewRecorder() handleProjects()(rec, req) diff --git a/pkg/cli/session_live.go b/pkg/cli/session_live.go index dafe728..4c438ce 100644 --- a/pkg/cli/session_live.go +++ b/pkg/cli/session_live.go @@ -8,11 +8,8 @@ import ( "time" "github.com/flanksource/captain/pkg/claude" - rpchttp "github.com/flanksource/clicky/rpc/http" ) -var discoverSessionProcesses = discoverAgentProcesses - const defaultSessionLiveLimit = 25 func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveResult, error) { @@ -25,38 +22,29 @@ func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveRe return SessionLiveResult{}, err } - scope, projectRoot, searchAll := resolveSessionScope(cwd, opts.All, opts.Project) + scope, projectRoot, _ := resolveSessionScope(cwd, opts.All, opts.Project) limit := opts.Limit if limit <= 0 && !opts.Full { limit = defaultSessionLiveLimit } - records, err := discoverLiveSessionRecords(ctx, cwd, searchAll, source, limit, opts.Full, projectRoot) + db, err := freshenSessionDB(ctx) if err != nil { return SessionLiveResult{}, err } - - stopEnrich := rpchttp.Track(ctx, "enrich") - processes, _ := discoverSessionProcesses() - if projectRoot != "" { - processes = filterAgentProcessesByProject(processes, projectRoot) - } - records = enrichSessionsWithLive(records, processes) - filtered := make([]SessionRecord, 0, len(records)) - for _, record := range records { - if sessionMatchesQuery(record, opts.Query) { - filtered = append(filtered, record) - } + records, err := dbSessionRecords(ctx, db, sessionRecordQuery{ + Source: source, ProjectRoot: projectRoot, Query: opts.Query, + }) + if err != nil { + return SessionLiveResult{}, err } - sortSessionRecords(filtered) - total := len(filtered) - summary := summarizeSessionDashboard(filtered) - stopEnrich() - if !opts.Full && limit > 0 && len(filtered) > limit { - filtered = filtered[:limit] + total := len(records) + summary := summarizeSessionDashboard(records) + if !opts.Full && limit > 0 && len(records) > limit { + records = records[:limit] } return SessionLiveResult{ - Sessions: filtered, + Sessions: records, Total: total, Source: source, Scope: scope, @@ -65,42 +53,6 @@ func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveRe }, nil } -func discoverLiveSessionRecords(ctx context.Context, cwd string, searchAll bool, source string, limit int, full bool, projectRoot string) ([]SessionRecord, error) { - if full { - project := "" - if searchAll && projectRoot != "" { - project = projectRoot - } - list, err := RunSessionList(ctx, SessionListOptions{ - Source: source, - All: searchAll, - Project: project, - Query: "", - Limit: 0, - }) - if err != nil { - return nil, err - } - return list.Sessions, nil - } - - candidateLimit := limit - if searchAll && projectRoot != "" { - candidateLimit = 0 - } - candidates, err := discoverLiveSessionCandidates(ctx, cwd, searchAll, source, candidateLimit) - if err != nil { - return nil, err - } - candidates = filterSessionCandidatesByProject(candidates, projectRoot) - records := make([]SessionRecord, 0, len(candidates)) - for _, candidate := range candidates { - records = append(records, candidate.record) - } - sortSessionRecords(records) - return records, nil -} - func sessionProjectRoot(cwd string) string { if cwd == "" { return "" @@ -112,82 +64,6 @@ func sessionProjectRoot(cwd string) string { return cwd } -func filterAgentProcessesByProject(processes []agentProcess, projectRoot string) []agentProcess { - if projectRoot == "" { - return processes - } - filtered := make([]agentProcess, 0, len(processes)) - for _, proc := range processes { - if sessionRecordMatchesProject(SessionRecord{CWD: proc.CWD}, projectRoot) { - filtered = append(filtered, proc) - } - } - return filtered -} - -func enrichSessionsWithLive(records []SessionRecord, processes []agentProcess) []SessionRecord { - out := make([]SessionRecord, len(records)) - copy(out, records) - matched := make(map[int]bool) - for i := range out { - idx := bestLiveMatch(out[i], processes, matched) - if idx < 0 { - out[i].Health = deriveSessionHealth(out[i]) - continue - } - matched[idx] = true - out[i].Live = processes[idx].wire() - if out[i].CWD == "" { - out[i].CWD = processes[idx].CWD - } - if out[i].StartedAt == nil && processes[idx].StartedAt != nil { - out[i].StartedAt = processes[idx].StartedAt - } - out[i].Health = deriveSessionHealth(out[i]) - } - for i, proc := range processes { - if matched[i] { - continue - } - record := SessionRecord{ - Key: fmt.Sprintf("live-%s-%d", proc.Source, proc.PID), - ID: fmt.Sprintf("pid:%d", proc.PID), - Source: proc.Source, - CWD: proc.CWD, - StartedAt: proc.StartedAt, - DetailAvailable: false, - Live: proc.wire(), - } - record.Health = deriveSessionHealth(record) - out = append(out, record) - } - sortSessionRecords(out) - return out -} - -func bestLiveMatch(record SessionRecord, processes []agentProcess, matched map[int]bool) int { - if record.Source == "" { - return -1 - } - best := -1 - for i, proc := range processes { - if matched[i] || proc.Source != record.Source { - continue - } - if record.CWD != "" && proc.CWD != "" && samePath(record.CWD, proc.CWD) { - return i - } - if best < 0 && record.CWD == "" { - best = i - } - } - return best -} - -func samePath(a, b string) bool { - return strings.TrimRight(a, "/") == strings.TrimRight(b, "/") -} - func deriveSessionHealth(record SessionRecord) []SessionHealthWire { var health []SessionHealthWire if record.Context != nil { diff --git a/pkg/cli/session_process.go b/pkg/cli/session_process.go index 353e22f..b191457 100644 --- a/pkg/cli/session_process.go +++ b/pkg/cli/session_process.go @@ -45,6 +45,22 @@ func (p agentProcess) wire() *SessionLiveWire { } } +// discoverSessionProcesses is indirected so ps tests can fake process lists. +var discoverSessionProcesses = discoverAgentProcesses + +func filterAgentProcessesByProject(processes []agentProcess, projectRoot string) []agentProcess { + if projectRoot == "" { + return processes + } + filtered := make([]agentProcess, 0, len(processes)) + for _, proc := range processes { + if sessionRecordMatchesProject(SessionRecord{CWD: proc.CWD}, projectRoot) { + filtered = append(filtered, proc) + } + } + return filtered +} + // parseClaudeSessionIDFromCommand extracts the session id claude was launched // with, from its "--session-id " / "--resume " argv (either the // space-separated or "=" form). Returns "" when absent. diff --git a/pkg/cli/session_ps.go b/pkg/cli/session_ps.go index e07fb36..40dcd6b 100644 --- a/pkg/cli/session_ps.go +++ b/pkg/cli/session_ps.go @@ -373,14 +373,16 @@ func isPreferredPrimary(candidate, current openTranscript) bool { } // psRecord builds the session record for a live process, augmenting it with the -// cached/DB summary (tokens, cost, context, model) when its transcript is known, +// database summary (tokens, cost, context, model) when its session is known, // and falling back to a minimal synthetic record otherwise. func psRecord(ctx context.Context, proc agentProcess) SessionRecord { - if proc.SessionFile != "" { - if cands := summarizeSessionRefs(ctx, []sessionFileRef{{source: proc.Source, path: proc.SessionFile}}); len(cands) > 0 { - record := cands[0].record - applyLiveProcess(&record, proc) - return record + if proc.SessionID != "" { + if db, err := captainDB(ctx); err == nil { + if overview, err := db.GetSessionOverviewByIdentity(ctx, proc.SessionID); err == nil { + record := recordFromOverview(*overview) + applyLiveProcess(&record, proc) + return record + } } } record := SessionRecord{ diff --git a/pkg/cli/session_record_db.go b/pkg/cli/session_record_db.go new file mode 100644 index 0000000..aced384 --- /dev/null +++ b/pkg/cli/session_record_db.go @@ -0,0 +1,213 @@ +package cli + +import ( + "context" + "encoding/json" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/session" +) + +// sessionRecordQuery narrows the DB-backed session record list. +type sessionRecordQuery struct { + Source string // "all", "claude", or "codex" + ProjectRoot string + Query string + LiveOnly bool +} + +// dbSessionRecords is the single source for session list/live/throughput +// records: it reads the captain_session_overview view (populated by the +// monitor) and projects rows onto the SessionRecord wire shape. +func dbSessionRecords(ctx context.Context, db *database.DB, q sessionRecordQuery) ([]SessionRecord, error) { + filter := database.SessionOverviewFilter{RootsOnly: true, LiveOnly: q.LiveOnly} + if q.Source != "" && q.Source != "all" { + filter.Source = q.Source + } + overviews, err := db.ListSessionOverviews(ctx, filter) + if err != nil { + return nil, err + } + records := make([]SessionRecord, 0, len(overviews)) + for _, overview := range overviews { + record := recordFromOverview(overview) + if q.ProjectRoot != "" && !sessionRecordMatchesProject(record, q.ProjectRoot) { + continue + } + if !sessionMatchesQuery(record, q.Query) { + continue + } + records = append(records, record) + } + sortSessionRecords(records) + return records, nil +} + +// resolveOverviewByAnyID resolves a session by UUID or provider-session-id +// prefix, falling back to the path-derived record Key the UI navigates with +// (source-, not stored in the database). +func resolveOverviewByAnyID(ctx context.Context, db *database.DB, id string) (*database.SessionOverview, error) { + overview, err := db.GetSessionOverviewByIdentity(ctx, id) + if err == nil { + return overview, nil + } + overviews, listErr := db.ListSessionOverviews(ctx, database.SessionOverviewFilter{RootsOnly: true}) + if listErr != nil { + return nil, err + } + for i := range overviews { + path := stringOr(overviews[i].HistoryFile, stringOr(overviews[i].Path, "")) + if path != "" && sessionRecordKey(overviews[i].Source, path) == id { + return &overviews[i], nil + } + } + return nil, err +} + +// candidateFromOverview adapts a DB overview row to the transcript-parsing +// candidate shape used by the detail and plan readers. +func candidateFromOverview(overview database.SessionOverview) sessionCandidate { + path := stringOr(overview.HistoryFile, stringOr(overview.Path, "")) + return sessionCandidate{ + record: minimalSessionRecord(overview.Source, path, stringOr(overview.ProviderSessionID, overview.ID.String())), + path: path, + } +} + +// sessionOverviewMetadata is the monitor-owned projection stored in +// captain_sessions.metadata (see pkg/monitor sessionMetadata). +type sessionOverviewMetadata struct { + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + Files session.ChangedFiles `json:"files,omitempty"` + Approvals session.ApprovalStats `json:"approvals,omitempty"` + Plan *session.Plan `json:"plan,omitempty"` +} + +func overviewMetadata(overview database.SessionOverview) sessionOverviewMetadata { + var metadata sessionOverviewMetadata + if len(overview.Metadata) > 0 { + _ = json.Unmarshal(overview.Metadata, &metadata) + } + return metadata +} + +func overviewGitBranch(overview database.SessionOverview) string { + if len(overview.Git) == 0 { + return "" + } + var git struct { + Branch string `json:"branch"` + } + _ = json.Unmarshal(overview.Git, &git) + return git.Branch +} + +func stringOr(value *string, fallback string) string { + if value != nil && *value != "" { + return *value + } + return fallback +} + +// recordFromOverview projects one overview row to the SessionRecord wire +// shape, keeping the path-derived Key stable with the previous file-scan +// implementation so UI list keys survive the storage switch. +func recordFromOverview(overview database.SessionOverview) SessionRecord { + metadata := overviewMetadata(overview) + path := stringOr(overview.HistoryFile, stringOr(overview.Path, "")) + id := stringOr(overview.ProviderSessionID, overview.ID.String()) + key := overview.ID.String() + if path != "" { + key = sessionRecordKey(overview.Source, path) + } + record := SessionRecord{ + Key: key, + ID: id, + Source: overview.Source, + Project: stringOr(overview.Project, ""), + Slug: stringOr(overview.Slug, ""), + Title: stringOr(overview.Title, ""), + InitialPrompt: stringOr(overview.InitialPrompt, ""), + StartedAt: overview.StartedAt, + EndedAt: overview.LastActivityAt, + Model: stringOr(overview.Model, metadata.Model), + ReasoningEffort: stringOr(overview.Effort, ""), + Version: stringOr(overview.CLIVersion, ""), + GitBranch: overviewGitBranch(overview), + Provider: metadata.Provider, + CWD: stringOr(overview.CWD, ""), + ToolCalls: int(overview.ToolCallCount), + Messages: int(overview.MessageCount), + DetailAvailable: path != "", + CostUSD: overview.CostUSD, + } + if record.EndedAt == nil { + record.EndedAt = overview.EndedAt + } + if overview.TotalTokens > 0 { + record.Tokens = &SessionTokensWire{ + InputTokens: int(overview.InputTokens), + OutputTokens: int(overview.OutputTokens), + CacheReadTokens: int(overview.CacheReadTokens), + CacheCreationTokens: int(overview.CacheWriteTokens), + TotalTokens: int(overview.TotalTokens), + } + } + if overview.ContextTokens != nil && *overview.ContextTokens > 0 { + context := &SessionContextWire{UsedTokens: int(*overview.ContextTokens)} + if overview.ContextWindowTokens != nil { + context.WindowTokens = int(*overview.ContextWindowTokens) + } + if overview.ContextFreePercent != nil { + context.FreePercent = *overview.ContextFreePercent + } + record.Context = context + } + if overview.ProcessActive { + record.Live = liveWireFromOverview(overview, id, path) + if record.CWD == "" { + record.CWD = stringOr(overview.ProcessCWD, "") + } + } + record.Health = deriveSessionHealth(record) + return record +} + +func liveWireFromOverview(overview database.SessionOverview, id, path string) *SessionLiveWire { + status := stringOr(overview.ProcessStatus, "") + _, active := processLiveStatus(status) + live := &SessionLiveWire{ + Status: status, + Active: active, + CWD: stringOr(overview.ProcessCWD, ""), + Command: stringOr(overview.ProcessCommand, ""), + SessionID: id, + SessionFile: path, + StartedAt: overview.ProcessStartedAt, + LastActivity: overview.LastActivityAt, + } + if overview.PID != nil { + live.PID = int(*overview.PID) + } + if overview.CPUPercent != nil { + live.CPUPercent = *overview.CPUPercent + } + if overview.MemoryPercent != nil { + live.MemoryPercent = *overview.MemoryPercent + } + return live +} + +// processLiveStatus mirrors the monitor's ps-stat classification: zombies and +// stopped processes are present but not active. +func processLiveStatus(status string) (string, bool) { + switch status { + case "zombie", "stopped", "exited": + return status, false + case "": + return status, false + default: + return status, true + } +} diff --git a/pkg/cli/session_throughput.go b/pkg/cli/session_throughput.go index 8b206db..e628ad2 100644 --- a/pkg/cli/session_throughput.go +++ b/pkg/cli/session_throughput.go @@ -75,24 +75,18 @@ func RunSessionThroughput(ctx context.Context, opts SessionThroughputOptions) (S if limit <= 0 { limit = defaultSessionThroughputLimit } - scope, projectRoot, searchAll := resolveSessionScope(cwd, opts.All, opts.Project) + scope, projectRoot, _ := resolveSessionScope(cwd, opts.All, opts.Project) - candidateLimit := limit - if searchAll && projectRoot != "" { - candidateLimit = 0 - } - candidates, err := discoverLiveSessionCandidates(ctx, cwd, searchAll, source, candidateLimit) + db, err := freshenSessionDB(ctx) if err != nil { return SessionThroughputResult{}, err } - candidates = filterSessionCandidatesByProject(candidates, projectRoot) - records := make([]SessionRecord, 0, len(candidates)) - for _, candidate := range candidates { - if sessionMatchesQuery(candidate.record, opts.Query) { - records = append(records, candidate.record) - } + records, err := dbSessionRecords(ctx, db, sessionRecordQuery{ + Source: source, ProjectRoot: projectRoot, Query: opts.Query, + }) + if err != nil { + return SessionThroughputResult{}, err } - sortSessionRecords(records) if limit > 0 && len(records) > limit { records = records[:limit] } diff --git a/pkg/cli/session_throughput_test.go b/pkg/cli/session_throughput_test.go index b047976..0b9f8e2 100644 --- a/pkg/cli/session_throughput_test.go +++ b/pkg/cli/session_throughput_test.go @@ -78,6 +78,7 @@ func TestAggregateSessionThroughputSkipsIncompleteSessions(t *testing.T) { func TestRunSessionThroughputRestrictsExplicitProject(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") otherProject := filepath.Join(home, "work", "other") if err := os.MkdirAll(project, 0o755); err != nil { diff --git a/pkg/cli/sessions.go b/pkg/cli/sessions.go index 5655288..3c5f5ea 100644 --- a/pkg/cli/sessions.go +++ b/pkg/cli/sessions.go @@ -14,8 +14,10 @@ import ( "github.com/flanksource/captain/pkg/ai/history" "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" rpchttp "github.com/flanksource/clicky/rpc/http" + "github.com/google/uuid" ) type SessionListOptions struct { @@ -27,8 +29,10 @@ type SessionListOptions struct { } type SessionGetOptions struct { - ID string `flag:"id" args:"true" help:"Session key or session id"` - Source string `flag:"source" help:"Restrict source: all, claude, codex" default:"all"` + ID string `flag:"id" args:"true" help:"Session id (full or unambiguous prefix)"` + Offset int `flag:"offset" help:"Skip this many messages from the start"` + Limit int `flag:"limit" help:"Maximum messages to return; 0 means all" short:"l"` + Tail int `flag:"tail" help:"Return only the last N messages (overrides offset/limit)"` } func (SessionGetOptions) GetName() string { return "get " } @@ -250,19 +254,17 @@ func RunSessionList(ctx context.Context, opts SessionListOptions) (SessionListRe return SessionListResult{}, err } - scope, projectRoot, searchAll := resolveSessionScope(cwd, opts.All, opts.Project) - candidates, err := discoverSessionCandidates(ctx, cwd, searchAll, source) + scope, projectRoot, _ := resolveSessionScope(cwd, opts.All, opts.Project) + db, err := freshenSessionDB(ctx) if err != nil { return SessionListResult{}, err } - candidates = filterSessionCandidatesByProject(candidates, projectRoot) - records := make([]SessionRecord, 0, len(candidates)) - for _, candidate := range candidates { - if sessionMatchesQuery(candidate.record, opts.Query) { - records = append(records, candidate.record) - } + records, err := dbSessionRecords(ctx, db, sessionRecordQuery{ + Source: source, ProjectRoot: projectRoot, Query: opts.Query, + }) + if err != nil { + return SessionListResult{}, err } - sortSessionRecords(records) total := len(records) if opts.Limit > 0 && len(records) > opts.Limit { records = records[:opts.Limit] @@ -277,56 +279,66 @@ func RunSessionList(ctx context.Context, opts SessionListOptions) (SessionListRe }, nil } -// RunSessionGet returns the unified session model for a session id. The viewer -// consumes this model natively — one shape for both Claude and Codex, carrying -// the agent hierarchy, cost, changed files, plan events, approvals, and the -// message stream (with per-message provenance and the raw JSONL line). +// RunSessionGet returns the unified session model for a session id. Identity +// resolves against the database (full UUID or unambiguous provider-session-id +// prefix); the message stream is parsed read-only from the transcript at the +// DB-recorded path and paged via offset/limit/tail. Each message carries its +// transcript line (sourceLine) so consumers can seek into the raw file. func RunSessionGet(ctx context.Context, opts SessionGetOptions) (*session.Session, error) { - source, err := normalizeSessionSource(opts.Source) - if err != nil { - return nil, err - } id := strings.TrimSpace(opts.ID) if id == "" { return nil, fmt.Errorf("id is required") } - - stopFind := rpchttp.Track(ctx, "find") - candidate, found, err := findSessionCandidateByID(id, source) - stopFind() + db, err := freshenSessionDB(ctx) if err != nil { return nil, err - } else if found { - return buildUnifiedSession(ctx, candidate) } - - candidates, err := discoverSessionCandidates(ctx, "", true, source) + overview, err := resolveOverviewByAnyID(ctx, db, id) if err != nil { return nil, err } - for _, candidate := range candidates { - if candidate.record.Key != id && candidate.record.ID != id && !strings.HasPrefix(candidate.record.ID, id) { - continue - } - return buildUnifiedSession(ctx, candidate) + path := stringOr(overview.HistoryFile, stringOr(overview.Path, "")) + if path == "" { + return nil, fmt.Errorf("session %q has no transcript recorded on this host", id) } - return nil, fmt.Errorf("session %q not found", id) -} -// buildUnifiedSession builds the pkg/session model for a resolved candidate: a -// Claude session (root + sub-agent transcripts, resolved by id) or a Codex -// session file. -func buildUnifiedSession(ctx context.Context, candidate sessionCandidate) (*session.Session, error) { defer rpchttp.Track(ctx, "parse")() + candidate := sessionCandidate{ + record: minimalSessionRecord(overview.Source, path, stringOr(overview.ProviderSessionID, overview.ID.String())), + path: path, + } s, err := buildSessionModel(candidate) if err != nil { return nil, err } - persistSessionRows(candidate, s) - attachStoredPrompt(s) + attachPromptRun(ctx, db, overview.ID, s) + pageSessionMessages(s, opts) return s, nil } +// pageSessionMessages windows the message stream: the last Tail messages, or +// an Offset/Limit slice from the start. +func pageSessionMessages(s *session.Session, opts SessionGetOptions) { + if opts.Tail > 0 { + if len(s.Messages) > opts.Tail { + s.Messages = s.Messages[len(s.Messages)-opts.Tail:] + } + return + } + if opts.Offset <= 0 && opts.Limit <= 0 { + return + } + offset := max(opts.Offset, 0) + if offset >= len(s.Messages) { + s.Messages = nil + return + } + s.Messages = s.Messages[offset:] + if opts.Limit > 0 && len(s.Messages) > opts.Limit { + s.Messages = s.Messages[:opts.Limit] + } +} + func buildSessionModel(candidate sessionCandidate) (*session.Session, error) { switch candidate.record.Source { case "claude": @@ -362,42 +374,15 @@ func buildSessionModel(candidate sessionCandidate) (*session.Session, error) { } } -// persistSessionRows upserts the session's rows (root + sub-agents linked by -// parent) so the store's hierarchy is populated on a detailed read. -func persistSessionRows(candidate sessionCandidate, s *session.Session) { - st := sessionStore() - if st == nil { +// attachPromptRun attaches the realized prompt (for captain-launched sessions) +// from the native prompt-run store to the session model. +func attachPromptRun(ctx context.Context, db *database.DB, sessionID uuid.UUID, s *session.Session) { + runs, err := db.ListPromptRuns(ctx, database.PromptRunFilter{SessionID: &sessionID}) + if err != nil || len(runs) == 0 || len(runs[0].RenderedSpec) == 0 { return } - switch candidate.record.Source { - case "claude": - parsed, err := claude.ParseSessions("", true, claude.Filter{SessionIDs: []string{s.ID}, IncludeAgents: true}) - if err != nil { - return - } - for _, ps := range parsed { - if ps.SessionID == s.ID { - st.upsertRows(session.Rows(ps)) - } - } - case "codex": - if r, ok := session.CodexRow(candidate.path); ok { - st.upsertRows([]session.Row{r}) - } - } -} - -// attachStoredPrompt attaches the realized prompt (for captain-launched -// sessions) from the store to the session model. -func attachStoredPrompt(s *session.Session) { - st := sessionStore() - if st == nil || s.ID == "" { - return - } - if p, ok := st.prompt(s.ID); ok { - if raw, err := json.Marshal(p.Realized); err == nil { - s.Prompt = raw - } + if raw, err := json.Marshal(runs[0].RenderedSpec); err == nil { + s.Prompt = raw } } diff --git a/pkg/cli/sessions_test.go b/pkg/cli/sessions_test.go index 30a91db..0bce462 100644 --- a/pkg/cli/sessions_test.go +++ b/pkg/cli/sessions_test.go @@ -10,12 +10,13 @@ import ( "time" "github.com/flanksource/captain/pkg/claude" - rpchttp "github.com/flanksource/clicky/rpc/http" + "github.com/flanksource/captain/pkg/monitor" ) func TestRunSessionListAndGetClaude(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) @@ -82,7 +83,7 @@ func TestRunSessionListAndGetClaude(t *testing.T) { if session.ID != "sess-claude" || session.Source != "claude" { t.Fatalf("session summary = %+v", session) } - if session.ToolCalls != 1 || session.Messages != 1 { + if session.ToolCalls != 1 || session.Messages != 3 { t.Fatalf("ToolCalls/Messages = %d/%d", session.ToolCalls, session.Messages) } if session.Key == "" { @@ -114,6 +115,7 @@ func TestRunSessionListAndGetClaude(t *testing.T) { func TestRunSessionListCodexScope(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) @@ -164,42 +166,17 @@ func TestSessionMatchesQueryIncludesIdentity(t *testing.T) { func TestRunSessionGetUnknown(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) _, err := RunSessionGet(context.Background(), SessionGetOptions{ID: "missing"}) if err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("err = %v", err) } } -func TestFindSessionCandidateByIDClaudeFilename(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - project := filepath.Join(home, "work", "project") - sessionFile := filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-fast.jsonl") - if err := os.MkdirAll(filepath.Dir(sessionFile), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(sessionFile, []byte("{not-json"), 0o644); err != nil { - t.Fatal(err) - } - - candidate, ok, err := findSessionCandidateByID("sess-fast", "all") - if err != nil { - t.Fatalf("findSessionCandidateByID: %v", err) - } - if !ok { - t.Fatal("expected candidate") - } - if candidate.path != sessionFile { - t.Fatalf("candidate.path = %q, want %q", candidate.path, sessionFile) - } - if candidate.record.ID != "sess-fast" || candidate.record.Source != "claude" { - t.Fatalf("candidate.record = %+v", candidate.record) - } -} - func TestRunSessionLiveEnrichesSummaryWithProcessHealth(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) @@ -237,13 +214,11 @@ func TestRunSessionLiveEnrichesSummaryWithProcessHealth(t *testing.T) { ) started := time.Date(2026, 6, 1, 9, 59, 0, 0, time.UTC) - orig := discoverSessionProcesses - discoverSessionProcesses = func() ([]agentProcess, error) { - return []agentProcess{{ + monitorDiscoverProcesses = func() ([]monitor.Process, error) { + return []monitor.Process{{ Source: "claude", PID: 12345, Status: "active", - Active: true, CPUPercent: 2.5, MemoryPercent: 1.25, StartedAt: &started, @@ -251,7 +226,6 @@ func TestRunSessionLiveEnrichesSummaryWithProcessHealth(t *testing.T) { Command: "claude", }}, nil } - t.Cleanup(func() { discoverSessionProcesses = orig }) result, err := RunSessionLive(context.Background(), SessionLiveOptions{Source: "claude", Limit: 10}) if err != nil { @@ -281,6 +255,7 @@ func TestRunSessionLiveEnrichesSummaryWithProcessHealth(t *testing.T) { func TestRunSessionLiveScopesUnmatchedProcessesToCurrentProject(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") otherProject := filepath.Join(home, "work", "other") if err := os.MkdirAll(project, 0o755); err != nil { @@ -292,14 +267,12 @@ func TestRunSessionLiveScopesUnmatchedProcessesToCurrentProject(t *testing.T) { t.Chdir(project) markProjectRoot(t, project) - orig := discoverSessionProcesses - discoverSessionProcesses = func() ([]agentProcess, error) { - return []agentProcess{ - {Source: "codex", PID: 100, Status: "sleeping", Active: true, CWD: project, Command: "codex"}, - {Source: "claude", PID: 200, Status: "sleeping", Active: true, CWD: otherProject, Command: "claude"}, + monitorDiscoverProcesses = func() ([]monitor.Process, error) { + return []monitor.Process{ + {Source: "codex", PID: 100, Status: "sleeping", CWD: project, Command: "codex"}, + {Source: "claude", PID: 200, Status: "sleeping", CWD: otherProject, Command: "claude"}, }, nil } - t.Cleanup(func() { discoverSessionProcesses = orig }) result, err := RunSessionLive(context.Background(), SessionLiveOptions{Source: "all", Limit: 10}) if err != nil { @@ -324,6 +297,7 @@ func TestRunSessionLiveScopesUnmatchedProcessesToCurrentProject(t *testing.T) { func TestRunSessionLiveRestrictsExplicitProject(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + withTestCaptainDB(t) project := filepath.Join(home, "work", "project") otherProject := filepath.Join(home, "work", "other") if err := os.MkdirAll(project, 0o755); err != nil { @@ -363,10 +337,6 @@ func TestRunSessionLiveRestrictsExplicitProject(t *testing.T) { }, ) - orig := discoverSessionProcesses - discoverSessionProcesses = func() ([]agentProcess, error) { return nil, nil } - t.Cleanup(func() { discoverSessionProcesses = orig }) - result, err := RunSessionLive(context.Background(), SessionLiveOptions{ Source: "claude", All: true, @@ -384,45 +354,6 @@ func TestRunSessionLiveRestrictsExplicitProject(t *testing.T) { } } -func TestRunSessionLiveRecordsPhaseTimings(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) - - writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-timing.jsonl"), - map[string]any{ - "type": "user", - "sessionId": "sess-timing", - "timestamp": "2026-06-01T10:00:00Z", - "cwd": project, - "message": map[string]any{ - "role": "user", - "content": []any{map[string]any{"type": "text", "text": "hi"}}, - }, - }, - ) - - orig := discoverSessionProcesses - discoverSessionProcesses = func() ([]agentProcess, error) { return nil, nil } - t.Cleanup(func() { discoverSessionProcesses = orig }) - - ctx, timings := rpchttp.WithTimings(context.Background()) - if _, err := RunSessionLive(ctx, SessionLiveOptions{Source: "claude", Limit: 10}); err != nil { - t.Fatalf("RunSessionLive: %v", err) - } - - header := timings.Header() - for _, phase := range []string{"find", "parse", "enrich"} { - if !strings.Contains(header, phase+";dur=") { - t.Fatalf("Header() = %q, missing phase %q", header, phase) - } - } -} - // markProjectRoot writes a project marker in dir so claude.FindProjectInfo // resolves dir as the project root deterministically. Without it, a temp project // dir has no marker and FindProjectInfo walks to the filesystem root — under diff --git a/pkg/cli/webapp/src/sessionData.ts b/pkg/cli/webapp/src/sessionData.ts index 34e3ed3..ae6c50d 100644 --- a/pkg/cli/webapp/src/sessionData.ts +++ b/pkg/cli/webapp/src/sessionData.ts @@ -353,11 +353,16 @@ export async function fetchProjectOptions(): Promise { export async function fetchSession( id: string, + page?: { offset?: number; limit?: number; tail?: number }, ): Promise { + const params: Record = { id }; + if (page?.tail) params.tail = String(page.tail); + if (page?.offset) params.offset = String(page.offset); + if (page?.limit) params.limit = String(page.limit); const response = await apiClient.executeCommand( "/api/v1/sessions/{id}", "GET", - { id }, + params, { Accept: "application/json" }, ); if (!response.success) { diff --git a/pkg/database/session_process_store.go b/pkg/database/session_process_store.go index 2d5661d..8aa035a 100644 --- a/pkg/database/session_process_store.go +++ b/pkg/database/session_process_store.go @@ -93,7 +93,7 @@ func (db *DB) UpsertSessionProcess(ctx context.Context, input SessionProcessInpu {Name: "host_id"}, {Name: "boot_id"}, {Name: "pid"}, {Name: "process_started_at"}, }, DoUpdates: clause.AssignmentColumns([]string{ - "status", "command", "cwd", "source", "cpu_percent", "memory_percent", + "session_id", "status", "command", "cwd", "source", "cpu_percent", "memory_percent", "memory_rss_bytes", "sampled_at", "last_heartbeat_at", }), }).Create(&record).Error diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index aa7f49f..a0021e3 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -36,6 +36,8 @@ type Config struct { Debounce time.Duration // BackfillInterval is the periodic incremental scan cadence (default 5m). BackfillInterval time.Duration + // DiscoverProcesses overrides ps-based agent-process discovery (tests). + DiscoverProcesses func() ([]Process, error) } type Monitor struct { @@ -62,6 +64,9 @@ func New(cfg Config) (*Monitor, error) { if cfg.BackfillInterval <= 0 { cfg.BackfillInterval = 5 * time.Minute } + if cfg.DiscoverProcesses == nil { + cfg.DiscoverProcesses = discoverAgentProcesses + } return &Monitor{cfg: cfg, db: cfg.DB, tracked: map[string]string{}}, nil } @@ -127,19 +132,18 @@ func (m *Monitor) Run(ctx context.Context) error { func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { ingestor := newIngestor(m) - if err := ingestor.refreshSourceStates(ctx); err != nil { - return err - } watcher, err := newTranscriptWatcher(m, ingestor) if err != nil { return err } defer watcher.close() + // Initial backfill runs before the first process poll so live processes + // bind to their ingested sessions instead of provisional stubs. + m.backfill(ctx, ingestor) if err := m.pollProcesses(ctx, watcher); err != nil { log.Warnf("process poll: %v", err) } - go m.backfill(ctx, ingestor) processTicker := time.NewTicker(m.cfg.ProcessInterval) backfillTicker := time.NewTicker(m.cfg.BackfillInterval) diff --git a/pkg/monitor/monitor_integration_test.go b/pkg/monitor/monitor_integration_test.go index c4c86ef..16e835e 100644 --- a/pkg/monitor/monitor_integration_test.go +++ b/pkg/monitor/monitor_integration_test.go @@ -59,16 +59,15 @@ func TestRunOnceIngestsAndIsIncremental(t *testing.T) { path := writeFixtureHome(t) processStart := time.Now().Add(-time.Minute).Truncate(time.Second) - discoverProcesses = func() ([]agentProcess, error) { - return []agentProcess{{ + fakeDiscover := func() ([]Process, error) { + return []Process{{ Source: "claude", PID: 4242, Status: "sleeping", CPUPercent: 20.5, MemoryPercent: 1.5, MemoryRSSKB: 1024, StartedAt: &processStart, CWD: fixtureCWD, Command: "claude --resume " + fixtureSessionID, }}, nil } - t.Cleanup(func() { discoverProcesses = discoverAgentProcesses }) - require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host", DiscoverProcesses: fakeDiscover})) overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) require.NoError(t, err) @@ -81,7 +80,7 @@ func TestRunOnceIngestsAndIsIncremental(t *testing.T) { assert.Equal(t, path, *overview.HistoryFile) t.Run("unchanged file is skipped, appended file re-ingests", func(t *testing.T) { - require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host", DiscoverProcesses: fakeDiscover})) overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) require.NoError(t, err) assert.EqualValues(t, 2, overview.MessageCount, "unchanged transcript must not re-ingest") @@ -92,7 +91,7 @@ func TestRunOnceIngestsAndIsIncremental(t *testing.T) { require.NoError(t, err) require.NoError(t, f.Close()) - require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host", DiscoverProcesses: fakeDiscover})) overview, err = db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) require.NoError(t, err) assert.EqualValues(t, 3, overview.MessageCount, "appended message must ingest incrementally") @@ -105,8 +104,8 @@ func TestRunOnceIngestsAndIsIncremental(t *testing.T) { }) t.Run("process vanish closes the process row", func(t *testing.T) { - discoverProcesses = func() ([]agentProcess, error) { return nil, nil } - require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host"})) + noProcesses := func() ([]Process, error) { return nil, nil } + require.NoError(t, RunOnce(t.Context(), Config{DB: db, HostID: "test-host", DiscoverProcesses: noProcesses})) overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) require.NoError(t, err) assert.False(t, overview.ProcessActive) diff --git a/pkg/monitor/oneshot.go b/pkg/monitor/oneshot.go index 7380820..deb0651 100644 --- a/pkg/monitor/oneshot.go +++ b/pkg/monitor/oneshot.go @@ -23,12 +23,11 @@ func RunOnce(ctx context.Context, cfg Config) error { defer func() { _ = conn.Close() }() ingestor := newIngestor(m) - if err := ingestor.refreshSourceStates(ctx); err != nil { - return err - } + // Ingest transcripts before polling processes so processes bind to real + // sessions instead of provisional stubs. + m.backfill(ctx, ingestor) if err := m.pollProcesses(ctx, nil); err != nil { log.Warnf("one-shot process poll: %v", err) } - m.backfill(ctx, ingestor) return nil } diff --git a/pkg/monitor/process.go b/pkg/monitor/process.go index d92aeae..54fcf3e 100644 --- a/pkg/monitor/process.go +++ b/pkg/monitor/process.go @@ -16,8 +16,8 @@ import ( "github.com/google/uuid" ) -// agentProcess is one live claude/codex OS process observed via ps. -type agentProcess struct { +// Process is one live claude/codex OS process observed via ps. +type Process struct { Source string PID int Status string @@ -32,11 +32,8 @@ type agentProcess struct { // pollProcesses is one monitor tick: sample agent processes, bind each to a // session, persist the metrics snapshot, close vanished process rows, and feed // the live transcript locations to the watcher (nil in one-shot runs). -// discoverProcesses is indirected so tests can substitute fake process lists. -var discoverProcesses = discoverAgentProcesses - func (m *Monitor) pollProcesses(ctx context.Context, watcher *transcriptWatcher) error { - processes, err := discoverProcesses() + processes, err := m.cfg.DiscoverProcesses() if err != nil { return err } @@ -68,19 +65,12 @@ func (m *Monitor) pollProcesses(ctx context.Context, watcher *transcriptWatcher) return nil } -// resolveProcessSession binds a process to a session: a previously recorded -// binding first, then the argv session id, then the newest session in the same -// working directory, finally a provisional session that the transcript ingest -// later fills in. -func (m *Monitor) resolveProcessSession(ctx context.Context, proc agentProcess) (uuid.UUID, error) { - startedAt := processStartOrNow(proc) - existing, err := m.db.FindProcessSessionID(ctx, m.cfg.HostID, bootID(), int64(proc.PID), startedAt) - if err != nil { - return uuid.Nil, err - } - if existing != uuid.Nil { - return existing, nil - } +// resolveProcessSession binds a process to a session, in precedence order: the +// authoritative argv session id; the previously recorded binding (unless it +// still points at a provisional stub); the newest ingested session in the same +// working directory; finally a provisional session that later transcript +// ingest fills in. +func (m *Monitor) resolveProcessSession(ctx context.Context, proc Process) (uuid.UUID, error) { if providerSessionID := parseClaudeSessionIDFromCommand(proc.Command); providerSessionID != "" { session, err := m.db.CreateOrGetSession(ctx, database.CreateSessionInput{ ProviderSessionID: providerSessionID, Source: proc.Source, HostID: m.cfg.HostID, CWD: proc.CWD, @@ -90,11 +80,21 @@ func (m *Monitor) resolveProcessSession(ctx context.Context, proc agentProcess) } return session.ID, nil } + sticky, stickyProvisional, err := m.stickyProcessSession(ctx, proc) + if err != nil { + return uuid.Nil, err + } + if sticky != uuid.Nil && !stickyProvisional { + return sticky, nil + } if byCWD, err := m.db.FindSessionIDByCWD(ctx, proc.Source, proc.CWD); err != nil { return uuid.Nil, err } else if byCWD != uuid.Nil { return byCWD, nil } + if sticky != uuid.Nil { + return sticky, nil // keep the provisional stub until an ingest claims the cwd + } session, err := m.db.CreateOrGetSession(ctx, database.CreateSessionInput{ Source: proc.Source, HostID: m.cfg.HostID, CWD: proc.CWD, Description: "provisional session for live process", @@ -105,7 +105,22 @@ func (m *Monitor) resolveProcessSession(ctx context.Context, proc agentProcess) return session.ID, nil } -func (m *Monitor) persistProcess(ctx context.Context, sessionID uuid.UUID, proc agentProcess, sampledAt time.Time) error { +// stickyProcessSession returns the process's previously recorded session and +// whether that session is still a provisional stub (no provider identity, no +// transcript) — stubs stay rebindable so a later ingest can claim the process. +func (m *Monitor) stickyProcessSession(ctx context.Context, proc Process) (uuid.UUID, bool, error) { + existing, err := m.db.FindProcessSessionID(ctx, m.cfg.HostID, bootID(), int64(proc.PID), processStartOrNow(proc)) + if err != nil || existing == uuid.Nil { + return uuid.Nil, false, err + } + session, err := m.db.GetSession(ctx, existing) + if err != nil { + return uuid.Nil, false, nil // stale binding to a deleted session: rebind + } + return existing, session.ProviderSessionID == "" && session.Path == "", nil +} + +func (m *Monitor) persistProcess(ctx context.Context, sessionID uuid.UUID, proc Process, sampledAt time.Time) error { input := database.SessionProcessInput{ SessionID: sessionID, HostID: m.cfg.HostID, BootID: bootID(), PID: int64(proc.PID), ProcessStartedAt: processStartOrNow(proc), @@ -130,7 +145,7 @@ func (m *Monitor) persistProcess(ctx context.Context, sessionID uuid.UUID, proc // watchLiveSession points the watcher at wherever this live session's // transcript lives (or will appear): the claude project directory for the // process cwd, or codex's per-day rollout directory. -func (m *Monitor) watchLiveSession(ctx context.Context, watcher *transcriptWatcher, proc agentProcess, sessionID uuid.UUID) { +func (m *Monitor) watchLiveSession(ctx context.Context, watcher *transcriptWatcher, proc Process, sessionID uuid.UUID) { switch proc.Source { case "claude": if proc.CWD != "" { @@ -152,7 +167,7 @@ func codexDayDir(now time.Time) string { return filepath.Join(home, ".codex", "sessions", now.Format("2006/01/02")) } -func processStartOrNow(proc agentProcess) time.Time { +func processStartOrNow(proc Process) time.Time { if proc.StartedAt != nil { return proc.StartedAt.UTC() } @@ -182,7 +197,7 @@ func parseClaudeSessionIDFromCommand(command string) string { return "" } -func discoverAgentProcesses() ([]agentProcess, error) { +func discoverAgentProcesses() ([]Process, error) { if runtime.GOOS == "windows" { return nil, nil } @@ -191,7 +206,7 @@ func discoverAgentProcesses() ([]agentProcess, error) { return nil, err } lines := bytes.Split(out, []byte{'\n'}) - processes := make([]agentProcess, 0) + processes := make([]Process, 0) for _, raw := range lines { line := strings.TrimSpace(string(raw)) if line == "" { @@ -210,19 +225,19 @@ func discoverAgentProcesses() ([]agentProcess, error) { return processes, nil } -func parseAgentProcessLine(line string) (agentProcess, bool) { +func parseAgentProcessLine(line string) (Process, bool) { fields := strings.Fields(line) if len(fields) < 11 { - return agentProcess{}, false + return Process{}, false } pid, err := strconv.Atoi(fields[0]) if err != nil || pid <= 0 { - return agentProcess{}, false + return Process{}, false } command := strings.Join(fields[10:], " ") source := processSource(command) if source == "" { - return agentProcess{}, false + return Process{}, false } cpu, _ := strconv.ParseFloat(fields[1], 64) mem, _ := strconv.ParseFloat(fields[2], 64) @@ -230,7 +245,7 @@ func parseAgentProcessLine(line string) (agentProcess, bool) { stat := fields[4] start := parseProcessStart(strings.Join(fields[5:10], " ")) status, _ := processStatus(stat) - return agentProcess{ + return Process{ Source: source, PID: pid, Status: status, @@ -306,7 +321,7 @@ func parseProcessStart(value string) *time.Time { return &t } -func processIDs(processes []agentProcess) []int { +func processIDs(processes []Process) []int { pids := make([]int, 0, len(processes)) for _, proc := range processes { if proc.PID > 0 { From c51bd01aca4ef4bdf664914eb2d8b1266c76086f Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 10:15:54 +0300 Subject: [PATCH 038/131] feat(cli): persist prompt runs natively and tail launched transcripts --- pkg/cli/prompt_run.go | 37 ++++----- pkg/cli/prompt_run_persist.go | 116 +++++++++++++++++++++++++++++ pkg/cli/prompt_run_persist_test.go | 59 +++++++++++++++ 3 files changed, 189 insertions(+), 23 deletions(-) create mode 100644 pkg/cli/prompt_run_persist.go create mode 100644 pkg/cli/prompt_run_persist_test.go diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index 0bb707c..92d409f 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -275,16 +275,10 @@ func executeSyncRunSingleDirect(ctx context.Context, rendered PromptRenderResult return PromptRunResult{}, err } r, _ := out.(AIPromptResult) - if r.SessionID != "" { - if st := sessionStore(); st != nil { - st.upsertPrompt(StoredPrompt{ - SessionID: r.SessionID, - Model: r.Model, - Backend: r.Backend, - Realized: rendered, - }) - } - } + persistPromptRun(context.WithoutCancel(ctx), promptRunRecordInput{ + Rendered: rendered, SessionID: r.SessionID, Model: r.Model, Backend: r.Backend, + ResultText: r.Text, + }) return PromptRunResult{ Status: "completed", Model: r.Model, @@ -556,20 +550,17 @@ func runPromptStream(t *task.Task, rendered PromptRenderResult, timeout time.Dur if session == "" && loop != nil && len(loop.Iterations) > 0 { session = loop.Iterations[0].SessionID } - // Persist the realized prompt for this launched session so `sessions get` can - // show what produced it. External (non-captain) sessions have no such record. - if session != "" { - if st := sessionStore(); st != nil { - st.upsertPrompt(StoredPrompt{ - SessionID: session, - RunID: runID, - Model: acc.model, - Backend: rendered.Backend, - Realized: rendered, - }) - } - } passed := verifyPassed(runResult.Verdicts) + // Persist the realized prompt run for this launched session so `sessions get` + // can show what produced it. External (non-captain) sessions have no record. + record := promptRunRecordInput{ + Rendered: rendered, RunID: runID, SessionID: session, + Model: acc.model, Backend: rendered.Backend, + } + if !passed { + record.Error = verifyReason(runResult.Verdicts) + } + persistPromptRun(context.WithoutCancel(ctx), record) summary := PromptRunSummary{ RunID: runID, SessionID: session, diff --git a/pkg/cli/prompt_run_persist.go b/pkg/cli/prompt_run_persist.go new file mode 100644 index 0000000..f97e91f --- /dev/null +++ b/pkg/cli/prompt_run_persist.go @@ -0,0 +1,116 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/database" +) + +// promptRunRecordInput is a completed captain-launched prompt run to persist. +type promptRunRecordInput struct { + Rendered PromptRenderResult + RunID string + SessionID string + Model string + Backend string + ResultText string + Error string +} + +// persistPromptRun records a captain-launched run against its session in the +// native store and registers the transcript for live tailing. Persistence +// failures are reported loudly but never fail the completed run itself. +func persistPromptRun(ctx context.Context, input promptRunRecordInput) { + if strings.TrimSpace(input.SessionID) == "" { + return + } + source := backendSource(api.Backend(input.Backend)) + if source == "" { + source = "claude" + } + db, err := captainDB(ctx) + if err != nil { + log.Errorf("persist prompt run for session %s: %v", input.SessionID, err) + return + } + session, err := db.CreateOrGetSession(ctx, database.CreateSessionInput{ + ProviderSessionID: input.SessionID, Source: source, HostID: captainHostID(), + CWD: input.Rendered.Input.Cwd(), + }) + if err != nil { + log.Errorf("persist prompt run for session %s: %v", input.SessionID, err) + return + } + run, err := db.CreatePromptRun(ctx, database.CreatePromptRunInput{ + SessionID: session.ID, + Origin: "captain", + AdmissionKey: input.RunID, + RenderedSpec: renderedSpecMap(input.Rendered), + PromptMarkdown: input.Rendered.Input.Prompt.User, + }) + if err != nil { + log.Errorf("persist prompt run for session %s: %v", input.SessionID, err) + return + } + finished := database.PromptRunPhaseFinished + state := database.PromptRunStateSucceeded + if input.Error != "" { + state = database.PromptRunStateFailed + } + update := database.UpdatePromptRunInput{ + ID: run.ID, ExpectedVersion: run.Version, Phase: &finished, State: &state, + } + if input.ResultText != "" { + update.ResultText = &input.ResultText + } + if input.Error != "" { + update.Error = &input.Error + } + if _, err := db.UpdatePromptRun(ctx, update); err != nil { + log.Errorf("finalize prompt run %s: %v", run.ID, err) + } + trackLaunchedTranscript(input, source) +} + +// trackLaunchedTranscript arms the serve monitor's fsnotify tail on the +// freshly launched session's transcript so it ingests immediately instead of +// waiting for the next process poll or backfill. +func trackLaunchedTranscript(input promptRunRecordInput, source string) { + mon := serveMonitor() + if mon == nil { + return + } + path := historyFileForRun(api.Backend(input.Backend), input.SessionID, input.Rendered.Input.Cwd()) + if path != "" { + mon.TrackTranscript(path, source) + } +} + +// backendSource maps a prompt backend to the transcript source it produces. +func backendSource(backend api.Backend) string { + switch backend { + case api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux: + return "claude" + case api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux: + return "codex" + default: + return "" + } +} + +// renderedSpecMap round-trips the realized prompt render into the jsonb shape +// stored on the prompt run. +func renderedSpecMap(rendered PromptRenderResult) map[string]any { + raw, err := json.Marshal(rendered) + if err != nil { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{} + } + return out +} diff --git a/pkg/cli/prompt_run_persist_test.go b/pkg/cli/prompt_run_persist_test.go new file mode 100644 index 0000000..a095d51 --- /dev/null +++ b/pkg/cli/prompt_run_persist_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "testing" + + "github.com/flanksource/captain/pkg/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersistPromptRunRecordsNativeRun(t *testing.T) { + db := withTestCaptainDB(t) + rendered := PromptRenderResult{Name: "fix-bug", Model: "claude-sonnet-5", Backend: "claude-agent"} + rendered.Input.Prompt.User = "fix the failing test" + + persistPromptRun(t.Context(), promptRunRecordInput{ + Rendered: rendered, RunID: "run-1", SessionID: "0195c1de-4ab8-7000-8000-00000000abcd", + Model: "claude-sonnet-5", Backend: "claude-agent", ResultText: "done", + }) + + session, err := db.GetSessionByIdentity(t.Context(), "0195c1de-4ab8-7000-8000-00000000abcd", "claude", "", "") + require.NoError(t, err) + runs, err := db.ListPromptRuns(t.Context(), database.PromptRunFilter{SessionID: &session.ID}) + require.NoError(t, err) + require.Len(t, runs, 1) + assert.Equal(t, database.PromptRunStateSucceeded, runs[0].State) + assert.Equal(t, database.PromptRunPhaseFinished, runs[0].Phase) + assert.Equal(t, "done", runs[0].ResultText) + assert.Equal(t, "captain", runs[0].Origin) + assert.Equal(t, "run-1", runs[0].AdmissionKey) + assert.Equal(t, "fix the failing test", runs[0].PromptMarkdown) + assert.NotEmpty(t, runs[0].RenderedSpec) + + t.Run("replay with the same run id is idempotent", func(t *testing.T) { + persistPromptRun(t.Context(), promptRunRecordInput{ + Rendered: rendered, RunID: "run-1", SessionID: "0195c1de-4ab8-7000-8000-00000000abcd", + Model: "claude-sonnet-5", Backend: "claude-agent", ResultText: "done", + }) + runs, err := db.ListPromptRuns(t.Context(), database.PromptRunFilter{SessionID: &session.ID}) + require.NoError(t, err) + assert.Len(t, runs, 1) + }) + + t.Run("failed run records error and failed state", func(t *testing.T) { + persistPromptRun(t.Context(), promptRunRecordInput{ + Rendered: rendered, RunID: "run-2", SessionID: "0195c1de-4ab8-7000-8000-00000000abcd", + Model: "claude-sonnet-5", Backend: "claude-agent", Error: "verify failed: tests red", + }) + runs, err := db.ListPromptRuns(t.Context(), database.PromptRunFilter{SessionID: &session.ID}) + require.NoError(t, err) + require.Len(t, runs, 2) + failed := runs[0] + if failed.AdmissionKey != "run-2" { + failed = runs[1] + } + assert.Equal(t, database.PromptRunStateFailed, failed.State) + assert.Equal(t, "verify failed: tests red", failed.Error) + }) +} From 08d9f7818bf7cca08e396717a22fdf740ca3265e Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 10:26:48 +0300 Subject: [PATCH 039/131] refactor(cli): remove legacy session summary cache --- pkg/cli/database_test.go | 61 ++ pkg/cli/gavel_dsn.go | 188 ++++++ pkg/cli/session_cache.go | 106 --- pkg/cli/session_cache_test.go | 83 --- pkg/cli/session_live_summary.go | 90 --- pkg/cli/session_store.go | 630 ------------------ .../session_store_native_integration_test.go | 86 --- pkg/cli/session_store_test.go | 279 -------- pkg/cli/sessions.go | 167 ----- pkg/database/database.go | 2 - 10 files changed, 249 insertions(+), 1443 deletions(-) create mode 100644 pkg/cli/gavel_dsn.go delete mode 100644 pkg/cli/session_cache.go delete mode 100644 pkg/cli/session_cache_test.go delete mode 100644 pkg/cli/session_live_summary.go delete mode 100644 pkg/cli/session_store.go delete mode 100644 pkg/cli/session_store_native_integration_test.go delete mode 100644 pkg/cli/session_store_test.go diff --git a/pkg/cli/database_test.go b/pkg/cli/database_test.go index 38b8586..8dc62f6 100644 --- a/pkg/cli/database_test.go +++ b/pkg/cli/database_test.go @@ -11,6 +11,67 @@ import ( "github.com/stretchr/testify/require" ) +func TestCaptainDSNPrecedence(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + t.Run("gavel primary env wins", func(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "postgres://primary/gavel") + t.Setenv(gavelCacheEnvDSN, "postgres://cache/gavel") + t.Setenv(captainSessionEnvDSN, "postgres://captain/db") + dsn, source, err := captainDSN() + require.NoError(t, err) + require.Equal(t, "postgres://primary/gavel", dsn) + require.Equal(t, gavelDBEnvDSN, source) + }) + + t.Run("cache env is next", func(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "") + t.Setenv(gavelCacheEnvDSN, "postgres://cache/gavel") + t.Setenv(captainSessionEnvDSN, "postgres://captain/db") + dsn, source, err := captainDSN() + require.NoError(t, err) + require.Equal(t, "postgres://cache/gavel", dsn) + require.Equal(t, gavelCacheEnvDSN, source) + }) + + t.Run("captain env is next", func(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "") + t.Setenv(gavelCacheEnvDSN, "") + t.Setenv(captainSessionEnvDSN, "postgres://captain/db") + dsn, source, err := captainDSN() + require.NoError(t, err) + require.Equal(t, "postgres://captain/db", dsn) + require.Equal(t, captainSessionEnvDSN, source) + }) + + t.Run("gavel db.json mode=dsn is used without env", func(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "") + t.Setenv(gavelCacheEnvDSN, "") + t.Setenv(captainSessionEnvDSN, "") + dir := filepath.Join(home, ".config", "gavel") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "db.json"), + []byte(`{"mode":"dsn","dsn":"postgres://from/config"}`), 0o644)) + dsn, source, err := captainDSN() + require.NoError(t, err) + require.Equal(t, "postgres://from/config", dsn) + require.Contains(t, source, "db.json") + }) + + t.Run("invalid db.json mode fails loudly", func(t *testing.T) { + t.Setenv(gavelDBEnvDSN, "") + t.Setenv(gavelCacheEnvDSN, "") + t.Setenv(captainSessionEnvDSN, "") + dir := filepath.Join(home, ".config", "gavel") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "db.json"), + []byte(`{"mode":"bogus"}`), 0o644)) + _, _, err := captainDSN() + require.Error(t, err) + }) +} + // withTestCaptainDB starts an isolated embedded postgres, injects it as the // process-wide captain database, and fakes live-process discovery so tests // never touch a configured DSN or the host's real processes. diff --git a/pkg/cli/gavel_dsn.go b/pkg/cli/gavel_dsn.go new file mode 100644 index 0000000..1bd44d4 --- /dev/null +++ b/pkg/cli/gavel_dsn.go @@ -0,0 +1,188 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + commonsdb "github.com/flanksource/commons-db/db" +) + +const ( + captainSessionEnvDSN = "CAPTAIN_SESSION_DB_URL" + gavelDBEnvDSN = "GAVEL_DB_DSN" + gavelCacheEnvDSN = "GAVEL_GITHUB_CACHE_DSN" + + gavelDBModeDSN = "dsn" + gavelDBModeEmbedded = "embedded" +) + +// sessionDBDir is the embedded-postgres data directory (shared across processes). +func sessionDBDir() (string, error) { + cache, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(cache, "captain", "session-db"), nil +} + +type gavelDBConfig struct { + Mode string `json:"mode"` + DSN string `json:"dsn,omitempty"` +} + +// gavelConfiguredSessionDSN resolves a gavel-shared database from +// ~/.config/gavel/db.json: an explicit DSN, or gavel's embedded postgres +// (reusing a running instance before starting one). +func gavelConfiguredSessionDSN() (string, string, error) { + cfg, path, err := loadGavelDBConfig() + if err != nil { + return "", "", err + } + switch cfg.Mode { + case "": + return "", "", nil + case gavelDBModeDSN: + if strings.TrimSpace(cfg.DSN) == "" { + return "", "", fmt.Errorf("%s has mode=%s but empty dsn", path, gavelDBModeDSN) + } + return cfg.DSN, path, nil + case gavelDBModeEmbedded: + running, err := findRunningGavelEmbeddedPostgres() + if err != nil { + return "", "", err + } + if running != nil { + return gavelEmbeddedDSN(running.Port), path, nil + } + dataDir, err := gavelEmbeddedDataDir() + if err != nil { + return "", "", err + } + dsn, _, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: dataDir, + Database: "gavel", + }) + if err != nil { + return "", "", err + } + return dsn, path, nil + default: + return "", "", fmt.Errorf("%s has unsupported mode %q", path, cfg.Mode) + } +} + +func loadGavelDBConfig() (gavelDBConfig, string, error) { + path, err := gavelDBConfigPath() + if err != nil { + return gavelDBConfig{}, "", err + } + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return gavelDBConfig{}, path, nil + } + return gavelDBConfig{}, path, fmt.Errorf("read %s: %w", path, err) + } + var cfg gavelDBConfig + if err := json.Unmarshal(b, &cfg); err != nil { + return gavelDBConfig{}, path, fmt.Errorf("parse %s: %w", path, err) + } + return cfg, path, nil +} + +func gavelDBConfigPath() (string, error) { + dir, err := gavelStateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "db.json"), nil +} + +func gavelEmbeddedDataDir() (string, error) { + dir, err := gavelStateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "embedded-pg"), nil +} + +func gavelStateDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + dir := filepath.Join(home, ".config", "gavel") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create gavel state dir %s: %w", dir, err) + } + return dir, nil +} + +type runningGavelEmbeddedPostgres struct { + PID int + Port int +} + +func findRunningGavelEmbeddedPostgres() (*runningGavelEmbeddedPostgres, error) { + dataDir, err := gavelEmbeddedDataDir() + if err != nil { + return nil, err + } + pidPath := filepath.Join(dataDir, "data", "postmaster.pid") + raw, err := os.ReadFile(pidPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", pidPath, err) + } + lines := strings.Split(string(raw), "\n") + const postmasterLinePort = 3 + if len(lines) <= postmasterLinePort { + return nil, fmt.Errorf("%s has %d lines, need >%d", pidPath, len(lines), postmasterLinePort) + } + pid, err := strconv.Atoi(strings.TrimSpace(lines[0])) + if err != nil || pid <= 0 { + return nil, fmt.Errorf("%s: invalid pid %q: %w", pidPath, lines[0], err) + } + port, err := strconv.Atoi(strings.TrimSpace(lines[postmasterLinePort])) + if err != nil || port <= 0 || port > 65535 { + return nil, fmt.Errorf("%s: invalid port %q: %w", pidPath, lines[postmasterLinePort], err) + } + if !processAlive(pid) || !tcpPortReachable("localhost", port) { + return nil, nil + } + return &runningGavelEmbeddedPostgres{PID: pid, Port: port}, nil +} + +func gavelEmbeddedDSN(port int) string { + return fmt.Sprintf("postgres://postgres:postgres@localhost:%d/gavel?sslmode=disable", port) +} + +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + p, err := os.FindProcess(pid) + if err != nil { + return false + } + return p.Signal(syscall.Signal(0)) == nil +} + +func tcpPortReachable(host string, port int) bool { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 200*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/pkg/cli/session_cache.go b/pkg/cli/session_cache.go deleted file mode 100644 index f448663..0000000 --- a/pkg/cli/session_cache.go +++ /dev/null @@ -1,106 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "os" - "runtime" - "sync" - - "github.com/flanksource/captain/pkg/session" - rpchttp "github.com/flanksource/clicky/rpc/http" -) - -// sessionFileRef identifies a session file and its source for summarization. -type sessionFileRef struct { - source string - path string -} - -// summarizeSessionFileCached returns the persisted summary for a session file, -// reusing the stored row when the file's mtime+size are unchanged and otherwise -// rebuilding the rich rows (root + sub-agents) and upserting them. When the -// store is unavailable it degrades to building the summary uncached. -func summarizeSessionFileCached(ref sessionFileRef) (SessionRecord, error) { - info, err := os.Stat(ref.path) - if err != nil { - return SessionRecord{}, err - } - if info.IsDir() { - return SessionRecord{}, fmt.Errorf("%s is a directory", ref.path) - } - modUnix, size := info.ModTime().UnixNano(), info.Size() - - st := sessionStore() - if st != nil { - if row, ok := st.lookupFresh(ref.path, modUnix, size); ok { - return row.toRecord(), nil - } - } - - rows, err := session.RowsFromFile(ref.path, ref.source) - if err != nil { - return SessionRecord{}, err - } - if st != nil { - st.upsertRows(rows) - } - for _, r := range rows { - if r.Path == ref.path { - return recordFromRow(r), nil - } - } - if len(rows) > 0 { - return recordFromRow(rows[0]), nil - } - return SessionRecord{}, fmt.Errorf("no summary for %s", ref.path) -} - -// summarizeSessionRefs sampled-summarizes files concurrently (bounded by -// GOMAXPROCS) through the summary cache, preserving input order. Files that -// fail to summarize or yield no session id are dropped. It records the "parse" -// phase for the wall time of the whole batch. -func summarizeSessionRefs(ctx context.Context, refs []sessionFileRef) []sessionCandidate { - defer rpchttp.Track(ctx, "parse")() - - records := make([]SessionRecord, len(refs)) - found := make([]bool, len(refs)) - - workers := runtime.GOMAXPROCS(0) - if workers > len(refs) { - workers = len(refs) - } - if workers < 1 { - workers = 1 - } - - jobs := make(chan int) - var wg sync.WaitGroup - for w := 0; w < workers; w++ { - wg.Add(1) - go func() { - defer wg.Done() - for i := range jobs { - record, err := summarizeSessionFileCached(refs[i]) - if err != nil || record.ID == "" { - continue - } - records[i] = record - found[i] = true - } - }() - } - for i := range refs { - jobs <- i - } - close(jobs) - wg.Wait() - - candidates := make([]sessionCandidate, 0, len(refs)) - for i := range refs { - if found[i] { - candidates = append(candidates, sessionCandidate{record: records[i], path: refs[i].path}) - } - } - return candidates -} diff --git a/pkg/cli/session_cache_test.go b/pkg/cli/session_cache_test.go deleted file mode 100644 index 420abcd..0000000 --- a/pkg/cli/session_cache_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "path/filepath" - "testing" -) - -// claudeSummaryRow is a minimal on-disk claude session line carrying the given -// session id, enough for the fast summarizer to extract an ID. -func claudeSummaryRow(id string) map[string]any { - return map[string]any{ - "type": "user", - "sessionId": id, - "timestamp": "2026-06-01T10:00:00Z", - "message": map[string]any{ - "role": "user", - "content": []any{map[string]any{"type": "text", "text": "hi"}}, - }, - } -} - -// TestSummarizeSessionFileCachedDegradesUncached checks the store-disabled path -// (TestMain sets CAPTAIN_SESSION_DB_URL=off): summarization still works, uses the -// in-file session id, and reflects the current content on every call (no stale -// cache). Cache reuse/invalidation against a real DB is covered by the gated -// integration test. -func TestSummarizeSessionFileCachedDegradesUncached(t *testing.T) { - path := filepath.Join(t.TempDir(), "aa.jsonl") - ref := sessionFileRef{source: "claude", path: path} - - writeJSONL(t, path, claudeSummaryRow("aa")) - first, err := summarizeSessionFileCached(ref) - if err != nil { - t.Fatalf("first summarize: %v", err) - } - if first.ID != "aa" { - t.Fatalf("first.ID = %q, want aa (from the in-file sessionId)", first.ID) - } - - // With no store, a rewritten file is re-read every time (uncached). - writeJSONL(t, path, claudeSummaryRow("bb")) - again, err := summarizeSessionFileCached(ref) - if err != nil { - t.Fatalf("second summarize: %v", err) - } - if again.ID != "bb" { - t.Fatalf("second.ID = %q, want bb (uncached re-read)", again.ID) - } -} - -func TestSummarizeSessionRefsParallelFindsEverySession(t *testing.T) { - dir := t.TempDir() - const count = 60 - refs := make([]sessionFileRef, 0, count) - want := make(map[string]bool, count) - for i := 0; i < count; i++ { - id := fmt.Sprintf("sess-%03d", i) - path := filepath.Join(dir, id+".jsonl") - writeJSONL(t, path, claudeSummaryRow(id)) - refs = append(refs, sessionFileRef{source: "claude", path: path}) - want[id] = true - } - - got := summarizeSessionRefs(context.Background(), refs) - if len(got) != count { - t.Fatalf("got %d candidates, want %d", len(got), count) - } - seen := make(map[string]bool, count) - for i, candidate := range got { - seen[candidate.record.ID] = true - // Order must match the input refs so downstream sorting is deterministic. - if candidate.path != refs[i].path { - t.Fatalf("candidate[%d].path = %q, want %q (order not preserved)", i, candidate.path, refs[i].path) - } - } - for id := range want { - if !seen[id] { - t.Fatalf("session %q missing from parallel summary", id) - } - } -} diff --git a/pkg/cli/session_live_summary.go b/pkg/cli/session_live_summary.go deleted file mode 100644 index f82d257..0000000 --- a/pkg/cli/session_live_summary.go +++ /dev/null @@ -1,90 +0,0 @@ -package cli - -import ( - "context" - "os" - "sort" - - "github.com/flanksource/captain/pkg/ai/history" - "github.com/flanksource/captain/pkg/claude" - rpchttp "github.com/flanksource/clicky/rpc/http" -) - -type liveSessionFile struct { - source string - path string - modUnix int64 -} - -func discoverLiveSessionCandidates(ctx context.Context, cwd string, searchAll bool, source string, limit int) ([]sessionCandidate, error) { - stopFind := rpchttp.Track(ctx, "find") - files, err := discoverLiveSessionFiles(cwd, searchAll, source) - stopFind() - if err != nil { - return nil, err - } - sort.Slice(files, func(i, j int) bool { - if files[i].modUnix == files[j].modUnix { - return files[i].path > files[j].path - } - return files[i].modUnix > files[j].modUnix - }) - if limit > 0 && len(files) > limit { - files = files[:limit] - } - - refs := make([]sessionFileRef, len(files)) - for i, file := range files { - refs[i] = sessionFileRef{source: file.source, path: file.path} - } - return summarizeSessionRefs(ctx, refs), nil -} - -func discoverLiveSessionFiles(cwd string, searchAll bool, source string) ([]liveSessionFile, error) { - var files []liveSessionFile - if source == "all" || source == "claude" { - claudeFiles, err := claude.FindSessionFiles(claude.GetProjectsDir(), cwd, searchAll) - if err != nil { - return nil, err - } - files = appendSessionFiles(files, "claude", claudeFiles) - } - if source == "all" || source == "codex" { - codexFiles, err := history.FindCodexSessionFiles() - if err != nil { - return nil, err - } - matchRoot := cwd - if cwd != "" { - projectInfo := claude.FindProjectInfo(cwd) - if projectInfo.Root != "" { - matchRoot = projectInfo.Root - } - } - for _, file := range codexFiles { - if !searchAll { - meta, err := history.ReadCodexSessionMeta(file) - if err != nil || meta == nil || !codexMetaMatchesProject(meta, matchRoot) { - continue - } - } - files = appendSessionFiles(files, "codex", []string{file}) - } - } - return files, nil -} - -func appendSessionFiles(out []liveSessionFile, source string, paths []string) []liveSessionFile { - for _, path := range paths { - info, err := os.Stat(path) - if err != nil || info.IsDir() { - continue - } - out = append(out, liveSessionFile{ - source: source, - path: path, - modUnix: info.ModTime().UnixNano(), - }) - } - return out -} diff --git a/pkg/cli/session_store.go b/pkg/cli/session_store.go deleted file mode 100644 index 09a7d91..0000000 --- a/pkg/cli/session_store.go +++ /dev/null @@ -1,630 +0,0 @@ -package cli - -import ( - "encoding/json" - "errors" - "fmt" - "net" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/flanksource/captain/pkg/api" - "github.com/flanksource/captain/pkg/session" - commonsdb "github.com/flanksource/commons-db/db" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// StoredSession is one persisted transcript summary — a root session or a -// sub-agent (linked by ParentID) — invalidated by the file's ModUnix+Size. The -// rich fields are stored as Postgres jsonb. -type StoredSession struct { - Path string `gorm:"primaryKey;column:path"` - ID string `gorm:"index;column:id"` - ParentID *string `gorm:"index;column:parent_id"` - Source string `gorm:"column:source"` - IsAgent bool `gorm:"column:is_agent"` - AgentType string `gorm:"column:agent_type"` - AgentDesc string `gorm:"column:agent_desc"` - - ModUnix int64 `gorm:"column:mod_unix"` - Size int64 `gorm:"column:size"` - SummaryVersion int `gorm:"column:summary_version"` - - Project string `gorm:"column:project"` - CWD string `gorm:"column:cwd"` - Model string `gorm:"column:model"` - Title string `gorm:"column:title"` - InitialPrompt string `gorm:"column:initial_prompt"` - - Git session.GitState `gorm:"serializer:json;type:jsonb;column:git"` - Provider ProviderInfo `gorm:"serializer:json;type:jsonb;column:provider"` - - StartedAt *time.Time `gorm:"column:started_at"` - EndedAt *time.Time `gorm:"column:ended_at"` - - Cost api.Cost `gorm:"serializer:json;type:jsonb;column:cost"` - Usage api.Usage `gorm:"serializer:json;type:jsonb;column:usage"` - Files session.ChangedFiles `gorm:"serializer:json;type:jsonb;column:files"` - Approvals session.ApprovalStats `gorm:"serializer:json;type:jsonb;column:approvals"` - - ToolCalls int `gorm:"column:tool_calls"` - MessageCount int `gorm:"column:message_count"` - ContextTokens int `gorm:"column:context_tokens"` - - Slug string `gorm:"column:slug"` - PlanPath string `gorm:"column:plan_path"` - PlanSlug string `gorm:"column:plan_slug"` - Plan *session.Plan `gorm:"serializer:json;type:jsonb;column:plan"` - - UpdatedAt time.Time -} - -func (StoredSession) TableName() string { return "captain_sessions" } - -const sessionSummaryVersion = 4 - -// ProviderInfo is the provider block stored as jsonb. -type ProviderInfo struct { - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - ReasoningEffort string `json:"reasoningEffort,omitempty"` - Backend string `json:"backend,omitempty"` -} - -// StoredPrompt is the realized prompt for a captain-launched session, keyed by -// session id and written by the prompt-run path. -type StoredPrompt struct { - SessionID string `gorm:"primaryKey;column:session_id"` - RunID string `gorm:"column:run_id"` - Model string `gorm:"column:model"` - Backend string `gorm:"column:backend"` - Realized PromptRenderResult `gorm:"serializer:json;type:jsonb;column:realized"` - CreatedAt time.Time -} - -func (StoredPrompt) TableName() string { return "captain_session_prompts" } - -// sessionDB wraps the gorm handle; a nil *sessionDB means "no store — uncached". -type sessionDB struct{ gdb *gorm.DB } - -// sessionStoreState serializes the choice between the legacy summary cache and -// Captain's authoritative native database. The two schemas both use the -// captain_sessions name but have intentionally different models, so they must -// never be active on the same connection. -type sessionStoreState struct { - once sync.Once - mu sync.RWMutex - - legacy *sessionDB - native *gorm.DB -} - -var sessionStores sessionStoreState - -const ( - captainSessionEnvDSN = "CAPTAIN_SESSION_DB_URL" - gavelDBEnvDSN = "GAVEL_DB_DSN" - gavelCacheEnvDSN = "GAVEL_GITHUB_CACHE_DSN" - - gavelDBModeDSN = "dsn" - gavelDBModeEmbedded = "embedded" -) - -// sessionStore returns the persistent session store, opening it once. It returns -// nil (and logs a single Warn) when the DB is unavailable, so callers degrade to -// uncached summarization. -func sessionStore() *sessionDB { - return sessionStores.get(openSessionStore) -} - -// ConfigureNativeDatabase records the authoritative Captain connection used by -// the host application. It must be called before the legacy session cache has -// opened. Once configured, session summary and realized-prompt callers degrade -// to their existing uncached paths instead of running the incompatible legacy -// AutoMigrate or querying native Captain tables. -// -// Native session repository wiring is deliberately separate from this guard; -// callers should use database.Open to migrate/reuse the shared pool, then call -// ConfigureNativeDatabase with that handle's Gorm connection. -func ConfigureNativeDatabase(gormDB *gorm.DB) error { - return sessionStores.configureNative(gormDB) -} - -func (s *sessionStoreState) configureNative(gormDB *gorm.DB) error { - if gormDB == nil { - return errors.New("native Captain database GORM pool is nil") - } - s.mu.Lock() - defer s.mu.Unlock() - if s.legacy != nil { - return errors.New("legacy Captain session cache is already initialized") - } - if s.native != nil { - if s.native == gormDB { - return nil - } - return errors.New("native Captain database is already configured with a different pool") - } - s.native = gormDB - return nil -} - -func (s *sessionStoreState) nativeDatabase() *gorm.DB { - s.mu.RLock() - defer s.mu.RUnlock() - return s.native -} - -func (s *sessionStoreState) get(opener func() *sessionDB) *sessionDB { - s.once.Do(func() { - s.mu.Lock() - defer s.mu.Unlock() - if s.native == nil { - s.legacy = opener() - } - }) - - s.mu.RLock() - defer s.mu.RUnlock() - if s.native != nil { - return nil - } - return s.legacy -} - -func openSessionStore() *sessionDB { - dsn, source, disabled, err := configuredSessionDSN() - if disabled { - return nil // explicitly disabled (tests, or users who opt out) - } - if err != nil { - log.Warnf("gavel session store unavailable: %v; falling back to captain session store", err) - } - if dsn == "" { - dir, err := sessionDBDir() - if err != nil { - // WORKAROUND(session-cache): user-approved degrade-to-uncached when the - // summary DB can't be opened; summarization still works, just uncached. - log.Warnf("session store unavailable: %v; continuing uncached", err) - return nil - } - embeddedDSN, _, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{DataDir: dir}) - if err != nil { - log.Warnf("session store unavailable: %v; continuing uncached", err) - return nil - } - dsn = embeddedDSN // shared daemon: leave running, don't call stop() - source = "captain embedded session DB" - } - log.Debugf("session store using %s", source) - gdb, pool, err := commonsdb.SetupDB(dsn, "session-cache") - if err != nil { - log.Warnf("session store unavailable: %v; continuing uncached", err) - return nil - } - // SetupDB uses the pgx pool to validate connectivity but GORM owns a separate - // database/sql pool. The legacy cache only retains the latter. - if pool != nil { - pool.Close() - } - return openOwnedLegacySessionStore(gdb) -} - -func openOwnedLegacySessionStore(gdb *gorm.DB) *sessionDB { - store := openLegacySessionStore(gdb) - if store != nil { - return store - } - if sqlDB, err := gdb.DB(); err != nil { - log.Warnf("access unused legacy session database: %v", err) - } else if err := sqlDB.Close(); err != nil { - log.Warnf("close unused legacy session database: %v", err) - } - return nil -} - -func openLegacySessionStore(gdb *gorm.DB) *sessionDB { - if hasAuthoritativeSessionSchema(gdb) { - log.Warnf("authoritative Captain database detected; legacy session cache disabled") - return nil - } - if err := gdb.AutoMigrate(&StoredSession{}, &StoredPrompt{}); err != nil { - log.Warnf("session store migrate failed: %v; continuing uncached", err) - return nil - } - return &sessionDB{gdb: gdb} -} - -// hasAuthoritativeSessionSchema uses lifecycle columns that never existed in -// the legacy summary cache as an unambiguous guard. Metadata inspection is safe; -// no native Captain rows are queried. -func hasAuthoritativeSessionSchema(gdb *gorm.DB) bool { - if gdb == nil { - return false - } - migrator := gdb.Migrator() - return migrator.HasColumn("captain_sessions", "lifecycle_status") && - migrator.HasColumn("captain_sessions", "activity_state") && - migrator.HasColumn("captain_sessions", "health_state") -} - -func configuredSessionDSN() (dsn string, source string, disabled bool, err error) { - if dsn := strings.TrimSpace(os.Getenv(gavelDBEnvDSN)); dsn != "" { - return dsn, gavelDBEnvDSN, false, nil - } - - if dsn := strings.TrimSpace(os.Getenv(gavelCacheEnvDSN)); dsn != "" { - return dsn, gavelCacheEnvDSN, false, nil - } - - if dsn := os.Getenv(captainSessionEnvDSN); dsn != "" { - if dsn == "off" { - return "", "", true, nil - } - return dsn, captainSessionEnvDSN, false, nil - } - - dsn, source, err = gavelConfiguredSessionDSN() - return dsn, source, false, err -} - -// sessionDBDir is the embedded-postgres data directory (shared across processes). -func sessionDBDir() (string, error) { - cache, err := os.UserCacheDir() - if err != nil { - return "", err - } - return filepath.Join(cache, "captain", "session-db"), nil -} - -type gavelDBConfig struct { - Mode string `json:"mode"` - DSN string `json:"dsn,omitempty"` -} - -func gavelSessionDSN() (string, string, error) { - if dsn := strings.TrimSpace(os.Getenv(gavelDBEnvDSN)); dsn != "" { - return dsn, gavelDBEnvDSN, nil - } - if dsn := strings.TrimSpace(os.Getenv(gavelCacheEnvDSN)); dsn != "" { - return dsn, gavelCacheEnvDSN, nil - } - return gavelConfiguredSessionDSN() -} - -func gavelConfiguredSessionDSN() (string, string, error) { - cfg, path, err := loadGavelDBConfig() - if err != nil { - return "", "", err - } - switch cfg.Mode { - case "": - return "", "", nil - case gavelDBModeDSN: - if strings.TrimSpace(cfg.DSN) == "" { - return "", "", fmt.Errorf("%s has mode=%s but empty dsn", path, gavelDBModeDSN) - } - return cfg.DSN, path, nil - case gavelDBModeEmbedded: - running, err := findRunningGavelEmbeddedPostgres() - if err != nil { - return "", "", err - } - if running != nil { - return gavelEmbeddedDSN(running.Port), path, nil - } - dataDir, err := gavelEmbeddedDataDir() - if err != nil { - return "", "", err - } - dsn, _, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ - DataDir: dataDir, - Database: "gavel", - }) - if err != nil { - return "", "", err - } - return dsn, path, nil - default: - return "", "", fmt.Errorf("%s has unsupported mode %q", path, cfg.Mode) - } -} - -func loadGavelDBConfig() (gavelDBConfig, string, error) { - path, err := gavelDBConfigPath() - if err != nil { - return gavelDBConfig{}, "", err - } - b, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return gavelDBConfig{}, path, nil - } - return gavelDBConfig{}, path, fmt.Errorf("read %s: %w", path, err) - } - var cfg gavelDBConfig - if err := json.Unmarshal(b, &cfg); err != nil { - return gavelDBConfig{}, path, fmt.Errorf("parse %s: %w", path, err) - } - return cfg, path, nil -} - -func gavelDBConfigPath() (string, error) { - dir, err := gavelStateDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "db.json"), nil -} - -func gavelEmbeddedDataDir() (string, error) { - dir, err := gavelStateDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "embedded-pg"), nil -} - -func gavelStateDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home dir: %w", err) - } - dir := filepath.Join(home, ".config", "gavel") - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("create gavel state dir %s: %w", dir, err) - } - return dir, nil -} - -type runningGavelEmbeddedPostgres struct { - PID int - Port int -} - -func findRunningGavelEmbeddedPostgres() (*runningGavelEmbeddedPostgres, error) { - dataDir, err := gavelEmbeddedDataDir() - if err != nil { - return nil, err - } - pidPath := filepath.Join(dataDir, "data", "postmaster.pid") - raw, err := os.ReadFile(pidPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, fmt.Errorf("read %s: %w", pidPath, err) - } - lines := strings.Split(string(raw), "\n") - const postmasterLinePort = 3 - if len(lines) <= postmasterLinePort { - return nil, fmt.Errorf("%s has %d lines, need >%d", pidPath, len(lines), postmasterLinePort) - } - pid, err := strconv.Atoi(strings.TrimSpace(lines[0])) - if err != nil || pid <= 0 { - return nil, fmt.Errorf("%s: invalid pid %q: %w", pidPath, lines[0], err) - } - port, err := strconv.Atoi(strings.TrimSpace(lines[postmasterLinePort])) - if err != nil || port <= 0 || port > 65535 { - return nil, fmt.Errorf("%s: invalid port %q: %w", pidPath, lines[postmasterLinePort], err) - } - if !processAlive(pid) || !tcpPortReachable("localhost", port) { - return nil, nil - } - return &runningGavelEmbeddedPostgres{PID: pid, Port: port}, nil -} - -func gavelEmbeddedDSN(port int) string { - return fmt.Sprintf("postgres://postgres:postgres@localhost:%d/gavel?sslmode=disable", port) -} - -func processAlive(pid int) bool { - if pid <= 0 { - return false - } - p, err := os.FindProcess(pid) - if err != nil { - return false - } - return p.Signal(syscall.Signal(0)) == nil -} - -func tcpPortReachable(host string, port int) bool { - conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 200*time.Millisecond) - if err != nil { - return false - } - _ = conn.Close() - return true -} - -// lookupFresh returns the stored row for path when it exists and its mtime+size -// still match the file (a fresh cache hit). -func (s *sessionDB) lookupFresh(path string, modUnix, size int64) (*StoredSession, bool) { - var row StoredSession - if err := s.gdb.Where("path = ?", path).First(&row).Error; err != nil { - return nil, false - } - if row.ModUnix != modUnix || row.Size != size || row.SummaryVersion != sessionSummaryVersion { - return nil, false - } - return &row, true -} - -// upsertRows persists each Row (one transcript), stamping the file's mtime+size. -func (s *sessionDB) upsertRows(rows []session.Row) { - for _, r := range rows { - stored, ok := storedFromRow(r) - if !ok { - continue - } - if err := s.gdb.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "path"}}, - UpdateAll: true, - }).Create(&stored).Error; err != nil { - log.Warnf("session store upsert %s: %v", r.Path, err) - } - } -} - -// prompt returns the realized-prompt record for a session id, if any. -func (s *sessionDB) prompt(sessionID string) (*StoredPrompt, bool) { - var p StoredPrompt - if err := s.gdb.Where("session_id = ?", sessionID).First(&p).Error; err != nil { - return nil, false - } - return &p, true -} - -// upsertPrompt records the realized prompt for a launched session. -func (s *sessionDB) upsertPrompt(p StoredPrompt) { - if err := s.gdb.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "session_id"}}, - UpdateAll: true, - }).Create(&p).Error; err != nil { - log.Warnf("session store upsert prompt %s: %v", p.SessionID, err) - } -} - -// storedBase maps a session.Row to a StoredSession without the file identity -// (ModUnix/Size) — the fields shared by persistence and projection. -func storedBase(r session.Row) StoredSession { - var parent *string - if r.ParentID != "" { - p := r.ParentID - parent = &p - } - row := StoredSession{ - Path: r.Path, - ID: r.ID, - ParentID: parent, - Source: r.Source, - IsAgent: r.IsAgent, - AgentType: r.AgentType, - AgentDesc: r.AgentDesc, - SummaryVersion: sessionSummaryVersion, - Project: r.Project, - CWD: r.CWD, - Model: r.Model, - Title: r.Title, - InitialPrompt: r.InitialPrompt, - Git: r.Git, - Provider: ProviderInfo{Name: r.Provider, Version: r.Version, ReasoningEffort: r.ReasoningEffort}, - StartedAt: r.StartedAt, - EndedAt: r.EndedAt, - Cost: r.Cost, - Usage: r.Usage, - Files: r.Files, - Approvals: r.Approvals, - ToolCalls: r.ToolCalls, - MessageCount: r.Messages, - ContextTokens: r.ContextTokens, - Slug: r.Slug, - } - if r.Plan != nil { - row.PlanPath = r.Plan.Path - row.PlanSlug = r.Plan.Slug - plan := *r.Plan - row.Plan = &plan - } - return row -} - -// storedFromRow maps a session.Row to a StoredSession, stamping the transcript -// file's mtime+size. Returns ok=false when the file can't be stat'd. -func storedFromRow(r session.Row) (StoredSession, bool) { - info, err := os.Stat(r.Path) - if err != nil { - return StoredSession{}, false - } - row := storedBase(r) - row.ModUnix = info.ModTime().UnixNano() - row.Size = info.Size() - return row, true -} - -const ( - claudeContextWindow = 1_000_000 - codexContextWindow = 200_000 -) - -// contextWindow returns the model context window for a source. -func contextWindow(source string) int { - if source == "codex" { - return codexContextWindow - } - return claudeContextWindow -} - -// freeContextPercent is 100 minus the used fraction of the window, clamped. -func freeContextPercent(used, window int) int { - if window <= 0 { - return 0 - } - if used < 0 { - used = 0 - } - free := 100 - int(float64(used)/float64(window)*100) - if free < 0 { - return 0 - } - if free > 100 { - return 100 - } - return free -} - -// recordFromRow projects a session.Row straight to a SessionRecord (miss path, -// where the store may be unavailable). -func recordFromRow(r session.Row) SessionRecord { - return storedBase(r).toRecord() -} - -// toRecord projects a StoredSession to the SessionRecord list/live wire shape. -func (row StoredSession) toRecord() SessionRecord { - rec := SessionRecord{ - Key: sessionRecordKey(row.Source, row.Path), - ID: row.ID, - Source: row.Source, - Project: row.Project, - Slug: row.Slug, - Title: row.Title, - InitialPrompt: row.InitialPrompt, - Model: row.Model, - ReasoningEffort: row.Provider.ReasoningEffort, - Version: row.Provider.Version, - Provider: row.Provider.Name, - GitBranch: row.Git.Branch, - CWD: row.CWD, - StartedAt: row.StartedAt, - EndedAt: row.EndedAt, - ToolCalls: row.ToolCalls, - Messages: row.MessageCount, - DetailAvailable: true, - CostUSD: row.Cost.Total(), - } - if u := row.Usage; u.TotalTokens() > 0 { - rec.Tokens = &SessionTokensWire{ - InputTokens: u.InputTokens, - OutputTokens: u.OutputTokens, - CacheReadTokens: u.CacheReadTokens, - CacheCreationTokens: u.CacheWriteTokens, - TotalTokens: u.TotalTokens(), - } - } - if row.ContextTokens > 0 { - window := contextWindow(row.Source) - rec.Context = &SessionContextWire{ - UsedTokens: row.ContextTokens, - WindowTokens: window, - FreePercent: freeContextPercent(row.ContextTokens, window), - } - } - return rec -} diff --git a/pkg/cli/session_store_native_integration_test.go b/pkg/cli/session_store_native_integration_test.go deleted file mode 100644 index 5ed298d..0000000 --- a/pkg/cli/session_store_native_integration_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/flanksource/captain/migrations" - commonsdb "github.com/flanksource/commons-db/db" -) - -func TestLegacySessionStoreDoesNotMutateAuthoritativeSchema(t *testing.T) { - if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { - t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres migration tests") - } - - dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ - DataDir: filepath.Join(t.TempDir(), "postgres"), - Database: "captain_legacy_guard", - }) - if err != nil { - t.Fatalf("start embedded postgres: %v", err) - } - t.Cleanup(func() { - if err := stop(); err != nil { - t.Errorf("stop embedded postgres: %v", err) - } - }) - - if err := migrations.Apply(t.Context(), dsn); err != nil { - t.Fatalf("apply Captain migrations: %v", err) - } - gormDB, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) - if err != nil { - t.Fatalf("open GORM: %v", err) - } - sqlDB, err := gormDB.DB() - if err != nil { - t.Fatalf("access SQL pool: %v", err) - } - t.Cleanup(func() { - if err := sqlDB.Close(); err != nil { - t.Errorf("close SQL pool: %v", err) - } - }) - - if !hasAuthoritativeSessionSchema(gormDB) { - t.Fatal("authoritative schema signature was not detected") - } - if store := openLegacySessionStore(gormDB); store != nil { - t.Fatalf("legacy session store = %+v, want nil", store) - } - if err := sqlDB.PingContext(t.Context()); err != nil { - t.Fatalf("non-owning legacy guard closed its caller's pool: %v", err) - } - if gormDB.Migrator().HasTable((StoredPrompt{}).TableName()) { - t.Fatalf("legacy table %s was created in the authoritative database", (StoredPrompt{}).TableName()) - } - - var idType string - if err := gormDB.Raw(`SELECT data_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'captain_sessions' - AND column_name = 'id'`).Scan(&idType).Error; err != nil { - t.Fatalf("read captain_sessions.id type: %v", err) - } - if idType != "uuid" { - t.Fatalf("captain_sessions.id type = %q, want uuid", idType) - } - - owned, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) - if err != nil { - t.Fatalf("open owned GORM pool: %v", err) - } - ownedSQL, err := owned.DB() - if err != nil { - t.Fatalf("access owned SQL pool: %v", err) - } - if store := openOwnedLegacySessionStore(owned); store != nil { - t.Fatalf("owned legacy session store = %+v, want nil", store) - } - if err := ownedSQL.PingContext(t.Context()); err == nil { - t.Fatal("unused owned legacy session pool remains open") - } -} diff --git a/pkg/cli/session_store_test.go b/pkg/cli/session_store_test.go deleted file mode 100644 index db1c7b1..0000000 --- a/pkg/cli/session_store_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/flanksource/captain/pkg/api" - "github.com/flanksource/captain/pkg/session" - commonsdb "github.com/flanksource/commons-db/db" - "gorm.io/gorm" -) - -func TestNativeDatabaseConfigurationSkipsLegacySessionStore(t *testing.T) { - shared := &gorm.DB{} - var state sessionStoreState - opened := false - - if err := state.configureNative(shared); err != nil { - t.Fatalf("configureNative: %v", err) - } - if got := state.get(func() *sessionDB { - opened = true - return &sessionDB{gdb: &gorm.DB{}} - }); got != nil { - t.Fatalf("session store = %+v, want nil with native database configured", got) - } - if opened { - t.Fatal("legacy session store opener ran after native database configuration") - } -} - -func TestNativeDatabaseConfigurationRejectsInitializedLegacyStore(t *testing.T) { - legacy := &sessionDB{gdb: &gorm.DB{}} - var state sessionStoreState - if got := state.get(func() *sessionDB { return legacy }); got != legacy { - t.Fatalf("session store = %+v, want legacy store", got) - } - if err := state.configureNative(&gorm.DB{}); err == nil { - t.Fatal("configureNative accepted an already initialized legacy store") - } -} - -func TestNativeDatabaseConfigurationRejectsNil(t *testing.T) { - var state sessionStoreState - if err := state.configureNative(nil); err == nil { - t.Fatal("configureNative accepted a nil database") - } -} - -func TestNativeDatabaseConfigurationRejectsReplacementPool(t *testing.T) { - var state sessionStoreState - first := &gorm.DB{} - if err := state.configureNative(first); err != nil { - t.Fatalf("configure first native database: %v", err) - } - if err := state.configureNative(first); err != nil { - t.Fatalf("reconfiguring the same native database should be idempotent: %v", err) - } - if err := state.configureNative(&gorm.DB{}); err == nil { - t.Fatal("configureNative accepted a replacement native pool") - } -} - -// TestRecordFromRow_ProjectsRichFields verifies the Row→SessionRecord projection -// carries git/provider/cost/tokens and derives the context-free percent. -func TestRecordFromRow_ProjectsRichFields(t *testing.T) { - r := session.Row{ - ID: "s1", - Source: "claude", - Project: "captain", - Title: "Improve session identity", - InitialPrompt: "Show meaningful session titles", - Model: "claude-opus-4", - Git: session.GitState{Branch: "main"}, - Provider: "anthropic", - Version: "1.2.3", - ReasoningEffort: "high", - Usage: api.Usage{InputTokens: 1000, OutputTokens: 500}, - Cost: api.Cost{InputCost: 0.015, OutputCost: 0.0375}, - ContextTokens: 940_000, // 94% of the 1M window → 6% free - ToolCalls: 3, - Messages: 2, - } - rec := recordFromRow(r) - - if rec.ID != "s1" || rec.GitBranch != "main" || rec.Provider != "anthropic" || rec.ReasoningEffort != "high" { - t.Fatalf("record meta = %+v", rec) - } - if rec.Project != "captain" || rec.Title != "Improve session identity" || rec.InitialPrompt != "Show meaningful session titles" { - t.Fatalf("record identity = %+v", rec) - } - if rec.Context == nil || rec.Context.FreePercent != 6 || rec.Context.WindowTokens != 1_000_000 { - t.Fatalf("context = %+v, want 6%% free of 1M", rec.Context) - } - if rec.Tokens == nil || rec.Tokens.InputTokens != 1000 || rec.Tokens.OutputTokens != 500 { - t.Fatalf("tokens = %+v", rec.Tokens) - } - if rec.CostUSD == 0 { - t.Errorf("cost not projected") - } - if rec.ToolCalls != 3 || rec.Messages != 2 { - t.Errorf("counts = %d/%d, want 3/2", rec.ToolCalls, rec.Messages) - } -} - -func TestStoredBase_PersistsInlinePlan(t *testing.T) { - r := session.Row{ - ID: "codex-plan", - Source: "codex", - Plan: &session.Plan{ - Content: "- [x] inspect\n- [ ] test", - Explicit: true, - Events: []session.PlanEvent{{Kind: session.PlanWrite}}, - }, - } - - stored := storedBase(r) - if stored.SummaryVersion != sessionSummaryVersion { - t.Fatalf("summary version = %d, want %d", stored.SummaryVersion, sessionSummaryVersion) - } - - if stored.Plan == nil { - t.Fatal("stored plan is nil") - } - if stored.Plan.Content != r.Plan.Content || !stored.Plan.Explicit { - t.Fatalf("stored plan = %+v, want %+v", stored.Plan, r.Plan) - } -} - -func TestGavelSessionDSNPrefersPrimaryEnv(t *testing.T) { - t.Setenv(gavelDBEnvDSN, "postgres://primary-dsn") - t.Setenv(gavelCacheEnvDSN, "postgres://legacy-dsn") - - dsn, source, err := gavelSessionDSN() - if err != nil { - t.Fatalf("gavelSessionDSN: %v", err) - } - if dsn != "postgres://primary-dsn" || source != gavelDBEnvDSN { - t.Fatalf("dsn/source = %q/%q", dsn, source) - } -} - -func TestGavelSessionDSNFallsBackToLegacyEnv(t *testing.T) { - t.Setenv(gavelDBEnvDSN, "") - t.Setenv(gavelCacheEnvDSN, "postgres://legacy-dsn") - - dsn, source, err := gavelSessionDSN() - if err != nil { - t.Fatalf("gavelSessionDSN: %v", err) - } - if dsn != "postgres://legacy-dsn" || source != gavelCacheEnvDSN { - t.Fatalf("dsn/source = %q/%q", dsn, source) - } -} - -func TestConfiguredSessionDSNPrefersPrimaryGavelEnv(t *testing.T) { - t.Setenv(gavelDBEnvDSN, "postgres://primary-dsn") - t.Setenv(gavelCacheEnvDSN, "postgres://gavel-dsn") - t.Setenv(captainSessionEnvDSN, "postgres://captain-dsn") - - dsn, source, disabled, err := configuredSessionDSN() - if err != nil { - t.Fatalf("configuredSessionDSN: %v", err) - } - if disabled { - t.Fatal("configuredSessionDSN unexpectedly disabled") - } - if dsn != "postgres://primary-dsn" || source != gavelDBEnvDSN { - t.Fatalf("dsn/source = %q/%q", dsn, source) - } -} - -func TestConfiguredSessionDSNFallsBackToLegacyGavelEnv(t *testing.T) { - t.Setenv(gavelDBEnvDSN, "") - t.Setenv(gavelCacheEnvDSN, "postgres://gavel-dsn") - t.Setenv(captainSessionEnvDSN, "postgres://captain-dsn") - - dsn, source, disabled, err := configuredSessionDSN() - if err != nil { - t.Fatalf("configuredSessionDSN: %v", err) - } - if disabled { - t.Fatal("configuredSessionDSN unexpectedly disabled") - } - if dsn != "postgres://gavel-dsn" || source != gavelCacheEnvDSN { - t.Fatalf("dsn/source = %q/%q", dsn, source) - } -} - -func TestConfiguredSessionDSNFallsBackToCaptainEnv(t *testing.T) { - t.Setenv(gavelDBEnvDSN, "") - t.Setenv(gavelCacheEnvDSN, "") - t.Setenv(captainSessionEnvDSN, "postgres://captain-dsn") - - dsn, source, disabled, err := configuredSessionDSN() - if err != nil { - t.Fatalf("configuredSessionDSN: %v", err) - } - if disabled { - t.Fatal("configuredSessionDSN unexpectedly disabled") - } - if dsn != "postgres://captain-dsn" || source != captainSessionEnvDSN { - t.Fatalf("dsn/source = %q/%q", dsn, source) - } -} - -func TestGavelSessionDSNFromDBConfig(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv(gavelDBEnvDSN, "") - t.Setenv(gavelCacheEnvDSN, "") - dir := filepath.Join(home, ".config", "gavel") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - path := filepath.Join(dir, "db.json") - if err := os.WriteFile(path, []byte(`{"mode":"dsn","dsn":"postgres://configured-dsn"}`), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } - - dsn, source, err := gavelSessionDSN() - if err != nil { - t.Fatalf("gavelSessionDSN: %v", err) - } - if dsn != "postgres://configured-dsn" || source != path { - t.Fatalf("dsn/source = %q/%q", dsn, source) - } -} - -// TestSessionStoreRoundTrip exercises the real gorm store: fresh miss inserts, -// unchanged hit is served from the row (parent-linked child included), a changed -// file invalidates, and a realized prompt round-trips. Gated on a Postgres DSN. -func TestSessionStoreRoundTrip(t *testing.T) { - dsn := os.Getenv("CAPTAIN_SESSION_DB_URL") - if dsn == "" || dsn == "off" { - t.Skip("set CAPTAIN_SESSION_DB_URL to a Postgres DSN to run the session store integration test") - } - gdb, _, err := commonsdb.SetupDB(dsn, "session-cache-test") - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := gdb.AutoMigrate(&StoredSession{}, &StoredPrompt{}); err != nil { - t.Fatalf("migrate: %v", err) - } - gdb.Exec("TRUNCATE captain_sessions, captain_session_prompts") - st := &sessionDB{gdb: gdb} - - // A root session with one sub-agent transcript. - dir := t.TempDir() - rootPath := filepath.Join(dir, "root.jsonl") - writeJSONL(t, rootPath, map[string]any{ - "type": "assistant", "sessionId": "root", "uuid": "r1", "cwd": "/repo", "gitBranch": "main", - "message": map[string]any{"role": "assistant", "model": "claude-opus-4", - "usage": map[string]any{"input_tokens": 1000, "output_tokens": 500}, - "content": []any{map[string]any{"type": "text", "text": "hi"}}}, - }) - - rows, err := session.RowsFromFile(rootPath, "claude") - if err != nil { - t.Fatalf("rows: %v", err) - } - st.upsertRows(rows) - - info, _ := os.Stat(rootPath) - if _, ok := st.lookupFresh(rootPath, info.ModTime().UnixNano(), info.Size()); !ok { - t.Fatal("expected a fresh hit after upsert") - } - // A changed size invalidates. - if _, ok := st.lookupFresh(rootPath, info.ModTime().UnixNano(), info.Size()+1); ok { - t.Fatal("size change must invalidate the row") - } - - // Realized-prompt round-trip. - st.upsertPrompt(StoredPrompt{SessionID: "root", RunID: "run-1", Model: "claude-opus-4", Backend: "claude_cli"}) - if p, ok := st.prompt("root"); !ok || p.RunID != "run-1" { - t.Fatalf("prompt = %+v, ok=%v", p, ok) - } -} diff --git a/pkg/cli/sessions.go b/pkg/cli/sessions.go index 3c5f5ea..dc842d3 100644 --- a/pkg/cli/sessions.go +++ b/pkg/cli/sessions.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/flanksource/captain/pkg/ai/history" "github.com/flanksource/captain/pkg/claude" "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" @@ -426,119 +425,6 @@ func projectResultValue(scope, projectRoot string) string { return "" } -func filterSessionCandidatesByProject(candidates []sessionCandidate, projectRoot string) []sessionCandidate { - if projectRoot == "" { - return candidates - } - filtered := make([]sessionCandidate, 0, len(candidates)) - for _, candidate := range candidates { - if sessionRecordMatchesProject(candidate.record, projectRoot) { - filtered = append(filtered, candidate) - } - } - return filtered -} - -func discoverSessionCandidates(ctx context.Context, cwd string, searchAll bool, source string) ([]sessionCandidate, error) { - var candidates []sessionCandidate - if source == "all" || source == "claude" { - claudeSessions, err := discoverClaudeSessions(ctx, cwd, searchAll) - if err != nil { - return nil, err - } - candidates = append(candidates, claudeSessions...) - } - if source == "all" || source == "codex" { - codexSessions, err := discoverCodexSessions(ctx, cwd, searchAll) - if err != nil { - return nil, err - } - candidates = append(candidates, codexSessions...) - } - return candidates, nil -} - -func findSessionCandidateByID(id string, source string) (sessionCandidate, bool, error) { - id = strings.TrimSpace(id) - if id == "" { - return sessionCandidate{}, false, nil - } - if source == "all" || source == "claude" { - candidate, ok, err := findClaudeSessionCandidateByID(id) - if err != nil || ok { - return candidate, ok, err - } - } - if source == "all" || source == "codex" { - candidate, ok, err := findCodexSessionCandidateByID(id) - if err != nil || ok { - return candidate, ok, err - } - } - return sessionCandidate{}, false, nil -} - -func findClaudeSessionCandidateByID(id string) (sessionCandidate, bool, error) { - if hasPathOrGlobMeta(id) { - return sessionCandidate{}, false, nil - } - projectsDir := claude.GetProjectsDir() - if _, err := os.Stat(projectsDir); os.IsNotExist(err) { - return sessionCandidate{}, false, nil - } else if err != nil { - return sessionCandidate{}, false, err - } - - files, err := filepath.Glob(filepath.Join(projectsDir, "*", id+"*.jsonl")) - if err != nil { - return sessionCandidate{}, false, err - } - sort.Strings(files) - for _, file := range files { - sessionID := sessionIDFromFile(file) - if sessionID != id && !strings.HasPrefix(sessionID, id) { - continue - } - return sessionCandidate{ - record: minimalSessionRecord("claude", file, sessionID), - path: file, - }, true, nil - } - return sessionCandidate{}, false, nil -} - -func findCodexSessionCandidateByID(id string) (sessionCandidate, bool, error) { - files, err := history.FindCodexSessionFiles() - if err != nil { - return sessionCandidate{}, false, err - } - sort.Strings(files) - for _, file := range files { - key := sessionRecordKey("codex", file) - fileID := sessionIDFromFile(file) - if key == id || fileID == id || (fileID != "" && strings.HasPrefix(fileID, id)) { - return sessionCandidate{ - record: minimalSessionRecord("codex", file, fileID), - path: file, - }, true, nil - } - meta, err := history.ReadCodexSessionMeta(file) - if err != nil || meta == nil || meta.ID == "" { - continue - } - if meta.ID == id || strings.HasPrefix(meta.ID, id) { - record := minimalSessionRecord("codex", file, meta.ID) - record.CWD = meta.CWD - record.Provider = meta.ModelProvider - record.Version = meta.CLIVersion - record.GitBranch = meta.GitBranch - record.StartedAt = meta.StartedAt - return sessionCandidate{record: record, path: file}, true, nil - } - } - return sessionCandidate{}, false, nil -} - func minimalSessionRecord(source, file, id string) SessionRecord { return SessionRecord{ Key: sessionRecordKey(source, file), @@ -548,59 +434,6 @@ func minimalSessionRecord(source, file, id string) SessionRecord { } } -func hasPathOrGlobMeta(s string) bool { - return strings.ContainsAny(s, `/\*?[`) -} - -func discoverClaudeSessions(ctx context.Context, cwd string, searchAll bool) ([]sessionCandidate, error) { - stopFind := rpchttp.Track(ctx, "find") - files, err := claude.FindSessionFiles(claude.GetProjectsDir(), cwd, searchAll) - stopFind() - if err != nil { - return nil, err - } - refs := make([]sessionFileRef, len(files)) - for i, file := range files { - refs[i] = sessionFileRef{source: "claude", path: file} - } - return summarizeSessionRefs(ctx, refs), nil -} - -func discoverCodexSessions(ctx context.Context, cwd string, searchAll bool) ([]sessionCandidate, error) { - stopFind := rpchttp.Track(ctx, "find") - files, err := history.FindCodexSessionFiles() - if err != nil { - stopFind() - return nil, err - } - matchRoot := cwd - if cwd != "" { - projectInfo := claude.FindProjectInfo(cwd) - if projectInfo.Root != "" { - matchRoot = projectInfo.Root - } - } - refs := make([]sessionFileRef, 0, len(files)) - for _, file := range files { - if !searchAll { - meta, err := history.ReadCodexSessionMeta(file) - if err != nil || meta == nil || !codexMetaMatchesProject(meta, matchRoot) { - continue - } - } - refs = append(refs, sessionFileRef{source: "codex", path: file}) - } - stopFind() - return summarizeSessionRefs(ctx, refs), nil -} - -func codexMetaMatchesProject(meta *history.CodexSessionInfo, projectRoot string) bool { - if meta == nil { - return false - } - return sessionRecordMatchesProject(SessionRecord{CWD: meta.CWD}, projectRoot) -} - func sessionRecordKey(source, path string) string { sum := sha256.Sum256([]byte(source + "\x00" + path)) return source + "-" + hex.EncodeToString(sum[:])[:16] diff --git a/pkg/database/database.go b/pkg/database/database.go index 869ac72..3469468 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -4,8 +4,6 @@ // existing GORM pool through Config.Gorm while also supplying the pool's DSN so // Captain can apply its own migration bundle first. Standalone consumers can // omit Config.Gorm and Captain will open a pool after applying the same bundle. -// Hosts that also invoke pkg/cli session APIs must pass the resulting Gorm -// connection to cli.ConfigureNativeDatabase before those APIs initialize. package database import ( From fa759f72c9d4de83aca9559b0ec6b22606b41f4c Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 13 Jul 2026 10:33:52 +0300 Subject: [PATCH 040/131] fix(lint): satisfy errcheck and ST1005 in migrations and database packages --- migrations/legacy_cutover.go | 14 +++++++------- migrations/migrations.go | 4 ++-- pkg/database/database.go | 8 ++++---- pkg/database/plan_store.go | 8 ++++---- pkg/database/session_prompt_store.go | 8 ++++---- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/migrations/legacy_cutover.go b/migrations/legacy_cutover.go index a82ae31..703d08a 100644 --- a/migrations/legacy_cutover.go +++ b/migrations/legacy_cutover.go @@ -97,7 +97,7 @@ type legacyPromptRow struct { func ApplyWithLegacySessionCutover(ctx context.Context, connection string) (_ *LegacySessionCutoverReport, resultErr error) { connection = strings.TrimSpace(connection) if connection == "" { - return nil, errors.New("Captain migration connection string is empty") + return nil, errors.New("captain migration connection string is empty") } lock, err := acquireMigrationLock(ctx, connection) @@ -142,7 +142,7 @@ func ApplyWithLegacySessionCutover(ctx context.Context, connection string) (_ *L return nil, err } if exists { - return nil, fmt.Errorf("Captain legacy cutover report exists but rollback table public.%s is missing", archiveSessionsTable) + return nil, fmt.Errorf("captain legacy cutover report exists but rollback table public.%s is missing", archiveSessionsTable) } return nil, nil } @@ -155,7 +155,7 @@ func archiveLegacySessionCache(ctx context.Context, db *sql.DB) (archiveState, e if err != nil { return archiveState{}, fmt.Errorf("begin Captain legacy archive transaction: %w", err) } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() sessionsExists, err := relationExists(ctx, tx, legacySessionsTable) if err != nil { @@ -263,7 +263,7 @@ func archiveLegacySessionCache(ctx context.Context, db *sql.DB) (archiveState, e return archiveState{}, err } if checksumJSONRows(sessionRaw) != checksumJSONRows(archivedSessionRaw) || len(sessionRaw) != len(archivedSessionRaw) { - return archiveState{}, errors.New("Captain legacy session rollback copy failed count/checksum validation") + return archiveState{}, errors.New("captain legacy session rollback copy failed count/checksum validation") } if promptsExists { _, archivedPromptRaw, err := loadLegacyPromptsIfPresent(ctx, tx, true, archivePromptsTable) @@ -271,7 +271,7 @@ func archiveLegacySessionCache(ctx context.Context, db *sql.DB) (archiveState, e return archiveState{}, err } if checksumJSONRows(promptRaw) != checksumJSONRows(archivedPromptRaw) || len(promptRaw) != len(archivedPromptRaw) { - return archiveState{}, errors.New("Captain legacy prompt rollback copy failed count/checksum validation") + return archiveState{}, errors.New("captain legacy prompt rollback copy failed count/checksum validation") } } @@ -302,7 +302,7 @@ func backfillLegacySessionCache(ctx context.Context, db *sql.DB, state archiveSt if err != nil { return nil, fmt.Errorf("begin Captain legacy backfill transaction: %w", err) } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() sessions, sessionRaw, err := loadLegacySessions(ctx, tx, archiveSessionsTable) if err != nil { @@ -488,7 +488,7 @@ func backfillLegacySessionCache(ctx context.Context, db *sql.DB, state archiveSt } if exists { if !sameCutoverValidation(existing, report) { - return nil, errors.New("Captain legacy cutover validation no longer matches the durable completed report") + return nil, errors.New("captain legacy cutover validation no longer matches the durable completed report") } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit repeated Captain legacy session validation: %w", err) diff --git a/migrations/migrations.go b/migrations/migrations.go index 673b825..7aa5659 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -75,7 +75,7 @@ func Apply(ctx context.Context, connection string) error { func apply(ctx context.Context, connection string, deps applyDependencies) (resultErr error) { if strings.TrimSpace(connection) == "" { - return errors.New("Captain migration connection string is empty") + return errors.New("captain migration connection string is empty") } lock, err := deps.acquireLock(ctx, connection) @@ -137,7 +137,7 @@ func (lock *migrationLock) Close() error { captainMigrationLockNamespace, captainMigrationLockKey).Scan(&unlocked); err != nil { cleanupErrors = append(cleanupErrors, fmt.Errorf("unlock Captain migration scope: %w", err)) } else if !unlocked { - cleanupErrors = append(cleanupErrors, errors.New("Captain migration advisory lock was not held")) + cleanupErrors = append(cleanupErrors, errors.New("captain migration advisory lock was not held")) } cancel() if err := lock.conn.Close(); err != nil { diff --git a/pkg/database/database.go b/pkg/database/database.go index 3469468..147242b 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -58,7 +58,7 @@ func Open(ctx context.Context, config Config) (*DB, error) { func open(ctx context.Context, config Config, deps dependencies) (*DB, error) { dsn := strings.TrimSpace(config.DSN) if config.Gorm == nil && dsn == "" { - return nil, errors.New("Captain database requires an injected GORM pool or DSN") + return nil, errors.New("captain database requires an injected GORM pool or DSN") } if dsn != "" { @@ -82,7 +82,7 @@ func open(ctx context.Context, config Config, deps dependencies) (*DB, error) { // apply its schema before reusing the pool. func Use(gormDB *gorm.DB) (*DB, error) { if gormDB == nil { - return nil, errors.New("Captain database GORM pool is nil") + return nil, errors.New("captain database GORM pool is nil") } return &DB{gorm: gormDB}, nil } @@ -114,10 +114,10 @@ func (db *DB) Gorm() *gorm.DB { // hosts can atomically update Captain rows and their own rows in one database. func (db *DB) Transaction(ctx context.Context, fn func(*DB) error) error { if db == nil || db.gorm == nil { - return errors.New("Captain database is not initialized") + return errors.New("captain database is not initialized") } if fn == nil { - return errors.New("Captain database transaction callback is nil") + return errors.New("captain database transaction callback is nil") } return db.gorm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return fn(&DB{gorm: tx}) diff --git a/pkg/database/plan_store.go b/pkg/database/plan_store.go index 061b3f4..55a45ca 100644 --- a/pkg/database/plan_store.go +++ b/pkg/database/plan_store.go @@ -16,9 +16,9 @@ import ( var ( ErrInvalidPlan = errors.New("invalid Captain plan") - ErrPlanNotFound = errors.New("Captain plan not found") - ErrPlanRevisionNotFound = errors.New("Captain plan revision not found") - ErrPlanConflict = errors.New("Captain plan conflict") + ErrPlanNotFound = errors.New("captain plan not found") + ErrPlanRevisionNotFound = errors.New("captain plan revision not found") + ErrPlanConflict = errors.New("captain plan conflict") ) // PlanApprovalState is the durable approval state of a plan. @@ -568,7 +568,7 @@ func (db *DB) hydratePlans(ctx context.Context, records []planRecord) ([]Plan, e func (db *DB) requireGorm() error { if db == nil || db.gorm == nil { - return errors.New("Captain database is not initialized") + return errors.New("captain database is not initialized") } return nil } diff --git a/pkg/database/session_prompt_store.go b/pkg/database/session_prompt_store.go index 618e18c..c81d69d 100644 --- a/pkg/database/session_prompt_store.go +++ b/pkg/database/session_prompt_store.go @@ -15,11 +15,11 @@ import ( var ( ErrInvalidSession = errors.New("invalid Captain session") - ErrSessionNotFound = errors.New("Captain session not found") - ErrSessionConflict = errors.New("Captain session conflict") + ErrSessionNotFound = errors.New("captain session not found") + ErrSessionConflict = errors.New("captain session conflict") ErrInvalidPromptRun = errors.New("invalid Captain prompt run") - ErrPromptRunNotFound = errors.New("Captain prompt run not found") - ErrPromptRunConflict = errors.New("Captain prompt run conflict") + ErrPromptRunNotFound = errors.New("captain prompt run not found") + ErrPromptRunConflict = errors.New("captain prompt run conflict") ) type SessionLifecycleStatus string From d059bc0ca795ca70d633d7c3870dbabc86406431 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 12:06:15 +0300 Subject: [PATCH 041/131] refactor: move adapter probe to pkg/ai for cross-package reuse --- .github/workflows/test.yml | 2 + pkg/ai/adapter_models.go | 278 ++++++++++++++++ pkg/ai/adapters.go | 214 ++++++++++++ pkg/ai/adapters_cache.go | 42 +++ pkg/ai/adapters_cache_test.go | 77 +++++ pkg/ai/adapters_test.go | 378 +++++++++++++++++++++ pkg/ai/catalog_info.go | 17 +- pkg/ai/live_catalog.go | 130 ++++++++ pkg/ai/live_catalog_test.go | 110 +++++++ pkg/api/workflow.go | 6 + pkg/api/workflow_test.go | 8 +- pkg/cli/prompt_schema.go | 41 +-- pkg/cli/prompt_schema_http_test.go | 12 +- pkg/cli/prompt_schema_test.go | 60 +--- pkg/cli/secret_catalog.go | 5 +- pkg/cli/secret_catalog_test.go | 5 +- pkg/cli/webapp/src/SessionTable.tsx | 78 +++-- pkg/cli/whoami.go | 492 +--------------------------- pkg/cli/whoami_render.go | 28 +- pkg/cli/whoami_test.go | 418 ++--------------------- 20 files changed, 1377 insertions(+), 1024 deletions(-) create mode 100644 pkg/ai/adapter_models.go create mode 100644 pkg/ai/adapters.go create mode 100644 pkg/ai/adapters_cache.go create mode 100644 pkg/ai/adapters_cache_test.go create mode 100644 pkg/ai/adapters_test.go create mode 100644 pkg/ai/live_catalog.go create mode 100644 pkg/ai/live_catalog_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dea2e5f..6b93a6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,8 @@ jobs: with: go-version: "1.26.x" - run: go test ./... + env: + CAPTAIN_DB_EMBEDDED_TEST: "1" build: runs-on: ubuntu-latest diff --git a/pkg/ai/adapter_models.go b/pkg/ai/adapter_models.go new file mode 100644 index 0000000..ecd488f --- /dev/null +++ b/pkg/ai/adapter_models.go @@ -0,0 +1,278 @@ +package ai + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/flanksource/captain/pkg/api" +) + +type modelFetch struct { + models []ModelDef + err error +} + +// resolveModelRows is the live model resolver, a package var so tests can +// substitute deterministic rows without hitting a provider API. +var resolveModelRows = ResolveModels + +// fetchAPIModels resolves each direct provider backend's live /v1/models +// endpoint once, concurrently. Local CLI/agent/cmux adapters deliberately do +// not participate: their model catalogs must describe the runtime they execute, +// independent of whether the parent provider's API key happens to be present. +// The resolver is Captain's cached model path, so repeated probes reuse a fresh +// cache instead of hitting providers every time. +func fetchAPIModels(backends []Backend, getenv func(string) string) map[Backend]modelFetch { + apis := map[Backend]bool{} + for _, b := range backends { + if b.Kind() != "api" { + continue + } + source := modelSourceBackend(b) + if source == "" { + continue + } + if firstEnv(AuthEnvVars(source), getenv) != "" { + apis[source] = true + } + } + + out := make(map[Backend]modelFetch, len(apis)) + var mu sync.Mutex + var wg sync.WaitGroup + for b := range apis { + wg.Add(1) + go func(backend Backend) { + defer wg.Done() + rows, err := resolveModelRows(context.Background(), ResolveOptions{Backend: backend, UseTokens: true}) + m := liveModelDefs(rows, backend) + mu.Lock() + out[backend] = modelFetch{models: m, err: err} + mu.Unlock() + }(b) + } + wg.Wait() + return out +} + +func fetchCodexModels(backends []Backend, probe AuthProbe) modelFetch { + if probe.CodexModels == nil { + return modelFetch{} + } + wanted := false + for _, backend := range backends { + if isCodexBackend(backend) { + wanted = true + break + } + } + if !wanted { + return modelFetch{} + } + binary, err := probe.LookPath("codex") + if err != nil || strings.TrimSpace(binary) == "" { + return modelFetch{err: fmt.Errorf("codex not in PATH")} + } + models, err := probe.CodexModels(context.Background(), binary) + return modelFetch{models: models, err: err} +} + +func liveModelDefs(rows []ResolvedModel, backend Backend) []ModelDef { + out := make([]ModelDef, 0, len(rows)) + for _, row := range rows { + if !row.Live { + continue + } + id := row.RuntimeID() + if id == "" { + continue + } + name := row.Label + if name == "" { + name = id + } + out = append(out, ModelDef{ + ID: id, + Name: name, + Backend: backend, + ReleaseDate: row.ReleaseDate, + CapabilitiesKnown: true, + Reasoning: row.Reasoning, + Temperature: row.Temperature, + SupportedEfforts: append([]api.Effort(nil), row.SupportedEfforts...), + DefaultEffort: row.DefaultEffort, + Priority: row.Priority, + }) + } + return out +} + +// applyModels fills in the model listing (or the reason it is unavailable) for +// a single adapter. Direct API backends use live provider rows. Local adapters +// use the catalog of the runtime they execute: Codex's installed catalog when +// available, otherwise Captain's backend-specific registry projection. +func applyModels(st *AdapterStatus, b Backend, cache map[Backend]modelFetch, codex modelFetch, getenv func(string) string) { + if isCodexBackend(b) { + if codex.err == nil && len(codex.models) > 0 { + models := make([]ModelDef, len(codex.models)) + for i, model := range codex.models { + model.Backend = b + models[i] = model + } + setModels(st, models, true) + return + } + setRegistryModels(st, b, codex.err) + return + } + if b.Kind() == "cli" { + setRegistryModels(st, b, nil) + return + } + + source := modelSourceBackend(b) + if source == "" { + st.ModelError = fmt.Sprintf("backend %s has no model listing", b) + return + } + + envVars := AuthEnvVars(source) + if firstEnv(envVars, getenv) == "" { + st.ModelError = "set " + strings.Join(envVars, " or ") + " to list models" + return + } + + fetch, ok := cache[source] + if !ok { + return + } + if fetch.err != nil { + st.ModelError = fetch.err.Error() + return + } + setModels(st, modelsForAdapterBackend(b, fetch.models), false) +} + +func setRegistryModels(st *AdapterStatus, backend Backend, discoveryErr error) { + setModels(st, RegistryModelDefs(backend), true) + if len(st.Models) > 0 { + return + } + if discoveryErr != nil { + st.ModelError = fmt.Sprintf("runtime model discovery failed: %v; registry has no models for %s", discoveryErr, backend) + return + } + st.ModelError = fmt.Sprintf("registry has no models for %s", backend) +} + +func modelSourceBackend(backend Backend) Backend { + switch backend { + case BackendAnthropic, BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: + return BackendAnthropic + case BackendOpenAI, BackendCodexAgent, BackendCodexCLI, BackendCodexCmux: + return BackendOpenAI + case BackendGemini, BackendGeminiCLI: + return BackendGemini + case BackendDeepSeek: + return BackendDeepSeek + default: + return "" + } +} + +func modelsForAdapterBackend(backend Backend, models []ModelDef) []ModelDef { + out := make([]ModelDef, 0, len(models)) + positions := map[string]int{} + for _, model := range models { + if model.Backend == BackendOpenAI { + if known, available := RegistryModelAvailability(backend, bareProviderModelID(model.ID)); known && !available { + continue + } + if IsIgnoredOpenAIModelID(model.ID) { + if _, ok := RegistryModelDef(backend, bareProviderModelID(model.ID)); !ok { + continue + } + } + } + id := modelIDForAdapterBackend(backend, model.ID) + if id == "" { + continue + } + name := model.Name + if name == "" { + name = id + } + next := ModelDef{ + ID: id, + Name: name, + Backend: backend, + ReleaseDate: model.ReleaseDate, + CapabilitiesKnown: model.CapabilitiesKnown, + Reasoning: model.Reasoning, + Temperature: model.Temperature, + SupportedEfforts: append([]api.Effort(nil), model.SupportedEfforts...), + DefaultEffort: model.DefaultEffort, + Priority: model.Priority, + } + if idx, ok := positions[id]; ok { + if modelDefNewer(next, out[idx]) { + out[idx] = next + } + continue + } + positions[id] = len(out) + out = append(out, next) + } + return out +} + +func modelIDForAdapterBackend(backend Backend, id string) string { + return NormalizeModelForBackend(backend, bareProviderModelID(id)) +} + +func modelDefNewer(left, right ModelDef) bool { + if left.Priority != right.Priority && (left.Priority > 0 || right.Priority > 0) { + if left.Priority == 0 { + return false + } + if right.Priority == 0 { + return true + } + return left.Priority < right.Priority + } + if left.ReleaseDate == "" { + return false + } + if right.ReleaseDate == "" { + return true + } + return left.ReleaseDate > right.ReleaseDate +} + +func bareProviderModelID(id string) string { + id = strings.TrimSpace(id) + if i := strings.LastIndex(id, "/"); i >= 0 { + return id[i+1:] + } + return id +} + +// setModels filters legacy entries and copies the sorted model list onto the +// adapter status as a count plus id list. The richer details are retained only +// for pretty output; JSON stays as the historical []string model list. +func setModels(st *AdapterStatus, models []ModelDef, curated bool) { + if curated { + models = CurrentCuratedModelsByReleaseDate(models) + } else { + models = CurrentModelsByReleaseDate(models) + } + st.ModelCount = len(models) + ids := make([]string, 0, len(models)) + for _, m := range models { + ids = append(ids, m.ID) + } + st.Models = ids + st.ModelDetails = models +} diff --git a/pkg/ai/adapters.go b/pkg/ai/adapters.go new file mode 100644 index 0000000..c58b541 --- /dev/null +++ b/pkg/ai/adapters.go @@ -0,0 +1,214 @@ +package ai + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// WhoamiOptions selects which adapters to probe and whether to list their +// models. The flag tags drive `captain whoami`; the struct lives here so the +// probe and its caching can be reused by non-CLI consumers (e.g. the aichat +// server's model menu) without importing pkg/cli. +type WhoamiOptions struct { + Backend string `flag:"backend" help:"Show only this backend: anthropic|openai|gemini|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|gemini-cli" short:"b"` + Models bool `flag:"models" help:"List models from provider APIs or installed CLI catalogs" default:"true" short:"m"` + Limit int `flag:"limit" help:"Max sample model IDs to show per adapter in pretty output after per-prefix filtering (0 = all)" default:"0" 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"` + + ModelDetails []ModelDef `json:"modelDetails,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 +} + +// AuthProbe abstracts the host environment (env vars, PATH, credential files) +// so resolveAdapter stays pure and testable. Fields are exported so callers in +// other packages (and their tests) can construct a hermetic probe. +type AuthProbe struct { + Getenv func(string) string + LookPath func(string) (string, error) + FileExists func(string) bool + CodexModels func(context.Context, string) ([]ModelDef, error) + Home string +} + +// OSAuthProbe wires AuthProbe to the real host environment. +func OSAuthProbe() AuthProbe { + home, _ := os.UserHomeDir() + return AuthProbe{ + Getenv: os.Getenv, + LookPath: exec.LookPath, + CodexModels: FetchCodexDebugModels, + 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[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[Backend]cliAdapter{ + BackendClaudeAgent: claude, + BackendClaudeCLI: claude, + BackendClaudeCmux: claude, + BackendCodexCLI: { + binary: "codex", + logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, + }, + BackendCodexAgent: { + binary: "codex", + logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, + }, + BackendCodexCmux: { + binary: "codex", + logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, + }, + 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 Backend, p AuthProbe) AdapterStatus { + st := AdapterStatus{Backend: string(backend), Type: backend.Kind()} + + for _, v := range AuthEnvVars(backend) { + if val := p.Getenv(v); strings.TrimSpace(val) != "" { + st.Authenticated = true + st.AuthMethod = v + " (env)" + st.AuthDetail = MaskKey(val) + break + } + } + + if cli, ok := cliAdapters()[backend]; ok { + if path, err := p.LookPath(cli.binary); err == nil { + st.Binary = path + } else { + st.BinaryMissing = cli.binary + } + if !st.Authenticated { + for _, lf := range cli.logins { + full := filepath.Join(p.Home, lf.rel) + if p.FileExists(full) { + st.Authenticated = true + st.AuthMethod = lf.label + st.AuthDetail = full + break + } + } + } + } + + return st +} + +// MaskKey renders a secret as the first and last four characters so the output +// is identifiable without ever exposing the full token. +func MaskKey(s string) string { + s = strings.TrimSpace(s) + if len(s) <= 8 { + return "****" + } + return s[:4] + "…" + s[len(s)-4:] +} + +func firstEnv(vars []string, getenv func(string) string) string { + for _, v := range vars { + if val := getenv(v); strings.TrimSpace(val) != "" { + return val + } + } + return "" +} + +// ProbeAdapters resolves each backend's auth/availability and (when opts.Models) +// its model listing against the supplied environment probe. It is the shared, +// injectable core behind `captain whoami`, the prompt --schema builder, and the +// aichat server model menu, so passing a stub AuthProbe keeps callers hermetic +// (no live API calls when the probe reports no API keys). +func ProbeAdapters(opts WhoamiOptions, probe AuthProbe) ([]AdapterStatus, error) { + backends := AllBackends() + if opts.Backend != "" { + b := Backend(opts.Backend) + if !b.Valid() { + return nil, fmt.Errorf("--backend must be one of: %s (got %q)", BackendList(), opts.Backend) + } + backends = []Backend{b} + } + + var models map[Backend]modelFetch + var codexModels modelFetch + if opts.Models { + models = fetchAPIModels(backends, probe.Getenv) + codexModels = fetchCodexModels(backends, probe) + } + + adapters := make([]AdapterStatus, 0, len(backends)) + for _, b := range backends { + st := resolveAdapter(b, probe) + if opts.Models { + applyModels(&st, b, models, codexModels, probe.Getenv) + } + adapters = append(adapters, st) + } + return adapters, nil +} diff --git a/pkg/ai/adapters_cache.go b/pkg/ai/adapters_cache.go new file mode 100644 index 0000000..76687f2 --- /dev/null +++ b/pkg/ai/adapters_cache.go @@ -0,0 +1,42 @@ +package ai + +import ( + "sync" + "time" +) + +// adapterCacheTTL bounds how long a probed adapter set is reused. A long-running +// server (whoami-backed model menu, prompt schema) refreshes on this cadence so +// key/login/model changes surface without a probe per request. +const adapterCacheTTL = 60 * time.Second + +// adapterProbe is the live probe sourcing the cache. It is a package var so +// tests can substitute a deterministic, network-free stub. +var adapterProbe = func() ([]AdapterStatus, error) { + return ProbeAdapters(WhoamiOptions{Models: true}, OSAuthProbe()) +} + +var ( + adapterCacheMu sync.Mutex + adapterCache []AdapterStatus + adapterCacheAt time.Time +) + +// CachedAdapters returns the probed adapters, reusing a cached probe within the +// TTL. A probe error is never cached: the next call retries so a transient +// failure does not permanently empty the catalog. `now` is a parameter so tests +// can advance time deterministically. +func CachedAdapters(now time.Time) ([]AdapterStatus, error) { + adapterCacheMu.Lock() + defer adapterCacheMu.Unlock() + if adapterCache != nil && now.Sub(adapterCacheAt) < adapterCacheTTL { + return adapterCache, nil + } + adapters, err := adapterProbe() + if err != nil { + return nil, err + } + adapterCache = adapters + adapterCacheAt = now + return adapters, nil +} diff --git a/pkg/ai/adapters_cache_test.go b/pkg/ai/adapters_cache_test.go new file mode 100644 index 0000000..3acb9b5 --- /dev/null +++ b/pkg/ai/adapters_cache_test.go @@ -0,0 +1,77 @@ +package ai + +import ( + "errors" + "testing" + "time" +) + +func TestCachedAdaptersReusesWithinTTL(t *testing.T) { + prevProbe := adapterProbe + prevCache, prevAt := adapterCache, adapterCacheAt + t.Cleanup(func() { + adapterProbe = prevProbe + adapterCache, adapterCacheAt = prevCache, prevAt + }) + + stub := []AdapterStatus{{Backend: string(BackendAnthropic), Type: "api"}} + calls := 0 + adapterProbe = func() ([]AdapterStatus, error) { + calls++ + return stub, nil + } + adapterCache, adapterCacheAt = nil, time.Time{} + + base := time.Unix(1_000_000, 0) + if _, err := CachedAdapters(base); err != nil { + t.Fatalf("CachedAdapters: %v", err) + } + if _, err := CachedAdapters(base.Add(10 * time.Second)); err != nil { + t.Fatalf("CachedAdapters: %v", err) + } + if calls != 1 { + t.Errorf("probe called %d times within TTL, want 1", calls) + } + if _, err := CachedAdapters(base.Add(2 * adapterCacheTTL)); err != nil { + t.Fatalf("CachedAdapters: %v", err) + } + if calls != 2 { + t.Errorf("probe called %d times after TTL expiry, want 2", calls) + } +} + +func TestCachedAdaptersDoesNotCacheErrors(t *testing.T) { + prevProbe := adapterProbe + prevCache, prevAt := adapterCache, adapterCacheAt + t.Cleanup(func() { + adapterProbe = prevProbe + adapterCache, adapterCacheAt = prevCache, prevAt + }) + adapterCache, adapterCacheAt = nil, time.Time{} + + calls := 0 + adapterProbe = func() ([]AdapterStatus, error) { + calls++ + if calls == 1 { + return nil, errors.New("transient probe failure") + } + return []AdapterStatus{{Backend: string(BackendOpenAI), Type: "api"}}, nil + } + + base := time.Unix(2_000_000, 0) + if _, err := CachedAdapters(base); err == nil { + t.Fatal("expected the transient probe error to surface") + } + // The next call within the TTL window must retry rather than serve a cached + // (empty) failure. + got, err := CachedAdapters(base.Add(time.Second)) + if err != nil { + t.Fatalf("CachedAdapters retry: %v", err) + } + if len(got) != 1 || got[0].Backend != string(BackendOpenAI) { + t.Fatalf("retry adapters = %+v, want the successful probe result", got) + } + if calls != 2 { + t.Errorf("probe called %d times, want 2 (error not cached)", calls) + } +} diff --git a/pkg/ai/adapters_test.go b/pkg/ai/adapters_test.go new file mode 100644 index 0000000..691634d --- /dev/null +++ b/pkg/ai/adapters_test.go @@ -0,0 +1,378 @@ +package ai + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/api" +) + +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(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(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(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_CmuxUsesLocalLoginWithoutAPIKey(t *testing.T) { + home := "/home/u" + claudeAuth := filepath.Join(home, ".claude.json") + codexAuth := filepath.Join(home, ".codex", "auth.json") + + claude := resolveAdapter(BackendClaudeCmux, fakeProbe( + nil, + map[string]string{"claude": "/usr/local/bin/claude"}, + map[string]bool{claudeAuth: true}, + home)) + if claude.Type != "cli" || !claude.Ready() { + t.Fatalf("claude-cmux should be ready through local claude login, got %+v", claude) + } + if claude.AuthMethod != "claude login" || claude.AuthDetail != claudeAuth { + t.Fatalf("claude-cmux auth = %q %q", claude.AuthMethod, claude.AuthDetail) + } + + codex := resolveAdapter(BackendCodexCmux, fakeProbe( + nil, + map[string]string{"codex": "/usr/local/bin/codex"}, + map[string]bool{codexAuth: true}, + home)) + if codex.Type != "cli" || !codex.Ready() { + t.Fatalf("codex-cmux should be ready through local codex login, got %+v", codex) + } + if codex.AuthMethod != "codex login" || codex.AuthDetail != codexAuth { + t.Fatalf("codex-cmux auth = %q %q", codex.AuthMethod, codex.AuthDetail) + } +} + +func TestResolveAdapter_CLIBinaryMissingNotReady(t *testing.T) { + home := "/home/u" + st := resolveAdapter(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(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) + } +} + +func TestProbeAdaptersUsesRegistryModelsForClaudeCmuxRegardlessOfAPIKey(t *testing.T) { + prev := resolveModelRows + resolveModelRows = func(_ context.Context, opts ResolveOptions) ([]ResolvedModel, error) { + t.Fatalf("local Claude adapter should not resolve provider API models: %+v", opts) + return nil, nil + } + t.Cleanup(func() { resolveModelRows = prev }) + + var withoutKey []string + for _, env := range []map[string]string{nil, {"ANTHROPIC_API_KEY": "sk-ant-test"}} { + adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(BackendClaudeCmux), Models: true}, fakeProbe( + env, + map[string]string{"claude": "/usr/local/bin/claude"}, + nil, + "/home/u", + )) + if err != nil { + t.Fatalf("ProbeAdapters: %v", err) + } + if len(adapters) != 1 || len(adapters[0].Models) == 0 { + t.Fatalf("adapters = %+v, want one adapter with registry models", adapters) + } + if !stringSliceContains(adapters[0].Models, "claude-fable-5") { + t.Fatalf("models = %v, want preferred Fable model", adapters[0].Models) + } + var fable *ModelDef + for i := range adapters[0].ModelDetails { + if adapters[0].ModelDetails[i].ID == "claude-fable-5" { + fable = &adapters[0].ModelDetails[i] + break + } + } + if fable == nil || !fable.CapabilitiesKnown || !fable.Reasoning || fable.Temperature || len(fable.SupportedEfforts) != 5 { + t.Fatalf("fable model details = %+v", fable) + } + if withoutKey == nil { + withoutKey = append([]string(nil), adapters[0].Models...) + continue + } + if strings.Join(adapters[0].Models, "\x00") != strings.Join(withoutKey, "\x00") { + t.Fatalf("models with key = %v, without key = %v", adapters[0].Models, withoutKey) + } + } +} + +func TestProbeAdaptersFiltersNoisyOpenAIModelsForDirectAPI(t *testing.T) { + prev := resolveModelRows + resolveModelRows = func(_ context.Context, opts ResolveOptions) ([]ResolvedModel, error) { + if opts.Backend != BackendOpenAI || !opts.UseTokens { + t.Fatalf("resolve opts = %+v, want openai live token resolve", opts) + } + return []ResolvedModel{ + {Model: Model{ID: "openai/gpt-5.5", Backend: BackendOpenAI, Label: "GPT-5.5", ReleaseDate: "2026-06-01"}, Live: true}, + {Model: Model{ID: "openai/gpt-5.4", Backend: BackendOpenAI, Label: "GPT-5.4", ReleaseDate: "2026-05-15"}, Live: true}, + {Model: Model{ID: "openai/gpt-realtime-2.1", Backend: BackendOpenAI, Label: "Realtime"}, Live: true}, + {Model: Model{ID: "openai/gpt-image-2", Backend: BackendOpenAI, Label: "Image"}, Live: true}, + {Model: Model{ID: "openai/gpt-audio-1.5", Backend: BackendOpenAI, Label: "Audio"}, Live: true}, + {Model: Model{ID: "openai/gpt-5.3-codex", Backend: BackendOpenAI, Label: "Codex"}, Live: true}, + {Model: Model{ID: "openai/gpt-5.3-chat-latest", Backend: BackendOpenAI, Label: "Chat latest"}, Live: true}, + {Model: Model{ID: "openai/gpt-5.5-pro", Backend: BackendOpenAI, Label: "Pro"}, Live: true}, + {Model: Model{ID: "openai/o4-mini", Backend: BackendOpenAI, Label: "O4 mini"}, Live: true}, + {Model: Model{ID: "openai/sora-2", Backend: BackendOpenAI, Label: "Sora"}, Live: true}, + }, nil + } + t.Cleanup(func() { resolveModelRows = prev }) + + adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(BackendOpenAI), Models: true}, fakeProbe( + map[string]string{"OPENAI_API_KEY": "sk-test"}, + map[string]string{"codex": "/usr/local/bin/codex"}, + nil, + "/home/u", + )) + if err != nil { + t.Fatalf("ProbeAdapters: %v", err) + } + if len(adapters) != 1 { + t.Fatalf("adapter count = %d, want 1", len(adapters)) + } + got := adapters[0].Models + for _, want := range []string{"gpt-5.5", "gpt-5.4"} { + if !stringSliceContains(got, want) { + t.Fatalf("models = %v, want primary OpenAI model %q", got, want) + } + } + for _, hidden := range []string{"gpt-realtime-2.1", "gpt-image-2", "gpt-audio-1.5", "gpt-5.3-codex", "gpt-5.3-chat-latest", "gpt-5.5-pro", "o4-mini", "sora-2"} { + if stringSliceContains(got, hidden) { + t.Fatalf("models = %v, should hide noisy OpenAI model %q", got, hidden) + } + } +} + +func TestProbeAdaptersUsesCodexDebugModelsOnceRegardlessOfAPIKey(t *testing.T) { + probe := fakeProbe(map[string]string{"OPENAI_API_KEY": "sk-test"}, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") + calls := 0 + probe.CodexModels = func(_ context.Context, binary string) ([]ModelDef, error) { + calls++ + if binary != "/usr/local/bin/codex" { + t.Fatalf("binary = %q", binary) + } + return []ModelDef{ + {ID: "gpt-5.6-sol", Name: "GPT-5.6-Sol", Priority: 1, DefaultEffort: api.EffortLow, SupportedEfforts: []api.Effort{api.EffortLow, api.EffortHigh, api.EffortUltra}}, + {ID: "gpt-5.6-luna", Name: "GPT-5.6-Luna", Priority: 3, DefaultEffort: api.EffortMedium, SupportedEfforts: []api.Effort{api.EffortLow, api.EffortHigh, api.EffortMax}}, + }, nil + } + adapters, err := ProbeAdapters(WhoamiOptions{Models: true}, probe) + if err != nil { + t.Fatalf("ProbeAdapters: %v", err) + } + if calls != 1 { + t.Fatalf("codex debug calls = %d, want 1", calls) + } + for _, backend := range []Backend{BackendCodexCLI, BackendCodexAgent, BackendCodexCmux} { + adapter := findAdapter(t, adapters, backend) + if len(adapter.Models) != 2 || adapter.Models[0] != "gpt-5.6-sol" { + t.Fatalf("%s models = %v", backend, adapter.Models) + } + if adapter.ModelDetails[0].DefaultEffort != api.EffortLow { + t.Fatalf("%s details = %+v", backend, adapter.ModelDetails[0]) + } + } +} + +func TestFetchCodexModelsUsesDebugWithAPIKey(t *testing.T) { + probe := fakeProbe(map[string]string{"OPENAI_API_KEY": "sk-test"}, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") + calls := 0 + probe.CodexModels = func(_ context.Context, binary string) ([]ModelDef, error) { + calls++ + if binary != "/usr/local/bin/codex" { + t.Fatalf("binary = %q", binary) + } + return []ModelDef{{ID: "gpt-5.6-sol"}}, nil + } + got := fetchCodexModels([]Backend{BackendCodexAgent}, probe) + if got.err != nil || len(got.models) != 1 || calls != 1 { + t.Fatalf("fetch = %+v calls = %d, want one discovered model", got, calls) + } +} + +func TestProbeAdaptersFallsBackToRegistryWhenCodexDebugFails(t *testing.T) { + probe := fakeProbe(nil, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") + probe.CodexModels = func(context.Context, string) ([]ModelDef, error) { + return nil, errors.New("old codex") + } + adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(BackendCodexCLI), Models: true}, probe) + if err != nil { + t.Fatalf("ProbeAdapters: %v", err) + } + if len(adapters) != 1 || !stringSliceContains(adapters[0].Models, "gpt-5.6-sol") { + t.Fatalf("registry fallback models = %+v", adapters) + } +} + +func findAdapter(t *testing.T, adapters []AdapterStatus, backend Backend) AdapterStatus { + t.Helper() + for _, adapter := range adapters { + if adapter.Backend == string(backend) { + return adapter + } + } + t.Fatalf("adapter %s not found", backend) + return AdapterStatus{} +} + +func TestSetModelsFiltersAndSortsByReleaseDate(t *testing.T) { + st := AdapterStatus{Backend: string(BackendAnthropic)} + setModels(&st, []ModelDef{ + {ID: "claude-sonnet-5", Backend: BackendAnthropic, ReleaseDate: "2026-06-01"}, + {ID: "claude-sonnet-4-6", Backend: BackendAnthropic, ReleaseDate: "2026-05-01"}, + {ID: "claude-sonnet-4-5", Backend: BackendAnthropic, ReleaseDate: "2026-04-01"}, + {ID: "claude-sonnet-4-4", Backend: BackendAnthropic, ReleaseDate: "2026-03-01"}, + {ID: "claude-haiku-4-5", Backend: BackendAnthropic, ReleaseDate: "2025-10-15"}, + {ID: "claude-3-5-sonnet-20241022", Backend: BackendAnthropic, ReleaseDate: "2024-10-22"}, + }, false) + + want := []string{"claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5"} + if st.ModelCount != len(want) { + t.Fatalf("ModelCount = %d, want %d (%+v)", st.ModelCount, len(want), st) + } + for i, w := range want { + if st.Models[i] != w { + t.Errorf("Models[%d] = %q, want %q", i, st.Models[i], w) + } + } +} + +func TestSetModelsKeepsGeminiProFamily(t *testing.T) { + st := AdapterStatus{Backend: string(BackendGemini)} + setModels(&st, []ModelDef{ + {ID: "gemini-3.5-flash", Backend: BackendGemini, ReleaseDate: "2026-06-10"}, + {ID: "gemini-3.0-pro", Backend: BackendGemini}, + {ID: "gemini-2.5-pro", Backend: BackendGemini, ReleaseDate: "2025-06-17"}, + {ID: "gemini-2.0-flash", Backend: BackendGemini, ReleaseDate: "2025-02-05"}, + }, false) + + want := []string{"gemini-3.5-flash", "gemini-3.0-pro", "gemini-2.5-pro"} + if st.ModelCount != len(want) { + t.Fatalf("ModelCount = %d, want %d (%+v)", st.ModelCount, len(want), st) + } + for i, w := range want { + if st.Models[i] != w { + t.Errorf("Models[%d] = %q, want %q", i, st.Models[i], w) + } + } +} + +func TestSetModelsHidesCodexCodeVariantsForCLI(t *testing.T) { + st := AdapterStatus{Backend: string(BackendCodexCLI)} + setModels(&st, []ModelDef{ + {ID: "gpt-5-codex", Backend: BackendCodexCLI, ReleaseDate: "2025-08-07"}, + }, false) + + if st.ModelCount != 0 || len(st.Models) != 0 { + t.Fatalf("codex code variant should be hidden for CLI: %+v", st) + } +} + +func stringSliceContains(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} diff --git a/pkg/ai/catalog_info.go b/pkg/ai/catalog_info.go index 586cf44..5c55ae4 100644 --- a/pkg/ai/catalog_info.go +++ b/pkg/ai/catalog_info.go @@ -37,13 +37,18 @@ func BackendToProvider(b Backend) string { } } -// 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. +// CatalogInfo returns the static 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() + return catalogInfoFrom(Catalog(), configuredProviders) +} + +// catalogInfoFrom annotates an arbitrary model list with selectability, shared +// by CatalogInfo (static catalog) and LiveCatalogInfo (whoami-probed catalog). +func catalogInfoFrom(models []Model, configuredProviders []string) []ModelInfo { out := make([]ModelInfo, len(models)) for i, m := range models { configured := false diff --git a/pkg/ai/live_catalog.go b/pkg/ai/live_catalog.go new file mode 100644 index 0000000..984c6df --- /dev/null +++ b/pkg/ai/live_catalog.go @@ -0,0 +1,130 @@ +package ai + +import ( + "time" + + "github.com/flanksource/captain/pkg/api" +) + +// LiveCatalog is the model menu reconciled with what the host actually exposes +// right now (the cached whoami probe): live provider /v1/models and the codex +// debug catalog are merged over the static registry projection, keyed by menu +// ID. Static entries are never dropped — an API model with no key stays in the +// menu (rendered disabled by LiveCatalogInfo) so the picker still communicates +// what a key would unlock — but any model the probe describes with fresher data +// overrides its static counterpart. +func LiveCatalog() ([]Model, error) { + adapters, err := CachedAdapters(time.Now()) + if err != nil { + return nil, err + } + return mergeLiveCatalog(Catalog(), adapters), nil +} + +// LiveCatalogInfo annotates the live catalog with per-caller selectability, +// reusing the same rules as CatalogInfo: API models are configured when their +// provider key is present (configuredProviders), agent/CLI models when their +// local backend binary is installed. +func LiveCatalogInfo(configuredProviders []string) ([]ModelInfo, error) { + models, err := LiveCatalog() + if err != nil { + return nil, err + } + return catalogInfoFrom(models, configuredProviders), nil +} + +// mergeLiveCatalog upserts each probed model onto the static catalog. Ordering +// follows the static catalog, with live-only models appended in probe order so +// the menu stays stable across refreshes. +func mergeLiveCatalog(static []Model, adapters []AdapterStatus) []Model { + out := append([]Model(nil), static...) + pos := make(map[string]int, len(out)) + for i, m := range out { + pos[m.ID] = i + } + + for _, a := range adapters { + menuBackend, hasMenu := menuBackendFor(Backend(a.Backend)) + if !hasMenu { + continue + } + for _, md := range a.ModelDetails { + live := liveModel(menuBackend, md) + if idx, seen := pos[live.ID]; seen { + out[idx] = mergeModel(out[idx], live) + continue + } + pos[live.ID] = len(out) + out = append(out, live) + } + } + return out +} + +// menuBackendFor maps a probed backend to the backend the menu uses for that +// model, and whether the backend has a menu representation at all. The three +// claude execution backends collapse onto claude-agent and the three codex ones +// onto codex-agent — the menu offers one agent entry per model. Backends with no +// menu representation (gemini-cli, whose models already appear under the googleai +// API entry) return false and are skipped. Whether an id is provider-prefixed is +// decided later by the menu backend's Kind (see liveModel). +func menuBackendFor(b Backend) (Backend, bool) { + switch b { + case BackendAnthropic, BackendOpenAI, BackendGemini, BackendDeepSeek: + return b, true + case BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: + return BackendClaudeAgent, true + case BackendCodexAgent, BackendCodexCLI, BackendCodexCmux: + return BackendCodexAgent, true + default: + return "", false + } +} + +// liveModel builds a catalog Model from one probed model detail, in the menu's +// id convention (provider-prefixed for API backends, exact id for agent +// backends). ContextWindow/AdaptiveThinking are not carried by the live probe; +// a model already in the static catalog keeps them via mergeModel, and a +// live-only model leaves ContextWindow zero (the usage gauge degrades to no +// denominator rather than a fabricated one). +func liveModel(menuBackend Backend, md ModelDef) Model { + bare := bareProviderModelID(md.ID) + id := bare + if menuBackend.Kind() == "api" { + id = BackendToProvider(menuBackend) + "/" + bare + } + label := md.Name + if label == "" { + label = bare + } + return Model{ + ID: id, + Backend: menuBackend, + Label: label, + Reasoning: md.Reasoning, + Temperature: md.Temperature, + ReleaseDate: md.ReleaseDate, + SupportedEfforts: append([]api.Effort(nil), md.SupportedEfforts...), + DefaultEffort: md.DefaultEffort, + Priority: md.Priority, + } +} + +// mergeModel refreshes a static catalog entry with live probe data while keeping +// the richer static metadata (menu label, context window, adaptive-thinking +// flag) that the live probe does not carry. +func mergeModel(static, live Model) Model { + merged := static + if live.ReleaseDate != "" { + merged.ReleaseDate = live.ReleaseDate + } + if len(live.SupportedEfforts) > 0 { + merged.SupportedEfforts = live.SupportedEfforts + } + if live.DefaultEffort != api.EffortNone { + merged.DefaultEffort = live.DefaultEffort + } + merged.Reasoning = live.Reasoning + merged.Temperature = live.Temperature + return merged +} diff --git a/pkg/ai/live_catalog_test.go b/pkg/ai/live_catalog_test.go new file mode 100644 index 0000000..d7b710d --- /dev/null +++ b/pkg/ai/live_catalog_test.go @@ -0,0 +1,110 @@ +package ai + +import ( + "testing" + "time" + + "github.com/flanksource/captain/pkg/api" +) + +func findModel(t *testing.T, models []Model, id string) Model { + t.Helper() + for _, m := range models { + if m.ID == id { + return m + } + } + t.Fatalf("model %q not in catalog %+v", id, modelIDsFrom(models)) + return Model{} +} + +func TestMergeLiveCatalogUpsertsLiveAndPreservesStatic(t *testing.T) { + static := []Model{ + {ID: "anthropic/claude-sonnet-5", Backend: BackendAnthropic, Label: "Claude Sonnet 5", ContextWindow: 1_000_000, ReleaseDate: "2026-06-01"}, + {ID: "claude-opus-4-8", Backend: BackendClaudeAgent, Label: "Claude Agent · Opus 4.8", ContextWindow: 1_000_000}, + } + adapters := []AdapterStatus{ + {Backend: string(BackendCodexCLI), Type: "cli", ModelDetails: []ModelDef{ + {ID: "gpt-5.6-sol", Name: "GPT-5.6-Sol", Reasoning: true, ReleaseDate: "2026-07-09", SupportedEfforts: []api.Effort{api.EffortLow, api.EffortMax}}, + }}, + {Backend: string(BackendAnthropic), Type: "api", ModelDetails: []ModelDef{ + {ID: "claude-sonnet-5", Name: "Claude Sonnet 5", Reasoning: true, ReleaseDate: "2026-06-29"}, + }}, + // gemini-cli has no menu backend; its models must not leak into the menu. + {Backend: string(BackendGeminiCLI), Type: "cli", ModelDetails: []ModelDef{ + {ID: "gemini-3.5-flash", Name: "Gemini 3.5 Flash"}, + }}, + } + + merged := mergeLiveCatalog(static, adapters) + + // A live codex model becomes one codex-agent entry keyed by its exact id. + sol := findModel(t, merged, "gpt-5.6-sol") + if sol.Backend != BackendCodexAgent { + t.Errorf("sol backend = %q, want codex-agent", sol.Backend) + } + if !sol.Reasoning || sol.ReleaseDate != "2026-07-09" || len(sol.SupportedEfforts) != 2 { + t.Errorf("sol not projected from live probe: %+v", sol) + } + + // The API entry is upserted: static context window preserved, live release + // date wins. + sonnet := findModel(t, merged, "anthropic/claude-sonnet-5") + if sonnet.ContextWindow != 1_000_000 { + t.Errorf("sonnet context window = %d, want static 1000000 preserved", sonnet.ContextWindow) + } + if sonnet.ReleaseDate != "2026-06-29" { + t.Errorf("sonnet release date = %q, want live 2026-06-29", sonnet.ReleaseDate) + } + + // A static entry the probe did not rediscover is retained (shown disabled by + // LiveCatalogInfo when its provider is unconfigured). + findModel(t, merged, "claude-opus-4-8") + + for _, m := range merged { + if m.ID == "googleai/gemini-3.5-flash" || m.Backend == BackendGeminiCLI { + t.Errorf("gemini-cli model leaked into the menu: %+v", m) + } + } +} + +func TestLiveCatalogInfoAppliesPerProviderConfigured(t *testing.T) { + prevProbe := adapterProbe + prevCache, prevAt := adapterCache, adapterCacheAt + t.Cleanup(func() { + adapterProbe = prevProbe + adapterCache, adapterCacheAt = prevCache, prevAt + }) + adapterCache, adapterCacheAt = nil, time.Time{} + adapterProbe = func() ([]AdapterStatus, error) { + return []AdapterStatus{ + {Backend: string(BackendAnthropic), Type: "api", ModelDetails: []ModelDef{ + {ID: "claude-sonnet-5", Name: "Claude Sonnet 5", Reasoning: true}, + }}, + {Backend: string(BackendOpenAI), Type: "api", ModelDetails: []ModelDef{ + {ID: "gpt-5.5", Name: "GPT-5.5", Reasoning: true}, + }}, + }, nil + } + + infos, err := LiveCatalogInfo([]string{"anthropic"}) + if err != nil { + t.Fatalf("LiveCatalogInfo: %v", err) + } + byID := map[string]ModelInfo{} + for _, info := range infos { + byID[info.ID] = info + } + + anthropic, ok := byID["anthropic/claude-sonnet-5"] + if !ok || !anthropic.Configured { + t.Errorf("anthropic model should be configured when its provider key is present: %+v", anthropic) + } + if anthropic.Provider != "anthropic" { + t.Errorf("anthropic provider = %q", anthropic.Provider) + } + openai, ok := byID["openai/gpt-5.5"] + if !ok || openai.Configured { + t.Errorf("openai model should be unconfigured (no key) but still listed: %+v", openai) + } +} diff --git a/pkg/api/workflow.go b/pkg/api/workflow.go index fd35b45..2013611 100644 --- a/pkg/api/workflow.go +++ b/pkg/api/workflow.go @@ -14,6 +14,12 @@ import "fmt" type Workflow struct { Verify *Verify `json:"verify,omitempty" yaml:"verify,omitempty"` PostRun *PostRun `json:"postRun,omitempty" yaml:"postRun,omitempty"` + + // AutoVerifyWithoutFixture is the explicit policy opt-in for hosts that + // project a successful generate-only run into a durable verified state. A + // false value keeps the durable work item open when no verification fixture + // ran; success by itself is not treated as proof of correctness. + AutoVerifyWithoutFixture bool `json:"autoVerifyWithoutFixture,omitempty" yaml:"autoVerifyWithoutFixture,omitempty"` } // Verify is the loop's definition-of-done: it runs after each generation and diff --git a/pkg/api/workflow_test.go b/pkg/api/workflow_test.go index 9133469..00e2bbe 100644 --- a/pkg/api/workflow_test.go +++ b/pkg/api/workflow_test.go @@ -17,7 +17,8 @@ func TestWorkflowSpecRoundTrip(t *testing.T) { Scope: VerifyScopeChanged, MaxIterations: 3, }, - PostRun: &PostRun{Commit: true, CommitMessage: "apply"}, + PostRun: &PostRun{Commit: true, CommitMessage: "apply"}, + AutoVerifyWithoutFixture: true, }, } @@ -42,6 +43,9 @@ func TestWorkflowSpecRoundTrip(t *testing.T) { if got.Workflow.PostRun == nil || !got.Workflow.PostRun.Commit { t.Errorf("postRun not preserved: %+v", got.Workflow.PostRun) } + if !got.Workflow.AutoVerifyWithoutFixture { + t.Errorf("autoVerifyWithoutFixture not preserved: %+v", got.Workflow) + } } func TestWorkflowOmittedWhenNil(t *testing.T) { @@ -59,7 +63,7 @@ func TestSpecSchemaIncludesWorkflow(t *testing.T) { if err != nil { t.Fatalf("schema: %v", err) } - for _, want := range []string{"workflow", "Workflow", "Verify", "commands", "maxIterations"} { + for _, want := range []string{"workflow", "Workflow", "Verify", "commands", "maxIterations", "autoVerifyWithoutFixture"} { if !strings.Contains(string(data), want) { t.Errorf("reflected spec schema missing %q", want) } diff --git a/pkg/cli/prompt_schema.go b/pkg/cli/prompt_schema.go index a14f014..ac6f5a2 100644 --- a/pkg/cli/prompt_schema.go +++ b/pkg/cli/prompt_schema.go @@ -66,46 +66,21 @@ func WritePromptSchema(w io.Writer) error { // PromptSchemaDocument assembles the prompt/spec editor schema document from the // live runtime: the cmux "extra args" reflected from Go structs (cached, since // they never change within a process) and the available backends/models probed -// from the environment via whoami (cached with a short TTL so a long-running -// serve process reflects key/model changes without re-probing per request). +// from the environment (ai.CachedAdapters owns the short-TTL cache so a +// long-running serve process reflects key/model changes without re-probing per +// request). func PromptSchemaDocument() (map[string]any, error) { - adapters, err := cachedSchemaAdapters(time.Now()) + adapters, err := schemaAdapters() if err != nil { return nil, err } return buildPromptSchemaDocument(adapters) } -// probeSchemaAdapters is the live probe used to source backends and models. It is -// a package var so tests can substitute a deterministic, network-free stub. -var probeSchemaAdapters = func() ([]AdapterStatus, error) { - return ProbeAdapters(WhoamiOptions{Models: true}, osAuthProbe()) -} - -const schemaAdapterCacheTTL = 60 * time.Second - -var ( - schemaAdapterMu sync.Mutex - schemaAdapterCache []AdapterStatus - schemaAdapterAt time.Time -) - -// cachedSchemaAdapters returns the probed adapters, reusing a cached probe within -// the TTL. A probe error is never cached: the next call retries so a transient -// failure does not permanently empty the schema. -func cachedSchemaAdapters(now time.Time) ([]AdapterStatus, error) { - schemaAdapterMu.Lock() - defer schemaAdapterMu.Unlock() - if schemaAdapterCache != nil && now.Sub(schemaAdapterAt) < schemaAdapterCacheTTL { - return schemaAdapterCache, nil - } - adapters, err := probeSchemaAdapters() - if err != nil { - return nil, err - } - schemaAdapterCache = adapters - schemaAdapterAt = now - return adapters, nil +// schemaAdapters sources the probed adapters through pkg/ai's cache. It is a +// package var so tests can substitute a deterministic, network-free stub. +var schemaAdapters = func() ([]AdapterStatus, error) { + return ai.CachedAdapters(time.Now()) } // reflectedSchemaBytes holds the JSON of the reflection-derived schemas. They are diff --git a/pkg/cli/prompt_schema_http_test.go b/pkg/cli/prompt_schema_http_test.go index cc5dddc..ed30c15 100644 --- a/pkg/cli/prompt_schema_http_test.go +++ b/pkg/cli/prompt_schema_http_test.go @@ -5,19 +5,13 @@ import ( "net/http" "net/http/httptest" "testing" - "time" ) func TestHandlePromptSchemaServesDocument(t *testing.T) { - prevProbe := probeSchemaAdapters - prevCache, prevAt := schemaAdapterCache, schemaAdapterAt - t.Cleanup(func() { - probeSchemaAdapters = prevProbe - schemaAdapterCache, schemaAdapterAt = prevCache, prevAt - }) + prev := schemaAdapters + t.Cleanup(func() { schemaAdapters = prev }) stub := stubbedSchemaAdapters(t) - probeSchemaAdapters = func() ([]AdapterStatus, error) { return stub, nil } - schemaAdapterCache, schemaAdapterAt = nil, time.Time{} + schemaAdapters = func() ([]AdapterStatus, error) { return stub, nil } req := httptest.NewRequest(http.MethodGet, "/api/captain/ai/prompt/schema", nil) rec := httptest.NewRecorder() diff --git a/pkg/cli/prompt_schema_test.go b/pkg/cli/prompt_schema_test.go index 8454a81..1b33df1 100644 --- a/pkg/cli/prompt_schema_test.go +++ b/pkg/cli/prompt_schema_test.go @@ -7,7 +7,6 @@ import ( "reflect" "strings" "testing" - "time" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" @@ -18,18 +17,18 @@ import ( // binaries present. This keeps the probe hermetic: fetchAPIModels makes no // network call without an API key, API backends stay key-gated, and CLI-style // backends project exact IDs from Captain's internal registry. -func fakeSchemaProbe() authProbe { - return authProbe{ - getenv: func(string) string { return "" }, - lookPath: func(bin string) (string, error) { return "/usr/local/bin/" + bin, nil }, - fileExists: func(string) bool { return false }, - home: "/home/test", +func fakeSchemaProbe() ai.AuthProbe { + return ai.AuthProbe{ + Getenv: func(string) string { return "" }, + LookPath: func(bin string) (string, error) { return "/usr/local/bin/" + bin, nil }, + FileExists: func(string) bool { return false }, + Home: "/home/test", } } func stubbedSchemaAdapters(t *testing.T) []AdapterStatus { t.Helper() - adapters, err := ProbeAdapters(WhoamiOptions{Models: true}, fakeSchemaProbe()) + adapters, err := ai.ProbeAdapters(ai.WhoamiOptions{Models: true}, fakeSchemaProbe()) if err != nil { t.Fatalf("ProbeAdapters: %v", err) } @@ -259,50 +258,11 @@ func TestPromptSchemaExampleIsPortable(t *testing.T) { } } -func TestCachedSchemaAdaptersReusesWithinTTL(t *testing.T) { - prevProbe := probeSchemaAdapters - prevCache, prevAt := schemaAdapterCache, schemaAdapterAt - t.Cleanup(func() { - probeSchemaAdapters = prevProbe - schemaAdapterCache, schemaAdapterAt = prevCache, prevAt - }) - - stub := stubbedSchemaAdapters(t) - calls := 0 - probeSchemaAdapters = func() ([]AdapterStatus, error) { - calls++ - return stub, nil - } - schemaAdapterCache, schemaAdapterAt = nil, time.Time{} - - base := time.Unix(1_000_000, 0) - if _, err := cachedSchemaAdapters(base); err != nil { - t.Fatalf("cachedSchemaAdapters: %v", err) - } - if _, err := cachedSchemaAdapters(base.Add(10 * time.Second)); err != nil { - t.Fatalf("cachedSchemaAdapters: %v", err) - } - if calls != 1 { - t.Errorf("probe called %d times within TTL, want 1", calls) - } - if _, err := cachedSchemaAdapters(base.Add(2 * schemaAdapterCacheTTL)); err != nil { - t.Fatalf("cachedSchemaAdapters: %v", err) - } - if calls != 2 { - t.Errorf("probe called %d times after TTL expiry, want 2", calls) - } -} - func TestWritePromptSchemaEmitsValidJSON(t *testing.T) { - prevProbe := probeSchemaAdapters - prevCache, prevAt := schemaAdapterCache, schemaAdapterAt - t.Cleanup(func() { - probeSchemaAdapters = prevProbe - schemaAdapterCache, schemaAdapterAt = prevCache, prevAt - }) + prev := schemaAdapters + t.Cleanup(func() { schemaAdapters = prev }) stub := stubbedSchemaAdapters(t) - probeSchemaAdapters = func() ([]AdapterStatus, error) { return stub, nil } - schemaAdapterCache, schemaAdapterAt = nil, time.Time{} + schemaAdapters = func() ([]AdapterStatus, error) { return stub, nil } var buf bytes.Buffer if err := WritePromptSchema(&buf); err != nil { diff --git a/pkg/cli/secret_catalog.go b/pkg/cli/secret_catalog.go index a3cc00e..d3038d0 100644 --- a/pkg/cli/secret_catalog.go +++ b/pkg/cli/secret_catalog.go @@ -9,6 +9,7 @@ import ( "strings" "unicode/utf8" + "github.com/flanksource/captain/pkg/ai" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -169,7 +170,7 @@ func previewsForConfigMap(item corev1.ConfigMap) []secretKeyPreview { out := make([]secretKeyPreview, 0, len(keys)) for _, key := range keys { if value, ok := item.Data[key]; ok { - out = append(out, secretKeyPreview{Key: key, Value: maskKey(value)}) + out = append(out, secretKeyPreview{Key: key, Value: ai.MaskKey(value)}) continue } out = append(out, secretKeyPreview{Key: key, Value: byteCountPreview(item.BinaryData[key])}) @@ -212,7 +213,7 @@ func maskPreviewBytes(value []byte) string { if !utf8.Valid(value) { return byteCountPreview(value) } - return maskKey(string(value)) + return ai.MaskKey(string(value)) } func byteCountPreview(value []byte) string { diff --git a/pkg/cli/secret_catalog_test.go b/pkg/cli/secret_catalog_test.go index 0ea50fc..260e2a6 100644 --- a/pkg/cli/secret_catalog_test.go +++ b/pkg/cli/secret_catalog_test.go @@ -3,6 +3,7 @@ package cli import ( "testing" + "github.com/flanksource/captain/pkg/ai" corev1 "k8s.io/api/core/v1" ) @@ -18,7 +19,7 @@ func TestPreviewsForByteDataMasksTextAndBinary(t *testing.T) { if previews[0].Key != "binary" || previews[0].Value != "3 bytes" { t.Fatalf("binary preview = %+v", previews[0]) } - if previews[1].Key != "token" || previews[1].Value != maskKey("sk-ant-api03-ABCDEFGH") { + if previews[1].Key != "token" || previews[1].Value != ai.MaskKey("sk-ant-api03-ABCDEFGH") { t.Fatalf("token preview = %+v", previews[1]) } } @@ -38,7 +39,7 @@ func TestConfigMapKeysIncludeBinaryData(t *testing.T) { if previews[0].Key != "cert" || previews[0].Value != "2 bytes" { t.Fatalf("cert preview = %+v", previews[0]) } - if previews[1].Key != "workspace" || previews[1].Value != maskKey("/repo/workspace") { + if previews[1].Key != "workspace" || previews[1].Value != ai.MaskKey("/repo/workspace") { t.Fatalf("workspace preview = %+v", previews[1]) } } diff --git a/pkg/cli/webapp/src/SessionTable.tsx b/pkg/cli/webapp/src/SessionTable.tsx index c79394e..de92d14 100644 --- a/pkg/cli/webapp/src/SessionTable.tsx +++ b/pkg/cli/webapp/src/SessionTable.tsx @@ -1,5 +1,6 @@ import type { ComponentProps, KeyboardEvent, ReactNode } from "react"; import { Button } from "@flanksource/clicky-ui/components"; +import { providerIcon } from "@flanksource/clicky-ui/chat"; import { CopyBadge, Icon, @@ -10,11 +11,6 @@ import { UiChip, UiCopy, UiHistory, - UiLogoClaude, - UiLogoDeepseek, - UiLogoGemini, - UiLogoMistral, - UiLogoOpenai, UiMemoryStick, UiTerminal, } from "@flanksource/clicky-ui/data"; @@ -59,7 +55,7 @@ const SESSION_GRID_CLASS = "grid grid-cols-[minmax(14rem,1.6fr)_5.25rem_6.25rem] sm:grid-cols-[minmax(16rem,1.7fr)_5.25rem_6.25rem_6.25rem] lg:grid-cols-[minmax(20rem,2fr)_5.5rem_7rem_7rem_7rem_6rem_7rem_5.5rem]"; const SESSION_COLUMNS = [ - { label: "Model", sort: "model" }, + { label: "Session", sort: "model" }, { label: "Status", sort: "health" }, { label: "CPU", sort: "cpu" }, { label: "Memory", sort: "memory" }, @@ -219,7 +215,16 @@ export function UsageBarsCell({ ); } +// identityTitle prefers the human prompt (collapsed to one line) so the session +// list reads by what was asked, falling back to the derived title for live or +// prompt-less rows. +export function identityTitle(session: SessionRecord): string { + const prompt = session.initialPrompt?.replace(/\s+/g, " ").trim(); + return prompt || sessionTitle(session); +} + export function SessionIdentity({ session }: { session: SessionRecord }) { + const title = identityTitle(session); const model = modelLabel(session); const effort = effortLabel(session.reasoningEffort); const id = shortID(session.id) || session.key; @@ -229,31 +234,31 @@ export function SessionIdentity({ session }: { session: SessionRecord }) { - {model} + + {title} +
-
+
+ {model ? {model} : null} {effort ? ( - + {effort} ) : null} - {sessionTitle(session)} -
-
event.stopPropagation()} - > - {id ? : null} - {session.live?.pid ? ( - + event.stopPropagation()} + > + {id ? : null} + {session.live?.pid ? ( + + ) : null} + + {session.live?.command ? ( + {commandLabel(session.live.command)} ) : null}
- {session.live?.command ? ( -
- {commandLabel(session.live.command)} -
- ) : null}
); } @@ -488,15 +493,24 @@ export function modelIcon(session: { provider?: string; source?: string; }): SessionIcon { - const value = `${session.provider ?? ""} ${session.model ?? ""} ${session.source ?? ""}`.toLowerCase(); - if (value.includes("claude") || value.includes("anthropic")) return UiLogoClaude; - if (value.includes("codex") || value.includes("openai") || value.includes("gpt")) { - return UiLogoOpenai; - } - if (value.includes("gemini") || value.includes("google")) return UiLogoGemini; - if (value.includes("deepseek")) return UiLogoDeepseek; - if (value.includes("mistral")) return UiLogoMistral; - return UiTerminal; + return ( + providerIcon(session.provider) ?? + providerIcon(session.source) ?? + providerIcon(providerFromModel(session.model)) ?? + UiTerminal + ); +} + +// providerFromModel maps a model-id fragment to a provider key that providerIcon +// understands, for sessions that carry a model but no explicit provider/source. +function providerFromModel(model?: string): string | undefined { + const value = (model ?? "").toLowerCase(); + if (value.includes("claude") || value.includes("anthropic")) return "anthropic"; + if (value.includes("codex") || value.includes("gpt") || /\bo\d/.test(value)) return "openai"; + if (value.includes("gemini") || value.includes("google")) return "google"; + if (value.includes("deepseek")) return "deepseek"; + if (value.includes("mistral")) return "mistral"; + return undefined; } export function effortLabel(value: string | undefined) { diff --git a/pkg/cli/whoami.go b/pkg/cli/whoami.go index a86051c..0f7f1d3 100644 --- a/pkg/cli/whoami.go +++ b/pkg/cli/whoami.go @@ -1,503 +1,29 @@ package cli import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "github.com/flanksource/captain/pkg/ai" - "github.com/flanksource/captain/pkg/api" ) -type WhoamiOptions struct { - Backend string `flag:"backend" help:"Show only this backend: anthropic|openai|gemini|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|gemini-cli" short:"b"` - Models bool `flag:"models" help:"List models from provider APIs or installed CLI catalogs" default:"true" short:"m"` - Limit int `flag:"limit" help:"Max sample model IDs to show per adapter in pretty output after per-prefix filtering (0 = all)" default:"0" 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"` - - ModelDetails []ai.ModelDef `json:"modelDetails,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 -} +// WhoamiOptions and AdapterStatus live in pkg/ai: the adapter probe moved there +// so non-CLI consumers (the prompt --schema builder and the aichat server's +// model menu) can reuse it and its caching without importing pkg/cli. They are +// aliased here for the `captain whoami` command and its renderer. +type WhoamiOptions = ai.WhoamiOptions +type AdapterStatus = ai.AdapterStatus +// WhoamiResult is the command's render model: the probed adapters plus +// display-only knobs consumed by Pretty(). The knobs are never serialized. 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 - codexModels func(context.Context, string) ([]ai.ModelDef, error) - home string -} - -func osAuthProbe() authProbe { - home, _ := os.UserHomeDir() - return authProbe{ - getenv: os.Getenv, - lookPath: exec.LookPath, - codexModels: ai.FetchCodexDebugModels, - 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.BackendClaudeCmux: claude, - ai.BackendCodexCLI: { - binary: "codex", - logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, - }, - ai.BackendCodexAgent: { - binary: "codex", - logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, - }, - ai.BackendCodexCmux: { - binary: "codex", - logins: []loginFile{{rel: filepath.Join(".codex", "auth.json"), label: "codex login"}}, - }, - ai.BackendGeminiCLI: { - binary: "gemini", - logins: []loginFile{ - {rel: filepath.Join(".gemini", "oauth_creds.json"), label: "gemini login"}, - {rel: filepath.Join(".gemini", "google_accounts.json"), label: "gemini login"}, - }, - }, - } -} - -// resolveAdapter determines a backend's auth method and (for CLI backends) -// binary availability from the probed environment. An API-key env var always -// wins over a CLI login file because that is the path NewProvider/ListModels -// actually take. -func resolveAdapter(backend ai.Backend, p authProbe) AdapterStatus { - st := AdapterStatus{Backend: string(backend), Type: backend.Kind()} - - for _, v := range ai.AuthEnvVars(backend) { - if val := p.getenv(v); strings.TrimSpace(val) != "" { - st.Authenticated = true - st.AuthMethod = v + " (env)" - st.AuthDetail = maskKey(val) - break - } - } - - if cli, ok := cliAdapters()[backend]; ok { - if path, err := p.lookPath(cli.binary); err == nil { - st.Binary = path - } else { - st.BinaryMissing = cli.binary - } - if !st.Authenticated { - for _, lf := range cli.logins { - full := filepath.Join(p.home, lf.rel) - if p.fileExists(full) { - st.Authenticated = true - st.AuthMethod = lf.label - st.AuthDetail = full - break - } - } - } - } - - return st -} - -// maskKey renders a secret as the first and last four characters so the output -// is identifiable without ever exposing the full token. -func maskKey(s string) string { - s = strings.TrimSpace(s) - if len(s) <= 8 { - return "****" - } - return s[:4] + "…" + s[len(s)-4:] -} - -func firstEnv(vars []string, getenv func(string) string) string { - for _, v := range vars { - if val := getenv(v); strings.TrimSpace(val) != "" { - return val - } - } - return "" -} - func RunWhoami(opts WhoamiOptions) (any, error) { - adapters, err := ProbeAdapters(opts, osAuthProbe()) + adapters, err := ai.ProbeAdapters(opts, ai.OSAuthProbe()) if err != nil { return nil, err } return WhoamiResult{Adapters: adapters, sampleLimit: opts.Limit, showModels: opts.Models}, nil } - -// ProbeAdapters resolves each backend's auth/availability and (when opts.Models) -// its model listing against the supplied environment probe. It is the shared, -// injectable core behind both `captain whoami` and the prompt --schema builder, -// so passing a stub authProbe keeps callers hermetic (no live API calls when the -// probe reports no API keys). -func ProbeAdapters(opts WhoamiOptions, probe authProbe) ([]AdapterStatus, 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} - } - - var models map[ai.Backend]modelFetch - var codexModels modelFetch - if opts.Models { - models = fetchAPIModels(backends, probe.getenv) - codexModels = fetchCodexModels(backends, probe) - } - - adapters := make([]AdapterStatus, 0, len(backends)) - for _, b := range backends { - st := resolveAdapter(b, probe) - if opts.Models { - applyModels(&st, b, models, codexModels, probe.getenv) - } - adapters = append(adapters, st) - } - return adapters, nil -} - -type modelFetch struct { - models []ai.ModelDef - err error -} - -var resolveModelRows = ai.ResolveModels - -// fetchAPIModels resolves each direct provider backend's live /v1/models -// endpoint once, concurrently. Local CLI/agent/cmux adapters deliberately do -// not participate: their model catalogs must describe the runtime they execute, -// independent of whether the parent provider's API key happens to be present. -// The resolver is Captain's cached model path, so repeated whoami calls reuse a -// fresh cache instead of hitting providers every time. -func fetchAPIModels(backends []ai.Backend, getenv func(string) string) map[ai.Backend]modelFetch { - apis := map[ai.Backend]bool{} - for _, b := range backends { - if b.Kind() != "api" { - continue - } - source := modelSourceBackend(b) - if source == "" { - continue - } - if firstEnv(ai.AuthEnvVars(source), getenv) != "" { - apis[source] = true - } - } - - out := make(map[ai.Backend]modelFetch, len(apis)) - var mu sync.Mutex - var wg sync.WaitGroup - for b := range apis { - wg.Add(1) - go func(backend ai.Backend) { - defer wg.Done() - rows, err := resolveModelRows(context.Background(), ai.ResolveOptions{Backend: backend, UseTokens: true}) - m := liveModelDefs(rows, backend) - mu.Lock() - out[backend] = modelFetch{models: m, err: err} - mu.Unlock() - }(b) - } - wg.Wait() - return out -} - -func fetchCodexModels(backends []ai.Backend, probe authProbe) modelFetch { - if probe.codexModels == nil { - return modelFetch{} - } - wanted := false - for _, backend := range backends { - if isCodexBackend(backend) { - wanted = true - break - } - } - if !wanted { - return modelFetch{} - } - binary, err := probe.lookPath("codex") - if err != nil || strings.TrimSpace(binary) == "" { - return modelFetch{err: fmt.Errorf("codex not in PATH")} - } - models, err := probe.codexModels(context.Background(), binary) - return modelFetch{models: models, err: err} -} - -func isCodexBackend(backend ai.Backend) bool { - switch backend { - case ai.BackendCodexAgent, ai.BackendCodexCLI, ai.BackendCodexCmux: - return true - default: - return false - } -} - -func liveModelDefs(rows []ai.ResolvedModel, backend ai.Backend) []ai.ModelDef { - out := make([]ai.ModelDef, 0, len(rows)) - for _, row := range rows { - if !row.Live { - continue - } - id := row.RuntimeID() - if id == "" { - continue - } - name := row.Label - if name == "" { - name = id - } - out = append(out, ai.ModelDef{ - ID: id, - Name: name, - Backend: backend, - ReleaseDate: row.ReleaseDate, - CapabilitiesKnown: true, - Reasoning: row.Reasoning, - Temperature: row.Temperature, - SupportedEfforts: append([]api.Effort(nil), row.SupportedEfforts...), - DefaultEffort: row.DefaultEffort, - Priority: row.Priority, - }) - } - return out -} - -// applyModels fills in the model listing (or the reason it is unavailable) for -// a single adapter. Direct API backends use live provider rows. Local adapters -// use the catalog of the runtime they execute: Codex's installed catalog when -// available, otherwise Captain's backend-specific registry projection. -func applyModels(st *AdapterStatus, b ai.Backend, cache map[ai.Backend]modelFetch, codex modelFetch, getenv func(string) string) { - if isCodexBackend(b) { - if codex.err == nil && len(codex.models) > 0 { - models := make([]ai.ModelDef, len(codex.models)) - for i, model := range codex.models { - model.Backend = b - models[i] = model - } - setModels(st, models, true) - return - } - setRegistryModels(st, b, codex.err) - return - } - if b.Kind() == "cli" { - setRegistryModels(st, b, nil) - return - } - - source := modelSourceBackend(b) - if source == "" { - st.ModelError = fmt.Sprintf("backend %s has no model listing", b) - return - } - - envVars := ai.AuthEnvVars(source) - if firstEnv(envVars, getenv) == "" { - st.ModelError = "set " + strings.Join(envVars, " or ") + " to list models" - return - } - - fetch, ok := cache[source] - if !ok { - return - } - if fetch.err != nil { - st.ModelError = fetch.err.Error() - return - } - setModels(st, modelsForAdapterBackend(b, fetch.models), false) -} - -func setRegistryModels(st *AdapterStatus, backend ai.Backend, discoveryErr error) { - setModels(st, ai.RegistryModelDefs(backend), true) - if len(st.Models) > 0 { - return - } - if discoveryErr != nil { - st.ModelError = fmt.Sprintf("runtime model discovery failed: %v; registry has no models for %s", discoveryErr, backend) - return - } - st.ModelError = fmt.Sprintf("registry has no models for %s", backend) -} - -func modelSourceBackend(backend ai.Backend) ai.Backend { - switch backend { - case ai.BackendAnthropic, ai.BackendClaudeAgent, ai.BackendClaudeCLI, ai.BackendClaudeCmux: - return ai.BackendAnthropic - case ai.BackendOpenAI, ai.BackendCodexAgent, ai.BackendCodexCLI, ai.BackendCodexCmux: - return ai.BackendOpenAI - case ai.BackendGemini, ai.BackendGeminiCLI: - return ai.BackendGemini - case ai.BackendDeepSeek: - return ai.BackendDeepSeek - default: - return "" - } -} - -func modelsForAdapterBackend(backend ai.Backend, models []ai.ModelDef) []ai.ModelDef { - out := make([]ai.ModelDef, 0, len(models)) - positions := map[string]int{} - for _, model := range models { - if model.Backend == ai.BackendOpenAI { - if known, available := ai.RegistryModelAvailability(backend, bareProviderModelID(model.ID)); known && !available { - continue - } - if ai.IsIgnoredOpenAIModelID(model.ID) { - if _, ok := ai.RegistryModelDef(backend, bareProviderModelID(model.ID)); !ok { - continue - } - } - } - id := modelIDForAdapterBackend(backend, model.ID) - if id == "" { - continue - } - name := model.Name - if name == "" { - name = id - } - next := ai.ModelDef{ - ID: id, - Name: name, - Backend: backend, - ReleaseDate: model.ReleaseDate, - CapabilitiesKnown: model.CapabilitiesKnown, - Reasoning: model.Reasoning, - Temperature: model.Temperature, - SupportedEfforts: append([]api.Effort(nil), model.SupportedEfforts...), - DefaultEffort: model.DefaultEffort, - Priority: model.Priority, - } - if idx, ok := positions[id]; ok { - if modelDefNewer(next, out[idx]) { - out[idx] = next - } - continue - } - positions[id] = len(out) - out = append(out, next) - } - return out -} - -func modelIDForAdapterBackend(backend ai.Backend, id string) string { - return ai.NormalizeModelForBackend(backend, bareProviderModelID(id)) -} - -func modelDefNewer(left, right ai.ModelDef) bool { - if left.Priority != right.Priority && (left.Priority > 0 || right.Priority > 0) { - if left.Priority == 0 { - return false - } - if right.Priority == 0 { - return true - } - return left.Priority < right.Priority - } - if left.ReleaseDate == "" { - return false - } - if right.ReleaseDate == "" { - return true - } - return left.ReleaseDate > right.ReleaseDate -} - -func bareProviderModelID(id string) string { - id = strings.TrimSpace(id) - if i := strings.LastIndex(id, "/"); i >= 0 { - return id[i+1:] - } - return id -} - -// setModels filters legacy entries and copies the sorted model list onto the -// adapter status as a count plus id list. The richer details are retained only -// for pretty output; JSON stays as the historical []string model list. -func setModels(st *AdapterStatus, models []ai.ModelDef, curated bool) { - if curated { - models = ai.CurrentCuratedModelsByReleaseDate(models) - } else { - models = ai.CurrentModelsByReleaseDate(models) - } - st.ModelCount = len(models) - ids := make([]string, 0, len(models)) - for _, m := range models { - ids = append(ids, m.ID) - } - st.Models = ids - st.ModelDetails = models -} diff --git a/pkg/cli/whoami_render.go b/pkg/cli/whoami_render.go index ec025e4..5b32509 100644 --- a/pkg/cli/whoami_render.go +++ b/pkg/cli/whoami_render.go @@ -36,30 +36,34 @@ func (r WhoamiResult) renderGroup(t api.Text, kind string, icon api.Textable, ti Add(icon).Space(). Append(title, "font-bold text-gray-700") for _, a := range rows { - t = t.NewLine().Add(a.prettyLine(r.showModels, r.sampleLimit)) + t = t.NewLine().Add(adapterPrettyLine(a, r.showModels, r.sampleLimit)) } return t } -func (a AdapterStatus) prettyLine(showModels bool, limit int) api.Text { +// AdapterStatus is defined in pkg/ai, so its renderers are free functions here +// rather than methods (a package may only declare methods on its own types). + +func adapterPrettyLine(a AdapterStatus, showModels bool, limit int) api.Text { t := api.Text{}. Append(" ", ""). - Add(a.statusIcon()).Space(). + Add(adapterStatusIcon(a)).Space(). Append(fmt.Sprintf("%-13s", a.Backend), "font-medium") - t = a.appendAuth(t) + t = adapterAppendAuth(a, t) if a.Type == "cli" { - t = a.appendBinary(t) + t = adapterAppendBinary(a, t) } if showModels { - t = a.appendModels(t, limit) + t = adapterAppendModels(a, 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 { +// adapterStatusIcon 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 adapterStatusIcon(a AdapterStatus) api.Textable { switch { case a.Ready(): return icons.Check @@ -70,7 +74,7 @@ func (a AdapterStatus) statusIcon() api.Textable { } } -func (a AdapterStatus) appendAuth(t api.Text) api.Text { +func adapterAppendAuth(a AdapterStatus, t api.Text) api.Text { if !a.Authenticated { msg := "not configured" if vars := strings.Join(ai.AuthEnvVars(ai.Backend(a.Backend)), " or "); vars != "" { @@ -85,14 +89,14 @@ func (a AdapterStatus) appendAuth(t api.Text) api.Text { return t } -func (a AdapterStatus) appendBinary(t api.Text) api.Text { +func adapterAppendBinary(a AdapterStatus, 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 { +func adapterAppendModels(a AdapterStatus, 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") diff --git a/pkg/cli/whoami_test.go b/pkg/cli/whoami_test.go index 7d3d799..612e762 100644 --- a/pkg/cli/whoami_test.go +++ b/pkg/cli/whoami_test.go @@ -1,248 +1,14 @@ package cli import ( - "context" - "errors" - "os" - "path/filepath" "strings" "testing" "github.com/flanksource/captain/pkg/ai" - "github.com/flanksource/captain/pkg/api" ) -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_CmuxUsesLocalLoginWithoutAPIKey(t *testing.T) { - home := "/home/u" - claudeAuth := filepath.Join(home, ".claude.json") - codexAuth := filepath.Join(home, ".codex", "auth.json") - - claude := resolveAdapter(ai.BackendClaudeCmux, fakeProbe( - nil, - map[string]string{"claude": "/usr/local/bin/claude"}, - map[string]bool{claudeAuth: true}, - home)) - if claude.Type != "cli" || !claude.Ready() { - t.Fatalf("claude-cmux should be ready through local claude login, got %+v", claude) - } - if claude.AuthMethod != "claude login" || claude.AuthDetail != claudeAuth { - t.Fatalf("claude-cmux auth = %q %q", claude.AuthMethod, claude.AuthDetail) - } - - codex := resolveAdapter(ai.BackendCodexCmux, fakeProbe( - nil, - map[string]string{"codex": "/usr/local/bin/codex"}, - map[string]bool{codexAuth: true}, - home)) - if codex.Type != "cli" || !codex.Ready() { - t.Fatalf("codex-cmux should be ready through local codex login, got %+v", codex) - } - if codex.AuthMethod != "codex login" || codex.AuthDetail != codexAuth { - t.Fatalf("codex-cmux auth = %q %q", codex.AuthMethod, codex.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) - } -} - -func TestProbeAdaptersUsesRegistryModelsForClaudeCmuxRegardlessOfAPIKey(t *testing.T) { - prev := resolveModelRows - resolveModelRows = func(_ context.Context, opts ai.ResolveOptions) ([]ai.ResolvedModel, error) { - t.Fatalf("local Claude adapter should not resolve provider API models: %+v", opts) - return nil, nil - } - t.Cleanup(func() { resolveModelRows = prev }) - - var withoutKey []string - for _, env := range []map[string]string{nil, map[string]string{"ANTHROPIC_API_KEY": "sk-ant-test"}} { - adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendClaudeCmux), Models: true}, fakeProbe( - env, - map[string]string{"claude": "/usr/local/bin/claude"}, - nil, - "/home/u", - )) - if err != nil { - t.Fatalf("ProbeAdapters: %v", err) - } - if len(adapters) != 1 || len(adapters[0].Models) == 0 { - t.Fatalf("adapters = %+v, want one adapter with registry models", adapters) - } - if !stringSliceContains(adapters[0].Models, "claude-fable-5") { - t.Fatalf("models = %v, want preferred Fable model", adapters[0].Models) - } - var fable *ai.ModelDef - for i := range adapters[0].ModelDetails { - if adapters[0].ModelDetails[i].ID == "claude-fable-5" { - fable = &adapters[0].ModelDetails[i] - break - } - } - if fable == nil || !fable.CapabilitiesKnown || !fable.Reasoning || fable.Temperature || len(fable.SupportedEfforts) != 5 { - t.Fatalf("fable model details = %+v", fable) - } - if withoutKey == nil { - withoutKey = append([]string(nil), adapters[0].Models...) - continue - } - if strings.Join(adapters[0].Models, "\x00") != strings.Join(withoutKey, "\x00") { - t.Fatalf("models with key = %v, without key = %v", adapters[0].Models, withoutKey) - } - } -} - -func TestProbeAdaptersFiltersNoisyOpenAIModelsForDirectAPI(t *testing.T) { - prev := resolveModelRows - resolveModelRows = func(_ context.Context, opts ai.ResolveOptions) ([]ai.ResolvedModel, error) { - if opts.Backend != ai.BackendOpenAI || !opts.UseTokens { - t.Fatalf("resolve opts = %+v, want openai live token resolve", opts) - } - return []ai.ResolvedModel{ - {Model: ai.Model{ID: "openai/gpt-5.5", Backend: ai.BackendOpenAI, Label: "GPT-5.5", ReleaseDate: "2026-06-01"}, Live: true}, - {Model: ai.Model{ID: "openai/gpt-5.4", Backend: ai.BackendOpenAI, Label: "GPT-5.4", ReleaseDate: "2026-05-15"}, Live: true}, - {Model: ai.Model{ID: "openai/gpt-realtime-2.1", Backend: ai.BackendOpenAI, Label: "Realtime"}, Live: true}, - {Model: ai.Model{ID: "openai/gpt-image-2", Backend: ai.BackendOpenAI, Label: "Image"}, Live: true}, - {Model: ai.Model{ID: "openai/gpt-audio-1.5", Backend: ai.BackendOpenAI, Label: "Audio"}, Live: true}, - {Model: ai.Model{ID: "openai/gpt-5.3-codex", Backend: ai.BackendOpenAI, Label: "Codex"}, Live: true}, - {Model: ai.Model{ID: "openai/gpt-5.3-chat-latest", Backend: ai.BackendOpenAI, Label: "Chat latest"}, Live: true}, - {Model: ai.Model{ID: "openai/gpt-5.5-pro", Backend: ai.BackendOpenAI, Label: "Pro"}, Live: true}, - {Model: ai.Model{ID: "openai/o4-mini", Backend: ai.BackendOpenAI, Label: "O4 mini"}, Live: true}, - {Model: ai.Model{ID: "openai/sora-2", Backend: ai.BackendOpenAI, Label: "Sora"}, Live: true}, - }, nil - } - t.Cleanup(func() { resolveModelRows = prev }) - - adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendOpenAI), Models: true}, fakeProbe( - map[string]string{"OPENAI_API_KEY": "sk-test"}, - map[string]string{"codex": "/usr/local/bin/codex"}, - nil, - "/home/u", - )) - if err != nil { - t.Fatalf("ProbeAdapters: %v", err) - } - if len(adapters) != 1 { - t.Fatalf("adapter count = %d, want 1", len(adapters)) - } - got := adapters[0].Models - for _, want := range []string{"gpt-5.5", "gpt-5.4"} { - if !stringSliceContains(got, want) { - t.Fatalf("models = %v, want primary OpenAI model %q", got, want) - } - } - for _, hidden := range []string{"gpt-realtime-2.1", "gpt-image-2", "gpt-audio-1.5", "gpt-5.3-codex", "gpt-5.3-chat-latest", "gpt-5.5-pro", "o4-mini", "sora-2"} { - if stringSliceContains(got, hidden) { - t.Fatalf("models = %v, should hide noisy OpenAI model %q", got, hidden) - } - } -} +// The adapter probe and its logic tests live in pkg/ai (pkg/ai/adapters_test.go). +// These cover the CLI-only surface: the whoami command and its Pretty() renderer. func TestWhoamiPrettyKeylessCmuxDoesNotRequestAPIKey(t *testing.T) { got := (WhoamiResult{ @@ -261,158 +27,6 @@ func TestWhoamiPrettyKeylessCmuxDoesNotRequestAPIKey(t *testing.T) { } } -func TestProbeAdaptersUsesCodexDebugModelsOnceRegardlessOfAPIKey(t *testing.T) { - probe := fakeProbe(map[string]string{"OPENAI_API_KEY": "sk-test"}, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") - calls := 0 - probe.codexModels = func(_ context.Context, binary string) ([]ai.ModelDef, error) { - calls++ - if binary != "/usr/local/bin/codex" { - t.Fatalf("binary = %q", binary) - } - return []ai.ModelDef{ - {ID: "gpt-5.6-sol", Name: "GPT-5.6-Sol", Priority: 1, DefaultEffort: api.EffortLow, SupportedEfforts: []api.Effort{api.EffortLow, api.EffortHigh, api.EffortUltra}}, - {ID: "gpt-5.6-luna", Name: "GPT-5.6-Luna", Priority: 3, DefaultEffort: api.EffortMedium, SupportedEfforts: []api.Effort{api.EffortLow, api.EffortHigh, api.EffortMax}}, - }, nil - } - adapters, err := ProbeAdapters(WhoamiOptions{Models: true}, probe) - if err != nil { - t.Fatalf("ProbeAdapters: %v", err) - } - if calls != 1 { - t.Fatalf("codex debug calls = %d, want 1", calls) - } - for _, backend := range []ai.Backend{ai.BackendCodexCLI, ai.BackendCodexAgent, ai.BackendCodexCmux} { - adapter := findAdapter(t, adapters, backend) - if len(adapter.Models) != 2 || adapter.Models[0] != "gpt-5.6-sol" { - t.Fatalf("%s models = %v", backend, adapter.Models) - } - if adapter.ModelDetails[0].DefaultEffort != api.EffortLow { - t.Fatalf("%s details = %+v", backend, adapter.ModelDetails[0]) - } - } -} - -func TestFetchCodexModelsUsesDebugWithAPIKey(t *testing.T) { - probe := fakeProbe(map[string]string{"OPENAI_API_KEY": "sk-test"}, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") - calls := 0 - probe.codexModels = func(_ context.Context, binary string) ([]ai.ModelDef, error) { - calls++ - if binary != "/usr/local/bin/codex" { - t.Fatalf("binary = %q", binary) - } - return []ai.ModelDef{{ID: "gpt-5.6-sol"}}, nil - } - got := fetchCodexModels([]ai.Backend{ai.BackendCodexAgent}, probe) - if got.err != nil || len(got.models) != 1 || calls != 1 { - t.Fatalf("fetch = %+v calls = %d, want one discovered model", got, calls) - } -} - -func TestProbeAdaptersFallsBackToRegistryWhenCodexDebugFails(t *testing.T) { - probe := fakeProbe(nil, map[string]string{"codex": "/usr/local/bin/codex"}, nil, "/home/u") - probe.codexModels = func(context.Context, string) ([]ai.ModelDef, error) { - return nil, errors.New("old codex") - } - adapters, err := ProbeAdapters(WhoamiOptions{Backend: string(ai.BackendCodexCLI), Models: true}, probe) - if err != nil { - t.Fatalf("ProbeAdapters: %v", err) - } - if len(adapters) != 1 || !stringSliceContains(adapters[0].Models, "gpt-5.6-sol") { - t.Fatalf("registry fallback models = %+v", adapters) - } -} - -func findAdapter(t *testing.T, adapters []AdapterStatus, backend ai.Backend) AdapterStatus { - t.Helper() - for _, adapter := range adapters { - if adapter.Backend == string(backend) { - return adapter - } - } - t.Fatalf("adapter %s not found", backend) - return AdapterStatus{} -} - -// 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") - } -} - -func TestSetModelsFiltersAndSortsByReleaseDate(t *testing.T) { - st := AdapterStatus{Backend: string(ai.BackendAnthropic)} - setModels(&st, []ai.ModelDef{ - {ID: "claude-sonnet-5", Backend: ai.BackendAnthropic, ReleaseDate: "2026-06-01"}, - {ID: "claude-sonnet-4-6", Backend: ai.BackendAnthropic, ReleaseDate: "2026-05-01"}, - {ID: "claude-sonnet-4-5", Backend: ai.BackendAnthropic, ReleaseDate: "2026-04-01"}, - {ID: "claude-sonnet-4-4", Backend: ai.BackendAnthropic, ReleaseDate: "2026-03-01"}, - {ID: "claude-haiku-4-5", Backend: ai.BackendAnthropic, ReleaseDate: "2025-10-15"}, - {ID: "claude-3-5-sonnet-20241022", Backend: ai.BackendAnthropic, ReleaseDate: "2024-10-22"}, - }, false) - - want := []string{"claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5"} - if st.ModelCount != len(want) { - t.Fatalf("ModelCount = %d, want %d (%+v)", st.ModelCount, len(want), st) - } - for i, w := range want { - if st.Models[i] != w { - t.Errorf("Models[%d] = %q, want %q", i, st.Models[i], w) - } - } -} - -func TestSetModelsKeepsGeminiProFamily(t *testing.T) { - st := AdapterStatus{Backend: string(ai.BackendGemini)} - setModels(&st, []ai.ModelDef{ - {ID: "gemini-3.5-flash", Backend: ai.BackendGemini, ReleaseDate: "2026-06-10"}, - {ID: "gemini-3.0-pro", Backend: ai.BackendGemini}, - {ID: "gemini-2.5-pro", Backend: ai.BackendGemini, ReleaseDate: "2025-06-17"}, - {ID: "gemini-2.0-flash", Backend: ai.BackendGemini, ReleaseDate: "2025-02-05"}, - }, false) - - want := []string{"gemini-3.5-flash", "gemini-3.0-pro", "gemini-2.5-pro"} - if st.ModelCount != len(want) { - t.Fatalf("ModelCount = %d, want %d (%+v)", st.ModelCount, len(want), st) - } - for i, w := range want { - if st.Models[i] != w { - t.Errorf("Models[%d] = %q, want %q", i, st.Models[i], w) - } - } -} - -func TestSetModelsHidesCodexCodeVariantsForCLI(t *testing.T) { - st := AdapterStatus{Backend: string(ai.BackendCodexCLI)} - setModels(&st, []ai.ModelDef{ - {ID: "gpt-5-codex", Backend: ai.BackendCodexCLI, ReleaseDate: "2025-08-07"}, - }, false) - - if st.ModelCount != 0 || len(st.Models) != 0 { - t.Fatalf("codex code variant should be hidden for CLI: %+v", st) - } -} - func TestWhoamiPrettyRendersModelListItems(t *testing.T) { adapter := AdapterStatus{ Backend: string(ai.BackendOpenAI), @@ -455,11 +69,29 @@ func TestWhoamiPrettyRendersModelListItems(t *testing.T) { } } -func stringSliceContains(items []string, want string) bool { - for _, item := range items { - if item == want { - return true +// 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) } } - return false +} + +func TestRunWhoami_RejectsUnknownBackend(t *testing.T) { + if _, err := RunWhoami(WhoamiOptions{Backend: "bogus", Models: false}); err == nil { + t.Fatal("expected error for unknown --backend") + } } From 0d4402e303fa4b464b15c35eaa3aea19e1dc7e62 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 12:07:10 +0300 Subject: [PATCH 042/131] feat|fix|perf|refactor|test|docs|build|ci|chore|revert: support plan-mode terminal outcomes and structured data validation --- pkg/ai/provider/cmux/executor.go | 22 +++- pkg/ai/provider/cmux/provider.go | 129 +++++++++++++++++++----- pkg/ai/provider/cmux/provider_test.go | 33 +++++- pkg/ai/provider/cmux/sessionlog.go | 34 ++++++- pkg/ai/provider/cmux/sessionlog_test.go | 57 +++++++++++ pkg/ai/provider/cmux/stall.go | 13 ++- pkg/ai/provider/cmux/stall_test.go | 48 +++++++++ pkg/cmux/client.go | 3 + pkg/cmux/client_test.go | 5 +- 9 files changed, 308 insertions(+), 36 deletions(-) diff --git a/pkg/ai/provider/cmux/executor.go b/pkg/ai/provider/cmux/executor.go index 45fa6f4..27f77a8 100644 --- a/pkg/ai/provider/cmux/executor.go +++ b/pkg/ai/provider/cmux/executor.go @@ -112,13 +112,33 @@ type run struct { // 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 + approvals sync.WaitGroup + planMode bool + planExitSeen bool + dismissPlanOnce sync.Once + dismissPlanErr error lastSurface WorkspaceRef lastSessionID string lastWorkDir string } +func (r *run) dismissPlanSurface(ctx context.Context, ref WorkspaceRef, sessionID string) error { + r.dismissPlanOnce.Do(func() { + if err := r.client.SendKeySurface(ctx, ref.String(), ref.SurfaceID, "Escape"); err != nil { + r.dismissPlanErr = fmt.Errorf("dismiss claude plan surface for session %s: %w", sessionID, err) + } + }) + return r.dismissPlanErr +} + +func (r *run) dismissCompletedPlan(ctx context.Context, ref WorkspaceRef, sessionID string) error { + if !r.planMode || !r.planExitSeen { + return nil + } + return r.dismissPlanSurface(ctx, ref, sessionID) +} + // 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{ diff --git a/pkg/ai/provider/cmux/provider.go b/pkg/ai/provider/cmux/provider.go index b56dbaf..41e5b68 100644 --- a/pkg/ai/provider/cmux/provider.go +++ b/pkg/ai/provider/cmux/provider.go @@ -78,10 +78,9 @@ func (p *Provider) GetBackend() ai.Backend { // Execute drains its own ExecuteStream into a buffered ai.Response. cmux cannot // constrain output natively, so a structured-output request is served by -// appending a schema instruction to the prompt (see ExecuteStream) and -// extracting the JSON object from the reply here. +// appending a schema instruction to the prompt (see ExecuteStream); the stream +// carries the validated JSON on its terminal EventResult. func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { - schemaRequested := req.Prompt.HasSchema() start := time.Now() events, err := p.ExecuteStream(ctx, req) if err != nil { @@ -89,14 +88,25 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e } var ( - text strings.Builder - usage ai.Usage - sessionID string - success = true - sawResult bool - lastErr string + text strings.Builder + usage ai.Usage + sessionID string + success = true + sawResult bool + lastErr string + structured json.RawMessage + outcome *ai.TerminalOutcome + outcomeErr error ) for ev := range events { + if outcomeErr == nil { + parsed, parseErr := ai.TerminalOutcomeFromEvent(ev) + if parseErr != nil { + outcomeErr = parseErr + } else if parsed != nil { + outcome = parsed + } + } switch ev.Kind { case ai.EventText: text.WriteString(ev.Text) @@ -107,6 +117,9 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e case ai.EventResult: sawResult = true success = ev.Success + if len(ev.StructuredData) > 0 { + structured = ev.StructuredData + } if ev.Usage != nil { usage = *ev.Usage } @@ -117,6 +130,9 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e lastErr = ev.Error } } + if outcomeErr != nil { + return nil, fmt.Errorf("cmux: invalid terminal outcome: %w", outcomeErr) + } if !sawResult && lastErr != "" { return nil, fmt.Errorf("%w: %s", ai.ErrCLIExecutionFailed, lastErr) @@ -130,19 +146,24 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e } resp := &ai.Response{ - Text: text.String(), - Model: p.model, - Backend: p.GetBackend(), - Usage: usage, - Duration: time.Since(start), + Text: text.String(), + Model: p.model, + Backend: p.GetBackend(), + Usage: usage, + Duration: time.Since(start), + TerminalOutcome: outcome, } if sessionID != "" { resp.Raw = map[string]any{"session_id": sessionID} } - if schemaRequested { - if obj, ok := ai.ExtractJSONObject(resp.Text); ok { - resp.StructuredData = json.RawMessage(obj) + if len(structured) > 0 && req.Prompt.Schema != nil { + if err := ai.BindStructuredOutput(req.Prompt.Schema, structured); err != nil { + return nil, err } + resp.StructuredData = req.Prompt.Schema + resp.Text = "" + } else if len(structured) > 0 { + resp.StructuredData = structured } return resp, nil } @@ -150,7 +171,7 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e // 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) { - req, err := withSchemaPrompt(req) + req, schema, err := withSchemaPrompt(req) if err != nil { return nil, err } @@ -158,45 +179,91 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai return nil, fmt.Errorf("cmux provider: prompt is required") } events := make(chan ai.Event, 32) - go p.drive(ctx, req, events) + go p.drive(ctx, req, schema, events) return events, nil } // withSchemaPrompt makes a structured-output request runnable on cmux (which // cannot enforce a schema natively) by appending a JSON-only schema instruction // to the user prompt and clearing the native schema fields so the run is a plain -// text turn. The reply's JSON is recovered in Execute. -func withSchemaPrompt(req ai.Request) (ai.Request, error) { +// text turn. The reply's JSON is validated and attached to EventResult in drive. +func withSchemaPrompt(req ai.Request) (ai.Request, json.RawMessage, error) { schema, err := ai.SchemaJSONFor(req.Prompt) if err != nil { - return req, err + return req, nil, err } if len(schema) > 0 { req.Prompt.User = strings.TrimRight(req.Prompt.User, "\n") + "\n\n" + ai.SchemaInstruction(string(schema)) } req.Prompt.Schema = nil req.Prompt.SchemaJSON = nil - return req, nil + return req, schema, 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) { +func (p *Provider) drive(ctx context.Context, req ai.Request, schema json.RawMessage, events chan ai.Event) { defer close(events) + var text strings.Builder + var outcome *ai.TerminalOutcome + var outcomeErr error r := &run{ client: NewClient(""), - emit: func(ev ai.Event) { emit(ctx, events, ev) }, canUseTool: p.cfg.CanUseTool, } + r.emit = func(ev ai.Event) { + switch ev.Kind { + case ai.EventText: + text.WriteString(ev.Text) + case ai.EventToolUse: + if ev.Tool == "ExitPlanMode" { + r.planExitSeen = true + } + if outcomeErr == nil { + parsed, err := ai.TerminalOutcomeFromEvent(ev) + if err != nil { + outcomeErr = err + } else if parsed != nil { + outcome = parsed + } + } + } + emit(ctx, events, ev) + } usage, cost, err := p.execute(ctx, req, r) + if err == nil && outcomeErr != nil { + err = fmt.Errorf("cmux: invalid terminal outcome: %w", outcomeErr) + } + var structured json.RawMessage + if err == nil { + structured, err = validatedStructuredData(schema, text.String(), outcome) + } 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}) + emit(ctx, events, ai.Event{Kind: ai.EventResult, Success: true, Usage: usage, CostUSD: cost, Model: p.model, StructuredData: structured}) +} + +func validatedStructuredData(schema json.RawMessage, text string, outcome *ai.TerminalOutcome) (json.RawMessage, error) { + if len(schema) == 0 || outcome != nil { + return nil, nil + } + object, ok := ai.ExtractJSONObject(text) + if !ok { + return nil, fmt.Errorf("%w: response carried no JSON object", ai.ErrSchemaValidation) + } + violations, err := ai.ValidateStructuredJSON(schema, object) + if err != nil { + return nil, err + } + if violations != "" { + return nil, fmt.Errorf("%w: %s", ai.ErrSchemaValidation, violations) + } + return json.RawMessage(object), nil } // cmuxExtraArgs decodes req.CLIArgs into the backend's typed "extra cmux args" @@ -247,6 +314,7 @@ func decodeCLIArgs(args map[string]any, out any) error { func (p *Provider) execute(ctx context.Context, req ai.Request, r *run) (*ai.Usage, float64, error) { start := time.Now() agent := p.agent + r.planMode = req.Permissions.Mode == api.PermissionPlan model := modelFlag(agent, p.model) workDir := groupWorkDir(req.Cwd()) @@ -376,12 +444,19 @@ func (p *Provider) execute(ctx context.Context, req ai.Request, r *run) (*ai.Usa acc.Finish() snap := acc.snapshot() pausedForQuestion := snap.State == sessionStateAsk + if err := r.dismissCompletedPlan(ctx, ref, sessionID); err != nil { + return nil, 0, err + } if pausedForQuestion { // Claude's terminal UI remains inside the interactive question picker even // though the structured tool event contains everything the host needs to // render the form. Dismiss that surface once; the host resumes the same // session later with SendFeedback and a structured answers payload. - if err := r.client.SendKeySurface(ctx, ref.String(), ref.SurfaceID, "Escape"); err != nil { + if r.planMode { + if err := r.dismissPlanSurface(ctx, ref, sessionID); err != nil { + return nil, 0, err + } + } else if err := r.client.SendKeySurface(ctx, ref.String(), ref.SurfaceID, "Escape"); err != nil { return nil, 0, fmt.Errorf("dismiss claude question for session %s: %w", sessionID, err) } } diff --git a/pkg/ai/provider/cmux/provider_test.go b/pkg/ai/provider/cmux/provider_test.go index 20383eb..ca259cf 100644 --- a/pkg/ai/provider/cmux/provider_test.go +++ b/pkg/ai/provider/cmux/provider_test.go @@ -2,6 +2,7 @@ package cmux import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -19,13 +20,16 @@ func TestWithSchemaPrompt(t *testing.T) { User: "review the diff", SchemaJSON: []byte(`{"type":"object","required":["pass"]}`), }} - got, err := withSchemaPrompt(req) + got, schema, err := withSchemaPrompt(req) if err != nil { t.Fatalf("withSchemaPrompt: %v", err) } if got.Prompt.Schema != nil || got.Prompt.SchemaJSON != nil { t.Errorf("native schema fields must be cleared, got Schema=%v SchemaJSON=%s", got.Prompt.Schema, got.Prompt.SchemaJSON) } + if string(schema) != string(req.Prompt.SchemaJSON) { + t.Errorf("preserved schema = %s, want %s", schema, req.Prompt.SchemaJSON) + } if !strings.Contains(got.Prompt.User, "review the diff") { t.Errorf("original prompt lost: %q", got.Prompt.User) } @@ -35,13 +39,38 @@ func TestWithSchemaPrompt(t *testing.T) { // A text-mode request is returned unchanged. plain := ai.Request{Prompt: api.Prompt{User: "hi"}} - got, err = withSchemaPrompt(plain) + got, schema, err = withSchemaPrompt(plain) if err != nil { t.Fatalf("withSchemaPrompt(text): %v", err) } if got.Prompt.User != "hi" { t.Errorf("text prompt altered: %q", got.Prompt.User) } + if len(schema) != 0 { + t.Errorf("text prompt preserved unexpected schema: %s", schema) + } +} + +func TestValidatedStructuredData(t *testing.T) { + schema := json.RawMessage(`{"type":"object","required":["pass"],"properties":{"pass":{"type":"boolean"}}}`) + + got, err := validatedStructuredData(schema, "Result:\n```json\n{\"pass\":true}\n```", nil) + if err != nil { + t.Fatalf("validatedStructuredData(valid) error = %v", err) + } + if string(got) != `{"pass":true}` { + t.Fatalf("validatedStructuredData(valid) = %s", got) + } + + if _, err := validatedStructuredData(schema, `{"pass":"yes"}`, nil); !errors.Is(err, ai.ErrSchemaValidation) { + t.Fatalf("validatedStructuredData(invalid) error = %v, want ErrSchemaValidation", err) + } + + outcome := &ai.TerminalOutcome{Kind: ai.TerminalOutcomePlan, Plan: &ai.TerminalPlan{Content: "1. Inspect"}} + got, err = validatedStructuredData(schema, "not a schema envelope", outcome) + if err != nil || got != nil { + t.Fatalf("native terminal outcome must bypass schema extraction, got (%s, %v)", got, err) + } } func TestNewDerivesAgentAndBackend(t *testing.T) { diff --git a/pkg/ai/provider/cmux/sessionlog.go b/pkg/ai/provider/cmux/sessionlog.go index 729227b..d42567a 100644 --- a/pkg/ai/provider/cmux/sessionlog.go +++ b/pkg/ai/provider/cmux/sessionlog.go @@ -15,8 +15,14 @@ import ( ) const ( - defaultSessionLogPollInterval = 500 * time.Millisecond - defaultSessionLogAppearTimeout = 30 * time.Second + defaultSessionLogPollInterval = 500 * time.Millisecond + // defaultSessionLogAppearTimeout bounds how long we wait for claude to create + // its session log after launch. A cold first invocation (auth check, MCP + // server startup, model-registry fetch) routinely takes longer than 30s to + // write the first log line, so a short window mis-reports a slow-but-healthy + // start as a failed launch. 2 minutes covers cold starts while still failing + // loudly if the session never begins. + defaultSessionLogAppearTimeout = 2 * time.Minute ) // errSessionLogNotFound is returned by the tailer when Claude never created the @@ -69,6 +75,9 @@ type sessionTailer struct { // 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) + // terminal overrides the normal end-turn predicate for modes whose native + // terminal signal is a tool outcome rather than every assistant end_turn. + terminal func(history.SessionEvent) bool } func (st sessionTailer) poll() time.Duration { @@ -109,7 +118,7 @@ func (st sessionTailer) tail(ctx context.Context, onEvent func(history.SessionEv } if progressed { lastActivity = time.Now() - } else if sawActivity && st.quiescePeriod > 0 && time.Since(lastActivity) >= st.quiescePeriod { + } else if sawActivity && st.terminal == nil && st.quiescePeriod > 0 && time.Since(lastActivity) >= st.quiescePeriod { return true, nil } sawActivity = sawActivity || progressed @@ -149,7 +158,7 @@ func (st sessionTailer) drain(f *os.File, pending *[]byte, buf []byte, onEvent f // 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 || isUserQuestionEvent(ev) { + if st.isTerminal(ev) { return progressed, true, nil } } @@ -164,6 +173,20 @@ func (st sessionTailer) drain(f *os.File, pending *[]byte, buf []byte, onEvent f } } +func (st sessionTailer) isTerminal(ev history.SessionEvent) bool { + if st.terminal != nil { + return st.terminal(ev) + } + return ev.Kind == history.EventTurnEnd || ev.Kind == history.EventError || isUserQuestionEvent(ev) +} + +func planModeTerminalEvent(ev history.SessionEvent) bool { + if ev.Kind == history.EventError || isUserQuestionEvent(ev) { + return true + } + return ev.Kind == history.EventToolUse && ev.ToolUse.Tool == "ExitPlanMode" +} + func isUserQuestionEvent(ev history.SessionEvent) bool { return ev.Kind == history.EventToolUse && ev.ToolUse.Tool == "AskUserQuestion" } @@ -211,6 +234,9 @@ func (r *run) awaitSessionCompletion(ctx context.Context, sessionID, workDir str quiescePeriod: r.sessionLogQuiescePeriod(), seekToEnd: resume, } + if r.planMode { + tailer.terminal = planModeTerminalEvent + } if acc != nil { tailer.onLine = acc.AddLine } diff --git a/pkg/ai/provider/cmux/sessionlog_test.go b/pkg/ai/provider/cmux/sessionlog_test.go index 512aafb..5e9974d 100644 --- a/pkg/ai/provider/cmux/sessionlog_test.go +++ b/pkg/ai/provider/cmux/sessionlog_test.go @@ -115,6 +115,63 @@ func TestSessionTailerPausesOnAskUserQuestion(t *testing.T) { } } +func TestSessionTailerPlanModeIgnoresEndTurnUntilExitPlanMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, + `{"type":"assistant","sessionId":"s","message":{"stop_reason":"end_turn","content":[{"type":"text","text":"intermediate"}]}}`, + `{"type":"assistant","sessionId":"s","message":{"stop_reason":"tool_use","content":[{"type":"tool_use","id":"plan-1","name":"ExitPlanMode","input":{"plan":"1. Inspect\n2. Implement","planFilePath":"/repo/.claude/plans/example.md"}}]}}`, + ) + + tailer := sessionTailer{ + path: path, + pollInterval: time.Millisecond, + appearTimeout: 200 * time.Millisecond, + terminal: planModeTerminalEvent, + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var events []history.SessionEvent + completed, err := tailer.tail(ctx, func(ev history.SessionEvent) { events = append(events, ev) }) + + if err != nil || !completed { + t.Fatalf("tail() = (%v, %v), want terminal plan outcome", completed, err) + } + if len(events) != 3 { + t.Fatalf("events = %+v, want intermediate text + end turn + ExitPlanMode", events) + } + last := events[len(events)-1] + if last.Kind != history.EventToolUse || last.ToolUse.Tool != "ExitPlanMode" { + t.Fatalf("last event = %+v, want ExitPlanMode", last) + } +} + +func TestSessionTailerPlanModeUsesTimeoutInsteadOfQuiescence(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + writeSessionLog(t, path, + `{"type":"assistant","sessionId":"s","message":{"stop_reason":"end_turn","content":[{"type":"text","text":"waiting for background agents"}]}}`, + ) + + tailer := sessionTailer{ + path: path, + pollInterval: time.Millisecond, + appearTimeout: 200 * time.Millisecond, + quiescePeriod: 5 * time.Millisecond, + terminal: planModeTerminalEvent, + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + completed, err := tailer.tail(ctx, func(history.SessionEvent) {}) + + if completed { + t.Fatal("plan-mode tail completed on quiescence before ExitPlanMode") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("tail() error = %v, want context deadline", err) + } +} + 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. diff --git a/pkg/ai/provider/cmux/stall.go b/pkg/ai/provider/cmux/stall.go index 177afff..ab0a85f 100644 --- a/pkg/ai/provider/cmux/stall.go +++ b/pkg/ai/provider/cmux/stall.go @@ -225,7 +225,18 @@ func (w *stallWatchdog) markAwaitingHuman() { // interactive terminal user to answer (the stall clock is held by awaitingHuman). func (w *stallWatchdog) maybeRequestApproval(ctx context.Context, screen string) { req, ok := detectApprovalRequest(w.sessionID, screen) - if !ok || w.r.canUseTool == nil { + if !ok { + return + } + // A plan-only run must return ExitPlanMode to its caller. Accepting this + // dialog would start implementation inside the same agent turn. + if w.r.planMode && req.Tool == "ExitPlanMode" { + if err := w.r.dismissPlanSurface(ctx, w.ref, w.sessionID); err != nil { + log.Warnf("cmux: failed to dismiss plan-only approval: %v", err) + } + return + } + if w.r.canUseTool == nil { return } if !w.approving.CompareAndSwap(false, true) { diff --git a/pkg/ai/provider/cmux/stall_test.go b/pkg/ai/provider/cmux/stall_test.go index 1954404..59ff772 100644 --- a/pkg/ai/provider/cmux/stall_test.go +++ b/pkg/ai/provider/cmux/stall_test.go @@ -312,6 +312,54 @@ func TestStallWatchdogPlanApprovalBrokeredOnce(t *testing.T) { } } +func TestStallWatchdogPlanRunNeverApprovesExitPlanMode(t *testing.T) { + runner := &stallRunner{} + runner.setScreen(planApprovalScreen) + r := newTestRun(runConfig{}, runner.run) + r.planMode = true + var broker atomic.Int32 + r.canUseTool = func(_ context.Context, _ ai.PermissionRequest) (ai.PermissionDecision, error) { + broker.Add(1) + return ai.PermissionDecision{Allow: true}, nil + } + wd := newWatchdog(r, "plan-safe", filepath.Join(t.TempDir(), "s.jsonl"), nil) + + wd.maybeRequestApproval(context.Background(), planApprovalScreen) + wd.maybeRequestApproval(context.Background(), planApprovalScreen) + r.approvals.Wait() + + if got := broker.Load(); got != 0 { + t.Fatalf("plan approval broker called %d times, want 0", got) + } + if got := runner.enterCount(); got != 0 { + t.Fatalf("Enter sent %d times, want 0 for a plan-only run", got) + } + if got := runner.escapeCount(); got != 1 { + t.Fatalf("Escape sent %d times, want exactly 1 for a plan-only run", got) + } +} + +func TestCompletedPlanExitDismissesSurfaceWhenWatchdogMissesDialog(t *testing.T) { + runner := &stallRunner{} + r := newTestRun(runConfig{}, runner.run) + r.planMode = true + r.planExitSeen = true + + if err := r.dismissCompletedPlan(context.Background(), testSurface, "plan-complete"); err != nil { + t.Fatalf("dismissCompletedPlan() error = %v", err) + } + if err := r.dismissCompletedPlan(context.Background(), testSurface, "plan-complete"); err != nil { + t.Fatalf("second dismissCompletedPlan() error = %v", err) + } + + if got := runner.enterCount(); got != 0 { + t.Fatalf("Enter sent %d times, want 0 for a completed plan-only run", got) + } + if got := runner.escapeCount(); got != 1 { + t.Fatalf("Escape sent %d times, want exactly 1 for a completed plan-only run", got) + } +} + func TestStallWatchdogLogGrowthResetsTimer(t *testing.T) { logPath := filepath.Join(t.TempDir(), "s.jsonl") writeSessionLog(t, logPath, "seed") diff --git a/pkg/cmux/client.go b/pkg/cmux/client.go index 040ebc9..0d66833 100644 --- a/pkg/cmux/client.go +++ b/pkg/cmux/client.go @@ -134,6 +134,7 @@ func DefaultSocketPath() string { // matches the CMUX_SURFACE_ID env var of the process running inside it). type Surface struct { ID string + Ref string Title string Workspace string Tty string @@ -159,6 +160,7 @@ type cmuxTree struct { Panes []struct { Surfaces []struct { ID string `json:"id"` + Ref string `json:"ref"` Title string `json:"title"` Tty string `json:"tty"` Type string `json:"type"` @@ -183,6 +185,7 @@ func parseSurfaces(data []byte) (map[string]Surface, error) { } surfaces[s.ID] = Surface{ ID: s.ID, + Ref: s.Ref, Title: stripStatusGlyph(s.Title), Workspace: stripStatusGlyph(ws.Title), Tty: s.Tty, diff --git a/pkg/cmux/client_test.go b/pkg/cmux/client_test.go index 938a26b..8374cb8 100644 --- a/pkg/cmux/client_test.go +++ b/pkg/cmux/client_test.go @@ -9,7 +9,7 @@ func TestParseSurfaces(t *testing.T) { "title": "gavel-claude", "panes": [{ "surfaces": [ - {"id": "CD1AD43E-1", "title": "✳ Review gavel cmux prompt documentation", "tty": "ttys018", "type": "terminal"}, + {"id": "CD1AD43E-1", "ref": "surface:41", "title": "✳ Review gavel cmux prompt documentation", "tty": "ttys018", "type": "terminal"}, {"id": "2EDAFDF1-2", "title": "⠐ implement-captain-ps-command", "tty": "ttys017", "type": "terminal"} ] }] @@ -40,6 +40,9 @@ func TestParseSurfaces(t *testing.T) { if s.Workspace != "gavel-claude" { t.Fatalf("workspace = %q", s.Workspace) } + if s.Ref != "surface:41" { + t.Fatalf("ref = %q", s.Ref) + } if surfaces["2EDAFDF1-2"].Title != "implement-captain-ps-command" { t.Fatalf("braille glyph not stripped: %q", surfaces["2EDAFDF1-2"].Title) } From f9e889cde655114d91d775e160aa8ad5b8e63533 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 12:07:39 +0300 Subject: [PATCH 043/131] feat - Implement RunSessionGet to return multiple sessions matching UUID or provider-session-id prefix - Add SessionGetResult, SessionGetItem types with detail availability tracking - Support message paging via offset/limit/tail - Extract session list rendering logic into reusable session_list_render module - Add enrichLiveSessionSurfaces to discover cmux surface refs for live processes - Add sessionDatabaseStatus to report live session monitoring health - Extend SessionLiveWire with sampled_at, last_heartbeat_at, lease_owner, lease_expires_at fields - Support identity-based session lookup in dbSessionRecords via sessionOverviewStore interface - Add ListSessionOverviewsByIdentity, ListSessionIdentityMatches for efficient prefix resolution - Add SessionConflictError to report ambiguous prefix matches - Extract psTokenTotal logic into shared sessionTokenTotal function - Add database.MaskDSN utility to redact passwords from connection strings - Sanitize JSON null characters (U+0000) before PostgreSQL jsonb persistence - Fix EndOtherSessionProcesses and EndVanishedProcesses to handle legacy UTC-misinterpreted process start times - Add CountLiveRootSessions query for session dashboard metrics - Comprehensive test coverage for all new functionality IMPACT: Users can now retrieve multiple session records by provider ID prefix, enabling bulk operations and improved session discovery. Database now properly handles edge cases with recycled PIDs and JSON null characters.(cli): add session get command with multi-result support --- pkg/cli/session_get.go | 136 +++++++++++++ pkg/cli/session_get_multi_test.go | 120 ++++++++++++ pkg/cli/session_list_render.go | 110 +++++++++++ pkg/cli/session_list_render_test.go | 93 +++++++++ pkg/cli/session_live.go | 71 ++++++- pkg/cli/session_live_db_test.go | 107 ++++++++++ pkg/cli/session_live_render.go | 108 ++++++++++ pkg/cli/session_live_render_test.go | 184 ++++++++++++++++++ pkg/cli/session_ps.go | 13 +- pkg/cli/session_ps_render.go | 28 +-- pkg/cli/session_record_db.go | 75 +++++-- pkg/cli/session_record_db_test.go | 34 ++++ pkg/cli/sessions.go | 106 +++------- pkg/cli/sessions_test.go | 130 +++++++++++-- pkg/cli/stdin_test.go | 49 +++++ pkg/database/dsn.go | 27 +++ pkg/database/dsn_test.go | 16 ++ pkg/database/session_ingest_store.go | 90 ++++++++- .../session_ingest_store_integration_test.go | 119 ++++++++++- pkg/database/session_ingest_store_test.go | 35 ++++ pkg/database/session_process_store.go | 67 +++++-- pkg/database/session_read_store.go | 169 ++++++++++++++-- pkg/database/session_read_store_test.go | 33 ++++ 23 files changed, 1732 insertions(+), 188 deletions(-) create mode 100644 pkg/cli/session_get.go create mode 100644 pkg/cli/session_get_multi_test.go create mode 100644 pkg/cli/session_list_render.go create mode 100644 pkg/cli/session_list_render_test.go create mode 100644 pkg/cli/session_live_db_test.go create mode 100644 pkg/cli/session_live_render.go create mode 100644 pkg/cli/session_live_render_test.go create mode 100644 pkg/cli/session_record_db_test.go create mode 100644 pkg/database/dsn.go create mode 100644 pkg/database/dsn_test.go create mode 100644 pkg/database/session_ingest_store_test.go create mode 100644 pkg/database/session_read_store_test.go diff --git a/pkg/cli/session_get.go b/pkg/cli/session_get.go new file mode 100644 index 0000000..dd213c2 --- /dev/null +++ b/pkg/cli/session_get.go @@ -0,0 +1,136 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/session" + "github.com/flanksource/clicky" + clickyapi "github.com/flanksource/clicky/api" + rpchttp "github.com/flanksource/clicky/rpc/http" +) + +type SessionGetResult struct { + Sessions []SessionGetItem `json:"sessions"` + Total int `json:"total"` +} + +type SessionGetItem struct { + CaptainID string `json:"captainId"` + ProviderSessionID string `json:"providerSessionId,omitempty"` + Host string `json:"host,omitempty"` + DetailAvailable bool `json:"detailAvailable"` + Summary SessionRecord `json:"summary"` + Detail *session.Session `json:"detail,omitempty"` +} + +// RunSessionGet returns every Captain session matching an exact Captain UUID +// or provider-session-id prefix. Transcript-less matches remain visible via +// their overview metadata; recorded transcripts are parsed and paged. +func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResult, error) { + id := strings.TrimSpace(opts.ID) + if id == "" { + return SessionGetResult{}, fmt.Errorf("id is required") + } + db, err := captainDB(ctx) + if err != nil { + return SessionGetResult{}, err + } + overviews, err := resolveOverviewsByAnyID(ctx, db, id) + if err != nil { + return SessionGetResult{}, err + } + + defer rpchttp.Track(ctx, "parse")() + items := make([]SessionGetItem, 0, len(overviews)) + for i := range overviews { + overview := overviews[i] + path := stringOr(overview.HistoryFile, stringOr(overview.Path, "")) + item := SessionGetItem{ + CaptainID: overview.ID.String(), + ProviderSessionID: stringOr(overview.ProviderSessionID, ""), + Host: overview.HostID, + DetailAvailable: path != "", + Summary: recordFromOverview(overview), + } + if path != "" { + detail, buildErr := buildSessionModel(candidateFromOverview(overview)) + if buildErr != nil { + return SessionGetResult{}, fmt.Errorf("parse Captain session %s: %w", overview.ID, buildErr) + } + attachPromptRun(ctx, db, overview.ID, detail) + pageSessionMessages(detail, opts) + item.Detail = detail + } + items = append(items, item) + } + return SessionGetResult{Sessions: items, Total: len(items)}, nil +} + +func (r SessionGetResult) Pretty() clickyapi.Text { + list := clicky.List() + list.Unstyled = true + list.MaxInline = 1 + for i := range r.Sessions { + list.Items = append(list.Items, sessionGetListItem{text: r.Sessions[i].Pretty()}) + } + return clickyapi.Text{}.Add(list) +} + +func (i SessionGetItem) Pretty() clickyapi.Text { + text := clickyapi.Text{}. + Append("Captain ", "text-gray-500"). + Append(i.CaptainID, "font-bold text-blue-600") + if i.Summary.Source != "" { + text = text.Append(" ", "").Append(strings.ToUpper(i.Summary.Source), "text-gray-500") + } + for _, metadata := range []struct{ label, value string }{ + {label: "Provider session", value: i.ProviderSessionID}, + {label: "Host", value: i.Host}, + {label: "Project", value: i.Summary.Project}, + {label: "CWD", value: i.Summary.CWD}, + } { + if strings.TrimSpace(metadata.value) != "" { + text = text.NewLine(). + Append(" "+metadata.label+": ", "text-gray-500"). + Append(metadata.value, "text-muted") + } + } + if i.Detail != nil { + return text.NewLine().NewLine().Add(i.Detail.Pretty()) + } + return text.NewLine().Append(" Transcript: unavailable", "text-amber-600") +} + +type sessionGetListItem struct { + text clickyapi.Text +} + +func (i sessionGetListItem) String() string { return i.text.String() + "\n" } +func (i sessionGetListItem) ANSI() string { return i.text.ANSI() } +func (i sessionGetListItem) HTML() string { return i.text.HTML() } +func (i sessionGetListItem) Markdown() string { return i.text.Markdown() } + +// pageSessionMessages windows the message stream: the last Tail messages, or +// an Offset/Limit slice from the start. +func pageSessionMessages(s *session.Session, opts SessionGetOptions) { + if opts.Tail > 0 { + if len(s.Messages) > opts.Tail { + s.Messages = s.Messages[len(s.Messages)-opts.Tail:] + } + return + } + if opts.Offset <= 0 && opts.Limit <= 0 { + return + } + offset := max(opts.Offset, 0) + if offset >= len(s.Messages) { + s.Messages = nil + return + } + s.Messages = s.Messages[offset:] + if opts.Limit > 0 && len(s.Messages) > opts.Limit { + s.Messages = s.Messages[:opts.Limit] + } +} diff --git a/pkg/cli/session_get_multi_test.go b/pkg/cli/session_get_multi_test.go new file mode 100644 index 0000000..1c5e404 --- /dev/null +++ b/pkg/cli/session_get_multi_test.go @@ -0,0 +1,120 @@ +package cli + +import ( + "context" + "encoding/json" + "testing" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/session" + "github.com/flanksource/clicky" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSessionGetMulti(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Session Get Multi Suite") +} + +var _ = Describe("session get multi-result output", func() { + It("uses targeted identity lookup for UUID-prefix list searches", func() { + providerID := "ad4c854e-cde6-4b99-99f3-667bf74112e3" + store := &sessionGetOverviewStore{ + identity: []database.SessionOverview{ + {ProviderSessionID: &providerID, Source: "claude"}, + {ProviderSessionID: &providerID, Source: "gavel"}, + }, + } + + records, err := dbSessionRecords(context.Background(), store, sessionRecordQuery{ + Source: "all", Query: "ad4c854e", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(records).To(HaveLen(2)) + Expect(store.identities).To(Equal([]string{"ad4c854e"})) + Expect(store.listCalls).To(BeZero()) + }) + + It("renders every match sequentially and preserves metadata-only sessions", func() { + result := SessionGetResult{ + Sessions: []SessionGetItem{ + { + CaptainID: "055781c7-360a-4eb2-80be-452b3937fcfe", + ProviderSessionID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", + Host: "MacBook-Pro.local", + DetailAvailable: true, + Summary: SessionRecord{ + ID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", Source: "claude", Project: "flanksource", + }, + Detail: &session.Session{ID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", Source: "claude"}, + }, + { + CaptainID: "7ca78c55-e280-50ff-a19a-9f355a6fc55e", + ProviderSessionID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", + Host: "local", + Summary: SessionRecord{ + ID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", Source: "gavel", Project: "xero-cli", + }, + }, + }, + Total: 2, + } + + plain := result.Pretty().String() + Expect(plain).To(ContainSubstring("055781c7-360a-4eb2-80be-452b3937fcfe")) + Expect(plain).To(ContainSubstring("7ca78c55-e280-50ff-a19a-9f355a6fc55e")) + Expect(plain).To(ContainSubstring("Transcript: unavailable")) + Expect(plain).To(MatchRegexp("055781c7[\\s\\S]*7ca78c55")) + + markdown := result.Pretty().Markdown() + Expect(markdown).To(MatchRegexp("055781c7[\\s\\S]*7ca78c55")) + + html := result.Pretty().HTML() + Expect(html).NotTo(MatchRegexp(`text-gray-(600|700)`)) + Expect(html).To(ContainSubstring("text-muted")) + formattedHTML, err := clicky.Format(result, clicky.FormatOptions{Format: "html"}) + Expect(err).NotTo(HaveOccurred()) + Expect(formattedHTML).NotTo(MatchRegexp(`text-gray-(600|700)`)) + Expect(formattedHTML).To(ContainSubstring("text-muted")) + + wire, err := json.Marshal(result) + Expect(err).NotTo(HaveOccurred()) + Expect(wire).To(MatchJSON(`{ + "sessions": [ + { + "captainId": "055781c7-360a-4eb2-80be-452b3937fcfe", + "providerSessionId": "ad4c854e-cde6-4b99-99f3-667bf74112e3", + "host": "MacBook-Pro.local", + "detailAvailable": true, + "summary": {"key":"","id":"ad4c854e-cde6-4b99-99f3-667bf74112e3","source":"claude","project":"flanksource","toolCalls":0,"messages":0,"detailAvailable":false}, + "detail": {"id":"ad4c854e-cde6-4b99-99f3-667bf74112e3","source":"claude","git":{},"usage":{"inputTokens":0,"outputTokens":0},"cost":{"inputTokens":0,"outputTokens":0,"totalTokens":0,"inputCost":0,"outputCost":0},"capabilities":{},"files":{},"approvals":{"approved":0,"denied":0}} + }, + { + "captainId": "7ca78c55-e280-50ff-a19a-9f355a6fc55e", + "providerSessionId": "ad4c854e-cde6-4b99-99f3-667bf74112e3", + "host": "local", + "detailAvailable": false, + "summary": {"key":"","id":"ad4c854e-cde6-4b99-99f3-667bf74112e3","source":"gavel","project":"xero-cli","toolCalls":0,"messages":0,"detailAvailable":false} + } + ], + "total": 2 + }`)) + }) +}) + +type sessionGetOverviewStore struct { + identity []database.SessionOverview + identities []string + listCalls int +} + +func (s *sessionGetOverviewStore) ListSessionOverviewsByIdentity(_ context.Context, identity string) ([]database.SessionOverview, error) { + s.identities = append(s.identities, identity) + return s.identity, nil +} + +func (s *sessionGetOverviewStore) ListSessionOverviews(context.Context, database.SessionOverviewFilter) ([]database.SessionOverview, error) { + s.listCalls++ + return nil, nil +} diff --git a/pkg/cli/session_list_render.go b/pkg/cli/session_list_render.go new file mode 100644 index 0000000..142bc96 --- /dev/null +++ b/pkg/cli/session_list_render.go @@ -0,0 +1,110 @@ +package cli + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/flanksource/clicky/api" +) + +const ( + sessionIDDisplayWidth = 12 + sessionInitialPromptWidth = 100 +) + +var _ api.TableProvider = SessionRecord{} + +// Columns keeps the historical session list compact while leaving the full +// SessionRecord available to JSON and detail consumers. +func (SessionRecord) Columns() []api.ColumnDef { + return []api.ColumnDef{ + api.Column("age").Label("Age").Build(), + api.Column("source").Label("Agent").MaxWidth(12).Build(), + api.Column("project").Label("Project").MaxWidth(20).Build(), + api.Column("session").Label("Session").MaxWidth(sessionIDDisplayWidth).Build(), + api.Column("model").Label("Model").MaxWidth(24).Build(), + api.Column("initial_prompt").Label("Initial Prompt").Style("max-lines-[1] truncate-suffix").MaxWidth(sessionInitialPromptWidth).Build(), + api.Column("tokens").Label("Tokens").Build(), + } +} + +func (r SessionRecord) Row() map[string]any { + row := map[string]any{ + "source": r.Source, + "session": sessionListID(r.ID), + "model": r.Model, + "initial_prompt": sessionInitialPromptCell(r.InitialPrompt), + } + if project := sessionProjectName(r); project != "" { + row["project"] = project + } + if age := sessionAge(r); age != nil { + row["age"] = age + } + if usage := sessionUsageCell(r); usage != nil { + row["tokens"] = usage + } + return row +} + +func sessionListID(id string) string { + if len(id) > sessionIDDisplayWidth { + return id[:sessionIDDisplayWidth] + } + return id +} + +func sessionInitialPromptCell(prompt string) api.Textable { + style := fmt.Sprintf("max-lines-[1] max-w-[%dch] truncate-suffix", sessionInitialPromptWidth) + return api.Text{}.Append(prompt, style) +} + +func sessionProjectName(r SessionRecord) string { + project := strings.TrimSpace(r.Project) + if project == "" { + project = strings.TrimSpace(r.CWD) + } + if project == "" { + return "" + } + return filepath.Base(filepath.Clean(project)) +} + +func sessionAge(r SessionRecord) api.Textable { + observedAt := sessionSortTime(r) + if observedAt.IsZero() { + return nil + } + return api.TimeAgo(&observedAt) +} + +// sessionUsageCell renders the one compact usage value shared by session list +// and ps tables: "27.4M $13.61". +func sessionUsageCell(r SessionRecord) api.Textable { + total := sessionTokenTotal(r) + if total == 0 && r.CostUSD <= 0 { + return nil + } + t := api.Text{} + if total > 0 { + t = t.Add(api.HumanNumber(total, "text-muted")) + } + if r.CostUSD > 0 { + if total > 0 { + t = t.Space() + } + t = t.Append(fmt.Sprintf("$%.2f", r.CostUSD), "text-green-600") + } + return t +} + +func sessionTokenTotal(r SessionRecord) int64 { + if r.Tokens == nil { + return 0 + } + if r.Tokens.TotalTokens > 0 { + return int64(r.Tokens.TotalTokens) + } + return int64(r.Tokens.InputTokens + r.Tokens.OutputTokens + r.Tokens.CacheReadTokens + r.Tokens.CacheCreationTokens) +} diff --git a/pkg/cli/session_list_render_test.go b/pkg/cli/session_list_render_test.go new file mode 100644 index 0000000..5aa5104 --- /dev/null +++ b/pkg/cli/session_list_render_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "strings" + "testing" + "time" + + "github.com/flanksource/clicky/api" +) + +func TestSessionRecordTableProviderIsCompact(t *testing.T) { + endedAt := time.Now().Add(-2 * time.Hour) + longTail := strings.Repeat("x", 120) + secondLine := "second line must not render" + record := SessionRecord{ + ID: "6522fe00-9a7c-4cee-a205-123456789abc", + Source: "codex", + Project: "/work/flanksource/captain", + Model: "gpt-5.6-sol", + InitialPrompt: "Improve the sessions table " + longTail + "\n" + secondLine, + EndedAt: &endedAt, + Tokens: &SessionTokensWire{TotalTokens: 12_345}, + CostUSD: 1.25, + } + + columns := record.Columns() + wantColumns := []string{"age", "source", "project", "session", "model", "initial_prompt", "tokens"} + if len(columns) != len(wantColumns) { + t.Fatalf("columns = %+v", columns) + } + for i, want := range wantColumns { + if columns[i].Name != want { + t.Fatalf("column %d = %q, want %q", i, columns[i].Name, want) + } + } + if columns[5].MaxWidth != sessionInitialPromptWidth { + t.Fatalf("initial prompt max width = %d, want %d", columns[5].MaxWidth, sessionInitialPromptWidth) + } + if !strings.Contains(columns[5].Style, "max-lines-[1]") { + t.Fatalf("initial prompt style = %q", columns[5].Style) + } + + row := record.Row() + if columns[3].MaxWidth != sessionIDDisplayWidth { + t.Fatalf("session max width = %d, want %d", columns[3].MaxWidth, sessionIDDisplayWidth) + } + if row["session"] != "6522fe00-9a7" { + t.Fatalf("session = %q", row["session"]) + } + if row["project"] != "captain" { + t.Fatalf("project = %q", row["project"]) + } + usage, ok := row["tokens"].(api.Textable) + if !ok || !strings.Contains(usage.String(), "12.3K") || !strings.Contains(usage.String(), "$1.25") { + t.Fatalf("tokens = %#v", row["tokens"]) + } + if _, ok := row["age"].(api.Textable); !ok { + t.Fatalf("age = %#v", row["age"]) + } + + table := api.NewTableFrom([]SessionRecord{record}) + prompt := table.Rows[0]["initial_prompt"].String() + if strings.Contains(prompt, "\n") || strings.Contains(prompt, secondLine) { + t.Fatalf("initial prompt is not one line: %q", prompt) + } + if got := len([]rune(prompt)); got != sessionInitialPromptWidth { + t.Fatalf("initial prompt width = %d, want %d: %q", got, sessionInitialPromptWidth, prompt) + } + if !strings.HasPrefix(prompt, "Improve the sessions table") || !strings.HasSuffix(prompt, "…") { + t.Fatalf("initial prompt = %q", prompt) + } + for name, rendered := range map[string]string{"ansi": table.ANSI(), "markdown": table.Markdown()} { + if !strings.Contains(rendered, "6522fe00-9a7") || !strings.Contains(rendered, "$1.25") { + t.Fatalf("%s output missing compact identity/usage: %q", name, rendered) + } + if strings.Contains(rendered, longTail) || strings.Contains(rendered, secondLine) { + t.Fatalf("%s output did not truncate the initial prompt: %q", name, rendered) + } + } +} + +func TestSessionRecordTableProviderHandlesSparseRows(t *testing.T) { + row := (SessionRecord{ID: "short", CWD: "/work/project"}).Row() + if row["session"] != "short" || row["project"] != "project" { + t.Fatalf("row = %+v", row) + } + if _, ok := row["age"]; ok { + t.Fatalf("zero age should be omitted: %+v", row) + } + if _, ok := row["tokens"]; ok { + t.Fatalf("zero usage should be omitted: %+v", row) + } +} diff --git a/pkg/cli/session_live.go b/pkg/cli/session_live.go index 4c438ce..2db3a92 100644 --- a/pkg/cli/session_live.go +++ b/pkg/cli/session_live.go @@ -8,10 +8,20 @@ import ( "time" "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/database" ) const defaultSessionLiveLimit = 25 +type SessionDatabaseStatusWire struct { + Source string `json:"source,omitempty"` + DSN string `json:"dsn,omitempty"` + ReadAt time.Time `json:"readAt"` + LatestSampledAt *time.Time `json:"latestSampledAt,omitempty"` + LatestHeartbeatAt *time.Time `json:"latestHeartbeatAt,omitempty"` + EarliestLeaseExpiry *time.Time `json:"earliestLeaseExpiry,omitempty"` +} + func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveResult, error) { source, err := normalizeSessionSource(opts.Source) if err != nil { @@ -27,18 +37,20 @@ func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveRe if limit <= 0 && !opts.Full { limit = defaultSessionLiveLimit } - db, err := freshenSessionDB(ctx) + db, err := captainDB(ctx) if err != nil { return SessionLiveResult{}, err } records, err := dbSessionRecords(ctx, db, sessionRecordQuery{ - Source: source, ProjectRoot: projectRoot, Query: opts.Query, + Source: source, ProjectRoot: projectRoot, Query: opts.Query, LiveOnly: true, }) if err != nil { return SessionLiveResult{}, err } + enrichLiveSessionSurfaces(records) total := len(records) summary := summarizeSessionDashboard(records) + databaseStatus := sessionDatabaseStatus(records, time.Now().UTC()) if !opts.Full && limit > 0 && len(records) > limit { records = records[:limit] } @@ -50,9 +62,64 @@ func RunSessionLive(ctx context.Context, opts SessionLiveOptions) (SessionLiveRe Scope: scope, Project: projectResultValue(scope, projectRoot), Summary: summary, + Database: databaseStatus, }, nil } +func enrichLiveSessionSurfaces(records []SessionRecord) { + detected := false + for i := range records { + if records[i].Live == nil || records[i].Live.PID <= 0 { + continue + } + if records[i].Live.Surface != nil { + detected = true + } + if surface := discoverProcessSurface(records[i].Live.PID); surface != nil { + records[i].Live.Surface = surface + detected = true + } + } + if !detected { + return + } + surfaces, err := discoverCmuxSurfaces() + if err != nil { + return + } + for i := range records { + if records[i].Live == nil { + continue + } + if surface := records[i].Live.Surface; surface != nil && surface.SurfaceID != "" { + enrichCmuxSurface(surface, surfaces) + } + } +} + +func sessionDatabaseStatus(records []SessionRecord, readAt time.Time) SessionDatabaseStatusWire { + dsn, source := captainDatabaseIdentity() + status := SessionDatabaseStatusWire{Source: source, DSN: database.MaskDSN(dsn), ReadAt: readAt} + for _, record := range records { + if record.Live == nil { + continue + } + if sampledAt := record.Live.SampledAt; sampledAt != nil && + (status.LatestSampledAt == nil || sampledAt.After(*status.LatestSampledAt)) { + status.LatestSampledAt = sampledAt + } + if heartbeatAt := record.Live.LastHeartbeatAt; heartbeatAt != nil && + (status.LatestHeartbeatAt == nil || heartbeatAt.After(*status.LatestHeartbeatAt)) { + status.LatestHeartbeatAt = heartbeatAt + } + if expiresAt := record.Live.LeaseExpiresAt; expiresAt != nil && + (status.EarliestLeaseExpiry == nil || expiresAt.Before(*status.EarliestLeaseExpiry)) { + status.EarliestLeaseExpiry = expiresAt + } + } + return status +} + func sessionProjectRoot(cwd string) string { if cwd == "" { return "" diff --git a/pkg/cli/session_live_db_test.go b/pkg/cli/session_live_db_test.go new file mode 100644 index 0000000..3f138fc --- /dev/null +++ b/pkg/cli/session_live_db_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/monitor" +) + +func TestRunSessionLiveReadsStoredProcessStatusWithoutPolling(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + db := withTestCaptainDB(t) + project := filepath.Join(home, "work", "project") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + + stored, err := db.CreateOrGetSession(t.Context(), database.CreateSessionInput{ + ProviderSessionID: "sess-stored", Source: "codex", CWD: project, + }) + if err != nil { + t.Fatalf("create stored session: %v", err) + } + started := time.Now().UTC().Add(-time.Minute).Truncate(time.Second) + sampledAt := started.Add(30 * time.Second) + expiresAt := sampledAt.Add(time.Minute) + if err := db.UpsertSessionProcess(t.Context(), database.SessionProcessInput{ + SessionID: stored.ID, HostID: captainHostID(), BootID: "boot", PID: 24680, + ProcessStartedAt: started, SampledAt: sampledAt, + Status: "sleeping", CWD: project, Source: "codex", + }); err != nil { + t.Fatalf("upsert stored process: %v", err) + } + if err := db.Gorm().WithContext(t.Context()).Exec(` + UPDATE captain_session_processes + SET lease_owner = ?, lease_token = ?, lease_expires_at = ? + WHERE session_id = ?`, "captain-serve", "6522fe00-9a7c-4cee-a205-123456789abc", expiresAt, stored.ID).Error; err != nil { + t.Fatalf("set stored process lease: %v", err) + } + + discoveryCalls := 0 + monitorDiscoverProcesses = func() ([]monitor.Process, error) { + discoveryCalls++ + return nil, nil + } + + result, err := RunSessionLive(context.Background(), SessionLiveOptions{Source: "all", Limit: 10}) + if err != nil { + t.Fatalf("RunSessionLive: %v", err) + } + if discoveryCalls != 0 { + t.Fatalf("process discovery calls = %d, want 0", discoveryCalls) + } + if result.Total != 1 || len(result.Sessions) != 1 { + t.Fatalf("live sessions = %+v", result) + } + if result.Database.ReadAt.IsZero() { + t.Fatal("database read time is missing") + } + if result.Database.LatestSampledAt == nil || !result.Database.LatestSampledAt.Equal(sampledAt) { + t.Fatalf("database latest sample = %v, want %v", result.Database.LatestSampledAt, sampledAt) + } + if result.Database.LatestHeartbeatAt == nil || !result.Database.LatestHeartbeatAt.Equal(sampledAt) { + t.Fatalf("database latest heartbeat = %v, want %v", result.Database.LatestHeartbeatAt, sampledAt) + } + if result.Database.EarliestLeaseExpiry == nil || !result.Database.EarliestLeaseExpiry.Equal(expiresAt) { + t.Fatalf("database lease expiry = %v, want %v", result.Database.EarliestLeaseExpiry, expiresAt) + } + live := result.Sessions[0].Live + if live == nil || live.PID != 24680 || live.Status != "sleeping" { + t.Fatalf("stored process status = %+v", live) + } + if live.SampledAt == nil || !live.SampledAt.Equal(sampledAt) { + t.Fatalf("stored sample time = %v, want %v", live.SampledAt, sampledAt) + } + if live.LastHeartbeatAt == nil || !live.LastHeartbeatAt.Equal(sampledAt) { + t.Fatalf("stored heartbeat = %v, want %v", live.LastHeartbeatAt, sampledAt) + } + if live.LeaseOwner != "captain-serve" { + t.Fatalf("stored lease owner = %q", live.LeaseOwner) + } + if live.LeaseExpiresAt == nil || !live.LeaseExpiresAt.Equal(expiresAt) { + t.Fatalf("stored lease expiry = %v, want %v", live.LeaseExpiresAt, expiresAt) + } +} + +func refreshTestSessionDB(t *testing.T) { + t.Helper() + if _, err := freshenSessionDB(t.Context()); err != nil { + t.Fatalf("refresh test session database: %v", err) + } +} + +func hasHealth(signals []SessionHealthWire, kind, severity string) bool { + for _, signal := range signals { + if signal.Kind == kind && signal.Severity == severity { + return true + } + } + return false +} diff --git a/pkg/cli/session_live_render.go b/pkg/cli/session_live_render.go new file mode 100644 index 0000000..9179417 --- /dev/null +++ b/pkg/cli/session_live_render.go @@ -0,0 +1,108 @@ +package cli + +import ( + "strconv" + "strings" + "time" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" +) + +type sessionLiveRow struct { + SessionRecord +} + +type sessionLiveConfiguration struct { + mode string + model string + effort string +} + +func (c sessionLiveConfiguration) Pretty() api.Text { + return api.Text{}. + Append(sessionLiveConfigValue(c.mode), "text-cyan-600"). + Append(" · ", "text-gray-400"). + Append(sessionLiveConfigValue(c.model), "text-purple-600 font-medium"). + Append(" · ", "text-gray-400"). + Append(sessionLiveConfigValue(c.effort), "text-amber-600") +} + +func (c sessionLiveConfiguration) String() string { return c.Pretty().String() } +func (c sessionLiveConfiguration) ANSI() string { return c.Pretty().ANSI() } +func (c sessionLiveConfiguration) HTML() string { return c.Pretty().HTML() } +func (c sessionLiveConfiguration) Markdown() string { return c.String() } + +func (r SessionLiveResult) Pretty() api.Text { + diagnostics := map[string]any{ + "Database": r.Database.Source, + "DSN": r.Database.DSN, + "Read": api.Human(r.Database.ReadAt, "text-muted"), + "Latest sample": sessionLiveTime(r.Database.LatestSampledAt), + "Latest heartbeat": sessionLiveTime(r.Database.LatestHeartbeatAt), + "Lease expiry": sessionLiveTime(r.Database.EarliestLeaseExpiry), + "Live sessions": r.Total, + } + rows := make([]sessionLiveRow, len(r.Sessions)) + for i := range r.Sessions { + rows[i] = sessionLiveRow{SessionRecord: r.Sessions[i]} + } + return api.Text{}. + Add(clicky.Map(diagnostics)). + NewLine(). + Add(api.NewTableFrom(rows)) +} + +func (sessionLiveRow) Columns() []api.ColumnDef { + return []api.ColumnDef{ + api.Column("configuration").Label("Agent").MaxWidth(32).Build(), + api.Column("project").Label("Project").MaxWidth(16).Build(), + api.Column("session").Label("Session").MaxWidth(sessionIDDisplayWidth).Build(), + api.Column("pid").Label("PID").MaxWidth(20).Build(), + api.Column("status").Label("Status").MaxWidth(8).Build(), + api.Column("title").Label("Title").Build(), + } +} + +func (r sessionLiveRow) Row() map[string]any { + title := strings.TrimSpace(r.Title) + if prompt := strings.TrimSpace(r.InitialPrompt); prompt != "" && (r.Source == "codex" || title == "") { + title, _, _ = strings.Cut(prompt, "\n") + title = strings.TrimSpace(title) + } + row := map[string]any{ + "configuration": sessionLiveConfiguration{mode: r.Backend, model: r.Model, effort: r.ReasoningEffort}, + "project": sessionProjectName(r.SessionRecord), + "session": sessionListID(r.ID), + "title": title, + } + if r.Live == nil { + return row + } + identity := make([]string, 0, 2) + if r.Live.PID > 0 { + identity = append(identity, strconv.Itoa(r.Live.PID)) + } + if r.Live.Surface != nil && r.Live.Surface.SurfaceRef != "" { + identity = append(identity, r.Live.Surface.SurfaceRef) + } + if len(identity) > 0 { + row["pid"] = strings.Join(identity, " ") + } + row["status"] = r.Live.Status + return row +} + +func sessionLiveConfigValue(value string) string { + if value = strings.TrimSpace(value); value != "" { + return value + } + return "—" +} + +func sessionLiveTime(value *time.Time) api.Textable { + if value == nil { + return api.Text{}.Append("—", "text-muted") + } + return api.Human(*value, "text-muted") +} diff --git a/pkg/cli/session_live_render_test.go b/pkg/cli/session_live_render_test.go new file mode 100644 index 0000000..2567ae6 --- /dev/null +++ b/pkg/cli/session_live_render_test.go @@ -0,0 +1,184 @@ +package cli + +import ( + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/cmux" + "github.com/flanksource/captain/pkg/database" + clickyapi "github.com/flanksource/clicky/api" +) + +func TestSessionLiveResultPrettyReportsDatabaseDiagnostics(t *testing.T) { + sampledAt := time.Date(2026, time.July, 13, 15, 0, 0, 0, time.UTC) + expiresAt := sampledAt.Add(time.Minute) + result := SessionLiveResult{ + Sessions: []SessionRecord{{ + ID: "6522fe00-9a7c-4cee-a205-123456789abc", Source: "codex", Project: "/work/captain", + Backend: "codex-cmux", Model: "gpt-5.6-sol", ReasoningEffort: "high", + InitialPrompt: "Diagnose the monitor lock without polling", + Live: &SessionLiveWire{ + PID: 24680, Status: "sleeping", SampledAt: &sampledAt, LastHeartbeatAt: &sampledAt, + LeaseOwner: "captain-serve", LeaseExpiresAt: &expiresAt, + }, + }}, + Total: 1, + Database: SessionDatabaseStatusWire{ + Source: "captain embedded database", DSN: "postgres://localhost/captain", ReadAt: sampledAt, + LatestSampledAt: &sampledAt, LatestHeartbeatAt: &sampledAt, EarliestLeaseExpiry: &expiresAt, + }, + } + + rendered := result.Pretty().String() + for _, expected := range []string{ + "captain embedded database", "Latest sample", "Latest heartbeat", "Lease expiry", + "6522fe00", "24680", "sleeping", + } { + if !strings.Contains(rendered, expected) { + t.Fatalf("pretty output missing %q: %s", expected, rendered) + } + } +} + +func TestSessionLiveResultPrettyKeepsUnavailableDatabaseDiagnosticsVisible(t *testing.T) { + rendered := (SessionLiveResult{Database: SessionDatabaseStatusWire{ReadAt: time.Now()}}).Pretty().String() + for _, expected := range []string{"Latest sample", "Latest heartbeat", "Lease expiry", "—"} { + if !strings.Contains(rendered, expected) { + t.Fatalf("pretty output missing %q: %s", expected, rendered) + } + } +} + +func TestSessionLiveRowColumnsIncludeModelConfigurationAndTitle(t *testing.T) { + row := sessionLiveRow{SessionRecord: SessionRecord{ + Backend: "codex-cli", Model: "gpt-5.5", ReasoningEffort: "medium", + Title: "Stored title", InitialPrompt: "Fallback prompt", + }} + wantColumns := []string{ + "configuration", "project", "session", "pid", "status", "title", + } + columns := row.Columns() + if len(columns) != len(wantColumns) { + t.Fatalf("columns = %+v", columns) + } + if columns[0].Label != "Agent" { + t.Fatalf("configuration label = %q, want Agent", columns[0].Label) + } + for i, want := range wantColumns { + if columns[i].Name != want { + t.Fatalf("column %d = %q, want %q", i, columns[i].Name, want) + } + } + wantWidths := []int{32, 16, sessionIDDisplayWidth, 20, 8, 0} + for i, want := range wantWidths { + if columns[i].MaxWidth != want { + t.Fatalf("column %q width = %d, want %d", columns[i].Name, columns[i].MaxWidth, want) + } + } + if columns[5].MaxWidth != 0 || columns[5].Style != "" { + t.Fatalf("title column = %+v", columns[5]) + } + values := row.Row() + configuration := values["configuration"].(interface { + String() string + HTML() string + Markdown() string + }) + if configuration.String() != "codex-cli · gpt-5.5 · medium" { + t.Fatalf("configuration = %q", configuration.String()) + } + if configuration.Markdown() != configuration.String() { + t.Fatalf("configuration markdown = %q, want plain text", configuration.Markdown()) + } + for _, style := range []string{"text-cyan-600", "text-purple-600", "text-amber-600"} { + if !strings.Contains(configuration.HTML(), style) { + t.Fatalf("configuration HTML missing %q: %s", style, configuration.HTML()) + } + } + ansiTable := clickyapi.NewTableFrom([]sessionLiveRow{row}).ANSI() + if !strings.Contains(ansiTable, values["configuration"].(interface{ ANSI() string }).ANSI()) { + t.Fatalf("table ANSI dropped configuration colors: %q", ansiTable) + } + if title := values["title"]; title != "Stored title" { + t.Fatalf("title = %q, want stored title", title) + } + withID := sessionLiveRow{SessionRecord: SessionRecord{ID: "6522fe00-9a7c-4cee-a205-123456789abc"}}.Row() + if withID["session"] != "6522fe00-9a7" { + t.Fatalf("session = %q, want wider copyable prefix", withID["session"]) + } +} + +func TestSessionLiveRowPIDIncludesCmuxSurfaceRef(t *testing.T) { + row := sessionLiveRow{SessionRecord: SessionRecord{Live: &SessionLiveWire{ + PID: 24680, + Surface: &CmuxSurface{ + SurfaceRef: "surface:383", + }, + }}}.Row() + if got := row["pid"]; got != "24680 surface:383" { + t.Fatalf("pid = %q, want process and cmux surface refs", got) + } +} + +func TestSessionLiveRowCodexTitleUsesFullFirstPromptLine(t *testing.T) { + firstLine := "Review dependency installation versions and identify duplication using jscpd" + row := sessionLiveRow{SessionRecord: SessionRecord{ + Source: "codex", + Title: "Review dependency installation versions and identify duplication…", + InitialPrompt: firstLine + "\n\nDetails that do not belong in the title", + }}.Row() + if got := row["title"]; got != firstLine { + t.Fatalf("title = %q, want full first prompt line", got) + } +} + +func TestEnrichLiveSessionSurfacesResolvesShortRef(t *testing.T) { + const ( + pid = 24680 + surfaceID = "4F846CB1-2EE5-4359-8D5B-A0F6F3837952" + ) + defer stubPSDiscovery(t, nil, nil, func(gotPID int) *CmuxSurface { + if gotPID != pid { + t.Fatalf("surface lookup pid = %d, want %d", gotPID, pid) + } + return &CmuxSurface{SurfaceID: surfaceID} + }, map[string]cmux.Surface{ + surfaceID: {ID: surfaceID, Ref: "surface:383"}, + })() + + records := []SessionRecord{{Live: &SessionLiveWire{PID: pid}}} + enrichLiveSessionSurfaces(records) + if got := records[0].Live.Surface; got == nil || got.SurfaceRef != "surface:383" { + t.Fatalf("surface = %+v, want short cmux ref", got) + } +} + +func TestSessionLiveRowTitleFallsBackToInitialPrompt(t *testing.T) { + prompt := "Fallback prompt " + strings.Repeat("x", 80) + liveRow := sessionLiveRow{SessionRecord: SessionRecord{InitialPrompt: prompt}} + row := liveRow.Row() + title := row["title"] + if title != prompt { + t.Fatalf("title = %q, want full fallback prompt", title) + } + if rendered := clickyapi.NewTableFrom([]sessionLiveRow{liveRow}).String(); !strings.Contains(rendered, prompt) { + t.Fatalf("rendered table truncated title: %s", rendered) + } +} + +func TestSessionLiveRowKeepsEmptyModelConfigurationColumnsVisible(t *testing.T) { + row := (sessionLiveRow{}).Row() + configuration := row["configuration"].(interface{ String() string }).String() + if configuration != "— · — · —" { + t.Fatalf("configuration = %q, want missing-value markers", configuration) + } +} + +func TestRecordFromOverviewIncludesDatabaseBackend(t *testing.T) { + backend := "claude-cmux" + record := recordFromOverview(database.SessionOverview{Backend: &backend}) + if record.Backend != backend { + t.Fatalf("backend = %q, want %q", record.Backend, backend) + } +} diff --git a/pkg/cli/session_ps.go b/pkg/cli/session_ps.go index 40dcd6b..b05b5e8 100644 --- a/pkg/cli/session_ps.go +++ b/pkg/cli/session_ps.go @@ -156,10 +156,15 @@ func enrichSurfacesFromCmux(processes []agentProcess) { if s == nil || s.SurfaceID == "" { continue } - if info, ok := surfaces[s.SurfaceID]; ok { - s.Title = info.Title - s.Workspace = info.Workspace - } + enrichCmuxSurface(s, surfaces) + } +} + +func enrichCmuxSurface(surface *CmuxSurface, surfaces map[string]cmux.Surface) { + if info, ok := surfaces[surface.SurfaceID]; ok { + surface.SurfaceRef = info.Ref + surface.Title = info.Title + surface.Workspace = info.Workspace } } diff --git a/pkg/cli/session_ps_render.go b/pkg/cli/session_ps_render.go index 54646bf..a5ce3c7 100644 --- a/pkg/cli/session_ps_render.go +++ b/pkg/cli/session_ps_render.go @@ -37,7 +37,7 @@ func (r PSRow) Row() map[string]any { "status": psStatusIcon(r), "agent": psAgentCell(r), "title": psTitle(r), - "session": shortSessionID(psSessionID(r)), + "session": sessionListID(psSessionID(r)), "pid": psPID(r), } if r.CWD != "" { @@ -71,21 +71,7 @@ func psAgentCell(r PSRow) api.Text { // psUsageCell merges token total and cost into one cell: "27.4M $13.61". // Returns nil when the session has neither (a fresh/synthetic process). func psUsageCell(r PSRow) api.Textable { - total := psTokenTotal(r) - if total == 0 && r.CostUSD <= 0 { - return nil - } - t := api.Text{} - if total > 0 { - t = t.Add(api.HumanNumber(total, "text-muted")) - } - if r.CostUSD > 0 { - if total > 0 { - t = t.Space() - } - t = t.Append(fmt.Sprintf("$%.2f", r.CostUSD), "text-green-600") - } - return t + return sessionUsageCell(r.SessionRecord) } // RowDetail expands the verbose fields that don't belong in the scannable table: @@ -233,13 +219,3 @@ func psAgentIDs(r PSRow) []string { func psAgentCount(r PSRow) int { return len(psAgentIDs(r)) } - -func psTokenTotal(r PSRow) int64 { - if r.Tokens == nil { - return 0 - } - if r.Tokens.TotalTokens > 0 { - return int64(r.Tokens.TotalTokens) - } - return int64(r.Tokens.InputTokens + r.Tokens.OutputTokens + r.Tokens.CacheReadTokens + r.Tokens.CacheCreationTokens) -} diff --git a/pkg/cli/session_record_db.go b/pkg/cli/session_record_db.go index aced384..46e2e58 100644 --- a/pkg/cli/session_record_db.go +++ b/pkg/cli/session_record_db.go @@ -3,11 +3,18 @@ package cli import ( "context" "encoding/json" + "errors" + "strings" "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" ) +type sessionOverviewStore interface { + ListSessionOverviewsByIdentity(context.Context, string) ([]database.SessionOverview, error) + ListSessionOverviews(context.Context, database.SessionOverviewFilter) ([]database.SessionOverview, error) +} + // sessionRecordQuery narrows the DB-backed session record list. type sessionRecordQuery struct { Source string // "all", "claude", or "codex" @@ -19,17 +26,31 @@ type sessionRecordQuery struct { // dbSessionRecords is the single source for session list/live/throughput // records: it reads the captain_session_overview view (populated by the // monitor) and projects rows onto the SessionRecord wire shape. -func dbSessionRecords(ctx context.Context, db *database.DB, q sessionRecordQuery) ([]SessionRecord, error) { +func dbSessionRecords(ctx context.Context, db sessionOverviewStore, q sessionRecordQuery) ([]SessionRecord, error) { filter := database.SessionOverviewFilter{RootsOnly: true, LiveOnly: q.LiveOnly} if q.Source != "" && q.Source != "all" { filter.Source = q.Source } - overviews, err := db.ListSessionOverviews(ctx, filter) + var overviews []database.SessionOverview + var err error + if isSessionIdentityQuery(q.Query) { + overviews, err = db.ListSessionOverviewsByIdentity(ctx, q.Query) + if errors.Is(err, database.ErrSessionNotFound) { + return []SessionRecord{}, nil + } + } else { + overviews, err = db.ListSessionOverviews(ctx, filter) + } if err != nil { return nil, err } records := make([]SessionRecord, 0, len(overviews)) for _, overview := range overviews { + if overview.ParentSessionID != nil || + (filter.LiveOnly && !overview.ProcessActive) || + (filter.Source != "" && overview.Source != filter.Source) { + continue + } record := recordFromOverview(overview) if q.ProjectRoot != "" && !sessionRecordMatchesProject(record, q.ProjectRoot) { continue @@ -43,13 +64,28 @@ func dbSessionRecords(ctx context.Context, db *database.DB, q sessionRecordQuery return records, nil } -// resolveOverviewByAnyID resolves a session by UUID or provider-session-id -// prefix, falling back to the path-derived record Key the UI navigates with -// (source-, not stored in the database). -func resolveOverviewByAnyID(ctx context.Context, db *database.DB, id string) (*database.SessionOverview, error) { - overview, err := db.GetSessionOverviewByIdentity(ctx, id) +func isSessionIdentityQuery(query string) bool { + query = strings.TrimSpace(query) + if len(query) < 8 || len(query) > 36 { + return false + } + for _, char := range query { + if char != '-' && (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') { + return false + } + } + return true +} + +// resolveOverviewsByAnyID resolves sessions by UUID or provider-session-id +// prefix, falling back to the path-derived record Key the UI navigates with. +func resolveOverviewsByAnyID(ctx context.Context, db sessionOverviewStore, id string) ([]database.SessionOverview, error) { + overviews, err := db.ListSessionOverviewsByIdentity(ctx, id) if err == nil { - return overview, nil + return overviews, nil + } + if !errors.Is(err, database.ErrSessionNotFound) { + return nil, err } overviews, listErr := db.ListSessionOverviews(ctx, database.SessionOverviewFilter{RootsOnly: true}) if listErr != nil { @@ -58,7 +94,7 @@ func resolveOverviewByAnyID(ctx context.Context, db *database.DB, id string) (*d for i := range overviews { path := stringOr(overviews[i].HistoryFile, stringOr(overviews[i].Path, "")) if path != "" && sessionRecordKey(overviews[i].Source, path) == id { - return &overviews[i], nil + return []database.SessionOverview{overviews[i]}, nil } } return nil, err @@ -136,6 +172,7 @@ func recordFromOverview(overview database.SessionOverview) SessionRecord { Version: stringOr(overview.CLIVersion, ""), GitBranch: overviewGitBranch(overview), Provider: metadata.Provider, + Backend: stringOr(overview.Backend, ""), CWD: stringOr(overview.CWD, ""), ToolCalls: int(overview.ToolCallCount), Messages: int(overview.MessageCount), @@ -178,14 +215,18 @@ func liveWireFromOverview(overview database.SessionOverview, id, path string) *S status := stringOr(overview.ProcessStatus, "") _, active := processLiveStatus(status) live := &SessionLiveWire{ - Status: status, - Active: active, - CWD: stringOr(overview.ProcessCWD, ""), - Command: stringOr(overview.ProcessCommand, ""), - SessionID: id, - SessionFile: path, - StartedAt: overview.ProcessStartedAt, - LastActivity: overview.LastActivityAt, + Status: status, + Active: active, + CWD: stringOr(overview.ProcessCWD, ""), + Command: stringOr(overview.ProcessCommand, ""), + SessionID: id, + SessionFile: path, + StartedAt: overview.ProcessStartedAt, + SampledAt: overview.ProcessSampledAt, + LastHeartbeatAt: overview.LastHeartbeatAt, + LeaseOwner: stringOr(overview.LeaseOwner, ""), + LeaseExpiresAt: overview.LeaseExpiresAt, + LastActivity: overview.LastActivityAt, } if overview.PID != nil { live.PID = int(*overview.PID) diff --git a/pkg/cli/session_record_db_test.go b/pkg/cli/session_record_db_test.go new file mode 100644 index 0000000..78ec03c --- /dev/null +++ b/pkg/cli/session_record_db_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "context" + "errors" + "testing" + + "github.com/flanksource/captain/pkg/database" +) + +type sessionOverviewStoreStub struct { + getErr error + listCalled bool +} + +func (s *sessionOverviewStoreStub) ListSessionOverviewsByIdentity(context.Context, string) ([]database.SessionOverview, error) { + return nil, s.getErr +} + +func (s *sessionOverviewStoreStub) ListSessionOverviews(context.Context, database.SessionOverviewFilter) ([]database.SessionOverview, error) { + s.listCalled = true + return nil, nil +} + +func TestResolveOverviewsByAnyIDDoesNotScanOnResolutionFailure(t *testing.T) { + store := &sessionOverviewStoreStub{getErr: &database.SessionConflictError{Identity: "ad4c854e"}} + _, err := resolveOverviewsByAnyID(t.Context(), store, "ad4c854e") + if !errors.Is(err, database.ErrSessionConflict) { + t.Fatalf("error = %v, want session conflict", err) + } + if store.listCalled { + t.Fatal("conflict triggered full overview fallback scan") + } +} diff --git a/pkg/cli/sessions.go b/pkg/cli/sessions.go index dc842d3..0aebae9 100644 --- a/pkg/cli/sessions.go +++ b/pkg/cli/sessions.go @@ -15,7 +15,6 @@ import ( "github.com/flanksource/captain/pkg/claude" "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" - rpchttp "github.com/flanksource/clicky/rpc/http" "github.com/google/uuid" ) @@ -54,12 +53,13 @@ type SessionLiveOptions struct { } type SessionLiveResult struct { - Sessions []SessionRecord `json:"sessions"` - Total int `json:"total"` - Source string `json:"source"` - Scope string `json:"scope"` - Project string `json:"project,omitempty"` - Summary SessionDashboardWire `json:"summary"` + Sessions []SessionRecord `json:"sessions"` + Total int `json:"total"` + Source string `json:"source"` + Scope string `json:"scope"` + Project string `json:"project,omitempty"` + Summary SessionDashboardWire `json:"summary"` + Database SessionDatabaseStatusWire `json:"database"` } type SessionRecord struct { @@ -77,6 +77,7 @@ type SessionRecord struct { Version string `json:"version,omitempty"` GitBranch string `json:"gitBranch,omitempty"` Provider string `json:"provider,omitempty"` + Backend string `json:"backend,omitempty"` CWD string `json:"cwd,omitempty"` ToolCalls int `json:"toolCalls"` Messages int `json:"messages"` @@ -103,19 +104,23 @@ type SessionContextWire struct { } type SessionLiveWire struct { - PID int `json:"pid,omitempty"` - Status string `json:"status,omitempty"` - Active bool `json:"active"` - CPUPercent float64 `json:"cpuPercent,omitempty"` - MemoryPercent float64 `json:"memoryPercent,omitempty"` - StartedAt *time.Time `json:"startedAt,omitempty"` - CWD string `json:"cwd,omitempty"` - Command string `json:"command,omitempty"` - SessionID string `json:"sessionId,omitempty"` - AgentIDs []string `json:"agentIds,omitempty"` - LastActivity *time.Time `json:"lastActivity,omitempty"` - SessionFile string `json:"sessionFile,omitempty"` - Surface *CmuxSurface `json:"surface,omitempty"` + PID int `json:"pid,omitempty"` + Status string `json:"status,omitempty"` + Active bool `json:"active"` + CPUPercent float64 `json:"cpuPercent,omitempty"` + MemoryPercent float64 `json:"memoryPercent,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + SampledAt *time.Time `json:"sampledAt,omitempty"` + LastHeartbeatAt *time.Time `json:"lastHeartbeatAt,omitempty"` + LeaseOwner string `json:"leaseOwner,omitempty"` + LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` + CWD string `json:"cwd,omitempty"` + Command string `json:"command,omitempty"` + SessionID string `json:"sessionId,omitempty"` + AgentIDs []string `json:"agentIds,omitempty"` + LastActivity *time.Time `json:"lastActivity,omitempty"` + SessionFile string `json:"sessionFile,omitempty"` + Surface *CmuxSurface `json:"surface,omitempty"` } // CmuxSurface identifies the cmux multiplexer surface hosting an agent process. @@ -123,6 +128,7 @@ type SessionLiveWire struct { // variables; Title/Workspace are the authoritative names joined from cmux itself. type CmuxSurface struct { SurfaceID string `json:"surfaceId,omitempty"` + SurfaceRef string `json:"surfaceRef,omitempty"` WorkspaceID string `json:"workspaceId,omitempty"` TabID string `json:"tabId,omitempty"` PanelID string `json:"panelId,omitempty"` @@ -278,66 +284,6 @@ func RunSessionList(ctx context.Context, opts SessionListOptions) (SessionListRe }, nil } -// RunSessionGet returns the unified session model for a session id. Identity -// resolves against the database (full UUID or unambiguous provider-session-id -// prefix); the message stream is parsed read-only from the transcript at the -// DB-recorded path and paged via offset/limit/tail. Each message carries its -// transcript line (sourceLine) so consumers can seek into the raw file. -func RunSessionGet(ctx context.Context, opts SessionGetOptions) (*session.Session, error) { - id := strings.TrimSpace(opts.ID) - if id == "" { - return nil, fmt.Errorf("id is required") - } - db, err := freshenSessionDB(ctx) - if err != nil { - return nil, err - } - overview, err := resolveOverviewByAnyID(ctx, db, id) - if err != nil { - return nil, err - } - path := stringOr(overview.HistoryFile, stringOr(overview.Path, "")) - if path == "" { - return nil, fmt.Errorf("session %q has no transcript recorded on this host", id) - } - - defer rpchttp.Track(ctx, "parse")() - candidate := sessionCandidate{ - record: minimalSessionRecord(overview.Source, path, stringOr(overview.ProviderSessionID, overview.ID.String())), - path: path, - } - s, err := buildSessionModel(candidate) - if err != nil { - return nil, err - } - attachPromptRun(ctx, db, overview.ID, s) - pageSessionMessages(s, opts) - return s, nil -} - -// pageSessionMessages windows the message stream: the last Tail messages, or -// an Offset/Limit slice from the start. -func pageSessionMessages(s *session.Session, opts SessionGetOptions) { - if opts.Tail > 0 { - if len(s.Messages) > opts.Tail { - s.Messages = s.Messages[len(s.Messages)-opts.Tail:] - } - return - } - if opts.Offset <= 0 && opts.Limit <= 0 { - return - } - offset := max(opts.Offset, 0) - if offset >= len(s.Messages) { - s.Messages = nil - return - } - s.Messages = s.Messages[offset:] - if opts.Limit > 0 && len(s.Messages) > opts.Limit { - s.Messages = s.Messages[:opts.Limit] - } -} - func buildSessionModel(candidate sessionCandidate) (*session.Session, error) { switch candidate.record.Source { case "claude": diff --git a/pkg/cli/sessions_test.go b/pkg/cli/sessions_test.go index 0bce462..8804a21 100644 --- a/pkg/cli/sessions_test.go +++ b/pkg/cli/sessions_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/monitor" ) @@ -94,10 +95,14 @@ func TestRunSessionListAndGetClaude(t *testing.T) { if err != nil { t.Fatalf("RunSessionGet: %v", err) } - if detail.Source != "claude" || detail.ID != "sess-claude" { - t.Fatalf("session detail meta = %+v", detail) + if detail.Total != 1 || len(detail.Sessions) != 1 || detail.Sessions[0].Detail == nil { + t.Fatalf("session detail result = %+v", detail) } - entries := detail.ToReplayEntries() + parsed := detail.Sessions[0].Detail + if parsed.Source != "claude" || parsed.ID != "sess-claude" { + t.Fatalf("session detail meta = %+v", parsed) + } + entries := parsed.ToReplayEntries() if len(entries) != 2 { t.Fatalf("entries = %+v", entries) } @@ -173,6 +178,54 @@ func TestRunSessionGetUnknown(t *testing.T) { } } +func TestRunSessionGetReturnsAllProviderPrefixMatches(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + db := withTestCaptainDB(t) + project := filepath.Join(home, "work", "project") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + providerID := "ad4c854e-cde6-4b99-99f3-667bf74112e3" + sessionFile := filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), providerID+".jsonl") + writeJSONL(t, sessionFile, + map[string]any{ + "type": "assistant", "sessionId": providerID, "uuid": "a1", + "timestamp": "2026-07-14T10:00:00Z", "cwd": project, + "message": map[string]any{ + "role": "assistant", "model": "claude-opus-4", + "content": []any{map[string]any{"type": "text", "text": "canonical transcript"}}, + }, + }, + ) + for _, input := range []database.CreateSessionInput{ + {ProviderSessionID: providerID, Source: "claude", HostID: "test-host", Path: sessionFile, Project: "flanksource", CWD: project}, + {ProviderSessionID: providerID, Source: "gavel", Provider: "cmux", HostID: "local", Project: "xero-cli"}, + {ProviderSessionID: providerID, Source: "claude", Provider: "cmux", HostID: "local", Project: "xero-cli"}, + } { + if _, err := db.CreateOrGetSession(t.Context(), input); err != nil { + t.Fatalf("create duplicate session: %v", err) + } + } + + result, err := RunSessionGet(t.Context(), SessionGetOptions{ID: "ad4c854e"}) + if err != nil { + t.Fatalf("RunSessionGet: %v", err) + } + if result.Total != 3 || len(result.Sessions) != 3 { + t.Fatalf("result = %+v", result) + } + if !result.Sessions[0].DetailAvailable || result.Sessions[0].Detail == nil { + t.Fatalf("first match should contain parsed transcript: %+v", result.Sessions[0]) + } + for _, item := range result.Sessions[1:] { + if item.DetailAvailable || item.Detail != nil { + t.Fatalf("metadata-only match = %+v", item) + } + } +} + func TestRunSessionLiveEnrichesSummaryWithProcessHealth(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -226,6 +279,7 @@ func TestRunSessionLiveEnrichesSummaryWithProcessHealth(t *testing.T) { Command: "claude", }}, nil } + refreshTestSessionDB(t) result, err := RunSessionLive(context.Background(), SessionLiveOptions{Source: "claude", Limit: 10}) if err != nil { @@ -252,6 +306,59 @@ func TestRunSessionLiveEnrichesSummaryWithProcessHealth(t *testing.T) { } } +func TestRunSessionLiveExcludesEndedSessions(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + db := withTestCaptainDB(t) + project := filepath.Join(home, "work", "project") + endedCWD := filepath.Join(project, "ended") + if err := os.MkdirAll(endedCWD, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(project) + + live, err := db.CreateOrGetSession(t.Context(), database.CreateSessionInput{ + ProviderSessionID: "sess-live", Source: "claude", CWD: project, + }) + if err != nil { + t.Fatalf("create live session: %v", err) + } + ended, err := db.CreateOrGetSession(t.Context(), database.CreateSessionInput{ + ProviderSessionID: "sess-ended", Source: "claude", CWD: endedCWD, + }) + if err != nil { + t.Fatalf("create ended session: %v", err) + } + + started := time.Now().UTC().Add(-time.Minute).Truncate(time.Second) + hostID := captainHostID() + for _, process := range []database.SessionProcessInput{ + {SessionID: live.ID, HostID: hostID, BootID: "boot", PID: 12345, ProcessStartedAt: started, Status: "active", CWD: project, Source: "claude"}, + {SessionID: ended.ID, HostID: hostID, BootID: "boot", PID: 54321, ProcessStartedAt: started, Status: "active", CWD: endedCWD, Source: "claude"}, + } { + if err := db.UpsertSessionProcess(t.Context(), process); err != nil { + t.Fatalf("upsert process: %v", err) + } + } + if _, err := db.EndVanishedProcesses(t.Context(), hostID, []int64{12345}); err != nil { + t.Fatalf("end historical process: %v", err) + } + monitorDiscoverProcesses = func() ([]monitor.Process, error) { + return []monitor.Process{{ + Source: "claude", PID: 12345, Status: "active", StartedAt: &started, + CWD: project, Command: "claude", + }}, nil + } + + result, err := RunSessionLive(context.Background(), SessionLiveOptions{Source: "all", All: true, Limit: 10}) + if err != nil { + t.Fatalf("RunSessionLive: %v", err) + } + if result.Total != 1 || len(result.Sessions) != 1 || result.Sessions[0].ID != "sess-live" { + t.Fatalf("live sessions = %+v", result) + } +} + func TestRunSessionLiveScopesUnmatchedProcessesToCurrentProject(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -273,6 +380,7 @@ func TestRunSessionLiveScopesUnmatchedProcessesToCurrentProject(t *testing.T) { {Source: "claude", PID: 200, Status: "sleeping", CWD: otherProject, Command: "claude"}, }, nil } + refreshTestSessionDB(t) result, err := RunSessionLive(context.Background(), SessionLiveOptions{Source: "all", Limit: 10}) if err != nil { @@ -336,6 +444,13 @@ func TestRunSessionLiveRestrictsExplicitProject(t *testing.T) { }, }, ) + monitorDiscoverProcesses = func() ([]monitor.Process, error) { + return []monitor.Process{ + {Source: "claude", PID: 301, Status: "sleeping", CWD: project, Command: "claude --resume sess-current"}, + {Source: "claude", PID: 302, Status: "sleeping", CWD: otherProject, Command: "claude --resume sess-other"}, + }, nil + } + refreshTestSessionDB(t) result, err := RunSessionLive(context.Background(), SessionLiveOptions{ Source: "claude", @@ -367,15 +482,6 @@ func markProjectRoot(t *testing.T, dir string) { } } -func hasHealth(signals []SessionHealthWire, kind, severity string) bool { - for _, signal := range signals { - if signal.Kind == kind && signal.Severity == severity { - return true - } - } - return false -} - func writeCodexSession(t *testing.T, path, id, cwd string) { t.Helper() writeJSONL(t, path, diff --git a/pkg/cli/stdin_test.go b/pkg/cli/stdin_test.go index 6532309..3cdf94f 100644 --- a/pkg/cli/stdin_test.go +++ b/pkg/cli/stdin_test.go @@ -42,6 +42,55 @@ func TestParseFromReader_CodexJSONL(t *testing.T) { assert.Equal(t, "codex", result.ToolUses[0].Source) } +func TestParseFromReader_CodexWaitWithContentOutput(t *testing.T) { + data := []byte(`{"timestamp":"2026-07-13T09:31:13.359Z","type":"session_meta","payload":{"id":"019f5a68-c638-7fe2-b0d0-c1d8a3c4de55","cwd":"/repo"}} +{"timestamp":"2026-07-13T09:31:13.362Z","type":"turn_context","payload":{"turn_id":"019f5ad0-fc23-7b92-8b13-a9f50d903704","model":"gpt-5.6-sol","effort":"max"}} +{"timestamp":"2026-07-13T09:41:33.611Z","type":"response_item","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"214\",\"yield_time_ms\":20000,\"max_tokens\":5000}","call_id":"call-wait"}} +{"timestamp":"2026-07-13T09:41:41.017Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-wait","output":[{"type":"input_text","text":"Script completed\nWall time 7.4 seconds\nOutput:\n"},{"type":"input_text","text":"evaluation failed\n"},{"type":"input_text","text":"exit=1"}]}} +`) + + result, err := parseFromReader(data) + require.NoError(t, err) + assert.Equal(t, claude.FormatCodexJSONL, result.Format) + require.Len(t, result.ToolUses, 1) + use := result.ToolUses[0] + assert.Equal(t, "Wait", use.Tool) + assert.Equal(t, "214", use.Input["cell_id"]) + assert.Equal(t, float64(20000), use.Input["yield_time_ms"]) + assert.Equal(t, float64(5000), use.Input["max_tokens"]) + assert.Equal(t, "evaluation failed\nexit=1", use.Response) + assert.Equal(t, "call-wait", use.ToolUseID) + + historyResult, err := runHistoryFromReader(data, HistoryOptions{}) + require.NoError(t, err) + rows := historyResult.(session.HistoryResult) + require.Len(t, rows.Results, 1) + assert.Equal(t, "Wait", rows.Results[0].Tool) +} + +func TestParseFromReader_CodexUserShellCommand(t *testing.T) { + data := []byte(`{"timestamp":"2026-07-13T06:13:59Z","type":"session_meta","payload":{"id":"sess-shell","cwd":"/repo"}} +{"timestamp":"2026-07-13T06:14:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n\ngavel proc restart\n\n\nExit code: 1\nDuration: 2.9909 seconds\nOutput:\nsignal: killed\nKill sent but port 8088 is still bound\n\n"}]}} +`) + + result, err := parseFromReader(data) + require.NoError(t, err) + assert.Equal(t, claude.FormatCodexJSONL, result.Format) + require.Len(t, result.ToolUses, 1) + use := result.ToolUses[0] + assert.Equal(t, "UserShellCommand", use.Tool) + assert.Equal(t, "gavel proc restart", use.Input["command"]) + assert.Equal(t, 1, use.Input["exit_code"]) + assert.Contains(t, use.Input["output"], "Kill sent but port 8088 is still bound") + assert.Contains(t, use.Response, "signal: killed") + + historyResult, err := runHistoryFromReader(data, HistoryOptions{}) + require.NoError(t, err) + rows := historyResult.(session.HistoryResult) + require.Len(t, rows.Results, 1) + assert.Equal(t, "UserShellCommand", rows.Results[0].Tool) +} + func TestParseFromReader_ClaudeStreamJSON(t *testing.T) { data := []byte(`{"type":"system","subtype":"init","cwd":"/tmp","session_id":"sess-1","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/database/dsn.go b/pkg/database/dsn.go new file mode 100644 index 0000000..c79a2c8 --- /dev/null +++ b/pkg/database/dsn.go @@ -0,0 +1,27 @@ +package database + +import "strings" + +// MaskDSN hides the password in URL and key-value PostgreSQL connection +// strings so the selected database can be named safely in startup logs. +func MaskDSN(dsn string) string { + if strings.Contains(dsn, "://") { + if at := strings.LastIndex(dsn, "@"); at > 0 { + if colon := strings.Index(dsn[:at], "://"); colon >= 0 { + schemeEnd := colon + 3 + credentials := dsn[schemeEnd:at] + if user, _, ok := strings.Cut(credentials, ":"); ok { + return dsn[:schemeEnd] + user + ":REDACTED" + dsn[at:] + } + } + } + return dsn + } + parts := strings.Fields(dsn) + for i, part := range parts { + if strings.HasPrefix(part, "password=") { + parts[i] = "password=REDACTED" + } + } + return strings.Join(parts, " ") +} diff --git a/pkg/database/dsn_test.go b/pkg/database/dsn_test.go new file mode 100644 index 0000000..0743fbd --- /dev/null +++ b/pkg/database/dsn_test.go @@ -0,0 +1,16 @@ +package database + +import "testing" + +func TestMaskDSN(t *testing.T) { + tests := map[string]string{ + "postgres://captain:secret@db.internal:5432/captain?sslmode=require": "postgres://captain:REDACTED@db.internal:5432/captain?sslmode=require", + "postgres://db.internal/captain": "postgres://db.internal/captain", + "host=db.internal user=captain password=secret dbname=captain": "host=db.internal user=captain password=REDACTED dbname=captain", + } + for input, want := range tests { + if got := MaskDSN(input); got != want { + t.Errorf("MaskDSN(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/pkg/database/session_ingest_store.go b/pkg/database/session_ingest_store.go index bec3178..40bd121 100644 --- a/pkg/database/session_ingest_store.go +++ b/pkg/database/session_ingest_store.go @@ -1,6 +1,7 @@ package database import ( + "bytes" "context" "encoding/json" "errors" @@ -422,17 +423,27 @@ func (db *DB) upsertTurnCall(ctx context.Context, turnID uuid.UUID, call IngestM } // insertMessages appends message rows. Messages are immutable, so replays and -// overlapping batches are dropped by the (session_id, sequence) key. +// overlapping batches are dropped by either durable identity key: +// (session_id, sequence) or (session_id, provider_message_id). func (db *DB) insertMessages(ctx context.Context, sessionID uuid.UUID, turnIDs map[int]uuid.UUID, messages []IngestMessage) error { for _, message := range messages { + parts := message.PartsJSON + if len(parts) == 0 { + parts = []byte("[]") + } + parts, err := sanitizePostgresJSON(parts) + if err != nil { + return fmt.Errorf("insert Captain message seq %d parts: %w", message.Sequence, err) + } + raw, err := sanitizePostgresJSON(message.RawJSON) + if err != nil { + return fmt.Errorf("insert Captain message seq %d raw: %w", message.Sequence, err) + } record := messageRecord{ ID: uuid.New(), SessionID: sessionID, ProviderMessageID: nullableTrimmed(message.ProviderMessageID), Sequence: message.Sequence, Role: strings.TrimSpace(message.Role), - Parts: message.PartsJSON, Raw: message.RawJSON, OccurredAt: message.OccurredAt, - } - if len(record.Parts) == 0 { - record.Parts = []byte("[]") + Parts: parts, Raw: raw, OccurredAt: message.OccurredAt, } if message.SourceLine > 0 { line := message.SourceLine @@ -443,10 +454,10 @@ func (db *DB) insertMessages(ctx context.Context, sessionID uuid.UUID, turnIDs m record.TurnID = &turnID } } - err := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "session_id"}, {Name: "sequence"}}, - DoNothing: true, - }).Create(&record).Error + // Do not name one conflict target: a provider replay can retain the same + // message ID while its parser sequence shifts. PostgreSQL must suppress + // either unique-key conflict, while check/FK failures still surface. + err = db.gorm.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&record).Error if err != nil { return fmt.Errorf("insert Captain message seq %d: %w", message.Sequence, err) } @@ -459,5 +470,66 @@ func jsonbValue(value map[string]any) any { if err != nil { return "{}" } + encoded, err = sanitizePostgresJSON(encoded) + if err != nil { + return "{}" + } return string(encoded) } + +// sanitizePostgresJSON replaces JSON string escapes for U+0000 with U+FFFD. +// PostgreSQL jsonb rejects U+0000 because its text representation cannot store +// a zero byte, even though \u0000 is valid JSON. This scanner only rewrites an +// active JSON escape: a literal string such as "\\u0000" remains unchanged. +func sanitizePostgresJSON(data []byte) ([]byte, error) { + if len(data) == 0 { + return data, nil + } + if !json.Valid(data) { + return nil, fmt.Errorf("invalid JSON") + } + if !bytes.Contains(data, []byte(`\u0000`)) { + return data, nil + } + + out := make([]byte, 0, len(data)) + inString := false + escaped := false + changed := false + for i := 0; i < len(data); i++ { + b := data[i] + if !inString { + out = append(out, b) + if b == '"' { + inString = true + } + continue + } + if escaped { + out = append(out, b) + escaped = false + continue + } + if b == '"' { + out = append(out, b) + inString = false + continue + } + if b != '\\' { + out = append(out, b) + continue + } + if i+5 < len(data) && data[i+1] == 'u' && bytes.Equal(data[i+2:i+6], []byte("0000")) { + out = append(out, []byte(`\ufffd`)...) + i += 5 + changed = true + continue + } + out = append(out, b) + escaped = true + } + if !changed { + return data, nil + } + return out, nil +} diff --git a/pkg/database/session_ingest_store_integration_test.go b/pkg/database/session_ingest_store_integration_test.go index e0e0688..8d816e9 100644 --- a/pkg/database/session_ingest_store_integration_test.go +++ b/pkg/database/session_ingest_store_integration_test.go @@ -75,7 +75,7 @@ func testIngestBatch(modTime time.Time, byteOffset int64) IngestTranscriptInput Index: 1, ProviderTurnID: "turn-1", Status: TurnStatusEnded, StartedAt: &turn0End, EndedAt: &modTime, Call: &IngestModelCall{ - Model: "claude-sonnet-5", Backend: "claude", InputTokens: 2000, OutputTokens: 400, + Model: "claude-sonnet-5", Backend: "claude", Effort: "high", InputTokens: 2000, OutputTokens: 400, CacheReadTokens: 8000, ContextTokens: 60000, ContextWindowTokens: 200000, InputCost: 0.006, OutputCost: 0.006, }, @@ -85,7 +85,7 @@ func testIngestBatch(modTime time.Time, byteOffset int64) IngestTranscriptInput {Sequence: 1, ProviderMessageID: "uuid-1", Role: "user", TurnIndex: &turnIdx0, PartsJSON: []byte(`[{"type":"text","text":"hello"}]`), SourceLine: 1, OccurredAt: &started}, {Sequence: 3, ProviderMessageID: "uuid-3", Role: "assistant", TurnIndex: &turnIdx0, - PartsJSON: []byte(`[{"type":"text","text":"hi"},{"type":"dynamic-tool","toolName":"Read","toolCallId":"t1"}]`), + PartsJSON: []byte(`[{"type":"text","text":"hi"},{"type":"dynamic-tool","toolName":"Read","toolCallId":"t1"}]`), SourceLine: 3, OccurredAt: &turn0End}, {Sequence: 5, ProviderMessageID: "uuid-5", Role: "user", TurnIndex: &turnIdx1, PartsJSON: []byte(`[{"type":"text","text":"next"}]`), SourceLine: 5}, @@ -118,6 +118,8 @@ func TestIngestTranscriptAndReadStores(t *testing.T) { assert.Equal(t, 70, *overview.ContextFreePercent, "latest call: 1-60000/200000 = 70%") require.NotNil(t, overview.Model) assert.Equal(t, "claude-sonnet-5", *overview.Model) + require.NotNil(t, overview.Effort) + assert.Equal(t, "high", *overview.Effort) require.NotNil(t, overview.HistoryFile) assert.Equal(t, testTranscriptPath, *overview.HistoryFile) assert.False(t, overview.ProcessActive) @@ -138,6 +140,20 @@ func TestIngestTranscriptAndReadStores(t *testing.T) { assert.EqualValues(t, 3000, overview.InputTokens, "replay must not duplicate model calls") }) + t.Run("provider message replay with shifted sequence is idempotent", func(t *testing.T) { + shifted := testIngestBatch(modTime, 4096) + shifted.Messages = []IngestMessage{{ + Sequence: 11, ProviderMessageID: "uuid-1", Role: "user", + PartsJSON: []byte(`[{"type":"text","text":"hello"}]`), SourceLine: 11, OccurredAt: &modTime, + }} + _, err := db.IngestTranscript(t.Context(), shifted) + require.NoError(t, err) + + overview, err := db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + require.NoError(t, err) + assert.EqualValues(t, 4, overview.MessageCount, "provider message replay must not duplicate rows") + }) + t.Run("session sources bookkeeping is listed by path", func(t *testing.T) { sources, err := db.ListSessionSources(t.Context()) require.NoError(t, err) @@ -246,6 +262,9 @@ func TestIngestTranscriptAndReadStores(t *testing.T) { live, err := db.ListSessionOverviews(t.Context(), SessionOverviewFilter{LiveOnly: true}) require.NoError(t, err) require.Len(t, live, 1) + liveCount, err := db.CountLiveRootSessions(t.Context()) + require.NoError(t, err) + assert.EqualValues(t, 1, liveCount) closed, err := db.EndVanishedProcesses(t.Context(), "test-host", []int64{9999}) require.NoError(t, err) @@ -253,6 +272,51 @@ func TestIngestTranscriptAndReadStores(t *testing.T) { overview, err = db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) require.NoError(t, err) assert.False(t, overview.ProcessActive) + liveCount, err = db.CountLiveRootSessions(t.Context()) + require.NoError(t, err) + assert.Zero(t, liveCount) + }) + + t.Run("future-dated legacy process can be closed", func(t *testing.T) { + futureStart := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second) + correctStart := futureStart.Add(-3 * time.Hour) + legacySession, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: "future-process-legacy", Source: "codex", CWD: "/home/dev/legacy", + }) + require.NoError(t, err) + input := SessionProcessInput{ + SessionID: legacySession.ID, HostID: "test-host", BootID: "boot-1", PID: 4343, + ProcessStartedAt: futureStart, Status: "running", Source: "codex", SampledAt: time.Now().UTC(), + } + require.NoError(t, db.UpsertSessionProcess(t.Context(), input)) + + // A corrected observation can be rebound to another session. Its upsert + // must still close the impossible identity for the same OS PID. + input.SessionID = session.ID + require.NoError(t, db.EndOtherSessionProcesses(t.Context(), session.ID, input.PID, correctStart)) + input.ProcessStartedAt = correctStart + require.NoError(t, db.UpsertSessionProcess(t.Context(), input)) + + var records []sessionProcessRecord + require.NoError(t, db.gorm.WithContext(t.Context()).Order("process_started_at").Find(&records, "pid = ?", input.PID).Error) + require.Len(t, records, 2) + require.NotNil(t, records[1].EndedAt, "future-dated identity should be superseded even across sessions") + assert.False(t, records[1].EndedAt.Before(records[1].ProcessStartedAt)) + assert.Nil(t, records[0].EndedAt, "corrected identity should be active") + + closed, err := db.EndVanishedProcesses(t.Context(), "test-host", nil) + require.NoError(t, err) + assert.EqualValues(t, 1, closed) + require.NoError(t, db.UpsertSessionProcess(t.Context(), input)) + + var corrected sessionProcessRecord + require.NoError(t, db.gorm.WithContext(t.Context()).First(&corrected, + "pid = ? AND process_started_at = ?", input.PID, correctStart).Error) + assert.Nil(t, corrected.EndedAt, "a newly observed exact identity should reactivate") + + closed, err = db.EndVanishedProcesses(t.Context(), "test-host", nil) + require.NoError(t, err) + assert.EqualValues(t, 1, closed) }) t.Run("project aggregates group sessions and live processes", func(t *testing.T) { @@ -264,4 +328,55 @@ func TestIngestTranscriptAndReadStores(t *testing.T) { assert.EqualValues(t, 0, projects[0].LiveCount) assert.Equal(t, "claude", projects[0].Sources) }) + + t.Run("JSON null characters are sanitized before jsonb persistence", func(t *testing.T) { + input := testIngestBatch(modTime, 1024) + input.Session.ProviderSessionID = "0195c1de-4ab8-7000-8000-0123456789ac" + input.Session.Path = "/home/dev/.codex/sessions/nul-output.jsonl" + input.Session.Project = "nul-json" + input.Session.Metadata = map[string]any{"output": "before\x00after"} + input.Source.Path = input.Session.Path + input.Source.SourceIdentity = input.Session.ProviderSessionID + input.Turns = nil + input.Messages = []IngestMessage{{ + Sequence: 1, ProviderMessageID: "nul-message", Role: "assistant", + PartsJSON: []byte(`[{"type":"text","text":"before\u0000after","literal":"before\\u0000after"}]`), + RawJSON: []byte(`{"text":"raw\u0000value"}`), + }} + + persisted, err := db.IngestTranscript(t.Context(), input) + require.NoError(t, err) + + var record messageRecord + require.NoError(t, db.gorm.WithContext(t.Context()).First(&record, "session_id = ?", persisted.ID).Error) + assert.JSONEq(t, + `[{"type":"text","text":"before\ufffdafter","literal":"before\\u0000after"}]`, + string(record.Parts), + ) + assert.JSONEq(t, `{"text":"raw\ufffdvalue"}`, string(record.Raw)) + }) + + t.Run("ambiguous provider prefixes list every Captain session", func(t *testing.T) { + duplicate, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: testProviderSessionID, + Source: "gavel", + Provider: "cmux-claude", + HostID: "local", + }) + require.NoError(t, err) + overviews, err := db.ListSessionOverviewsByIdentity(t.Context(), testProviderSessionID[:8]) + require.NoError(t, err) + require.Len(t, overviews, 2) + assert.Equal(t, session.ID, overviews[0].ID) + assert.Equal(t, duplicate.ID, overviews[1].ID) + + _, err = db.GetSessionOverviewByIdentity(t.Context(), testProviderSessionID) + var conflict *SessionConflictError + require.ErrorAs(t, err, &conflict) + require.Len(t, conflict.Matches, 2) + assert.Equal(t, session.ID, conflict.Matches[0].ID) + assert.Equal(t, duplicate.ID, conflict.Matches[1].ID) + assert.Contains(t, err.Error(), session.ID.String()) + assert.Contains(t, err.Error(), duplicate.ID.String()) + }) } diff --git a/pkg/database/session_ingest_store_test.go b/pkg/database/session_ingest_store_test.go new file mode 100644 index 0000000..c804065 --- /dev/null +++ b/pkg/database/session_ingest_store_test.go @@ -0,0 +1,35 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizePostgresJSON(t *testing.T) { + input := []byte(`{"nul":"before\u0000after","literal":"before\\u0000after","nested":["\u0000"]}`) + got, err := sanitizePostgresJSON(input) + require.NoError(t, err) + assert.Equal(t, + `{"nul":"before\ufffdafter","literal":"before\\u0000after","nested":["\ufffd"]}`, + string(got), + ) +} + +func TestSanitizePostgresJSONPreservesCleanBytes(t *testing.T) { + input := []byte(`{ "number": 9223372036854775807, "text": "clean" }`) + got, err := sanitizePostgresJSON(input) + require.NoError(t, err) + assert.Equal(t, input, got) +} + +func TestSanitizePostgresJSONRejectsInvalidInput(t *testing.T) { + _, err := sanitizePostgresJSON([]byte(`{"broken":`)) + require.Error(t, err) +} + +func TestJSONBValueSanitizesNullCharacters(t *testing.T) { + got := jsonbValue(map[string]any{"value": "before\x00after"}) + assert.JSONEq(t, `{"value":"before\ufffdafter"}`, got.(string)) +} diff --git a/pkg/database/session_process_store.go b/pkg/database/session_process_store.go index 8aa035a..523de3b 100644 --- a/pkg/database/session_process_store.go +++ b/pkg/database/session_process_store.go @@ -88,15 +88,32 @@ func (db *DB) UpsertSessionProcess(ctx context.Context, input SessionProcessInpu if record.Status == "" { record.Status = "running" } - err := db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{ - {Name: "host_id"}, {Name: "boot_id"}, {Name: "pid"}, {Name: "process_started_at"}, - }, - DoUpdates: clause.AssignmentColumns([]string{ - "session_id", "status", "command", "cwd", "source", "cpu_percent", "memory_percent", - "memory_rss_bytes", "sampled_at", "last_heartbeat_at", - }), - }).Create(&record).Error + err := db.gorm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // One host/boot/PID identifies only one live OS process. Close stale + // identities before recording the current start time; this also drains + // rows created when ps(1)'s local lstart value was interpreted as UTC, + // even if transcript reconciliation has since rebound the PID to another + // Captain session. + if err := tx.Model(&sessionProcessRecord{}). + Where("host_id = ? AND boot_id = ? AND pid = ? AND ended_at IS NULL", hostID, bootID, input.PID). + Where("process_started_at <> ?", input.ProcessStartedAt.UTC()). + Updates(map[string]any{ + "ended_at": gorm.Expr("GREATEST(process_started_at, ?)", sampledAt), + "status": "exited", + }).Error; err != nil { + return err + } + + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "host_id"}, {Name: "boot_id"}, {Name: "pid"}, {Name: "process_started_at"}, + }, + DoUpdates: clause.AssignmentColumns([]string{ + "session_id", "status", "command", "cwd", "source", "cpu_percent", "memory_percent", + "memory_rss_bytes", "sampled_at", "last_heartbeat_at", "ended_at", + }), + }).Create(&record).Error + }) if err != nil { return fmt.Errorf("upsert Captain session process pid %d: %w", input.PID, err) } @@ -125,15 +142,29 @@ func (db *DB) FindProcessSessionID(ctx context.Context, hostID, bootID string, p } // EndOtherSessionProcesses closes open process rows for a session except the -// given PID, satisfying the one-active-process-per-session constraint before a -// new process binds to the session (e.g. resumed under a new PID). -func (db *DB) EndOtherSessionProcesses(ctx context.Context, sessionID uuid.UUID, keepPID int64) error { +// exact process identity being observed, satisfying the one-active-process-per- +// session constraint before a process binds to the session. The start time is +// part of the identity: a PID can be reused, and legacy monitor rows may hold +// the same PID with an incorrectly UTC-interpreted local start time. +func (db *DB) EndOtherSessionProcesses( + ctx context.Context, + sessionID uuid.UUID, + keepPID int64, + keepStartedAt time.Time, +) error { if err := db.requireGorm(); err != nil { return err } + now := time.Now().UTC() err := db.gorm.WithContext(ctx).Model(&sessionProcessRecord{}). - Where("session_id = ? AND ended_at IS NULL AND pid <> ?", sessionID, keepPID). - Updates(map[string]any{"ended_at": time.Now().UTC(), "status": "exited"}).Error + Where("session_id = ? AND ended_at IS NULL", sessionID). + Where("NOT (pid = ? AND process_started_at = ?)", keepPID, keepStartedAt.UTC()). + Updates(map[string]any{ + // GREATEST also closes legacy rows whose process_started_at was + // misread as UTC from ps(1)'s offset-free local lstart output. + "ended_at": gorm.Expr("GREATEST(process_started_at, ?)", now), + "status": "exited", + }).Error if err != nil { return fmt.Errorf("end superseded Captain session processes: %w", err) } @@ -181,7 +212,13 @@ func (db *DB) EndVanishedProcesses(ctx context.Context, hostID string, alivePIDs if len(alivePIDs) > 0 { query = query.Where("pid NOT IN ?", alivePIDs) } - result := query.Updates(map[string]any{"ended_at": now, "status": "exited"}) + result := query.Updates(map[string]any{ + // Keep the close operation total even for legacy/future-dated rows. + // New observations are stored correctly by the monitor's timezone-aware + // lstart parser, while this expression safely drains existing bad rows. + "ended_at": gorm.Expr("GREATEST(process_started_at, ?)", now), + "status": "exited", + }) if result.Error != nil { return 0, fmt.Errorf("end vanished Captain session processes: %w", result.Error) } diff --git a/pkg/database/session_read_store.go b/pkg/database/session_read_store.go index 18cff3b..42a329c 100644 --- a/pkg/database/session_read_store.go +++ b/pkg/database/session_read_store.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/flanksource/captain/pkg/api" "github.com/google/uuid" "gorm.io/gorm" ) @@ -46,6 +47,10 @@ type SessionOverview struct { ProcessCommand *string `gorm:"column:command" json:"processCommand,omitempty"` ProcessCWD *string `gorm:"column:process_cwd" json:"processCwd,omitempty"` ProcessStartedAt *time.Time `gorm:"column:process_started_at" json:"processStartedAt,omitempty"` + ProcessSampledAt *time.Time `gorm:"column:process_sampled_at" json:"processSampledAt,omitempty"` + LastHeartbeatAt *time.Time `gorm:"column:last_heartbeat_at" json:"lastHeartbeatAt,omitempty"` + LeaseOwner *string `gorm:"column:lease_owner" json:"leaseOwner,omitempty"` + LeaseExpiresAt *time.Time `gorm:"column:lease_expires_at" json:"leaseExpiresAt,omitempty"` ProcessActive bool `gorm:"column:process_active" json:"processActive"` CPUPercent *float64 `gorm:"column:cpu_percent" json:"cpuPercent,omitempty"` MemoryPercent *float64 `gorm:"column:memory_percent" json:"memoryPercent,omitempty"` @@ -73,6 +78,49 @@ type SessionOverview struct { func (SessionOverview) TableName() string { return "captain_session_overview" } +// SessionIdentityMatch is a lightweight captain_sessions row used for fast +// UUID/provider-prefix resolution without evaluating the aggregate overview. +type SessionIdentityMatch struct { + ID uuid.UUID `gorm:"column:id"` + ProviderSessionID string `gorm:"column:provider_session_id"` + Source string `gorm:"column:source"` + HostID string `gorm:"column:host_id"` + Path string `gorm:"column:path"` + Project string `gorm:"column:project"` + LastActivityAt *time.Time `gorm:"column:last_activity_at"` +} + +// SessionConflictError identifies every database session matching an +// ambiguous user-supplied prefix so callers can retry with a Captain UUID. +type SessionConflictError struct { + Identity string + Matches []SessionIdentityMatch +} + +func (e *SessionConflictError) Error() string { + var message strings.Builder + fmt.Fprintf(&message, "%s: session ID prefix %q matches %d sessions", ErrSessionConflict, e.Identity, len(e.Matches)) + for _, match := range e.Matches { + fmt.Fprintf(&message, "\n %s %s", match.ID, match.Source) + if match.ProviderSessionID != "" { + fmt.Fprintf(&message, " provider=%s", match.ProviderSessionID) + } + if match.Project != "" { + fmt.Fprintf(&message, " project=%s", match.Project) + } + if match.HostID != "" { + fmt.Fprintf(&message, " host=%s", match.HostID) + } + if match.Path != "" { + fmt.Fprintf(&message, " path=%s", match.Path) + } + } + message.WriteString("\nRetry with a full Captain UUID from the first column.") + return message.String() +} + +func (e *SessionConflictError) Unwrap() error { return ErrSessionConflict } + // SessionOverviewFilter narrows ListSessionOverviews. Zero values do not filter. type SessionOverviewFilter struct { Source string @@ -87,6 +135,7 @@ func (db *DB) ListSessionOverviews(ctx context.Context, filter SessionOverviewFi return nil, err } query := db.gorm.WithContext(ctx).Model(&SessionOverview{}). + Where("COALESCE(metadata->>'model', model, '') <> ?", api.CodexAutoReviewModel). Order("COALESCE(last_activity_at, started_at, created_at) DESC, id DESC") if source := strings.TrimSpace(filter.Source); source != "" { query = query.Where("source = ?", source) @@ -110,10 +159,45 @@ func (db *DB) ListSessionOverviews(ctx context.Context, filter SessionOverviewFi return rows, nil } -// GetSessionOverviewByIdentity resolves a session UUID or a provider session ID -// prefix (the CLI accepts short IDs) to its overview row. Ambiguous prefixes -// are an error rather than an arbitrary pick. +// CountLiveRootSessions returns the number of top-level sessions currently +// associated with an active process. It uses the same overview projection and +// root/live semantics as the session list surfaces. +func (db *DB) CountLiveRootSessions(ctx context.Context) (int64, error) { + if err := db.requireGorm(); err != nil { + return 0, err + } + var count int64 + if err := db.gorm.WithContext(ctx).Model(&SessionOverview{}). + Where("COALESCE(metadata->>'model', model, '') <> ?", api.CodexAutoReviewModel). + Where("parent_session_id IS NULL"). + Where("process_active"). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("count live Captain sessions: %w", err) + } + return count, nil +} + +// GetSessionOverviewByIdentity resolves a full Captain UUID or a provider +// session ID prefix to its overview row. Ambiguous prefixes list every match. func (db *DB) GetSessionOverviewByIdentity(ctx context.Context, identity string) (*SessionOverview, error) { + rows, err := db.ListSessionOverviewsByIdentity(ctx, identity) + if err != nil { + return nil, err + } + if len(rows) > 1 { + matches, matchErr := db.ListSessionIdentityMatches(ctx, identity) + if matchErr != nil { + return nil, matchErr + } + return nil, &SessionConflictError{Identity: strings.TrimSpace(identity), Matches: matches} + } + return &rows[0], nil +} + +// ListSessionOverviewsByIdentity resolves a full Captain UUID or every session +// whose provider session ID starts with identity. It narrows the aggregate view +// by Captain UUIDs discovered from the lightweight base table. +func (db *DB) ListSessionOverviewsByIdentity(ctx context.Context, identity string) ([]SessionOverview, error) { if err := db.requireGorm(); err != nil { return nil, err } @@ -122,34 +206,76 @@ func (db *DB) GetSessionOverviewByIdentity(ctx context.Context, identity string) return nil, fmt.Errorf("%w: identity is required", ErrInvalidSession) } if parsed, err := uuid.Parse(identity); err == nil { - var row SessionOverview - err := db.gorm.WithContext(ctx).First(&row, "id = ?", parsed).Error - if err == nil { - return &row, nil + row, getErr := db.getSessionOverviewByID(ctx, parsed) + if getErr == nil { + return []SessionOverview{*row}, nil } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("resolve Captain session overview UUID: %w", err) + if !errors.Is(getErr, gorm.ErrRecordNotFound) { + return nil, getErr } } - var rows []SessionOverview - err := db.gorm.WithContext(ctx). - Where("provider_session_id LIKE ?", escapeLike(identity)+"%"). - Limit(3).Find(&rows).Error + matches, err := db.ListSessionIdentityMatches(ctx, identity) if err != nil { - return nil, fmt.Errorf("resolve Captain session overview identity: %w", err) + return nil, err } - if len(rows) == 0 { + if len(matches) == 0 { return nil, fmt.Errorf("%w: %s", ErrSessionNotFound, identity) } + ids := make([]uuid.UUID, len(matches)) + for i := range matches { + ids[i] = matches[i].ID + } + var rows []SessionOverview + if err := db.gorm.WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain session overviews by identity: %w", err) + } + byID := make(map[uuid.UUID]SessionOverview, len(rows)) for i := range rows { - if optionalString(rows[i].ProviderSessionID) == identity { - return &rows[i], nil + byID[rows[i].ID] = rows[i] + } + ordered := make([]SessionOverview, 0, len(matches)) + for _, match := range matches { + row, ok := byID[match.ID] + if !ok { + return nil, fmt.Errorf("%w: overview missing for matched Captain session %s", ErrInvalidSession, match.ID) } + ordered = append(ordered, row) } - if len(rows) > 1 { - return nil, fmt.Errorf("%w: session ID prefix %q is ambiguous", ErrSessionConflict, identity) + return ordered, nil +} + +// ListSessionIdentityMatches resolves prefixes against the lightweight base +// table so ambiguous lookups do not evaluate every overview aggregate. +func (db *DB) ListSessionIdentityMatches(ctx context.Context, identity string) ([]SessionIdentityMatch, error) { + if err := db.requireGorm(); err != nil { + return nil, err } - return &rows[0], nil + identity = strings.TrimSpace(identity) + if identity == "" { + return nil, fmt.Errorf("%w: identity is required", ErrInvalidSession) + } + prefix := escapeLike(identity) + "%" + var matches []SessionIdentityMatch + err := db.gorm.WithContext(ctx). + Table("captain_sessions"). + Where("provider_session_id LIKE ?", prefix). + Order("provider_session_id, (path IS NULL), last_activity_at DESC NULLS LAST, id"). + Find(&matches).Error + if err != nil { + return nil, fmt.Errorf("list Captain session identity matches: %w", err) + } + return matches, nil +} + +func (db *DB) getSessionOverviewByID(ctx context.Context, id uuid.UUID) (*SessionOverview, error) { + var row SessionOverview + if err := db.gorm.WithContext(ctx).First(&row, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return nil, fmt.Errorf("resolve Captain session overview UUID: %w", err) + } + return &row, nil } // TranscriptMessage is one row of the captain_session_transcript view. @@ -229,8 +355,9 @@ func (db *DB) ListProjectAggregates(ctx context.Context) ([]ProjectAggregate, er FROM captain_sessions s LEFT JOIN captain_session_processes p ON p.session_id = s.id AND p.ended_at IS NULL WHERE s.project IS NOT NULL AND s.project <> '' + AND COALESCE(s.metadata->>'model', '') <> ? GROUP BY s.project - ORDER BY max(s.last_activity_at) DESC NULLS LAST`).Scan(&rows).Error + ORDER BY max(s.last_activity_at) DESC NULLS LAST`, api.CodexAutoReviewModel).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("list Captain project aggregates: %w", err) } diff --git a/pkg/database/session_read_store_test.go b/pkg/database/session_read_store_test.go new file mode 100644 index 0000000..bd188bb --- /dev/null +++ b/pkg/database/session_read_store_test.go @@ -0,0 +1,33 @@ +package database + +import ( + "errors" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestSessionConflictErrorListsEveryMatch(t *testing.T) { + err := &SessionConflictError{ + Identity: "ad4c854e", + Matches: []SessionIdentityMatch{ + {ID: uuid.MustParse("055781c7-360a-4eb2-80be-452b3937fcfe"), ProviderSessionID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", Source: "claude", Project: "flanksource", Path: "/sessions/ad4c854e.jsonl"}, + {ID: uuid.MustParse("7ca78c55-e280-50ff-a19a-9f355a6fc55e"), ProviderSessionID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", Source: "gavel"}, + }, + } + if !errors.Is(err, ErrSessionConflict) { + t.Fatalf("error = %v, want ErrSessionConflict", err) + } + for _, expected := range []string{ + "session ID prefix \"ad4c854e\" matches 2 sessions", + "055781c7-360a-4eb2-80be-452b3937fcfe", + "7ca78c55-e280-50ff-a19a-9f355a6fc55e", + "ad4c854e-cde6-4b99-99f3-667bf74112e3", + "claude", "gavel", "flanksource", "/sessions/ad4c854e.jsonl", + } { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("error missing %q: %s", expected, err) + } + } +} From cf2142131ee04e0c449d7824f0e5018b79e35e79 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 12:08:26 +0300 Subject: [PATCH 044/131] refactor(tools): extract event helpers and renderers, standardize muted styling Refactors large event.go file by extracting helper functions into event_helpers.go and tool renderers into event_renderers.go. Improves code organization and maintainability. Also standardizes text styling by replacing specific gray colors (text-gray-600, text-gray-700) with semantic text-muted class throughout tool Pretty() methods for better theme consistency. Adds UserShellCommandTool support for handling user-executed shell commands with command preview, duration, and exit code display. --- pkg/claude/tools/assistant.go | 4 +- pkg/claude/tools/event.go | 570 +------------------------- pkg/claude/tools/event_helpers.go | 239 +++++++++++ pkg/claude/tools/event_renderers.go | 367 +++++++++++++++++ pkg/claude/tools/muted_styles_test.go | 113 +++++ pkg/claude/tools/stream.go | 12 +- pkg/claude/tools/stream_test.go | 26 +- pkg/claude/tools/tool.go | 2 + 8 files changed, 756 insertions(+), 577 deletions(-) create mode 100644 pkg/claude/tools/event_helpers.go create mode 100644 pkg/claude/tools/event_renderers.go create mode 100644 pkg/claude/tools/muted_styles_test.go diff --git a/pkg/claude/tools/assistant.go b/pkg/claude/tools/assistant.go index 940edee..7152803 100644 --- a/pkg/claude/tools/assistant.go +++ b/pkg/claude/tools/assistant.go @@ -18,7 +18,7 @@ func (t *AssistantTool) Pretty() api.Text { icon := icons.Icon{Unicode: "🤖", Iconify: "mdi:robot", Style: "muted"} text := t.header(icon, "assistant", "text-blue-500 font-medium") if body := t.Str("text"); body != "" { - text = text.Append(" "+body, "text-gray-700 max-w-[tw-20ch]") + text = text.Append(" "+body, "text-muted max-w-[tw-20ch]") } return text } @@ -38,7 +38,7 @@ func (t *ReasoningTool) Pretty() api.Text { icon := icons.Icon{Unicode: "💭", Iconify: "mdi:thought-bubble", Style: "muted"} text := t.header(icon, "reasoning", "text-purple-500 font-medium") if body := t.Str("text"); body != "" { - text = text.Append(" "+body, "text-gray-500 italic max-w-[tw-20ch]") + text = text.Append(" "+body, "text-muted italic max-w-[tw-20ch]") } return text } diff --git a/pkg/claude/tools/event.go b/pkg/claude/tools/event.go index 62d4a9f..04a9c68 100644 --- a/pkg/claude/tools/event.go +++ b/pkg/claude/tools/event.go @@ -2,7 +2,6 @@ package tools import ( "fmt" - "path/filepath" "strings" "github.com/flanksource/clicky" @@ -86,7 +85,7 @@ func IsEventToolName(name string) bool { "CollabClose", "QueueOperation", "DeferredToolsDelta", "AgentListingDelta", "MemoryCitation", "SkillListing", "Budget", "PrLink", "CompactBoundary", "LocalCommand", "ScheduledTaskFire", - "Informational", "WorktreeState", "Relocated", "Started": + "Informational", "WorktreeState", "Relocated", "Started", "UserShellCommand": return true default: return false @@ -129,7 +128,7 @@ func (t *TokenCountTool) Detail() api.Textable { return t.BaseTool.Detail() } func (t *TokenCountTool) Pretty() api.Text { text := eventText(icons.Icon{Unicode: "◌", Iconify: "mdi:counter", Style: "muted"}, "tokens", "text-slate-500 font-medium") if total := eventInt(t.Input["total_tokens"]); total > 0 { - text = text.Append(" "+FormatCompactTokens(int(total)), "text-gray-600") + text = text.Append(" "+FormatCompactTokens(int(total)), "text-muted") } if in := eventInt(t.Input["input_tokens"]); in > 0 { text = text.Append(fmt.Sprintf(" in=%s", FormatCompactTokens(int(in))), "text-gray-500") @@ -197,568 +196,3 @@ func (t *LifecycleEventTool) Pretty() api.Text { } return text } - -type CodexExecCommandTool struct{ BaseTool } - -func (t *CodexExecCommandTool) Name() string { return "CodexExecCommand" } -func (t *CodexExecCommandTool) Category() string { return "chat" } -func (t *CodexExecCommandTool) FilePath() string { return "" } -func (t *CodexExecCommandTool) ExtractPath() string { - if cwd := t.Str("cwd"); cwd != "" { - return cwd - } - return "" -} -func (t *CodexExecCommandTool) Detail() api.Textable { return commandOutputDetail(t.BaseTool) } -func (t *CodexExecCommandTool) Pretty() api.Text { - text := eventText(icons.Icon{Unicode: "💻", Iconify: "codicon:terminal", Style: "muted"}, "exec", "text-green-500 font-medium") - if cmd := codexCommandString(t.Input["command"]); cmd != "" { - text = text.Append(" "+eventPreview(cmd, 120), "text-gray-700") - } - if status := t.Str("status"); status != "" { - color := "text-green-600" - if status != "completed" && status != "success" { - color = "text-red-500" - } - text = text.Append(" "+status, color) - } - if code := eventInt(t.Input["exit_code"]); code != 0 { - text = text.Append(fmt.Sprintf(" exit=%d", code), "text-red-500") - } - return text -} - -type CodexPatchApplyTool struct{ BaseTool } - -func (t *CodexPatchApplyTool) Name() string { return "CodexPatchApply" } -func (t *CodexPatchApplyTool) Category() string { return "chat" } -func (t *CodexPatchApplyTool) FilePath() string { return "" } -func (t *CodexPatchApplyTool) ExtractPath() string { return "" } -func (t *CodexPatchApplyTool) Detail() api.Textable { return commandOutputDetail(t.BaseTool) } -func (t *CodexPatchApplyTool) Pretty() api.Text { - text := eventText(icons.Icon{Unicode: "✏️", Iconify: "codicon:edit", Style: "muted"}, "patch", "text-orange-500 font-medium") - if success, ok := t.Input["success"].(bool); ok { - if success { - text = text.Append(" applied", "text-green-600") - } else { - text = text.Append(" failed", "text-red-500") - } - } - if n := mapLen(t.Input["changes"]); n > 0 { - text = text.Append(fmt.Sprintf(" files=%d", n), "text-gray-500") - } - return text -} - -type MCPToolCallTool struct{ BaseTool } - -func (t *MCPToolCallTool) Name() string { return "MCPToolCall" } -func (t *MCPToolCallTool) Category() string { return "chat" } -func (t *MCPToolCallTool) FilePath() string { return "" } -func (t *MCPToolCallTool) ExtractPath() string { return "" } -func (t *MCPToolCallTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *MCPToolCallTool) Pretty() api.Text { - server, tool := invocationName(t.Input["invocation"]) - text := eventText(icons.Package, "mcp", "text-indigo-500 font-medium") - if server != "" || tool != "" { - text = text.Append(" "+strings.Trim(server+"."+tool, "."), "text-gray-700") - } - if d := durationString(t.Input["duration"]); d != "" { - text = text.Append(" "+d, "text-gray-500") - } - if resultStatus := resultStatus(t.Input["result"]); resultStatus != "" { - text = text.Append(" "+resultStatus, statusColor(resultStatus)) - } - return text -} - -type WebSearchEventTool struct{ BaseTool } - -func (t *WebSearchEventTool) Name() string { return "WebSearchEvent" } -func (t *WebSearchEventTool) Category() string { return "chat" } -func (t *WebSearchEventTool) FilePath() string { return "" } -func (t *WebSearchEventTool) ExtractPath() string { return "" } -func (t *WebSearchEventTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *WebSearchEventTool) Pretty() api.Text { - text := eventText(icons.Search, "web search", "text-purple-500 font-medium") - if query := firstNonEmptyEvent(t.Str("query"), actionQuery(t.Input["action"])); query != "" { - text = text.Append(" "+eventPreview(query, 100), "text-gray-700") - } - return text -} - -type ViewImageTool struct{ BaseTool } - -func (t *ViewImageTool) Name() string { return "ViewImage" } -func (t *ViewImageTool) Category() string { return "chat" } -func (t *ViewImageTool) FilePath() string { return t.Str("path") } -func (t *ViewImageTool) ExtractPath() string { return t.Str("path") } -func (t *ViewImageTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *ViewImageTool) Pretty() api.Text { - text := eventText(icons.File, "image", "text-cyan-500 font-medium") - if path := t.Str("path"); path != "" { - text = text.Append(" "+ShortenPath(path), "text-gray-700") - } - return text -} - -type GuardianAssessmentTool struct{ BaseTool } - -func (t *GuardianAssessmentTool) Name() string { return "GuardianAssessment" } -func (t *GuardianAssessmentTool) Category() string { return "chat" } -func (t *GuardianAssessmentTool) FilePath() string { return "" } -func (t *GuardianAssessmentTool) ExtractPath() string { return "" } -func (t *GuardianAssessmentTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *GuardianAssessmentTool) Pretty() api.Text { - text := eventText(icons.Icon{Unicode: "🛡", Iconify: "mdi:shield-check", Style: "muted"}, "guardian", "text-amber-600 font-medium") - if status := t.Str("status"); status != "" { - text = text.Append(" "+status, statusColor(status)) - } - if action := actionSummary(t.Input["action"]); action != "" { - text = text.Append(" "+eventPreview(action, 100), "text-gray-600") - } - return text -} - -type ReviewModeTool struct{ BaseTool } - -func (t *ReviewModeTool) Name() string { return "ReviewMode" } -func (t *ReviewModeTool) Category() string { return "chat" } -func (t *ReviewModeTool) FilePath() string { return "" } -func (t *ReviewModeTool) ExtractPath() string { return "" } -func (t *ReviewModeTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *ReviewModeTool) Pretty() api.Text { - action := strings.TrimSuffix(strings.TrimPrefix(t.Str("event"), "entered_"), "_mode") - if action == "" { - action = strings.TrimSuffix(strings.TrimPrefix(t.Str("event"), "exited_"), "_mode") - } - text := eventText(icons.Info, "review "+action, "text-purple-500 font-medium") - if n := findingsCount(t.Input["review_output"]); n > 0 { - text = text.Append(fmt.Sprintf(" findings=%d", n), "text-gray-500") - } - return text -} - -type CollabEventTool struct{ BaseTool } - -func (t *CollabEventTool) Name() string { return t.RawTool } -func (t *CollabEventTool) Category() string { return "chat" } -func (t *CollabEventTool) FilePath() string { return "" } -func (t *CollabEventTool) ExtractPath() string { return "" } -func (t *CollabEventTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *CollabEventTool) Pretty() api.Text { - label := strings.TrimPrefix(strings.TrimPrefix(t.Str("event"), "collab_"), "agent_") - label = strings.TrimSuffix(label, "_end") - if label == "" { - label = strings.ToLower(t.RawTool) - } - text := eventText(icons.Icon{Unicode: "🤝", Iconify: "mdi:handshake", Style: "muted"}, "collab "+strings.ReplaceAll(label, "_", " "), "text-indigo-500 font-medium") - if nick := firstNonEmptyEvent(t.Str("nickname"), nestedString(t.Input["receiver"], "nickname")); nick != "" { - text = text.Append(" "+nick, "text-gray-700") - } - if status := t.Str("status"); status != "" { - text = text.Append(" "+status, statusColor(status)) - } - return text -} - -type QueueOperationTool struct{ BaseTool } - -func (t *QueueOperationTool) Name() string { return "QueueOperation" } -func (t *QueueOperationTool) Category() string { return "chat" } -func (t *QueueOperationTool) FilePath() string { return "" } -func (t *QueueOperationTool) ExtractPath() string { return "" } -func (t *QueueOperationTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *QueueOperationTool) Pretty() api.Text { - op := firstNonEmptyEvent(t.Str("operation"), "queue") - text := eventText(icons.Package, "queue "+op, "text-slate-500 font-medium") - if content := t.Str("content"); content != "" { - text = text.Append(" "+eventPreview(content, 100), "text-gray-500") - } - return text -} - -type DeferredToolsDeltaTool struct{ BaseTool } - -func (t *DeferredToolsDeltaTool) Name() string { return "DeferredToolsDelta" } -func (t *DeferredToolsDeltaTool) Category() string { return "chat" } -func (t *DeferredToolsDeltaTool) FilePath() string { return "" } -func (t *DeferredToolsDeltaTool) ExtractPath() string { return "" } -func (t *DeferredToolsDeltaTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *DeferredToolsDeltaTool) Pretty() api.Text { - text := eventText(icons.Package, "tools", "text-slate-500 font-medium") - appendListCount(&text, "added", t.Input["addedNames"]) - appendListCount(&text, "pending", t.Input["pendingMcpServers"]) - return text -} - -type AgentListingDeltaTool struct{ BaseTool } - -func (t *AgentListingDeltaTool) Name() string { return "AgentListingDelta" } -func (t *AgentListingDeltaTool) Category() string { return "chat" } -func (t *AgentListingDeltaTool) FilePath() string { return "" } -func (t *AgentListingDeltaTool) ExtractPath() string { return "" } -func (t *AgentListingDeltaTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *AgentListingDeltaTool) Pretty() api.Text { - text := eventText(icons.Icon{Unicode: "🤖", Iconify: "mdi:robot", Style: "muted"}, "agents", "text-indigo-500 font-medium") - appendListCount(&text, "added", t.Input["addedTypes"]) - return text -} - -type SkillListingTool struct{ BaseTool } - -func (t *SkillListingTool) Name() string { return "SkillListing" } -func (t *SkillListingTool) Category() string { return "chat" } -func (t *SkillListingTool) FilePath() string { return "" } -func (t *SkillListingTool) ExtractPath() string { return "" } -func (t *SkillListingTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *SkillListingTool) Pretty() api.Text { - text := eventText(icons.Info, "skills", "text-teal-500 font-medium") - appendListCount(&text, "count", t.Input["names"]) - if count := eventInt(t.Input["skillCount"]); count > 0 { - text = text.Append(fmt.Sprintf(" count=%d", count), "text-gray-500") - } - return text -} - -type BudgetTool struct{ BaseTool } - -func (t *BudgetTool) Name() string { return "Budget" } -func (t *BudgetTool) Category() string { return "chat" } -func (t *BudgetTool) FilePath() string { return "" } -func (t *BudgetTool) ExtractPath() string { return "" } -func (t *BudgetTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *BudgetTool) Pretty() api.Text { - text := eventText(icons.Icon{Unicode: "$", Iconify: "mdi:cash", Style: "muted"}, "budget", "text-green-600 font-medium") - if used := eventFloat(t.Input["used"]); used > 0 { - text = text.Append(fmt.Sprintf(" used=$%.2f", used), "text-gray-600") - } - if total := eventFloat(t.Input["total"]); total > 0 { - text = text.Append(fmt.Sprintf(" total=$%.2f", total), "text-gray-500") - } - if remaining := eventFloat(t.Input["remaining"]); remaining > 0 { - text = text.Append(fmt.Sprintf(" remaining=$%.2f", remaining), "text-gray-500") - } - return text -} - -type PrLinkTool struct{ BaseTool } - -func (t *PrLinkTool) Name() string { return "PrLink" } -func (t *PrLinkTool) Category() string { return "chat" } -func (t *PrLinkTool) FilePath() string { return "" } -func (t *PrLinkTool) ExtractPath() string { return "" } -func (t *PrLinkTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *PrLinkTool) Pretty() api.Text { - text := eventText(icons.Git, "pr", "text-cyan-600 font-medium") - if n := eventInt(t.Input["prNumber"]); n > 0 { - text = text.Append(fmt.Sprintf(" #%d", n), "text-gray-700") - } - if repo := t.Str("prRepository"); repo != "" { - text = text.Append(" "+repo, "text-gray-500") - } - return text -} - -type ContentEventTool struct{ BaseTool } - -func (t *ContentEventTool) Name() string { return t.RawTool } -func (t *ContentEventTool) Category() string { return "chat" } -func (t *ContentEventTool) FilePath() string { return "" } -func (t *ContentEventTool) ExtractPath() string { return "" } -func (t *ContentEventTool) Detail() api.Textable { - if d := t.BaseTool.Detail(); d != nil { - return d - } - if c := strings.TrimSpace(t.Str("content")); c != "" { - text := clicky.Text("").Append(c, "") - return &text - } - return nil -} -func (t *ContentEventTool) Pretty() api.Text { - label := strings.TrimSpace(t.Str("event")) - if label == "" { - label = strings.ToLower(t.RawTool) - } - text := eventText(icons.Info, strings.ReplaceAll(label, "_", " "), "text-slate-500 font-medium") - if content := t.Str("content"); content != "" { - text = text.Append(" "+eventPreview(content, 100), "text-gray-500") - } - return text -} - -type WorktreeStateTool struct{ BaseTool } - -func (t *WorktreeStateTool) Name() string { return "WorktreeState" } -func (t *WorktreeStateTool) Category() string { return "chat" } -func (t *WorktreeStateTool) FilePath() string { - return nestedString(t.Input["worktreeSession"], "worktreePath") -} -func (t *WorktreeStateTool) ExtractPath() string { return t.FilePath() } -func (t *WorktreeStateTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *WorktreeStateTool) Pretty() api.Text { - text := eventText(icons.Git, "worktree", "text-green-600 font-medium") - if name := nestedString(t.Input["worktreeSession"], "worktreeName"); name != "" { - text = text.Append(" "+name, "text-gray-700") - } - if branch := nestedString(t.Input["worktreeSession"], "worktreeBranch"); branch != "" { - text = text.Append(" "+branch, "text-gray-500") - } - return text -} - -type RelocatedTool struct{ BaseTool } - -func (t *RelocatedTool) Name() string { return "Relocated" } -func (t *RelocatedTool) Category() string { return "chat" } -func (t *RelocatedTool) FilePath() string { return t.Str("relocatedCwd") } -func (t *RelocatedTool) ExtractPath() string { return t.Str("relocatedCwd") } -func (t *RelocatedTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *RelocatedTool) Pretty() api.Text { - text := eventText(icons.Folder, "relocated", "text-cyan-600 font-medium") - if cwd := t.Str("relocatedCwd"); cwd != "" { - text = text.Append(" "+ShortenPath(cwd), "text-gray-700") - } - return text -} - -type StartedTool struct{ BaseTool } - -func (t *StartedTool) Name() string { return "Started" } -func (t *StartedTool) Category() string { return "chat" } -func (t *StartedTool) FilePath() string { return "" } -func (t *StartedTool) ExtractPath() string { return "" } -func (t *StartedTool) Detail() api.Textable { return t.BaseTool.Detail() } -func (t *StartedTool) Pretty() api.Text { - return eventText(icons.Play, "started", "text-green-600 font-medium") -} - -func eventInt(value any) int64 { - switch v := value.(type) { - case int: - return int64(v) - case int64: - return v - case float64: - return int64(v) - default: - return 0 - } -} - -func eventFloat(value any) float64 { - switch v := value.(type) { - case int: - return float64(v) - case int64: - return float64(v) - case float64: - return v - default: - return 0 - } -} - -func eventString(value any) string { - switch v := value.(type) { - case string: - return v - default: - return "" - } -} - -func eventText(icon api.Textable, label, color string) api.Text { - return clicky.Text("").Add(icon).Append(" "+label, color) -} - -func eventPreview(s string, max int) string { - s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ") - if max <= 0 || len(s) <= max { - return s - } - if max <= 3 { - return s[:max] - } - return s[:max-3] + "..." -} - -func firstNonEmptyEvent(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func mapLen(value any) int { - switch v := value.(type) { - case map[string]any: - return len(v) - case map[string]map[string]any: - return len(v) - default: - return 0 - } -} - -func listLen(value any) int { - switch v := value.(type) { - case []any: - return len(v) - case []string: - return len(v) - default: - return 0 - } -} - -func appendListCount(text *api.Text, label string, value any) { - if n := listLen(value); n > 0 { - *text = text.Append(fmt.Sprintf(" %s=%d", label, n), "text-gray-500") - } -} - -func statusColor(status string) string { - switch strings.ToLower(status) { - case "completed", "success", "ok", "approved", "pass", "passed": - return "text-green-600" - case "failed", "error", "denied", "blocked", "rejected", "interrupted": - return "text-red-500" - default: - return "text-gray-500" - } -} - -func codexCommandString(value any) string { - switch v := value.(type) { - case string: - return v - case []any: - parts := make([]string, 0, len(v)) - for _, item := range v { - if s, ok := item.(string); ok { - parts = append(parts, s) - } - } - return strings.Join(parts, " ") - case []string: - return strings.Join(v, " ") - default: - return "" - } -} - -func commandOutputDetail(base BaseTool) api.Textable { - if d := base.Detail(); d != nil { - return d - } - stdout := strings.TrimSpace(base.Str("stdout")) - stderr := strings.TrimSpace(base.Str("stderr")) - if stdout == "" && stderr == "" { - stdout = strings.TrimSpace(base.Str("aggregated_output")) - } - if stdout == "" && stderr == "" { - return nil - } - text := clicky.Text("") - if stdout != "" { - text = text.Append("stdout: ", "font-bold text-gray-600").Append(stdout, "") - } - if stderr != "" { - if stdout != "" { - text = text.NewLine() - } - text = text.Append("stderr: ", "font-bold text-red-500").Append(stderr, "") - } - return &text -} - -func invocationName(value any) (string, string) { - m, ok := value.(map[string]any) - if !ok { - return "", "" - } - return eventString(m["server"]), eventString(m["tool"]) -} - -func durationString(value any) string { - m, ok := value.(map[string]any) - if !ok { - return "" - } - if secs := eventFloat(m["secs"]); secs > 0 { - return formatDurationMS(secs * 1000) - } - if nanos := eventFloat(m["nanos"]); nanos > 0 { - return formatDurationMS(nanos / 1_000_000) - } - return "" -} - -func resultStatus(value any) string { - m, ok := value.(map[string]any) - if !ok { - return "" - } - for k := range m { - if strings.EqualFold(k, "ok") { - return "ok" - } - if strings.EqualFold(k, "err") || strings.EqualFold(k, "error") { - return "error" - } - } - return "" -} - -func actionQuery(value any) string { - m, ok := value.(map[string]any) - if !ok { - return "" - } - if q := eventString(m["query"]); q != "" { - return q - } - if qs, ok := m["queries"].([]any); ok && len(qs) > 0 { - if q, ok := qs[0].(string); ok { - return q - } - } - return "" -} - -func actionSummary(value any) string { - m, ok := value.(map[string]any) - if !ok { - return "" - } - parts := make([]string, 0, 3) - for _, key := range []string{"tool", "command", "cwd"} { - if value := eventString(m[key]); value != "" { - if key == "cwd" { - value = filepath.Base(value) - } - parts = append(parts, value) - } - } - return strings.Join(parts, " ") -} - -func findingsCount(value any) int { - m, ok := value.(map[string]any) - if !ok { - return 0 - } - return listLen(m["findings"]) -} - -func nestedString(value any, key string) string { - m, ok := value.(map[string]any) - if !ok { - return "" - } - return eventString(m[key]) -} diff --git a/pkg/claude/tools/event_helpers.go b/pkg/claude/tools/event_helpers.go new file mode 100644 index 0000000..5fd3b9a --- /dev/null +++ b/pkg/claude/tools/event_helpers.go @@ -0,0 +1,239 @@ +package tools + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" +) + +func eventInt(value any) int64 { + switch v := value.(type) { + case int: + return int64(v) + case int64: + return v + case float64: + return int64(v) + default: + return 0 + } +} + +func eventFloat(value any) float64 { + switch v := value.(type) { + case int: + return float64(v) + case int64: + return float64(v) + case float64: + return v + default: + return 0 + } +} + +func eventString(value any) string { + switch v := value.(type) { + case string: + return v + default: + return "" + } +} + +func eventText(icon api.Textable, label, color string) api.Text { + return clicky.Text("").Add(icon).Append(" "+label, color) +} + +func eventPreview(s string, max int) string { + s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ") + if max <= 0 || len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + +func firstNonEmptyEvent(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mapLen(value any) int { + switch v := value.(type) { + case map[string]any: + return len(v) + case map[string]map[string]any: + return len(v) + default: + return 0 + } +} + +func listLen(value any) int { + switch v := value.(type) { + case []any: + return len(v) + case []string: + return len(v) + default: + return 0 + } +} + +func appendListCount(text *api.Text, label string, value any) { + if n := listLen(value); n > 0 { + *text = text.Append(fmt.Sprintf(" %s=%d", label, n), "text-gray-500") + } +} + +func statusColor(status string) string { + switch strings.ToLower(status) { + case "completed", "success", "ok", "approved", "pass", "passed": + return "text-green-600" + case "failed", "error", "denied", "blocked", "rejected", "interrupted": + return "text-red-500" + default: + return "text-gray-500" + } +} + +func codexCommandString(value any) string { + switch v := value.(type) { + case string: + return v + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + parts = append(parts, s) + } + } + return strings.Join(parts, " ") + case []string: + return strings.Join(v, " ") + default: + return "" + } +} + +func commandOutputDetail(base BaseTool) api.Textable { + if d := base.Detail(); d != nil { + return d + } + stdout := strings.TrimSpace(base.Str("stdout")) + stderr := strings.TrimSpace(base.Str("stderr")) + if stdout == "" && stderr == "" { + stdout = strings.TrimSpace(base.Str("aggregated_output")) + } + if stdout == "" && stderr == "" { + return nil + } + text := clicky.Text("") + if stdout != "" { + text = text.Append("stdout: ", "font-bold text-muted").Append(stdout, "") + } + if stderr != "" { + if stdout != "" { + text = text.NewLine() + } + text = text.Append("stderr: ", "font-bold text-red-500").Append(stderr, "") + } + return &text +} + +func invocationName(value any) (string, string) { + m, ok := value.(map[string]any) + if !ok { + return "", "" + } + return eventString(m["server"]), eventString(m["tool"]) +} + +func durationString(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + if secs := eventFloat(m["secs"]); secs > 0 { + return formatDurationMS(secs * 1000) + } + if nanos := eventFloat(m["nanos"]); nanos > 0 { + return formatDurationMS(nanos / 1_000_000) + } + return "" +} + +func resultStatus(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + for k := range m { + if strings.EqualFold(k, "ok") { + return "ok" + } + if strings.EqualFold(k, "err") || strings.EqualFold(k, "error") { + return "error" + } + } + return "" +} + +func actionQuery(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + if q := eventString(m["query"]); q != "" { + return q + } + if qs, ok := m["queries"].([]any); ok && len(qs) > 0 { + if q, ok := qs[0].(string); ok { + return q + } + } + return "" +} + +func actionSummary(value any) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + parts := make([]string, 0, 3) + for _, key := range []string{"tool", "command", "cwd"} { + if value := eventString(m[key]); value != "" { + if key == "cwd" { + value = filepath.Base(value) + } + parts = append(parts, value) + } + } + return strings.Join(parts, " ") +} + +func findingsCount(value any) int { + m, ok := value.(map[string]any) + if !ok { + return 0 + } + return listLen(m["findings"]) +} + +func nestedString(value any, key string) string { + m, ok := value.(map[string]any) + if !ok { + return "" + } + return eventString(m[key]) +} diff --git a/pkg/claude/tools/event_renderers.go b/pkg/claude/tools/event_renderers.go new file mode 100644 index 0000000..ab49bed --- /dev/null +++ b/pkg/claude/tools/event_renderers.go @@ -0,0 +1,367 @@ +package tools + +import ( + "fmt" + "strings" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/api/icons" +) + +type CodexExecCommandTool struct{ BaseTool } + +func (t *CodexExecCommandTool) Name() string { return "CodexExecCommand" } +func (t *CodexExecCommandTool) Category() string { return "chat" } +func (t *CodexExecCommandTool) FilePath() string { return "" } +func (t *CodexExecCommandTool) ExtractPath() string { + if cwd := t.Str("cwd"); cwd != "" { + return cwd + } + return "" +} +func (t *CodexExecCommandTool) Detail() api.Textable { return commandOutputDetail(t.BaseTool) } +func (t *CodexExecCommandTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "💻", Iconify: "codicon:terminal", Style: "muted"}, "exec", "text-green-500 font-medium") + if cmd := codexCommandString(t.Input["command"]); cmd != "" { + text = text.Append(" "+eventPreview(cmd, 120), "text-muted") + } + if status := t.Str("status"); status != "" { + color := "text-green-600" + if status != "completed" && status != "success" { + color = "text-red-500" + } + text = text.Append(" "+status, color) + } + if code := eventInt(t.Input["exit_code"]); code != 0 { + text = text.Append(fmt.Sprintf(" exit=%d", code), "text-red-500") + } + return text +} + +type UserShellCommandTool struct{ BaseTool } + +func (t *UserShellCommandTool) Name() string { return "UserShellCommand" } +func (t *UserShellCommandTool) Category() string { return "chat" } +func (t *UserShellCommandTool) FilePath() string { return "" } +func (t *UserShellCommandTool) ExtractPath() string { return "" } +func (t *UserShellCommandTool) Detail() api.Textable { return commandOutputDetail(t.BaseTool) } +func (t *UserShellCommandTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "💻", Iconify: "codicon:terminal", Style: "muted"}, "local command", "text-slate-500 font-medium") + if cmd := t.Str("command"); cmd != "" { + text = text.Append(" "+eventPreview(cmd, 120), "text-muted") + } + if duration := eventFloat(t.Input["duration_ms"]); duration > 0 { + text = text.Append(" "+formatDurationMS(duration), "text-gray-500") + } + if code := eventInt(t.Input["exit_code"]); code != 0 { + text = text.Append(fmt.Sprintf(" exit=%d", code), "text-red-500") + } + return text +} + +type CodexPatchApplyTool struct{ BaseTool } + +func (t *CodexPatchApplyTool) Name() string { return "CodexPatchApply" } +func (t *CodexPatchApplyTool) Category() string { return "chat" } +func (t *CodexPatchApplyTool) FilePath() string { return "" } +func (t *CodexPatchApplyTool) ExtractPath() string { return "" } +func (t *CodexPatchApplyTool) Detail() api.Textable { return commandOutputDetail(t.BaseTool) } +func (t *CodexPatchApplyTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "✏️", Iconify: "codicon:edit", Style: "muted"}, "patch", "text-orange-500 font-medium") + if success, ok := t.Input["success"].(bool); ok { + if success { + text = text.Append(" applied", "text-green-600") + } else { + text = text.Append(" failed", "text-red-500") + } + } + if n := mapLen(t.Input["changes"]); n > 0 { + text = text.Append(fmt.Sprintf(" files=%d", n), "text-gray-500") + } + return text +} + +type MCPToolCallTool struct{ BaseTool } + +func (t *MCPToolCallTool) Name() string { return "MCPToolCall" } +func (t *MCPToolCallTool) Category() string { return "chat" } +func (t *MCPToolCallTool) FilePath() string { return "" } +func (t *MCPToolCallTool) ExtractPath() string { return "" } +func (t *MCPToolCallTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *MCPToolCallTool) Pretty() api.Text { + server, tool := invocationName(t.Input["invocation"]) + text := eventText(icons.Package, "mcp", "text-indigo-500 font-medium") + if server != "" || tool != "" { + text = text.Append(" "+strings.Trim(server+"."+tool, "."), "text-muted") + } + if d := durationString(t.Input["duration"]); d != "" { + text = text.Append(" "+d, "text-gray-500") + } + if resultStatus := resultStatus(t.Input["result"]); resultStatus != "" { + text = text.Append(" "+resultStatus, statusColor(resultStatus)) + } + return text +} + +type WebSearchEventTool struct{ BaseTool } + +func (t *WebSearchEventTool) Name() string { return "WebSearchEvent" } +func (t *WebSearchEventTool) Category() string { return "chat" } +func (t *WebSearchEventTool) FilePath() string { return "" } +func (t *WebSearchEventTool) ExtractPath() string { return "" } +func (t *WebSearchEventTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *WebSearchEventTool) Pretty() api.Text { + text := eventText(icons.Search, "web search", "text-purple-500 font-medium") + if query := firstNonEmptyEvent(t.Str("query"), actionQuery(t.Input["action"])); query != "" { + text = text.Append(" "+eventPreview(query, 100), "text-muted") + } + return text +} + +type ViewImageTool struct{ BaseTool } + +func (t *ViewImageTool) Name() string { return "ViewImage" } +func (t *ViewImageTool) Category() string { return "chat" } +func (t *ViewImageTool) FilePath() string { return t.Str("path") } +func (t *ViewImageTool) ExtractPath() string { return t.Str("path") } +func (t *ViewImageTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *ViewImageTool) Pretty() api.Text { + text := eventText(icons.File, "image", "text-cyan-500 font-medium") + if path := t.Str("path"); path != "" { + text = text.Append(" "+ShortenPath(path), "text-muted") + } + return text +} + +type GuardianAssessmentTool struct{ BaseTool } + +func (t *GuardianAssessmentTool) Name() string { return "GuardianAssessment" } +func (t *GuardianAssessmentTool) Category() string { return "chat" } +func (t *GuardianAssessmentTool) FilePath() string { return "" } +func (t *GuardianAssessmentTool) ExtractPath() string { return "" } +func (t *GuardianAssessmentTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *GuardianAssessmentTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "🛡", Iconify: "mdi:shield-check", Style: "muted"}, "guardian", "text-amber-600 font-medium") + if status := t.Str("status"); status != "" { + text = text.Append(" "+status, statusColor(status)) + } + if action := actionSummary(t.Input["action"]); action != "" { + text = text.Append(" "+eventPreview(action, 100), "text-muted") + } + return text +} + +type ReviewModeTool struct{ BaseTool } + +func (t *ReviewModeTool) Name() string { return "ReviewMode" } +func (t *ReviewModeTool) Category() string { return "chat" } +func (t *ReviewModeTool) FilePath() string { return "" } +func (t *ReviewModeTool) ExtractPath() string { return "" } +func (t *ReviewModeTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *ReviewModeTool) Pretty() api.Text { + action := strings.TrimSuffix(strings.TrimPrefix(t.Str("event"), "entered_"), "_mode") + if action == "" { + action = strings.TrimSuffix(strings.TrimPrefix(t.Str("event"), "exited_"), "_mode") + } + text := eventText(icons.Info, "review "+action, "text-purple-500 font-medium") + if n := findingsCount(t.Input["review_output"]); n > 0 { + text = text.Append(fmt.Sprintf(" findings=%d", n), "text-gray-500") + } + return text +} + +type CollabEventTool struct{ BaseTool } + +func (t *CollabEventTool) Name() string { return t.RawTool } +func (t *CollabEventTool) Category() string { return "chat" } +func (t *CollabEventTool) FilePath() string { return "" } +func (t *CollabEventTool) ExtractPath() string { return "" } +func (t *CollabEventTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *CollabEventTool) Pretty() api.Text { + label := strings.TrimPrefix(strings.TrimPrefix(t.Str("event"), "collab_"), "agent_") + label = strings.TrimSuffix(label, "_end") + if label == "" { + label = strings.ToLower(t.RawTool) + } + text := eventText(icons.Icon{Unicode: "🤝", Iconify: "mdi:handshake", Style: "muted"}, "collab "+strings.ReplaceAll(label, "_", " "), "text-indigo-500 font-medium") + if nick := firstNonEmptyEvent(t.Str("nickname"), nestedString(t.Input["receiver"], "nickname")); nick != "" { + text = text.Append(" "+nick, "text-muted") + } + if status := t.Str("status"); status != "" { + text = text.Append(" "+status, statusColor(status)) + } + return text +} + +type QueueOperationTool struct{ BaseTool } + +func (t *QueueOperationTool) Name() string { return "QueueOperation" } +func (t *QueueOperationTool) Category() string { return "chat" } +func (t *QueueOperationTool) FilePath() string { return "" } +func (t *QueueOperationTool) ExtractPath() string { return "" } +func (t *QueueOperationTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *QueueOperationTool) Pretty() api.Text { + op := firstNonEmptyEvent(t.Str("operation"), "queue") + text := eventText(icons.Package, "queue "+op, "text-slate-500 font-medium") + if content := t.Str("content"); content != "" { + text = text.Append(" "+eventPreview(content, 100), "text-gray-500") + } + return text +} + +type DeferredToolsDeltaTool struct{ BaseTool } + +func (t *DeferredToolsDeltaTool) Name() string { return "DeferredToolsDelta" } +func (t *DeferredToolsDeltaTool) Category() string { return "chat" } +func (t *DeferredToolsDeltaTool) FilePath() string { return "" } +func (t *DeferredToolsDeltaTool) ExtractPath() string { return "" } +func (t *DeferredToolsDeltaTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *DeferredToolsDeltaTool) Pretty() api.Text { + text := eventText(icons.Package, "tools", "text-slate-500 font-medium") + appendListCount(&text, "added", t.Input["addedNames"]) + appendListCount(&text, "pending", t.Input["pendingMcpServers"]) + return text +} + +type AgentListingDeltaTool struct{ BaseTool } + +func (t *AgentListingDeltaTool) Name() string { return "AgentListingDelta" } +func (t *AgentListingDeltaTool) Category() string { return "chat" } +func (t *AgentListingDeltaTool) FilePath() string { return "" } +func (t *AgentListingDeltaTool) ExtractPath() string { return "" } +func (t *AgentListingDeltaTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *AgentListingDeltaTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "🤖", Iconify: "mdi:robot", Style: "muted"}, "agents", "text-indigo-500 font-medium") + appendListCount(&text, "added", t.Input["addedTypes"]) + return text +} + +type SkillListingTool struct{ BaseTool } + +func (t *SkillListingTool) Name() string { return "SkillListing" } +func (t *SkillListingTool) Category() string { return "chat" } +func (t *SkillListingTool) FilePath() string { return "" } +func (t *SkillListingTool) ExtractPath() string { return "" } +func (t *SkillListingTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *SkillListingTool) Pretty() api.Text { + text := eventText(icons.Info, "skills", "text-teal-500 font-medium") + appendListCount(&text, "count", t.Input["names"]) + if count := eventInt(t.Input["skillCount"]); count > 0 { + text = text.Append(fmt.Sprintf(" count=%d", count), "text-gray-500") + } + return text +} + +type BudgetTool struct{ BaseTool } + +func (t *BudgetTool) Name() string { return "Budget" } +func (t *BudgetTool) Category() string { return "chat" } +func (t *BudgetTool) FilePath() string { return "" } +func (t *BudgetTool) ExtractPath() string { return "" } +func (t *BudgetTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *BudgetTool) Pretty() api.Text { + text := eventText(icons.Icon{Unicode: "$", Iconify: "mdi:cash", Style: "muted"}, "budget", "text-green-600 font-medium") + if used := eventFloat(t.Input["used"]); used > 0 { + text = text.Append(fmt.Sprintf(" used=$%.2f", used), "text-muted") + } + if total := eventFloat(t.Input["total"]); total > 0 { + text = text.Append(fmt.Sprintf(" total=$%.2f", total), "text-gray-500") + } + if remaining := eventFloat(t.Input["remaining"]); remaining > 0 { + text = text.Append(fmt.Sprintf(" remaining=$%.2f", remaining), "text-gray-500") + } + return text +} + +type PrLinkTool struct{ BaseTool } + +func (t *PrLinkTool) Name() string { return "PrLink" } +func (t *PrLinkTool) Category() string { return "chat" } +func (t *PrLinkTool) FilePath() string { return "" } +func (t *PrLinkTool) ExtractPath() string { return "" } +func (t *PrLinkTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *PrLinkTool) Pretty() api.Text { + text := eventText(icons.Git, "pr", "text-cyan-600 font-medium") + if n := eventInt(t.Input["prNumber"]); n > 0 { + text = text.Append(fmt.Sprintf(" #%d", n), "text-muted") + } + if repo := t.Str("prRepository"); repo != "" { + text = text.Append(" "+repo, "text-gray-500") + } + return text +} + +type ContentEventTool struct{ BaseTool } + +func (t *ContentEventTool) Name() string { return t.RawTool } +func (t *ContentEventTool) Category() string { return "chat" } +func (t *ContentEventTool) FilePath() string { return "" } +func (t *ContentEventTool) ExtractPath() string { return "" } +func (t *ContentEventTool) Detail() api.Textable { + if d := t.BaseTool.Detail(); d != nil { + return d + } + if c := strings.TrimSpace(t.Str("content")); c != "" { + text := clicky.Text("").Append(c, "") + return &text + } + return nil +} +func (t *ContentEventTool) Pretty() api.Text { + label := strings.TrimSpace(t.Str("event")) + if label == "" { + label = strings.ToLower(t.RawTool) + } + text := eventText(icons.Info, strings.ReplaceAll(label, "_", " "), "text-slate-500 font-medium") + if content := t.Str("content"); content != "" { + text = text.Append(" "+eventPreview(content, 100), "text-gray-500") + } + return text +} + +type WorktreeStateTool struct{ BaseTool } + +func (t *WorktreeStateTool) Name() string { return "WorktreeState" } +func (t *WorktreeStateTool) Category() string { return "chat" } +func (t *WorktreeStateTool) FilePath() string { + return nestedString(t.Input["worktreeSession"], "worktreePath") +} +func (t *WorktreeStateTool) ExtractPath() string { return t.FilePath() } +func (t *WorktreeStateTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *WorktreeStateTool) Pretty() api.Text { + text := eventText(icons.Git, "worktree", "text-green-600 font-medium") + if name := nestedString(t.Input["worktreeSession"], "worktreeName"); name != "" { + text = text.Append(" "+name, "text-muted") + } + if branch := nestedString(t.Input["worktreeSession"], "worktreeBranch"); branch != "" { + text = text.Append(" "+branch, "text-gray-500") + } + return text +} + +type RelocatedTool struct{ BaseTool } + +func (t *RelocatedTool) Name() string { return "Relocated" } +func (t *RelocatedTool) Category() string { return "chat" } +func (t *RelocatedTool) FilePath() string { return t.Str("relocatedCwd") } +func (t *RelocatedTool) ExtractPath() string { return t.Str("relocatedCwd") } +func (t *RelocatedTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *RelocatedTool) Pretty() api.Text { + text := eventText(icons.Folder, "relocated", "text-cyan-600 font-medium") + if cwd := t.Str("relocatedCwd"); cwd != "" { + text = text.Append(" "+ShortenPath(cwd), "text-muted") + } + return text +} + +type StartedTool struct{ BaseTool } + +func (t *StartedTool) Name() string { return "Started" } +func (t *StartedTool) Category() string { return "chat" } +func (t *StartedTool) FilePath() string { return "" } +func (t *StartedTool) ExtractPath() string { return "" } +func (t *StartedTool) Detail() api.Textable { return t.BaseTool.Detail() } +func (t *StartedTool) Pretty() api.Text { + return eventText(icons.Play, "started", "text-green-600 font-medium") +} diff --git a/pkg/claude/tools/muted_styles_test.go b/pkg/claude/tools/muted_styles_test.go new file mode 100644 index 0000000..a370080 --- /dev/null +++ b/pkg/claude/tools/muted_styles_test.go @@ -0,0 +1,113 @@ +package tools + +import ( + "strings" + "testing" + + "github.com/flanksource/clicky/api" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMutedStyles(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Muted Pretty Styles Suite") +} + +var _ = Describe("semantic muted pretty styles", func() { + DescribeTable("renders secondary session text with the semantic muted style", + func(render func() api.Textable, content string) { + text := render() + Expect(text).NotTo(BeNil()) + Expect(textStyleForContent(text, content)).To(ContainElement("text-muted")) + Expect(text.HTML()).NotTo(MatchRegexp(`text-gray-(600|700)`)) + Expect(text.String()).To(ContainSubstring(strings.TrimSpace(content))) + }, + Entry("assistant body", func() api.Textable { + return (&AssistantTool{BaseTool: BaseTool{Input: map[string]any{"text": "assistant payload"}}}).Pretty() + }, " assistant payload"), + Entry("reasoning body", func() api.Textable { + return (&ReasoningTool{BaseTool: BaseTool{Input: map[string]any{"text": "reasoning payload"}}}).Pretty() + }, " reasoning payload"), + Entry("session model", func() api.Textable { + return (&SystemInitTool{BaseTool: BaseTool{Input: map[string]any{"model": "model-x"}}}).Pretty() + }, " model-x"), + Entry("hook stdout label", func() api.Textable { + return (&HookResponseTool{BaseTool: BaseTool{Input: map[string]any{"stdout": "hook output"}}}).Detail() + }, "stdout: "), + Entry("parse error raw label", func() api.Textable { + return (&ParseErrorTool{BaseTool: BaseTool{Input: map[string]any{"raw": "raw payload"}}}).Detail() + }, "raw: "), + Entry("turn label", func() api.Textable { + return (&TurnDurationTool{}).Pretty() + }, " turn"), + Entry("away summary label", func() api.Textable { + return (&AwaySummaryTool{}).Pretty() + }, " away-summary"), + Entry("session title", func() api.Textable { + return (&SessionTitleTool{BaseTool: BaseTool{Input: map[string]any{"aiTitle": "session title"}}}).Pretty() + }, " session title"), + Entry("token total", func() api.Textable { + return (&TokenCountTool{BaseTool: BaseTool{Input: map[string]any{"total_tokens": 12}}}).Pretty() + }, " 12"), + Entry("Codex command", func() api.Textable { + return (&CodexExecCommandTool{BaseTool: BaseTool{Input: map[string]any{"command": "command --flag"}}}).Pretty() + }, " command --flag"), + Entry("user shell command", func() api.Textable { + return (&UserShellCommandTool{BaseTool: BaseTool{Input: map[string]any{"command": "local --flag"}}}).Pretty() + }, " local --flag"), + Entry("MCP invocation", func() api.Textable { + return (&MCPToolCallTool{BaseTool: BaseTool{Input: map[string]any{"invocation": map[string]any{"server": "server", "tool": "tool"}}}}).Pretty() + }, " server.tool"), + Entry("web search query", func() api.Textable { + return (&WebSearchEventTool{BaseTool: BaseTool{Input: map[string]any{"query": "search terms"}}}).Pretty() + }, " search terms"), + Entry("image path", func() api.Textable { + return (&ViewImageTool{BaseTool: BaseTool{Input: map[string]any{"path": "image.png"}}}).Pretty() + }, " image.png"), + Entry("guardian action", func() api.Textable { + return (&GuardianAssessmentTool{BaseTool: BaseTool{Input: map[string]any{"action": map[string]any{"tool": "tool-name"}}}}).Pretty() + }, " tool-name"), + Entry("collaboration nickname", func() api.Textable { + return (&CollabEventTool{BaseTool: BaseTool{Input: map[string]any{"nickname": "worker"}}}).Pretty() + }, " worker"), + Entry("budget used", func() api.Textable { + return (&BudgetTool{BaseTool: BaseTool{Input: map[string]any{"used": 1.25}}}).Pretty() + }, " used=$1.25"), + Entry("pull request number", func() api.Textable { + return (&PrLinkTool{BaseTool: BaseTool{Input: map[string]any{"prNumber": 42}}}).Pretty() + }, " #42"), + Entry("worktree name", func() api.Textable { + return (&WorktreeStateTool{BaseTool: BaseTool{Input: map[string]any{"worktreeSession": map[string]any{"worktreeName": "feature-name"}}}}).Pretty() + }, " feature-name"), + Entry("relocated directory", func() api.Textable { + return (&RelocatedTool{BaseTool: BaseTool{Input: map[string]any{"relocatedCwd": "repo"}}}).Pretty() + }, " repo"), + Entry("command stdout label", func() api.Textable { + return (&CodexExecCommandTool{BaseTool: BaseTool{Input: map[string]any{"stdout": "command output"}}}).Detail() + }, "stdout: "), + ) +}) + +func textStyleForContent(value api.Textable, content string) []string { + switch text := value.(type) { + case api.Text: + return findTextStyle(text, content) + case *api.Text: + return findTextStyle(*text, content) + default: + return nil + } +} + +func findTextStyle(text api.Text, content string) []string { + if text.Content == content { + return strings.Fields(text.Style) + } + for _, child := range text.Children { + if style := textStyleForContent(child, content); style != nil { + return style + } + } + return nil +} diff --git a/pkg/claude/tools/stream.go b/pkg/claude/tools/stream.go index cbcc105..26028c9 100644 --- a/pkg/claude/tools/stream.go +++ b/pkg/claude/tools/stream.go @@ -22,7 +22,7 @@ func (t *SystemInitTool) Pretty() api.Text { Add(icons.Icon{Unicode: "🚀", Iconify: "mdi:rocket-launch", Style: "muted"}). Append(" init", "text-purple-500 font-medium") if model := t.Str("model"); model != "" { - text = text.Append(" "+model, "text-gray-700") + text = text.Append(" "+model, "text-muted") } if cwd := t.Str("cwd"); cwd != "" { text = text.Append(" cwd="+ShortenPath(cwd), "text-gray-500") @@ -86,7 +86,7 @@ func (t *HookResponseTool) Detail() api.Textable { } text := clicky.Text("") if stdout != "" { - text = text.Append("stdout: ", "font-bold text-gray-600").Append(stdout, "") + text = text.Append("stdout: ", "font-bold text-muted").Append(stdout, "") } if stderr != "" { if stdout != "" { @@ -215,7 +215,7 @@ func (t *ParseErrorTool) Detail() api.Textable { if raw == "" { return t.BaseTool.Detail() } - text := clicky.Text("").Append("raw: ", "font-bold text-gray-600").Append(raw, "") + text := clicky.Text("").Append("raw: ", "font-bold text-muted").Append(raw, "") return &text } @@ -261,7 +261,7 @@ func (t *TurnDurationTool) Category() string { return "system" } func (t *TurnDurationTool) Pretty() api.Text { text := clicky.Text(""). Add(icons.Icon{Unicode: "⏱", Iconify: "mdi:timer-outline", Style: "muted"}). - Append(" turn", "text-gray-700 font-medium") + Append(" turn", "text-muted font-medium") if ms := t.Float("durationMs"); ms > 0 { text = text.Append(" "+formatDurationMS(ms), "text-gray-500") } @@ -281,7 +281,7 @@ func (t *AwaySummaryTool) Category() string { return "system" } func (t *AwaySummaryTool) Pretty() api.Text { text := clicky.Text(""). Add(icons.Icon{Unicode: "💤", Iconify: "mdi:sleep", Style: "muted"}). - Append(" away-summary", "text-gray-700 font-medium") + Append(" away-summary", "text-muted font-medium") if content := strings.TrimSpace(t.Str("content")); content != "" { preview := content if len(preview) > 80 { @@ -313,7 +313,7 @@ func (t *SessionTitleTool) Pretty() api.Text { Add(icons.Icon{Unicode: "🏷", Iconify: "mdi:tag", Style: "muted"}). Append(" title", "text-purple-500 font-medium") if title := t.Str("aiTitle"); title != "" { - text = text.Append(" "+title, "text-gray-700") + text = text.Append(" "+title, "text-muted") } return text } diff --git a/pkg/claude/tools/stream_test.go b/pkg/claude/tools/stream_test.go index f7e00be..eb360ef 100644 --- a/pkg/claude/tools/stream_test.go +++ b/pkg/claude/tools/stream_test.go @@ -80,7 +80,7 @@ func TestNewTool_DispatchSyntheticTypes(t *testing.T) { "SessionInit", "HookStart", "HookResponse", "Result", "TokenCount", "TaskStarted", "TaskComplete", "TurnAborted", "ContextCompacted", "ThreadRolledBack", "ItemCompleted", - "CodexExecCommand", "CodexPatchApply", "MCPToolCall", + "CodexExecCommand", "UserShellCommand", "CodexPatchApply", "MCPToolCall", "WebSearchEvent", "ViewImage", "GuardianAssessment", "ReviewMode", "CollabAgentSpawn", "CollabAgentInteraction", "CollabWaiting", "CollabClose", "QueueOperation", "DeferredToolsDelta", @@ -92,3 +92,27 @@ func TestNewTool_DispatchSyntheticTypes(t *testing.T) { assert.Equal(t, name, got.Name(), "expected NewTool to return the right concrete type for %q", name) } } + +func TestUserShellCommandTool_PrettyAndDetail(t *testing.T) { + tool := NewTool(BaseTool{ + RawTool: "UserShellCommand", + Input: map[string]any{ + "command": "gavel proc restart", + "exit_code": 1, + "duration_ms": 2990.9, + "stdout": "Kill sent but port 8088 is still bound", + }, + }) + if _, ok := tool.(*UserShellCommandTool); !ok { + t.Fatalf("NewTool returned %T, want *UserShellCommandTool", tool) + } + pretty := tool.Pretty().String() + assert.Contains(t, pretty, "local command") + assert.Contains(t, pretty, "gavel proc restart") + assert.Contains(t, pretty, "exit=1") + assert.Contains(t, pretty, "3.0s") + detail := tool.Detail() + if assert.NotNil(t, detail) { + assert.Contains(t, detail.String(), "Kill sent but port 8088 is still bound") + } +} diff --git a/pkg/claude/tools/tool.go b/pkg/claude/tools/tool.go index 2465552..dcd03ba 100644 --- a/pkg/claude/tools/tool.go +++ b/pkg/claude/tools/tool.go @@ -257,6 +257,8 @@ func NewTool(base BaseTool) Tool { return &EventTool{BaseTool: base} case "CodexExecCommand": return &CodexExecCommandTool{BaseTool: base} + case "UserShellCommand": + return &UserShellCommandTool{BaseTool: base} case "CodexPatchApply": return &CodexPatchApplyTool{BaseTool: base} case "MCPToolCall": From cd255af6da83c6cd27de460b741c8411c34d3055 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 12:10:07 +0300 Subject: [PATCH 045/131] feat(ai): Add support for user shell commands and wait tool in Codex history --- pkg/ai/history/codex_parser.go | 178 +++++++++++++++++++++++- pkg/ai/history/codex_parser_test.go | 159 +++++++++++++++++++++ pkg/ai/history/codex_types.go | 23 +-- pkg/ai/provider/claudeagent/provider.go | 27 +++- pkg/ai/provider/coalesce.go | 19 ++- pkg/ai/provider/coalesce_test.go | 36 +++++ pkg/ai/provider/codex_appserver.go | 6 + pkg/ai/provider/codex_appserver_test.go | 57 +++++++- pkg/ai/provider/codex_cli.go | 2 +- 9 files changed, 483 insertions(+), 24 deletions(-) diff --git a/pkg/ai/history/codex_parser.go b/pkg/ai/history/codex_parser.go index f2a6c3d..b3b3f2c 100644 --- a/pkg/ai/history/codex_parser.go +++ b/pkg/ai/history/codex_parser.go @@ -6,10 +6,12 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "time" "github.com/flanksource/captain/pkg/ai/assistanttags" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/claude/tools" "github.com/segmentio/encoding/json" ) @@ -158,6 +160,21 @@ func ReadCodexSessionInfo(sessionFile string) (*CodexSessionInfo, error) { return info, nil } +// IsCodexAutoReviewSession reports whether a rollout belongs to Codex's +// internal approval reviewer. Match parsed model metadata only: ordinary user +// prompts and transcripts may legitimately mention the model name. +func IsCodexAutoReviewSession(sessionFile string) (bool, error) { + info, err := ReadCodexSessionInfo(sessionFile) + if err != nil || info == nil { + return false, err + } + return IsCodexAutoReviewModel(info.Model), nil +} + +func IsCodexAutoReviewModel(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), api.CodexAutoReviewModel) +} + func ExtractCodexToolUsesFromReader(r io.Reader) ([]ToolUse, error) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) @@ -210,6 +227,9 @@ func ExtractCodexToolUsesFromReader(r io.Reader) ([]ToolUse, error) { } if event.Payload.Model != "" { currentModel = event.Payload.Model + if IsCodexAutoReviewModel(currentModel) { + return nil, nil + } } if event.Payload.Effort != "" { currentEff = event.Payload.Effort @@ -314,6 +334,9 @@ func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cw return extractCodexAssistantText(text, event, cwd, sessionID, "response_item.message") case "user": text = codexContentText(event.Payload.Content, "input_text", "text") + if shell, ok := parseCodexUserShellCommand(text); ok { + return []ToolUse{buildCodexUserShellCommandUse(shell, event, cwd, sessionID, "response_item.message.user_shell_command")} + } var ok bool if tool, ok = codexUserMessageTool(text); !ok { return nil @@ -437,6 +460,9 @@ func extractEventMsg(event CodexEvent, cwd, sessionID string) []ToolUse { if text == "" { return nil } + if shell, ok := parseCodexUserShellCommand(text); ok { + return []ToolUse{buildCodexUserShellCommandUse(shell, event, cwd, sessionID, "event_msg.user_shell_command")} + } tool, ok := codexUserMessageTool(text) if !ok { return nil @@ -559,6 +585,113 @@ func codexContentText(content []CodexContent, accepted ...string) string { return text } +type codexUserShellCommand struct { + Command string + ExitCode int + DurationSeconds float64 + Output string +} + +func parseCodexUserShellCommand(text string) (codexUserShellCommand, bool) { + const ( + wrapperOpen = "" + wrapperClose = "" + ) + + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, wrapperOpen) || !strings.HasSuffix(text, wrapperClose) { + return codexUserShellCommand{}, false + } + body := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(text, wrapperOpen), wrapperClose)) + command, ok := codexWrappedSection(body, "command") + if !ok || strings.TrimSpace(command) == "" { + return codexUserShellCommand{}, false + } + result, ok := codexWrappedSection(body, "result") + if !ok { + return codexUserShellCommand{}, false + } + + lines := strings.Split(strings.TrimSpace(result), "\n") + if len(lines) < 3 { + return codexUserShellCommand{}, false + } + exitText, ok := strings.CutPrefix(strings.TrimSuffix(lines[0], "\r"), "Exit code:") + if !ok { + return codexUserShellCommand{}, false + } + exitCode, err := strconv.Atoi(strings.TrimSpace(exitText)) + if err != nil { + return codexUserShellCommand{}, false + } + durationText, ok := strings.CutPrefix(strings.TrimSuffix(lines[1], "\r"), "Duration:") + if !ok { + return codexUserShellCommand{}, false + } + durationText = strings.TrimSpace(durationText) + durationText, ok = strings.CutSuffix(durationText, " seconds") + if !ok { + return codexUserShellCommand{}, false + } + durationSeconds, err := strconv.ParseFloat(strings.TrimSpace(durationText), 64) + if err != nil { + return codexUserShellCommand{}, false + } + if strings.TrimSuffix(lines[2], "\r") != "Output:" { + return codexUserShellCommand{}, false + } + + return codexUserShellCommand{ + Command: strings.TrimSpace(command), + ExitCode: exitCode, + DurationSeconds: durationSeconds, + Output: strings.TrimSpace(strings.Join(lines[3:], "\n")), + }, true +} + +func codexWrappedSection(text, name string) (string, bool) { + open := "<" + name + ">" + close := "" + start := strings.Index(text, open) + if start < 0 { + return "", false + } + start += len(open) + end := strings.Index(text[start:], close) + if end < 0 { + return "", false + } + return text[start : start+end], true +} + +func buildCodexUserShellCommandUse(cmd codexUserShellCommand, event CodexEvent, cwd, sessionID, recordType string) ToolUse { + status := "success" + if cmd.ExitCode != 0 { + status = "failed" + } + input := map[string]any{ + "event": "user_shell_command", + "command": cmd.Command, + "exit_code": cmd.ExitCode, + "duration_seconds": cmd.DurationSeconds, + "duration_ms": cmd.DurationSeconds * 1000, + "output": cmd.Output, + "stdout": cmd.Output, + "status": status, + } + return ToolUse{ + Tool: "UserShellCommand", + Input: input, + Timestamp: event.Time(), + CWD: cwd, + SessionID: sessionID, + TurnID: codexEventTurnID(event), + Source: "codex", + Response: cmd.Output, + RecordType: recordType, + } +} + func isCodexInternalUserText(text string) bool { text = strings.TrimSpace(text) return strings.HasPrefix(text, "") || @@ -788,10 +921,7 @@ func codexRecordPriority(recordType string) int { } func buildToolUse(callEvent, outputEvent CodexEvent, cwd, sessionID string) ToolUse { - var response string - if outputEvent.Payload.Output != "" { - response = extractCommandOutput(outputEvent.Payload.Output) - } + response := extractCommandOutput(CodexOutputText(outputEvent.Payload.Output)) ts := callEvent.Time() if ts == nil { ts = outputEvent.Time() @@ -828,6 +958,20 @@ func buildToolUse(callEvent, outputEvent CodexEvent, cwd, sessionID string) Tool Response: response, Namespace: callEvent.Payload.Namespace, } + case "wait": + return ToolUse{ + Tool: "Wait", + Input: extractArgumentsMap(callEvent.Payload.Arguments), + Timestamp: ts, + CWD: cwd, + SessionID: sessionID, + TurnID: firstNonEmpty(codexEventTurnID(callEvent), codexEventTurnID(outputEvent)), + ToolUseID: callEvent.Payload.CallID, + Source: "codex", + Response: response, + Namespace: callEvent.Payload.Namespace, + RecordType: "response_item.function_call", + } case "spawn_agent": args := extractArgumentsMap(callEvent.Payload.Arguments) agentType, _ := args["agent_type"].(string) @@ -950,6 +1094,32 @@ func normalizeCodexArguments(argsJSON json.RawMessage) string { return string(argsJSON) } +// CodexOutputText normalizes the scalar-string and ordered content-block forms +// Codex uses for function_call_output records. Provider streaming and history +// ingestion share it so the same output is rendered in both paths. +func CodexOutputText(raw json.RawMessage) string { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return "" + } + + var scalar string + if json.Unmarshal(raw, &scalar) == nil { + return scalar + } + + var content []CodexContent + if json.Unmarshal(raw, &content) == nil { + if combined := codexContentText(content, "input_text", "output_text", "text"); combined != "" { + return combined + } + } + + // Preserve unfamiliar non-null shapes instead of dropping the completion. + // RawMessage already contains compact JSON from the transcript. + return text +} + func extractCommandOutput(raw string) string { if _, after, ok := strings.Cut(raw, "Output:\n"); ok { return after diff --git a/pkg/ai/history/codex_parser_test.go b/pkg/ai/history/codex_parser_test.go index f2a039f..c1d3566 100644 --- a/pkg/ai/history/codex_parser_test.go +++ b/pkg/ai/history/codex_parser_test.go @@ -2,8 +2,12 @@ package history import ( "os" + "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestExtractCodexToolUses_LiveDottedSchema(t *testing.T) { @@ -88,6 +92,141 @@ func TestExtractCodexToolUses_ModelAndEffortStamping(t *testing.T) { } } +func TestExtractCodexToolUses_IgnoresAutoReviewSession(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-13T09:00:00Z","type":"session_meta","payload":{"id":"review-1","cwd":"/repo","source":{"subagent":{"other":"guardian"}}}}`, + `{"timestamp":"2026-07-13T09:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review this command"}]}}`, + `{"timestamp":"2026-07-13T09:00:02Z","type":"turn_context","payload":{"turn_id":"turn-review","model":"codex-auto-review","effort":"low"}}`, + `{"timestamp":"2026-07-13T09:00:03Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"approved"}]}}`, + }, "\n") + + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + require.NoError(t, err) + assert.Empty(t, uses) +} + +func TestExtractCodexToolUses_DoesNotIgnoreMentionOfAutoReview(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-13T09:00:00Z","type":"session_meta","payload":{"id":"user-1","cwd":"/repo"}}`, + `{"timestamp":"2026-07-13T09:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Ignore codex-auto-review sessions"}]}}`, + `{"timestamp":"2026-07-13T09:00:02Z","type":"turn_context","payload":{"turn_id":"turn-user","model":"gpt-5.6-sol","effort":"high"}}`, + }, "\n") + + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + require.NoError(t, err) + require.Len(t, uses, 1) + assert.Equal(t, "User", uses[0].Tool) +} + +func TestIsCodexAutoReviewSession(t *testing.T) { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + stream := strings.Join([]string{ + `{"timestamp":"2026-07-13T09:00:00Z","type":"session_meta","payload":{"id":"review-1","cwd":"/repo"}}`, + `{"timestamp":"2026-07-13T09:00:01Z","type":"turn_context","payload":{"model":"codex-auto-review","effort":"low"}}`, + }, "\n") + require.NoError(t, os.WriteFile(path, []byte(stream), 0o600)) + + ignored, err := IsCodexAutoReviewSession(path) + require.NoError(t, err) + assert.True(t, ignored) +} + +func TestExtractCodexToolUses_ParsesWaitCallWithContentOutput(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-13T09:31:13.359Z","type":"session_meta","payload":{"id":"019f5a68-c638-7fe2-b0d0-c1d8a3c4de55","cwd":"/repo"}}`, + `{"timestamp":"2026-07-13T09:31:13.362Z","type":"turn_context","payload":{"turn_id":"019f5ad0-fc23-7b92-8b13-a9f50d903704","model":"gpt-5.6-sol","effort":"max"}}`, + `{"timestamp":"2026-07-13T09:41:33.611Z","type":"response_item","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"214\",\"yield_time_ms\":20000,\"max_tokens\":5000}","call_id":"call-wait"}}`, + `{"timestamp":"2026-07-13T09:41:41.017Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-wait","output":[{"type":"input_text","text":"Script completed\nWall time 7.4 seconds\nOutput:\n"},{"type":"input_text","text":"evaluation failed\n"},{"type":"input_text","text":"exit=1"}]}}`, + }, "\n") + + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + if err != nil { + t.Fatalf("ExtractCodexToolUsesFromReader: %v", err) + } + if len(uses) != 1 { + t.Fatalf("uses = %+v, want one Wait", uses) + } + use := uses[0] + if use.Tool != "Wait" || use.ToolUseID != "call-wait" { + t.Fatalf("use = %+v, want first-class Wait", use) + } + if use.Input["cell_id"] != "214" || use.Input["yield_time_ms"] != float64(20000) || use.Input["max_tokens"] != float64(5000) { + t.Fatalf("input = %#v, want structured wait arguments", use.Input) + } + if use.Response != "evaluation failed\nexit=1" { + t.Fatalf("response = %q", use.Response) + } + if use.TurnID != "019f5ad0-fc23-7b92-8b13-a9f50d903704" || use.Model != "gpt-5.6-sol" || use.ReasoningEffort != "max" { + t.Fatalf("metadata = %+v", use) + } + if use.RecordType != "response_item.function_call" { + t.Fatalf("record type = %q", use.RecordType) + } +} + +func TestExtractCodexToolUses_ParsesUserShellCommandMessage(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-13T06:13:59Z","type":"session_meta","payload":{"id":"sess-shell","cwd":"/repo"}}`, + `{"timestamp":"2026-07-13T06:14:00Z","type":"turn_context","payload":{"turn_id":"turn-shell","model":"gpt-5.5-codex","effort":"high"}}`, + `{"timestamp":"2026-07-13T06:14:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n\ngavel proc restart\n\n\nExit code: 1\nDuration: 2.9909 seconds\nOutput:\n\u001b[36mdev | \u001b[0msignal: killed\n\u001b[36mdev | \u001b[0mKill sent but port 8088 is still bound\n\n"}]}}`, + }, "\n") + + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + if err != nil { + t.Fatalf("ExtractCodexToolUsesFromReader: %v", err) + } + if len(uses) != 1 { + t.Fatalf("uses = %+v, want exactly one UserShellCommand", uses) + } + use := uses[0] + if use.Tool != "UserShellCommand" || use.Input["event"] != "user_shell_command" { + t.Fatalf("use = %+v, want UserShellCommand event", use) + } + if use.Input["command"] != "gavel proc restart" || use.Input["exit_code"] != 1 || use.Input["status"] != "failed" { + t.Fatalf("command result = %+v", use.Input) + } + if use.Input["duration_seconds"] != 2.9909 || use.Input["duration_ms"] != 2990.9 { + t.Fatalf("duration = %v seconds / %v ms", use.Input["duration_seconds"], use.Input["duration_ms"]) + } + output, _ := use.Input["output"].(string) + wantOutput := "\x1b[36mdev | \x1b[0msignal: killed\n\x1b[36mdev | \x1b[0mKill sent but port 8088 is still bound" + if output != wantOutput { + t.Fatalf("output = %q, want exact preserved output %q", output, wantOutput) + } + if use.Response != output || use.TurnID != "turn-shell" || use.Model != "gpt-5.5-codex" || use.ReasoningEffort != "high" { + t.Fatalf("metadata/response = %+v", use) + } + if use.RecordType != "response_item.message.user_shell_command" { + t.Fatalf("record type = %q", use.RecordType) + } +} + +func TestExtractCodexToolUses_ParsesUserShellCommandWithEmptyOutput(t *testing.T) { + stream := `{"timestamp":"2026-07-13T06:14:01Z","type":"event_msg","payload":{"type":"user_message","message":"\n\nopen file.pdf\n\n\nExit code: 0\nDuration: 0.25 seconds\nOutput:\n\n"}}` + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + if err != nil { + t.Fatalf("ExtractCodexToolUsesFromReader: %v", err) + } + if len(uses) != 1 || uses[0].Tool != "UserShellCommand" { + t.Fatalf("uses = %+v, want one UserShellCommand", uses) + } + if uses[0].Input["output"] != "" || uses[0].Input["status"] != "success" || uses[0].RecordType != "event_msg.user_shell_command" { + t.Fatalf("use = %+v", uses[0]) + } +} + +func TestParseCodexUserShellCommandRejectsPartialWrapper(t *testing.T) { + for _, text := range []string{ + "please parse if you see one", + "echo hi", + "prefix echo hiExit code: 0\nDuration: 1 seconds\nOutput:\n", + } { + if _, ok := parseCodexUserShellCommand(text); ok { + t.Fatalf("parseCodexUserShellCommand(%q) unexpectedly succeeded", text) + } + } +} + func TestExtractCodexToolUses_MapsCodexControlCalls(t *testing.T) { stream := strings.Join([]string{ `{"timestamp":"2026-06-01T10:00:00Z","type":"session_meta","payload":{"id":"sess-1","cwd":"/p"}}`, @@ -282,6 +421,26 @@ func TestReadCodexSessionInfo_LiveThreadStarted(t *testing.T) { } } +func TestCodexSessionMetaAcceptsObjectSource(t *testing.T) { + stream := strings.Join([]string{ + `{"timestamp":"2026-06-19T07:05:51.061Z","type":"session_meta","payload":{"id":"019edeb3-449e-7af3-b300-7123f10944b2","cwd":"/repo","source":{"subagent":{"other":"guardian"}},"model_provider":"openai"}}`, + `{"timestamp":"2026-06-19T07:05:52.061Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"check the change"}]}}`, + }, "\n") + + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + require.NoError(t, err) + require.Len(t, uses, 1) + assert.Equal(t, "019edeb3-449e-7af3-b300-7123f10944b2", uses[0].SessionID) + + dir := t.TempDir() + path := filepath.Join(dir, "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(stream), 0o600)) + info, err := ReadCodexSessionInfo(path) + require.NoError(t, err) + require.NotNil(t, info) + assert.Equal(t, "019edeb3-449e-7af3-b300-7123f10944b2", info.ID) +} + 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"}}`, diff --git a/pkg/ai/history/codex_types.go b/pkg/ai/history/codex_types.go index 590d463..1702fe2 100644 --- a/pkg/ai/history/codex_types.go +++ b/pkg/ai/history/codex_types.go @@ -55,13 +55,17 @@ type CodexPayload struct { Raw map[string]any `json:"-"` // session_meta - ID string `json:"id,omitempty"` - CWD string `json:"cwd,omitempty"` - CLIVersion string `json:"cli_version,omitempty"` - Source string `json:"source,omitempty"` - ModelProvider string `json:"model_provider,omitempty"` - Originator string `json:"originator,omitempty"` - Git *CodexGitMeta `json:"git,omitempty"` + ID string `json:"id,omitempty"` + CWD string `json:"cwd,omitempty"` + CLIVersion string `json:"cli_version,omitempty"` + // Source has appeared as both a scalar (for root sessions) and an object + // (for example {"subagent":{"other":"guardian"}}). Keep the provider + // shape opaque so a new source variant cannot invalidate the whole + // session_meta record and discard its ID. + Source json.RawMessage `json:"source,omitempty"` + ModelProvider string `json:"model_provider,omitempty"` + Originator string `json:"originator,omitempty"` + Git *CodexGitMeta `json:"git,omitempty"` // response_item: function_call Name string `json:"name,omitempty"` @@ -70,8 +74,9 @@ type CodexPayload struct { CallID string `json:"call_id,omitempty"` Metadata *CodexMetadata `json:"internal_chat_message_metadata_passthrough,omitempty"` - // response_item: function_call_output - Output string `json:"output,omitempty"` + // response_item: function_call_output. Codex emits either a JSON string or + // an ordered array of text content blocks depending on the tool transport. + Output json.RawMessage `json:"output,omitempty"` Tools []CodexToolSearchNamespace `json:"tools,omitempty"` // response_item: reasoning carries an array of CodexReasoningSummary; diff --git a/pkg/ai/provider/claudeagent/provider.go b/pkg/ai/provider/claudeagent/provider.go index 5c63dd5..32008ce 100644 --- a/pkg/ai/provider/claudeagent/provider.go +++ b/pkg/ai/provider/claudeagent/provider.go @@ -161,8 +161,18 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e success = true sawResult bool lastErr string + outcome *ai.TerminalOutcome + outcomeErr error ) for ev := range events { + if outcomeErr == nil { + parsed, parseErr := ai.TerminalOutcomeFromEvent(ev) + if parseErr != nil { + outcomeErr = parseErr + } else if parsed != nil { + outcome = parsed + } + } switch ev.Kind { case ai.EventText: text.WriteString(ev.Text) @@ -189,6 +199,9 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e lastErr = ev.Error } } + if outcomeErr != nil { + return nil, fmt.Errorf("claude-agent: invalid terminal outcome: %w", outcomeErr) + } if !sawResult && lastErr != "" { return nil, fmt.Errorf("%w: %s", ai.ErrCLIExecutionFailed, lastErr) @@ -198,15 +211,19 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e } resp := &ai.Response{ - Text: text.String(), - Model: p.model, - Backend: ai.BackendClaudeAgent, - Usage: usage, - Duration: time.Since(start), + Text: text.String(), + Model: p.model, + Backend: ai.BackendClaudeAgent, + Usage: usage, + Duration: time.Since(start), + TerminalOutcome: outcome, } if sessionID != "" { resp.Raw = map[string]any{"session_id": sessionID} } + if outcome != nil { + return resp, nil + } if req.Prompt.Schema != nil { if err := ai.BindStructuredOutput(req.Prompt.Schema, structured); err != nil { return nil, err diff --git a/pkg/ai/provider/coalesce.go b/pkg/ai/provider/coalesce.go index 18e4302..1f99d38 100644 --- a/pkg/ai/provider/coalesce.go +++ b/pkg/ai/provider/coalesce.go @@ -24,13 +24,30 @@ func CoalesceStreamForBackend(ctx context.Context, backend ai.Backend, model str lastResult *ai.Event errEvents []ai.Event sessionID string + outcome *ai.TerminalOutcome + outcomeErr error ) for { select { case ev, ok := <-events: if !ok { - return finaliseCoalescedResponse(backend, model, text.String(), usage, lastResult, errEvents, sessionID, start) + if outcomeErr != nil { + return nil, fmt.Errorf("invalid terminal outcome: %w", outcomeErr) + } + resp, err := finaliseCoalescedResponse(backend, model, text.String(), usage, lastResult, errEvents, sessionID, start) + if resp != nil { + resp.TerminalOutcome = outcome + } + return resp, err + } + if outcomeErr == nil { + parsed, err := ai.TerminalOutcomeFromEvent(ev) + if err != nil { + outcomeErr = err + } else if parsed != nil { + outcome = parsed + } } switch ev.Kind { case ai.EventText: diff --git a/pkg/ai/provider/coalesce_test.go b/pkg/ai/provider/coalesce_test.go index d6eacde..8bded16 100644 --- a/pkg/ai/provider/coalesce_test.go +++ b/pkg/ai/provider/coalesce_test.go @@ -66,6 +66,42 @@ func TestCoalesceStream_CarriesStructuredData(t *testing.T) { } } +func TestCoalesceStream_CarriesTerminalOutcome(t *testing.T) { + events := []ai.Event{ + {Kind: ai.EventToolUse, Tool: "ExitPlanMode", Input: map[string]any{ + "plan": "1. Inspect\n2. Implement", + "planFilePath": "/repo/.claude/plans/example.md", + }}, + {Kind: ai.EventResult, Success: true}, + } + + resp, err := CoalesceStream(context.Background(), "claude", feedEvents(events), time.Now()) + if err != nil { + t.Fatalf("CoalesceStream err: %v", err) + } + if resp.TerminalOutcome == nil || resp.TerminalOutcome.Plan == nil { + t.Fatalf("TerminalOutcome = %+v, want plan", resp.TerminalOutcome) + } + if resp.TerminalOutcome.Plan.Content != "1. Inspect\n2. Implement" { + t.Errorf("plan content = %q", resp.TerminalOutcome.Plan.Content) + } +} + +func TestCoalesceStream_FailsOnMalformedTerminalOutcome(t *testing.T) { + events := []ai.Event{ + {Kind: ai.EventToolUse, Tool: "ExitPlanMode", Input: map[string]any{"planFilePath": "/repo/plan.md"}}, + {Kind: ai.EventResult, Success: true}, + } + + resp, err := CoalesceStream(context.Background(), "claude", feedEvents(events), time.Now()) + if err == nil { + t.Fatalf("CoalesceStream error = nil, response = %+v", resp) + } + if !strings.Contains(err.Error(), "plan is required") { + t.Fatalf("CoalesceStream error = %v, want missing plan", err) + } +} + func TestCoalesceStream_UsesResultTextWhenNoTextEvents(t *testing.T) { const model = "claude-sonnet-5" events := []ai.Event{ diff --git a/pkg/ai/provider/codex_appserver.go b/pkg/ai/provider/codex_appserver.go index a63858a..becc254 100644 --- a/pkg/ai/provider/codex_appserver.go +++ b/pkg/ai/provider/codex_appserver.go @@ -310,12 +310,18 @@ func (c *CodexAppServer) handleNotification(method string, params json.RawMessag if id := parseAppServerNotif(params).ItemID; id != "" { ts.streamed[id] = true } + if len(ts.outputSchema) > 0 { + return + } case "item/completed": // The completed agent message carries the full text (structured runs use it // as the validated JSON result). Capture it, then drop the duplicate so // CoalesceStream / renderers don't double-count already-streamed deltas. if it := parseAppServerNotif(params).Item; it != nil && it.Type == "agentMessage" { ts.lastAgentMessage = it.Text + if len(ts.outputSchema) > 0 { + return + } } if appServerStreamedAgentMessage(params, ts.streamed) { return diff --git a/pkg/ai/provider/codex_appserver_test.go b/pkg/ai/provider/codex_appserver_test.go index 7398145..a6ca0bb 100644 --- a/pkg/ai/provider/codex_appserver_test.go +++ b/pkg/ai/provider/codex_appserver_test.go @@ -217,7 +217,7 @@ func resultEvent(t *testing.T, evs []ai.Event) ai.Event { return ai.Event{} } -// A structured turn attaches the final agentMessage JSON to the terminal result. +// A structured turn attaches the final agentMessage JSON only to the terminal result. func TestHandleNotification_StructuredOutput(t *testing.T) { schema, err := ai.SchemaJSONFor(api.Prompt{Schema: &struct { Answer string `json:"answer"` @@ -225,18 +225,67 @@ func TestHandleNotification_StructuredOutput(t *testing.T) { require.NoError(t, err) c, ts := activeTurn(t, schema) - // The final agent message's text IS the validated JSON (codex has no separate - // structured field). + c.handleNotification("item/agentMessage/delta", + json.RawMessage(`{"itemId":"a1","delta":"{\"answer\":"}`)) + c.handleNotification("item/agentMessage/delta", + json.RawMessage(`{"itemId":"a1","delta":"\"42\"}"}`)) c.handleNotification("item/completed", json.RawMessage(`{"item":{"id":"a1","type":"agentMessage","text":"{\"answer\":\"42\"}"}}`)) c.handleNotification("turn/completed", json.RawMessage(`{"threadId":"t","turn":{"id":"u","status":"completed"}}`)) - result := resultEvent(t, drainEvents(ts)) + events := drainEvents(ts) + var resultCount int + for _, ev := range events { + assert.NotEqual(t, ai.EventText, ev.Kind, "structured agent messages must not emit text progress") + if ev.Kind == ai.EventResult { + resultCount++ + } + } + assert.Equal(t, 1, resultCount, "structured turn should emit exactly one terminal result") + result := resultEvent(t, events) + assert.True(t, result.Success) require.NotEmpty(t, result.StructuredData, "structured turn should carry the final JSON on the result") assert.JSONEq(t, `{"answer":"42"}`, string(result.StructuredData)) } +func TestHandleNotification_StructuredOutputWithoutDeltas(t *testing.T) { + schema := json.RawMessage(`{"type":"object"}`) + c, ts := activeTurn(t, schema) + c.handleNotification("item/completed", + json.RawMessage(`{"item":{"id":"a1","type":"agentMessage","text":"{\"answer\":\"42\"}"}}`)) + c.handleNotification("turn/completed", + json.RawMessage(`{"threadId":"t","turn":{"id":"u","status":"completed"}}`)) + + events := drainEvents(ts) + for _, ev := range events { + assert.NotEqual(t, ai.EventText, ev.Kind, "completed structured message must not leak as text progress") + } + assert.JSONEq(t, `{"answer":"42"}`, string(resultEvent(t, events).StructuredData)) +} + +func TestHandleNotification_TextModeStreamsDeltasAndDeduplicatesCompletedMessage(t *testing.T) { + c, ts := activeTurn(t, nil) + c.handleNotification("item/agentMessage/delta", + json.RawMessage(`{"itemId":"a1","delta":"plain "}`)) + c.handleNotification("item/agentMessage/delta", + json.RawMessage(`{"itemId":"a1","delta":"answer"}`)) + c.handleNotification("item/completed", + json.RawMessage(`{"item":{"id":"a1","type":"agentMessage","text":"plain answer"}}`)) + c.handleNotification("turn/completed", + json.RawMessage(`{"threadId":"t","turn":{"id":"u","status":"completed"}}`)) + + events := drainEvents(ts) + var text []string + for _, ev := range events { + if ev.Kind == ai.EventText { + text = append(text, ev.Text) + } + } + assert.Equal(t, []string{"plain ", "answer"}, text) + assert.Empty(t, resultEvent(t, events).StructuredData) +} + // A text-mode turn (no schema) leaves the result's StructuredData empty. func TestHandleNotification_NoStructuredWithoutSchema(t *testing.T) { c, ts := activeTurn(t, nil) diff --git a/pkg/ai/provider/codex_cli.go b/pkg/ai/provider/codex_cli.go index ff372d4..10cae2d 100644 --- a/pkg/ai/provider/codex_cli.go +++ b/pkg/ai/provider/codex_cli.go @@ -336,7 +336,7 @@ func (s *codexCLIState) buildFunctionToolUse(call, output history.CodexEvent) cl } tu := codexToolUse(name, input, s.sessionID, s.model) tu.ToolUseID = call.Payload.CallID - tu.Response = output.Payload.Output + tu.Response = history.CodexOutputText(output.Payload.Output) return tu } From a783418d9c5edeab5a7112af94e6aa4fe58259c4 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 12:10:27 +0300 Subject: [PATCH 046/131] feat: Add terminal outcome support for native plan and question tools --- pkg/ai/agent.go | 32 ++-- pkg/ai/agent/runner.go | 20 ++- pkg/ai/agent/runner_test.go | 41 +++++ pkg/ai/agent_test.go | 19 +- .../internal/gen-model-registry/patches.json | 6 +- pkg/ai/middleware/validation.go | 18 +- pkg/ai/model_effort_test.go | 4 +- pkg/ai/model_registry.json | 3 + pkg/ai/runtime_selector_test.go | 21 ++- pkg/ai/schema.go | 10 +- pkg/ai/schema_test.go | 5 +- pkg/ai/schema_validation.go | 32 ++++ pkg/ai/schema_validation_test.go | 25 +++ pkg/ai/terminal_outcome.go | 169 ++++++++++++++++++ pkg/ai/terminal_outcome_test.go | 87 +++++++++ pkg/ai/types.go | 23 ++- 16 files changed, 456 insertions(+), 59 deletions(-) create mode 100644 pkg/ai/schema_validation.go create mode 100644 pkg/ai/schema_validation_test.go create mode 100644 pkg/ai/terminal_outcome.go create mode 100644 pkg/ai/terminal_outcome_test.go diff --git a/pkg/ai/agent.go b/pkg/ai/agent.go index 6b62e00..cf5d781 100644 --- a/pkg/ai/agent.go +++ b/pkg/ai/agent.go @@ -49,14 +49,15 @@ type PromptRequest struct { // PromptResponse is the result of one PromptRequest. type PromptResponse struct { - Request PromptRequest `json:"request,omitempty"` - Result string `json:"result"` - StructuredData any `json:"structured_data,omitempty"` - Costs Costs `json:"costs,omitempty"` - Model string `json:"model,omitempty"` - Error string `json:"error,omitempty"` - Duration time.Duration `json:"duration,omitempty"` - CacheHit bool `json:"cache_hit,omitempty"` + Request PromptRequest `json:"request,omitempty"` + Result string `json:"result"` + StructuredData any `json:"structured_data,omitempty"` + TerminalOutcome *TerminalOutcome `json:"terminal_outcome,omitempty"` + Costs Costs `json:"costs,omitempty"` + Model string `json:"model,omitempty"` + Error string `json:"error,omitempty"` + Duration time.Duration `json:"duration,omitempty"` + CacheHit bool `json:"cache_hit,omitempty"` } // IsOK reports whether the prompt succeeded. @@ -100,13 +101,14 @@ func (a *Agent) ExecutePrompt(ctx context.Context, req PromptRequest) (*PromptRe cost := a.accrue(resp) return &PromptResponse{ - Request: req, - Result: resp.Text, - StructuredData: resp.StructuredData, - Costs: Costs{cost}, - Model: resp.Model, - Duration: time.Since(start), - CacheHit: resp.CacheHit, + Request: req, + Result: resp.Text, + StructuredData: resp.StructuredData, + TerminalOutcome: resp.TerminalOutcome, + Costs: Costs{cost}, + Model: resp.Model, + Duration: time.Since(start), + CacheHit: resp.CacheHit, }, nil } diff --git a/pkg/ai/agent/runner.go b/pkg/ai/agent/runner.go index be503d9..aee89af 100644 --- a/pkg/ai/agent/runner.go +++ b/pkg/ai/agent/runner.go @@ -242,7 +242,7 @@ func (r *Runner[T]) runLoop(ctx context.Context, hc *HookContext, result *Result maxIter = 1 } req := r.Request - var verifyErr error + var responseErr, verifyErr error loop, loopErr := ai.RunUntil(ctx, ai.LoopOptions{ Provider: r.Provider, @@ -255,7 +255,10 @@ func (r *Runner[T]) runLoop(ctx context.Context, hc *HookContext, result *Result }, BuildRequest: func(iter int, prev *ai.LoopIteration) (ai.Request, bool) { if prev != nil { - r.updateResponse(hc, prev) + if err := r.updateResponse(hc, prev); err != nil { + responseErr = err + return ai.Request{}, false + } verdicts, retry, allValid, err := r.verify(hc) if err != nil { verifyErr = err @@ -281,6 +284,9 @@ func (r *Runner[T]) runLoop(ctx context.Context, hc *HookContext, result *Result if loopErr != nil { return loopErr } + if responseErr != nil { + return responseErr + } return verifyErr } @@ -321,13 +327,20 @@ func (r *Runner[T]) verify(hc *HookContext) (verdicts []VerifyResult, retry *ai. // updateResponse folds one completed iteration into the accumulating response: // its assembled text, structured data, usage, and session id. -func (r *Runner[T]) updateResponse(hc *HookContext, prev *ai.LoopIteration) { +func (r *Runner[T]) updateResponse(hc *HookContext, prev *ai.LoopIteration) error { ws := hc.Workspace() if prev.SessionID != "" { ws.SessionID = prev.SessionID } var text strings.Builder for _, ev := range prev.Events { + outcome, err := ai.TerminalOutcomeFromEvent(ev) + if err != nil { + return fmt.Errorf("agent: invalid terminal outcome: %w", err) + } + if outcome != nil { + hc.Response.TerminalOutcome = outcome + } switch ev.Kind { case ai.EventText: text.WriteString(ev.Text) @@ -339,6 +352,7 @@ func (r *Runner[T]) updateResponse(hc *HookContext, prev *ai.LoopIteration) { } hc.Response.Text = text.String() hc.Response.Usage = prev.Usage + return nil } // recordEvent updates the workspace from one streamed event: session id and the diff --git a/pkg/ai/agent/runner_test.go b/pkg/ai/agent/runner_test.go index 623864d..9404bf9 100644 --- a/pkg/ai/agent/runner_test.go +++ b/pkg/ai/agent/runner_test.go @@ -58,6 +58,47 @@ func TestRunner_CapturesSessionAndChangedFiles(t *testing.T) { assert.Equal(t, 1, prov.calls, "no verify hooks ⇒ exactly one iteration") } +func TestRunner_CapturesNativePlanOutcome(t *testing.T) { + prov := &fakeProvider{events: func(int) []ai.Event { + return []ai.Event{ + {Kind: ai.EventToolUse, Tool: "ExitPlanMode", Input: map[string]any{ + "plan": "1. Inspect\n2. Implement", + "planFilePath": "/repo/.claude/plans/example.md", + }}, + {Kind: ai.EventResult, Success: true}, + } + }} + + result, err := (&Runner[string]{ + Provider: prov, + Request: ai.Request{Prompt: api.Prompt{User: "plan"}}, + }).Run(context.Background()) + + require.NoError(t, err) + require.NotNil(t, result.Response.TerminalOutcome) + assert.Equal(t, ai.TerminalOutcomePlan, result.Response.TerminalOutcome.Kind) + require.NotNil(t, result.Response.TerminalOutcome.Plan) + assert.Equal(t, "1. Inspect\n2. Implement", result.Response.TerminalOutcome.Plan.Content) + assert.Equal(t, "/repo/.claude/plans/example.md", result.Response.TerminalOutcome.Plan.Path) +} + +func TestRunner_FailsOnMalformedNativePlanOutcome(t *testing.T) { + prov := &fakeProvider{events: func(int) []ai.Event { + return []ai.Event{ + {Kind: ai.EventToolUse, Tool: "ExitPlanMode", Input: map[string]any{"planFilePath": "/repo/plan.md"}}, + {Kind: ai.EventResult, Success: true}, + } + }} + + _, err := (&Runner[string]{ + Provider: prov, + Request: ai.Request{Prompt: api.Prompt{User: "plan"}}, + }).Run(context.Background()) + + require.ErrorContains(t, err, "ExitPlanMode") + require.ErrorContains(t, err, "plan is required") +} + func TestRunner_VerifyDrivesRerunThenStops(t *testing.T) { prov := &fakeProvider{events: func(int) []ai.Event { return []ai.Event{{Kind: ai.EventResult, Success: true}} diff --git a/pkg/ai/agent_test.go b/pkg/ai/agent_test.go index efb252d..b94ea42 100644 --- a/pkg/ai/agent_test.go +++ b/pkg/ai/agent_test.go @@ -17,6 +17,7 @@ type mockProvider struct { err error closed bool lastReq Request + outcome *TerminalOutcome } func (m *mockProvider) GetModel() string { return m.model } @@ -28,13 +29,23 @@ func (m *mockProvider) Execute(_ context.Context, req Request) (*Response, error return nil, m.err } return &Response{ - Text: m.text + ":" + req.Prompt.User, - Model: m.model, - Backend: BackendAnthropic, - Usage: Usage{InputTokens: 10, OutputTokens: 5}, + Text: m.text + ":" + req.Prompt.User, + Model: m.model, + Backend: BackendAnthropic, + Usage: Usage{InputTokens: 10, OutputTokens: 5}, + TerminalOutcome: m.outcome, }, nil } +func TestAgent_ExecutePromptCarriesTerminalOutcome(t *testing.T) { + outcome := &TerminalOutcome{Kind: TerminalOutcomePlan, Plan: &TerminalPlan{Content: "1. Inspect"}} + a := NewAgentWithProvider(&mockProvider{model: "m", outcome: outcome}, Config{Model: api.Model{Name: "m"}}) + + resp, err := a.ExecutePrompt(context.Background(), PromptRequest{Name: "plan", Prompt: "plan this"}) + require.NoError(t, err) + assert.Same(t, outcome, resp.TerminalOutcome) +} + func TestAgent_ExecutePromptAccruesCost(t *testing.T) { a := NewAgentWithProvider(&mockProvider{model: "test-model", text: "out"}, Config{Model: api.Model{Name: "test-model"}}) diff --git a/pkg/ai/internal/gen-model-registry/patches.json b/pkg/ai/internal/gen-model-registry/patches.json index 0ae589b..5ffbee0 100644 --- a/pkg/ai/internal/gen-model-registry/patches.json +++ b/pkg/ai/internal/gen-model-registry/patches.json @@ -10,19 +10,19 @@ }, "gpt-5.6-sol": { "preferred": true, - "availability": ["codex"], + "availability": ["api", "codex"], "supportedEfforts": ["low", "medium", "high", "xhigh", "max", "ultra"], "priority": 1 }, "gpt-5.6-terra": { "preferred": true, - "availability": ["codex"], + "availability": ["api", "codex"], "supportedEfforts": ["low", "medium", "high", "xhigh", "max", "ultra"], "priority": 2 }, "gpt-5.6-luna": { "preferred": true, - "availability": ["codex"], + "availability": ["api", "codex"], "priority": 3 }, "gpt-5.5": { "preferred": true }, diff --git a/pkg/ai/middleware/validation.go b/pkg/ai/middleware/validation.go index 409e6e0..a88e6a1 100644 --- a/pkg/ai/middleware/validation.go +++ b/pkg/ai/middleware/validation.go @@ -9,7 +9,6 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" - "github.com/xeipuuv/gojsonschema" ) // maxSchemaRetries bounds the number of fix-up re-asks the retry strictness makes @@ -228,20 +227,5 @@ func responseJSON(resp *ai.Response) string { // returns a human-readable joined error string (empty when the response conforms), // plus a hard error only when the schema or document could not be loaded/parsed. func validateResponse(schema json.RawMessage, resp *ai.Response) (string, error) { - doc := responseJSON(resp) - if strings.TrimSpace(doc) == "" { - return "response carried no JSON to validate", nil - } - result, err := gojsonschema.Validate(gojsonschema.NewBytesLoader(schema), gojsonschema.NewStringLoader(doc)) - if err != nil { - return "", fmt.Errorf("%w: validation could not run: %v", ai.ErrSchemaValidation, err) - } - if result.Valid() { - return "", nil - } - msgs := make([]string, 0, len(result.Errors())) - for _, e := range result.Errors() { - msgs = append(msgs, e.String()) - } - return strings.Join(msgs, "; "), nil + return ai.ValidateStructuredJSON(schema, responseJSON(resp)) } diff --git a/pkg/ai/model_effort_test.go b/pkg/ai/model_effort_test.go index e5bdaa0..38608c9 100644 --- a/pkg/ai/model_effort_test.go +++ b/pkg/ai/model_effort_test.go @@ -49,7 +49,7 @@ func TestRegistryGPT56Availability(t *testing.T) { if _, ok := RegistryModelDef(BackendCodexAgent, "sol"); !ok { t.Fatal("Sol should be available to Codex") } - if _, ok := RegistryModelDef(BackendOpenAI, "sol"); ok { - t.Fatal("Sol should not be available to OpenAI") + if _, ok := RegistryModelDef(BackendOpenAI, "sol"); !ok { + t.Fatal("Sol should be available to OpenAI") } } diff --git a/pkg/ai/model_registry.json b/pkg/ai/model_registry.json index 53456bd..d870d57 100644 --- a/pkg/ai/model_registry.json +++ b/pkg/ai/model_registry.json @@ -196,6 +196,7 @@ "contextWindow": 1050000, "preferred": true, "availability": [ + "api", "codex" ], "supportedEfforts": [ @@ -218,6 +219,7 @@ "contextWindow": 1050000, "preferred": true, "availability": [ + "api", "codex" ], "supportedEfforts": [ @@ -241,6 +243,7 @@ "contextWindow": 1050000, "preferred": true, "availability": [ + "api", "codex" ], "supportedEfforts": [ diff --git a/pkg/ai/runtime_selector_test.go b/pkg/ai/runtime_selector_test.go index aa14e35..08de4ea 100644 --- a/pkg/ai/runtime_selector_test.go +++ b/pkg/ai/runtime_selector_test.go @@ -230,7 +230,7 @@ func TestResolveRuntimeSelectors_WildcardRespectsAvailability(t *testing.T) { t.Fatalf("unexpected model: %+v", model) } } - want := []api.Backend{api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux} + want := []api.Backend{api.BackendOpenAI, api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux} if !reflect.DeepEqual(backends, want) { t.Fatalf("backends = %v, want %v", backends, want) } @@ -240,7 +240,22 @@ func TestResolveModelSelectors_EffortErrors(t *testing.T) { if _, err := ResolveModelSelectors(api.Model{Name: "agent:sol:extreme"}); err == nil { t.Fatal("expected invalid effort suffix error") } - if _, err := ResolveModelSelectors(api.Model{Name: "openai:sol:high"}); err == nil { - t.Fatal("expected Codex-only Sol to be rejected by OpenAI backend") +} + +func TestResolveModelSelectors_OpenAI56VariantsAvailableViaAPI(t *testing.T) { + for alias, want := range map[string]string{ + "luna": "gpt-5.6-luna", + "sol": "gpt-5.6-sol", + "terra": "gpt-5.6-terra", + } { + t.Run(alias, func(t *testing.T) { + got, err := ResolveModelSelectors(api.Model{Name: "api:" + alias}) + if err != nil { + t.Fatalf("ResolveModelSelectors(api:%s): %v", alias, err) + } + if got.Backend != api.BackendOpenAI || got.Name != want { + t.Fatalf("got %s/%s, want openai/%s", got.Backend, got.Name, want) + } + }) } } diff --git a/pkg/ai/schema.go b/pkg/ai/schema.go index 63c549d..aab200c 100644 --- a/pkg/ai/schema.go +++ b/pkg/ai/schema.go @@ -283,6 +283,7 @@ func normalizeOpenAISchema(v any, path string) error { } var openAIRefAnnotationKeywords = map[string]bool{ + "$id": true, "$comment": true, "default": true, "deprecated": true, @@ -296,9 +297,10 @@ var openAIRefAnnotationKeywords = map[string]bool{ // normalizeOpenAIRef makes referenced nodes conform to OpenAI's strict schema // subset. A nested $ref must stand alone; invopop/jsonschema commonly attaches // field annotations such as description alongside it, which OpenAI rejects. -// Root schema metadata and definitions are retained so the reference remains -// resolvable, while unexpected semantic siblings fail loudly rather than being -// silently discarded. +// Root schema dialect metadata and definitions are retained so the reference +// remains resolvable. $id is stripped even at the root because OpenAI rejects +// it as a $ref sibling. Unexpected semantic siblings fail loudly rather than +// being silently discarded. func normalizeOpenAIRef(node map[string]any, path string) (bool, error) { rawRef, hasRef := node["$ref"] if !hasRef { @@ -317,7 +319,7 @@ func normalizeOpenAIRef(node map[string]any, path string) (bool, error) { delete(node, key) continue } - if path == "$" && (key == "$schema" || key == "$id" || key == "$defs" || key == "definitions") { + if path == "$" && (key == "$schema" || key == "$defs" || key == "definitions") { continue } return false, fmt.Errorf("openai schema transform: %s.$ref has unsupported sibling %q", path, key) diff --git a/pkg/ai/schema_test.go b/pkg/ai/schema_test.go index e324cf7..1283569 100644 --- a/pkg/ai/schema_test.go +++ b/pkg/ai/schema_test.go @@ -213,9 +213,12 @@ func TestOpenAICompatibleSchema_RemovesAnnotationsFromReferences(t *testing.T) { } root := decodeSchemaObject(t, got) - if root["$ref"] != "#/$defs/PlanEnvelope" || root["$schema"] == nil || root["$id"] == nil || root["$defs"] == nil { + if root["$ref"] != "#/$defs/PlanEnvelope" || root["$schema"] == nil || root["$defs"] == nil { t.Fatalf("root reference metadata was not preserved: %#v", root) } + if _, exists := root["$id"]; exists { + t.Fatalf("root reference retained unsupported $id sibling: %#v", root) + } defs := root["$defs"].(map[string]any) planEnvelope := defs["PlanEnvelope"].(map[string]any) plan := planEnvelope["properties"].(map[string]any)["plan"].(map[string]any) diff --git a/pkg/ai/schema_validation.go b/pkg/ai/schema_validation.go new file mode 100644 index 0000000..d482e02 --- /dev/null +++ b/pkg/ai/schema_validation.go @@ -0,0 +1,32 @@ +package ai + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/xeipuuv/gojsonschema" +) + +// ValidateStructuredJSON returns joined schema violations, or a hard error when +// the schema or JSON document cannot be evaluated. +func ValidateStructuredJSON(schema json.RawMessage, document string) (string, error) { + if len(schema) == 0 { + return "", fmt.Errorf("%w: schema is required", ErrSchemaValidation) + } + if strings.TrimSpace(document) == "" { + return "response carried no JSON to validate", nil + } + result, err := gojsonschema.Validate(gojsonschema.NewBytesLoader(schema), gojsonschema.NewStringLoader(document)) + if err != nil { + return "", fmt.Errorf("%w: validation could not run: %v", ErrSchemaValidation, err) + } + if result.Valid() { + return "", nil + } + messages := make([]string, 0, len(result.Errors())) + for _, validationErr := range result.Errors() { + messages = append(messages, validationErr.String()) + } + return strings.Join(messages, "; "), nil +} diff --git a/pkg/ai/schema_validation_test.go b/pkg/ai/schema_validation_test.go new file mode 100644 index 0000000..1e26f41 --- /dev/null +++ b/pkg/ai/schema_validation_test.go @@ -0,0 +1,25 @@ +package ai + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateStructuredJSON(t *testing.T) { + schema := json.RawMessage(`{"type":"object","required":["pass"],"properties":{"pass":{"type":"boolean"}}}`) + + violations, err := ValidateStructuredJSON(schema, `{"pass":true}`) + require.NoError(t, err) + assert.Empty(t, violations) + + violations, err = ValidateStructuredJSON(schema, `{"pass":"yes"}`) + require.NoError(t, err) + assert.Contains(t, violations, "boolean") + + violations, err = ValidateStructuredJSON(schema, "") + require.NoError(t, err) + assert.Equal(t, "response carried no JSON to validate", violations) +} diff --git a/pkg/ai/terminal_outcome.go b/pkg/ai/terminal_outcome.go new file mode 100644 index 0000000..53961de --- /dev/null +++ b/pkg/ai/terminal_outcome.go @@ -0,0 +1,169 @@ +package ai + +import ( + "fmt" + "strings" +) + +// TerminalOutcomeFromEvent normalizes supported native terminal tool events. +func TerminalOutcomeFromEvent(event Event) (*TerminalOutcome, error) { + if event.Kind != EventToolUse { + return nil, nil + } + switch event.Tool { + case "ExitPlanMode": + return terminalPlanFromInput(event.Input) + case "AskUserQuestion": + return terminalQuestionsFromInput(event.Input) + default: + return nil, nil + } +} + +func terminalPlanFromInput(input map[string]any) (*TerminalOutcome, error) { + content, err := requiredString(input, "plan") + if err != nil { + return nil, fmt.Errorf("ExitPlanMode: %w", err) + } + path, err := optionalString(input, "planFilePath") + if err != nil { + return nil, fmt.Errorf("ExitPlanMode: %w", err) + } + outcome := &TerminalOutcome{ + Kind: TerminalOutcomePlan, + Plan: &TerminalPlan{Content: content, Path: path}, + } + return outcome, outcome.Validate() +} + +func terminalQuestionsFromInput(input map[string]any) (*TerminalOutcome, error) { + raw, ok := input["questions"] + if !ok { + question, err := terminalQuestionFromMap(input) + if err != nil { + return nil, fmt.Errorf("AskUserQuestion: %w", err) + } + outcome := &TerminalOutcome{Kind: TerminalOutcomeQuestions, Questions: []TerminalQuestion{question}} + return outcome, outcome.Validate() + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("AskUserQuestion: questions must be an array, got %T", raw) + } + questions := make([]TerminalQuestion, 0, len(items)) + for i, item := range items { + values, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("AskUserQuestion: question %d must be an object, got %T", i+1, item) + } + question, err := terminalQuestionFromMap(values) + if err != nil { + return nil, fmt.Errorf("AskUserQuestion: question %d: %w", i+1, err) + } + questions = append(questions, question) + } + outcome := &TerminalOutcome{Kind: TerminalOutcomeQuestions, Questions: questions} + return outcome, outcome.Validate() +} + +func terminalQuestionFromMap(values map[string]any) (TerminalQuestion, error) { + text, err := firstRequiredString(values, "question", "prompt", "text") + if err != nil { + return TerminalQuestion{}, err + } + context, err := firstOptionalString(values, "context", "header") + if err != nil { + return TerminalQuestion{}, err + } + options, err := terminalQuestionOptions(values["options"]) + if err != nil { + return TerminalQuestion{}, err + } + return TerminalQuestion{Text: text, Context: context, Options: options}, nil +} + +func terminalQuestionOptions(raw any) ([]string, error) { + if raw == nil { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("options must be an array, got %T", raw) + } + options := make([]string, 0, len(items)) + for i, item := range items { + var option string + var err error + switch value := item.(type) { + case string: + option = strings.TrimSpace(value) + case map[string]any: + option, err = firstRequiredString(value, "label", "value") + default: + err = fmt.Errorf("must be a string or object, got %T", item) + } + if err != nil || option == "" { + if err == nil { + err = fmt.Errorf("must not be empty") + } + return nil, fmt.Errorf("option %d %w", i+1, err) + } + options = append(options, option) + } + return options, nil +} + +func requiredString(values map[string]any, key string) (string, error) { + value, ok := values[key] + if !ok { + return "", fmt.Errorf("%s is required", key) + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string, got %T", key, value) + } + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("%s is required", key) + } + return text, nil +} + +func optionalString(values map[string]any, key string) (string, error) { + value, ok := values[key] + if !ok || value == nil { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string, got %T", key, value) + } + return text, nil +} + +func firstRequiredString(values map[string]any, keys ...string) (string, error) { + text, err := firstOptionalString(values, keys...) + if err != nil { + return "", err + } + if text == "" { + return "", fmt.Errorf("one of %s is required", strings.Join(keys, ", ")) + } + return text, nil +} + +func firstOptionalString(values map[string]any, keys ...string) (string, error) { + for _, key := range keys { + value, ok := values[key] + if !ok || value == nil { + continue + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string, got %T", key, value) + } + if strings.TrimSpace(text) != "" { + return text, nil + } + } + return "", nil +} diff --git a/pkg/ai/terminal_outcome_test.go b/pkg/ai/terminal_outcome_test.go new file mode 100644 index 0000000..a360445 --- /dev/null +++ b/pkg/ai/terminal_outcome_test.go @@ -0,0 +1,87 @@ +package ai + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTerminalOutcomeFromEventPlan(t *testing.T) { + outcome, err := TerminalOutcomeFromEvent(Event{ + Kind: EventToolUse, + Tool: "ExitPlanMode", + Input: map[string]any{ + "plan": "1. Inspect the seam\n2. Implement it", + "planFilePath": "/repo/.claude/plans/example.md", + }, + }) + + require.NoError(t, err) + require.NotNil(t, outcome) + assert.Equal(t, TerminalOutcomePlan, outcome.Kind) + require.NotNil(t, outcome.Plan) + assert.Equal(t, "1. Inspect the seam\n2. Implement it", outcome.Plan.Content) + assert.Equal(t, "/repo/.claude/plans/example.md", outcome.Plan.Path) +} + +func TestTerminalOutcomeFromEventQuestions(t *testing.T) { + outcome, err := TerminalOutcomeFromEvent(Event{ + Kind: EventToolUse, + Tool: "AskUserQuestion", + Input: map[string]any{"questions": []any{ + map[string]any{ + "question": "Which database?", + "header": "Storage", + "options": []any{ + map[string]any{"label": "PostgreSQL", "description": "Production database"}, + "SQLite", + }, + }, + }}, + }) + + require.NoError(t, err) + require.NotNil(t, outcome) + assert.Equal(t, TerminalOutcomeQuestions, outcome.Kind) + require.Equal(t, []TerminalQuestion{{ + Text: "Which database?", + Context: "Storage", + Options: []string{"PostgreSQL", "SQLite"}, + }}, outcome.Questions) +} + +func TestTerminalOutcomeFromEventFailsOnMalformedNativeOutcome(t *testing.T) { + tests := []struct { + name string + event Event + want string + }{ + { + name: "missing plan", + event: Event{Kind: EventToolUse, Tool: "ExitPlanMode", Input: map[string]any{"planFilePath": "/repo/plan.md"}}, + want: "plan is required", + }, + { + name: "invalid option", + event: Event{Kind: EventToolUse, Tool: "AskUserQuestion", Input: map[string]any{ + "questions": []any{map[string]any{"question": "Continue?", "options": []any{42}}}, + }}, + want: "option 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outcome, err := TerminalOutcomeFromEvent(tt.event) + assert.Nil(t, outcome) + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestTerminalOutcomeFromEventIgnoresOrdinaryTools(t *testing.T) { + outcome, err := TerminalOutcomeFromEvent(Event{Kind: EventToolUse, Tool: "Read", Input: map[string]any{"file_path": "README.md"}}) + require.NoError(t, err) + assert.Nil(t, outcome) +} diff --git a/pkg/ai/types.go b/pkg/ai/types.go index 67ab6a9..3713ae4 100644 --- a/pkg/ai/types.go +++ b/pkg/ai/types.go @@ -18,13 +18,22 @@ type Request = api.Spec // 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 + PermissionFunc = api.PermissionFunc + PermissionRequest = api.PermissionRequest + PermissionDecision = api.PermissionDecision + Response = api.Response + TerminalOutcome = api.TerminalOutcome + TerminalOutcomeKind = api.TerminalOutcomeKind + TerminalPlan = api.TerminalPlan + TerminalQuestion = api.TerminalQuestion + EventKind = api.EventKind + Event = api.Event + Config = api.Config +) + +const ( + TerminalOutcomePlan = api.TerminalOutcomePlan + TerminalOutcomeQuestions = api.TerminalOutcomeQuestions ) const ( From c236705d6a9dea1ed4699491bdf76bb1a62ae5d3 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 12:10:42 +0300 Subject: [PATCH 047/131] feat: Add database URL flag, terminal outcomes, and transcript filtering --- cmd/captain/main.go | 1 + migrations/10_sessions.pg.hcl | 7 ++ pkg/api/model.go | 4 + pkg/api/runtime_event.go | 17 +-- pkg/api/terminal_outcome.go | 63 +++++++++++ pkg/api/terminal_outcome_test.go | 55 +++++++++ pkg/cli/ai.go | 3 + pkg/cli/ai_test.go | 46 ++++++++ pkg/cli/database.go | 65 ++++++++++- pkg/cli/database_test.go | 43 +++++++ pkg/cli/prompt_run_events.go | 4 + pkg/cli/prompt_run_events_test.go | 16 +++ pkg/cli/serve.go | 17 ++- pkg/cli/serve_test.go | 32 ++++-- pkg/cli/webapp/src/SessionBrowser.tsx | 50 ++++++-- pkg/cli/webapp/src/sessionData.ts | 23 +++- pkg/monitor/backfill.go | 38 ++++++- pkg/monitor/backfill_test.go | 71 ++++++++++++ pkg/monitor/ingest.go | 28 ++++- pkg/monitor/ingest_test.go | 20 ++++ pkg/monitor/monitor.go | 144 ++++++++++++++++++++---- pkg/monitor/monitor_integration_test.go | 10 +- pkg/monitor/monitor_test.go | 42 +++++++ pkg/monitor/oneshot.go | 4 +- pkg/monitor/process.go | 34 ++++-- pkg/session/build_codex.go | 57 ++++++++-- pkg/session/build_codex_test.go | 102 +++++++++++++++++ pkg/session/pretty.go | 109 +----------------- pkg/session/pretty_helpers.go | 115 +++++++++++++++++++ pkg/session/pretty_muted_test.go | 32 ++++++ pkg/session/session.go | 25 ++-- pkg/session/turns.go | 35 ++++-- pkg/session/turns_test.go | 54 +++++++++ 33 files changed, 1143 insertions(+), 223 deletions(-) create mode 100644 pkg/api/terminal_outcome.go create mode 100644 pkg/api/terminal_outcome_test.go create mode 100644 pkg/monitor/backfill_test.go create mode 100644 pkg/monitor/ingest_test.go create mode 100644 pkg/session/pretty_helpers.go create mode 100644 pkg/session/pretty_muted_test.go create mode 100644 pkg/session/turns_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 5223503..d64938b 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -35,6 +35,7 @@ func main() { } clicky.BindAllFlags(rootCmd.PersistentFlags(), "format") + cli.BindDatabaseURLFlag(rootCmd.PersistentFlags()) // Bind commons' -P/--properties flag so per-subsystem log levels and HTTP // wire logging can be toggled, e.g. -Plog.level.http=trace3. properties.BindFlags(rootCmd.PersistentFlags()) diff --git a/migrations/10_sessions.pg.hcl b/migrations/10_sessions.pg.hcl index 344f6ee..b075062 100644 --- a/migrations/10_sessions.pg.hcl +++ b/migrations/10_sessions.pg.hcl @@ -152,6 +152,13 @@ table "captain_sessions" { columns = [column.source, column.provider, column.host_id, column.provider_session_id] where = "provider_session_id IS NOT NULL" } + index "captain_sessions_provider_session_id_idx" { + on { + column = column.provider_session_id + ops = "text_pattern_ops" + } + where = "provider_session_id IS NOT NULL" + } index "captain_sessions_parent_session_id_idx" { columns = [column.parent_session_id] } diff --git a/pkg/api/model.go b/pkg/api/model.go index c0d1487..753fd64 100644 --- a/pkg/api/model.go +++ b/pkg/api/model.go @@ -7,6 +7,10 @@ import ( "github.com/flanksource/captain/pkg/collections" ) +// CodexAutoReviewModel is the internal model used by Codex approval reviewers. +// Its transcripts are implementation noise rather than user sessions. +const CodexAutoReviewModel = "codex-auto-review" + // Model identifies which LLM serves a request plus the per-request inference // knobs. Maps onto the legacy ai.Config.Model + ai.Request.{Temperature, // ReasoningEffort}. diff --git a/pkg/api/runtime_event.go b/pkg/api/runtime_event.go index a97aa61..04a8b23 100644 --- a/pkg/api/runtime_event.go +++ b/pkg/api/runtime_event.go @@ -7,14 +7,15 @@ import ( // 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 + Text string + StructuredData any + TerminalOutcome *TerminalOutcome + Model string + Backend Backend + Usage Usage + Duration time.Duration + CacheHit bool + Raw any // Workspace is the run's working-dir runtime state (cwd, git details, changed // files, commits, plan). Populated by the agent runner + worktree hook. diff --git a/pkg/api/terminal_outcome.go b/pkg/api/terminal_outcome.go new file mode 100644 index 0000000..bdf6e5b --- /dev/null +++ b/pkg/api/terminal_outcome.go @@ -0,0 +1,63 @@ +package api + +import ( + "fmt" + "strings" +) + +// TerminalOutcomeKind identifies a native agent terminal result. +type TerminalOutcomeKind string + +const ( + TerminalOutcomePlan TerminalOutcomeKind = "plan" + TerminalOutcomeQuestions TerminalOutcomeKind = "questions" +) + +// TerminalPlan is the plan returned by a native planning tool. +type TerminalPlan struct { + Content string `json:"content"` + Path string `json:"path,omitempty"` +} + +// TerminalQuestion is one question returned by a native ask-user tool. +type TerminalQuestion struct { + Text string `json:"text"` + Context string `json:"context,omitempty"` + Options []string `json:"options,omitempty"` +} + +// TerminalOutcome carries native plan or question completion independently of +// schema-constrained StructuredData. +type TerminalOutcome struct { + Kind TerminalOutcomeKind `json:"kind"` + Plan *TerminalPlan `json:"plan,omitempty"` + Questions []TerminalQuestion `json:"questions,omitempty"` +} + +// Validate rejects incomplete or mixed terminal payloads. +func (o TerminalOutcome) Validate() error { + switch o.Kind { + case TerminalOutcomePlan: + if o.Plan == nil || strings.TrimSpace(o.Plan.Content) == "" { + return fmt.Errorf("terminal plan content is required") + } + if len(o.Questions) > 0 { + return fmt.Errorf("plan outcome must not carry questions") + } + case TerminalOutcomeQuestions: + if o.Plan != nil { + return fmt.Errorf("questions outcome must not carry a plan") + } + if len(o.Questions) == 0 { + return fmt.Errorf("terminal questions are required") + } + for i, question := range o.Questions { + if strings.TrimSpace(question.Text) == "" { + return fmt.Errorf("terminal question %d text is required", i+1) + } + } + default: + return fmt.Errorf("unknown terminal outcome kind %q", o.Kind) + } + return nil +} diff --git a/pkg/api/terminal_outcome_test.go b/pkg/api/terminal_outcome_test.go new file mode 100644 index 0000000..5c616ee --- /dev/null +++ b/pkg/api/terminal_outcome_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTerminalOutcomeValidate(t *testing.T) { + tests := []struct { + name string + outcome TerminalOutcome + wantErr string + }{ + { + name: "plan", + outcome: TerminalOutcome{ + Kind: TerminalOutcomePlan, + Plan: &TerminalPlan{Content: "1. Inspect\n2. Change"}, + }, + }, + { + name: "questions", + outcome: TerminalOutcome{ + Kind: TerminalOutcomeQuestions, + Questions: []TerminalQuestion{{Text: "Which database?", Options: []string{"PostgreSQL", "SQLite"}}}, + }, + }, + { + name: "plan requires content", + outcome: TerminalOutcome{Kind: TerminalOutcomePlan, Plan: &TerminalPlan{}}, + wantErr: "plan content is required", + }, + { + name: "questions reject plan payload", + outcome: TerminalOutcome{ + Kind: TerminalOutcomeQuestions, + Plan: &TerminalPlan{Content: "not a question"}, + Questions: []TerminalQuestion{{Text: "Continue?"}}, + }, + wantErr: "questions outcome must not carry a plan", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.outcome.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 0f3188b..bc225f7 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -523,6 +523,9 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) text += ev.Text } if ev.Kind == ai.EventResult { + if len(ev.StructuredData) > 0 { + text = string(ev.StructuredData) + } if ev.Usage != nil { usage = *ev.Usage } diff --git a/pkg/cli/ai_test.go b/pkg/cli/ai_test.go index 1ab768f..a8715d8 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -49,6 +49,28 @@ func (promptResultStreamingProvider) ExecuteStream(_ context.Context, _ ai.Reque return events, nil } +type structuredResultStreamingProvider struct { + text string +} + +func (structuredResultStreamingProvider) GetModel() string { return "gpt-5-codex" } + +func (structuredResultStreamingProvider) GetBackend() ai.Backend { return ai.BackendCodexCLI } + +func (structuredResultStreamingProvider) Execute(context.Context, ai.Request) (*ai.Response, error) { + return nil, nil +} + +func (p structuredResultStreamingProvider) ExecuteStream(_ context.Context, _ ai.Request) (<-chan ai.Event, error) { + events := make(chan ai.Event, 2) + if p.text != "" { + events <- ai.Event{Kind: ai.EventText, Text: p.text} + } + events <- ai.Event{Kind: ai.EventResult, StructuredData: json.RawMessage(`{"answer":"42"}`)} + 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. @@ -426,6 +448,30 @@ func TestRunStreaming_JSONIncludesFullInputSpec(t *testing.T) { } } +func TestRunStreaming_StructuredResultIsReturnedOnce(t *testing.T) { + for _, tc := range []struct { + name string + text string + }{ + {name: "result only"}, + {name: "replaces prior text", text: "discarded narrative"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := runStreaming(context.Background(), structuredResultStreamingProvider{text: tc.text}, ai.Request{}) + if err != nil { + t.Fatal(err) + } + result, ok := got.(AIPromptResult) + if !ok { + t.Fatalf("runStreaming returned %T, want AIPromptResult", got) + } + if result.Text != `{"answer":"42"}` { + t.Fatalf("Text = %q, want authoritative structured JSON once", result.Text) + } + }) + } +} + func defaultPromptOptions(t *testing.T) AIPromptOptions { t.Helper() return AIPromptOptions{ diff --git a/pkg/cli/database.go b/pkg/cli/database.go index a302942..2961592 100644 --- a/pkg/cli/database.go +++ b/pkg/cli/database.go @@ -10,8 +10,20 @@ import ( "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/monitor" commonsdb "github.com/flanksource/commons-db/db" + "github.com/spf13/pflag" + "gorm.io/gorm" ) +const databaseURLFlag = "db-url" + +var databaseURL string + +// BindDatabaseURLFlag exposes the process database as a root persistent flag. +// The explicit CLI value wins over environment variables and config files. +func BindDatabaseURLFlag(flags *pflag.FlagSet) { + flags.StringVar(&databaseURL, databaseURLFlag, "", "PostgreSQL database URL (overrides environment and db.json)") +} + // captainDBState memoizes the process-wide native database handle. The // database is mandatory: session/plan/prompt surfaces read it exclusively, so // failing to open it is a loud error rather than a degraded mode. @@ -19,6 +31,8 @@ var captainDBState struct { mu sync.Mutex opened bool db *database.DB + dsn string + source string err error } @@ -30,12 +44,39 @@ func captainDB(ctx context.Context) (*database.DB, error) { captainDBState.mu.Lock() defer captainDBState.mu.Unlock() if !captainDBState.opened { - captainDBState.db, captainDBState.err = openCaptainDB(ctx) + captainDBState.db, captainDBState.dsn, captainDBState.source, captainDBState.err = openCaptainDB(ctx) captainDBState.opened = true } return captainDBState.db, captainDBState.err } +// ConfigureNativeDatabase injects a host-owned GORM pool before Captain's CLI +// database is first used. Hosts such as Gavel use this to keep Captain session, +// prompt, and plan APIs on the same process-owned database. Reconfiguring the +// same pool is idempotent; replacing an initialized pool is rejected because +// callers may already hold handles backed by it. +func ConfigureNativeDatabase(gormDB *gorm.DB) error { + db, err := database.Use(gormDB) + if err != nil { + return err + } + + captainDBState.mu.Lock() + defer captainDBState.mu.Unlock() + if captainDBState.opened { + if captainDBState.err == nil && captainDBState.db != nil && captainDBState.db.Gorm() == gormDB { + return nil + } + return fmt.Errorf("native Captain database is already configured with a different pool") + } + captainDBState.db = db + captainDBState.dsn = "" + captainDBState.source = "host-provided database" + captainDBState.err = nil + captainDBState.opened = true + return nil +} + // setCaptainDBForTest injects (or, with nil, resets) the process-wide handle // so tests run against their own embedded database instead of a configured // DSN. Production code never calls this. @@ -43,19 +84,21 @@ func setCaptainDBForTest(db *database.DB) { captainDBState.mu.Lock() defer captainDBState.mu.Unlock() captainDBState.db = db + captainDBState.dsn = "" + captainDBState.source = "" captainDBState.err = nil captainDBState.opened = db != nil } -func openCaptainDB(ctx context.Context) (*database.DB, error) { +func openCaptainDB(ctx context.Context) (*database.DB, string, string, error) { dsn, source, err := captainDSN() if err != nil { - return nil, err + return nil, "", "", err } log.Debugf("captain database using %s", source) report, err := database.MigrateWithLegacySessionCutover(ctx, dsn) if err != nil { - return nil, fmt.Errorf("migrate captain database (%s): %w", source, err) + return nil, "", "", fmt.Errorf("migrate captain database (%s): %w", source, err) } if report != nil { log.Infof("migrated legacy session cache: %d sessions, %d prompt runs", @@ -63,9 +106,16 @@ func openCaptainDB(ctx context.Context) (*database.DB, error) { } gormDB, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) if err != nil { - return nil, fmt.Errorf("open captain database (%s): %w", source, err) + return nil, "", "", fmt.Errorf("open captain database (%s): %w", source, err) } - return database.Use(gormDB) + db, err := database.Use(gormDB) + return db, dsn, source, err +} + +func captainDatabaseIdentity() (dsn, source string) { + captainDBState.mu.Lock() + defer captainDBState.mu.Unlock() + return captainDBState.dsn, captainDBState.source } // serveMonitorState holds the serve process's live monitor so prompt-run code @@ -117,6 +167,9 @@ func freshenSessionDB(ctx context.Context) (*database.DB, error) { // captainDSN resolves the database connection: explicit env DSNs first, then a // gavel-shared database, finally captain's own shared embedded postgres. func captainDSN() (dsn, source string, err error) { + if dsn := strings.TrimSpace(databaseURL); dsn != "" { + return dsn, "--" + databaseURLFlag, nil + } for _, env := range []string{gavelDBEnvDSN, gavelCacheEnvDSN, captainSessionEnvDSN} { if dsn := strings.TrimSpace(os.Getenv(env)); dsn != "" { return dsn, env, nil diff --git a/pkg/cli/database_test.go b/pkg/cli/database_test.go index 8dc62f6..f0b7cd3 100644 --- a/pkg/cli/database_test.go +++ b/pkg/cli/database_test.go @@ -8,12 +8,55 @@ import ( "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/monitor" commonsdb "github.com/flanksource/commons-db/db" + "github.com/spf13/pflag" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) +func TestConfigureNativeDatabase(t *testing.T) { + setCaptainDBForTest(nil) + t.Cleanup(func() { setCaptainDBForTest(nil) }) + + first := &gorm.DB{} + require.NoError(t, ConfigureNativeDatabase(first)) + require.NoError(t, ConfigureNativeDatabase(first), "reconfiguring the same pool should be idempotent") + + db, err := captainDB(t.Context()) + require.NoError(t, err) + require.Same(t, first, db.Gorm()) + + err = ConfigureNativeDatabase(&gorm.DB{}) + require.EqualError(t, err, "native Captain database is already configured with a different pool") +} + +func TestConfigureNativeDatabaseRejectsNil(t *testing.T) { + setCaptainDBForTest(nil) + t.Cleanup(func() { setCaptainDBForTest(nil) }) + + err := ConfigureNativeDatabase(nil) + require.EqualError(t, err, "captain database GORM pool is nil") +} + func TestCaptainDSNPrecedence(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + databaseURL = "" + t.Cleanup(func() { databaseURL = "" }) + + t.Run("db-url flag wins", func(t *testing.T) { + flags := pflag.NewFlagSet("captain", pflag.ContinueOnError) + BindDatabaseURLFlag(flags) + require.NoError(t, flags.Parse([]string{"--db-url", "postgres://flag/captain"})) + t.Cleanup(func() { databaseURL = "" }) + + t.Setenv(gavelDBEnvDSN, "postgres://primary/gavel") + t.Setenv(gavelCacheEnvDSN, "postgres://cache/gavel") + t.Setenv(captainSessionEnvDSN, "postgres://captain/db") + dsn, source, err := captainDSN() + require.NoError(t, err) + require.Equal(t, "postgres://flag/captain", dsn) + require.Equal(t, "--db-url", source) + }) t.Run("gavel primary env wins", func(t *testing.T) { t.Setenv(gavelDBEnvDSN, "postgres://primary/gavel") diff --git a/pkg/cli/prompt_run_events.go b/pkg/cli/prompt_run_events.go index cafb6f3..56dc97f 100644 --- a/pkg/cli/prompt_run_events.go +++ b/pkg/cli/prompt_run_events.go @@ -90,6 +90,10 @@ func (a *promptEventAccumulator) handle(_ int, ev ai.Event) { a.emitError(ev) case ai.EventResult: a.flush() + if len(ev.StructuredData) > 0 { + a.appendText(string(ev.StructuredData)) + a.flush() + } if ev.Usage != nil { a.usage = *ev.Usage } diff --git a/pkg/cli/prompt_run_events_test.go b/pkg/cli/prompt_run_events_test.go index 86360f0..6c21c7b 100644 --- a/pkg/cli/prompt_run_events_test.go +++ b/pkg/cli/prompt_run_events_test.go @@ -49,6 +49,22 @@ func TestPromptRunAccumulator_CoalescesTextDeltas(t *testing.T) { } } +func TestPromptRunAccumulator_EmitsStructuredResultAsFreshMessage(t *testing.T) { + msgs := collectEntries("m", "b", + ai.Event{Kind: ai.EventText, Text: "narrative"}, + ai.Event{Kind: ai.EventResult, StructuredData: json.RawMessage(`{"answer":"42"}`)}, + ) + if len(msgs) != 2 { + t.Fatalf("want narrative and one structured result frame, got %d", len(msgs)) + } + if msgs[0].ID == msgs[1].ID { + t.Fatalf("structured result must start a fresh message") + } + if got := msgs[1].Parts[0].Text; got != `{"answer":"42"}` { + t.Fatalf("structured result text = %q, want coherent JSON", got) + } +} + func TestPromptRunAccumulator_ThinkingAndTextAreSeparateTurns(t *testing.T) { msgs := collectEntries("m", "b", ai.Event{Kind: ai.EventThinking, Text: "hmm"}, diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index c9571c9..b484997 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -19,6 +19,7 @@ import ( "syscall" "time" + "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/monitor" "github.com/flanksource/clicky/aichat" "github.com/flanksource/clicky/rpc" @@ -211,6 +212,17 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve log.Errorf("session monitor stopped: %v", err) } }() + select { + case <-mon.Ready(): + case <-ctx.Done(): + return ctx.Err() + } + liveSessions, err := db.CountLiveRootSessions(ctx) + if err != nil { + return err + } + dsn, source := captainDatabaseIdentity() + log.Infof("Database Info: source=%q dsn=%q live_sessions=%d", source, database.MaskDSN(dsn), liveSessions) go prunePromptRuns(ctx, promptRuns) @@ -352,9 +364,8 @@ func handleProjects() http.HandlerFunc { } } -// handleSessionGet serves the unified session model for one session id at -// GET /api/captain/sessions/{id}, so the web UI can render the same model the -// CLI `sessions get` returns. +// handleSessionGet serves every Captain session matching an exact UUID or +// provider-session-id prefix, using the same result envelope as the CLI. func handleSessionGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("id")) diff --git a/pkg/cli/serve_test.go b/pkg/cli/serve_test.go index 88908ef..6541388 100644 --- a/pkg/cli/serve_test.go +++ b/pkg/cli/serve_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/claude" - "github.com/flanksource/captain/pkg/session" + "github.com/flanksource/captain/pkg/database" ) func TestHandleThreadFromAgentCreatesThread(t *testing.T) { @@ -62,10 +62,10 @@ func TestHandleThreadFromAgentRequiresProviderSession(t *testing.T) { } } -func TestHandleSessionGetReturnsUnifiedModel(t *testing.T) { +func TestHandleSessionGetReturnsAllMatches(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - withTestCaptainDB(t) + db := withTestCaptainDB(t) project := filepath.Join(home, "work", "project") if err := os.MkdirAll(project, 0o755); err != nil { t.Fatal(err) @@ -73,7 +73,8 @@ func TestHandleSessionGetReturnsUnifiedModel(t *testing.T) { t.Chdir(project) markProjectRoot(t, project) - writeJSONL(t, filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-web.jsonl"), + sessionFile := filepath.Join(home, ".claude", "projects", claude.NormalizePath(project), "sess-web.jsonl") + writeJSONL(t, sessionFile, map[string]any{ "type": "assistant", "sessionId": "sess-web", "uuid": "a1", "timestamp": "2026-07-06T10:00:00Z", "cwd": project, @@ -83,6 +84,15 @@ func TestHandleSessionGetReturnsUnifiedModel(t *testing.T) { }, }, ) + for _, input := range []database.CreateSessionInput{ + {ProviderSessionID: "sess-web", Source: "claude", HostID: "test-host", Path: sessionFile, Project: "example", CWD: project}, + {ProviderSessionID: "sess-web", Source: "gavel", Provider: "cmux", HostID: "local"}, + {ProviderSessionID: "sess-web", Source: "claude", Provider: "cmux", HostID: "local"}, + } { + if _, err := db.CreateOrGetSession(t.Context(), input); err != nil { + t.Fatalf("create duplicate session: %v", err) + } + } req := httptest.NewRequest(http.MethodGet, "/api/captain/sessions/sess-web", nil) req.SetPathValue("id", "sess-web") @@ -92,15 +102,19 @@ func TestHandleSessionGetReturnsUnifiedModel(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } - var got session.Session + var got SessionGetResult if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v (body=%s)", err, rec.Body.String()) } - if got.ID != "sess-web" || got.Source != "claude" { - t.Fatalf("session = %+v", got) + if got.Total != 3 || len(got.Sessions) != 3 { + t.Fatalf("result = %+v", got) + } + detail := got.Sessions[0].Detail + if detail == nil || detail.ID != "sess-web" || detail.Source != "claude" { + t.Fatalf("session = %+v", detail) } - if len(got.Messages) == 0 || got.Messages[0].Parts[0].Text != "hello from the model" { - t.Fatalf("messages = %+v", got.Messages) + if len(detail.Messages) == 0 || detail.Messages[0].Parts[0].Text != "hello from the model" { + t.Fatalf("messages = %+v", detail.Messages) } } diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx index 814608d..6cfc698 100644 --- a/pkg/cli/webapp/src/SessionBrowser.tsx +++ b/pkg/cli/webapp/src/SessionBrowser.tsx @@ -31,6 +31,8 @@ import { sessionToolCount, unifiedSessionTitle, type SessionDashboard, + type SessionGetItem, + type SessionGetResult, type SessionRecord, type ProjectScope, type SourceFilter, @@ -102,7 +104,7 @@ function SessionDetailPage({ actions={actions} bodyHeader={ item.detail)?.detail} timing={detailQuery.data?.timing} loading={detailQuery.isLoading} /> @@ -120,7 +122,7 @@ function SessionDetailPage({ contentClassName="p-0 overflow-hidden" > @@ -324,11 +326,11 @@ function SessionHeader({ } function SessionDetail({ - session, + result, loading, error, }: { - session?: UnifiedSession; + result?: SessionGetResult; loading: boolean; error: unknown; }) { @@ -346,15 +348,46 @@ function SessionDetail({
); } + if (!result?.sessions.length) { + return ( +
+ No matching sessions. +
+ ); + } return ( - // AppShell's content region is a bounded block (h-full), not a flex parent, so - // `h-full` (not `flex-1`) is what gives the viewer a definite height to scroll within. -
- +
+ {result.sessions.map((item) => ( + + ))}
); } +function SessionGetItemDetail({ item, single }: { item: SessionGetItem; single: boolean }) { + return ( +
+
+
{item.captainId}
+
+ {item.summary.source} + {item.providerSessionId && provider={item.providerSessionId}} + {item.host && host={item.host}} + {item.summary.project && project={item.summary.project}} + {item.summary.cwd && {item.summary.cwd}} +
+
+ {item.detail ? ( +
+ +
+ ) : ( +
Transcript unavailable.
+ )} +
+ ); +} + function fileCountLabel(files: NonNullable) { const read = files.read?.length ?? 0; const written = files.written?.length ?? 0; @@ -402,4 +435,3 @@ function SessionSummary({
); } - diff --git a/pkg/cli/webapp/src/sessionData.ts b/pkg/cli/webapp/src/sessionData.ts index ae6c50d..1cc48c8 100644 --- a/pkg/cli/webapp/src/sessionData.ts +++ b/pkg/cli/webapp/src/sessionData.ts @@ -41,9 +41,8 @@ export type SessionRecord = { health?: SessionHealth[]; }; -// UnifiedSession is captain's canonical session.Session (served by the sessions -// `get` action at /api/v1/sessions/{id}). The detail view consumes it directly — -// its `messages` (SessionUIMessage[]) feed the SessionViewer. +// UnifiedSession is captain's canonical parsed session detail. Session get +// returns it inside a SessionGetItem when a transcript is available. export type UnifiedGit = { branch?: string; commit?: string; worktree?: string; diff?: string }; export type UnifiedUsage = { @@ -185,6 +184,20 @@ export type UnifiedSession = { prompt?: unknown; }; +export type SessionGetItem = { + captainId: string; + providerSessionId?: string; + host?: string; + detailAvailable: boolean; + summary: SessionRecord; + detail?: UnifiedSession; +}; + +export type SessionGetResult = { + sessions: SessionGetItem[]; + total: number; +}; + export type SessionTokens = { inputTokens?: number; outputTokens?: number; @@ -354,7 +367,7 @@ export async function fetchProjectOptions(): Promise { export async function fetchSession( id: string, page?: { offset?: number; limit?: number; tail?: number }, -): Promise { +): Promise { const params: Record = { id }; if (page?.tail) params.tail = String(page.tail); if (page?.offset) params.offset = String(page.offset); @@ -369,7 +382,7 @@ export async function fetchSession( throw new Error(response.error || "Failed to load session."); } const timing = parseServerTiming(response.responseHeaders?.["server-timing"]); - return { ...(response.parsed as UnifiedSession), ...(timing.length ? { timing } : {}) }; + return { ...(response.parsed as SessionGetResult), ...(timing.length ? { timing } : {}) }; } /** Sum a unified session's per-bucket costs into a total USD. */ diff --git a/pkg/monitor/backfill.go b/pkg/monitor/backfill.go index ca46ba5..a91fbce 100644 --- a/pkg/monitor/backfill.go +++ b/pkg/monitor/backfill.go @@ -3,7 +3,9 @@ package monitor import ( "context" "os" + "path/filepath" "runtime" + "strings" "sync" "github.com/flanksource/captain/pkg/ai/history" @@ -33,6 +35,9 @@ func discoverTranscripts() (roots, agents []transcriptRef) { projectsDir := claude.GetProjectsDir() if rootFiles, err := claude.FindSessionFiles(projectsDir, "", true); err == nil { for _, path := range rootFiles { + if isEphemeralClaudeTranscript(projectsDir, path) { + continue + } roots = append(roots, transcriptRef{source: "claude", path: path}) } } else { @@ -40,6 +45,9 @@ func discoverTranscripts() (roots, agents []transcriptRef) { } if codexFiles, err := history.FindCodexSessionFiles(); err == nil { for _, path := range codexFiles { + if ignored, _ := history.IsCodexAutoReviewSession(path); ignored { + continue + } roots = append(roots, transcriptRef{source: "codex", path: path}) } } else { @@ -47,6 +55,9 @@ func discoverTranscripts() (roots, agents []transcriptRef) { } if agentFiles, err := claude.FindAgentTranscripts(projectsDir, "", true); err == nil { for _, path := range agentFiles { + if isEphemeralClaudeTranscript(projectsDir, path) { + continue + } agents = append(agents, transcriptRef{source: "claude", path: path}) } } else { @@ -55,6 +66,25 @@ func discoverTranscripts() (roots, agents []transcriptRef) { return roots, agents } +// isEphemeralClaudeTranscript excludes projects created below a system temp +// root. Go integration tests that launch Claude leave their transcript mirror +// under ~/.claude/projects even after the temporary working directory is gone; +// those fixtures are not durable user sessions and should not be backfilled. +func isEphemeralClaudeTranscript(projectsDir, path string) bool { + rel, err := filepath.Rel(projectsDir, path) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + projectDir := strings.SplitN(rel, string(filepath.Separator), 2)[0] + for _, root := range []string{os.TempDir(), "/tmp", "/private/tmp"} { + prefix := strings.TrimSuffix(claude.NormalizePath(filepath.Clean(root)), "-") + if prefix != "" && (projectDir == prefix || strings.HasPrefix(projectDir, prefix+"-")) { + return true + } + } + return false +} + func ingestChanged(ctx context.Context, ingestor *ingestor, refs []transcriptRef) { changed := make([]transcriptRef, 0) for _, ref := range refs { @@ -90,11 +120,13 @@ func ingestChanged(ctx context.Context, ingestor *ingestor, refs []transcriptRef } }() } +queueing: for _, ref := range changed { - if ctx.Err() != nil { - break + select { + case <-ctx.Done(): + break queueing + case queue <- ref: } - queue <- ref } close(queue) wg.Wait() diff --git a/pkg/monitor/backfill_test.go b/pkg/monitor/backfill_test.go new file mode 100644 index 0000000..864a4b4 --- /dev/null +++ b/pkg/monitor/backfill_test.go @@ -0,0 +1,71 @@ +package monitor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/claude" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsEphemeralClaudeTranscript(t *testing.T) { + projectsDir := filepath.Join(t.TempDir(), ".claude", "projects") + tempProject := claude.NormalizePath(filepath.Join(os.TempDir(), "TestCmuxExecutorFreshSessionReferencesPriorInPrompt123", "001")) + + assert.True(t, isEphemeralClaudeTranscript(projectsDir, + filepath.Join(projectsDir, tempProject, "session.jsonl"))) + assert.True(t, isEphemeralClaudeTranscript(projectsDir, + filepath.Join(projectsDir, claude.NormalizePath("/private/tmp/test-project"), "session", "subagents", "agent-a.jsonl"))) + assert.False(t, isEphemeralClaudeTranscript(projectsDir, + filepath.Join(projectsDir, claude.NormalizePath("/Users/dev/src/project"), "session.jsonl"))) + assert.False(t, isEphemeralClaudeTranscript(projectsDir, + filepath.Join(t.TempDir(), "outside-projects", "session.jsonl"))) +} + +func TestDiscoverTranscriptsSkipsSystemTempClaudeProjects(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + projectsDir := filepath.Join(home, ".claude", "projects") + normalProject := filepath.Join(projectsDir, claude.NormalizePath("/Users/dev/src/project")) + tempProject := filepath.Join(projectsDir, + claude.NormalizePath(filepath.Join(os.TempDir(), "TestCmuxExecutorFreshSessionReferencesPriorInPrompt123", "001"))) + + normalRoot := filepath.Join(normalProject, "normal.jsonl") + normalAgent := filepath.Join(normalProject, "normal", "subagents", "agent-a.jsonl") + for _, path := range []string{ + normalRoot, + normalAgent, + filepath.Join(tempProject, "stale.jsonl"), + filepath.Join(tempProject, "stale", "subagents", "agent-a.jsonl"), + } { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) + } + + roots, agents := discoverTranscripts() + require.Equal(t, []transcriptRef{{source: "claude", path: normalRoot}}, roots) + require.Equal(t, []transcriptRef{{source: "claude", path: normalAgent}}, agents) +} + +func TestDiscoverTranscriptsSkipsCodexAutoReviewSessions(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sessionsDir := filepath.Join(home, ".codex", "sessions", "2026", "07", "13") + require.NoError(t, os.MkdirAll(sessionsDir, 0o755)) + + userSession := filepath.Join(sessionsDir, "user.jsonl") + reviewSession := filepath.Join(sessionsDir, "review.jsonl") + require.NoError(t, os.WriteFile(userSession, []byte( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"user-1\",\"cwd\":\"/repo\"}}\n"+ + "{\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5.6-sol\",\"effort\":\"high\"}}\n"), 0o644)) + require.NoError(t, os.WriteFile(reviewSession, []byte( + "{\"type\":\"session_meta\",\"payload\":{\"id\":\"review-1\",\"cwd\":\"/repo\"}}\n"+ + "{\"type\":\"turn_context\",\"payload\":{\"model\":\"codex-auto-review\",\"effort\":\"low\"}}\n"), 0o644)) + + roots, agents := discoverTranscripts() + assert.Contains(t, roots, transcriptRef{source: "codex", path: userSession}) + assert.NotContains(t, roots, transcriptRef{source: "codex", path: reviewSession}) + assert.Empty(t, agents) +} diff --git a/pkg/monitor/ingest.go b/pkg/monitor/ingest.go index 51c2ae9..3a467b9 100644 --- a/pkg/monitor/ingest.go +++ b/pkg/monitor/ingest.go @@ -9,13 +9,14 @@ import ( "strings" "sync" + "github.com/flanksource/captain/pkg/ai/history" "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" ) // parserVersion invalidates every ingested transcript when the parsing or // mapping logic changes shape. -const parserVersion = 1 +const parserVersion = 2 // ingestor turns changed transcript files into native database rows, skipping // files whose recorded mtime/size/parser version still match the disk state. @@ -26,8 +27,9 @@ type ingestor struct { // directory; nil outside watched (one-shot) runs. watchSubagents func(rootTranscriptPath string) - mu sync.Mutex - sources map[string]database.SessionSourceState + mu sync.Mutex + sources map[string]database.SessionSourceState + ingestLocks sync.Map // transcript path -> *sync.Mutex } func newIngestor(m *Monitor) *ingestor { @@ -35,13 +37,13 @@ func newIngestor(m *Monitor) *ingestor { } func (ing *ingestor) refreshSourceStates(ctx context.Context) error { + ing.mu.Lock() + defer ing.mu.Unlock() sources, err := ing.db.ListSessionSources(ctx) if err != nil { return err } - ing.mu.Lock() ing.sources = sources - ing.mu.Unlock() return nil } @@ -74,6 +76,11 @@ func (ing *ingestor) needsIngest(path string, info os.FileInfo) bool { // child sessions of the root transcript's session; root ingests also arm the // watcher on the session's subagents directory. func (ing *ingestor) ingestFile(ctx context.Context, source, path string) error { + lockValue, _ := ing.ingestLocks.LoadOrStore(path, &sync.Mutex{}) + pathLock := lockValue.(*sync.Mutex) + pathLock.Lock() + defer pathLock.Unlock() + info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { @@ -85,6 +92,15 @@ func (ing *ingestor) ingestFile(ctx context.Context, source, path string) error if !ing.needsIngest(path, info) { return nil } + if source == "codex" { + ignored, ignoreErr := history.IsCodexAutoReviewSession(path) + if ignoreErr != nil { + return ignoreErr + } + if ignored { + return nil + } + } var input database.IngestTranscriptInput switch source { case "claude": @@ -184,7 +200,7 @@ func unifiedIngestInput(s *session.Session, backend string, sequence func(sessio Index: turn.Index, ProviderTurnID: turn.ID, Status: database.TurnStatusEnded, StopReason: turn.StopReason, StartedAt: turn.StartedAt, EndedAt: turn.EndedAt, Call: &database.IngestModelCall{ - Model: turn.Model, Backend: backend, + Model: turn.Model, Backend: backend, Effort: turn.ReasoningEffort, InputTokens: int64(turn.Usage.InputTokens), OutputTokens: int64(turn.Usage.OutputTokens), ReasoningTokens: int64(turn.Usage.ReasoningTokens), CacheReadTokens: int64(turn.Usage.CacheReadTokens), CacheWriteTokens: int64(turn.Usage.CacheWriteTokens), diff --git a/pkg/monitor/ingest_test.go b/pkg/monitor/ingest_test.go new file mode 100644 index 0000000..ec5fe16 --- /dev/null +++ b/pkg/monitor/ingest_test.go @@ -0,0 +1,20 @@ +package monitor + +import ( + "testing" + + "github.com/flanksource/captain/pkg/session" +) + +func TestUnifiedIngestInputPreservesTurnEffort(t *testing.T) { + input := unifiedIngestInput(&session.Session{Turns: []session.Turn{{ + ID: "turn-1", Index: 1, Model: "gpt-5.6-sol", ReasoningEffort: "max", + }}}, "codex", func(session.Message, int) int64 { return 0 }) + + if len(input.Turns) != 1 || input.Turns[0].Call == nil { + t.Fatalf("turns = %+v, want one model call", input.Turns) + } + if input.Turns[0].Call.Effort != "max" { + t.Fatalf("effort = %q, want max", input.Turns[0].Call.Effort) + } +} diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index a0021e3..5cbd618 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -11,6 +11,9 @@ import ( "database/sql" "errors" "fmt" + "os" + "strconv" + "strings" "sync" "time" @@ -25,6 +28,7 @@ var log = logger.GetLogger("monitor") const ( monitorLockNamespace int32 = 0x43415054 monitorLockKey int32 = 0x4d4f4e49 + monitorLockAppPrefix = "captain-monitor:" ) type Config struct { @@ -46,6 +50,9 @@ type Monitor struct { mu sync.Mutex tracked map[string]string // transcript path -> source kind, the live tail set + + readyOnce sync.Once + ready chan struct{} } func New(cfg Config) (*Monitor, error) { @@ -67,7 +74,20 @@ func New(cfg Config) (*Monitor, error) { if cfg.DiscoverProcesses == nil { cfg.DiscoverProcesses = discoverAgentProcesses } - return &Monitor{cfg: cfg, db: cfg.DB, tracked: map[string]string{}}, nil + return &Monitor{cfg: cfg, db: cfg.DB, tracked: map[string]string{}, ready: make(chan struct{})}, nil +} + +// Ready is closed after the first process reconciliation attempt, or as soon +// as Run confirms that another monitor already owns the writer lock. Callers +// can use it as a lightweight startup barrier without waiting for historical +// backfill. +func (m *Monitor) Ready() <-chan struct{} { return m.ready } + +func (m *Monitor) markReady() { + if m == nil || m.ready == nil { + return + } + m.readyOnce.Do(func() { close(m.ready) }) } // TrackTranscript adds a transcript to the live tail set before its session is @@ -105,12 +125,13 @@ func (m *Monitor) untrackTranscript(path string) { // another monitor holds the lock, Run keeps retrying the lock instead of // double-writing, so a serve restart takes over cleanly. func (m *Monitor) Run(ctx context.Context) error { + defer m.markReady() for { - conn, acquired, err := m.tryAcquireWriterLock(ctx) + conn, holderPID, err := m.tryAcquireWriterLock(ctx) if err != nil { return err } - if acquired { + if conn != nil { err := m.runLocked(ctx, conn) _ = conn.Close() if ctx.Err() != nil { @@ -121,7 +142,8 @@ func (m *Monitor) Run(ctx context.Context) error { } continue } - log.Infof("another Captain monitor holds the writer lock; standing by") + m.markReady() + log.Infof("another Captain monitor (PID %d) holds the writer lock; standing by", holderPID) select { case <-ctx.Done(): return nil @@ -131,19 +153,42 @@ func (m *Monitor) Run(ctx context.Context) error { } func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { + runCtx, cancel := context.WithCancel(ctx) ingestor := newIngestor(m) watcher, err := newTranscriptWatcher(m, ingestor) if err != nil { + cancel() return err } defer watcher.close() - // Initial backfill runs before the first process poll so live processes - // bind to their ingested sessions instead of provisional stubs. - m.backfill(ctx, ingestor) - if err := m.pollProcesses(ctx, watcher); err != nil { + // Arm live transcript directories before the historical scan. Backfill can + // take minutes on a cold database; it must never prevent fsnotify events or + // process polling from keeping a newly launched session current. + if err := m.pollProcesses(runCtx, watcher); err != nil { log.Warnf("process poll: %v", err) } + m.markReady() + + backfillRequests := make(chan struct{}, 1) + var backfillWG sync.WaitGroup + backfillWG.Add(1) + go func() { + defer backfillWG.Done() + for { + select { + case <-runCtx.Done(): + return + case <-backfillRequests: + m.backfill(runCtx, ingestor) + } + } + }() + defer func() { + cancel() + backfillWG.Wait() + }() + requestBackfill(backfillRequests) processTicker := time.NewTicker(m.cfg.ProcessInterval) backfillTicker := time.NewTicker(m.cfg.BackfillInterval) @@ -152,19 +197,19 @@ func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return nil case <-processTicker.C: - if err := m.pollProcesses(ctx, watcher); err != nil { + if err := m.pollProcesses(runCtx, watcher); err != nil { log.Warnf("process poll: %v", err) } case <-backfillTicker.C: - m.backfill(ctx, ingestor) + requestBackfill(backfillRequests) case event, ok := <-watcher.events(): if !ok { return errors.New("transcript watcher closed unexpectedly") } - watcher.handle(ctx, event) + watcher.handle(runCtx, event) case err, ok := <-watcher.errors(): if ok && err != nil { log.Warnf("transcript watcher: %v", err) @@ -173,27 +218,80 @@ func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { } } +// requestBackfill coalesces scan requests while one scan is already running. +// The worker remains the sole backfill caller, while the monitor event loop is +// free to service live transcript writes and process polls. +func requestBackfill(requests chan<- struct{}) { + select { + case requests <- struct{}{}: + default: + } +} + // tryAcquireWriterLock takes the monitor advisory lock on a dedicated // connection so it is held for the monitor's lifetime, not a pooled statement. -func (m *Monitor) tryAcquireWriterLock(ctx context.Context) (*sql.Conn, bool, error) { +func (m *Monitor) tryAcquireWriterLock(ctx context.Context) (*sql.Conn, int, error) { sqlDB, err := m.db.Gorm().DB() if err != nil { - return nil, false, fmt.Errorf("access Captain SQL pool: %w", err) + return nil, 0, fmt.Errorf("access Captain SQL pool: %w", err) } conn, err := sqlDB.Conn(ctx) if err != nil { - return nil, false, fmt.Errorf("open Captain monitor lock connection: %w", err) + return nil, 0, fmt.Errorf("open Captain monitor lock connection: %w", err) } - var acquired bool - err = conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1, $2)", - monitorLockNamespace, monitorLockKey).Scan(&acquired) - if err != nil { + if _, err := conn.ExecContext(ctx, "SELECT set_config('application_name', $1, false)", + fmt.Sprintf("%s%d", monitorLockAppPrefix, os.Getpid())); err != nil { _ = conn.Close() - return nil, false, fmt.Errorf("acquire Captain monitor lock: %w", err) + return nil, 0, fmt.Errorf("register Captain monitor lock owner: %w", err) } - if !acquired { + for { + var acquired bool + if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1, $2)", + monitorLockNamespace, monitorLockKey).Scan(&acquired); err != nil { + _ = conn.Close() + return nil, 0, fmt.Errorf("acquire Captain monitor lock: %w", err) + } + if acquired { + return conn, 0, nil + } + + holderPID, err := queryMonitorLockHolderPID(ctx, conn) + if errors.Is(err, sql.ErrNoRows) { + continue + } _ = conn.Close() - return nil, false, nil + if err != nil { + return nil, 0, err + } + return nil, holderPID, nil + } +} + +func queryMonitorLockHolderPID(ctx context.Context, conn *sql.Conn) (int, error) { + var applicationName string + err := conn.QueryRowContext(ctx, ` + SELECT activity.application_name + FROM pg_catalog.pg_locks AS lock + JOIN pg_catalog.pg_stat_activity AS activity ON activity.pid = lock.pid + WHERE lock.locktype = 'advisory' + AND lock.database = ( + SELECT oid FROM pg_catalog.pg_database WHERE datname = current_database() + ) + AND lock.classid = $1::oid + AND lock.objid = $2::oid + AND lock.objsubid = 2 + AND lock.granted + LIMIT 1`, monitorLockNamespace, monitorLockKey).Scan(&applicationName) + if err != nil { + return 0, fmt.Errorf("identify Captain monitor lock owner: %w", err) + } + value, ok := strings.CutPrefix(applicationName, monitorLockAppPrefix) + if !ok { + return 0, fmt.Errorf("captain monitor lock owner has unexpected application name %q", applicationName) + } + holderPID, err := strconv.Atoi(value) + if err != nil || holderPID <= 0 { + return 0, fmt.Errorf("captain monitor lock owner has invalid PID in application name %q", applicationName) } - return conn, true, nil + return holderPID, nil } diff --git a/pkg/monitor/monitor_integration_test.go b/pkg/monitor/monitor_integration_test.go index 16e835e..89eeb55 100644 --- a/pkg/monitor/monitor_integration_test.go +++ b/pkg/monitor/monitor_integration_test.go @@ -114,16 +114,18 @@ func TestRunOnceIngestsAndIsIncremental(t *testing.T) { t.Run("writer lock excludes a second monitor", func(t *testing.T) { m, err := New(Config{DB: db, HostID: "test-host"}) require.NoError(t, err) - conn, acquired, err := m.tryAcquireWriterLock(t.Context()) + conn, holderPID, err := m.tryAcquireWriterLock(t.Context()) require.NoError(t, err) - require.True(t, acquired) + require.NotNil(t, conn) + assert.Zero(t, holderPID) defer func() { require.NoError(t, conn.Close()) }() second, err := New(Config{DB: db, HostID: "test-host"}) require.NoError(t, err) - conn2, acquired2, err := second.tryAcquireWriterLock(t.Context()) + conn2, holderPID, err := second.tryAcquireWriterLock(t.Context()) require.NoError(t, err) - assert.False(t, acquired2, "second monitor must not acquire the writer lock") + assert.Nil(t, conn2, "second monitor must not acquire the writer lock") + assert.Equal(t, os.Getpid(), holderPID) if conn2 != nil { _ = conn2.Close() } diff --git a/pkg/monitor/monitor_test.go b/pkg/monitor/monitor_test.go index 2ba8ab9..4beef39 100644 --- a/pkg/monitor/monitor_test.go +++ b/pkg/monitor/monitor_test.go @@ -34,6 +34,18 @@ func TestParseAgentProcessLine(t *testing.T) { } } +func TestParseProcessStartUsesHostTimezone(t *testing.T) { + location := time.FixedZone("UTC+03", 3*60*60) + started := parseProcessStartInLocation("Sun Jul 12 09:00:00 2026", location) + if started == nil { + t.Fatal("process start should parse") + } + want := time.Date(2026, time.July, 12, 6, 0, 0, 0, time.UTC) + if !started.Equal(want) { + t.Fatalf("process start = %s, want %s", started, want) + } +} + func TestParseAgentProcessLine_SkipsNonAgents(t *testing.T) { for _, line := range []string{ "77 0.0 0.1 1024 S Sun Jul 12 09:00:00 2026 /usr/bin/captain serve", @@ -103,3 +115,33 @@ func TestWatcherClassify(t *testing.T) { } } } + +func TestRequestBackfillCoalescesWhileWorkerIsBusy(t *testing.T) { + requests := make(chan struct{}, 1) + requestBackfill(requests) + requestBackfill(requests) + requestBackfill(requests) + + select { + case <-requests: + case <-time.After(time.Second): + t.Fatal("backfill request was not queued") + } + select { + case <-requests: + t.Fatal("duplicate backfill requests were not coalesced") + default: + } +} + +func TestMonitorReadyClosesOnce(t *testing.T) { + m := &Monitor{ready: make(chan struct{})} + m.markReady() + m.markReady() + + select { + case <-m.Ready(): + case <-time.After(time.Second): + t.Fatal("monitor readiness was not signalled") + } +} diff --git a/pkg/monitor/oneshot.go b/pkg/monitor/oneshot.go index deb0651..eee2f90 100644 --- a/pkg/monitor/oneshot.go +++ b/pkg/monitor/oneshot.go @@ -13,11 +13,11 @@ func RunOnce(ctx context.Context, cfg Config) error { if err != nil { return err } - conn, acquired, err := m.tryAcquireWriterLock(ctx) + conn, _, err := m.tryAcquireWriterLock(ctx) if err != nil { return err } - if !acquired { + if conn == nil { return nil } defer func() { _ = conn.Close() }() diff --git a/pkg/monitor/process.go b/pkg/monitor/process.go index 54fcf3e..8cd891b 100644 --- a/pkg/monitor/process.go +++ b/pkg/monitor/process.go @@ -121,9 +121,10 @@ func (m *Monitor) stickyProcessSession(ctx context.Context, proc Process) (uuid. } func (m *Monitor) persistProcess(ctx context.Context, sessionID uuid.UUID, proc Process, sampledAt time.Time) error { + startedAt := processStartOrNow(proc) input := database.SessionProcessInput{ SessionID: sessionID, HostID: m.cfg.HostID, BootID: bootID(), - PID: int64(proc.PID), ProcessStartedAt: processStartOrNow(proc), + PID: int64(proc.PID), ProcessStartedAt: startedAt, Status: proc.Status, Command: proc.Command, CWD: proc.CWD, Source: proc.Source, CPUPercent: proc.CPUPercent, MemoryPercent: proc.MemoryPercent, SampledAt: sampledAt, } @@ -131,15 +132,14 @@ func (m *Monitor) persistProcess(ctx context.Context, sessionID uuid.UUID, proc rss := proc.MemoryRSSKB * 1024 input.MemoryRSSBytes = &rss } - err := m.db.UpsertSessionProcess(ctx, input) - if err != nil && strings.Contains(err.Error(), "captain_session_processes_active_session_key") { - // The session was resumed under a new PID; close the superseded row and retry. - if endErr := m.db.EndOtherSessionProcesses(ctx, sessionID, int64(proc.PID)); endErr != nil { - return endErr - } - err = m.db.UpsertSessionProcess(ctx, input) + // Close a superseded process identity before inserting the observation. This + // avoids using a predictable unique-key violation as control flow (and the + // corresponding ERROR log), and also drains legacy rows whose timezone bug + // left the same PID open under a different start timestamp. + if err := m.db.EndOtherSessionProcesses(ctx, sessionID, int64(proc.PID), startedAt); err != nil { + return err } - return err + return m.db.UpsertSessionProcess(ctx, input) } // watchLiveSession points the watcher at wherever this live session's @@ -311,14 +311,26 @@ func processStatus(stat string) (string, bool) { } func parseProcessStart(value string) *time.Time { + return parseProcessStartInLocation(value, time.Local) +} + +func parseProcessStartInLocation(value string, location *time.Location) *time.Time { if value == "" { return nil } - t, err := time.Parse("Mon Jan 2 15:04:05 2006", value) + if location == nil { + location = time.Local + } + // ps(1) renders lstart in the host's local timezone, but the value carries + // no offset. time.Parse would interpret it as UTC and, on positive-offset + // hosts, persist a process start several hours in the future. Closing that + // row then violates ended_at >= process_started_at. + t, err := time.ParseInLocation("Mon Jan 2 15:04:05 2006", value, location) if err != nil { return nil } - return &t + utc := t.UTC() + return &utc } func processIDs(processes []Process) []int { diff --git a/pkg/session/build_codex.go b/pkg/session/build_codex.go index fdd7f34..7eba2aa 100644 --- a/pkg/session/build_codex.go +++ b/pkg/session/build_codex.go @@ -13,6 +13,7 @@ import ( "github.com/flanksource/captain/pkg/claude" "github.com/flanksource/captain/pkg/claude/tools" "github.com/flanksource/commons/logger" + "github.com/google/uuid" "github.com/segmentio/encoding/json" ) @@ -23,6 +24,10 @@ var codexLog = logger.GetLogger("session") func BuildCodex(files []string) []*Session { out := make([]*Session, 0, len(files)) for _, f := range files { + info, _ := history.ReadCodexSessionInfo(f) + if info != nil && history.IsCodexAutoReviewModel(info.Model) { + continue + } uses, err := history.ExtractCodexToolUses(f) if err != nil { codexLog.Warnf("skipping unreadable codex session %s: %v", f, err) @@ -31,7 +36,24 @@ func BuildCodex(files []string) []*Session { if len(uses) == 0 { continue } - info, _ := history.ReadCodexSessionInfo(f) + sessionID := "" + if info != nil { + sessionID = strings.TrimSpace(info.ID) + } + if sessionID == "" { + sessionID = codexSessionIDFromHistoryFile(f) + } + if sessionID != "" { + if info == nil { + info = &history.CodexSessionInfo{} + } + info.ID = sessionID + for i := range uses { + if strings.TrimSpace(uses[i].SessionID) == "" { + uses[i].SessionID = sessionID + } + } + } s := buildCodexSession(uses, info) s.HistoryFile = f if s.Root != nil { @@ -42,6 +64,24 @@ func BuildCodex(files []string) []*Session { return out } +// codexSessionIDFromHistoryFile recovers the UUID Codex writes at the end of +// rollout filenames. It is a last-resort identity when an otherwise-readable +// transcript predates session_meta or contains a metadata shape newer than the +// local parser. +func codexSessionIDFromHistoryFile(path string) string { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + const uuidLength = 36 + if len(name) < uuidLength { + return "" + } + candidate := name[len(name)-uuidLength:] + id, err := uuid.Parse(candidate) + if err != nil { + return "" + } + return id.String() +} + // buildCodexSession assembles one Session from a Codex session's tool uses and // metadata sidecar. func buildCodexSession(uses []history.ToolUse, info *history.CodexSessionInfo) *Session { @@ -418,9 +458,7 @@ func (b *codexTurnBuilder) addMessage(u history.ToolUse, messageID string) { return } turn.MessageIDs = appendUnique(turn.MessageIDs, messageID) - if u.Model != "" { - turn.Model = u.Model - } + observeCodexTurnRuntime(turn, u) } func (b *codexTurnBuilder) addEvent(u history.ToolUse, ev Event) { @@ -435,9 +473,7 @@ func (b *codexTurnBuilder) addEvent(u history.ToolUse, ev Event) { turn.EndedAt = cloneTime(u.Timestamp) } turn.Events = append(turn.Events, ev) - if u.Model != "" { - turn.Model = u.Model - } + observeCodexTurnRuntime(turn, u) } func (b *codexTurnBuilder) addUsage(u history.ToolUse, cost api.Cost) { @@ -450,9 +486,16 @@ func (b *codexTurnBuilder) addUsage(u history.ToolUse, cost api.Cost) { if ctx := codexContextFromUse(u); ctx != nil { turn.Context = ctx } + observeCodexTurnRuntime(turn, u) +} + +func observeCodexTurnRuntime(turn *Turn, u history.ToolUse) { if u.Model != "" { turn.Model = u.Model } + if u.ReasoningEffort != "" { + turn.ReasoningEffort = u.ReasoningEffort + } } func (b *codexTurnBuilder) finish() []Turn { diff --git a/pkg/session/build_codex_test.go b/pkg/session/build_codex_test.go index cb1fb2e..9a25030 100644 --- a/pkg/session/build_codex_test.go +++ b/pkg/session/build_codex_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/flanksource/captain/pkg/ai/history" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestBuildCodexSession_MapsMessagesFilesAndMeta(t *testing.T) { @@ -224,6 +226,9 @@ func TestBuildCodexSession_RichCodexMetadata(t *testing.T) { if len(s.Turns) != 1 || s.Turns[0].ID != "turn-rich" || s.Turns[0].Usage.TotalTokens() != 1050 { t.Fatalf("turns = %+v", s.Turns) } + if s.Turns[0].ReasoningEffort != "xhigh" { + t.Fatalf("turn effort = %q, want xhigh", s.Turns[0].ReasoningEffort) + } if len(s.Agents) != 2 || s.Agents[1].ID != "agent-1" || s.Agents[1].Type != "worker" || s.Agents[1].Desc != "Fix lint" { t.Fatalf("agents = %+v", s.Agents) } @@ -273,3 +278,100 @@ func TestBuildCodex_AttachesHistoryFile(t *testing.T) { t.Fatalf("root history file = %+v, want %q", sessions[0].Root, file) } } + +func TestBuildCodex_IgnoresAutoReviewSession(t *testing.T) { + file := filepath.Join(t.TempDir(), "review.jsonl") + stream := strings.Join([]string{ + `{"timestamp":"2026-07-13T09:00:00Z","type":"session_meta","payload":{"id":"review-1","cwd":"/repo"}}`, + `{"timestamp":"2026-07-13T09:00:01Z","type":"turn_context","payload":{"turn_id":"turn-review","model":"codex-auto-review","effort":"low"}}`, + `{"timestamp":"2026-07-13T09:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"approved"}]}}`, + }, "\n") + require.NoError(t, os.WriteFile(file, []byte(stream), 0o600)) + + assert.Empty(t, BuildCodex([]string{file})) +} + +func TestBuildCodex_RecoversSessionIDFromRolloutFilename(t *testing.T) { + const sessionID = "019edeb3-449e-7af3-b300-7123f10944b2" + file := filepath.Join(t.TempDir(), "rollout-2026-06-19T10-05-51-"+sessionID+".jsonl") + stream := `{"timestamp":"2026-06-19T07:05:52.061Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"check the change"}]}}` + require.NoError(t, os.WriteFile(file, []byte(stream), 0o600)) + + sessions := BuildCodex([]string{file}) + require.Len(t, sessions, 1) + assert.Equal(t, sessionID, sessions[0].ID) + require.NotNil(t, sessions[0].Root) + assert.Equal(t, sessionID, sessions[0].Root.ID) + require.Len(t, sessions[0].Messages, 1) + require.NotNil(t, sessions[0].Messages[0].Provenance) + assert.Equal(t, sessionID, sessions[0].Messages[0].Provenance.SessionID) +} + +func TestBuildCodexSession_UserShellCommandIsNonOperationalEvent(t *testing.T) { + ts := time.Date(2026, 7, 13, 6, 14, 1, 0, time.UTC) + input := map[string]any{ + "event": "user_shell_command", + "command": "gavel proc restart", + "exit_code": 1, + "duration_seconds": 2.9909, + "duration_ms": 2990.9, + "output": "Kill sent but port 8088 is still bound", + "stdout": "Kill sent but port 8088 is still bound", + "status": "failed", + } + uses := []history.ToolUse{{ + Tool: "UserShellCommand", Input: input, Timestamp: &ts, + SessionID: "sess-shell", Source: "codex", + }} + + s := buildCodexSession(uses, &history.CodexSessionInfo{ID: "sess-shell", CWD: "/repo"}) + if len(s.Events) != 1 || s.Events[0].Type != "user_shell_command" { + t.Fatalf("events = %+v, want one user_shell_command", s.Events) + } + if len(s.Messages) != 0 { + t.Fatalf("messages = %+v, user shell command must not be an assistant tool call", s.Messages) + } + if s.Events[0].Data["command"] != "gavel proc restart" || s.Events[0].Data["exit_code"] != 1 || s.Events[0].Data["duration_ms"] != 2990.9 || s.Events[0].Data["output"] != "Kill sent but port 8088 is still bound" { + t.Fatalf("event data = %+v", s.Events[0].Data) + } + if !isNonApprovalActivity("UserShellCommand") || isOperationalToolActivity("UserShellCommand") { + t.Fatal("UserShellCommand must be non-operational activity") + } +} + +func TestBuildCodexSession_WaitIsConversationalToolWithOutput(t *testing.T) { + ts := time.Date(2026, 7, 13, 9, 41, 33, 611000000, time.UTC) + uses := []history.ToolUse{{ + Tool: "Wait", + Input: map[string]any{ + "cell_id": "214", + "yield_time_ms": float64(20000), + "max_tokens": float64(5000), + }, + Response: "evaluation failed\nexit=1", Timestamp: &ts, + ToolUseID: "call-wait", TurnID: "turn-wait", SessionID: "sess-wait", + Source: "codex", Model: "gpt-5.6-sol", ReasoningEffort: "max", + }} + + s := buildCodexSession(uses, &history.CodexSessionInfo{ID: "sess-wait", CWD: "/repo"}) + if len(s.Events) != 0 { + t.Fatalf("events = %+v, Wait must remain transcript activity", s.Events) + } + if len(s.Messages) != 1 || !IsConversationalMessage(s.Messages[0]) { + t.Fatalf("messages = %+v, want one conversational Wait", s.Messages) + } + message := s.Messages[0] + if message.TurnID != "turn-wait" || message.Provenance == nil || message.Provenance.Model != "gpt-5.6-sol" || message.Provenance.ReasoningEffort != "max" { + t.Fatalf("metadata = %+v", message) + } + part := message.Parts[0] + if part.ToolName != "Wait" || part.ToolCallID != "call-wait" || part.State != ToolStateOutputAvailable { + t.Fatalf("part = %+v", part) + } + if string(part.Input) != `{"cell_id":"214","max_tokens":5000,"yield_time_ms":20000}` { + t.Fatalf("input = %s", part.Input) + } + if string(part.Output) != `"evaluation failed\nexit=1"` { + t.Fatalf("output = %s", part.Output) + } +} diff --git a/pkg/session/pretty.go b/pkg/session/pretty.go index 8c7f626..c67a4e3 100644 --- a/pkg/session/pretty.go +++ b/pkg/session/pretty.go @@ -100,7 +100,7 @@ func sessionSummaryRows(s *Session) []prettyKVRow { func prettyKV(label, value string) clickyapi.Text { return clickyapi.Text{}. Append(" "+label+": ", "text-gray-500"). - Append(value, "text-gray-700") + Append(value, "text-muted") } type historyFileRow struct { @@ -403,110 +403,3 @@ func messageAgentID(m Message) string { } return "" } - -func prettyAgentName(a *Agent) string { - if a == nil { - return "" - } - if a.Desc != "" { - return truncatePretty(a.Desc, 48) - } - if a.Type != "" { - return a.Type - } - if a.ID != "" { - return shortPrettyID(a.ID) - } - return "agent" -} - -func agentHistoryFile(a *Agent) string { - if a == nil { - return "" - } - return a.HistoryFile -} - -func countSessionToolParts(messages []Message) int { - total := 0 - for _, m := range messages { - for _, p := range m.Parts { - if p.ToolName != "" && (p.Type == PartTool || strings.HasPrefix(p.Type, "tool-")) { - total++ - } - } - } - return total -} - -func prettyGit(g GitState) string { - parts := make([]string, 0, 3) - if g.Branch != "" { - parts = append(parts, g.Branch) - } - if g.Commit != "" { - parts = append(parts, shortPrettyID(g.Commit)) - } - if g.Worktree != "" { - parts = append(parts, g.Worktree) - } - return strings.Join(parts, " ") -} - -func prettyTime(t *time.Time) string { - if t == nil || t.IsZero() { - return "" - } - return t.Format("2006-01-02 15:04:05") -} - -func prettyDuration(start, end *time.Time) string { - if start == nil || end == nil || start.IsZero() || end.IsZero() { - return "" - } - d := end.Sub(*start) - if d < 0 { - return "" - } - return d.Round(time.Second).String() -} - -func shortPrettyID(id string) string { - id = strings.TrimSpace(id) - if len(id) <= 12 { - return id - } - return id[:12] -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func truncatePretty(s string, max int) string { - s = compactWhitespace(s) - if max <= 0 || len(s) <= max { - return s - } - if max <= 3 { - return s[:max] - } - return s[:max-3] + "..." -} - -func compactWhitespace(s string) string { - return strings.Join(strings.Fields(strings.TrimSpace(s)), " ") -} - -func textValue(s string) clickyapi.Textable { - return clickyapi.Text{Content: s} -} - -func cellValue(s string) clickyapi.TypedValue { - return clickyapi.TypedValue{Textable: clickyapi.Text{Content: s}} -} diff --git a/pkg/session/pretty_helpers.go b/pkg/session/pretty_helpers.go new file mode 100644 index 0000000..705ff4a --- /dev/null +++ b/pkg/session/pretty_helpers.go @@ -0,0 +1,115 @@ +package session + +import ( + "strings" + "time" + + clickyapi "github.com/flanksource/clicky/api" +) + +func prettyAgentName(a *Agent) string { + if a == nil { + return "" + } + if a.Desc != "" { + return truncatePretty(a.Desc, 48) + } + if a.Type != "" { + return a.Type + } + if a.ID != "" { + return shortPrettyID(a.ID) + } + return "agent" +} + +func agentHistoryFile(a *Agent) string { + if a == nil { + return "" + } + return a.HistoryFile +} + +func countSessionToolParts(messages []Message) int { + total := 0 + for _, m := range messages { + for _, p := range m.Parts { + if p.ToolName != "" && (p.Type == PartTool || strings.HasPrefix(p.Type, "tool-")) { + total++ + } + } + } + return total +} + +func prettyGit(g GitState) string { + parts := make([]string, 0, 3) + if g.Branch != "" { + parts = append(parts, g.Branch) + } + if g.Commit != "" { + parts = append(parts, shortPrettyID(g.Commit)) + } + if g.Worktree != "" { + parts = append(parts, g.Worktree) + } + return strings.Join(parts, " ") +} + +func prettyTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +func prettyDuration(start, end *time.Time) string { + if start == nil || end == nil || start.IsZero() || end.IsZero() { + return "" + } + d := end.Sub(*start) + if d < 0 { + return "" + } + return d.Round(time.Second).String() +} + +func shortPrettyID(id string) string { + id = strings.TrimSpace(id) + if len(id) <= 12 { + return id + } + return id[:12] +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func truncatePretty(s string, max int) string { + s = compactWhitespace(s) + if max <= 0 || len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + +func compactWhitespace(s string) string { + return strings.Join(strings.Fields(strings.TrimSpace(s)), " ") +} + +func textValue(s string) clickyapi.Textable { + return clickyapi.Text{Content: s} +} + +func cellValue(s string) clickyapi.TypedValue { + return clickyapi.TypedValue{Textable: clickyapi.Text{Content: s}} +} diff --git a/pkg/session/pretty_muted_test.go b/pkg/session/pretty_muted_test.go new file mode 100644 index 0000000..7b0a5e3 --- /dev/null +++ b/pkg/session/pretty_muted_test.go @@ -0,0 +1,32 @@ +package session + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPrettyMuted(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Session Pretty Muted Suite") +} + +var _ = ginkgo.Describe("session Pretty muted styles", func() { + ginkgo.It("renders summary values and assistant payloads without low-contrast palette classes", func() { + session := &Session{ + ID: "session-id", + Project: "tenant-x", + Messages: []Message{{ + Role: "assistant", + Parts: []Part{{Type: PartText, Text: "assistant payload"}}, + }}, + } + + rendered := session.Pretty() + Expect(rendered.HTML()).NotTo(MatchRegexp(`text-gray-(600|700)`)) + Expect(rendered.HTML()).To(ContainSubstring("text-muted")) + Expect(rendered.String()).To(ContainSubstring("tenant-x")) + Expect(rendered.String()).To(ContainSubstring("assistant payload")) + }) +}) diff --git a/pkg/session/session.go b/pkg/session/session.go index bbdb189..e782b31 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -111,18 +111,19 @@ type Event struct { // Turn groups user/assistant messages, tool calls, usage, cost, and contextual // state for one model turn. type Turn struct { - ID string `json:"id"` - Index int `json:"index"` - StartedAt *time.Time `json:"startedAt,omitempty"` - EndedAt *time.Time `json:"endedAt,omitempty"` - StopReason string `json:"stopReason,omitempty"` - Model string `json:"model,omitempty"` - MessageIDs []string `json:"messageIds,omitempty"` - Usage api.Usage `json:"usage,omitempty"` - Cost api.Cost `json:"cost,omitempty"` - Context *Context `json:"context,omitempty"` - Budget *Budget `json:"budget,omitempty"` - Events []Event `json:"events,omitempty"` + ID string `json:"id"` + Index int `json:"index"` + StartedAt *time.Time `json:"startedAt,omitempty"` + EndedAt *time.Time `json:"endedAt,omitempty"` + StopReason string `json:"stopReason,omitempty"` + Model string `json:"model,omitempty"` + ReasoningEffort string `json:"reasoningEffort,omitempty"` + MessageIDs []string `json:"messageIds,omitempty"` + Usage api.Usage `json:"usage,omitempty"` + Cost api.Cost `json:"cost,omitempty"` + Context *Context `json:"context,omitempty"` + Budget *Budget `json:"budget,omitempty"` + Events []Event `json:"events,omitempty"` } // ChangedFiles is the read/write file set aggregated across a session, diff --git a/pkg/session/turns.go b/pkg/session/turns.go index 1a362e5..18b7514 100644 --- a/pkg/session/turns.go +++ b/pkg/session/turns.go @@ -26,13 +26,25 @@ type sessionMetadataBuild struct { func buildSessionMetadata(source string, entries []claude.HistoryEntry) sessionMetadataBuild { b := sessionMetadataBuild{turnByEntry: map[string]string{}} var current *Turn + var latestTurnTime *time.Time var turnSeq int + seenEntries := map[string]struct{}{} + + observeTurnTime := func(turn *Turn, ts *time.Time) { + if turn == nil || ts == nil { + return + } + if turn.StartedAt == nil || ts.Before(*turn.StartedAt) { + turn.StartedAt = cloneTime(ts) + } + if latestTurnTime == nil || ts.After(*latestTurnTime) { + latestTurnTime = cloneTime(ts) + } + } startTurn := func(ts *time.Time) *Turn { if current != nil { - if current.StartedAt == nil && ts != nil { - current.StartedAt = cloneTime(ts) - } + observeTurnTime(current, ts) return current } turnSeq++ @@ -40,26 +52,35 @@ func buildSessionMetadata(source string, entries []claude.HistoryEntry) sessionM ID: fmt.Sprintf("turn-%d", turnSeq), Index: turnSeq, } - if ts != nil { - current.StartedAt = cloneTime(ts) - } + observeTurnTime(current, ts) return current } finishTurn := func(ts *time.Time) { if current == nil { return } + observeTurnTime(current, ts) if ts != nil { - current.EndedAt = cloneTime(ts) + current.EndedAt = cloneTime(latestTurnTime) } if current.Context == nil { setTurnContext(current, source) } b.turns = append(b.turns, *current) current = nil + latestTurnTime = nil } for _, entry := range entries { + // Claude can append a previously recorded branch to the same JSONL file. + // UUID is the durable entry identity, so replaying it must not create a + // second turn boundary or charge the entry to a later turn. + if entry.UUID != "" { + if _, ok := seenEntries[entry.UUID]; ok { + continue + } + seenEntries[entry.UUID] = struct{}{} + } ts := entryTime(entry) if entry.Event != nil { ev := eventFromEntry(entry) diff --git a/pkg/session/turns_test.go b/pkg/session/turns_test.go new file mode 100644 index 0000000..82da4bd --- /dev/null +++ b/pkg/session/turns_test.go @@ -0,0 +1,54 @@ +package session + +import ( + "testing" + "time" + + "github.com/flanksource/captain/pkg/claude" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildSessionMetadataIgnoresReplayedClaudeBranch(t *testing.T) { + entries := []claude.HistoryEntry{ + claudeTurnEntry("user-1", "2026-07-09T10:00:00Z", claude.MessageRoleUser, ""), + claudeTurnEntry("assistant-1", "2026-07-09T10:00:05Z", claude.MessageRoleAssistant, claude.StopReasonEndTurn), + claudeTurnEntry("user-2", "2026-07-09T11:00:00Z", claude.MessageRoleUser, ""), + // Claude branch restoration can append an older, already recorded stop. + claudeTurnEntry("assistant-1", "2026-07-09T10:00:05Z", claude.MessageRoleAssistant, claude.StopReasonEndTurn), + claudeTurnEntry("assistant-2", "2026-07-09T11:00:05Z", claude.MessageRoleAssistant, claude.StopReasonEndTurn), + } + + meta := buildSessionMetadata("claude", entries) + require.Len(t, meta.turns, 2) + assert.Equal(t, []string{"user-1", "assistant-1"}, meta.turns[0].MessageIDs) + assert.Equal(t, []string{"user-2", "assistant-2"}, meta.turns[1].MessageIDs) + assert.False(t, meta.turns[1].EndedAt.Before(*meta.turns[1].StartedAt)) + assert.Equal(t, "turn-1", meta.turnByEntry["assistant-1"]) +} + +func TestBuildSessionMetadataBoundsOutOfOrderUniqueTimestamps(t *testing.T) { + entries := []claude.HistoryEntry{ + claudeTurnEntry("user", "2026-07-09T14:36:14Z", claude.MessageRoleUser, ""), + claudeTurnEntry("assistant", "2026-07-09T13:37:53Z", claude.MessageRoleAssistant, claude.StopReasonEndTurn), + } + + meta := buildSessionMetadata("claude", entries) + require.Len(t, meta.turns, 1) + require.NotNil(t, meta.turns[0].StartedAt) + require.NotNil(t, meta.turns[0].EndedAt) + assert.Equal(t, time.Date(2026, 7, 9, 13, 37, 53, 0, time.UTC), *meta.turns[0].StartedAt) + assert.Equal(t, time.Date(2026, 7, 9, 14, 36, 14, 0, time.UTC), *meta.turns[0].EndedAt) +} + +func claudeTurnEntry(id, timestamp string, role claude.MessageRole, stopReason claude.StopReason) claude.HistoryEntry { + return claude.HistoryEntry{ + UUID: id, + Timestamp: timestamp, + Message: claude.Message{ + Role: role, + StopReason: stopReason, + Content: []claude.ContentBlock{{Type: claude.ContentTypeText, Text: id}}, + }, + } +} From c4524dfc64328d11d8c3a6cee848033b63a4535d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 15:01:51 +0300 Subject: [PATCH 048/131] feat scope:api, body:Implement captain serve hooks API endpoint and CLI commands to enable session monitoring through provider-specific hook notifications. Adds the ability for Claude Code and Codex to forward hook events (e.g., session start/stop) to a running captain serve instance via POST /api/captain/hooks/{provider}. Introduces environment-based configuration (CAPTAIN_SERVER_URL, CAPTAIN_MONITOR_HOOKS) to route events correctly across different serve instances and allow opt-out for bare/fixture runs. Includes comprehensive tests for hook event validation and routing.(api): add session monitoring hooks infrastructure --- cmd/captain/main.go | 22 +++++++++++++++++ pkg/api/serve.go | 38 +++++++++++++++++++++++++++++ pkg/api/serve_test.go | 25 +++++++++++++++++++ pkg/cli/serve.go | 8 +++++++ pkg/cli/serve_hooks.go | 42 ++++++++++++++++++++++++++++++++ pkg/cli/serve_hooks_test.go | 48 +++++++++++++++++++++++++++++++++++++ 6 files changed, 183 insertions(+) create mode 100644 pkg/api/serve.go create mode 100644 pkg/api/serve_test.go create mode 100644 pkg/cli/serve_hooks.go create mode 100644 pkg/cli/serve_hooks_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index d64938b..d667dc7 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -155,6 +155,28 @@ func main() { hookCmd.AddCommand(dodHookCmd) clicky.AddNamedCommand("install", dodHookCmd, cli.HookInstallOptions{}, cli.RunDodInstall) + monitorHookCmd := &cobra.Command{Use: "monitor", Short: "Session monitoring hooks (hooks-first session tracking)"} + hookCmd.AddCommand(monitorHookCmd) + monitorNotifyCmd := &cobra.Command{ + Use: "notify", + Short: "Forward one provider hook event to captain serve", + Long: "Hook receiver for session monitoring: Claude Code lifecycle hooks pipe their JSON payload " + + "on stdin, codex notify appends its payload as the final argument. The event is POSTed to the " + + "running captain serve instance ($CAPTAIN_SERVER_URL or http://localhost:9020) with a 1s timeout " + + "and always exits 0 — when serve is unreachable the event is dropped and the daily recon reconciles it.", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + provider, _ := cmd.Flags().GetString("provider") + url, _ := cmd.Flags().GetString("url") + return cli.RunHookMonitorNotify(cli.HookMonitorNotifyOptions{Provider: provider, URL: url}, args) + }, + } + monitorNotifyCmd.Flags().String("provider", "claude", "Hook payload provider: claude or codex") + monitorNotifyCmd.Flags().String("url", "", "Captain serve base URL (default $CAPTAIN_SERVER_URL or http://localhost:9020)") + monitorHookCmd.AddCommand(monitorNotifyCmd) + monitorInstallCmd := clicky.AddNamedCommand("install", monitorHookCmd, cli.HookMonitorInstallOptions{}, cli.RunHookMonitorInstall) + monitorInstallCmd.Short = "Install session-monitoring hooks into ~/.claude/settings.json and ~/.codex/config.toml" + projectsCmd := &cobra.Command{Use: "projects", Short: "Manage Claude Code project sessions"} rootCmd.AddCommand(projectsCmd) clicky.AddNamedCommand("list", projectsCmd, cli.ProjectsListOptions{}, cli.RunProjectsList) diff --git a/pkg/api/serve.go b/pkg/api/serve.go new file mode 100644 index 0000000..e4fb55d --- /dev/null +++ b/pkg/api/serve.go @@ -0,0 +1,38 @@ +package api + +import ( + "os" + "strings" +) + +// ServeURLEnv names the environment variable captain serve exports with its +// listen address, so hook receivers and captain-launched agents find the right +// instance even off the default port. +const ServeURLEnv = "CAPTAIN_SERVER_URL" + +// DefaultServeURL is captain serve's default listen address. +const DefaultServeURL = "http://localhost:9020" + +// MonitorHooksEnv disables captain's session-monitoring hook injection when +// set to "off" — for fixtures and CI runs that need deterministic agent argv. +const MonitorHooksEnv = "CAPTAIN_MONITOR_HOOKS" + +// ServeBaseURL resolves the running captain serve instance: $CAPTAIN_SERVER_URL +// when set (exported by serve itself), else the default port on localhost. +func ServeBaseURL() string { + if url := strings.TrimSpace(os.Getenv(ServeURLEnv)); url != "" { + return url + } + return DefaultServeURL +} + +// MonitorHooksEnabled reports whether captain injects its session-monitoring +// hooks into an agent it launches. The hooks are captain infrastructure, not +// user hooks — Memory.SkipHooks does not suppress them. Bare runs and +// CAPTAIN_MONITOR_HOOKS=off opt out. +func MonitorHooksEnabled(spec Spec) bool { + if os.Getenv(MonitorHooksEnv) == "off" { + return false + } + return !spec.Memory.Bare && !spec.Permissions.HasPreset(PresetBare) +} diff --git a/pkg/api/serve_test.go b/pkg/api/serve_test.go new file mode 100644 index 0000000..138a916 --- /dev/null +++ b/pkg/api/serve_test.go @@ -0,0 +1,25 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestServeBaseURL(t *testing.T) { + t.Setenv(ServeURLEnv, "") + assert.Equal(t, DefaultServeURL, ServeBaseURL()) + t.Setenv(ServeURLEnv, "http://localhost:9999") + assert.Equal(t, "http://localhost:9999", ServeBaseURL()) +} + +func TestMonitorHooksEnabled(t *testing.T) { + t.Setenv(MonitorHooksEnv, "") + assert.True(t, MonitorHooksEnabled(Spec{}), "default requests carry monitoring hooks") + assert.True(t, MonitorHooksEnabled(Spec{Memory: Memory{SkipHooks: true}}), + "SkipHooks governs user hooks, not captain's monitoring hooks") + assert.False(t, MonitorHooksEnabled(Spec{Memory: Memory{Bare: true}}), "bare runs opt out") + + t.Setenv(MonitorHooksEnv, "off") + assert.False(t, MonitorHooksEnabled(Spec{}), "CAPTAIN_MONITOR_HOOKS=off opts out") +} diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index b484997..b38b030 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -19,6 +19,7 @@ import ( "syscall" "time" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/monitor" "github.com/flanksource/clicky/aichat" @@ -166,6 +167,7 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve mux.HandleFunc("GET /api/captain/sessions/live", handleSessionsLive()) mux.HandleFunc("GET /api/captain/sessions/throughput", handleSessionsThroughput()) mux.HandleFunc("GET /api/captain/sessions/{id}", handleSessionGet()) + mux.HandleFunc("POST /api/captain/hooks/{provider}", handleMonitorHookEvent()) mux.HandleFunc("GET /api/captain/ai/permissions/catalog", handlePermissionCatalog(cwd)) mux.HandleFunc("GET /api/captain/ai/prompt/schema", handlePromptSchema()) mux.HandleFunc("GET /api/captain/secrets/resources", handleSecretResources()) @@ -187,6 +189,12 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve mux.Handle("/", uiHandler) addr := fmt.Sprintf("%s:%d", opts.Host, opts.Port) + // Export the serve URL so every captain-launched agent (and the hook + // receiver subprocesses its sessions spawn) delivers hook events to this + // instance even off the default port. + if err := os.Setenv(api.ServeURLEnv, "http://"+addr); err != nil { + return err + } httpSrv := &http.Server{ Addr: addr, Handler: rpchttp.TimingMiddleware(PromptDirsMiddleware(mux, opts.PromptDirs)), diff --git a/pkg/cli/serve_hooks.go b/pkg/cli/serve_hooks.go new file mode 100644 index 0000000..3fe7d98 --- /dev/null +++ b/pkg/cli/serve_hooks.go @@ -0,0 +1,42 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/flanksource/captain/pkg/monitor" +) + +// handleMonitorHookEvent accepts normalized hook events from `captain hook +// monitor notify` and enqueues them on the serve monitor. Delivery is +// fire-and-forget for the sender: 202 means enqueued, not ingested. +func handleMonitorHookEvent() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + provider := strings.TrimSpace(r.PathValue("provider")) + if provider != "claude" && provider != "codex" { + http.Error(w, fmt.Sprintf("unknown hook provider %q", provider), http.StatusBadRequest) + return + } + var ev monitor.HookEvent + if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { + http.Error(w, "invalid hook event payload: "+err.Error(), http.StatusBadRequest) + return + } + if strings.TrimSpace(ev.Event) == "" { + http.Error(w, "hook event name is required", http.StatusBadRequest) + return + } + mon := serveMonitor() + if mon == nil { + http.Error(w, "session monitor is not running", http.StatusServiceUnavailable) + return + } + ev.Provider = provider + ev.ReceivedAt = time.Now().UTC() + mon.NotifyHookEvent(ev) + w.WriteHeader(http.StatusAccepted) + } +} diff --git a/pkg/cli/serve_hooks_test.go b/pkg/cli/serve_hooks_test.go new file mode 100644 index 0000000..8d078b7 --- /dev/null +++ b/pkg/cli/serve_hooks_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/monitor" + "github.com/stretchr/testify/assert" +) + +func postHookEventRequest(t *testing.T, provider, body string) *httptest.ResponseRecorder { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /api/captain/hooks/{provider}", handleMonitorHookEvent()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/captain/hooks/"+provider, strings.NewReader(body)) + mux.ServeHTTP(rec, req) + return rec +} + +func TestHandleMonitorHookEvent(t *testing.T) { + validBody := `{"event":"Stop","sessionId":"abc123","transcriptPath":"/p.jsonl"}` + + t.Run("unknown provider is rejected", func(t *testing.T) { + assert.Equal(t, http.StatusBadRequest, postHookEventRequest(t, "gemini", validBody).Code) + }) + + t.Run("malformed body is rejected", func(t *testing.T) { + assert.Equal(t, http.StatusBadRequest, postHookEventRequest(t, "claude", "not json").Code) + }) + + t.Run("missing event name is rejected", func(t *testing.T) { + assert.Equal(t, http.StatusBadRequest, postHookEventRequest(t, "claude", `{"sessionId":"abc123"}`).Code) + }) + + t.Run("no running monitor is unavailable", func(t *testing.T) { + setServeMonitor(nil) + assert.Equal(t, http.StatusServiceUnavailable, postHookEventRequest(t, "claude", validBody).Code) + }) + + t.Run("valid event is accepted", func(t *testing.T) { + setServeMonitor(&monitor.Monitor{}) + t.Cleanup(func() { setServeMonitor(nil) }) + assert.Equal(t, http.StatusAccepted, postHookEventRequest(t, "claude", validBody).Code) + }) +} From 70d11bb1ca82583daa910f2789518c9c94d3c77c Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 15:01:53 +0300 Subject: [PATCH 049/131] feat(monitor): add provider hooks for real-time session tracking --- pkg/ai/provider/monitor_hooks.go | 95 +++++++++++++ pkg/ai/provider/monitor_hooks_test.go | 152 ++++++++++++++++++++ pkg/claude/hooks.go | 7 + pkg/claude/hooks_test.go | 105 ++++++++++++++ pkg/claude/types.go | 2 + pkg/cli/hook_monitor.go | 139 +++++++++++++++++++ pkg/cli/hook_monitor_test.go | 52 +++++++ pkg/monitor/hookpayload.go | 93 +++++++++++++ pkg/monitor/hookpayload_test.go | 86 ++++++++++++ pkg/monitor/hooks.go | 192 ++++++++++++++++++++++++++ pkg/monitor/hooks_integration_test.go | 89 ++++++++++++ pkg/monitor/hooks_test.go | 76 ++++++++++ pkg/monitor/maintenance.go | 41 ++++++ pkg/monitor/monitor.go | 93 +++++++++++-- pkg/monitor/process.go | 3 + 15 files changed, 1215 insertions(+), 10 deletions(-) create mode 100644 pkg/ai/provider/monitor_hooks.go create mode 100644 pkg/ai/provider/monitor_hooks_test.go create mode 100644 pkg/cli/hook_monitor.go create mode 100644 pkg/cli/hook_monitor_test.go create mode 100644 pkg/monitor/hookpayload.go create mode 100644 pkg/monitor/hookpayload_test.go create mode 100644 pkg/monitor/hooks.go create mode 100644 pkg/monitor/hooks_integration_test.go create mode 100644 pkg/monitor/hooks_test.go create mode 100644 pkg/monitor/maintenance.go diff --git a/pkg/ai/provider/monitor_hooks.go b/pkg/ai/provider/monitor_hooks.go new file mode 100644 index 0000000..f70fcde --- /dev/null +++ b/pkg/ai/provider/monitor_hooks.go @@ -0,0 +1,95 @@ +package provider + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/claude" +) + +// monitorHookTimeout is the settings.json timeout for the injected hooks; the +// receiver itself delivers-or-drops within 1s, this is only the safety bound. +const monitorHookTimeout = 10 + +// monitorHookEvents are the Claude Code lifecycle events captain subscribes to +// for session monitoring (matching `captain hook monitor install`). +var monitorHookEvents = []claude.HookEventType{ + claude.HookEventSessionStart, + claude.HookEventUserPromptSubmit, + claude.HookEventStop, + claude.HookEventSubagentStop, + claude.HookEventSessionEnd, +} + +// captainBinary resolves the binary the injected hooks invoke. This package is +// also consumed as a library from other binaries (gavel, tests), where +// os.Executable is not captain; fall back to PATH, and report false when no +// captain exists — injection is then skipped and the user-level hook install +// plus the monitor's recon cover those sessions. +func captainBinary() (string, bool) { + path, err := os.Executable() + if err == nil && strings.HasPrefix(filepath.Base(path), "captain") { + return path, true + } + if found, err := exec.LookPath("captain"); err == nil { + return found, true + } + return "", false +} + +// claudeMonitorSettings is the --settings document injected into +// captain-launched claude CLI sessions so they report lifecycle events even +// without the user-level hook install. +func claudeMonitorSettings(binary string) ([]byte, error) { + command := fmt.Sprintf("%s hook monitor notify --provider claude", binary) + hooks := claude.HooksConfig{Hooks: map[claude.HookEventType][]claude.HookMatcher{}} + for _, event := range monitorHookEvents { + hooks.Hooks[event] = []claude.HookMatcher{{ + Hooks: []claude.Hook{{Type: claude.HookTypeCommand, Command: command, Timeout: monitorHookTimeout}}, + }} + } + data, err := json.Marshal(hooks) + if err != nil { + return nil, fmt.Errorf("encode monitor hook settings: %w", err) + } + return data, nil +} + +// writeClaudeMonitorSettings writes the --settings document to a temp file and +// returns its path with a cleanup, mirroring writeTempSchema. +func writeClaudeMonitorSettings(binary string) (string, func(), error) { + data, err := claudeMonitorSettings(binary) + if err != nil { + return "", nil, err + } + f, err := os.CreateTemp("", "captain-claude-hooks-*.json") + if err != nil { + return "", nil, fmt.Errorf("claude-cli: create monitor hooks settings file: %w", err) + } + path := f.Name() + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = os.Remove(path) + return "", nil, fmt.Errorf("claude-cli: write monitor hooks settings: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(path) + return "", nil, fmt.Errorf("claude-cli: close monitor hooks settings: %w", err) + } + return path, func() { _ = os.Remove(path) }, nil +} + +// codexNotifyOverride renders the -c override installing captain as codex's +// notify program for one invocation. A JSON string array is valid TOML. +func codexNotifyOverride(binary string) (string, error) { + argv := []string{binary, "hook", "monitor", "notify", "--provider", "codex"} + value, err := json.Marshal(argv) + if err != nil { + return "", fmt.Errorf("encode codex notify override: %w", err) + } + return "notify=" + string(value), nil +} diff --git a/pkg/ai/provider/monitor_hooks_test.go b/pkg/ai/provider/monitor_hooks_test.go new file mode 100644 index 0000000..087635d --- /dev/null +++ b/pkg/ai/provider/monitor_hooks_test.go @@ -0,0 +1,152 @@ +package provider + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/claude" +) + +// fakeCaptainOnPath makes captainBinary() resolve deterministically in tests: +// the test binary is not captain-named, so PATH lookup finds this fake. +func fakeCaptainOnPath(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "captain") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + t.Setenv(api.MonitorHooksEnv, "") + return path +} + +func monitorHookEventNames() []string { + names := make([]string, 0, len(monitorHookEvents)) + for _, event := range monitorHookEvents { + names = append(names, string(event)) + } + return names +} + +func TestClaudeCLIMonitorHooksInjection(t *testing.T) { + binary := fakeCaptainOnPath(t) + req := ai.Request{Prompt: api.Prompt{User: "hi"}} + + args, cleanup, err := buildClaudeCLIArgs("claude-sonnet-5", req) + if err != nil { + t.Fatalf("buildClaudeCLIArgs: %v", err) + } + settingsPath := flagValue(t, args, "--settings") + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("read injected settings: %v", err) + } + var config claude.HooksConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("parse injected settings: %v", err) + } + for _, event := range monitorHookEventNames() { + matchers := config.Hooks[claude.HookEventType(event)] + if len(matchers) != 1 || len(matchers[0].Hooks) != 1 { + t.Fatalf("event %s: expected exactly one hook, got %+v", event, matchers) + } + command := matchers[0].Hooks[0].Command + if command != binary+" hook monitor notify --provider claude" { + t.Fatalf("event %s: unexpected command %q", event, command) + } + } + + cleanup() + if _, err := os.Stat(settingsPath); !os.IsNotExist(err) { + t.Fatalf("cleanup must remove the temp settings file") + } + + t.Run("bare request suppresses injection", func(t *testing.T) { + bare := req + bare.Memory.Bare = true + args, cleanup, err := buildClaudeCLIArgs("claude-sonnet-5", bare) + if err != nil { + t.Fatal(err) + } + defer cleanup() + assertNoFlag(t, args, "--settings") + }) + + t.Run("CAPTAIN_MONITOR_HOOKS=off suppresses injection", func(t *testing.T) { + t.Setenv(api.MonitorHooksEnv, "off") + args, cleanup, err := buildClaudeCLIArgs("claude-sonnet-5", req) + if err != nil { + t.Fatal(err) + } + defer cleanup() + assertNoFlag(t, args, "--settings") + }) +} + +func TestCodexCLIMonitorHooksInjection(t *testing.T) { + binary := fakeCaptainOnPath(t) + req := ai.Request{Prompt: api.Prompt{User: "hi"}} + + args, cleanup, err := buildCodexCLIArgs("gpt-5", req) + if err != nil { + t.Fatalf("buildCodexCLIArgs: %v", err) + } + defer cleanup() + + notify := codexOverrideValue(t, args, "notify=") + var argv []string + if err := json.Unmarshal([]byte(strings.TrimPrefix(notify, "notify=")), &argv); err != nil { + t.Fatalf("notify override is not a JSON string array: %v", err) + } + want := []string{binary, "hook", "monitor", "notify", "--provider", "codex"} + if len(argv) != len(want) { + t.Fatalf("notify argv = %v, want %v", argv, want) + } + for i := range want { + if argv[i] != want[i] { + t.Fatalf("notify argv = %v, want %v", argv, want) + } + } + + t.Run("CAPTAIN_MONITOR_HOOKS=off suppresses injection", func(t *testing.T) { + t.Setenv(api.MonitorHooksEnv, "off") + args, cleanup, err := buildCodexCLIArgs("gpt-5", req) + if err != nil { + t.Fatal(err) + } + defer cleanup() + for _, arg := range args { + if strings.HasPrefix(arg, "notify=") { + t.Fatalf("notify override must be suppressed, got args %v", args) + } + } + }) +} + +// codexOverrideValue returns the -c override value with the given prefix. +func codexOverrideValue(t *testing.T, args []string, prefix string) string { + t.Helper() + for i := 0; i < len(args)-1; i++ { + if args[i] == "-c" && strings.HasPrefix(args[i+1], prefix) { + return args[i+1] + } + } + t.Fatalf("no -c %s… override in args %v", prefix, args) + return "" +} + +func assertNoFlag(t *testing.T, args []string, flag string) { + t.Helper() + for _, arg := range args { + if arg == flag { + t.Fatalf("args must not contain %s: %v", flag, args) + } + } +} diff --git a/pkg/claude/hooks.go b/pkg/claude/hooks.go index 70f13ef..3e9dd5d 100644 --- a/pkg/claude/hooks.go +++ b/pkg/claude/hooks.go @@ -30,6 +30,13 @@ type HookInput struct { TranscriptPath string `json:"transcript_path,omitempty"` StopHookReason string `json:"stop_hook_reason,omitempty"` Prompt string `json:"prompt,omitempty"` + CWD string `json:"cwd,omitempty"` + HookEventName string `json:"hook_event_name,omitempty"` + // Source is set on SessionStart: startup|resume|clear|compact. + Source string `json:"source,omitempty"` + // Reason is set on SessionEnd: clear|resume|logout|prompt_input_exit|other. + Reason string `json:"reason,omitempty"` + LastAssistantMessage string `json:"last_assistant_message,omitempty"` } // HookOutput is the JSON returned by hooks diff --git a/pkg/claude/hooks_test.go b/pkg/claude/hooks_test.go index eec263f..1ba43c2 100644 --- a/pkg/claude/hooks_test.go +++ b/pkg/claude/hooks_test.go @@ -2,6 +2,7 @@ package claude import ( "encoding/json" + "reflect" "testing" ) @@ -107,6 +108,110 @@ func TestHookOutput_Marshal(t *testing.T) { } } +func TestHookInput_Unmarshal_LifecycleEvents(t *testing.T) { + cases := []struct { + name string + payload string + want HookInput + }{ + { + name: "SessionStart", + payload: `{ + "session_id": "abc123", + "transcript_path": "/Users/x/.claude/projects/-repo/abc123.jsonl", + "cwd": "/repo", + "hook_event_name": "SessionStart", + "source": "startup" + }`, + want: HookInput{ + SessionID: "abc123", + TranscriptPath: "/Users/x/.claude/projects/-repo/abc123.jsonl", + CWD: "/repo", + HookEventName: "SessionStart", + Source: "startup", + }, + }, + { + name: "SessionEnd", + payload: `{ + "session_id": "abc123", + "transcript_path": "/Users/x/.claude/projects/-repo/abc123.jsonl", + "cwd": "/repo", + "hook_event_name": "SessionEnd", + "reason": "prompt_input_exit" + }`, + want: HookInput{ + SessionID: "abc123", + TranscriptPath: "/Users/x/.claude/projects/-repo/abc123.jsonl", + CWD: "/repo", + HookEventName: "SessionEnd", + Reason: "prompt_input_exit", + }, + }, + { + name: "Stop", + payload: `{ + "session_id": "abc123", + "transcript_path": "/Users/x/.claude/projects/-repo/abc123.jsonl", + "cwd": "/repo", + "hook_event_name": "Stop", + "last_assistant_message": "done" + }`, + want: HookInput{ + SessionID: "abc123", + TranscriptPath: "/Users/x/.claude/projects/-repo/abc123.jsonl", + CWD: "/repo", + HookEventName: "Stop", + LastAssistantMessage: "done", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got HookInput + if err := json.Unmarshal([]byte(tc.payload), &got); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestHooksConfig_Marshal_SessionLifecycle(t *testing.T) { + config := HooksConfig{Hooks: map[HookEventType][]HookMatcher{ + HookEventSessionStart: {{Hooks: []Hook{{Type: HookTypeCommand, Command: "captain hook monitor notify --provider claude", Timeout: 10}}}}, + HookEventSessionEnd: {{Hooks: []Hook{{Type: HookTypeCommand, Command: "captain hook monitor notify --provider claude", Timeout: 10}}}}, + }} + + data, err := json.Marshal(config) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var parsed map[string]map[string][]map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + for _, event := range []string{"SessionStart", "SessionEnd"} { + matchers := parsed["hooks"][event] + if len(matchers) != 1 { + t.Fatalf("%s: expected 1 matcher, got %d", event, len(matchers)) + } + if _, exists := matchers[0]["matcher"]; exists { + t.Errorf("%s: matcher key should be omitted for lifecycle events", event) + } + hooks := matchers[0]["hooks"].([]any) + hook := hooks[0].(map[string]any) + if hook["type"] != "command" || hook["timeout"] != float64(10) { + t.Errorf("%s: unexpected hook entry: %+v", event, hook) + } + } +} + func TestHookOutput_Marshal_OmitsNil(t *testing.T) { output := HookOutput{Continue: true} diff --git a/pkg/claude/types.go b/pkg/claude/types.go index f407ba2..8c6ba86 100644 --- a/pkg/claude/types.go +++ b/pkg/claude/types.go @@ -10,6 +10,8 @@ const ( HookEventStop HookEventType = "Stop" HookEventSubagentStop HookEventType = "SubagentStop" HookEventUserPromptSubmit HookEventType = "UserPromptSubmit" + HookEventSessionStart HookEventType = "SessionStart" + HookEventSessionEnd HookEventType = "SessionEnd" ) // PermissionMode represents Claude's current permission mode diff --git a/pkg/cli/hook_monitor.go b/pkg/cli/hook_monitor.go new file mode 100644 index 0000000..68ae182 --- /dev/null +++ b/pkg/cli/hook_monitor.go @@ -0,0 +1,139 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/codexconfig" + "github.com/flanksource/captain/pkg/monitor" +) + +type HookMonitorNotifyOptions struct { + Provider string // claude | codex + URL string // serve base URL override; default monitor.ServeBaseURL() +} + +// hookNotifyTimeout bounds hook delivery so an agent turn is never held up: +// deliver within it or drop the event (recon reconciles drops). +const hookNotifyTimeout = time.Second + +// RunHookMonitorNotify is the hook receiver both providers invoke: Claude Code +// pipes the payload on stdin, codex appends it as the final argv argument. It +// always succeeds with empty stdout — a hook failure must never block or slow +// an agent turn, and Claude injects UserPromptSubmit hook stdout as context. +func RunHookMonitorNotify(opts HookMonitorNotifyOptions, args []string) error { + ev, err := readHookNotifyEvent(opts.Provider, args) + if err != nil { + fmt.Fprintf(os.Stderr, "captain hook monitor notify: %v\n", err) + return nil + } + baseURL := opts.URL + if baseURL == "" { + baseURL = api.ServeBaseURL() + } + ctx, cancel := context.WithTimeout(context.Background(), hookNotifyTimeout) + defer cancel() + if err := monitor.PostHookEvent(ctx, baseURL, ev); err != nil { + // Degraded mode by design: captain serve is down or slow. The event is + // dropped; startup/daily recon and the stale reaper converge the DB. + fmt.Fprintf(os.Stderr, "captain hook monitor notify: %v\n", err) + } + return nil +} + +type HookMonitorInstallOptions struct { + Timeout int `flag:"timeout" help:"Hook timeout in seconds" default:"10"` + URL string `flag:"url" help:"Captain serve base URL baked into the hook command (for non-default ports)"` +} + +// monitorHookEvents are the Claude Code lifecycle events captain subscribes to +// for session monitoring: start/end bind and tear down the session, the middle +// three signal progress worth ingesting. +var monitorHookEvents = []claude.HookEventType{ + claude.HookEventSessionStart, + claude.HookEventUserPromptSubmit, + claude.HookEventStop, + claude.HookEventSubagentStop, + claude.HookEventSessionEnd, +} + +// RunHookMonitorInstall installs the session-monitoring hooks user-wide: +// Claude Code lifecycle hooks in ~/.claude/settings.json and codex's notify +// program in ~/.codex/config.toml. +func RunHookMonitorInstall(opts HookMonitorInstallOptions) (any, error) { + captainPath, err := os.Executable() + if err != nil { + captainPath = "captain" + } + urlSuffix := "" + if opts.URL != "" { + urlSuffix = " --url " + opts.URL + } + + target, err := ensureUserClaudeSettings() + if err != nil { + return nil, err + } + claudeCommand := fmt.Sprintf("%s hook monitor notify --provider claude%s", captainPath, urlSuffix) + var results []string + for _, event := range monitorHookEvents { + result, err := installHook(target, string(event), "", claudeCommand, "hook monitor notify --provider claude", opts.Timeout) + if err != nil { + return nil, err + } + results = append(results, result) + } + + codexArgv := []string{captainPath, "hook", "monitor", "notify", "--provider", "codex"} + if opts.URL != "" { + codexArgv = append(codexArgv, "--url", opts.URL) + } + codexResult, err := codexconfig.SetNotify(codexArgv) + if err != nil { + return nil, err + } + results = append(results, codexResult) + + results = append(results, "Events are delivered to a running `captain serve`; when it is down they are dropped and the daily recon backfills them.") + return strings.Join(results, "\n"), nil +} + +// ensureUserClaudeSettings returns the user-level settings.json path, creating +// an empty file when missing — monitoring hooks are user-level infrastructure +// and must be installable on a fresh machine. +func ensureUserClaudeSettings() (string, error) { + target := filepath.Join(claude.GetClaudeHome(), "settings.json") + if _, err := os.Stat(target); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", fmt.Errorf("ensure %s: %w", filepath.Dir(target), err) + } + if err := os.WriteFile(target, []byte("{}\n"), 0o644); err != nil { + return "", fmt.Errorf("create %s: %w", target, err) + } + } + return target, nil +} + +func readHookNotifyEvent(provider string, args []string) (monitor.HookEvent, error) { + switch provider { + case "claude": + if !claude.IsStdinPiped() { + return monitor.HookEvent{}, fmt.Errorf("claude hook payload must be piped on stdin") + } + data, err := os.ReadFile("/dev/stdin") + if err != nil { + return monitor.HookEvent{}, fmt.Errorf("reading stdin: %w", err) + } + return monitor.ParseClaudeHookPayload(data) + case "codex": + return monitor.ParseCodexNotifyPayload(args) + default: + return monitor.HookEvent{}, fmt.Errorf("unknown hook provider %q", provider) + } +} diff --git a/pkg/cli/hook_monitor_test.go b/pkg/cli/hook_monitor_test.go new file mode 100644 index 0000000..bc777dc --- /dev/null +++ b/pkg/cli/hook_monitor_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/codexconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunHookMonitorInstall(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + codexPath := filepath.Join(home, ".codex", "config.toml") + codexconfig.SetPathForTesting(codexPath) + t.Cleanup(func() { codexconfig.SetPathForTesting("") }) + + readHookEvents := func() map[string][]any { + t.Helper() + data, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json")) + require.NoError(t, err) + var settings struct { + Hooks map[string][]any `json:"hooks"` + } + require.NoError(t, json.Unmarshal(data, &settings)) + return settings.Hooks + } + + _, err := RunHookMonitorInstall(HookMonitorInstallOptions{Timeout: 10}) + require.NoError(t, err) + + hooks := readHookEvents() + for _, event := range []string{"SessionStart", "UserPromptSubmit", "Stop", "SubagentStop", "SessionEnd"} { + assert.Len(t, hooks[event], 1, "event %s must be installed", event) + } + + codexData, err := os.ReadFile(codexPath) + require.NoError(t, err) + assert.Contains(t, string(codexData), `"hook","monitor","notify","--provider","codex"`) + + t.Run("second install is idempotent", func(t *testing.T) { + _, err := RunHookMonitorInstall(HookMonitorInstallOptions{Timeout: 10}) + require.NoError(t, err) + hooks := readHookEvents() + for _, event := range []string{"SessionStart", "UserPromptSubmit", "Stop", "SubagentStop", "SessionEnd"} { + assert.Len(t, hooks[event], 1, "event %s must not be duplicated", event) + } + }) +} diff --git a/pkg/monitor/hookpayload.go b/pkg/monitor/hookpayload.go new file mode 100644 index 0000000..5920422 --- /dev/null +++ b/pkg/monitor/hookpayload.go @@ -0,0 +1,93 @@ +package monitor + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/flanksource/captain/pkg/claude" +) + +// ParseClaudeHookPayload maps a Claude Code hook stdin JSON onto a HookEvent. +func ParseClaudeHookPayload(data []byte) (HookEvent, error) { + var input claude.HookInput + if err := json.Unmarshal(data, &input); err != nil { + return HookEvent{}, fmt.Errorf("parse claude hook payload: %w", err) + } + if input.HookEventName == "" { + return HookEvent{}, fmt.Errorf("claude hook payload has no hook_event_name") + } + detail := input.Source + if detail == "" { + detail = input.Reason + } + return HookEvent{ + Provider: "claude", + Event: input.HookEventName, + SessionID: input.SessionID, + TranscriptPath: input.TranscriptPath, + CWD: input.CWD, + Detail: detail, + }, nil +} + +// codexNotifyPayload is the JSON codex appends as the final notify argv +// argument (codex-rs legacy_notify): kebab-case keys, one event type. +type codexNotifyPayload struct { + Type string `json:"type"` + ThreadID string `json:"thread-id"` + TurnID string `json:"turn-id"` + CWD string `json:"cwd"` +} + +// ParseCodexNotifyPayload maps a codex notify invocation onto a HookEvent. +// Codex passes the payload as the final argv argument; the rollout path is not +// in the payload and is resolved from the thread id by the monitor. +func ParseCodexNotifyPayload(args []string) (HookEvent, error) { + if len(args) == 0 { + return HookEvent{}, fmt.Errorf("codex notify payload argument is missing") + } + raw := args[len(args)-1] + var payload codexNotifyPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return HookEvent{}, fmt.Errorf("parse codex notify payload: %w", err) + } + if payload.Type == "" { + return HookEvent{}, fmt.Errorf("codex notify payload has no type") + } + return HookEvent{ + Provider: "codex", + Event: payload.Type, + SessionID: payload.ThreadID, + CWD: payload.CWD, + Detail: payload.TurnID, + }, nil +} + +// PostHookEvent delivers one hook event to a captain serve instance. Callers +// own the context deadline; any failure is their cue to drop the event (the +// documented degraded mode — recon reconciles missed events). +func PostHookEvent(ctx context.Context, baseURL string, ev HookEvent) error { + body, err := json.Marshal(ev) + if err != nil { + return fmt.Errorf("encode hook event: %w", err) + } + url := strings.TrimRight(baseURL, "/") + "/api/captain/hooks/" + ev.Provider + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build hook event request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("deliver hook event: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 300 { + return fmt.Errorf("deliver hook event: %s returned %s", url, resp.Status) + } + return nil +} diff --git a/pkg/monitor/hookpayload_test.go b/pkg/monitor/hookpayload_test.go new file mode 100644 index 0000000..9bc4ee9 --- /dev/null +++ b/pkg/monitor/hookpayload_test.go @@ -0,0 +1,86 @@ +package monitor + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseClaudeHookPayload(t *testing.T) { + payload := `{ + "session_id": "abc123", + "transcript_path": "/Users/x/.claude/projects/-repo/abc123.jsonl", + "cwd": "/repo", + "hook_event_name": "SessionStart", + "source": "startup" + }` + ev, err := ParseClaudeHookPayload([]byte(payload)) + require.NoError(t, err) + assert.Equal(t, HookEvent{ + Provider: "claude", Event: "SessionStart", SessionID: "abc123", + TranscriptPath: "/Users/x/.claude/projects/-repo/abc123.jsonl", + CWD: "/repo", Detail: "startup", + }, ev) + + end, err := ParseClaudeHookPayload([]byte(`{"session_id":"abc123","hook_event_name":"SessionEnd","reason":"logout"}`)) + require.NoError(t, err) + assert.Equal(t, "logout", end.Detail) + + _, err = ParseClaudeHookPayload([]byte(`{"session_id":"abc123"}`)) + assert.Error(t, err, "payload without hook_event_name must be rejected") + + _, err = ParseClaudeHookPayload([]byte(`not json`)) + assert.Error(t, err) +} + +func TestParseCodexNotifyPayload(t *testing.T) { + // Payload shape from codex-rs hooks/legacy_notify.rs: kebab-case keys, + // appended as the final argv argument. + payload := `{"type":"agent-turn-complete","thread-id":"0195c1de-4ab8-7000-8000-0123456789ab",` + + `"turn-id":"turn-1","cwd":"/repo","input-messages":["hi"],"last-assistant-message":"done"}` + + ev, err := ParseCodexNotifyPayload([]string{payload}) + require.NoError(t, err) + assert.Equal(t, HookEvent{ + Provider: "codex", Event: "agent-turn-complete", + SessionID: "0195c1de-4ab8-7000-8000-0123456789ab", CWD: "/repo", Detail: "turn-1", + }, ev) + + _, err = ParseCodexNotifyPayload(nil) + assert.Error(t, err, "missing payload argument must be rejected") + + _, err = ParseCodexNotifyPayload([]string{`{}`}) + assert.Error(t, err, "payload without type must be rejected") +} + +func TestPostHookEvent(t *testing.T) { + var gotPath string + var gotEvent HookEvent + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotEvent)) + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + ev := HookEvent{Provider: "claude", Event: "Stop", SessionID: "abc123", TranscriptPath: "/p.jsonl"} + require.NoError(t, PostHookEvent(t.Context(), server.URL, ev)) + assert.Equal(t, "/api/captain/hooks/claude", gotPath) + assert.Equal(t, ev, gotEvent) + + t.Run("non-2xx is an error", func(t *testing.T) { + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusServiceUnavailable) + })) + defer failing.Close() + assert.Error(t, PostHookEvent(t.Context(), failing.URL, ev)) + }) + + t.Run("unreachable server is an error", func(t *testing.T) { + assert.Error(t, PostHookEvent(t.Context(), "http://127.0.0.1:1", ev)) + }) +} diff --git a/pkg/monitor/hooks.go b/pkg/monitor/hooks.go new file mode 100644 index 0000000..5736bf4 --- /dev/null +++ b/pkg/monitor/hooks.go @@ -0,0 +1,192 @@ +package monitor + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai/history" + "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/database" +) + +// CodexEventTurnComplete is the only event codex's notify mechanism emits. +const CodexEventTurnComplete = "agent-turn-complete" + +// HookEvent is one normalized real-time signal from a provider hook: a Claude +// Code lifecycle hook (SessionStart/UserPromptSubmit/Stop/SubagentStop/ +// SessionEnd) or codex's notify agent-turn-complete. Hooks carry exact session +// identity and transcript location, replacing ps-based discovery for sessions +// that have them installed. +type HookEvent struct { + Provider string `json:"provider,omitempty"` // claude | codex + Event string `json:"event"` + SessionID string `json:"sessionId,omitempty"` // provider session id / codex thread id + TranscriptPath string `json:"transcriptPath,omitempty"` + CWD string `json:"cwd,omitempty"` + Detail string `json:"detail,omitempty"` // SessionStart source / SessionEnd reason + ReceivedAt time.Time `json:"-"` +} + +// NotifyHookEvent enqueues a hook event without blocking. Events are dropped +// (with a debug log) when the buffer is full or no locked run loop is draining +// it — hook delivery is best-effort by design; the startup/daily recon and the +// stale-process reaper converge the database over dropped events. +func (m *Monitor) NotifyHookEvent(ev HookEvent) { + if ev.ReceivedAt.IsZero() { + ev.ReceivedAt = time.Now().UTC() + } + select { + case m.hookEvents <- ev: + default: + log.Debugf("hook event queue full; dropping %s %s for %s", ev.Provider, ev.Event, ev.SessionID) + } +} + +// handleHookEvent maps one hook event onto monitor actions: SessionStart binds +// the session and arms tailing, activity events trigger a targeted ingest, and +// SessionEnd finalizes the transcript and closes the session's process rows. +func (m *Monitor) handleHookEvent(ctx context.Context, watcher *transcriptWatcher, ingestor *ingestor, ev HookEvent) { + m.noteActivity(ev.ReceivedAt) + path := ev.TranscriptPath + if ev.Provider == "codex" && path == "" { + path = resolveCodexTranscript(ev.SessionID) + if path == "" { + // The rollout may not be flushed yet; arm today's day dir so the + // eventual write is tailed, and let recon cover the rest. + log.Debugf("hook codex/%s: no rollout found for thread %s yet", ev.Event, ev.SessionID) + watcher.watchDir(codexDayDir(time.Now()), "codex") + return + } + } + if path != "" { + if err := validateHookTranscript(ev.Provider, path); err != nil { + log.Warnf("hook %s/%s: %v", ev.Provider, ev.Event, err) + return + } + } + + switch ev.Event { + case string(claude.HookEventSessionStart): + if ev.SessionID != "" { + if _, err := m.db.CreateOrGetSession(ctx, database.CreateSessionInput{ + ProviderSessionID: ev.SessionID, Source: ev.Provider, HostID: m.cfg.HostID, + CWD: ev.CWD, Path: path, + }); err != nil { + log.Warnf("hook %s/SessionStart: bind session %s: %v", ev.Provider, ev.SessionID, err) + } + } + if path != "" { + m.TrackTranscript(path, ev.Provider) + watcher.track(path, ev.Provider) + } + case string(claude.HookEventSessionEnd): + if path != "" { + if err := ingestor.ingestFile(ctx, ev.Provider, path); err != nil { + log.Warnf("hook %s/SessionEnd: ingest %s: %v", ev.Provider, path, err) + } + m.untrackTranscript(path) + } + m.endHookSessionProcesses(ctx, ev) + default: + // UserPromptSubmit / Stop / SubagentStop / agent-turn-complete: the + // session made progress — tail it and ingest what is on disk now. + // ingestFile is idempotent, so overlap with fsnotify-triggered ingest + // of the same file is safe. + if path != "" { + m.TrackTranscript(path, ev.Provider) + watcher.track(path, ev.Provider) + if err := ingestor.ingestFile(ctx, ev.Provider, path); err != nil { + log.Warnf("hook %s/%s: ingest %s: %v", ev.Provider, ev.Event, path, err) + } + } + } +} + +// endHookSessionProcesses closes the open process rows of the session named by +// a SessionEnd event. For reason=clear/resume the OS process lives on under a +// successor session id; the next process poll rebinds it. +func (m *Monitor) endHookSessionProcesses(ctx context.Context, ev HookEvent) { + if ev.SessionID == "" { + return + } + session, err := m.db.CreateOrGetSession(ctx, database.CreateSessionInput{ + ProviderSessionID: ev.SessionID, Source: ev.Provider, HostID: m.cfg.HostID, CWD: ev.CWD, + }) + if err != nil { + log.Warnf("hook %s/SessionEnd: resolve session %s: %v", ev.Provider, ev.SessionID, err) + return + } + if _, err := m.db.EndSessionProcesses(ctx, session.ID); err != nil { + log.Warnf("hook %s/SessionEnd: close processes for %s: %v", ev.Provider, ev.SessionID, err) + } +} + +// validateHookTranscript rejects transcript paths outside the provider's known +// session roots. Hook events arrive over an unauthenticated localhost endpoint; +// the monitor must never ingest arbitrary files. +func validateHookTranscript(provider, path string) error { + root, err := hookTranscriptRoot(provider) + if err != nil { + return err + } + if root == "" { + return fmt.Errorf("no session root for provider %q", provider) + } + if !strings.HasPrefix(filepath.Clean(path), root+string(filepath.Separator)) { + return fmt.Errorf("transcript %s is outside %s", path, root) + } + return nil +} + +func hookTranscriptRoot(provider string) (string, error) { + switch provider { + case "claude": + return claude.GetProjectsDir(), nil + case "codex": + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".codex", "sessions"), nil + default: + return "", fmt.Errorf("unknown hook provider %q", provider) + } +} + +// resolveCodexTranscript finds the rollout file for a codex thread id. The +// notify payload carries no path, but rollout filenames end with the thread id +// (rollout--.jsonl); check the recent day directories +// first, then fall back to the full sessions scan for long-running threads. +func resolveCodexTranscript(threadID string) string { + if threadID == "" { + return "" + } + suffix := "-" + threadID + ".jsonl" + now := time.Now() + for _, day := range []time.Time{now, now.AddDate(0, 0, -1)} { + dir := codexDayDir(day) + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), suffix) { + return filepath.Join(dir, entry.Name()) + } + } + } + files, err := history.FindCodexSessionFiles() + if err != nil { + return "" + } + for _, file := range files { + if strings.HasSuffix(filepath.Base(file), suffix) { + return file + } + } + return "" +} diff --git a/pkg/monitor/hooks_integration_test.go b/pkg/monitor/hooks_integration_test.go new file mode 100644 index 0000000..68d69df --- /dev/null +++ b/pkg/monitor/hooks_integration_test.go @@ -0,0 +1,89 @@ +package monitor + +import ( + "os" + "testing" + "time" + + "github.com/flanksource/captain/pkg/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHookEventsDriveIngest exercises the hooks-first flow with process +// discovery stubbed out entirely: session rows, ingest, and process teardown +// must all be driven by hook events alone. +func TestHookEventsDriveIngest(t *testing.T) { + db := openMonitorTestDB(t) + path := writeFixtureHome(t) + + m, err := New(Config{DB: db, HostID: "test-host", + DiscoverProcesses: func() ([]Process, error) { return nil, nil }}) + require.NoError(t, err) + ingestor := newIngestor(m) + watcher, err := newTranscriptWatcher(m, ingestor) + require.NoError(t, err) + defer watcher.close() + + t.Run("SessionStart binds the session and arms tailing", func(t *testing.T) { + m.handleHookEvent(t.Context(), watcher, ingestor, HookEvent{ + Provider: "claude", Event: "SessionStart", SessionID: fixtureSessionID, + TranscriptPath: path, CWD: fixtureCWD, Detail: "startup", ReceivedAt: time.Now().UTC(), + }) + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.EqualValues(t, 0, overview.MessageCount, "SessionStart must not ingest") + assert.Equal(t, "claude", m.trackedPaths()[path]) + }) + + t.Run("Stop ingests the transcript", func(t *testing.T) { + m.handleHookEvent(t.Context(), watcher, ingestor, HookEvent{ + Provider: "claude", Event: "Stop", SessionID: fixtureSessionID, + TranscriptPath: path, CWD: fixtureCWD, ReceivedAt: time.Now().UTC(), + }) + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.EqualValues(t, 2, overview.MessageCount) + }) + + t.Run("transcript outside the provider root is rejected", func(t *testing.T) { + m.handleHookEvent(t.Context(), watcher, ingestor, HookEvent{ + Provider: "claude", Event: "Stop", SessionID: fixtureSessionID, + TranscriptPath: "/etc/passwd", ReceivedAt: time.Now().UTC(), + }) + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.EqualValues(t, 2, overview.MessageCount, "rejected path must not ingest") + }) + + t.Run("SessionEnd closes the session's process rows and untracks", func(t *testing.T) { + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + require.NoError(t, db.UpsertSessionProcess(t.Context(), database.SessionProcessInput{ + SessionID: overview.ID, HostID: "test-host", BootID: "boot", PID: 4242, + ProcessStartedAt: time.Now().UTC().Add(-time.Minute), Source: "claude", + })) + + m.handleHookEvent(t.Context(), watcher, ingestor, HookEvent{ + Provider: "claude", Event: "SessionEnd", SessionID: fixtureSessionID, + TranscriptPath: path, Detail: "prompt_input_exit", ReceivedAt: time.Now().UTC(), + }) + + overview, err = db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.False(t, overview.ProcessActive, "SessionEnd must close the process row") + assert.NotContains(t, m.trackedPaths(), path) + }) + + t.Run("maintenance prunes bookkeeping for deleted transcripts", func(t *testing.T) { + require.NoError(t, os.Remove(path)) + m.maintain(t.Context()) + sources, err := db.ListSessionSources(t.Context()) + require.NoError(t, err) + assert.NotContains(t, sources, path) + + overview, err := db.GetSessionOverviewByIdentity(t.Context(), fixtureSessionID) + require.NoError(t, err) + assert.NotEmpty(t, overview.ID, "the session itself must survive maintenance") + }) +} diff --git a/pkg/monitor/hooks_test.go b/pkg/monitor/hooks_test.go new file mode 100644 index 0000000..4d41222 --- /dev/null +++ b/pkg/monitor/hooks_test.go @@ -0,0 +1,76 @@ +package monitor + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNextPollInterval(t *testing.T) { + m := &Monitor{cfg: Config{ProcessInterval: 5 * time.Second, IdleProcessInterval: time.Minute}} + + assert.Equal(t, time.Minute, m.nextPollInterval(), "no activity ever observed must poll at the idle cadence") + + m.noteActivity(time.Now()) + assert.Equal(t, 5*time.Second, m.nextPollInterval(), "recent activity must poll at the fast cadence") + + m.lastActivity = time.Now().Add(-activityWindow - time.Second) + assert.Equal(t, time.Minute, m.nextPollInterval(), "activity older than the window must poll at the idle cadence") + + m.noteActivity(m.lastActivity.Add(-time.Minute)) + assert.Equal(t, time.Minute, m.nextPollInterval(), "noteActivity must never move lastActivity backwards") +} + +func TestNotifyHookEventNeverBlocks(t *testing.T) { + m := &Monitor{cfg: Config{ProcessInterval: 5 * time.Second, IdleProcessInterval: time.Minute}, hookEvents: make(chan HookEvent, 2)} + for range 5 { + m.NotifyHookEvent(HookEvent{Provider: "claude", Event: "Stop", SessionID: "s"}) + } + assert.Len(t, m.hookEvents, 2, "overflow events must be dropped, not block") +} + +func TestValidateHookTranscript(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + claudePath := filepath.Join(home, ".claude", "projects", "-repo", "s.jsonl") + codexPath := filepath.Join(home, ".codex", "sessions", "2026", "07", "14", "rollout-x.jsonl") + + assert.NoError(t, validateHookTranscript("claude", claudePath)) + assert.NoError(t, validateHookTranscript("codex", codexPath)) + + assert.Error(t, validateHookTranscript("claude", "/etc/passwd")) + assert.Error(t, validateHookTranscript("claude", codexPath), "codex path must not pass as claude") + assert.Error(t, validateHookTranscript("claude", + filepath.Join(home, ".claude", "projects", "..", "..", "secret.jsonl")), "traversal must be rejected") + assert.Error(t, validateHookTranscript("gemini", claudePath), "unknown provider must be rejected") +} + +func TestResolveCodexTranscript(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + threadID := "0195c1de-4ab8-7000-8000-0123456789ab" + + assert.Empty(t, resolveCodexTranscript(threadID), "no sessions dir yet") + assert.Empty(t, resolveCodexTranscript(""), "empty thread id") + + writeRollout := func(day time.Time, name string) string { + dir := codexDayDir(day) + require.NoError(t, os.MkdirAll(dir, 0o755)) + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) + return path + } + + today := writeRollout(time.Now(), "rollout-2026-07-14T10-00-00-"+threadID+".jsonl") + writeRollout(time.Now(), "rollout-2026-07-14T09-00-00-other-thread.jsonl") + assert.Equal(t, today, resolveCodexTranscript(threadID)) + + oldThread := "0195c1de-4ab8-7000-8000-00000000cafe" + old := writeRollout(time.Now().AddDate(0, 0, -10), "rollout-2026-07-04T10-00-00-"+oldThread+".jsonl") + assert.Equal(t, old, resolveCodexTranscript(oldThread), "threads outside the recent day dirs resolve via the full scan") +} diff --git a/pkg/monitor/maintenance.go b/pkg/monitor/maintenance.go new file mode 100644 index 0000000..84ac136 --- /dev/null +++ b/pkg/monitor/maintenance.go @@ -0,0 +1,41 @@ +package monitor + +import ( + "context" + "os" + "time" +) + +// staleProcessCutoff is how long an open process row may go unobserved before +// the daily maintenance closes it. Live processes are re-sampled every poll, +// so only crash leftovers and rows from vanished monitors ever reach it. +const staleProcessCutoff = time.Hour + +// maintain is the daily database upkeep that rides the recon backfill: prune +// ingest bookkeeping for transcripts deleted from disk, close process rows no +// poll has observed recently, and vacuum/analyze the embedded database. +func (m *Monitor) maintain(ctx context.Context) { + sources, err := m.db.ListSessionSources(ctx) + if err != nil { + log.Warnf("maintenance: list transcript bookkeeping: %v", err) + return + } + var missing []string + for path := range sources { + if _, err := os.Stat(path); os.IsNotExist(err) { + missing = append(missing, path) + } + } + pruned, err := m.db.DeleteSessionSourcesByPaths(ctx, missing) + if err != nil { + log.Warnf("maintenance: prune transcript bookkeeping: %v", err) + } + closed, err := m.db.EndStaleSessionProcesses(ctx, time.Now().UTC().Add(-staleProcessCutoff)) + if err != nil { + log.Warnf("maintenance: close stale processes: %v", err) + } + if err := m.db.VacuumAnalyze(ctx); err != nil { + log.Warnf("maintenance: vacuum analyze: %v", err) + } + log.Infof("daily maintenance: pruned %d orphaned transcript sources, closed %d stale processes", pruned, closed) +} diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index 5cbd618..c28c7fd 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -1,9 +1,13 @@ // Package monitor is Captain's live session monitor: the single writer that -// keeps the native database current. It discovers claude/codex agent processes -// via ps and samples their CPU/RAM, tails the transcripts of live and -// captain-launched sessions with fsnotify (debounced), and incrementally -// backfills older transcripts by mtime/size. Every read surface (dashboard, -// CLI, API) reads the database this monitor writes. +// keeps the native database current. Provider hooks (Claude Code lifecycle +// hooks, codex notify) are the primary real-time signal: they push exact +// session identity and transcript location the moment a session starts, makes +// progress, or ends. fsnotify tails live transcripts between hook events +// (debounced). Polling is demoted to two jobs: an adaptive ps poll that +// samples CPU/RAM and reaps vanished processes (crashes never emit +// SessionEnd), and a daily recon that backfills anything hooks missed plus +// database maintenance. Every read surface (dashboard, CLI, API) reads the +// database this monitor writes. package monitor import ( @@ -15,6 +19,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/flanksource/captain/pkg/database" @@ -34,16 +39,28 @@ const ( type Config struct { DB *database.DB HostID string - // ProcessInterval is the ps poll cadence (default 5s). + // ProcessInterval is the ps poll cadence while sessions are active + // (default 5s): agent processes were seen or a hook event arrived within + // activityWindow. ProcessInterval time.Duration + // IdleProcessInterval is the relaxed ps poll cadence when no session + // activity has been observed recently (default 60s) — the poll then only + // serves as the stale-process reaper and hookless-session fallback. + IdleProcessInterval time.Duration // Debounce delays transcript ingest after an fsnotify event (default 750ms). Debounce time.Duration - // BackfillInterval is the periodic incremental scan cadence (default 5m). + // BackfillInterval is the recon cadence (default 24h): a full incremental + // scan over every known transcript plus database maintenance, catching + // whatever hooks and fsnotify missed. BackfillInterval time.Duration // DiscoverProcesses overrides ps-based agent-process discovery (tests). DiscoverProcesses func() ([]Process, error) } +// activityWindow is how long after the last observed session activity (agent +// process seen, hook event received) the process poll stays on the fast cadence. +const activityWindow = 2 * time.Minute + type Monitor struct { cfg Config db *database.DB @@ -51,6 +68,13 @@ type Monitor struct { mu sync.Mutex tracked map[string]string // transcript path -> source kind, the live tail set + hookEvents chan HookEvent + + activityMu sync.Mutex + lastActivity time.Time + + maintenanceDue atomic.Bool + readyOnce sync.Once ready chan struct{} } @@ -65,16 +89,51 @@ func New(cfg Config) (*Monitor, error) { if cfg.ProcessInterval <= 0 { cfg.ProcessInterval = 5 * time.Second } + if cfg.IdleProcessInterval <= 0 { + cfg.IdleProcessInterval = 60 * time.Second + } if cfg.Debounce <= 0 { cfg.Debounce = 750 * time.Millisecond } if cfg.BackfillInterval <= 0 { - cfg.BackfillInterval = 5 * time.Minute + cfg.BackfillInterval = 24 * time.Hour } if cfg.DiscoverProcesses == nil { cfg.DiscoverProcesses = discoverAgentProcesses } - return &Monitor{cfg: cfg, db: cfg.DB, tracked: map[string]string{}, ready: make(chan struct{})}, nil + return &Monitor{ + cfg: cfg, db: cfg.DB, tracked: map[string]string{}, + hookEvents: make(chan HookEvent, 128), + ready: make(chan struct{}), + }, nil +} + +// noteActivity records session activity: an agent process observed by the +// poll or a hook event. It keeps the process poll on the fast cadence. +func (m *Monitor) noteActivity(at time.Time) { + if at.IsZero() { + at = time.Now().UTC() + } + m.activityMu.Lock() + if at.After(m.lastActivity) { + m.lastActivity = at + } + m.activityMu.Unlock() +} + +func (m *Monitor) idle() bool { + m.activityMu.Lock() + defer m.activityMu.Unlock() + return time.Since(m.lastActivity) > activityWindow +} + +// nextPollInterval picks the process poll cadence: fast while session +// activity was observed within activityWindow, relaxed otherwise. +func (m *Monitor) nextPollInterval() time.Duration { + if m.idle() { + return m.cfg.IdleProcessInterval + } + return m.cfg.ProcessInterval } // Ready is closed after the first process reconciliation attempt, or as soon @@ -181,6 +240,9 @@ func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { return case <-backfillRequests: m.backfill(runCtx, ingestor) + if m.maintenanceDue.CompareAndSwap(true, false) { + m.maintain(runCtx) + } } } }() @@ -190,7 +252,7 @@ func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { }() requestBackfill(backfillRequests) - processTicker := time.NewTicker(m.cfg.ProcessInterval) + processTicker := time.NewTicker(m.nextPollInterval()) backfillTicker := time.NewTicker(m.cfg.BackfillInterval) defer processTicker.Stop() defer backfillTicker.Stop() @@ -203,7 +265,18 @@ func (m *Monitor) runLocked(ctx context.Context, lock *sql.Conn) error { if err := m.pollProcesses(runCtx, watcher); err != nil { log.Warnf("process poll: %v", err) } + processTicker.Reset(m.nextPollInterval()) + case ev := <-m.hookEvents: + // A hook after an idle stretch snaps the poll back to the fast + // cadence; during sustained activity the ticker is left alone so + // frequent events cannot starve the reaper. + wasIdle := m.idle() + m.handleHookEvent(runCtx, watcher, ingestor, ev) + if wasIdle { + processTicker.Reset(m.cfg.ProcessInterval) + } case <-backfillTicker.C: + m.maintenanceDue.Store(true) requestBackfill(backfillRequests) case event, ok := <-watcher.events(): if !ok { diff --git a/pkg/monitor/process.go b/pkg/monitor/process.go index 8cd891b..0f97475 100644 --- a/pkg/monitor/process.go +++ b/pkg/monitor/process.go @@ -38,6 +38,9 @@ func (m *Monitor) pollProcesses(ctx context.Context, watcher *transcriptWatcher) return err } sampledAt := time.Now().UTC() + if len(processes) > 0 { + m.noteActivity(sampledAt) + } alive := make([]int64, 0, len(processes)) for _, proc := range processes { alive = append(alive, int64(proc.PID)) From a38da304bd2272b7919c4e4ddb3a14b48c8909d9 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 15:01:55 +0300 Subject: [PATCH 050/131] feat: add session monitoring hooks and codex config management --- AGENTS.md | 14 ++ pkg/ai/provider/claude_cli.go | 19 ++- pkg/ai/provider/claude_cli_test.go | 3 +- pkg/ai/provider/claudeagent/agent.ts | 41 +++++ pkg/ai/provider/claudeagent/provider.go | 12 ++ pkg/ai/provider/codex_cli.go | 7 + pkg/codexconfig/config.go | 161 ++++++++++++++++++ pkg/codexconfig/config_test.go | 103 +++++++++++ .../session_ingest_store_integration_test.go | 4 +- pkg/database/session_maintenance_store.go | 83 +++++++++ ...sion_maintenance_store_integration_test.go | 128 ++++++++++++++ 11 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 pkg/codexconfig/config.go create mode 100644 pkg/codexconfig/config_test.go create mode 100644 pkg/database/session_maintenance_store.go create mode 100644 pkg/database/session_maintenance_store_integration_test.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..024bc37 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# captain — agent notes + +Shared ways of working, the gavel todo workflow, and global skills come from the root ~/.agents/AGENTS.md. + +## Memory +- [Commons-db migrations (HCL + SQL)](.agents/memory/commons-db-migrations.md) — HCL for tables, post-HCL SQL for views/triggers/deferred constraints; verify with a seeded PostgreSQL smoke, not dry apply. +- [AI model fallback, recommendations & whoami catalogs](.agents/memory/ai-model-catalog-fallback.md) — IsFallbackEligible/ErrNoAPIKey classification in pkg/ai, per-family model retention in model_lists.go, keyless cmux backends in whoami. +- [AI schema shaping, generation config & runtime logging](.agents/memory/ai-schema-generation-logging.md) — SchemaJSONForBackend provider transforms vs local validation, EffortConfig in generation_config.go, agent:model[:effort] log identity. +- [Commit grouping for mixed Captain diffs](.agents/memory/commit-grouping.md) — group by semantic product slice not directory tree, tests travel with features, every file accounted for exactly once. +- [Session viewer, Codex/Claude parsing & model normalization](.agents/memory/sessions-viewer-metadata.md) — SessionBrowser/SessionInspector seams, shared Codex extraction, synthetic event tools, unified Title/InitialPrompt derivation. +- [History discovery, performance & bash categorization](.agents/memory/history-discovery-performance.md) — case-insensitive session-dir matching in both finders, prune Claude/Codex discovery early, pkg/bash priority-based classification. +- [Prompt CLI runs: JSON contract, context.dir, run tables & Codex app-server](.agents/memory/prompt-cli-runs.md) — AIPromptResult full-request JSON, context.dir normalization into cmd.Dir, -M comparison tables, app-server JSON-RPC seams. +- [Prompt workbench, serve UI, catalog & backend modes](.agents/memory/prompt-workbench-serve-ui.md) — captain serve hosts the workbench, catalog-backed family-first selectors, cmux-style backend modes land across four seams, rebuild linked clicky-ui dist. +- [Lint cleanup: gavel lint sweeps & betterleaks fixes](.agents/memory/lint-cleanup.md) — snapshot gavel lint baseline, split by ownership slices, betterleaks comment hits fixed by neutral wording. diff --git a/pkg/ai/provider/claude_cli.go b/pkg/ai/provider/claude_cli.go index 3f36fc4..b31aede 100644 --- a/pkg/ai/provider/claude_cli.go +++ b/pkg/ai/provider/claude_cli.go @@ -54,7 +54,7 @@ func (c *ClaudeCLI) Execute(ctx context.Context, req ai.Request) (*ai.Response, } func (c *ClaudeCLI) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai.Event, error) { - args, err := buildClaudeCLIArgs(c.model, req) + args, cleanup, err := buildClaudeCLIArgs(c.model, req) if err != nil { return nil, err } @@ -64,11 +64,13 @@ func (c *ClaudeCLI) ExecuteStream(ctx context.Context, req ai.Request) (<-chan a } cmd, stdout, stderrBuf, err := startCLIStream(ctx, claudeCLICommand, args, []byte(composePrompt(req)), req.Cwd(), env) if err != nil { + cleanup() return nil, err } out := make(chan ai.Event, 16) go func() { defer close(out) + defer cleanup() defer func() { _ = stdout.Close() }() iterator := claude.NewStreamJSONIterator(stdout) for iterator.Next() { @@ -86,8 +88,9 @@ func (c *ClaudeCLI) ExecuteStream(ctx context.Context, req ai.Request) (<-chan a return out, nil } -func buildClaudeCLIArgs(model string, req ai.Request) ([]string, error) { +func buildClaudeCLIArgs(model string, req ai.Request) ([]string, func(), error) { args := []string{"-p", "--verbose", "--output-format", "stream-json"} + cleanup := func() {} if m := claudeCLIModel(model); m != "" { args = append(args, "--model", m) } @@ -129,14 +132,22 @@ func buildClaudeCLIArgs(model string, req ai.Request) ([]string, error) { if req.Permissions.MCP.Disabled { args = append(args, "--mcp-config", `{"mcpServers":{}}`, "--strict-mcp-config") } + if binary, ok := captainBinary(); ok && api.MonitorHooksEnabled(req) { + settingsPath, remove, err := writeClaudeMonitorSettings(binary) + if err != nil { + return nil, cleanup, err + } + cleanup = remove + args = append(args, "--settings", settingsPath) + } schema, err := ai.SchemaJSONForBackend(ai.BackendClaudeCLI, req.Prompt) if err != nil { - return nil, fmt.Errorf("claude-cli: cannot derive structured-output schema: %w", err) + return nil, cleanup, fmt.Errorf("claude-cli: cannot derive structured-output schema: %w", err) } if len(schema) > 0 { args = append(args, "--json-schema", string(schema)) } - return args, nil + return args, cleanup, nil } func claudeCLIModel(model string) string { diff --git a/pkg/ai/provider/claude_cli_test.go b/pkg/ai/provider/claude_cli_test.go index 4529572..6150650 100644 --- a/pkg/ai/provider/claude_cli_test.go +++ b/pkg/ai/provider/claude_cli_test.go @@ -36,10 +36,11 @@ func TestBuildClaudeCLIArgs(t *testing.T) { }, } - args, err := buildClaudeCLIArgs("claude-sonnet-5", req) + args, cleanup, err := buildClaudeCLIArgs("claude-sonnet-5", req) if err != nil { t.Fatalf("buildClaudeCLIArgs: %v", err) } + defer cleanup() wantPrefix := []string{"-p", "--verbose", "--output-format", "stream-json"} if !reflect.DeepEqual(args[:len(wantPrefix)], wantPrefix) { t.Fatalf("args prefix = %v, want %v", args[:len(wantPrefix)], wantPrefix) diff --git a/pkg/ai/provider/claudeagent/agent.ts b/pkg/ai/provider/claudeagent/agent.ts index 8b0216f..9653937 100644 --- a/pkg/ai/provider/claudeagent/agent.ts +++ b/pkg/ai/provider/claudeagent/agent.ts @@ -62,6 +62,9 @@ interface InitializeParams { // structured-output target. Present => the SDK is asked for validated JSON // (options.outputFormat) and every turn's result carries structured_output. outputSchema?: Record; + // monitorUrl is the captain serve base URL session-monitoring lifecycle + // hooks POST to. Empty/absent disables monitoring hook injection. + monitorUrl?: string; } type JsonRpcId = number | string | null; @@ -228,6 +231,44 @@ function buildOptions(params: InitializeParams): Options { }, }; + // Session-monitoring lifecycle hooks: fire-and-forget POSTs to captain + // serve so the session appears in the database in real time. A monitoring + // failure (serve down, slow) must never block or slow the agent turn. + if (params.monitorUrl) { + const monitorUrl = params.monitorUrl.replace(/\/+$/, ""); + const monitorEvents = [ + "SessionStart", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "SessionEnd", + ] as const; + const hooks = options.hooks as unknown as Record; + for (const event of monitorEvents) { + hooks[event] = [ + { + hooks: [ + async (input: Record) => { + fetch(`${monitorUrl}/api/captain/hooks/claude`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event, + sessionId: input.session_id, + transcriptPath: input.transcript_path, + cwd: input.cwd, + detail: input.source ?? input.reason ?? undefined, + }), + signal: AbortSignal.timeout(1000), + }).catch(() => {}); + return {}; + }, + ], + }, + ]; + } + } + // appendSystemPrompt is not a top-level Options field; it must ride on the // claude_code preset. A custom systemPrompt string replaces the default. if (params.systemPrompt && params.appendSystemPrompt) { diff --git a/pkg/ai/provider/claudeagent/provider.go b/pkg/ai/provider/claudeagent/provider.go index 32008ce..7b12f35 100644 --- a/pkg/ai/provider/claudeagent/provider.go +++ b/pkg/ai/provider/claudeagent/provider.go @@ -423,9 +423,20 @@ func (p *Provider) initializeParams(req ai.Request) initializeParams { Resume: resume, ApprovalMode: approvalMode, OutputSchema: p.sessionSchema, + MonitorURL: monitorHooksURL(req), } } +// monitorHooksURL resolves the captain serve URL the SDK's session-monitoring +// hooks deliver lifecycle events to; empty disables injection (bare runs, +// CAPTAIN_MONITOR_HOOKS=off). +func monitorHooksURL(req ai.Request) string { + if !api.MonitorHooksEnabled(req) { + return "" + } + return api.ServeBaseURL() +} + type initializeParams struct { Cwd string `json:"cwd,omitempty"` Model string `json:"model,omitempty"` @@ -438,6 +449,7 @@ type initializeParams struct { Resume string `json:"resume,omitempty"` ApprovalMode string `json:"approvalMode,omitempty"` OutputSchema json.RawMessage `json:"outputSchema,omitempty"` + MonitorURL string `json:"monitorUrl,omitempty"` } func (p *Provider) setInitResult(err error) { diff --git a/pkg/ai/provider/codex_cli.go b/pkg/ai/provider/codex_cli.go index 10cae2d..9ef24a2 100644 --- a/pkg/ai/provider/codex_cli.go +++ b/pkg/ai/provider/codex_cli.go @@ -119,6 +119,13 @@ func buildCodexCLIArgs(model string, req ai.Request) ([]string, func(), error) { if req.Memory.SkipProject || req.Memory.SkipHooks { args = append(args, "--ignore-rules") } + if binary, ok := captainBinary(); ok && api.MonitorHooksEnabled(req) { + notify, err := codexNotifyOverride(binary) + if err != nil { + return nil, cleanup, err + } + args = append(args, "-c", notify) + } schema, err := ai.SchemaJSONForBackend(ai.BackendCodexCLI, req.Prompt) if err != nil { return nil, cleanup, fmt.Errorf("codex-cli: cannot derive structured-output schema: %w", err) diff --git a/pkg/codexconfig/config.go b/pkg/codexconfig/config.go new file mode 100644 index 0000000..a5e0d55 --- /dev/null +++ b/pkg/codexconfig/config.go @@ -0,0 +1,161 @@ +// Package codexconfig manages the notify entry in codex's user configuration +// at ~/.codex/config.toml. Codex supports exactly one notify program, so the +// package only ever installs or updates a captain-owned entry and refuses to +// clobber a foreign notifier. Edits are line-level to preserve the user's +// comments and formatting; go-toml is used for validation and detection only. +package codexconfig + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + toml "github.com/pelletier/go-toml/v2" +) + +// pathOverride lets tests redirect Path() to a temp directory without touching +// $HOME. Empty string means "use os.UserHomeDir". +var pathOverride string + +// SetPathForTesting redirects Path() to the given absolute file path. Tests +// must call it with t.Cleanup(func() { SetPathForTesting("") }). +func SetPathForTesting(p string) { pathOverride = p } + +// Path returns the absolute path to codex's user config file. +func Path() (string, error) { + if pathOverride != "" { + return pathOverride, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".codex", "config.toml"), nil +} + +// IsCaptainNotify reports whether a notify argv is captain's hook receiver, +// regardless of where the captain binary lives. +func IsCaptainNotify(argv []string) bool { + return len(argv) >= 4 && argv[1] == "hook" && argv[2] == "monitor" && argv[3] == "notify" +} + +// SetNotify installs argv as codex's notify program, preserving the rest of +// the file byte-for-byte. It fails loudly on unparseable TOML, on a notify +// value that does not sit on a single line, and on a foreign (non-captain) +// notify entry — codex has one notify slot and the user's notifier wins. +func SetNotify(argv []string) (string, error) { + if !IsCaptainNotify(argv) { + return "", fmt.Errorf("refusing to install non-captain notify argv %v", argv) + } + path, err := Path() + if err != nil { + return "", err + } + line, err := notifyLine(argv) + if err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("ensure %s: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(line+"\n"), 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return fmt.Sprintf("codex notify: installed in %s (created)", path), nil + } + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + + var config struct { + Notify []string `toml:"notify"` + } + if err := toml.Unmarshal(data, &config); err != nil { + return "", fmt.Errorf("parse %s: %w (fix the file before installing the captain notify hook)", path, err) + } + + if config.Notify == nil { + updated := insertTopLevelLine(string(data), line) + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return fmt.Sprintf("codex notify: installed in %s", path), nil + } + if !IsCaptainNotify(config.Notify) { + return "", fmt.Errorf("%s already sets notify = %v; codex supports one notify program — remove it first to let captain monitor codex sessions", path, config.Notify) + } + + updated, err := replaceNotifyLine(string(data), line) + if err != nil { + return "", fmt.Errorf("%s: %w", path, err) + } + if updated == string(data) { + return fmt.Sprintf("codex notify: already installed in %s", path), nil + } + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return fmt.Sprintf("codex notify: updated in %s", path), nil +} + +// notifyLine renders `notify = ["…", …]`. A JSON string array is valid TOML. +func notifyLine(argv []string) (string, error) { + value, err := json.Marshal(argv) + if err != nil { + return "", fmt.Errorf("encode notify argv: %w", err) + } + return "notify = " + string(value), nil +} + +// insertTopLevelLine adds a top-level assignment: TOML requires top-level keys +// to precede the first table header, so the line goes right above it (or at +// the end of a table-free file). +func insertTopLevelLine(content, line string) string { + lines := strings.Split(content, "\n") + for i, existing := range lines { + if strings.HasPrefix(strings.TrimSpace(existing), "[") { + lines = append(lines[:i], append([]string{line, ""}, lines[i:]...)...) + return strings.Join(lines, "\n") + } + } + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + return content + line + "\n" +} + +// replaceNotifyLine swaps the existing top-level notify assignment for the new +// line. The assignment must sit on a single line: TOML allows multiline +// arrays, and rewriting one line-by-line would corrupt the file. +func replaceNotifyLine(content, line string) (string, error) { + lines := strings.Split(content, "\n") + for i, existing := range lines { + trimmed := strings.TrimSpace(existing) + if strings.HasPrefix(trimmed, "[") { + break // notify was parsed at top level but its assignment wasn't found above the first table + } + if !strings.HasPrefix(trimmed, "notify") { + continue + } + rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "notify")) + if !strings.HasPrefix(rest, "=") { + continue + } + var single struct { + Notify []string `toml:"notify"` + } + if err := toml.Unmarshal([]byte(trimmed), &single); err != nil || single.Notify == nil { + return "", fmt.Errorf("notify assignment spans multiple lines; update it manually") + } + lines[i] = line + return strings.Join(lines, "\n"), nil + } + return "", fmt.Errorf("notify assignment not found on a single top-level line; update it manually") +} diff --git a/pkg/codexconfig/config_test.go b/pkg/codexconfig/config_test.go new file mode 100644 index 0000000..a82c4df --- /dev/null +++ b/pkg/codexconfig/config_test.go @@ -0,0 +1,103 @@ +package codexconfig + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var captainArgv = []string{"/usr/local/bin/captain", "hook", "monitor", "notify", "--provider", "codex"} + +const captainNotifyLine = `notify = ["/usr/local/bin/captain","hook","monitor","notify","--provider","codex"]` + +func useTempConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".codex", "config.toml") + if content != "" { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + SetPathForTesting(path) + t.Cleanup(func() { SetPathForTesting("") }) + return path +} + +func TestSetNotify(t *testing.T) { + t.Run("creates a missing config file", func(t *testing.T) { + path := useTempConfig(t, "") + result, err := SetNotify(captainArgv) + require.NoError(t, err) + assert.Contains(t, result, "created") + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, captainNotifyLine+"\n", string(data)) + }) + + t.Run("inserts above the first table preserving comments", func(t *testing.T) { + content := "# my codex config\nmodel = \"gpt-5\"\n\n[profiles.fast]\nmodel = \"gpt-5-mini\"\n" + path := useTempConfig(t, content) + _, err := SetNotify(captainArgv) + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, + "# my codex config\nmodel = \"gpt-5\"\n\n"+captainNotifyLine+"\n\n[profiles.fast]\nmodel = \"gpt-5-mini\"\n", + string(data)) + }) + + t.Run("appends to a table-free config", func(t *testing.T) { + path := useTempConfig(t, "model = \"gpt-5\"\n") + _, err := SetNotify(captainArgv) + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "model = \"gpt-5\"\n"+captainNotifyLine+"\n", string(data)) + }) + + t.Run("existing captain notify is idempotent", func(t *testing.T) { + path := useTempConfig(t, captainNotifyLine+"\n") + result, err := SetNotify(captainArgv) + require.NoError(t, err) + assert.Contains(t, result, "already installed") + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, captainNotifyLine+"\n", string(data)) + }) + + t.Run("captain notify at another path is updated in place", func(t *testing.T) { + path := useTempConfig(t, "# keep\nnotify = [\"/old/captain\",\"hook\",\"monitor\",\"notify\",\"--provider\",\"codex\"]\n") + result, err := SetNotify(captainArgv) + require.NoError(t, err) + assert.Contains(t, result, "updated") + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "# keep\n"+captainNotifyLine+"\n", string(data)) + }) + + t.Run("foreign notify fails loudly", func(t *testing.T) { + useTempConfig(t, "notify = [\"/usr/bin/terminal-notifier\"]\n") + _, err := SetNotify(captainArgv) + require.ErrorContains(t, err, "one notify program") + }) + + t.Run("invalid TOML fails loudly", func(t *testing.T) { + useTempConfig(t, "model = [unclosed\n") + _, err := SetNotify(captainArgv) + require.ErrorContains(t, err, "parse") + }) + + t.Run("multi-line captain notify fails loudly", func(t *testing.T) { + useTempConfig(t, "notify = [\n \"/old/captain\", \"hook\", \"monitor\", \"notify\",\n]\n") + _, err := SetNotify(captainArgv) + require.ErrorContains(t, err, "manually") + }) + + t.Run("non-captain argv is refused", func(t *testing.T) { + useTempConfig(t, "") + _, err := SetNotify([]string{"/bin/echo"}) + require.ErrorContains(t, err, "non-captain") + }) +} diff --git a/pkg/database/session_ingest_store_integration_test.go b/pkg/database/session_ingest_store_integration_test.go index 8d816e9..5e28722 100644 --- a/pkg/database/session_ingest_store_integration_test.go +++ b/pkg/database/session_ingest_store_integration_test.go @@ -331,7 +331,9 @@ func TestIngestTranscriptAndReadStores(t *testing.T) { t.Run("JSON null characters are sanitized before jsonb persistence", func(t *testing.T) { input := testIngestBatch(modTime, 1024) - input.Session.ProviderSessionID = "0195c1de-4ab8-7000-8000-0123456789ac" + // Distinct prefix: the "ambiguous provider prefixes" subtest below counts + // exact matches for testProviderSessionID's first 8 characters. + input.Session.ProviderSessionID = "0195c2de-4ab8-7000-8000-0123456789ac" input.Session.Path = "/home/dev/.codex/sessions/nul-output.jsonl" input.Session.Project = "nul-json" input.Session.Metadata = map[string]any{"output": "before\x00after"} diff --git a/pkg/database/session_maintenance_store.go b/pkg/database/session_maintenance_store.go new file mode 100644 index 0000000..35ac068 --- /dev/null +++ b/pkg/database/session_maintenance_store.go @@ -0,0 +1,83 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// EndSessionProcesses closes every still-open process row bound to a session — +// the SessionEnd hook's teardown — and returns how many rows were closed. +func (db *DB) EndSessionProcesses(ctx context.Context, sessionID uuid.UUID) (int64, error) { + if err := db.requireGorm(); err != nil { + return 0, err + } + if sessionID == uuid.Nil { + return 0, fmt.Errorf("%w: session ID is required", ErrInvalidSessionProcess) + } + now := time.Now().UTC() + result := db.gorm.WithContext(ctx).Model(&sessionProcessRecord{}). + Where("session_id = ? AND ended_at IS NULL", sessionID). + Updates(map[string]any{ + "ended_at": gorm.Expr("GREATEST(process_started_at, ?)", now), + "status": "exited", + }) + if result.Error != nil { + return 0, fmt.Errorf("end Captain session processes: %w", result.Error) + } + return result.RowsAffected, nil +} + +// EndStaleSessionProcesses closes open process rows on any host whose last +// observation predates cutoff — crash leftovers the per-host reaper cannot see +// because its ps snapshot only covers the local host. +func (db *DB) EndStaleSessionProcesses(ctx context.Context, cutoff time.Time) (int64, error) { + if err := db.requireGorm(); err != nil { + return 0, err + } + result := db.gorm.WithContext(ctx).Model(&sessionProcessRecord{}). + Where("ended_at IS NULL"). + Where("COALESCE(last_heartbeat_at, sampled_at, process_started_at) < ?", cutoff.UTC()). + Updates(map[string]any{ + "ended_at": gorm.Expr("GREATEST(process_started_at, ?)", time.Now().UTC()), + "status": "exited", + }) + if result.Error != nil { + return 0, fmt.Errorf("end stale Captain session processes: %w", result.Error) + } + return result.RowsAffected, nil +} + +// DeleteSessionSourcesByPaths prunes ingest bookkeeping for transcripts that no +// longer exist on disk. Session rows stay — only the per-file resume state goes. +func (db *DB) DeleteSessionSourcesByPaths(ctx context.Context, paths []string) (int64, error) { + if err := db.requireGorm(); err != nil { + return 0, err + } + if len(paths) == 0 { + return 0, nil + } + result := db.gorm.WithContext(ctx). + Where("path IN ?", paths). + Delete(&sessionSourceRecord{}) + if result.Error != nil { + return 0, fmt.Errorf("delete Captain session sources: %w", result.Error) + } + return result.RowsAffected, nil +} + +// VacuumAnalyze reclaims dead tuples and refreshes planner statistics on the +// embedded database. VACUUM cannot run inside a transaction block, so this +// issues a bare statement. +func (db *DB) VacuumAnalyze(ctx context.Context) error { + if err := db.requireGorm(); err != nil { + return err + } + if err := db.gorm.WithContext(ctx).Exec("VACUUM ANALYZE").Error; err != nil { + return fmt.Errorf("vacuum analyze: %w", err) + } + return nil +} diff --git a/pkg/database/session_maintenance_store_integration_test.go b/pkg/database/session_maintenance_store_integration_test.go new file mode 100644 index 0000000..8075f13 --- /dev/null +++ b/pkg/database/session_maintenance_store_integration_test.go @@ -0,0 +1,128 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + "time" + + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func openMaintenanceTestDB(t *testing.T) *DB { + t.Helper() + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres store tests") + } + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(t.TempDir(), "postgres"), + Database: "captain_maintenance_stores", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stop()) }) + + db, err := Open(t.Context(), Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return db +} + +func seedMaintenanceSession(t *testing.T, db *DB, providerSessionID string) *Session { + t.Helper() + session, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: providerSessionID, + Source: "claude", + HostID: "test-host", + CWD: "/home/dev/example", + }) + require.NoError(t, err) + return session +} + +func seedProcess(t *testing.T, db *DB, sessionID uuid.UUID, pid int64, sampledAt time.Time) { + t.Helper() + require.NoError(t, db.UpsertSessionProcess(t.Context(), SessionProcessInput{ + SessionID: sessionID, + HostID: "test-host", + BootID: "boot-1", + PID: pid, + ProcessStartedAt: sampledAt.Add(-time.Minute), + Source: "claude", + SampledAt: sampledAt, + })) +} + +func openProcessCount(t *testing.T, db *DB, sessionID uuid.UUID) int64 { + t.Helper() + var count int64 + require.NoError(t, db.gorm.WithContext(t.Context()).Model(&sessionProcessRecord{}). + Where("session_id = ? AND ended_at IS NULL", sessionID).Count(&count).Error) + return count +} + +func TestSessionMaintenanceStore(t *testing.T) { + db := openMaintenanceTestDB(t) + now := time.Now().UTC().Truncate(time.Second) + + t.Run("EndSessionProcesses closes only the target session", func(t *testing.T) { + target := seedMaintenanceSession(t, db, "0195c1de-4ab8-7000-8000-00000000e0d1") + other := seedMaintenanceSession(t, db, "0195c1de-4ab8-7000-8000-00000000e0d2") + seedProcess(t, db, target.ID, 5001, now) + seedProcess(t, db, other.ID, 5002, now) + + closed, err := db.EndSessionProcesses(t.Context(), target.ID) + require.NoError(t, err) + assert.EqualValues(t, 1, closed) + assert.EqualValues(t, 0, openProcessCount(t, db, target.ID)) + assert.EqualValues(t, 1, openProcessCount(t, db, other.ID)) + }) + + t.Run("EndSessionProcesses requires a session id", func(t *testing.T) { + _, err := db.EndSessionProcesses(t.Context(), uuid.Nil) + require.ErrorIs(t, err, ErrInvalidSessionProcess) + }) + + t.Run("EndStaleSessionProcesses closes rows older than cutoff on any host", func(t *testing.T) { + stale := seedMaintenanceSession(t, db, "0195c1de-4ab8-7000-8000-00000000e0d3") + fresh := seedMaintenanceSession(t, db, "0195c1de-4ab8-7000-8000-00000000e0d4") + seedProcess(t, db, stale.ID, 5003, now.Add(-2*time.Hour)) + seedProcess(t, db, fresh.ID, 5004, now) + + closed, err := db.EndStaleSessionProcesses(t.Context(), now.Add(-time.Hour)) + require.NoError(t, err) + assert.EqualValues(t, 1, closed) + assert.EqualValues(t, 0, openProcessCount(t, db, stale.ID)) + assert.EqualValues(t, 1, openProcessCount(t, db, fresh.ID)) + }) + + t.Run("DeleteSessionSourcesByPaths prunes bookkeeping but keeps the session", func(t *testing.T) { + modTime := now.Add(-time.Hour) + session, err := db.IngestTranscript(t.Context(), testIngestBatch(modTime, 2048)) + require.NoError(t, err) + + deleted, err := db.DeleteSessionSourcesByPaths(t.Context(), []string{testTranscriptPath}) + require.NoError(t, err) + assert.EqualValues(t, 1, deleted) + + sources, err := db.ListSessionSources(t.Context()) + require.NoError(t, err) + assert.NotContains(t, sources, testTranscriptPath) + + kept, err := db.GetSession(t.Context(), session.ID) + require.NoError(t, err) + assert.Equal(t, session.ID, kept.ID) + }) + + t.Run("DeleteSessionSourcesByPaths with no paths is a no-op", func(t *testing.T) { + deleted, err := db.DeleteSessionSourcesByPaths(t.Context(), nil) + require.NoError(t, err) + assert.EqualValues(t, 0, deleted) + }) + + t.Run("VacuumAnalyze succeeds", func(t *testing.T) { + require.NoError(t, db.VacuumAnalyze(t.Context())) + }) +} From c73ed52f8e4350e2f054fed12834c2ac686b29e2 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 14 Jul 2026 15:01:55 +0300 Subject: [PATCH 051/131] chore: update generated and lock files --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 8e1b7d9..36f1493 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( cloud.google.com/go/cloudsqlconn v1.22.1 // indirect github.com/exaring/otelpgx v0.9.3 // indirect github.com/fergusstrange/embedded-postgres v1.34.0 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect go.opencensus.io v0.24.0 // indirect gorm.io/driver/postgres v1.6.0 // indirect diff --git a/go.sum b/go.sum index cdf3b72..1dd1f08 100644 --- a/go.sum +++ b/go.sum @@ -592,6 +592,8 @@ github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= From d3ffc2fc40021fe218fb638d011aef3ea227f8bf Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 16:03:28 +0300 Subject: [PATCH 052/131] feat(claude): parse Claude command and goal records as structured events Expose Claude slash-command, command-output, and goal-status records as structured non-operational events. Preserve command arguments and output streams while preventing wrapper markup from appearing as conversational messages. --- pkg/claude/reader.go | 166 ++++++++++++++ pkg/claude/reader_command_test.go | 213 ++++++++++++++++++ pkg/claude/reader_test.go | 7 +- pkg/claude/tools/claude_command_event_test.go | 126 +++++++++++ pkg/claude/tools/event.go | 7 +- pkg/claude/tools/event_renderers.go | 94 ++++++++ pkg/claude/tools/muted_styles_test.go | 9 + pkg/claude/tools/stream_test.go | 1 + pkg/claude/tools/tool.go | 4 + pkg/cli/stdin_claude_command_test.go | 73 ++++++ pkg/session/claude_command_test.go | 91 ++++++++ 11 files changed, 788 insertions(+), 3 deletions(-) create mode 100644 pkg/claude/reader_command_test.go create mode 100644 pkg/claude/tools/claude_command_event_test.go create mode 100644 pkg/cli/stdin_claude_command_test.go create mode 100644 pkg/session/claude_command_test.go diff --git a/pkg/claude/reader.go b/pkg/claude/reader.go index cd651ca..f7b1f9d 100644 --- a/pkg/claude/reader.go +++ b/pkg/claude/reader.go @@ -127,6 +127,11 @@ type streamJSONLine struct { Subtype string `json:"subtype,omitempty"` Message json.RawMessage `json:"message,omitempty"` + // Content is the top-level content on system/local_command lines. Kept raw + // because other line types (e.g. queue-operation) carry a non-string content + // object; contentString decodes it only when it is a JSON string. + Content json.RawMessage `json:"content,omitempty"` + // Stream-json fields SessionIDSnake string `json:"session_id,omitempty"` TimestampSnake string `json:"timestamp,omitempty"` @@ -153,6 +158,20 @@ func (sj streamJSONLine) timestamp() string { return sj.TimestampSnake } +// contentString returns the top-level content when it is a JSON string, and "" +// when it is absent or a non-string object. Only system/local_command lines +// carry a string content wrapper this reader needs to inspect. +func (sj streamJSONLine) contentString() string { + if len(sj.Content) == 0 || sj.Content[0] != '"' { + return "" + } + var s string + if err := json.Unmarshal(sj.Content, &s); err != nil { + return "" + } + return s +} + // knownSessionStorageTypes are line types that appear in on-disk session // files for state tracking but carry no useful row-level information. // Listed explicitly so they don't pollute the unhandled-types diagnostic. @@ -239,11 +258,148 @@ func attachmentEventEntry(sj streamJSONLine, raw []byte) []HistoryEntry { return metadataEventEntry(sj, typ, "session", attachment) case "budget_usd": return metadataEventEntry(sj, typ, "turn", attachment) + case "goal_status": + // Session-scoped goal directive. Kept as a map so future goal fields + // (beyond condition/met/sentinel/reason) round-trip without parser churn. + return metadataEventEntry(sj, "goal_status", "session", attachment) default: return attachmentEntry(sj) } } +// claudeCommandRecord is the parsed form of a Claude slash-command wrapper +// (//). Claude writes it as a +// text-only user message (modern) or a system/local_command line (older). +type claudeCommandRecord struct { + Name string + Message string + Args string +} + +// claudeCommandOutputRecord is the parsed form of a or +// wrapper carrying a slash command's captured output. +type claudeCommandOutputRecord struct { + Stream string + Content string +} + +// cutWrapperTag extracts the inner content of the section that s +// must begin with, returning the content and the remainder after the closing +// tag. last selects strings.LastIndex for the closing tag so a trailing +// free-form section (args/output) may itself contain the literal closing tag. +func cutWrapperTag(s, tag string, last bool) (inner, rest string, err error) { + open, closeTag := "<"+tag+">", "" + if !strings.HasPrefix(s, open) { + return "", "", fmt.Errorf("expected <%s>", tag) + } + body := s[len(open):] + idx := strings.Index(body, closeTag) + if last { + idx = strings.LastIndex(body, closeTag) + } + if idx < 0 { + return "", "", fmt.Errorf("unterminated <%s>", tag) + } + return body[:idx], body[idx+len(closeTag):], nil +} + +// parseClaudeCommandRecord recognizes a complete slash-command wrapper. It +// returns matched=false for text that does not open with (so +// ordinary prose is left as chat) and matched=true with an error for a +// recognized-but-malformed wrapper (surfaced as a ParseError row). Empty +// command arguments are valid. +func parseClaudeCommandRecord(text string) (claudeCommandRecord, bool, error) { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "") { + return claudeCommandRecord{}, false, nil + } + name, rest, err := cutWrapperTag(trimmed, "command-name", false) + if err != nil { + return claudeCommandRecord{}, true, err + } + message, rest, err := cutWrapperTag(strings.TrimSpace(rest), "command-message", false) + if err != nil { + return claudeCommandRecord{}, true, err + } + args, rest, err := cutWrapperTag(strings.TrimSpace(rest), "command-args", true) + if err != nil { + return claudeCommandRecord{}, true, err + } + if strings.TrimSpace(rest) != "" { + return claudeCommandRecord{}, true, fmt.Errorf("unexpected content after ") + } + return claudeCommandRecord{Name: name, Message: message, Args: args}, true, nil +} + +// parseClaudeCommandOutputRecord recognizes a complete local-command output +// wrapper. Its matched/error contract mirrors parseClaudeCommandRecord. Empty +// output is valid. +func parseClaudeCommandOutputRecord(text string) (claudeCommandOutputRecord, bool, error) { + trimmed := strings.TrimSpace(text) + for _, s := range []struct{ tag, stream string }{ + {"local-command-stdout", "stdout"}, + {"local-command-stderr", "stderr"}, + } { + if !strings.HasPrefix(trimmed, "<"+s.tag+">") { + continue + } + content, rest, err := cutWrapperTag(trimmed, s.tag, true) + if err != nil { + return claudeCommandOutputRecord{}, true, err + } + if strings.TrimSpace(rest) != "" { + return claudeCommandOutputRecord{}, true, fmt.Errorf("unexpected content after ", s.tag) + } + return claudeCommandOutputRecord{Stream: s.stream, Content: content}, true, nil + } + return claudeCommandOutputRecord{}, false, nil +} + +func claudeCommandEntry(sj streamJSONLine, command claudeCommandRecord) HistoryEntry { + return metadataEventEntry(sj, "claude_command", "turn", map[string]any{ + "command_name": command.Name, + "command_message": command.Message, + "command_args": command.Args, + })[0] +} + +func claudeCommandOutputEntry(sj streamJSONLine, output claudeCommandOutputRecord) HistoryEntry { + return metadataEventEntry(sj, "claude_command_output", "turn", map[string]any{ + "stream": output.Stream, + "content": output.Content, + })[0] +} + +// classifyClaudeCommandText converts a command wrapper or its output into +// structured event rows. It returns ok=false when text is not a recognized +// wrapper so the caller keeps its normal handling. A recognized-but-malformed +// wrapper yields a ParseError row (ok=true) rather than reverting to raw text. +func classifyClaudeCommandText(sj streamJSONLine, text string, raw []byte, lineNo int) ([]HistoryEntry, bool) { + if command, matched, err := parseClaudeCommandRecord(text); matched { + if err != nil { + return []HistoryEntry{parseErrorEntry(lineNo, raw, err, sj.timestamp())}, true + } + return single(claudeCommandEntry(sj, command)), true + } + if output, matched, err := parseClaudeCommandOutputRecord(text); matched { + if err != nil { + return []HistoryEntry{parseErrorEntry(lineNo, raw, err, sj.timestamp())}, true + } + return single(claudeCommandOutputEntry(sj, output)), true + } + return nil, false +} + +// singleTextContent returns the text of a message that is exactly one text +// block, so command-wrapper classification only fires on text-only user turns +// and never on mixed tool_use/tool_result content. +func singleTextContent(msg Message) (string, bool) { + if len(msg.Content) != 1 || msg.Content[0].Type != ContentTypeText { + return "", false + } + return msg.Content[0].Text, true +} + // dispatchEvent routes a typed line to zero, one, or more HistoryEntry rows. // A single line can emit multiple entries — e.g. an assistant message with a // top-level "error" field yields both the regular assistant entry and an @@ -259,6 +415,13 @@ func dispatchEvent(sj streamJSONLine, raw []byte, lineNo int) []HistoryEntry { if err := json.Unmarshal(sj.Message, &msg); err != nil { return []HistoryEntry{parseErrorEntry(lineNo, raw, err, sj.timestamp())} } + if sj.Type == "user" { + if text, ok := singleTextContent(msg); ok { + if entries, ok := classifyClaudeCommandText(sj, text, raw, lineNo); ok { + return entries + } + } + } out := []HistoryEntry{{ SessionID: sj.sessionID(), UUID: sj.UUID, @@ -310,6 +473,9 @@ func dispatchEvent(sj streamJSONLine, raw []byte, lineNo int) []HistoryEntry { "content", "compactMetadata", "level", })) case "local_command": + if entries, ok := classifyClaudeCommandText(sj, sj.contentString(), raw, lineNo); ok { + return entries + } return single(syntheticEntry(sj, "LocalCommand", raw, []string{"content", "level", "cwd"})) case "scheduled_task_fire": return single(syntheticEntry(sj, "ScheduledTaskFire", raw, []string{"content"})) diff --git a/pkg/claude/reader_command_test.go b/pkg/claude/reader_command_test.go new file mode 100644 index 0000000..571ba41 --- /dev/null +++ b/pkg/claude/reader_command_test.go @@ -0,0 +1,213 @@ +package claude + +import ( + "fmt" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/claude/tools" + "github.com/segmentio/encoding/json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClaudeCommandParsing(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Claude Command Parsing Suite") +} + +// jsonString encodes s as a JSON string literal for embedding in a JSONL line. +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +// userLine builds a modern text-only user record whose message content is text. +func userLine(uuid, text string) string { + return fmt.Sprintf( + `{"type":"user","uuid":%q,"sessionId":"s1","timestamp":"2026-07-14T12:16:09.113Z","cwd":"/repo","gitBranch":"main","message":{"role":"user","content":%s}}`, + uuid, jsonString(text)) +} + +// systemLocalCommandLine builds the older system/local_command record shape. +func systemLocalCommandLine(uuid, content string) string { + return fmt.Sprintf( + `{"type":"system","subtype":"local_command","uuid":%q,"sessionId":"s1","timestamp":"2026-07-14T12:16:09.113Z","content":%s,"level":"info"}`, + uuid, jsonString(content)) +} + +func goalStatusLine(uuid, condition string, met, sentinel bool) string { + return fmt.Sprintf( + `{"type":"attachment","uuid":%q,"sessionId":"s1","timestamp":"2026-07-14T12:16:09.113Z","cwd":"/repo","attachment":{"type":"goal_status","met":%t,"sentinel":%t,"condition":%s}}`, + uuid, met, sentinel, jsonString(condition)) +} + +// commandWrapper renders the slash-command envelope with the realistic newline +// and indentation Claude writes between sections. +func commandWrapper(name, message, args string) string { + return fmt.Sprintf( + "%s\n %s\n %s", + name, message, args) +} + +func stdoutWrapper(content string) string { + return "" + content + "" +} + +func stderrWrapper(content string) string { + return "" + content + "" +} + +func readEntries(lines ...string) []HistoryEntry { + entries, err := ReadHistory(strings.NewReader(strings.Join(lines, "\n"))) + Expect(err).NotTo(HaveOccurred()) + return entries +} + +var _ = Describe("Claude command / goal parsing in the shared reader", func() { + const goalCondition = "push and monitor the docker build on PR #32, if any dependencies need updates open PR's for them and pin to the git SHA until they merge into main and create a release" + + Describe("the exact reported /goal three-record shape", func() { + var entries []HistoryEntry + + BeforeEach(func() { + entries = readEntries( + goalStatusLine("uuid-goal", goalCondition, false, true), + userLine("uuid-cmd", commandWrapper("/goal", "goal", goalCondition)), + userLine("uuid-out", stdoutWrapper("Goal set: "+goalCondition)), + ) + }) + + It("surfaces goal_status as a session-scoped event preserving its fields", func() { + Expect(entries).To(HaveLen(3)) + ev := entries[0].Event + Expect(ev).NotTo(BeNil()) + Expect(ev.Type).To(Equal("goal_status")) + Expect(ev.Scope).To(Equal("session")) + Expect(ev.Data["condition"]).To(Equal(goalCondition)) + Expect(ev.Data["met"]).To(Equal(false)) + Expect(ev.Data["sentinel"]).To(Equal(true)) + }) + + It("parses the /goal record into a claude_command event with split fields", func() { + ev := entries[1].Event + Expect(ev).NotTo(BeNil()) + Expect(ev.Type).To(Equal("claude_command")) + Expect(ev.Scope).To(Equal("turn")) + Expect(ev.Data["command_name"]).To(Equal("/goal")) + Expect(ev.Data["command_message"]).To(Equal("goal")) + Expect(ev.Data["command_args"]).To(Equal(goalCondition)) + }) + + It("parses the stdout record into a wrapper-free claude_command_output event", func() { + ev := entries[2].Event + Expect(ev).NotTo(BeNil()) + Expect(ev.Type).To(Equal("claude_command_output")) + Expect(ev.Data["stream"]).To(Equal("stdout")) + Expect(ev.Data["content"]).To(Equal("Goal set: " + goalCondition)) + }) + + It("emits no raw User text carrying the wrapper tags", func() { + for _, e := range entries { + Expect(e.Message.Role).To(BeEmpty()) + Expect(e.Message.GetTextContent()).NotTo(ContainSubstring("")) + Expect(e.Message.GetTextContent()).NotTo(ContainSubstring("")) + } + }) + + It("projects the three records as non-operational event activity", func() { + uses := ExtractToolUses(entries) + names := make([]string, 0, len(uses)) + for _, u := range uses { + names = append(names, u.Tool) + Expect(tools.IsEventToolName(u.Tool)).To(BeTrue(), "expected %q to be an event tool", u.Tool) + } + Expect(names).To(Equal([]string{"GoalStatus", "ClaudeCommand", "ClaudeCommand"})) + }) + }) + + DescribeTable("command wrappers across command names and argument shapes", + func(line string, wantName, wantMessage, wantArgs string) { + entries := readEntries(line) + Expect(entries).To(HaveLen(1)) + ev := entries[0].Event + Expect(ev).NotTo(BeNil()) + Expect(ev.Type).To(Equal("claude_command")) + Expect(ev.Scope).To(Equal("turn")) + Expect(ev.Data["command_name"]).To(Equal(wantName)) + Expect(ev.Data["command_message"]).To(Equal(wantMessage)) + Expect(ev.Data["command_args"]).To(Equal(wantArgs)) + }, + Entry("/plan with empty arguments (modern user shape)", + userLine("u1", commandWrapper("/plan", "plan", "")), "/plan", "plan", ""), + Entry("/clear with empty arguments (modern user shape)", + userLine("u2", commandWrapper("/clear", "clear", "")), "/clear", "clear", ""), + Entry("/effort with non-empty arguments (modern user shape)", + userLine("u3", commandWrapper("/effort", "effort", "high")), "/effort", "effort", "high"), + Entry("/usage via the older system/local_command shape", + systemLocalCommandLine("u4", commandWrapper("/usage", "usage", "")), "/usage", "usage", ""), + Entry("multiline arguments preserved verbatim", + userLine("u5", commandWrapper("/goal", "goal", "line1\nline2")), "/goal", "goal", "line1\nline2"), + ) + + DescribeTable("output wrappers across streams and shapes", + func(line, wantStream, wantContent string) { + entries := readEntries(line) + Expect(entries).To(HaveLen(1)) + ev := entries[0].Event + Expect(ev).NotTo(BeNil()) + Expect(ev.Type).To(Equal("claude_command_output")) + Expect(ev.Scope).To(Equal("turn")) + Expect(ev.Data["stream"]).To(Equal(wantStream)) + Expect(ev.Data["content"]).To(Equal(wantContent)) + }, + Entry("empty stdout (modern user shape)", + userLine("o1", stdoutWrapper("")), "stdout", ""), + Entry("empty stdout (older system/local_command shape)", + systemLocalCommandLine("o2", stdoutWrapper("")), "stdout", ""), + Entry("non-empty stdout (older system/local_command shape)", + systemLocalCommandLine("o3", stdoutWrapper("MCP dialog dismissed")), "stdout", "MCP dialog dismissed"), + Entry("stderr stream", + userLine("o4", stderrWrapper("boom")), "stderr", "boom"), + Entry("multiline output with ANSI escapes preserved", + userLine("o5", stdoutWrapper("\x1b[31mred\x1b[0m\nsecond line")), "stdout", "\x1b[31mred\x1b[0m\nsecond line"), + ) + + Describe("false positives and malformed wrappers", func() { + It("leaves ordinary prose that merely mentions a tag as a User message", func() { + text := "Please review how is parsed in the reader." + entries := readEntries(userLine("p1", text)) + Expect(entries).To(HaveLen(1)) + Expect(entries[0].Event).To(BeNil()) + Expect(entries[0].Message.Role).To(Equal(MessageRoleUser)) + Expect(entries[0].Message.GetTextContent()).To(Equal(text)) + }) + + It("does not classify mixed content that is not a single text block", func() { + line := fmt.Sprintf( + `{"type":"user","uuid":"m1","sessionId":"s1","timestamp":"2026-07-14T12:16:09.113Z","message":{"role":"user","content":[{"type":"text","text":%s},{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}`, + jsonString(commandWrapper("/goal", "goal", "do things"))) + entries := readEntries(line) + Expect(entries).To(HaveLen(1)) + Expect(entries[0].Event).To(BeNil()) + Expect(entries[0].Message.Role).To(Equal(MessageRoleUser)) + }) + + It("surfaces a recognized-but-incomplete wrapper as a ParseError without stopping the read", func() { + partial := "/goal" // missing message + args sections + entries := readEntries( + userLine("bad", partial), + userLine("good", "a normal follow-up prompt"), + ) + Expect(entries).To(HaveLen(2)) + + parseErr := entries[0].Message.GetToolUses() + Expect(parseErr).To(HaveLen(1)) + Expect(parseErr[0].Name).To(Equal("ParseError")) + + Expect(entries[1].Event).To(BeNil()) + Expect(entries[1].Message.Role).To(Equal(MessageRoleUser)) + Expect(entries[1].Message.GetTextContent()).To(Equal("a normal follow-up prompt")) + }) + }) +}) diff --git a/pkg/claude/reader_test.go b/pkg/claude/reader_test.go index 7fe96f0..a56081c 100644 --- a/pkg/claude/reader_test.go +++ b/pkg/claude/reader_test.go @@ -321,8 +321,11 @@ func TestReadStreamJSON_PrLinkSurfaced(t *testing.T) { // dropped as unhandled. func TestReadStreamJSON_ContentSystemSubtypes(t *testing.T) { ResetUnhandledStreamTypes() + // A local_command whose content is a recognized command/output wrapper now + // surfaces as a structured claude_command(_output) event; only untagged + // content falls through to the generic LocalCommand row exercised here. input := `{"type":"system","subtype":"compact_boundary","content":"Conversation compacted","uuid":"c"} -{"type":"system","subtype":"local_command","content":"ok","uuid":"l"} +{"type":"system","subtype":"local_command","content":"cleared pending input","uuid":"l"} {"type":"system","subtype":"scheduled_task_fire","content":"resuming /loop","uuid":"s"} {"type":"system","subtype":"informational","content":"Remote Control disconnected","uuid":"i"}` @@ -332,7 +335,7 @@ func TestReadStreamJSON_ContentSystemSubtypes(t *testing.T) { } wantRows := map[string]string{ "CompactBoundary": "Conversation compacted", - "LocalCommand": "ok", + "LocalCommand": "cleared pending input", "ScheduledTaskFire": "resuming /loop", "Informational": "Remote Control disconnected", } diff --git a/pkg/claude/tools/claude_command_event_test.go b/pkg/claude/tools/claude_command_event_test.go new file mode 100644 index 0000000..f10cf46 --- /dev/null +++ b/pkg/claude/tools/claude_command_event_test.go @@ -0,0 +1,126 @@ +package tools + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ClaudeCommand and GoalStatus renderers", func() { + Describe("ClaudeCommandTool", func() { + It("renders a slash-command invocation with an argument preview", func() { + tool := NewTool(BaseTool{ + RawTool: "ClaudeCommand", + Input: map[string]any{ + "event": "claude_command", + "scope": "turn", + "command_name": "/goal", + "command_message": "goal", + "command_args": "ship the docker build on PR #32", + }, + }) + Expect(tool).To(BeAssignableToTypeOf(&ClaudeCommandTool{})) + Expect(tool.Name()).To(Equal("ClaudeCommand")) + Expect(tool.Category()).To(Equal("chat")) + + pretty := tool.Pretty().String() + Expect(pretty).To(ContainSubstring("/goal")) + Expect(pretty).To(ContainSubstring("ship the docker build on PR #32")) + + detail := tool.Detail() + Expect(detail).NotTo(BeNil()) + Expect(detail.String()).To(ContainSubstring("ship the docker build on PR #32")) + Expect(detail.String()).NotTo(ContainSubstring("")) + }) + + It("renders stdout output with the stream label and preview, wrapper-free", func() { + tool := NewTool(BaseTool{ + RawTool: "ClaudeCommand", + Input: map[string]any{ + "event": "claude_command_output", + "scope": "turn", + "stream": "stdout", + "content": "Goal set: ship it", + }, + }) + Expect(tool).To(BeAssignableToTypeOf(&ClaudeCommandTool{})) + + pretty := tool.Pretty().String() + Expect(pretty).To(ContainSubstring("stdout")) + Expect(pretty).To(ContainSubstring("Goal set: ship it")) + Expect(pretty).NotTo(ContainSubstring("")) + + detail := tool.Detail() + Expect(detail).NotTo(BeNil()) + Expect(detail.String()).To(ContainSubstring("Goal set: ship it")) + Expect(detail.String()).NotTo(ContainSubstring("")) + }) + + It("renders stderr output with the stderr stream label", func() { + tool := NewTool(BaseTool{ + RawTool: "ClaudeCommand", + Input: map[string]any{ + "event": "claude_command_output", + "scope": "turn", + "stream": "stderr", + "content": "command failed", + }, + }) + pretty := tool.Pretty().String() + Expect(pretty).To(ContainSubstring("stderr")) + Expect(pretty).To(ContainSubstring("command failed")) + }) + }) + + Describe("GoalStatusTool", func() { + It("renders an active goal with a condition preview", func() { + tool := NewTool(BaseTool{ + RawTool: "GoalStatus", + Input: map[string]any{ + "event": "goal_status", + "scope": "session", + "met": false, + "sentinel": true, + "condition": "release once CI is green", + }, + }) + Expect(tool).To(BeAssignableToTypeOf(&GoalStatusTool{})) + Expect(tool.Name()).To(Equal("GoalStatus")) + Expect(tool.Category()).To(Equal("chat")) + + pretty := tool.Pretty().String() + Expect(pretty).To(ContainSubstring("goal active")) + Expect(pretty).To(ContainSubstring("release once CI is green")) + + detail := tool.Detail() + Expect(detail).NotTo(BeNil()) + Expect(detail.String()).To(ContainSubstring("release once CI is green")) + }) + + It("renders a met goal", func() { + tool := NewTool(BaseTool{ + RawTool: "GoalStatus", + Input: map[string]any{ + "event": "goal_status", + "met": true, + "condition": "release once CI is green", + }, + }) + Expect(tool.Pretty().String()).To(ContainSubstring("goal met")) + }) + + It("includes a failure reason in the detail", func() { + tool := NewTool(BaseTool{ + RawTool: "GoalStatus", + Input: map[string]any{ + "event": "goal_status", + "met": false, + "condition": "release once CI is green", + "reason": "docker build still failing", + }, + }) + detail := tool.Detail() + Expect(detail).NotTo(BeNil()) + Expect(detail.String()).To(ContainSubstring("docker build still failing")) + }) + }) +}) diff --git a/pkg/claude/tools/event.go b/pkg/claude/tools/event.go index 04a9c68..1a63ea3 100644 --- a/pkg/claude/tools/event.go +++ b/pkg/claude/tools/event.go @@ -69,6 +69,10 @@ func EventToolName(eventType string) string { return "Relocated" case "started": return "Started" + case "claude_command", "claude_command_output": + return "ClaudeCommand" + case "goal_status": + return "GoalStatus" default: return "Event" } @@ -85,7 +89,8 @@ func IsEventToolName(name string) bool { "CollabClose", "QueueOperation", "DeferredToolsDelta", "AgentListingDelta", "MemoryCitation", "SkillListing", "Budget", "PrLink", "CompactBoundary", "LocalCommand", "ScheduledTaskFire", - "Informational", "WorktreeState", "Relocated", "Started", "UserShellCommand": + "Informational", "WorktreeState", "Relocated", "Started", "UserShellCommand", + "ClaudeCommand", "GoalStatus": return true default: return false diff --git a/pkg/claude/tools/event_renderers.go b/pkg/claude/tools/event_renderers.go index ab49bed..b2764fe 100644 --- a/pkg/claude/tools/event_renderers.go +++ b/pkg/claude/tools/event_renderers.go @@ -365,3 +365,97 @@ func (t *StartedTool) Detail() api.Textable { return t.BaseTool.Detail() } func (t *StartedTool) Pretty() api.Text { return eventText(icons.Play, "started", "text-green-600 font-medium") } + +// ClaudeCommandTool renders a Claude slash-command invocation (claude_command) +// or its captured output (claude_command_output) as a concise, non-operational +// event row. The wrapper tags are stripped by the reader; this only formats the +// preserved fields. +type ClaudeCommandTool struct{ BaseTool } + +func (t *ClaudeCommandTool) Name() string { return "ClaudeCommand" } +func (t *ClaudeCommandTool) Category() string { return "chat" } +func (t *ClaudeCommandTool) FilePath() string { return "" } +func (t *ClaudeCommandTool) ExtractPath() string { return "" } + +func (t *ClaudeCommandTool) isOutput() bool { + return t.Str("event") == "claude_command_output" || t.Str("stream") != "" +} + +func (t *ClaudeCommandTool) Pretty() api.Text { + if t.isOutput() { + stream := firstNonEmptyEvent(t.Str("stream"), "stdout") + color := "text-slate-500 font-medium" + if stream == "stderr" { + color = "text-red-500 font-medium" + } + text := eventText(icons.Terminal, stream, color) + if content := t.Str("content"); content != "" { + text = text.Append(" "+eventPreview(content, 100), "text-muted") + } + return text + } + name := firstNonEmptyEvent(t.Str("command_name"), "command") + text := eventText(icons.Terminal, name, "text-indigo-500 font-medium") + if args := t.Str("command_args"); args != "" { + text = text.Append(" "+eventPreview(args, 100), "text-muted") + } + return text +} + +func (t *ClaudeCommandTool) Detail() api.Textable { + if d := t.BaseTool.Detail(); d != nil { + return d + } + body := t.Str("command_args") + if t.isOutput() { + body = t.Str("content") + } + if strings.TrimSpace(body) == "" { + return nil + } + text := clicky.Text("").Append(body, "") + return &text +} + +// GoalStatusTool renders a session-scoped Claude goal directive as a concise, +// non-operational event row. +type GoalStatusTool struct{ BaseTool } + +func (t *GoalStatusTool) Name() string { return "GoalStatus" } +func (t *GoalStatusTool) Category() string { return "chat" } +func (t *GoalStatusTool) FilePath() string { return "" } +func (t *GoalStatusTool) ExtractPath() string { return "" } + +func (t *GoalStatusTool) Pretty() api.Text { + label, color := "goal active", "text-amber-600 font-medium" + if met, _ := t.Input["met"].(bool); met { + label, color = "goal met", "text-green-600 font-medium" + } + text := eventText(icons.Target, label, color) + if condition := t.Str("condition"); condition != "" { + text = text.Append(" "+eventPreview(condition, 100), "text-muted") + } + return text +} + +func (t *GoalStatusTool) Detail() api.Textable { + if d := t.BaseTool.Detail(); d != nil { + return d + } + condition := strings.TrimSpace(t.Str("condition")) + reason := strings.TrimSpace(t.Str("reason")) + if condition == "" && reason == "" { + return nil + } + text := clicky.Text("") + if condition != "" { + text = text.Append("condition: ", "font-bold text-muted").Append(condition, "") + } + if reason != "" { + if condition != "" { + text = text.NewLine() + } + text = text.Append("reason: ", "font-bold text-muted").Append(reason, "") + } + return &text +} diff --git a/pkg/claude/tools/muted_styles_test.go b/pkg/claude/tools/muted_styles_test.go index a370080..4aeebc4 100644 --- a/pkg/claude/tools/muted_styles_test.go +++ b/pkg/claude/tools/muted_styles_test.go @@ -86,6 +86,15 @@ var _ = Describe("semantic muted pretty styles", func() { Entry("command stdout label", func() api.Textable { return (&CodexExecCommandTool{BaseTool: BaseTool{Input: map[string]any{"stdout": "command output"}}}).Detail() }, "stdout: "), + Entry("claude command arguments", func() api.Textable { + return (&ClaudeCommandTool{BaseTool: BaseTool{Input: map[string]any{"event": "claude_command", "command_args": "argument preview"}}}).Pretty() + }, " argument preview"), + Entry("claude command output", func() api.Textable { + return (&ClaudeCommandTool{BaseTool: BaseTool{Input: map[string]any{"event": "claude_command_output", "stream": "stdout", "content": "output preview"}}}).Pretty() + }, " output preview"), + Entry("goal condition", func() api.Textable { + return (&GoalStatusTool{BaseTool: BaseTool{Input: map[string]any{"condition": "goal condition"}}}).Pretty() + }, " goal condition"), ) }) diff --git a/pkg/claude/tools/stream_test.go b/pkg/claude/tools/stream_test.go index eb360ef..7f36a2e 100644 --- a/pkg/claude/tools/stream_test.go +++ b/pkg/claude/tools/stream_test.go @@ -87,6 +87,7 @@ func TestNewTool_DispatchSyntheticTypes(t *testing.T) { "AgentListingDelta", "SkillListing", "Budget", "PrLink", "CompactBoundary", "LocalCommand", "ScheduledTaskFire", "Informational", "WorktreeState", "Relocated", "Started", + "ClaudeCommand", "GoalStatus", } { got := NewTool(BaseTool{RawTool: name}) assert.Equal(t, name, got.Name(), "expected NewTool to return the right concrete type for %q", name) diff --git a/pkg/claude/tools/tool.go b/pkg/claude/tools/tool.go index dcd03ba..4dba65c 100644 --- a/pkg/claude/tools/tool.go +++ b/pkg/claude/tools/tool.go @@ -287,6 +287,10 @@ func NewTool(base BaseTool) Tool { return &PrLinkTool{BaseTool: base} case "CompactBoundary", "LocalCommand", "ScheduledTaskFire", "Informational": return &ContentEventTool{BaseTool: base} + case "ClaudeCommand": + return &ClaudeCommandTool{BaseTool: base} + case "GoalStatus": + return &GoalStatusTool{BaseTool: base} case "WorktreeState": return &WorktreeStateTool{BaseTool: base} case "Relocated": diff --git a/pkg/cli/stdin_claude_command_test.go b/pkg/cli/stdin_claude_command_test.go new file mode 100644 index 0000000..ca56667 --- /dev/null +++ b/pkg/cli/stdin_claude_command_test.go @@ -0,0 +1,73 @@ +package cli + +import ( + "github.com/flanksource/captain/pkg/claude" + "github.com/flanksource/captain/pkg/session" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// claudeGoalTranscript is the exact three-record shape Claude writes for a +// `/goal` interaction: a goal_status attachment, the slash-command wrapper as a +// text-only user message, and its captured stdout. +const claudeGoalTranscript = `{"type":"attachment","uuid":"uuid-goal","sessionId":"s1","timestamp":"2026-07-14T12:16:09.113Z","cwd":"/repo","attachment":{"type":"goal_status","met":false,"sentinel":true,"condition":"ship the docker build"}} +{"type":"user","uuid":"uuid-cmd","sessionId":"s1","timestamp":"2026-07-14T12:16:09.200Z","cwd":"/repo","message":{"role":"user","content":"/goal\n goal\n ship the docker build"}} +{"type":"user","uuid":"uuid-out","sessionId":"s1","timestamp":"2026-07-14T12:16:09.300Z","cwd":"/repo","message":{"role":"user","content":"Goal set: ship the docker build"}}` + +func historyToolNames(uses []claude.ToolUse) []string { + names := make([]string, 0, len(uses)) + for _, u := range uses { + names = append(names, u.Tool) + } + return names +} + +var _ = Describe("Claude /goal transcript history from a reader", func() { + It("detects the input as Claude JSONL and structures the command records", func() { + res, err := parseFromReader([]byte(claudeGoalTranscript)) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Format).To(Equal(claude.FormatClaudeJSONL)) + + Expect(historyToolNames(res.ToolUses)).To(Equal([]string{"GoalStatus", "ClaudeCommand", "ClaudeCommand"})) + + for _, u := range res.ToolUses { + Expect(u.Tool).NotTo(Equal("User")) + if text, ok := u.Input["text"].(string); ok { + Expect(text).NotTo(ContainSubstring("")) + Expect(text).NotTo(ContainSubstring("")) + } + } + }) + + It("renders structured GoalStatus/ClaudeCommand rows and no raw wrapper rows under a normal limit", func() { + out, err := runHistoryFromReader([]byte(claudeGoalTranscript), HistoryOptions{Limit: 12}) + Expect(err).NotTo(HaveOccurred()) + + result, ok := out.(session.HistoryResult) + Expect(ok).To(BeTrue()) + + rowTools := make([]string, 0, len(result.Results)) + var goalSummary, cmdSummary, outSummary string + for _, row := range result.Results { + rowTools = append(rowTools, row.Tool) + switch { + case row.Tool == "GoalStatus": + goalSummary = row.Summary + case row.Tool == "ClaudeCommand" && cmdSummary == "": + cmdSummary = row.Summary + case row.Tool == "ClaudeCommand": + outSummary = row.Summary + } + Expect(row.Summary).NotTo(ContainSubstring("")) + Expect(row.Summary).NotTo(ContainSubstring("")) + } + + Expect(rowTools).To(ContainElement("GoalStatus")) + Expect(rowTools).To(ContainElement("ClaudeCommand")) + Expect(rowTools).NotTo(ContainElement("User")) + + Expect(goalSummary).To(ContainSubstring("ship the docker build")) + Expect(cmdSummary).To(ContainSubstring("/goal")) + Expect(outSummary).To(ContainSubstring("Goal set: ship the docker build")) + }) +}) diff --git a/pkg/session/claude_command_test.go b/pkg/session/claude_command_test.go new file mode 100644 index 0000000..d563e94 --- /dev/null +++ b/pkg/session/claude_command_test.go @@ -0,0 +1,91 @@ +package session + +import ( + "strings" + + "github.com/flanksource/captain/pkg/claude" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// claudeGoalSessionJSONL is a minimal session that opens with the three-record +// /goal shape (goal_status attachment, slash-command wrapper, stdout) followed +// by a real user prompt and an assistant reply. +const claudeGoalSessionJSONL = `{"type":"attachment","uuid":"g","sessionId":"sess-1","timestamp":"2026-07-14T12:16:09.113Z","cwd":"/repo","attachment":{"type":"goal_status","met":false,"sentinel":true,"condition":"ship the docker build"}} +{"type":"user","uuid":"c","sessionId":"sess-1","timestamp":"2026-07-14T12:16:09.200Z","cwd":"/repo","message":{"role":"user","content":"/goal\n goal\n ship the docker build"}} +{"type":"user","uuid":"o","sessionId":"sess-1","timestamp":"2026-07-14T12:16:09.300Z","cwd":"/repo","message":{"role":"user","content":"Goal set: ship the docker build"}} +{"type":"user","uuid":"p","sessionId":"sess-1","timestamp":"2026-07-14T12:16:10.000Z","cwd":"/repo","message":{"role":"user","content":[{"type":"text","text":"Acknowledge the goal and start on the docker build."}]}} +{"type":"assistant","uuid":"a","sessionId":"sess-1","timestamp":"2026-07-14T12:16:11.000Z","cwd":"/repo","message":{"role":"assistant","model":"claude-sonnet-4","content":[{"type":"text","text":"On it."}],"stop_reason":"end_turn"}}` + +func buildGoalSession() *Session { + entries, err := claude.ReadHistory(strings.NewReader(claudeGoalSessionJSONL)) + Expect(err).NotTo(HaveOccurred()) + ps := claude.ParsedSession{ + SessionID: "sess-1", + Transcripts: []claude.ParsedTranscript{{ + Path: "/repo/.claude/sess-1.jsonl", + Entries: entries, + ToolUses: claude.ExtractToolUsesWithTokens(entries), + }}, + } + return buildSession(ps) +} + +func allTurnEventTypes(s *Session) []string { + var types []string + for _, turn := range s.Turns { + for _, ev := range turn.Events { + types = append(types, ev.Type) + } + } + return types +} + +var _ = ginkgo.Describe("Claude /goal records in a built session", func() { + ginkgo.It("records goal_status as a session-scoped event, not a message", func() { + s := buildGoalSession() + + var goal *Event + for i := range s.Events { + if s.Events[i].Type == "goal_status" { + goal = &s.Events[i] + } + } + Expect(goal).NotTo(BeNil()) + Expect(goal.Scope).To(Equal("session")) + Expect(goal.Data["condition"]).To(Equal("ship the docker build")) + }) + + ginkgo.It("records command and output as turn-scoped events", func() { + s := buildGoalSession() + Expect(allTurnEventTypes(s)).To(ContainElements("claude_command", "claude_command_output")) + }) + + ginkgo.It("does not count command/goal records as approvals or denials", func() { + s := buildGoalSession() + Expect(s.Approvals.Approved).To(Equal(0)) + Expect(s.Approvals.Denied).To(Equal(0)) + }) + + ginkgo.It("keeps the real user prompt as the initial prompt and title source", func() { + s := buildGoalSession() + Expect(s.InitialPrompt).To(Equal("Acknowledge the goal and start on the docker build.")) + Expect(s.Title).NotTo(ContainSubstring("")) + Expect(s.Title).NotTo(ContainSubstring("command-args")) + }) + + ginkgo.It("never surfaces raw command markup as conversational message text", func() { + s := buildGoalSession() + var userPromptSeen bool + for _, m := range s.Messages { + for _, p := range m.Parts { + Expect(p.Text).NotTo(ContainSubstring("")) + Expect(p.Text).NotTo(ContainSubstring("")) + if m.Role == "user" && strings.Contains(p.Text, "Acknowledge the goal") { + userPromptSeen = true + } + } + } + Expect(userPromptSeen).To(BeTrue()) + }) +}) From 1a3032d7fb66a93b28624b925087197f2e36e7fe Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 16:19:14 +0300 Subject: [PATCH 053/131] feat(api): support prompt shorthand and execution-session binding Allow prompt configurations to use concise string forms while preserving structured fields. Add spec merging and execution-session tracking so prompt runs retain resolved configuration and safely associate provider threads across admission and execution. --- migrations/20_prompt_runs_and_plans.pg.hcl | 13 ++ migrations/50_views_and_triggers.sql | 7 +- migrations/migrations_test.go | 1 + pkg/api/prompt_unmarshal.go | 50 ++++++ pkg/api/prompt_unmarshal_test.go | 76 +++++++++ pkg/api/spec_merge.go | 151 +++++++++++++++++ pkg/api/spec_merge_test.go | 153 ++++++++++++++++++ pkg/cli/database.go | 6 +- pkg/database/host.go | 17 ++ .../prompt_run_store_integration_test.go | 32 ++++ pkg/database/session_prompt_store.go | 92 +++++++++-- 11 files changed, 580 insertions(+), 18 deletions(-) create mode 100644 pkg/api/prompt_unmarshal.go create mode 100644 pkg/api/prompt_unmarshal_test.go create mode 100644 pkg/api/spec_merge.go create mode 100644 pkg/api/spec_merge_test.go create mode 100644 pkg/database/host.go diff --git a/migrations/20_prompt_runs_and_plans.pg.hcl b/migrations/20_prompt_runs_and_plans.pg.hcl index db63bfe..def2f4b 100644 --- a/migrations/20_prompt_runs_and_plans.pg.hcl +++ b/migrations/20_prompt_runs_and_plans.pg.hcl @@ -14,6 +14,10 @@ table "captain_prompt_runs" { null = false type = uuid } + column "execution_session_id" { + null = true + type = uuid + } column "batch_id" { null = true type = uuid @@ -127,6 +131,12 @@ table "captain_prompt_runs" { on_update = NO_ACTION on_delete = CASCADE } + foreign_key "captain_prompt_runs_execution_session_id_fkey" { + columns = [column.execution_session_id] + ref_columns = [table.captain_sessions.column.id] + on_update = NO_ACTION + on_delete = SET_NULL + } foreign_key "captain_prompt_runs_parent_run_id_fkey" { columns = [column.parent_run_id] ref_columns = [table.captain_prompt_runs.column.id] @@ -165,6 +175,9 @@ table "captain_prompt_runs" { index "captain_prompt_runs_batch_id_idx" { columns = [column.batch_id] } + index "captain_prompt_runs_execution_session_id_idx" { + columns = [column.execution_session_id] + } index "captain_prompt_runs_parent_run_id_idx" { columns = [column.parent_run_id] } diff --git a/migrations/50_views_and_triggers.sql b/migrations/50_views_and_triggers.sql index fba4f88..b5c98d9 100644 --- a/migrations/50_views_and_triggers.sql +++ b/migrations/50_views_and_triggers.sql @@ -103,6 +103,8 @@ BEGIN NEW.phase, NEW.state, NEW.current_iteration, + NEW.execution_session_id, + NEW.rendered_spec, NEW.result_text, NEW.result_json, NEW.error @@ -110,6 +112,8 @@ BEGIN OLD.phase, OLD.state, OLD.current_iteration, + OLD.execution_session_id, + OLD.rendered_spec, OLD.result_text, OLD.result_json, OLD.error @@ -1249,7 +1253,8 @@ SELECT COALESCE(plan_stats.plan_count, 0) AS plan_count, plan_stats.latest_plan_id, plan_stats.latest_plan_approval_state, - plan_stats.latest_plan_revision + plan_stats.latest_plan_revision, + r.execution_session_id FROM public.captain_prompt_runs r LEFT JOIN LATERAL ( SELECT diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index 9cd8ebb..0a2bc49 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -40,6 +40,7 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { assertContainsAll(t, "20_prompt_runs_and_plans.pg.hcl", `table "captain_prompt_runs"`, `column "session_id"`, + `column "execution_session_id"`, `column "root_session_id"`, `column "phase"`, `column "state"`, diff --git a/pkg/api/prompt_unmarshal.go b/pkg/api/prompt_unmarshal.go new file mode 100644 index 0000000..d9cb05a --- /dev/null +++ b/pkg/api/prompt_unmarshal.go @@ -0,0 +1,50 @@ +package api + +import ( + "bytes" + "encoding/json" + + "gopkg.in/yaml.v3" +) + +// UnmarshalJSON accepts either a bare string — shorthand for {user: } — +// or the full object form. This lets a config scalar carry just the user prompt +// (`prompt: "review strictly"`) while still supporting the structured form +// (`prompt: {user: ..., system: ...}`). +func (p *Prompt) UnmarshalJSON(data []byte) error { + if trimmed := bytes.TrimSpace(data); len(trimmed) > 0 && trimmed[0] == '"' { + var user string + if err := json.Unmarshal(trimmed, &user); err != nil { + return err + } + *p = Prompt{User: user} + return nil + } + type promptAlias Prompt + var a promptAlias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *p = Prompt(a) + return nil +} + +// UnmarshalYAML mirrors UnmarshalJSON: a scalar node is the user prompt, a +// mapping node is the full object. +func (p *Prompt) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode && value.Tag != "!!null" { + var user string + if err := value.Decode(&user); err != nil { + return err + } + *p = Prompt{User: user} + return nil + } + type promptAlias Prompt + var a promptAlias + if err := value.Decode(&a); err != nil { + return err + } + *p = Prompt(a) + return nil +} diff --git a/pkg/api/prompt_unmarshal_test.go b/pkg/api/prompt_unmarshal_test.go new file mode 100644 index 0000000..0932934 --- /dev/null +++ b/pkg/api/prompt_unmarshal_test.go @@ -0,0 +1,76 @@ +package api + +import ( + "encoding/json" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPrompt_UnmarshalJSON_Shorthand(t *testing.T) { + cases := []struct { + name string + in string + want Prompt + }{ + {"bare string", `"review strictly"`, Prompt{User: "review strictly"}}, + {"object form", `{"user":"u","system":"s"}`, Prompt{User: "u", System: "s"}}, + {"empty string", `""`, Prompt{User: ""}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var p Prompt + if err := json.Unmarshal([]byte(tc.in), &p); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.User != tc.want.User || p.System != tc.want.System { + t.Errorf("got %+v, want %+v", p, tc.want) + } + }) + } +} + +func TestPrompt_UnmarshalYAML_Shorthand(t *testing.T) { + cases := []struct { + name string + in string + want Prompt + }{ + {"scalar", `review strictly`, Prompt{User: "review strictly"}}, + {"object", "user: u\nsystem: s", Prompt{User: "u", System: "s"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var p Prompt + if err := yaml.Unmarshal([]byte(tc.in), &p); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.User != tc.want.User || p.System != tc.want.System { + t.Errorf("got %+v, want %+v", p, tc.want) + } + }) + } +} + +// TestSpec_PromptShorthand pins that the shorthand works through the enclosing +// Spec — `prompt: "text"` alongside an inlined model — for both encoders. +func TestSpec_PromptShorthand(t *testing.T) { + t.Run("json", func(t *testing.T) { + var s Spec + if err := json.Unmarshal([]byte(`{"model":"m","prompt":"hi there"}`), &s); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if s.Model.Name != "m" || s.Prompt.User != "hi there" { + t.Errorf("got model=%q prompt.user=%q", s.Model.Name, s.Prompt.User) + } + }) + t.Run("yaml", func(t *testing.T) { + var s Spec + if err := yaml.Unmarshal([]byte("model: m\nprompt: hi there\n"), &s); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if s.Model.Name != "m" || s.Prompt.User != "hi there" { + t.Errorf("got model=%q prompt.user=%q", s.Model.Name, s.Prompt.User) + } + }) +} diff --git a/pkg/api/spec_merge.go b/pkg/api/spec_merge.go new file mode 100644 index 0000000..e55120b --- /dev/null +++ b/pkg/api/spec_merge.go @@ -0,0 +1,151 @@ +package api + +// Merge returns a copy of s with override's set (non-zero) fields taking +// precedence. A zero-valued field in override is treated as "unset" and keeps +// s's value, so a base spec can supply defaults that an operation-specific spec +// selectively overrides: +// +// resolved := base.Merge(operation) +// +// Scalar fields (model name, effort, budget cost, prompt user, …) merge +// individually. Slices, maps, and pointers (Fallbacks, Metadata, Setup, +// Workflow, CLIArgs, Permissions sub-values) replace wholesale when set in +// override rather than deep-merging — an override that lists tools means exactly +// those tools. Boolean toggles (NoCache, Skip*) follow zero=unset: an override +// can turn a flag on but not off, since false is indistinguishable from absent. +func (s Spec) Merge(override Spec) Spec { + s.Model = s.Model.merge(override.Model) + s.Prompt = s.Prompt.merge(override.Prompt) + s.Budget = s.Budget.merge(override.Budget) + s.Memory = s.Memory.merge(override.Memory) + s.Permissions = s.Permissions.merge(override.Permissions) + if override.Setup != nil { + s.Setup = override.Setup + } + if override.Workflow != nil { + s.Workflow = override.Workflow + } + if override.SessionID != "" { + s.SessionID = override.SessionID + } + if len(override.CLIArgs) > 0 { + s.CLIArgs = override.CLIArgs + } + return s +} + +func (m Model) merge(o Model) Model { + if o.Name != "" { + m.Name = o.Name + } + if o.ID != "" { + m.ID = o.ID + } + if o.Backend != "" { + m.Backend = o.Backend + } + if o.Temperature != nil { + m.Temperature = o.Temperature + } + if o.Effort != "" { + m.Effort = o.Effort + } + if o.NoCache { + m.NoCache = true + } + if len(o.Fallbacks) > 0 { + m.Fallbacks = o.Fallbacks + } + return m +} + +func (p Prompt) merge(o Prompt) Prompt { + if o.User != "" { + p.User = o.User + } + if o.System != "" { + p.System = o.System + } + if o.AppendSystem != "" { + p.AppendSystem = o.AppendSystem + } + if o.Source != "" { + p.Source = o.Source + } + if o.Schema != nil { + p.Schema = o.Schema + } + if len(o.SchemaJSON) > 0 { + p.SchemaJSON = o.SchemaJSON + } + if o.SchemaStrictness != "" { + p.SchemaStrictness = o.SchemaStrictness + } + if len(o.Metadata) > 0 { + p.Metadata = o.Metadata + } + return p +} + +func (b Budget) merge(o Budget) Budget { + if o.Cost != 0 { + b.Cost = o.Cost + } + if o.MaxTokens != 0 { + b.MaxTokens = o.MaxTokens + } + if o.MaxTurns != 0 { + b.MaxTurns = o.MaxTurns + } + if o.Timeout != "" { + b.Timeout = o.Timeout + } + return b +} + +func (m Memory) merge(o Memory) Memory { + if len(o.Skills) > 0 { + m.Skills = o.Skills + } + if o.SkipProject { + m.SkipProject = true + } + if o.SkipUser { + m.SkipUser = true + } + if o.SkipSkills { + m.SkipSkills = true + } + if o.SkipHooks { + m.SkipHooks = true + } + if o.SkipMemory { + m.SkipMemory = true + } + if o.Bare { + m.Bare = true + } + return m +} + +func (p Permissions) merge(o Permissions) Permissions { + if o.Mode != "" { + p.Mode = o.Mode + } + if len(o.Presets) > 0 { + p.Presets = o.Presets + } + if len(o.Tools.Allow) > 0 || len(o.Tools.Deny) > 0 || len(o.Tools.Modes) > 0 { + p.Tools = o.Tools + } + if o.MCP.Disabled || len(o.MCP.Servers) > 0 || len(o.MCP.Modes) > 0 { + p.MCP = o.MCP + } + if len(o.Plugins) > 0 { + p.Plugins = o.Plugins + } + if len(o.Skills) > 0 { + p.Skills = o.Skills + } + return p +} diff --git a/pkg/api/spec_merge_test.go b/pkg/api/spec_merge_test.go new file mode 100644 index 0000000..2555c03 --- /dev/null +++ b/pkg/api/spec_merge_test.go @@ -0,0 +1,153 @@ +package api + +import ( + "reflect" + "testing" +) + +func TestSpec_Merge(t *testing.T) { + cases := []struct { + name string + base Spec + override Spec + check func(t *testing.T, got Spec) + }{ + { + name: "empty override preserves base", + base: sampleSpec(), + override: Spec{}, + check: func(t *testing.T, got Spec) { + if !reflect.DeepEqual(got, sampleSpec()) { + t.Fatalf("empty override mutated base:\n got=%+v", got) + } + }, + }, + { + name: "override model name wins, base budget/effort kept", + base: Spec{Model: Model{Name: "base-model", Effort: EffortMedium}, Budget: Budget{Cost: 5}}, + override: Spec{Model: Model{Name: "op-model"}}, + check: func(t *testing.T, got Spec) { + if got.Model.Name != "op-model" { + t.Errorf("Name = %q, want op-model", got.Model.Name) + } + if got.Model.Effort != EffortMedium { + t.Errorf("Effort = %q, want medium (from base)", got.Model.Effort) + } + if got.Budget.Cost != 5 { + t.Errorf("Budget.Cost = %v, want 5 (from base)", got.Budget.Cost) + } + }, + }, + { + name: "empty override model keeps base model", + base: Spec{Model: Model{Name: "base-model", Effort: EffortHigh}}, + override: Spec{Budget: Budget{Cost: 3}}, + check: func(t *testing.T, got Spec) { + if got.Model.Name != "base-model" || got.Model.Effort != EffortHigh { + t.Errorf("model = %+v, want base preserved", got.Model) + } + if got.Budget.Cost != 3 { + t.Errorf("Budget.Cost = %v, want 3 (override)", got.Budget.Cost) + } + }, + }, + { + name: "budget merges field-wise", + base: Spec{Model: Model{Name: "m"}, Budget: Budget{Cost: 5, MaxTokens: 1000, MaxTurns: 10}}, + override: Spec{Budget: Budget{Cost: 9}}, + check: func(t *testing.T, got Spec) { + if got.Budget.Cost != 9 { + t.Errorf("Cost = %v, want 9", got.Budget.Cost) + } + if got.Budget.MaxTokens != 1000 || got.Budget.MaxTurns != 10 { + t.Errorf("budget lost base fields: %+v", got.Budget) + } + }, + }, + { + name: "prompt user overrides, base system kept", + base: Spec{Model: Model{Name: "m"}, Prompt: Prompt{User: "base body", System: "be precise"}}, + override: Spec{Prompt: Prompt{User: "op body"}}, + check: func(t *testing.T, got Spec) { + if got.Prompt.User != "op body" { + t.Errorf("User = %q, want op body", got.Prompt.User) + } + if got.Prompt.System != "be precise" { + t.Errorf("System = %q, want base system kept", got.Prompt.System) + } + }, + }, + { + name: "fallbacks replace wholesale", + base: Spec{Model: Model{Name: "m", Fallbacks: []Model{{Name: "a"}, {Name: "b"}}}}, + override: Spec{Model: Model{Fallbacks: []Model{{Name: "c"}}}}, + check: func(t *testing.T, got Spec) { + if len(got.Model.Fallbacks) != 1 || got.Model.Fallbacks[0].Name != "c" { + t.Errorf("Fallbacks = %+v, want [c]", got.Model.Fallbacks) + } + }, + }, + { + name: "temperature pointer: nil override keeps base", + base: Spec{Model: Model{Name: "m", Temperature: floatPtr(0.7)}}, + override: Spec{Model: Model{Name: "m"}}, + check: func(t *testing.T, got Spec) { + if got.Model.Temperature == nil || *got.Model.Temperature != 0.7 { + t.Errorf("Temperature = %v, want 0.7 kept", got.Model.Temperature) + } + }, + }, + { + name: "temperature pointer: explicit 0.0 override wins", + base: Spec{Model: Model{Name: "m", Temperature: floatPtr(0.7)}}, + override: Spec{Model: Model{Temperature: floatPtr(0)}}, + check: func(t *testing.T, got Spec) { + if got.Model.Temperature == nil || *got.Model.Temperature != 0 { + t.Errorf("Temperature = %v, want explicit 0.0", got.Model.Temperature) + } + }, + }, + { + name: "bool noCache: base true survives override false", + base: Spec{Model: Model{Name: "m", NoCache: true}}, + override: Spec{Model: Model{Name: "m", NoCache: false}}, + check: func(t *testing.T, got Spec) { + if !got.Model.NoCache { + t.Errorf("NoCache = false, want base true preserved (false=unset)") + } + }, + }, + { + name: "setup pointer replaced when set in override", + base: Spec{Model: Model{Name: "m"}}, + override: Spec{Model: Model{Name: "m"}, SessionID: "sess-op"}, + check: func(t *testing.T, got Spec) { + if got.SessionID != "sess-op" { + t.Errorf("SessionID = %q, want sess-op", got.SessionID) + } + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.base.Merge(tc.override) + tc.check(t, got) + }) + } +} + +// TestSpec_Merge_DoesNotMutateInputs guards that Merge is pure — neither the base +// nor the override is modified (slice/map aliasing aside, the top-level structs +// must be untouched so a shared base spec can be merged repeatedly). +func TestSpec_Merge_DoesNotMutateInputs(t *testing.T) { + base := Spec{Model: Model{Name: "base", Effort: EffortLow}, Budget: Budget{Cost: 1}} + override := Spec{Model: Model{Name: "op"}, Budget: Budget{Cost: 2}} + baseCopy, overrideCopy := base, override + _ = base.Merge(override) + if !reflect.DeepEqual(base, baseCopy) { + t.Errorf("Merge mutated base: %+v != %+v", base, baseCopy) + } + if !reflect.DeepEqual(override, overrideCopy) { + t.Errorf("Merge mutated override: %+v != %+v", override, overrideCopy) + } +} diff --git a/pkg/cli/database.go b/pkg/cli/database.go index 2961592..bd45509 100644 --- a/pkg/cli/database.go +++ b/pkg/cli/database.go @@ -138,11 +138,7 @@ func serveMonitor() *monitor.Monitor { } func captainHostID() string { - host, err := os.Hostname() - if err != nil || host == "" { - return "local" - } - return host + return database.LocalHostID() } // monitorDiscoverProcesses is indirected so cli tests can fake live-process diff --git a/pkg/database/host.go b/pkg/database/host.go new file mode 100644 index 0000000..755b8cd --- /dev/null +++ b/pkg/database/host.go @@ -0,0 +1,17 @@ +package database + +import ( + "os" + "strings" +) + +// LocalHostID returns the canonical identity used by every local Captain +// session producer. Keeping it shared prevents launchers and transcript +// monitors from creating different rows for the same provider thread. +func LocalHostID() string { + host, err := os.Hostname() + if err != nil || strings.TrimSpace(host) == "" { + return "local" + } + return strings.TrimSpace(host) +} diff --git a/pkg/database/prompt_run_store_integration_test.go b/pkg/database/prompt_run_store_integration_test.go index 5e569fc..5c85951 100644 --- a/pkg/database/prompt_run_store_integration_test.go +++ b/pkg/database/prompt_run_store_integration_test.go @@ -75,4 +75,36 @@ func TestListPromptRunsFiltersAndOrdersDeterministically(t *testing.T) { emptySessionID := uuid.Nil _, err = db.ListPromptRuns(t.Context(), PromptRunFilter{SessionID: &emptySessionID}) assert.ErrorIs(t, err, ErrInvalidPromptRun) + + admission, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: "provider-thread-1", Source: "gavel", Provider: "headless-claude", HostID: "test-host", + }) + require.NoError(t, err) + execution, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: "provider-thread-1", Source: "claude", Provider: "anthropic", HostID: "test-host", + }) + require.NoError(t, err) + linked, err := db.CreatePromptRun(t.Context(), CreatePromptRunInput{SessionID: admission.ID}) + require.NoError(t, err) + linked, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: linked.ID, ExpectedVersion: linked.Version, ExecutionSessionID: &execution.ID, + }) + require.NoError(t, err) + require.NotNil(t, linked.ExecutionSessionID) + assert.Equal(t, execution.ID, *linked.ExecutionSessionID) + version := linked.Version + linked, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: linked.ID, ExpectedVersion: linked.Version, ExecutionSessionID: &execution.ID, + }) + require.NoError(t, err) + assert.Equal(t, version, linked.Version, "an exact execution-session replay must be idempotent") + + otherExecution, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + ProviderSessionID: "provider-thread-1", Source: "claude", Provider: "anthropic", HostID: "other-host", + }) + require.NoError(t, err) + _, err = db.UpdatePromptRun(t.Context(), UpdatePromptRunInput{ + ID: linked.ID, ExpectedVersion: linked.Version, ExecutionSessionID: &otherExecution.ID, + }) + assert.ErrorIs(t, err, ErrPromptRunConflict) } diff --git a/pkg/database/session_prompt_store.go b/pkg/database/session_prompt_store.go index c81d69d..d11c4cb 100644 --- a/pkg/database/session_prompt_store.go +++ b/pkg/database/session_prompt_store.go @@ -423,6 +423,7 @@ type PromptRun struct { ID uuid.UUID `json:"id"` SessionID uuid.UUID `json:"sessionId"` RootSessionID uuid.UUID `json:"rootSessionId"` + ExecutionSessionID *uuid.UUID `json:"executionSessionId,omitempty"` BatchID *uuid.UUID `json:"batchId,omitempty"` ParentRunID *uuid.UUID `json:"parentRunId,omitempty"` InputPlanID *uuid.UUID `json:"inputPlanId,omitempty"` @@ -457,6 +458,7 @@ type CreatePromptRunInput struct { ID uuid.UUID SessionID uuid.UUID RootSessionID *uuid.UUID + ExecutionSessionID *uuid.UUID BatchID *uuid.UUID ParentRunID *uuid.UUID InputPlanID *uuid.UUID @@ -470,20 +472,23 @@ type CreatePromptRunInput struct { } type UpdatePromptRunInput struct { - ID uuid.UUID - ExpectedVersion int64 - Phase *PromptRunPhase - State *PromptRunState - CurrentIteration *int - ResultText *string - ResultJSON *map[string]any - Error *string + ID uuid.UUID + ExpectedVersion int64 + Phase *PromptRunPhase + State *PromptRunState + CurrentIteration *int + ExecutionSessionID *uuid.UUID + RenderedSpec *map[string]any + ResultText *string + ResultJSON *map[string]any + Error *string } type promptRunRecord struct { ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` RootSessionID uuid.UUID `gorm:"column:root_session_id;type:uuid"` + ExecutionSessionID *uuid.UUID `gorm:"column:execution_session_id;type:uuid"` BatchID *uuid.UUID `gorm:"column:batch_id;type:uuid"` ParentRunID *uuid.UUID `gorm:"column:parent_run_id;type:uuid"` InputPlanID *uuid.UUID `gorm:"column:input_plan_id;type:uuid"` @@ -539,6 +544,11 @@ func (db *DB) CreatePromptRun(ctx context.Context, input CreatePromptRunInput) ( return nil, fmt.Errorf("%w: root session %s does not match session aggregate root %s", ErrInvalidPromptRun, *input.RootSessionID, rootID) } } + if input.ExecutionSessionID != nil { + if err := db.validateExecutionSession(ctx, session, *input.ExecutionSessionID); err != nil { + return nil, err + } + } if input.ParentRunID != nil { if *input.ParentRunID == uuid.Nil || *input.ParentRunID == input.ID { return nil, fmt.Errorf("%w: parent run cannot be empty or self", ErrInvalidPromptRun) @@ -553,7 +563,7 @@ func (db *DB) CreatePromptRun(ctx context.Context, input CreatePromptRunInput) ( } now := time.Now().UTC() record := promptRunRecord{ - ID: input.ID, SessionID: input.SessionID, RootSessionID: rootID, BatchID: input.BatchID, + ID: input.ID, SessionID: input.SessionID, RootSessionID: rootID, ExecutionSessionID: input.ExecutionSessionID, BatchID: input.BatchID, ParentRunID: input.ParentRunID, InputPlanID: input.InputPlanID, InputPlanRevisionID: input.InputPlanRevisionID, Origin: nullableTrimmed(input.Origin), SpecProfile: nullableTrimmed(input.SpecProfile), AdmissionKey: nullableTrimmed(input.AdmissionKey), RenderedSpec: input.RenderedSpec, @@ -673,6 +683,37 @@ func (db *DB) UpdatePromptRun(ctx context.Context, input UpdatePromptRunInput) ( distinctPredicates = append(distinctPredicates, "current_iteration IS DISTINCT FROM ?") distinctArgs = append(distinctArgs, *input.CurrentIteration) } + if input.ExecutionSessionID != nil { + var record promptRunRecord + if err := db.gorm.WithContext(ctx).First(&record, "id = ?", input.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrPromptRunNotFound, input.ID) + } + return nil, fmt.Errorf("read Captain prompt run execution binding: %w", err) + } + admission, err := db.GetSession(ctx, record.SessionID) + if err != nil { + return nil, err + } + if err := db.validateExecutionSession(ctx, admission, *input.ExecutionSessionID); err != nil { + return nil, err + } + updates["execution_session_id"] = *input.ExecutionSessionID + distinctPredicates = append(distinctPredicates, "execution_session_id IS DISTINCT FROM ?") + distinctArgs = append(distinctArgs, *input.ExecutionSessionID) + } + if input.RenderedSpec != nil { + if *input.RenderedSpec == nil { + return nil, fmt.Errorf("%w: rendered spec cannot be null", ErrInvalidPromptRun) + } + encoded, err := json.Marshal(*input.RenderedSpec) + if err != nil { + return nil, fmt.Errorf("%w: encode rendered spec: %v", ErrInvalidPromptRun, err) + } + updates["rendered_spec"] = *input.RenderedSpec + distinctPredicates = append(distinctPredicates, "rendered_spec IS DISTINCT FROM CAST(? AS jsonb)") + distinctArgs = append(distinctArgs, string(encoded)) + } if input.ResultText != nil { updates["result_text"] = *input.ResultText distinctPredicates = append(distinctPredicates, "result_text IS DISTINCT FROM ?") @@ -700,9 +741,13 @@ func (db *DB) UpdatePromptRun(ctx context.Context, input UpdatePromptRunInput) ( if len(updates) == 0 { return nil, fmt.Errorf("%w: no update fields supplied", ErrInvalidPromptRun) } - result := db.gorm.WithContext(ctx).Model(&promptRunRecord{}). + query := db.gorm.WithContext(ctx).Model(&promptRunRecord{}). Where("id = ? AND version = ?", input.ID, input.ExpectedVersion). - Where("("+strings.Join(distinctPredicates, " OR ")+")", distinctArgs...).Updates(updates) + Where("("+strings.Join(distinctPredicates, " OR ")+")", distinctArgs...) + if input.ExecutionSessionID != nil { + query = query.Where("(execution_session_id IS NULL OR execution_session_id = ?)", *input.ExecutionSessionID) + } + result := query.Updates(updates) if result.Error != nil { return nil, fmt.Errorf("update Captain prompt run: %w", result.Error) } @@ -717,6 +762,9 @@ func (db *DB) UpdatePromptRun(ctx context.Context, input UpdatePromptRunInput) ( if current.Version != input.ExpectedVersion { return nil, fmt.Errorf("%w: prompt run %s is no longer at version %d", ErrPromptRunConflict, input.ID, input.ExpectedVersion) } + if input.ExecutionSessionID != nil && current.ExecutionSessionID != nil && *current.ExecutionSessionID != *input.ExecutionSessionID { + return nil, fmt.Errorf("%w: prompt run %s is already bound to execution session %s", ErrPromptRunConflict, input.ID, *current.ExecutionSessionID) + } out := promptRunFromRecord(current) return &out, nil } @@ -768,7 +816,7 @@ func validSessionHealth(value SessionHealthState) bool { func promptRunFromRecord(record promptRunRecord) PromptRun { return PromptRun{ - ID: record.ID, SessionID: record.SessionID, RootSessionID: record.RootSessionID, BatchID: record.BatchID, + ID: record.ID, SessionID: record.SessionID, RootSessionID: record.RootSessionID, ExecutionSessionID: record.ExecutionSessionID, BatchID: record.BatchID, ParentRunID: record.ParentRunID, InputPlanID: record.InputPlanID, InputPlanRevisionID: record.InputPlanRevisionID, Origin: optionalString(record.Origin), SpecProfile: optionalString(record.SpecProfile), AdmissionKey: optionalString(record.AdmissionKey), RenderedSpec: record.RenderedSpec, PromptMarkdown: optionalString(record.PromptMarkdown), @@ -779,6 +827,26 @@ func promptRunFromRecord(record promptRunRecord) PromptRun { } } +func (db *DB) validateExecutionSession(ctx context.Context, admission *Session, executionID uuid.UUID) error { + if admission == nil || executionID == uuid.Nil { + return fmt.Errorf("%w: execution session ID is required", ErrInvalidPromptRun) + } + execution, err := db.GetSession(ctx, executionID) + if err != nil { + return err + } + if execution.ParentSessionID != nil || execution.RootSessionID != nil { + return fmt.Errorf("%w: execution session %s is not a root provider thread", ErrPromptRunConflict, execution.ID) + } + if execution.Source != "claude" && execution.Source != "codex" { + return fmt.Errorf("%w: execution session %s has unsupported source %q", ErrPromptRunConflict, execution.ID, execution.Source) + } + if strings.TrimSpace(admission.ProviderSessionID) == "" || admission.ProviderSessionID != execution.ProviderSessionID { + return fmt.Errorf("%w: execution session %s provider identity does not match admission session %s", ErrPromptRunConflict, execution.ID, admission.ID) + } + return nil +} + func validPromptRunPhase(value PromptRunPhase) bool { switch value { case PromptRunPhaseQueued, PromptRunPhasePreRun, PromptRunPhaseGenerate, PromptRunPhaseVerify, From 28c32977701957eaf0b0629d24a2b39dd3eea4c5 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 16:19:22 +0300 Subject: [PATCH 054/131] fix(ai): handle plan terminal signals and render envelope summaries Keep plan-only turns from entering implementation while preserving their terminal outcome across transports. Render structured completion envelopes as readable summaries in assistant history. --- pkg/ai/assistanttags/envelope.go | 42 ++++++++++++ pkg/ai/assistanttags/envelope_test.go | 35 ++++++++++ pkg/ai/history/codex_parser.go | 6 +- pkg/ai/provider/claudeagent/provider_test.go | 68 ++++++++++++++++++++ pkg/ai/provider/claudeagent/turn.go | 20 +++++- pkg/ai/provider/cmux/stall.go | 8 ++- pkg/ai/terminal_outcome.go | 18 ++++++ pkg/ai/terminal_outcome_test.go | 13 ++++ pkg/claude/tooluse.go | 6 +- pkg/claude/tooluse_test.go | 17 +++++ pkg/cli/stdin_test.go | 15 +++++ 11 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 pkg/ai/assistanttags/envelope.go create mode 100644 pkg/ai/assistanttags/envelope_test.go diff --git a/pkg/ai/assistanttags/envelope.go b/pkg/ai/assistanttags/envelope.go new file mode 100644 index 0000000..ea75404 --- /dev/null +++ b/pkg/ai/assistanttags/envelope.go @@ -0,0 +1,42 @@ +package assistanttags + +import ( + "encoding/json" + "strings" +) + +// resultEnvelope is the subset of gavel's result/plan final-result envelope +// needed to recognize a structured completion message. The full schema (plan, +// questions) is owned by flanksource/gavel and intentionally not modeled here — +// captain only reads these messages, and the dependency runs gavel → captain. +type resultEnvelope struct { + Summary string `json:"summary"` + EndStatus string `json:"endStatus"` +} + +var envelopeEndStatuses = map[string]bool{ + "completed": true, + "failed": true, + "ask": true, +} + +// EnvelopeSummary returns the human-readable summary of a gavel result-envelope +// final message, and ok=false when text is not a recognized envelope. The whole +// (trimmed) message must be a single JSON object carrying a non-empty summary and +// an endStatus of completed|failed|ask — strict enough that ordinary assistant +// prose or unrelated JSON is never mistaken for an envelope. +func EnvelopeSummary(text string) (string, bool) { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "{") { + return "", false + } + var env resultEnvelope + if err := json.Unmarshal([]byte(trimmed), &env); err != nil { + return "", false + } + summary := strings.TrimSpace(env.Summary) + if summary == "" || !envelopeEndStatuses[env.EndStatus] { + return "", false + } + return summary, true +} diff --git a/pkg/ai/assistanttags/envelope_test.go b/pkg/ai/assistanttags/envelope_test.go new file mode 100644 index 0000000..21544f9 --- /dev/null +++ b/pkg/ai/assistanttags/envelope_test.go @@ -0,0 +1,35 @@ +package assistanttags + +import "testing" + +func TestEnvelopeSummary(t *testing.T) { + planEnvelopeJSON := `{"endStatus":"completed","plan":{"content":"","path":"/Users/moshe/.codex/plans/todo-review-banner-radio-buttons.md","status":"new"},"questions":[],"summary":"Authored and verified a complete read-only implementation plan.\n\n"}` + resultEnvelopeJSON := `{"summary":"Implemented the fix and ran the tests.","endStatus":"failed","questions":[]}` + + tests := []struct { + name string + input string + want string + wantOK bool + }{ + {"full plan envelope, summary trimmed", planEnvelopeJSON, "Authored and verified a complete read-only implementation plan.", true}, + {"result envelope without plan", resultEnvelopeJSON, "Implemented the fix and ran the tests.", true}, + {"ask envelope", `{"summary":"Blocked on a question.","endStatus":"ask","questions":[{"text":"which db?"}]}`, "Blocked on a question.", true}, + {"leading and trailing whitespace", "\n {\"summary\":\"done\",\"endStatus\":\"completed\"} \n", "done", true}, + {"unrelated json object missing endStatus", `{"summary":"looks like a summary but no status"}`, "", false}, + {"invalid endStatus value", `{"summary":"x","endStatus":"in_progress"}`, "", false}, + {"blank summary", `{"summary":" ","endStatus":"completed"}`, "", false}, + {"ordinary prose", "Here is my plan: first do X, then Y.", "", false}, + {"json array not object", `[{"summary":"x","endStatus":"completed"}]`, "", false}, + {"trailing garbage after object", `{"summary":"x","endStatus":"completed"} and more`, "", false}, + {"empty string", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := EnvelopeSummary(tt.input) + if ok != tt.wantOK || got != tt.want { + t.Fatalf("EnvelopeSummary(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/pkg/ai/history/codex_parser.go b/pkg/ai/history/codex_parser.go index b3b3f2c..b313a27 100644 --- a/pkg/ai/history/codex_parser.go +++ b/pkg/ai/history/codex_parser.go @@ -513,7 +513,11 @@ func extractCodexAssistantText(text string, event CodexEvent, cwd, sessionID, re } case assistanttags.SegmentText: use.Tool = "Assistant" - use.Input = map[string]any{"text": segment.Text} + body := segment.Text + if summary, ok := assistanttags.EnvelopeSummary(body); ok { + body = summary + } + use.Input = map[string]any{"text": body} default: continue } diff --git a/pkg/ai/provider/claudeagent/provider_test.go b/pkg/ai/provider/claudeagent/provider_test.go index 37bdbdf..1c6629f 100644 --- a/pkg/ai/provider/claudeagent/provider_test.go +++ b/pkg/ai/provider/claudeagent/provider_test.go @@ -107,6 +107,18 @@ func runFakeServer() { 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 "plan-approval": + // A plan-mode turn ending in ExitPlanMode: the tool_use streams first + // (the SDK yields the assistant message before executing the tool), + // then the permission check; the turn completes when the host replies. + enc(map[string]any{"jsonrpc": "2.0", "method": "message/tool_use", "params": map[string]any{ + "tool": "ExitPlanMode", "id": "tu-plan", + "input": map[string]any{"plan": "1. change the seam", "planFilePath": "/repo/.claude/plans/x.md"}, + }}) + enc(map[string]any{"jsonrpc": "2.0", "id": "perm-plan", "method": "can_use_tool", "params": map[string]any{ + "tool": "ExitPlanMode", "tool_use_id": "tu-plan", + "input": map[string]any{"plan": "1. change the seam", "planFilePath": "/repo/.claude/plans/x.md"}, + }}) case "hang": // Emit nothing more; wait for the interrupt control request. case "structured": @@ -385,6 +397,62 @@ func TestProvider_CanUseTool(t *testing.T) { assert.Contains(t, resultText, "ls -la") } +// TestProvider_PlanModeExitPlanModeAutoDenied verifies the shared plan-mode +// terminal policy: in a plan-only run the ExitPlanMode can_use_tool is answered +// by the provider itself (deny + guidance) — the host broker is never invoked, +// no EventPermission is surfaced, and the already-streamed tool_use still +// yields the plan terminal outcome. +func TestProvider_PlanModeExitPlanModeAutoDenied(t *testing.T) { + withFakeAgentProcessEnv(t, map[string]string{fakeServerEnv: "1", fakeModeEnv: "plan-approval"}) + + canUseTool := func(_ context.Context, r ai.PermissionRequest) (ai.PermissionDecision, error) { + t.Errorf("CanUseTool must not be invoked for ExitPlanMode in plan mode, got %s", r.Tool) + return ai.PermissionDecision{Allow: true}, nil + } + + p, err := New(ai.Config{Model: api.Model{Name: "claude-sonnet-5"}, CanUseTool: canUseTool}) + require.NoError(t, err) + t.Cleanup(func() { _ = p.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + events, err := p.ExecuteStream(ctx, ai.Request{ + Prompt: api.Prompt{User: "plan it"}, + Permissions: api.Permissions{Mode: api.PermissionPlan}, + }) + require.NoError(t, err) + + var outcome *ai.TerminalOutcome + var result *ai.Event + for ev := range events { + switch ev.Kind { + case ai.EventPermission: + t.Errorf("plan-terminal ExitPlanMode must not surface an EventPermission, got %s", ev.Tool) + case ai.EventToolUse: + parsed, perr := ai.TerminalOutcomeFromEvent(ev) + require.NoError(t, perr) + if parsed != nil { + outcome = parsed + } + case ai.EventResult: + cp := ev + result = &cp + } + } + + // The streamed tool_use still derives the plan terminal outcome. + require.NotNil(t, outcome, "expected the ExitPlanMode tool_use to stream") + assert.Equal(t, ai.TerminalOutcomePlan, outcome.Kind) + assert.Equal(t, "/repo/.claude/plans/x.md", outcome.Plan.Path) + + // The provider answered the can_use_tool itself with the deny + guidance. + require.NotNil(t, result, "expected a terminal result event") + resultText, _ := result.Input["result"].(string) + assert.Contains(t, resultText, `"allow":false`) + assert.Contains(t, resultText, "Plan captured for human review") +} + // TestProvider_InterruptNoKill verifies a cancelled turn is ended by the // interrupt control request — the fake records receiving it — rather than by // killing the process. diff --git a/pkg/ai/provider/claudeagent/turn.go b/pkg/ai/provider/claudeagent/turn.go index 89402c2..624b261 100644 --- a/pkg/ai/provider/claudeagent/turn.go +++ b/pkg/ai/provider/claudeagent/turn.go @@ -8,6 +8,7 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/ai/provider/jsonrpc" + "github.com/flanksource/captain/pkg/api" ) // turnState carries the channels a single in-flight turn uses to receive mapped @@ -25,6 +26,9 @@ type turnState struct { ctx context.Context canUseTool ai.PermissionFunc + // planMode marks a plan-only turn: ExitPlanMode is then the terminal signal + // and its can_use_tool is answered by the shared policy, never the broker. + planMode bool } type promptParams struct { @@ -50,6 +54,7 @@ func (p *Provider) runTurn(ctx context.Context, req ai.Request, events chan ai.E quit: make(chan struct{}), ctx: ctx, canUseTool: p.cfg.CanUseTool, + planMode: req.Permissions.Mode == api.PermissionPlan, } p.setActive(ts) defer func() { @@ -154,7 +159,7 @@ func (p *Provider) handleCanUseTool(params json.RawMessage) (any, *jsonrpc.RPCEr p.activeMu.Lock() ts := p.active p.activeMu.Unlock() - if ts == nil || ts.canUseTool == nil { + if ts == nil { return canUseToolResult{Allow: true, UpdatedInput: in.Input}, nil } @@ -162,6 +167,19 @@ func (p *Provider) handleCanUseTool(params json.RawMessage) (any, *jsonrpc.RPCEr sessionID := p.sessionID p.sessMu.Unlock() + // The plan-mode terminal signal is answered here, never brokered: nothing is + // awaiting a human, so no EventPermission is surfaced either. The tool_use + // already streamed, so the turn still ends in a plan terminal outcome. + if decision, handled := ai.PlanTerminalPermission(ts.planMode, ai.PermissionRequest{ + Tool: in.Tool, Input: in.Input, ToolUseID: in.ToolUseID, SessionID: sessionID, + }); handled { + return canUseToolResult{Allow: decision.Allow, Message: decision.Message}, nil + } + + if ts.canUseTool == nil { + return canUseToolResult{Allow: true, UpdatedInput: in.Input}, nil + } + p.deliver(ts, ai.Event{ Kind: ai.EventPermission, Tool: in.Tool, diff --git a/pkg/ai/provider/cmux/stall.go b/pkg/ai/provider/cmux/stall.go index ab0a85f..5581c44 100644 --- a/pkg/ai/provider/cmux/stall.go +++ b/pkg/ai/provider/cmux/stall.go @@ -228,9 +228,11 @@ func (w *stallWatchdog) maybeRequestApproval(ctx context.Context, screen string) if !ok { return } - // A plan-only run must return ExitPlanMode to its caller. Accepting this - // dialog would start implementation inside the same agent turn. - if w.r.planMode && req.Tool == "ExitPlanMode" { + // The shared plan-mode policy: a plan-only run must return ExitPlanMode to + // its caller — accepting this dialog would start implementation inside the + // same agent turn. On this transport the deny is applied by dismissing the + // plan surface. + if _, handled := ai.PlanTerminalPermission(w.r.planMode, req); handled { if err := w.r.dismissPlanSurface(ctx, w.ref, w.sessionID); err != nil { log.Warnf("cmux: failed to dismiss plan-only approval: %v", err) } diff --git a/pkg/ai/terminal_outcome.go b/pkg/ai/terminal_outcome.go index 53961de..1319cf0 100644 --- a/pkg/ai/terminal_outcome.go +++ b/pkg/ai/terminal_outcome.go @@ -20,6 +20,24 @@ func TerminalOutcomeFromEvent(event Event) (*TerminalOutcome, error) { } } +// PlanTerminalPermission is the shared plan-mode permission policy: in a +// plan-only run the ExitPlanMode call is the turn's terminal signal, not a +// permission to grant — allowing it would leave plan mode and start +// implementation inside the same agent turn. Transports consult this before +// brokering a can_use_tool round-trip; when handled they apply the decision +// themselves (the SDK answers the deny, cmux dismisses its plan surface). +// AskUserQuestion is deliberately not handled: its round-trip is the +// interactive ask flow. +func PlanTerminalPermission(planMode bool, req PermissionRequest) (PermissionDecision, bool) { + if !planMode || req.Tool != "ExitPlanMode" { + return PermissionDecision{}, false + } + return PermissionDecision{ + Allow: false, + Message: "Plan captured for human review. Do not implement it in this session — end the turn and return your final response.", + }, true +} + func terminalPlanFromInput(input map[string]any) (*TerminalOutcome, error) { content, err := requiredString(input, "plan") if err != nil { diff --git a/pkg/ai/terminal_outcome_test.go b/pkg/ai/terminal_outcome_test.go index a360445..a56f1c5 100644 --- a/pkg/ai/terminal_outcome_test.go +++ b/pkg/ai/terminal_outcome_test.go @@ -85,3 +85,16 @@ func TestTerminalOutcomeFromEventIgnoresOrdinaryTools(t *testing.T) { require.NoError(t, err) assert.Nil(t, outcome) } + +func TestPlanTerminalPermission(t *testing.T) { + decision, handled := PlanTerminalPermission(true, PermissionRequest{Tool: "ExitPlanMode", Input: map[string]any{"plan": "1. do it"}}) + require.True(t, handled, "ExitPlanMode in plan mode is the terminal signal, not a brokered approval") + assert.False(t, decision.Allow) + assert.NotEmpty(t, decision.Message) + + _, handled = PlanTerminalPermission(false, PermissionRequest{Tool: "ExitPlanMode"}) + assert.False(t, handled, "outside plan mode ExitPlanMode brokers normally") + + _, handled = PlanTerminalPermission(true, PermissionRequest{Tool: "AskUserQuestion"}) + assert.False(t, handled, "AskUserQuestion keeps its interactive broker round-trip") +} diff --git a/pkg/claude/tooluse.go b/pkg/claude/tooluse.go index a51653c..043e2e1 100644 --- a/pkg/claude/tooluse.go +++ b/pkg/claude/tooluse.go @@ -229,7 +229,11 @@ func taggedAssistantToolUses(entry HistoryEntry, text string, timestamp *time.Ti } case assistanttags.SegmentText: use.Tool = "Assistant" - use.Input = map[string]any{"text": segment.Text} + body := segment.Text + if summary, ok := assistanttags.EnvelopeSummary(body); ok { + body = summary + } + use.Input = map[string]any{"text": body} default: continue } diff --git a/pkg/claude/tooluse_test.go b/pkg/claude/tooluse_test.go index e16359f..f078294 100644 --- a/pkg/claude/tooluse_test.go +++ b/pkg/claude/tooluse_test.go @@ -162,6 +162,23 @@ MEMORY.md:1-2|note=[shared parser] } } +func TestExtractToolUses_EnvelopeRendersSummary(t *testing.T) { + entry := HistoryEntry{ + Timestamp: "2026-07-08T11:20:00Z", + Message: Message{ + Role: MessageRoleAssistant, + Content: []ContentBlock{{Type: ContentTypeText, Text: `{"endStatus":"completed","plan":{"content":"","path":"/Users/moshe/.codex/plans/x.md","status":"new"},"questions":[],"summary":"Authored the review-banner plan."}`}}, + }, + } + uses := ExtractToolUses([]HistoryEntry{entry}) + if len(uses) != 1 || uses[0].Tool != "Assistant" { + t.Fatalf("uses = %+v, want single Assistant", uses) + } + if uses[0].Input["text"] != "Authored the review-banner plan." { + t.Fatalf("text = %q, want the plain summary", uses[0].Input["text"]) + } +} + func TestExtractToolUses_DetectsDenials(t *testing.T) { denialContent, _ := json.Marshal("The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said:\nkeep commons/logger") diff --git a/pkg/cli/stdin_test.go b/pkg/cli/stdin_test.go index 3cdf94f..63cde1c 100644 --- a/pkg/cli/stdin_test.go +++ b/pkg/cli/stdin_test.go @@ -359,6 +359,21 @@ func TestRunHistoryFromReader_CodexChatRowsAndChatCategoryFilter(t *testing.T) { assert.Equal(t, "TokenCount", tokenResult.Results[0].Tool) } +func TestRunHistoryFromReader_CodexEnvelopeRendersSummary(t *testing.T) { + data := []byte(`{"timestamp":"2026-07-08T11:19:57.028Z","type":"session_meta","payload":{"id":"sess-env","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}} +{"timestamp":"2026-07-08T11:20:00.403Z","type":"event_msg","payload":{"type":"agent_message","message":"{\"endStatus\":\"completed\",\"plan\":{\"content\":\"\",\"path\":\"/Users/moshe/.codex/plans/x.md\",\"status\":\"new\"},\"questions\":[],\"summary\":\"Authored the review-banner plan.\"}"}} +`) + + result, err := runHistoryFromReader(data, HistoryOptions{}) + require.NoError(t, err) + histResult := result.(session.HistoryResult) + require.Len(t, histResult.Results, 1) + assert.Equal(t, "Assistant", histResult.Results[0].Tool) + assert.Contains(t, histResult.Results[0].Summary, "Authored the review-banner plan.") + assert.NotContains(t, histResult.Results[0].Summary, "endStatus") + assert.NotContains(t, histResult.Results[0].Summary, `{"plan"`) +} + func TestRunHistoryFromReader_HidesCodexTokenCountBeforeLimit(t *testing.T) { data := []byte(`{"timestamp":"2026-07-08T11:19:57.028Z","type":"session_meta","payload":{"id":"sess-rollout","cwd":"/repo","cli_version":"0.143.0","model_provider":"openai"}} {"timestamp":"2026-07-08T11:20:00.430Z","type":"event_msg","payload":{"type":"token_count","turn_id":"turn-1","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":2,"total_tokens":12},"total_token_usage":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":2,"total_tokens":12},"model_context_window":100}}} From 779832772585d2b4a40eb10ca311e73997bdad2d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 16:19:32 +0300 Subject: [PATCH 055/131] feat(session): unify threaded session details and runtime metadata Preserve provider and child-agent metadata across session parsing and database projections so threaded viewers can render complete runtime, turn, and cost details. Simplify the detail UI to rely on the unified session model and render envelope responses as readable summaries. --- pkg/cli/session_get.go | 31 ++++++ pkg/cli/session_get_multi_test.go | 22 ++++ pkg/cli/webapp/src/SessionBrowser.tsx | 87 +++------------ pkg/cli/webapp/src/sessionData.ts | 146 ++------------------------ pkg/database/session_thread_store.go | 145 +++++++++++++++++++++++++ pkg/monitor/ingest.go | 3 +- pkg/monitor/ingest_test.go | 10 ++ pkg/session/build.go | 2 +- pkg/session/build_messages.go | 6 +- pkg/session/build_test.go | 58 ++++++++++ pkg/session/session.go | 26 +++-- pkg/session/turns.go | 77 ++++++++++++++ 12 files changed, 388 insertions(+), 225 deletions(-) create mode 100644 pkg/database/session_thread_store.go diff --git a/pkg/cli/session_get.go b/pkg/cli/session_get.go index dd213c2..0de3e46 100644 --- a/pkg/cli/session_get.go +++ b/pkg/cli/session_get.go @@ -60,6 +60,7 @@ func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResul return SessionGetResult{}, fmt.Errorf("parse Captain session %s: %w", overview.ID, buildErr) } attachPromptRun(ctx, db, overview.ID, detail) + enrichSessionDetail(detail, item.Summary) pageSessionMessages(detail, opts) item.Detail = detail } @@ -68,6 +69,36 @@ func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResul return SessionGetResult{Sessions: items, Total: len(items)}, nil } +func enrichSessionDetail(detail *session.Session, summary SessionRecord) { + if detail.Provider == "" { + detail.Provider = summary.Provider + } + if detail.Model == "" { + detail.Model = summary.Model + } + if detail.Backend == "" { + detail.Backend = summary.Backend + } + if detail.ReasoningEffort == "" { + detail.ReasoningEffort = summary.ReasoningEffort + } + if summary.Live != nil { + detail.Live = &session.LiveProcess{ + PID: summary.Live.PID, Status: summary.Live.Status, Active: summary.Live.Active, + CPUPercent: summary.Live.CPUPercent, MemoryPercent: summary.Live.MemoryPercent, + StartedAt: summary.Live.StartedAt, CWD: summary.Live.CWD, Command: summary.Live.Command, + } + } + for i := range detail.Turns { + if detail.Turns[i].Backend == "" { + detail.Turns[i].Backend = summary.Backend + } + if detail.Turns[i].ReasoningEffort == "" { + detail.Turns[i].ReasoningEffort = summary.ReasoningEffort + } + } +} + func (r SessionGetResult) Pretty() clickyapi.Text { list := clicky.List() list.Unstyled = true diff --git a/pkg/cli/session_get_multi_test.go b/pkg/cli/session_get_multi_test.go index 1c5e404..3848233 100644 --- a/pkg/cli/session_get_multi_test.go +++ b/pkg/cli/session_get_multi_test.go @@ -18,6 +18,28 @@ func TestSessionGetMulti(t *testing.T) { } var _ = Describe("session get multi-result output", func() { + It("projects overview runtime data into the unified session detail", func() { + detail := &session.Session{Turns: []session.Turn{{ID: "turn-1", Index: 1}}} + summary := SessionRecord{ + Backend: "codex-cmux", + ReasoningEffort: "high", + Live: &SessionLiveWire{ + PID: 4821, Status: "running", Active: true, CWD: "/repo", Command: "codex", + }, + } + + enrichSessionDetail(detail, summary) + + Expect(detail.Backend).To(Equal("codex-cmux")) + Expect(detail.ReasoningEffort).To(Equal("high")) + Expect(detail.Live).To(Equal(&session.LiveProcess{ + PID: 4821, Status: "running", Active: true, CWD: "/repo", Command: "codex", + })) + Expect(detail.Turns).To(Equal([]session.Turn{{ + ID: "turn-1", Index: 1, Backend: "codex-cmux", ReasoningEffort: "high", + }})) + }) + It("uses targeted identity lookup for UUID-prefix list searches", func() { providerID := "ad4c854e-cde6-4b99-99f3-667bf74112e3" store := &sessionGetOverviewStore{ diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx index 6cfc698..dbc7fd1 100644 --- a/pkg/cli/webapp/src/SessionBrowser.tsx +++ b/pkg/cli/webapp/src/SessionBrowser.tsx @@ -27,16 +27,12 @@ import { fetchSession, formatCompactNumber, formatCost, - sessionCostTotal, - sessionToolCount, - unifiedSessionTitle, type SessionDashboard, type SessionGetItem, type SessionGetResult, type SessionRecord, type ProjectScope, type SourceFilter, - type UnifiedSession, } from "./sessionData"; type Navigate = (to: string, opts?: { replace?: boolean }) => void; @@ -104,7 +100,6 @@ function SessionDetailPage({ actions={actions} bodyHeader={ item.detail)?.detail} timing={detailQuery.data?.timing} loading={detailQuery.isLoading} /> @@ -272,55 +267,19 @@ function SessionList({ } function SessionHeader({ - session, timing, loading, }: { - session?: UnifiedSession; timing?: TimingMetric[]; loading: boolean; }) { - if (loading && !session) { + if (loading) { return
Loading session...
; } - if (!session) { - return ( -
-
Session Browser
-
Select a session to inspect activity.
-
- ); - } return ( -
-
-
{unifiedSessionTitle(session)}
- - {session.source} - - {session.live && ( - - {session.live.status || "live"} - - )} - -
-
- {session.model && {session.model}} - {sessionToolCount(session.messages)} actions - {session.messages?.length ?? 0} messages - {session.turns?.length ? {session.turns.length} turns : null} - {session.agents?.length ? {session.agents.length} agents : null} - {session.files ? {fileCountLabel(session.files)} : null} - {session.approvals ? {approvalCountLabel(session.approvals)} : null} - {sessionCostTotal(session.cost) ? {formatCost(sessionCostTotal(session.cost))} : null} - {session.provider && {session.provider}} - {session.version && {session.version}} - {session.git?.branch && {session.git.branch}} - {session.live?.pid && pid={session.live.pid}} - {session.historyFile && {session.historyFile}} - {session.cwd && {session.cwd}} -
+
+
Session
+
); } @@ -367,16 +326,18 @@ function SessionDetail({ function SessionGetItemDetail({ item, single }: { item: SessionGetItem; single: boolean }) { return (
-
-
{item.captainId}
-
- {item.summary.source} - {item.providerSessionId && provider={item.providerSessionId}} - {item.host && host={item.host}} - {item.summary.project && project={item.summary.project}} - {item.summary.cwd && {item.summary.cwd}} + {!single ? ( +
+
{item.captainId}
+
+ {item.summary.source} + {item.providerSessionId && provider={item.providerSessionId}} + {item.host && host={item.host}} + {item.summary.project && project={item.summary.project}} + {item.summary.cwd && {item.summary.cwd}} +
-
+ ) : null} {item.detail ? (
@@ -388,24 +349,6 @@ function SessionGetItemDetail({ item, single }: { item: SessionGetItem; single: ); } -function fileCountLabel(files: NonNullable) { - const read = files.read?.length ?? 0; - const written = files.written?.length ?? 0; - if (read && written) return `${read} read / ${written} written`; - if (read) return `${read} read`; - if (written) return `${written} written`; - return "0 files"; -} - -function approvalCountLabel(approvals: NonNullable) { - const approved = approvals.approved ?? 0; - const denied = approvals.denied ?? 0; - if (approved && denied) return `${approved} approved / ${denied} denied`; - if (approved) return `${approved} approved`; - if (denied) return `${denied} denied`; - return "0 approvals"; -} - function SessionSummary({ summary, loading, diff --git a/pkg/cli/webapp/src/sessionData.ts b/pkg/cli/webapp/src/sessionData.ts index 1cc48c8..9794731 100644 --- a/pkg/cli/webapp/src/sessionData.ts +++ b/pkg/cli/webapp/src/sessionData.ts @@ -1,4 +1,8 @@ -import type { SessionUIMessage } from "@flanksource/clicky-ui/ai"; +import type { + SessionCost, + SessionUIMessage, + UnifiedSessionInput, +} from "@flanksource/clicky-ui/ai"; import { apiClient } from "./api"; import { parseServerTiming, type TimingMetric } from "./serverTiming"; @@ -30,6 +34,7 @@ export type SessionRecord = { version?: string; gitBranch?: string; provider?: string; + backend?: string; cwd?: string; toolCalls: number; messages: number; @@ -41,147 +46,12 @@ export type SessionRecord = { health?: SessionHealth[]; }; -// UnifiedSession is captain's canonical parsed session detail. Session get -// returns it inside a SessionGetItem when a transcript is available. -export type UnifiedGit = { branch?: string; commit?: string; worktree?: string; diff?: string }; - -export type UnifiedUsage = { - inputTokens?: number; - outputTokens?: number; - reasoningTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - totalTokens?: number; -}; - -export type UnifiedCost = { - model?: string; - inputTokens?: number; - outputTokens?: number; - reasoningTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - totalTokens?: number; - inputCost?: number; - outputCost?: number; - reasoningCost?: number; - cacheReadCost?: number; - cacheWriteCost?: number; -}; - -export type UnifiedAgent = { - id?: string; - parentId?: string; - type?: string; - desc?: string; - isRoot?: boolean; - historyFile?: string; - children?: UnifiedAgent[]; - usage?: UnifiedUsage; - cost?: UnifiedCost; -}; - -export type UnifiedChangedFiles = { - read?: string[]; - written?: string[]; -}; - -export type UnifiedPlanEvent = { - kind: string; - timestamp?: string; - reason?: string; -}; - -export type UnifiedPlan = { - path?: string; - slug?: string; - content?: string; - explicit?: boolean; - events?: UnifiedPlanEvent[]; -}; - -export type UnifiedDenial = { - toolUseId?: string; - tool?: string; - reason?: string; -}; - -export type UnifiedApprovalStats = { - approved?: number; - denied?: number; - denials?: UnifiedDenial[]; -}; - -export type UnifiedBudget = { - used?: number; - total?: number; - remaining?: number; - updatedAt?: string; -}; - -export type UnifiedCapabilities = { - tools?: string[]; - pendingMcpServers?: string[]; - agents?: string[]; - skills?: string[]; -}; - -export type UnifiedMetadataEvent = { - type: string; - scope?: string; - turnId?: string; - timestamp?: string; - uuid?: string; - data?: Record; -}; - -export type UnifiedTurn = { - id: string; - index: number; - startedAt?: string; - endedAt?: string; - stopReason?: string; - model?: string; - messageIds?: string[]; - usage?: Record; - cost?: Record; - context?: SessionContext; - budget?: UnifiedBudget; - events?: UnifiedMetadataEvent[]; -}; - -export type UnifiedSession = { +export type UnifiedCost = SessionCost; +export type UnifiedSession = UnifiedSessionInput & { id: string; source: "claude" | "codex"; - project?: string; - cwd?: string; - slug?: string; title?: string; initialPrompt?: string; - version?: string; - provider?: string; - model?: string; - historyFile?: string; - git?: UnifiedGit; - startedAt?: string; - endedAt?: string; - usage?: UnifiedUsage; - cost?: UnifiedCost; - toolCosts?: UnifiedCost[]; - context?: SessionContext; - budget?: UnifiedBudget; - capabilities?: UnifiedCapabilities; - events?: UnifiedMetadataEvent[]; - turns?: UnifiedTurn[]; - root?: UnifiedAgent; - agents?: UnifiedAgent[]; - files?: UnifiedChangedFiles; - plan?: UnifiedPlan; - approvals?: UnifiedApprovalStats; - health?: SessionHealth[]; - live?: SessionLive; - messages?: SessionUIMessage[]; - prompt?: unknown; }; export type SessionGetItem = { diff --git a/pkg/database/session_thread_store.go b/pkg/database/session_thread_store.go new file mode 100644 index 0000000..354c838 --- /dev/null +++ b/pkg/database/session_thread_store.go @@ -0,0 +1,145 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// SessionTurn is the complete per-turn accounting projection used by threaded +// session viewers. +type SessionTurn struct { + ID uuid.UUID `gorm:"column:id" json:"id"` + SessionID uuid.UUID `gorm:"column:session_id" json:"sessionId"` + ProviderTurnID *string `gorm:"column:provider_turn_id" json:"providerTurnId,omitempty"` + TurnIndex int `gorm:"column:turn_index" json:"turnIndex"` + Status string `gorm:"column:status" json:"status"` + StopReason *string `gorm:"column:stop_reason" json:"stopReason,omitempty"` + Error *string `gorm:"column:error" json:"error,omitempty"` + StartedAt *time.Time `gorm:"column:started_at" json:"startedAt,omitempty"` + EndedAt *time.Time `gorm:"column:ended_at" json:"endedAt,omitempty"` + Model *string `gorm:"column:model" json:"model,omitempty"` + Backend *string `gorm:"column:backend" json:"backend,omitempty"` + Effort *string `gorm:"column:effort" json:"effort,omitempty"` + ModelCallCount int64 `gorm:"column:model_call_count" json:"modelCallCount"` + InputTokens int64 `gorm:"column:input_tokens" json:"inputTokens"` + OutputTokens int64 `gorm:"column:output_tokens" json:"outputTokens"` + ReasoningTokens int64 `gorm:"column:reasoning_tokens" json:"reasoningTokens"` + CacheReadTokens int64 `gorm:"column:cache_read_tokens" json:"cacheReadTokens"` + CacheWriteTokens int64 `gorm:"column:cache_write_tokens" json:"cacheWriteTokens"` + TotalTokens int64 `gorm:"column:total_tokens" json:"totalTokens"` + CostUSD float64 `gorm:"column:cost_usd" json:"costUsd"` + MessageCount int64 `gorm:"column:message_count" json:"messageCount"` +} + +func (SessionTurn) TableName() string { return "captain_session_turns" } + +// SessionAgent is one root or child session in a provider thread. +type SessionAgent struct { + ID uuid.UUID `gorm:"column:id" json:"id"` + SessionID uuid.UUID `gorm:"column:session_id" json:"sessionId"` + ParentSessionID *uuid.UUID `gorm:"column:parent_session_id" json:"parentSessionId,omitempty"` + RootSessionID *uuid.UUID `gorm:"column:root_session_id" json:"rootSessionId,omitempty"` + IsRoot bool `gorm:"column:is_root" json:"isRoot"` + AgentType *string `gorm:"column:agent_type" json:"agentType,omitempty"` + Description *string `gorm:"column:description" json:"description,omitempty"` + HistoryFile *string `gorm:"column:history_file" json:"historyFile,omitempty"` + Source string `gorm:"column:source" json:"source"` + Provider string `gorm:"column:provider" json:"provider,omitempty"` + LifecycleStatus string `gorm:"column:lifecycle_status" json:"lifecycleStatus"` + ActivityState string `gorm:"column:activity_state" json:"activityState"` + HealthState string `gorm:"column:health_state" json:"healthState"` + StartedAt *time.Time `gorm:"column:started_at" json:"startedAt,omitempty"` + EndedAt *time.Time `gorm:"column:ended_at" json:"endedAt,omitempty"` + ChildCount int64 `gorm:"column:child_count" json:"childCount"` + InputTokens int64 `gorm:"column:input_tokens" json:"inputTokens"` + OutputTokens int64 `gorm:"column:output_tokens" json:"outputTokens"` + ReasoningTokens int64 `gorm:"column:reasoning_tokens" json:"reasoningTokens"` + CacheReadTokens int64 `gorm:"column:cache_read_tokens" json:"cacheReadTokens"` + CacheWriteTokens int64 `gorm:"column:cache_write_tokens" json:"cacheWriteTokens"` + TotalTokens int64 `gorm:"column:total_tokens" json:"totalTokens"` + CostUSD float64 `gorm:"column:cost_usd" json:"costUsd"` +} + +func (SessionAgent) TableName() string { return "captain_session_agents" } + +// SessionCost groups actual model calls by model/backend/effort for one agent. +type SessionCost struct { + ID string `gorm:"column:id" json:"id"` + SessionID uuid.UUID `gorm:"column:session_id" json:"sessionId"` + Model string `gorm:"column:model" json:"model"` + Backend string `gorm:"column:backend" json:"backend"` + Effort *string `gorm:"column:effort" json:"effort,omitempty"` + Currency string `gorm:"column:currency" json:"currency"` + ModelCallCount int64 `gorm:"column:model_call_count" json:"modelCallCount"` + InputTokens int64 `gorm:"column:input_tokens" json:"inputTokens"` + OutputTokens int64 `gorm:"column:output_tokens" json:"outputTokens"` + ReasoningTokens int64 `gorm:"column:reasoning_tokens" json:"reasoningTokens"` + CacheReadTokens int64 `gorm:"column:cache_read_tokens" json:"cacheReadTokens"` + CacheWriteTokens int64 `gorm:"column:cache_write_tokens" json:"cacheWriteTokens"` + TotalTokens int64 `gorm:"column:total_tokens" json:"totalTokens"` + TotalCost float64 `gorm:"column:total_cost" json:"totalCost"` + FirstCallAt *time.Time `gorm:"column:first_call_at" json:"firstCallAt,omitempty"` + LastCallAt *time.Time `gorm:"column:last_call_at" json:"lastCallAt,omitempty"` +} + +func (SessionCost) TableName() string { return "captain_session_costs" } + +func (db *DB) ListThreadSessionOverviews(ctx context.Context, rootID uuid.UUID) ([]SessionOverview, error) { + if rootID == uuid.Nil { + return nil, fmt.Errorf("%w: thread root ID is required", ErrInvalidSession) + } + var rows []SessionOverview + if err := db.gorm.WithContext(ctx).Where("id = ? OR root_session_id = ?", rootID, rootID). + Order("parent_session_id NULLS FIRST, COALESCE(last_activity_at, started_at, created_at), id").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain thread sessions: %w", err) + } + return rows, nil +} + +func (db *DB) ListThreadTranscriptMessages(ctx context.Context, rootID uuid.UUID) ([]TranscriptMessage, error) { + if rootID == uuid.Nil { + return nil, fmt.Errorf("%w: thread root ID is required", ErrInvalidSession) + } + var rows []TranscriptMessage + if err := db.gorm.WithContext(ctx).Where("session_id = ? OR session_id IN (SELECT id FROM captain_sessions WHERE root_session_id = ?)", rootID, rootID). + Order("occurred_at NULLS LAST, session_id, sequence").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain thread transcript: %w", err) + } + return rows, nil +} + +func (db *DB) ListThreadTurns(ctx context.Context, rootID uuid.UUID) ([]SessionTurn, error) { + var rows []SessionTurn + if err := db.threadQuery(ctx, rootID, &SessionTurn{}).Order("started_at NULLS LAST, session_id, turn_index").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain thread turns: %w", err) + } + return rows, nil +} + +func (db *DB) ListThreadAgents(ctx context.Context, rootID uuid.UUID) ([]SessionAgent, error) { + var rows []SessionAgent + if err := db.threadQuery(ctx, rootID, &SessionAgent{}).Order("is_root DESC, started_at NULLS LAST, session_id").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain thread agents: %w", err) + } + return rows, nil +} + +func (db *DB) ListThreadCosts(ctx context.Context, rootID uuid.UUID) ([]SessionCost, error) { + var rows []SessionCost + if err := db.threadQuery(ctx, rootID, &SessionCost{}).Order("first_call_at NULLS LAST, model, backend").Find(&rows).Error; err != nil { + return nil, fmt.Errorf("list Captain thread costs: %w", err) + } + return rows, nil +} + +func (db *DB) threadQuery(ctx context.Context, rootID uuid.UUID, model any) *gorm.DB { + query := db.gorm.WithContext(ctx).Model(model) + if rootID == uuid.Nil { + return query.Where("1 = 0") + } + return query.Where("session_id = ? OR session_id IN (SELECT id FROM captain_sessions WHERE root_session_id = ?)", rootID, rootID) +} diff --git a/pkg/monitor/ingest.go b/pkg/monitor/ingest.go index 3a467b9..5816f48 100644 --- a/pkg/monitor/ingest.go +++ b/pkg/monitor/ingest.go @@ -16,7 +16,7 @@ import ( // parserVersion invalidates every ingested transcript when the parsing or // mapping logic changes shape. -const parserVersion = 2 +const parserVersion = 3 // ingestor turns changed transcript files into native database rows, skipping // files whose recorded mtime/size/parser version still match the disk state. @@ -155,7 +155,6 @@ func (ing *ingestor) claudeIngestInput(ctx context.Context, path string) (databa input.Session.AgentType = info.AgentType input.Session.Description = info.AgentDesc input.Session.Path = path - input.Turns = nil // turns belong to the root session } else { input.Session.ProviderSessionID = info.RootSessionID } diff --git a/pkg/monitor/ingest_test.go b/pkg/monitor/ingest_test.go index ec5fe16..fad39f6 100644 --- a/pkg/monitor/ingest_test.go +++ b/pkg/monitor/ingest_test.go @@ -18,3 +18,13 @@ func TestUnifiedIngestInputPreservesTurnEffort(t *testing.T) { t.Fatalf("effort = %q, want max", input.Turns[0].Call.Effort) } } + +func TestUnifiedIngestInputPreservesAgentTurnOwnership(t *testing.T) { + input := unifiedIngestInput(&session.Session{Turns: []session.Turn{{ + ID: "child/turn-1", AgentID: "child", Index: 1, Model: "claude-haiku-4-5", + }}}, "claude", func(session.Message, int) int64 { return 0 }) + + if len(input.Turns) != 1 || input.Turns[0].ProviderTurnID != "child/turn-1" { + t.Fatalf("turns = %+v, want the child agent turn", input.Turns) + } +} diff --git a/pkg/session/build.go b/pkg/session/build.go index a7f1ffc..729eb7b 100644 --- a/pkg/session/build.go +++ b/pkg/session/build.go @@ -34,7 +34,7 @@ func buildSession(ps claude.ParsedSession) *Session { allEntries = append(allEntries, t.Entries...) allToolUses = append(allToolUses, t.ToolUses...) } - meta := buildSessionMetadata("claude", allEntries) + meta := buildTranscriptMetadata(ps) h := buildHierarchy(ps, meta.turnByEntry) s := &Session{ diff --git a/pkg/session/build_messages.go b/pkg/session/build_messages.go index c1a6617..9db52b7 100644 --- a/pkg/session/build_messages.go +++ b/pkg/session/build_messages.go @@ -136,7 +136,11 @@ func partsFromEntry(e claude.HistoryEntry) []Part { for _, segment := range assistanttags.Parse(b.Text) { switch segment.Kind { case assistanttags.SegmentText: - parts = append(parts, Part{Type: PartText, Text: segment.Text}) + body := segment.Text + if summary, ok := assistanttags.EnvelopeSummary(body); ok { + body = summary + } + parts = append(parts, Part{Type: PartText, Text: body}) case assistanttags.SegmentPlan: parts = append(parts, Part{ Type: PartTool, diff --git a/pkg/session/build_test.go b/pkg/session/build_test.go index d506286..4d61534 100644 --- a/pkg/session/build_test.go +++ b/pkg/session/build_test.go @@ -240,6 +240,29 @@ MEMORY.md:10-12|note=[shared parser] } } +func TestBuildSession_EnvelopeRendersSummary(t *testing.T) { + entry := assistantEntry("env-1", "", "claude-opus-4", nil, + claude.ContentBlock{Type: claude.ContentTypeText, Text: `{"endStatus":"completed","plan":{"content":"","path":"/Users/moshe/.codex/plans/x.md","status":"new"},"questions":[],"summary":"Authored the review-banner plan."}`}, + ) + uses := claude.ExtractToolUses([]claude.HistoryEntry{entry}) + s := buildSession(claude.ParsedSession{ + SessionID: "root-sess", + Transcripts: []claude.ParsedTranscript{{ + Path: "/p/root-sess.jsonl", + Entries: []claude.HistoryEntry{entry}, + ToolUses: uses, + }}, + }) + + if len(s.Messages) != 1 || len(s.Messages[0].Parts) != 1 { + t.Fatalf("messages = %+v", s.Messages) + } + part := s.Messages[0].Parts[0] + if part.Type != PartText || part.Text != "Authored the review-banner plan." { + t.Fatalf("part = %+v, want plain summary text", part) + } +} + func TestBuildSession_MetadataTurnsCapabilitiesBudget(t *testing.T) { entries := []claude.HistoryEntry{ { @@ -384,6 +407,41 @@ func TestBuildSession_MetadataTurnsCapabilitiesBudget(t *testing.T) { } } +func TestBuildSessionKeepsConcurrentAgentTurnsSeparate(t *testing.T) { + rootEntries := []claude.HistoryEntry{ + claudeTurnEntry("root-user", "2026-07-14T12:00:00Z", claude.MessageRoleUser, ""), + claudeTurnEntry("root-assistant", "2026-07-14T12:00:05Z", claude.MessageRoleAssistant, claude.StopReasonEndTurn), + } + agentEntries := []claude.HistoryEntry{ + claudeTurnEntry("agent-user", "2026-07-14T12:00:01Z", claude.MessageRoleUser, ""), + claudeTurnEntry("agent-assistant", "2026-07-14T12:00:04Z", claude.MessageRoleAssistant, claude.StopReasonEndTurn), + } + s := buildSession(claude.ParsedSession{ + SessionID: "root-sess", + Transcripts: []claude.ParsedTranscript{ + {Path: "/p/root-sess.jsonl", Entries: rootEntries}, + {Path: "/p/root-sess/subagents/agent-child.jsonl", IsAgent: true, AgentID: "child", Entries: agentEntries}, + }, + }) + + if len(s.Turns) != 2 { + t.Fatalf("turns = %+v, want independent root and child turns", s.Turns) + } + if s.Turns[0].AgentID != "root-sess" || s.Turns[0].ID != "root-sess/turn-1" { + t.Fatalf("first turn = %+v, want namespaced root turn", s.Turns[0]) + } + if s.Turns[1].AgentID != "child" || s.Turns[1].ID != "child/turn-1" { + t.Fatalf("second turn = %+v, want namespaced child turn", s.Turns[1]) + } + turnByMessage := map[string]string{} + for _, message := range s.Messages { + turnByMessage[message.ID] = message.TurnID + } + if turnByMessage["root-assistant"] != "root-sess/turn-1" || turnByMessage["agent-assistant"] != "child/turn-1" { + t.Fatalf("message turns = %+v", turnByMessage) + } +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false diff --git a/pkg/session/session.go b/pkg/session/session.go index e782b31..501b002 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -11,17 +11,19 @@ import ( // history/sessions commands render, the viewer consumes, and the chat/live // surfaces project from. type Session struct { - ID string `json:"id"` - Source string `json:"source,omitempty"` // "claude" | "codex" - Project string `json:"project,omitempty"` - CWD string `json:"cwd,omitempty"` - Slug string `json:"slug,omitempty"` - Title string `json:"title,omitempty"` - InitialPrompt string `json:"initialPrompt,omitempty"` - Version string `json:"version,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` // primary model - HistoryFile string `json:"historyFile,omitempty"` + ID string `json:"id"` + Source string `json:"source,omitempty"` // "claude" | "codex" + Project string `json:"project,omitempty"` + CWD string `json:"cwd,omitempty"` + Slug string `json:"slug,omitempty"` + Title string `json:"title,omitempty"` + InitialPrompt string `json:"initialPrompt,omitempty"` + Version string `json:"version,omitempty"` + Provider string `json:"provider,omitempty"` + Backend string `json:"backend,omitempty"` + Model string `json:"model,omitempty"` // primary model + ReasoningEffort string `json:"reasoningEffort,omitempty"` + HistoryFile string `json:"historyFile,omitempty"` Git GitState `json:"git,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"` @@ -112,11 +114,13 @@ type Event struct { // state for one model turn. type Turn struct { ID string `json:"id"` + AgentID string `json:"agentId,omitempty"` Index int `json:"index"` StartedAt *time.Time `json:"startedAt,omitempty"` EndedAt *time.Time `json:"endedAt,omitempty"` StopReason string `json:"stopReason,omitempty"` Model string `json:"model,omitempty"` + Backend string `json:"backend,omitempty"` ReasoningEffort string `json:"reasoningEffort,omitempty"` MessageIDs []string `json:"messageIds,omitempty"` Usage api.Usage `json:"usage,omitempty"` diff --git a/pkg/session/turns.go b/pkg/session/turns.go index 18b7514..452edba 100644 --- a/pkg/session/turns.go +++ b/pkg/session/turns.go @@ -23,6 +23,83 @@ type sessionMetadataBuild struct { turnByEntry map[string]string } +func buildTranscriptMetadata(parsed claude.ParsedSession) sessionMetadataBuild { + combined := sessionMetadataBuild{turnByEntry: map[string]string{}} + for _, transcript := range parsed.Transcripts { + agentID := parsed.SessionID + if transcript.IsAgent { + agentID = transcript.AgentID + } + current := buildSessionMetadata("claude", transcript.Entries) + namespaceTurns(¤t, agentID) + combined.events = append(combined.events, current.events...) + combined.turns = append(combined.turns, current.turns...) + for entryID, turnID := range current.turnByEntry { + combined.turnByEntry[entryID] = turnID + } + combined.capabilities.Tools = append(combined.capabilities.Tools, current.capabilities.Tools...) + combined.capabilities.PendingMCPServers = append(combined.capabilities.PendingMCPServers, current.capabilities.PendingMCPServers...) + combined.capabilities.Agents = append(combined.capabilities.Agents, current.capabilities.Agents...) + combined.capabilities.Skills = append(combined.capabilities.Skills, current.capabilities.Skills...) + if current.budget != nil && (combined.budget == nil || budgetIsLater(current.budget, combined.budget)) { + combined.budget = current.budget + } + } + sort.SliceStable(combined.turns, func(i, j int) bool { + left, right := combined.turns[i], combined.turns[j] + if left.StartedAt != nil && right.StartedAt != nil && !left.StartedAt.Equal(*right.StartedAt) { + return left.StartedAt.Before(*right.StartedAt) + } + if left.StartedAt != nil && right.StartedAt == nil { + return true + } + if left.StartedAt == nil && right.StartedAt != nil { + return false + } + if left.AgentID != right.AgentID { + return left.AgentID < right.AgentID + } + return left.Index < right.Index + }) + combined.capabilities.Tools = sortedStrings(combined.capabilities.Tools) + combined.capabilities.PendingMCPServers = sortedStrings(combined.capabilities.PendingMCPServers) + combined.capabilities.Agents = sortedStrings(combined.capabilities.Agents) + combined.capabilities.Skills = sortedStrings(combined.capabilities.Skills) + return combined +} + +func namespaceTurns(metadata *sessionMetadataBuild, agentID string) { + if metadata == nil { + return + } + namespaced := map[string]string{} + for index := range metadata.turns { + turn := &metadata.turns[index] + previous := turn.ID + turn.AgentID = agentID + turn.ID = agentID + "/" + previous + namespaced[previous] = turn.ID + for eventIndex := range turn.Events { + turn.Events[eventIndex].TurnID = turn.ID + } + } + for index := range metadata.events { + if turnID, ok := namespaced[metadata.events[index].TurnID]; ok { + metadata.events[index].TurnID = turnID + } + } + for entryID, turnID := range metadata.turnByEntry { + metadata.turnByEntry[entryID] = namespaced[turnID] + } +} + +func budgetIsLater(candidate, current *Budget) bool { + if candidate == nil || candidate.UpdatedAt == nil { + return false + } + return current == nil || current.UpdatedAt == nil || candidate.UpdatedAt.After(*current.UpdatedAt) +} + func buildSessionMetadata(source string, entries []claude.HistoryEntry) sessionMetadataBuild { b := sessionMetadataBuild{turnByEntry: map[string]string{}} var current *Turn From 91857aa232e90991077d1db33e552781a01dd696 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 06:42:18 +0300 Subject: [PATCH 056/131] fix(db): avoid migration contention during transcript ingestion Split post-migration constraints, triggers, outbox logic, and views into hash-gated scripts so steady-state applies avoid repeated DDL. Retry idempotent transcript ingestion after transient deadlocks or serialization failures to prevent batches being dropped during concurrent migrations. --- go.mod | 19 +- go.sum | 26 + migrations/50_constraints.sql | 39 + migrations/50_views_and_triggers.sql | 1310 -------------------- migrations/51_state_triggers.sql | 320 +++++ migrations/52_outbox_triggers.sql | 262 ++++ migrations/60_view_session_overview.sql | 211 ++++ migrations/61_view_session_transcript.sql | 30 + migrations/62_view_session_turns.sql | 98 ++ migrations/63_view_session_agents.sql | 60 + migrations/64_view_session_files.sql | 25 + migrations/65_view_session_plans.sql | 51 + migrations/66_view_session_approvals.sql | 30 + migrations/67_view_session_costs.sql | 56 + migrations/68_view_session_events.sql | 26 + migrations/69_view_prompt_run_overview.sql | 115 ++ migrations/migrations_test.go | 81 +- pkg/database/migration_integration_test.go | 18 + pkg/database/retry.go | 58 + pkg/database/retry_test.go | 98 ++ pkg/database/session_ingest_store.go | 66 +- 21 files changed, 1647 insertions(+), 1352 deletions(-) create mode 100644 migrations/50_constraints.sql delete mode 100644 migrations/50_views_and_triggers.sql create mode 100644 migrations/51_state_triggers.sql create mode 100644 migrations/52_outbox_triggers.sql create mode 100644 migrations/60_view_session_overview.sql create mode 100644 migrations/61_view_session_transcript.sql create mode 100644 migrations/62_view_session_turns.sql create mode 100644 migrations/63_view_session_agents.sql create mode 100644 migrations/64_view_session_files.sql create mode 100644 migrations/65_view_session_plans.sql create mode 100644 migrations/66_view_session_approvals.sql create mode 100644 migrations/67_view_session_costs.sql create mode 100644 migrations/68_view_session_events.sql create mode 100644 migrations/69_view_prompt_run_overview.sql create mode 100644 pkg/database/retry.go create mode 100644 pkg/database/retry_test.go diff --git a/go.mod b/go.mod index 36f1493..05601ea 100644 --- a/go.mod +++ b/go.mod @@ -37,14 +37,27 @@ require ( sigs.k8s.io/yaml v1.6.0 ) -require github.com/flanksource/commons-db v0.1.21 +require ( + github.com/flanksource/commons-db v0.1.21 + github.com/pelletier/go-toml/v2 v2.4.3 +) require ( + ariga.io/atlas v0.38.0 // indirect cloud.google.com/go/cloudsqlconn v1.22.1 // indirect + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/exaring/otelpgx v0.9.3 // indirect github.com/fergusstrange/embedded-postgres v1.34.0 // indirect - github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/go-openapi/inflect v0.19.0 // indirect + github.com/hashicorp/hcl/v2 v2.13.0 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/pgplex/pgparser v0.2.0 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + github.com/zclconf/go-cty v1.14.4 // indirect + github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.opencensus.io v0.24.0 // indirect gorm.io/driver/postgres v1.6.0 // indirect ) @@ -279,7 +292,7 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/pflag v1.0.10 github.com/spiffe/go-spiffe/v2 v2.6.0 // 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 1dd1f08..a177218 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +ariga.io/atlas v0.38.0 h1:MwbtwVtDWJFq+ECyeTAz2ArvewDnpeiw/t/sgNdDsdo= +ariga.io/atlas v0.38.0/go.mod h1:D7XMK6ei3GvfDqvzk+2VId78j77LdqHrqPOWamn51/s= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -54,6 +56,8 @@ github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoy github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk= github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= @@ -77,6 +81,8 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/TomOnTime/utfutil v1.0.0 h1:/0Ivgo2OjXJxo8i7zgvs7ewSFZMLwCRGm3P5Umowb90= github.com/TomOnTime/utfutil v1.0.0/go.mod h1:l9lZmOniizVSuIliSkEf87qivMRlSNzbdBFKjuLRg1c= +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= @@ -99,6 +105,10 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -151,6 +161,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -324,6 +336,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= +github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= @@ -342,6 +356,8 @@ github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -426,6 +442,8 @@ github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbI github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc= +github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc= +github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -537,6 +555,8 @@ github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3v github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -596,6 +616,8 @@ github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdD github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pgplex/pgparser v0.2.0 h1:BB7I/Mrwetiw07PTq/AlXcKfajlCwoJfwB7KZ7V9irI= +github.com/pgplex/pgparser v0.2.0/go.mod h1:DWhK7oZJn/16KTFeOQ+mk78+sRnEYya1XPghIoP9NFU= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= @@ -768,6 +790,10 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= +github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= +github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= diff --git a/migrations/50_constraints.sql b/migrations/50_constraints.sql new file mode 100644 index 0000000..cfdb766 --- /dev/null +++ b/migrations/50_constraints.sql @@ -0,0 +1,39 @@ +-- phase: post + +-- Paired provenance foreign keys form intentional cycles across runs, plans, +-- iterations, calls and events. Deferring their NO ACTION checks allows a +-- complete session aggregate to cascade in one statement while a standalone +-- deletion of referenced provenance still fails at transaction commit. Atlas +-- OSS does not model this PostgreSQL constraint property, so the post-HCL SQL +-- phase owns it. +-- +-- This script is hash-gated: it re-runs only when its content changes. If an +-- HCL change recreates one of these constraints, bump this script so the +-- deferrable property is restored. +ALTER TABLE public.captain_prompt_runs + ALTER CONSTRAINT captain_prompt_runs_input_plan_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_prompt_runs + ALTER CONSTRAINT captain_prompt_runs_input_plan_revision_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_plans + ALTER CONSTRAINT captain_plans_source_prompt_run_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_plans + ALTER CONSTRAINT captain_plans_source_iteration_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_plans + ALTER CONSTRAINT captain_plans_approved_revision_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_model_calls + ALTER CONSTRAINT captain_model_calls_prompt_run_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_model_calls + ALTER CONSTRAINT captain_model_calls_iteration_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_events + ALTER CONSTRAINT captain_events_prompt_run_id_fkey + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE public.captain_events + ALTER CONSTRAINT captain_events_iteration_id_fkey + DEFERRABLE INITIALLY DEFERRED; diff --git a/migrations/50_views_and_triggers.sql b/migrations/50_views_and_triggers.sql deleted file mode 100644 index b5c98d9..0000000 --- a/migrations/50_views_and_triggers.sql +++ /dev/null @@ -1,1310 +0,0 @@ --- phase: post --- runs: always - --- This file is applied after the Atlas HCL realm by commons-db/migrate. Keep --- every definition repeat-safe: the script deliberately runs on every apply so --- views and triggers are restored after table reconciliation. - --- Paired provenance foreign keys form intentional cycles across runs, plans, --- iterations, calls and events. Deferring their NO ACTION checks allows a --- complete session aggregate to cascade in one statement while a standalone --- deletion of referenced provenance still fails at transaction commit. Atlas --- OSS does not model this PostgreSQL constraint property, so the post-HCL SQL --- phase owns it. -ALTER TABLE public.captain_prompt_runs - ALTER CONSTRAINT captain_prompt_runs_input_plan_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_prompt_runs - ALTER CONSTRAINT captain_prompt_runs_input_plan_revision_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_plans - ALTER CONSTRAINT captain_plans_source_prompt_run_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_plans - ALTER CONSTRAINT captain_plans_source_iteration_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_plans - ALTER CONSTRAINT captain_plans_approved_revision_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_model_calls - ALTER CONSTRAINT captain_model_calls_prompt_run_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_model_calls - ALTER CONSTRAINT captain_model_calls_iteration_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_events - ALTER CONSTRAINT captain_events_prompt_run_id_fkey - DEFERRABLE INITIALLY DEFERRED; -ALTER TABLE public.captain_events - ALTER CONSTRAINT captain_events_iteration_id_fkey - DEFERRABLE INITIALLY DEFERRED; - -CREATE OR REPLACE FUNCTION public.captain_set_updated_at() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -BEGIN - NEW.updated_at := clock_timestamp(); - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_set_session_state() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -DECLARE - observed_at timestamptz := clock_timestamp(); -BEGIN - IF TG_OP = 'UPDATE' THEN - IF ROW( - NEW.lifecycle_status, - NEW.activity_state, - NEW.health_state, - NEW.state_reason - ) IS DISTINCT FROM ROW( - OLD.lifecycle_status, - OLD.activity_state, - OLD.health_state, - OLD.state_reason - ) THEN - NEW.state_version := OLD.state_version + 1; - NEW.state_observed_at := observed_at; - ELSE - NEW.state_version := OLD.state_version; - NEW.state_observed_at := OLD.state_observed_at; - END IF; - END IF; - - IF NEW.lifecycle_status = 'running' THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); - NEW.ended_at := NULL; - ELSIF NEW.lifecycle_status IN ('succeeded', 'failed', 'cancelled', 'interrupted') THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); - NEW.ended_at := COALESCE(NEW.ended_at, observed_at); - END IF; - - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_set_prompt_run_state() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -DECLARE - observed_at timestamptz := clock_timestamp(); -BEGIN - IF TG_OP = 'UPDATE' THEN - IF ROW( - NEW.phase, - NEW.state, - NEW.current_iteration, - NEW.execution_session_id, - NEW.rendered_spec, - NEW.result_text, - NEW.result_json, - NEW.error - ) IS DISTINCT FROM ROW( - OLD.phase, - OLD.state, - OLD.current_iteration, - OLD.execution_session_id, - OLD.rendered_spec, - OLD.result_text, - OLD.result_json, - OLD.error - ) THEN - NEW.version := OLD.version + 1; - ELSE - NEW.version := OLD.version; - END IF; - END IF; - - IF NEW.state IN ('running', 'waiting') THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); - NEW.finished_at := NULL; - ELSIF NEW.state IN ('succeeded', 'failed', 'cancelled') THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.finished_at, NEW.created_at, observed_at); - NEW.finished_at := COALESCE(NEW.finished_at, observed_at); - END IF; - - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_set_prompt_iteration_state() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -DECLARE - observed_at timestamptz := clock_timestamp(); -BEGIN - IF NEW.state = 'running' THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); - NEW.finished_at := NULL; - ELSIF NEW.state IN ('succeeded', 'failed', 'cancelled') THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.finished_at, NEW.created_at, observed_at); - NEW.finished_at := COALESCE(NEW.finished_at, observed_at); - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_set_turn_state() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -DECLARE - observed_at timestamptz := clock_timestamp(); -BEGIN - IF NEW.status = 'open' THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); - NEW.ended_at := NULL; - ELSIF NEW.status IN ('ended', 'error', 'interrupted') THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); - NEW.ended_at := COALESCE(NEW.ended_at, observed_at); - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_set_model_call_state() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -DECLARE - observed_at timestamptz := clock_timestamp(); -BEGIN - IF NEW.status = 'running' THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); - NEW.ended_at := NULL; - ELSIF NEW.status IN ('succeeded', 'failed', 'cancelled') THEN - NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); - NEW.ended_at := COALESCE(NEW.ended_at, observed_at); - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_set_turn_request_state() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -BEGIN - IF TG_OP = 'UPDATE' THEN - IF ROW( - NEW.state, - NEW.response, - NEW.resolved_by, - NEW.reason - ) IS DISTINCT FROM ROW( - OLD.state, - OLD.response, - OLD.resolved_by, - OLD.reason - ) THEN - NEW.version := OLD.version + 1; - ELSE - NEW.version := OLD.version; - END IF; - END IF; - - IF NEW.state = 'pending' THEN - NEW.resolved_at := NULL; - ELSE - NEW.resolved_at := COALESCE(NEW.resolved_at, clock_timestamp()); - IF NEW.resolved_at < NEW.created_at THEN - NEW.created_at := NEW.resolved_at; - END IF; - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_set_plan_state() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -BEGIN - IF TG_OP = 'INSERT' THEN - IF NEW.approval_state IN ('approved', 'rejected') THEN - NEW.approval_created_at := COALESCE(NEW.approval_created_at, clock_timestamp()); - ELSIF NEW.approval_state = 'revision_requested' THEN - NEW.feedback_at := COALESCE(NEW.feedback_at, clock_timestamp()); - END IF; - ELSIF NEW.approval_state IS DISTINCT FROM OLD.approval_state THEN - IF NEW.approval_state IN ('approved', 'rejected') THEN - NEW.approval_created_at := COALESCE(NEW.approval_created_at, clock_timestamp()); - ELSIF NEW.approval_state = 'revision_requested' THEN - NEW.feedback_at := COALESCE(NEW.feedback_at, clock_timestamp()); - END IF; - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_sync_prompt_run_iteration() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, public -AS $$ -BEGIN - UPDATE public.captain_prompt_runs - SET current_iteration = GREATEST(current_iteration, NEW.iteration) - WHERE id = NEW.prompt_run_id - AND current_iteration < NEW.iteration; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_emit_session_change() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog, public -AS $$ -DECLARE - row_data jsonb; - session_id_value uuid; - record_id_value uuid; - activity_at timestamptz := clock_timestamp(); -BEGIN - -- Explicit historical backfills validate and checksum the final rows - -- themselves. Suppress activity/outbox projection for those transaction-local - -- writes so imported timestamps remain archive-derived and deterministic. - IF current_setting('captain.suppress_session_change', true) = 'on' THEN - IF TG_OP = 'DELETE' THEN - RETURN OLD; - END IF; - RETURN NEW; - END IF; - - -- Nested updates are maintenance performed by another trigger. Cascading - -- deletes are represented by the originating session mutation instead of a - -- separate outbox row for every child. - IF pg_trigger_depth() > 1 THEN - IF TG_OP = 'DELETE' THEN - RETURN OLD; - END IF; - RETURN NEW; - END IF; - - IF TG_OP = 'DELETE' THEN - row_data := to_jsonb(OLD); - ELSE - row_data := to_jsonb(NEW); - END IF; - - record_id_value := NULLIF(row_data ->> 'id', '')::uuid; - - CASE TG_TABLE_NAME - WHEN 'captain_sessions' THEN - session_id_value := record_id_value; - WHEN 'captain_prompt_run_iterations' THEN - SELECT r.session_id - INTO session_id_value - FROM public.captain_prompt_runs r - WHERE r.id = NULLIF(row_data ->> 'prompt_run_id', '')::uuid; - WHEN 'captain_plan_revisions' THEN - SELECT p.source_session_id - INTO session_id_value - FROM public.captain_plans p - WHERE p.id = NULLIF(row_data ->> 'plan_id', '')::uuid; - WHEN 'captain_model_calls' THEN - SELECT t.session_id - INTO session_id_value - FROM public.captain_turns t - WHERE t.id = NULLIF(row_data ->> 'turn_id', '')::uuid; - ELSE - session_id_value := NULLIF( - COALESCE(row_data ->> 'session_id', row_data ->> 'source_session_id'), - '' - )::uuid; - END CASE; - - -- A direct delete of an already-detached child can legitimately have no - -- surviving aggregate. There is nothing useful to publish in that case. - IF session_id_value IS NULL THEN - IF TG_OP = 'DELETE' THEN - RETURN OLD; - END IF; - RETURN NEW; - END IF; - - -- Referential actions do not reliably increase pg_trigger_depth(). Suppress - -- their child rows by observing that the owning aggregate has already gone. - -- A directly deleted child session still publishes while its root exists. - IF TG_OP = 'DELETE' THEN - IF TG_TABLE_NAME = 'captain_sessions' THEN - IF NULLIF(row_data ->> 'root_session_id', '') IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM public.captain_sessions root - WHERE root.id = NULLIF(row_data ->> 'root_session_id', '')::uuid - ) THEN - RETURN OLD; - END IF; - ELSIF NOT EXISTS ( - SELECT 1 - FROM public.captain_sessions aggregate - WHERE aggregate.id = session_id_value - ) THEN - RETURN OLD; - END IF; - END IF; - - activity_at := COALESCE( - NULLIF(row_data ->> 'occurred_at', '')::timestamptz, - NULLIF(row_data ->> 'state_observed_at', '')::timestamptz, - NULLIF(row_data ->> 'started_at', '')::timestamptz, - NULLIF(row_data ->> 'resolved_at', '')::timestamptz, - NULLIF(row_data ->> 'updated_at', '')::timestamptz, - NULLIF(row_data ->> 'recorded_at', '')::timestamptz, - NULLIF(row_data ->> 'created_at', '')::timestamptz, - clock_timestamp() - ); - - IF TG_OP <> 'DELETE' AND TG_TABLE_NAME <> 'captain_sessions' THEN - UPDATE public.captain_sessions - SET last_activity_at = CASE - WHEN last_activity_at IS NULL OR activity_at > last_activity_at THEN activity_at - ELSE last_activity_at - END - WHERE id = session_id_value; - END IF; - - INSERT INTO public.captain_outbox ( - topic, - aggregate_type, - aggregate_id, - event_type, - payload - ) VALUES ( - 'captain.session.changed', - 'session', - session_id_value, - TG_TABLE_NAME || '.' || lower(TG_OP), - jsonb_strip_nulls(jsonb_build_object( - 'table', TG_TABLE_NAME, - 'operation', lower(TG_OP), - 'record_id', record_id_value, - 'session_id', session_id_value, - 'turn_id', CASE - WHEN TG_TABLE_NAME = 'captain_turns' THEN record_id_value - ELSE NULLIF(row_data ->> 'turn_id', '')::uuid - END, - 'prompt_run_id', CASE - WHEN TG_TABLE_NAME = 'captain_prompt_runs' THEN record_id_value - ELSE NULLIF(row_data ->> 'prompt_run_id', '')::uuid - END, - 'iteration_id', CASE - WHEN TG_TABLE_NAME = 'captain_prompt_run_iterations' THEN record_id_value - ELSE NULLIF(row_data ->> 'iteration_id', '')::uuid - END, - 'plan_id', CASE - WHEN TG_TABLE_NAME = 'captain_plans' THEN record_id_value - ELSE NULLIF(row_data ->> 'plan_id', '')::uuid - END, - 'model_call_id', CASE - WHEN TG_TABLE_NAME = 'captain_model_calls' THEN record_id_value - ELSE NULLIF(row_data ->> 'model_call_id', '')::uuid - END, - 'state', row_data ->> 'state', - 'status', COALESCE(row_data ->> 'status', row_data ->> 'lifecycle_status'), - 'phase', row_data ->> 'phase', - 'occurred_at', activity_at - )) - ); - - IF TG_OP = 'DELETE' THEN - RETURN OLD; - END IF; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION public.captain_notify_outbox() -RETURNS trigger -LANGUAGE plpgsql -SET search_path = pg_catalog, public -AS $$ -BEGIN - PERFORM pg_notify('captain_outbox', NEW.sequence::text); - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS captain_sessions_state_before ON public.captain_sessions; -CREATE TRIGGER captain_sessions_state_before -BEFORE INSERT OR UPDATE ON public.captain_sessions -FOR EACH ROW EXECUTE FUNCTION public.captain_set_session_state(); - -DROP TRIGGER IF EXISTS captain_prompt_runs_state_before ON public.captain_prompt_runs; -CREATE TRIGGER captain_prompt_runs_state_before -BEFORE INSERT OR UPDATE ON public.captain_prompt_runs -FOR EACH ROW EXECUTE FUNCTION public.captain_set_prompt_run_state(); - -DROP TRIGGER IF EXISTS captain_prompt_run_iterations_state_before ON public.captain_prompt_run_iterations; -CREATE TRIGGER captain_prompt_run_iterations_state_before -BEFORE INSERT OR UPDATE ON public.captain_prompt_run_iterations -FOR EACH ROW EXECUTE FUNCTION public.captain_set_prompt_iteration_state(); - -DROP TRIGGER IF EXISTS captain_turns_state_before ON public.captain_turns; -CREATE TRIGGER captain_turns_state_before -BEFORE INSERT OR UPDATE ON public.captain_turns -FOR EACH ROW EXECUTE FUNCTION public.captain_set_turn_state(); - -DROP TRIGGER IF EXISTS captain_model_calls_state_before ON public.captain_model_calls; -CREATE TRIGGER captain_model_calls_state_before -BEFORE INSERT OR UPDATE ON public.captain_model_calls -FOR EACH ROW EXECUTE FUNCTION public.captain_set_model_call_state(); - -DROP TRIGGER IF EXISTS captain_turn_requests_state_before ON public.captain_turn_requests; -CREATE TRIGGER captain_turn_requests_state_before -BEFORE INSERT OR UPDATE ON public.captain_turn_requests -FOR EACH ROW EXECUTE FUNCTION public.captain_set_turn_request_state(); - -DROP TRIGGER IF EXISTS captain_plans_state_before ON public.captain_plans; -CREATE TRIGGER captain_plans_state_before -BEFORE INSERT OR UPDATE ON public.captain_plans -FOR EACH ROW EXECUTE FUNCTION public.captain_set_plan_state(); - -DROP TRIGGER IF EXISTS captain_sessions_updated_at_before ON public.captain_sessions; -CREATE TRIGGER captain_sessions_updated_at_before -BEFORE UPDATE ON public.captain_sessions -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_session_processes_updated_at_before ON public.captain_session_processes; -CREATE TRIGGER captain_session_processes_updated_at_before -BEFORE UPDATE ON public.captain_session_processes -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_prompt_runs_updated_at_before ON public.captain_prompt_runs; -CREATE TRIGGER captain_prompt_runs_updated_at_before -BEFORE UPDATE ON public.captain_prompt_runs -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_prompt_run_iterations_updated_at_before ON public.captain_prompt_run_iterations; -CREATE TRIGGER captain_prompt_run_iterations_updated_at_before -BEFORE UPDATE ON public.captain_prompt_run_iterations -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_plans_updated_at_before ON public.captain_plans; -CREATE TRIGGER captain_plans_updated_at_before -BEFORE UPDATE ON public.captain_plans -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_turns_updated_at_before ON public.captain_turns; -CREATE TRIGGER captain_turns_updated_at_before -BEFORE UPDATE ON public.captain_turns -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_model_calls_updated_at_before ON public.captain_model_calls; -CREATE TRIGGER captain_model_calls_updated_at_before -BEFORE UPDATE ON public.captain_model_calls -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_session_sources_updated_at_before ON public.captain_session_sources; -CREATE TRIGGER captain_session_sources_updated_at_before -BEFORE UPDATE ON public.captain_session_sources -FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); - -DROP TRIGGER IF EXISTS captain_prompt_run_iterations_sync_after ON public.captain_prompt_run_iterations; -CREATE TRIGGER captain_prompt_run_iterations_sync_after -AFTER INSERT OR UPDATE OF iteration, prompt_run_id ON public.captain_prompt_run_iterations -FOR EACH ROW EXECUTE FUNCTION public.captain_sync_prompt_run_iteration(); - -DROP TRIGGER IF EXISTS captain_sessions_emit_after ON public.captain_sessions; -CREATE TRIGGER captain_sessions_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_sessions -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_session_processes_emit_after ON public.captain_session_processes; -CREATE TRIGGER captain_session_processes_emit_after -AFTER INSERT OR DELETE OR UPDATE OF - status, - command, - cwd, - surface, - lease_owner, - lease_token, - lease_expires_at, - ended_at -ON public.captain_session_processes -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_prompt_runs_emit_after ON public.captain_prompt_runs; -CREATE TRIGGER captain_prompt_runs_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_prompt_runs -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_prompt_run_iterations_emit_after ON public.captain_prompt_run_iterations; -CREATE TRIGGER captain_prompt_run_iterations_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_prompt_run_iterations -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_plans_emit_after ON public.captain_plans; -CREATE TRIGGER captain_plans_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_plans -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_plan_revisions_emit_after ON public.captain_plan_revisions; -CREATE TRIGGER captain_plan_revisions_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_plan_revisions -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_turns_emit_after ON public.captain_turns; -CREATE TRIGGER captain_turns_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_turns -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_model_calls_emit_after ON public.captain_model_calls; -CREATE TRIGGER captain_model_calls_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_model_calls -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_messages_emit_after ON public.captain_messages; -CREATE TRIGGER captain_messages_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_messages -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_events_emit_after ON public.captain_events; -CREATE TRIGGER captain_events_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_events -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_turn_requests_emit_after ON public.captain_turn_requests; -CREATE TRIGGER captain_turn_requests_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_turn_requests -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_artifacts_emit_after ON public.captain_artifacts; -CREATE TRIGGER captain_artifacts_emit_after -AFTER INSERT OR UPDATE OR DELETE ON public.captain_artifacts -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_session_sources_emit_after ON public.captain_session_sources; -CREATE TRIGGER captain_session_sources_emit_after -AFTER INSERT OR DELETE OR UPDATE OF path, source_identity, parser_version -ON public.captain_session_sources -FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); - -DROP TRIGGER IF EXISTS captain_outbox_notify_after ON public.captain_outbox; -CREATE TRIGGER captain_outbox_notify_after -AFTER INSERT ON public.captain_outbox -FOR EACH ROW EXECUTE FUNCTION public.captain_notify_outbox(); - --- Internal trigger functions are not PostgREST RPC endpoints. -REVOKE ALL ON FUNCTION public.captain_set_updated_at() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_set_session_state() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_set_prompt_run_state() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_set_prompt_iteration_state() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_set_turn_state() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_set_model_call_state() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_set_turn_request_state() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_set_plan_state() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_sync_prompt_run_iteration() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_emit_session_change() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.captain_notify_outbox() FROM PUBLIC; - -CREATE OR REPLACE VIEW public.captain_session_overview -WITH (security_barrier = true) -AS -SELECT - s.id, - s.provider_session_id, - s.source, - s.provider, - s.host_id, - s.parent_session_id, - s.root_session_id, - COALESCE(s.root_session_id, s.id) AS thread_id, - s.path, - COALESCE(source.path, s.path) AS history_file, - source.source_kind, - source.parser_version, - s.project, - s.cwd, - s.title, - s.initial_prompt, - s.slug, - s.agent_type, - s.description, - s.cli_version, - s.lifecycle_status, - s.activity_state, - s.health_state, - s.state_reason, - s.state_version, - s.state_observed_at, - s.git, - s.metadata, - s.started_at, - s.ended_at, - s.last_activity_at, - s.created_at, - s.updated_at, - CASE - WHEN s.started_at IS NULL THEN NULL - ELSE EXTRACT(EPOCH FROM COALESCE(s.ended_at, clock_timestamp()) - s.started_at) - END AS duration_seconds, - process.id AS process_id, - process.pid, - process.status AS process_status, - process.command, - process.cwd AS process_cwd, - process.surface, - process.process_started_at, - process.last_heartbeat_at, - process.lease_owner, - process.lease_expires_at, - process.ended_at AS process_ended_at, - process.id IS NOT NULL AND process.ended_at IS NULL AS process_active, - COALESCE(message_stats.message_count, 0) AS message_count, - COALESCE(message_stats.tool_call_count, 0) AS tool_call_count, - COALESCE(event_stats.event_count, 0) AS event_count, - COALESCE(call_stats.turn_count, 0) AS turn_count, - COALESCE(call_stats.model_call_count, 0) AS model_call_count, - COALESCE(agent_stats.agent_count, 1) AS agent_count, - COALESCE(prompt_stats.prompt_run_count, 0) AS prompt_run_count, - COALESCE(plan_stats.plan_count, 0) AS plan_count, - COALESCE(request_stats.pending_request_count, 0) AS pending_request_count, - COALESCE(request_stats.approved_request_count, 0) AS approved_request_count, - COALESCE(request_stats.denied_request_count, 0) AS denied_request_count, - COALESCE(file_stats.file_read_count, 0) AS file_read_count, - COALESCE(file_stats.file_written_count, 0) AS file_written_count, - latest_call.model, - latest_call.backend, - latest_call.effort, - latest_call.context_tokens, - latest_call.context_window_tokens, - CASE - WHEN latest_call.context_window_tokens > 0 THEN - GREATEST( - 0, - LEAST( - 100, - round( - (1 - latest_call.context_tokens::numeric / latest_call.context_window_tokens::numeric) * 100 - )::integer - ) - ) - ELSE NULL - END AS context_free_percent, - COALESCE(call_stats.input_tokens, 0) AS input_tokens, - COALESCE(call_stats.output_tokens, 0) AS output_tokens, - COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, - COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, - COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, - COALESCE(call_stats.input_tokens, 0) - + COALESCE(call_stats.output_tokens, 0) - + COALESCE(call_stats.cache_read_tokens, 0) - + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, - COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, - -- Appended after initial release: CREATE OR REPLACE VIEW only allows adding - -- columns at the end, so later additions must stay below this line. - process.source AS process_source, - process.cpu_percent, - process.memory_percent, - process.memory_rss_bytes, - process.sampled_at AS process_sampled_at -FROM public.captain_sessions s -LEFT JOIN LATERAL ( - SELECT p.* - FROM public.captain_session_processes p - WHERE p.session_id = s.id - AND p.ended_at IS NULL - ORDER BY COALESCE(p.last_heartbeat_at, p.process_started_at) DESC, p.id DESC - LIMIT 1 -) process ON true -LEFT JOIN LATERAL ( - SELECT src.path, src.source_kind, src.parser_version - FROM public.captain_session_sources src - WHERE src.session_id = s.id - ORDER BY src.updated_at DESC, src.id DESC - LIMIT 1 -) source ON true -LEFT JOIN LATERAL ( - SELECT - count(*)::bigint AS message_count, - COALESCE(sum(( - SELECT count(*) - FROM jsonb_array_elements( - CASE - WHEN jsonb_typeof(m.parts) = 'array' THEN m.parts - ELSE '[]'::jsonb - END - ) part - WHERE ( - part ->> 'type' = 'dynamic-tool' - OR part ->> 'type' LIKE 'tool-%' - ) - AND COALESCE(part ->> 'toolName', '') <> '' - )), 0)::bigint AS tool_call_count - FROM public.captain_messages m - WHERE m.session_id = s.id -) message_stats ON true -LEFT JOIN LATERAL ( - SELECT count(*)::bigint AS event_count - FROM public.captain_events e - WHERE e.session_id = s.id -) event_stats ON true -LEFT JOIN LATERAL ( - SELECT - count(DISTINCT t.id)::bigint AS turn_count, - count(c.id)::bigint AS model_call_count, - COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, - COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, - COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, - COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, - COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, - COALESCE(sum( - c.input_cost - + c.output_cost - + c.reasoning_cost - + c.cache_read_cost - + c.cache_write_cost - ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd - FROM public.captain_turns t - LEFT JOIN public.captain_model_calls c ON c.turn_id = t.id - WHERE t.session_id = s.id -) call_stats ON true -LEFT JOIN LATERAL ( - SELECT - c.model, - c.backend, - c.effort, - c.context_tokens, - c.context_window_tokens - FROM public.captain_turns t - JOIN public.captain_model_calls c ON c.turn_id = t.id - WHERE t.session_id = s.id - ORDER BY COALESCE(c.ended_at, c.started_at, c.created_at) DESC, c.call_index DESC - LIMIT 1 -) latest_call ON true -LEFT JOIN LATERAL ( - SELECT count(*)::bigint + 1 AS agent_count - FROM public.captain_sessions child - WHERE child.root_session_id = s.id -) agent_stats ON true -LEFT JOIN LATERAL ( - SELECT count(*)::bigint AS prompt_run_count - FROM public.captain_prompt_runs r - WHERE r.session_id = s.id -) prompt_stats ON true -LEFT JOIN LATERAL ( - SELECT count(*)::bigint AS plan_count - FROM public.captain_plans p - WHERE p.source_session_id = s.id -) plan_stats ON true -LEFT JOIN LATERAL ( - SELECT - count(*) FILTER (WHERE r.state = 'pending')::bigint AS pending_request_count, - count(*) FILTER (WHERE r.state IN ('approved', 'answered'))::bigint AS approved_request_count, - count(*) FILTER (WHERE r.state = 'denied')::bigint AS denied_request_count - FROM public.captain_turn_requests r - WHERE r.session_id = s.id -) request_stats ON true -LEFT JOIN LATERAL ( - SELECT - count(*) FILTER (WHERE a.kind = 'file.read')::bigint AS file_read_count, - count(*) FILTER (WHERE a.kind IN ('file.write', 'file.edit', 'file.delete'))::bigint AS file_written_count - FROM public.captain_artifacts a - WHERE a.session_id = s.id - AND a.kind LIKE 'file.%' -) file_stats ON true; - -COMMENT ON VIEW public.captain_session_overview IS - 'One row per session for PostgREST list, metadata, health, live-process, usage and cost surfaces.'; - -CREATE OR REPLACE VIEW public.captain_session_transcript -WITH (security_barrier = true) -AS -SELECT - m.id, - m.session_id, - m.turn_id, - m.model_call_id, - m.provider_message_id, - m.sequence, - m.role, - m.parts, - m.raw, - m.schema_version, - m.occurred_at, - m.recorded_at, - c.model, - c.backend, - c.effort, - c.status AS model_call_status, - -- Appended after initial release: CREATE OR REPLACE VIEW only allows adding - -- columns at the end, so later additions must stay below this line. - m.source_line -FROM public.captain_messages m -LEFT JOIN public.captain_model_calls c ON c.id = m.model_call_id; - -COMMENT ON VIEW public.captain_session_transcript IS - 'Message rows for the SessionInspector transcript tab; order by sequence through PostgREST.'; - -CREATE OR REPLACE VIEW public.captain_session_turns -WITH (security_barrier = true) -AS -SELECT - t.id, - t.session_id, - t.provider_turn_id, - t.turn_index, - t.description, - t.status, - t.stop_reason, - t.error, - t.started_at, - t.ended_at, - t.created_at, - t.updated_at, - CASE - WHEN t.started_at IS NULL THEN NULL - ELSE EXTRACT(EPOCH FROM COALESCE(t.ended_at, clock_timestamp()) - t.started_at) - END AS duration_seconds, - latest_call.model, - latest_call.backend, - latest_call.effort, - latest_call.context_tokens, - latest_call.context_window_tokens, - CASE - WHEN latest_call.context_window_tokens > 0 THEN - GREATEST( - 0, - LEAST( - 100, - round( - (1 - latest_call.context_tokens::numeric / latest_call.context_window_tokens::numeric) * 100 - )::integer - ) - ) - ELSE NULL - END AS context_free_percent, - COALESCE(call_stats.model_call_count, 0) AS model_call_count, - COALESCE(call_stats.input_tokens, 0) AS input_tokens, - COALESCE(call_stats.output_tokens, 0) AS output_tokens, - COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, - COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, - COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, - COALESCE(call_stats.input_tokens, 0) - + COALESCE(call_stats.output_tokens, 0) - + COALESCE(call_stats.cache_read_tokens, 0) - + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, - COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, - COALESCE(message_stats.message_count, 0) AS message_count, - COALESCE(message_stats.message_ids, ARRAY[]::uuid[]) AS message_ids, - COALESCE(event_stats.event_count, 0) AS event_count, - COALESCE(event_stats.event_ids, ARRAY[]::uuid[]) AS event_ids -FROM public.captain_turns t -LEFT JOIN LATERAL ( - SELECT - count(*)::bigint AS model_call_count, - COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, - COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, - COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, - COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, - COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, - COALESCE(sum( - c.input_cost - + c.output_cost - + c.reasoning_cost - + c.cache_read_cost - + c.cache_write_cost - ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd - FROM public.captain_model_calls c - WHERE c.turn_id = t.id -) call_stats ON true -LEFT JOIN LATERAL ( - SELECT c.model, c.backend, c.effort, c.context_tokens, c.context_window_tokens - FROM public.captain_model_calls c - WHERE c.turn_id = t.id - ORDER BY COALESCE(c.ended_at, c.started_at, c.created_at) DESC, c.call_index DESC - LIMIT 1 -) latest_call ON true -LEFT JOIN LATERAL ( - SELECT - count(*)::bigint AS message_count, - array_agg(m.id ORDER BY m.sequence) AS message_ids - FROM public.captain_messages m - WHERE m.turn_id = t.id -) message_stats ON true -LEFT JOIN LATERAL ( - SELECT - count(*)::bigint AS event_count, - array_agg(e.id ORDER BY COALESCE(e.occurred_at, e.recorded_at), e.id) AS event_ids - FROM public.captain_events e - WHERE e.turn_id = t.id -) event_stats ON true; - -COMMENT ON VIEW public.captain_session_turns IS - 'Per-turn usage, cost, context and message/event attribution for the SessionInspector turns tab.'; - -CREATE OR REPLACE VIEW public.captain_session_agents -WITH (security_barrier = true) -AS -SELECT - s.id, - s.id AS session_id, - s.parent_session_id, - s.root_session_id, - COALESCE(s.root_session_id, s.id) AS thread_id, - s.parent_session_id IS NULL AS is_root, - s.agent_type, - s.description, - s.path AS history_file, - s.source, - s.provider, - s.lifecycle_status, - s.activity_state, - s.health_state, - s.started_at, - s.ended_at, - COALESCE(child_stats.child_count, 0) AS child_count, - COALESCE(call_stats.input_tokens, 0) AS input_tokens, - COALESCE(call_stats.output_tokens, 0) AS output_tokens, - COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, - COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, - COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, - COALESCE(call_stats.input_tokens, 0) - + COALESCE(call_stats.output_tokens, 0) - + COALESCE(call_stats.cache_read_tokens, 0) - + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, - COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd -FROM public.captain_sessions s -LEFT JOIN LATERAL ( - SELECT count(*)::bigint AS child_count - FROM public.captain_sessions child - WHERE child.parent_session_id = s.id -) child_stats ON true -LEFT JOIN LATERAL ( - SELECT - COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, - COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, - COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, - COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, - COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, - COALESCE(sum( - c.input_cost - + c.output_cost - + c.reasoning_cost - + c.cache_read_cost - + c.cache_write_cost - ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd - FROM public.captain_turns t - JOIN public.captain_model_calls c ON c.turn_id = t.id - WHERE t.session_id = s.id -) call_stats ON true; - -COMMENT ON VIEW public.captain_session_agents IS - 'Session hierarchy rows with usage and cost for the SessionInspector agents tab.'; - -CREATE OR REPLACE VIEW public.captain_session_files -WITH (security_barrier = true) -AS -SELECT - a.id, - a.session_id, - a.turn_id, - a.model_call_id, - a.prompt_run_id, - a.kind, - split_part(a.kind, '.', 2) AS operation, - a.path, - a.digest, - a.content_type, - a.metadata, - a.occurred_at, - a.created_at -FROM public.captain_artifacts a -WHERE a.kind LIKE 'file.%' - AND a.path IS NOT NULL; - -COMMENT ON VIEW public.captain_session_files IS - 'Normalized file.* artifact rows for the SessionInspector files tab.'; - -CREATE OR REPLACE VIEW public.captain_session_plans -WITH (security_barrier = true) -AS -SELECT - p.id, - p.source_session_id AS session_id, - p.source_prompt_run_id, - p.source_iteration_id, - p.source_turn_id, - p.title, - p.slug, - p.path, - p.variant, - p.spec_profile, - p.approval_state, - p.approved_revision_id, - p.approval_comment, - p.approved_by, - p.approval_created_at, - p.feedback_at, - p.created_at, - p.updated_at, - latest_revision.id AS latest_revision_id, - latest_revision.revision AS latest_revision, - latest_revision.plan_markdown AS latest_plan_markdown, - latest_revision.content_hash AS latest_content_hash, - latest_revision.feedback AS latest_feedback, - latest_revision.created_by AS latest_created_by, - latest_revision.created_at AS latest_created_at, - approved_revision.revision AS approved_revision, - approved_revision.plan_markdown AS approved_plan_markdown, - approved_revision.content_hash AS approved_content_hash, - COALESCE(approved_revision.id, latest_revision.id) AS current_revision_id, - COALESCE(approved_revision.revision, latest_revision.revision) AS current_revision, - COALESCE(approved_revision.plan_markdown, latest_revision.plan_markdown) AS plan_markdown, - COALESCE(approved_revision.content_hash, latest_revision.content_hash) AS content_hash -FROM public.captain_plans p -LEFT JOIN LATERAL ( - SELECT r.* - FROM public.captain_plan_revisions r - WHERE r.plan_id = p.id - ORDER BY r.revision DESC, r.created_at DESC, r.id DESC - LIMIT 1 -) latest_revision ON true -LEFT JOIN public.captain_plan_revisions approved_revision - ON approved_revision.id = p.approved_revision_id; - -COMMENT ON VIEW public.captain_session_plans IS - 'Plan rows with latest and approved immutable revisions for the SessionInspector plan tab.'; - -CREATE OR REPLACE VIEW public.captain_session_approvals -WITH (security_barrier = true) -AS -SELECT - r.id, - r.session_id, - r.turn_id, - r.prompt_run_id, - r.plan_id, - r.model_call_id, - r.tool_call_id, - r.kind, - r.state, - r.request, - r.response, - r.idempotency_key, - r.requested_by, - r.resolved_by, - r.reason, - r.version, - r.expires_at, - r.created_at, - r.resolved_at -FROM public.captain_turn_requests r -WHERE r.kind IN ('tool_approval', 'plan_exit_approval'); - -COMMENT ON VIEW public.captain_session_approvals IS - 'Tool and plan-exit approval rows for the SessionInspector approvals tab.'; - -CREATE OR REPLACE VIEW public.captain_session_costs -WITH (security_barrier = true) -AS -SELECT - concat_ws( - ':', - t.session_id::text, - c.model, - c.backend, - COALESCE(c.effort, 'default'), - upper(c.currency) - ) AS id, - t.session_id, - c.model, - c.backend, - c.effort, - upper(c.currency) AS currency, - count(*)::bigint AS model_call_count, - sum(c.input_tokens)::bigint AS input_tokens, - sum(c.output_tokens)::bigint AS output_tokens, - sum(c.reasoning_tokens)::bigint AS reasoning_tokens, - sum(c.cache_read_tokens)::bigint AS cache_read_tokens, - sum(c.cache_write_tokens)::bigint AS cache_write_tokens, - ( - sum(c.input_tokens) - + sum(c.output_tokens) - + sum(c.cache_read_tokens) - + sum(c.cache_write_tokens) - )::bigint AS total_tokens, - sum(c.input_cost) AS input_cost, - sum(c.output_cost) AS output_cost, - sum(c.reasoning_cost) AS reasoning_cost, - sum(c.cache_read_cost) AS cache_read_cost, - sum(c.cache_write_cost) AS cache_write_cost, - sum( - c.input_cost - + c.output_cost - + c.reasoning_cost - + c.cache_read_cost - + c.cache_write_cost - ) AS total_cost, - min(c.started_at) AS first_call_at, - max(c.ended_at) AS last_call_at -FROM public.captain_turns t -JOIN public.captain_model_calls c ON c.turn_id = t.id -GROUP BY - t.session_id, - c.model, - c.backend, - c.effort, - upper(c.currency); - -COMMENT ON VIEW public.captain_session_costs IS - 'Per-session model/backend/effort token and cost totals for the SessionInspector costs tab.'; - -CREATE OR REPLACE VIEW public.captain_session_events -WITH (security_barrier = true) -AS -SELECT - e.id, - e.session_id, - e.turn_id, - e.prompt_run_id, - e.iteration_id, - e.model_call_id, - e.parent_event_id, - e.event_key, - e.stream, - e.sequence, - e.kind, - e.scope, - e.payload, - e.schema_version, - e.occurred_at, - e.recorded_at -FROM public.captain_events e; - -COMMENT ON VIEW public.captain_session_events IS - 'Durable session and turn event rows for SessionInspector metadata and raw-event surfaces.'; - -CREATE OR REPLACE VIEW public.captain_prompt_run_overview -WITH (security_barrier = true) -AS -SELECT - r.id, - r.session_id, - r.root_session_id, - r.batch_id, - r.parent_run_id, - r.input_plan_id, - r.input_plan_revision_id, - r.origin, - r.spec_profile, - r.admission_key, - r.rendered_spec, - r.prompt_markdown, - r.verification_markdown, - r.phase, - r.state, - r.current_iteration, - r.result_text, - r.result_json, - r.error, - r.version, - r.queued_at, - r.started_at, - r.finished_at, - r.created_at, - r.updated_at, - CASE - WHEN r.started_at IS NULL THEN NULL - ELSE EXTRACT(EPOCH FROM COALESCE(r.finished_at, clock_timestamp()) - r.started_at) - END AS duration_seconds, - COALESCE(iteration_stats.iteration_count, 0) AS iteration_count, - COALESCE(iteration_stats.succeeded_iteration_count, 0) AS succeeded_iteration_count, - COALESCE(iteration_stats.failed_iteration_count, 0) AS failed_iteration_count, - latest_iteration.id AS latest_iteration_id, - latest_iteration.iteration AS latest_iteration, - latest_iteration.state AS latest_iteration_state, - latest_iteration.feedback AS latest_iteration_feedback, - latest_iteration.verification_result AS latest_verification_result, - latest_iteration.error AS latest_iteration_error, - latest_iteration.started_at AS latest_iteration_started_at, - latest_iteration.finished_at AS latest_iteration_finished_at, - COALESCE(call_stats.model_call_count, 0) AS model_call_count, - COALESCE(call_stats.input_tokens, 0) AS input_tokens, - COALESCE(call_stats.output_tokens, 0) AS output_tokens, - COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, - COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, - COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, - COALESCE(call_stats.input_tokens, 0) - + COALESCE(call_stats.output_tokens, 0) - + COALESCE(call_stats.cache_read_tokens, 0) - + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, - COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, - COALESCE(plan_stats.plan_count, 0) AS plan_count, - plan_stats.latest_plan_id, - plan_stats.latest_plan_approval_state, - plan_stats.latest_plan_revision, - r.execution_session_id -FROM public.captain_prompt_runs r -LEFT JOIN LATERAL ( - SELECT - count(*)::bigint AS iteration_count, - count(*) FILTER (WHERE i.state = 'succeeded')::bigint AS succeeded_iteration_count, - count(*) FILTER (WHERE i.state = 'failed')::bigint AS failed_iteration_count - FROM public.captain_prompt_run_iterations i - WHERE i.prompt_run_id = r.id -) iteration_stats ON true -LEFT JOIN LATERAL ( - SELECT i.* - FROM public.captain_prompt_run_iterations i - WHERE i.prompt_run_id = r.id - ORDER BY i.iteration DESC, i.created_at DESC, i.id DESC - LIMIT 1 -) latest_iteration ON true -LEFT JOIN LATERAL ( - SELECT - count(*)::bigint AS model_call_count, - COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, - COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, - COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, - COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, - COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, - COALESCE(sum( - c.input_cost - + c.output_cost - + c.reasoning_cost - + c.cache_read_cost - + c.cache_write_cost - ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd - FROM public.captain_model_calls c - WHERE c.prompt_run_id = r.id -) call_stats ON true -LEFT JOIN LATERAL ( - SELECT - count(*)::bigint AS plan_count, - (array_agg(p.id ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_id, - (array_agg(p.approval_state ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_approval_state, - (array_agg(pr.revision ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_revision - FROM public.captain_plans p - LEFT JOIN LATERAL ( - SELECT revision.revision - FROM public.captain_plan_revisions revision - WHERE revision.plan_id = p.id - ORDER BY revision.revision DESC, revision.created_at DESC, revision.id DESC - LIMIT 1 - ) pr ON true - WHERE p.source_prompt_run_id = r.id -) plan_stats ON true; - -COMMENT ON VIEW public.captain_prompt_run_overview IS - 'Prompt-run control-plane state with iteration, plan, usage, cost and verification summaries.'; diff --git a/migrations/51_state_triggers.sql b/migrations/51_state_triggers.sql new file mode 100644 index 0000000..3fba565 --- /dev/null +++ b/migrations/51_state_triggers.sql @@ -0,0 +1,320 @@ +-- phase: post + +CREATE OR REPLACE FUNCTION public.captain_set_updated_at() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + NEW.updated_at := clock_timestamp(); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_session_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF TG_OP = 'UPDATE' THEN + IF ROW( + NEW.lifecycle_status, + NEW.activity_state, + NEW.health_state, + NEW.state_reason + ) IS DISTINCT FROM ROW( + OLD.lifecycle_status, + OLD.activity_state, + OLD.health_state, + OLD.state_reason + ) THEN + NEW.state_version := OLD.state_version + 1; + NEW.state_observed_at := observed_at; + ELSE + NEW.state_version := OLD.state_version; + NEW.state_observed_at := OLD.state_observed_at; + END IF; + END IF; + + IF NEW.lifecycle_status = 'running' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.ended_at := NULL; + ELSIF NEW.lifecycle_status IN ('succeeded', 'failed', 'cancelled', 'interrupted') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); + NEW.ended_at := COALESCE(NEW.ended_at, observed_at); + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_prompt_run_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF TG_OP = 'UPDATE' THEN + IF ROW( + NEW.phase, + NEW.state, + NEW.current_iteration, + NEW.execution_session_id, + NEW.rendered_spec, + NEW.result_text, + NEW.result_json, + NEW.error + ) IS DISTINCT FROM ROW( + OLD.phase, + OLD.state, + OLD.current_iteration, + OLD.execution_session_id, + OLD.rendered_spec, + OLD.result_text, + OLD.result_json, + OLD.error + ) THEN + NEW.version := OLD.version + 1; + ELSE + NEW.version := OLD.version; + END IF; + END IF; + + IF NEW.state IN ('running', 'waiting') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.finished_at := NULL; + ELSIF NEW.state IN ('succeeded', 'failed', 'cancelled') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.finished_at, NEW.created_at, observed_at); + NEW.finished_at := COALESCE(NEW.finished_at, observed_at); + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_prompt_iteration_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF NEW.state = 'running' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.finished_at := NULL; + ELSIF NEW.state IN ('succeeded', 'failed', 'cancelled') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.finished_at, NEW.created_at, observed_at); + NEW.finished_at := COALESCE(NEW.finished_at, observed_at); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_turn_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF NEW.status = 'open' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.ended_at := NULL; + ELSIF NEW.status IN ('ended', 'error', 'interrupted') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); + NEW.ended_at := COALESCE(NEW.ended_at, observed_at); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_model_call_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +DECLARE + observed_at timestamptz := clock_timestamp(); +BEGIN + IF NEW.status = 'running' THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); + NEW.ended_at := NULL; + ELSIF NEW.status IN ('succeeded', 'failed', 'cancelled') THEN + NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); + NEW.ended_at := COALESCE(NEW.ended_at, observed_at); + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_turn_request_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF ROW( + NEW.state, + NEW.response, + NEW.resolved_by, + NEW.reason + ) IS DISTINCT FROM ROW( + OLD.state, + OLD.response, + OLD.resolved_by, + OLD.reason + ) THEN + NEW.version := OLD.version + 1; + ELSE + NEW.version := OLD.version; + END IF; + END IF; + + IF NEW.state = 'pending' THEN + NEW.resolved_at := NULL; + ELSE + NEW.resolved_at := COALESCE(NEW.resolved_at, clock_timestamp()); + IF NEW.resolved_at < NEW.created_at THEN + NEW.created_at := NEW.resolved_at; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_set_plan_state() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.approval_state IN ('approved', 'rejected') THEN + NEW.approval_created_at := COALESCE(NEW.approval_created_at, clock_timestamp()); + ELSIF NEW.approval_state = 'revision_requested' THEN + NEW.feedback_at := COALESCE(NEW.feedback_at, clock_timestamp()); + END IF; + ELSIF NEW.approval_state IS DISTINCT FROM OLD.approval_state THEN + IF NEW.approval_state IN ('approved', 'rejected') THEN + NEW.approval_created_at := COALESCE(NEW.approval_created_at, clock_timestamp()); + ELSIF NEW.approval_state = 'revision_requested' THEN + NEW.feedback_at := COALESCE(NEW.feedback_at, clock_timestamp()); + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_sync_prompt_run_iteration() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + UPDATE public.captain_prompt_runs + SET current_iteration = GREATEST(current_iteration, NEW.iteration) + WHERE id = NEW.prompt_run_id + AND current_iteration < NEW.iteration; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS captain_sessions_state_before ON public.captain_sessions; +CREATE TRIGGER captain_sessions_state_before +BEFORE INSERT OR UPDATE ON public.captain_sessions +FOR EACH ROW EXECUTE FUNCTION public.captain_set_session_state(); + +DROP TRIGGER IF EXISTS captain_prompt_runs_state_before ON public.captain_prompt_runs; +CREATE TRIGGER captain_prompt_runs_state_before +BEFORE INSERT OR UPDATE ON public.captain_prompt_runs +FOR EACH ROW EXECUTE FUNCTION public.captain_set_prompt_run_state(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_state_before ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_state_before +BEFORE INSERT OR UPDATE ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_set_prompt_iteration_state(); + +DROP TRIGGER IF EXISTS captain_turns_state_before ON public.captain_turns; +CREATE TRIGGER captain_turns_state_before +BEFORE INSERT OR UPDATE ON public.captain_turns +FOR EACH ROW EXECUTE FUNCTION public.captain_set_turn_state(); + +DROP TRIGGER IF EXISTS captain_model_calls_state_before ON public.captain_model_calls; +CREATE TRIGGER captain_model_calls_state_before +BEFORE INSERT OR UPDATE ON public.captain_model_calls +FOR EACH ROW EXECUTE FUNCTION public.captain_set_model_call_state(); + +DROP TRIGGER IF EXISTS captain_turn_requests_state_before ON public.captain_turn_requests; +CREATE TRIGGER captain_turn_requests_state_before +BEFORE INSERT OR UPDATE ON public.captain_turn_requests +FOR EACH ROW EXECUTE FUNCTION public.captain_set_turn_request_state(); + +DROP TRIGGER IF EXISTS captain_plans_state_before ON public.captain_plans; +CREATE TRIGGER captain_plans_state_before +BEFORE INSERT OR UPDATE ON public.captain_plans +FOR EACH ROW EXECUTE FUNCTION public.captain_set_plan_state(); + +DROP TRIGGER IF EXISTS captain_sessions_updated_at_before ON public.captain_sessions; +CREATE TRIGGER captain_sessions_updated_at_before +BEFORE UPDATE ON public.captain_sessions +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_session_processes_updated_at_before ON public.captain_session_processes; +CREATE TRIGGER captain_session_processes_updated_at_before +BEFORE UPDATE ON public.captain_session_processes +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_prompt_runs_updated_at_before ON public.captain_prompt_runs; +CREATE TRIGGER captain_prompt_runs_updated_at_before +BEFORE UPDATE ON public.captain_prompt_runs +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_updated_at_before ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_updated_at_before +BEFORE UPDATE ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_plans_updated_at_before ON public.captain_plans; +CREATE TRIGGER captain_plans_updated_at_before +BEFORE UPDATE ON public.captain_plans +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_turns_updated_at_before ON public.captain_turns; +CREATE TRIGGER captain_turns_updated_at_before +BEFORE UPDATE ON public.captain_turns +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_model_calls_updated_at_before ON public.captain_model_calls; +CREATE TRIGGER captain_model_calls_updated_at_before +BEFORE UPDATE ON public.captain_model_calls +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_session_sources_updated_at_before ON public.captain_session_sources; +CREATE TRIGGER captain_session_sources_updated_at_before +BEFORE UPDATE ON public.captain_session_sources +FOR EACH ROW EXECUTE FUNCTION public.captain_set_updated_at(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_sync_after ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_sync_after +AFTER INSERT OR UPDATE OF iteration, prompt_run_id ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_sync_prompt_run_iteration(); + +-- Internal trigger functions are not PostgREST RPC endpoints. +REVOKE ALL ON FUNCTION public.captain_set_updated_at() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_session_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_prompt_run_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_prompt_iteration_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_turn_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_model_call_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_turn_request_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_set_plan_state() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_sync_prompt_run_iteration() FROM PUBLIC; diff --git a/migrations/52_outbox_triggers.sql b/migrations/52_outbox_triggers.sql new file mode 100644 index 0000000..629e0f8 --- /dev/null +++ b/migrations/52_outbox_triggers.sql @@ -0,0 +1,262 @@ +-- phase: post + +CREATE OR REPLACE FUNCTION public.captain_emit_session_change() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + row_data jsonb; + session_id_value uuid; + record_id_value uuid; + activity_at timestamptz := clock_timestamp(); +BEGIN + -- Explicit historical backfills validate and checksum the final rows + -- themselves. Suppress activity/outbox projection for those transaction-local + -- writes so imported timestamps remain archive-derived and deterministic. + IF current_setting('captain.suppress_session_change', true) = 'on' THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + -- Nested updates are maintenance performed by another trigger. Cascading + -- deletes are represented by the originating session mutation instead of a + -- separate outbox row for every child. + IF pg_trigger_depth() > 1 THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + row_data := to_jsonb(OLD); + ELSE + row_data := to_jsonb(NEW); + END IF; + + record_id_value := NULLIF(row_data ->> 'id', '')::uuid; + + CASE TG_TABLE_NAME + WHEN 'captain_sessions' THEN + session_id_value := record_id_value; + WHEN 'captain_prompt_run_iterations' THEN + SELECT r.session_id + INTO session_id_value + FROM public.captain_prompt_runs r + WHERE r.id = NULLIF(row_data ->> 'prompt_run_id', '')::uuid; + WHEN 'captain_plan_revisions' THEN + SELECT p.source_session_id + INTO session_id_value + FROM public.captain_plans p + WHERE p.id = NULLIF(row_data ->> 'plan_id', '')::uuid; + WHEN 'captain_model_calls' THEN + SELECT t.session_id + INTO session_id_value + FROM public.captain_turns t + WHERE t.id = NULLIF(row_data ->> 'turn_id', '')::uuid; + ELSE + session_id_value := NULLIF( + COALESCE(row_data ->> 'session_id', row_data ->> 'source_session_id'), + '' + )::uuid; + END CASE; + + -- A direct delete of an already-detached child can legitimately have no + -- surviving aggregate. There is nothing useful to publish in that case. + IF session_id_value IS NULL THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + -- Referential actions do not reliably increase pg_trigger_depth(). Suppress + -- their child rows by observing that the owning aggregate has already gone. + -- A directly deleted child session still publishes while its root exists. + IF TG_OP = 'DELETE' THEN + IF TG_TABLE_NAME = 'captain_sessions' THEN + IF NULLIF(row_data ->> 'root_session_id', '') IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM public.captain_sessions root + WHERE root.id = NULLIF(row_data ->> 'root_session_id', '')::uuid + ) THEN + RETURN OLD; + END IF; + ELSIF NOT EXISTS ( + SELECT 1 + FROM public.captain_sessions aggregate + WHERE aggregate.id = session_id_value + ) THEN + RETURN OLD; + END IF; + END IF; + + activity_at := COALESCE( + NULLIF(row_data ->> 'occurred_at', '')::timestamptz, + NULLIF(row_data ->> 'state_observed_at', '')::timestamptz, + NULLIF(row_data ->> 'started_at', '')::timestamptz, + NULLIF(row_data ->> 'resolved_at', '')::timestamptz, + NULLIF(row_data ->> 'updated_at', '')::timestamptz, + NULLIF(row_data ->> 'recorded_at', '')::timestamptz, + NULLIF(row_data ->> 'created_at', '')::timestamptz, + clock_timestamp() + ); + + IF TG_OP <> 'DELETE' AND TG_TABLE_NAME <> 'captain_sessions' THEN + UPDATE public.captain_sessions + SET last_activity_at = CASE + WHEN last_activity_at IS NULL OR activity_at > last_activity_at THEN activity_at + ELSE last_activity_at + END + WHERE id = session_id_value; + END IF; + + INSERT INTO public.captain_outbox ( + topic, + aggregate_type, + aggregate_id, + event_type, + payload + ) VALUES ( + 'captain.session.changed', + 'session', + session_id_value, + TG_TABLE_NAME || '.' || lower(TG_OP), + jsonb_strip_nulls(jsonb_build_object( + 'table', TG_TABLE_NAME, + 'operation', lower(TG_OP), + 'record_id', record_id_value, + 'session_id', session_id_value, + 'turn_id', CASE + WHEN TG_TABLE_NAME = 'captain_turns' THEN record_id_value + ELSE NULLIF(row_data ->> 'turn_id', '')::uuid + END, + 'prompt_run_id', CASE + WHEN TG_TABLE_NAME = 'captain_prompt_runs' THEN record_id_value + ELSE NULLIF(row_data ->> 'prompt_run_id', '')::uuid + END, + 'iteration_id', CASE + WHEN TG_TABLE_NAME = 'captain_prompt_run_iterations' THEN record_id_value + ELSE NULLIF(row_data ->> 'iteration_id', '')::uuid + END, + 'plan_id', CASE + WHEN TG_TABLE_NAME = 'captain_plans' THEN record_id_value + ELSE NULLIF(row_data ->> 'plan_id', '')::uuid + END, + 'model_call_id', CASE + WHEN TG_TABLE_NAME = 'captain_model_calls' THEN record_id_value + ELSE NULLIF(row_data ->> 'model_call_id', '')::uuid + END, + 'state', row_data ->> 'state', + 'status', COALESCE(row_data ->> 'status', row_data ->> 'lifecycle_status'), + 'phase', row_data ->> 'phase', + 'occurred_at', activity_at + )) + ); + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION public.captain_notify_outbox() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public +AS $$ +BEGIN + PERFORM pg_notify('captain_outbox', NEW.sequence::text); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS captain_sessions_emit_after ON public.captain_sessions; +CREATE TRIGGER captain_sessions_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_sessions +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_session_processes_emit_after ON public.captain_session_processes; +CREATE TRIGGER captain_session_processes_emit_after +AFTER INSERT OR DELETE OR UPDATE OF + status, + command, + cwd, + surface, + lease_owner, + lease_token, + lease_expires_at, + ended_at +ON public.captain_session_processes +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_prompt_runs_emit_after ON public.captain_prompt_runs; +CREATE TRIGGER captain_prompt_runs_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_prompt_runs +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_prompt_run_iterations_emit_after ON public.captain_prompt_run_iterations; +CREATE TRIGGER captain_prompt_run_iterations_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_prompt_run_iterations +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_plans_emit_after ON public.captain_plans; +CREATE TRIGGER captain_plans_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_plans +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_plan_revisions_emit_after ON public.captain_plan_revisions; +CREATE TRIGGER captain_plan_revisions_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_plan_revisions +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_turns_emit_after ON public.captain_turns; +CREATE TRIGGER captain_turns_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_turns +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_model_calls_emit_after ON public.captain_model_calls; +CREATE TRIGGER captain_model_calls_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_model_calls +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_messages_emit_after ON public.captain_messages; +CREATE TRIGGER captain_messages_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_messages +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_events_emit_after ON public.captain_events; +CREATE TRIGGER captain_events_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_events +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_turn_requests_emit_after ON public.captain_turn_requests; +CREATE TRIGGER captain_turn_requests_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_turn_requests +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_artifacts_emit_after ON public.captain_artifacts; +CREATE TRIGGER captain_artifacts_emit_after +AFTER INSERT OR UPDATE OR DELETE ON public.captain_artifacts +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_session_sources_emit_after ON public.captain_session_sources; +CREATE TRIGGER captain_session_sources_emit_after +AFTER INSERT OR DELETE OR UPDATE OF path, source_identity, parser_version +ON public.captain_session_sources +FOR EACH ROW EXECUTE FUNCTION public.captain_emit_session_change(); + +DROP TRIGGER IF EXISTS captain_outbox_notify_after ON public.captain_outbox; +CREATE TRIGGER captain_outbox_notify_after +AFTER INSERT ON public.captain_outbox +FOR EACH ROW EXECUTE FUNCTION public.captain_notify_outbox(); + +-- Internal trigger functions are not PostgREST RPC endpoints. +REVOKE ALL ON FUNCTION public.captain_emit_session_change() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.captain_notify_outbox() FROM PUBLIC; diff --git a/migrations/60_view_session_overview.sql b/migrations/60_view_session_overview.sql new file mode 100644 index 0000000..e2db594 --- /dev/null +++ b/migrations/60_view_session_overview.sql @@ -0,0 +1,211 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_overview +WITH (security_barrier = true) +AS +SELECT + s.id, + s.provider_session_id, + s.source, + s.provider, + s.host_id, + s.parent_session_id, + s.root_session_id, + COALESCE(s.root_session_id, s.id) AS thread_id, + s.path, + COALESCE(source.path, s.path) AS history_file, + source.source_kind, + source.parser_version, + s.project, + s.cwd, + s.title, + s.initial_prompt, + s.slug, + s.agent_type, + s.description, + s.cli_version, + s.lifecycle_status, + s.activity_state, + s.health_state, + s.state_reason, + s.state_version, + s.state_observed_at, + s.git, + s.metadata, + s.started_at, + s.ended_at, + s.last_activity_at, + s.created_at, + s.updated_at, + CASE + WHEN s.started_at IS NULL THEN NULL + ELSE EXTRACT(EPOCH FROM COALESCE(s.ended_at, clock_timestamp()) - s.started_at) + END AS duration_seconds, + process.id AS process_id, + process.pid, + process.status AS process_status, + process.command, + process.cwd AS process_cwd, + process.surface, + process.process_started_at, + process.last_heartbeat_at, + process.lease_owner, + process.lease_expires_at, + process.ended_at AS process_ended_at, + process.id IS NOT NULL AND process.ended_at IS NULL AS process_active, + COALESCE(message_stats.message_count, 0) AS message_count, + COALESCE(message_stats.tool_call_count, 0) AS tool_call_count, + COALESCE(event_stats.event_count, 0) AS event_count, + COALESCE(call_stats.turn_count, 0) AS turn_count, + COALESCE(call_stats.model_call_count, 0) AS model_call_count, + COALESCE(agent_stats.agent_count, 1) AS agent_count, + COALESCE(prompt_stats.prompt_run_count, 0) AS prompt_run_count, + COALESCE(plan_stats.plan_count, 0) AS plan_count, + COALESCE(request_stats.pending_request_count, 0) AS pending_request_count, + COALESCE(request_stats.approved_request_count, 0) AS approved_request_count, + COALESCE(request_stats.denied_request_count, 0) AS denied_request_count, + COALESCE(file_stats.file_read_count, 0) AS file_read_count, + COALESCE(file_stats.file_written_count, 0) AS file_written_count, + latest_call.model, + latest_call.backend, + latest_call.effort, + latest_call.context_tokens, + latest_call.context_window_tokens, + CASE + WHEN latest_call.context_window_tokens > 0 THEN + GREATEST( + 0, + LEAST( + 100, + round( + (1 - latest_call.context_tokens::numeric / latest_call.context_window_tokens::numeric) * 100 + )::integer + ) + ) + ELSE NULL + END AS context_free_percent, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, + -- Appended after initial release: CREATE OR REPLACE VIEW only allows adding + -- columns at the end, so later additions must stay below this line. + process.source AS process_source, + process.cpu_percent, + process.memory_percent, + process.memory_rss_bytes, + process.sampled_at AS process_sampled_at +FROM public.captain_sessions s +LEFT JOIN LATERAL ( + SELECT p.* + FROM public.captain_session_processes p + WHERE p.session_id = s.id + AND p.ended_at IS NULL + ORDER BY COALESCE(p.last_heartbeat_at, p.process_started_at) DESC, p.id DESC + LIMIT 1 +) process ON true +LEFT JOIN LATERAL ( + SELECT src.path, src.source_kind, src.parser_version + FROM public.captain_session_sources src + WHERE src.session_id = s.id + ORDER BY src.updated_at DESC, src.id DESC + LIMIT 1 +) source ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS message_count, + COALESCE(sum(( + SELECT count(*) + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(m.parts) = 'array' THEN m.parts + ELSE '[]'::jsonb + END + ) part + WHERE ( + part ->> 'type' = 'dynamic-tool' + OR part ->> 'type' LIKE 'tool-%' + ) + AND COALESCE(part ->> 'toolName', '') <> '' + )), 0)::bigint AS tool_call_count + FROM public.captain_messages m + WHERE m.session_id = s.id +) message_stats ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS event_count + FROM public.captain_events e + WHERE e.session_id = s.id +) event_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(DISTINCT t.id)::bigint AS turn_count, + count(c.id)::bigint AS model_call_count, + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_turns t + LEFT JOIN public.captain_model_calls c ON c.turn_id = t.id + WHERE t.session_id = s.id +) call_stats ON true +LEFT JOIN LATERAL ( + SELECT + c.model, + c.backend, + c.effort, + c.context_tokens, + c.context_window_tokens + FROM public.captain_turns t + JOIN public.captain_model_calls c ON c.turn_id = t.id + WHERE t.session_id = s.id + ORDER BY COALESCE(c.ended_at, c.started_at, c.created_at) DESC, c.call_index DESC + LIMIT 1 +) latest_call ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint + 1 AS agent_count + FROM public.captain_sessions child + WHERE child.root_session_id = s.id +) agent_stats ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS prompt_run_count + FROM public.captain_prompt_runs r + WHERE r.session_id = s.id +) prompt_stats ON true +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS plan_count + FROM public.captain_plans p + WHERE p.source_session_id = s.id +) plan_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*) FILTER (WHERE r.state = 'pending')::bigint AS pending_request_count, + count(*) FILTER (WHERE r.state IN ('approved', 'answered'))::bigint AS approved_request_count, + count(*) FILTER (WHERE r.state = 'denied')::bigint AS denied_request_count + FROM public.captain_turn_requests r + WHERE r.session_id = s.id +) request_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*) FILTER (WHERE a.kind = 'file.read')::bigint AS file_read_count, + count(*) FILTER (WHERE a.kind IN ('file.write', 'file.edit', 'file.delete'))::bigint AS file_written_count + FROM public.captain_artifacts a + WHERE a.session_id = s.id + AND a.kind LIKE 'file.%' +) file_stats ON true; + +COMMENT ON VIEW public.captain_session_overview IS + 'One row per session for PostgREST list, metadata, health, live-process, usage and cost surfaces.'; diff --git a/migrations/61_view_session_transcript.sql b/migrations/61_view_session_transcript.sql new file mode 100644 index 0000000..445b75c --- /dev/null +++ b/migrations/61_view_session_transcript.sql @@ -0,0 +1,30 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_transcript +WITH (security_barrier = true) +AS +SELECT + m.id, + m.session_id, + m.turn_id, + m.model_call_id, + m.provider_message_id, + m.sequence, + m.role, + m.parts, + m.raw, + m.schema_version, + m.occurred_at, + m.recorded_at, + c.model, + c.backend, + c.effort, + c.status AS model_call_status, + -- Appended after initial release: CREATE OR REPLACE VIEW only allows adding + -- columns at the end, so later additions must stay below this line. + m.source_line +FROM public.captain_messages m +LEFT JOIN public.captain_model_calls c ON c.id = m.model_call_id; + +COMMENT ON VIEW public.captain_session_transcript IS + 'Message rows for the SessionInspector transcript tab; order by sequence through PostgREST.'; diff --git a/migrations/62_view_session_turns.sql b/migrations/62_view_session_turns.sql new file mode 100644 index 0000000..c67a47a --- /dev/null +++ b/migrations/62_view_session_turns.sql @@ -0,0 +1,98 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_turns +WITH (security_barrier = true) +AS +SELECT + t.id, + t.session_id, + t.provider_turn_id, + t.turn_index, + t.description, + t.status, + t.stop_reason, + t.error, + t.started_at, + t.ended_at, + t.created_at, + t.updated_at, + CASE + WHEN t.started_at IS NULL THEN NULL + ELSE EXTRACT(EPOCH FROM COALESCE(t.ended_at, clock_timestamp()) - t.started_at) + END AS duration_seconds, + latest_call.model, + latest_call.backend, + latest_call.effort, + latest_call.context_tokens, + latest_call.context_window_tokens, + CASE + WHEN latest_call.context_window_tokens > 0 THEN + GREATEST( + 0, + LEAST( + 100, + round( + (1 - latest_call.context_tokens::numeric / latest_call.context_window_tokens::numeric) * 100 + )::integer + ) + ) + ELSE NULL + END AS context_free_percent, + COALESCE(call_stats.model_call_count, 0) AS model_call_count, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, + COALESCE(message_stats.message_count, 0) AS message_count, + COALESCE(message_stats.message_ids, ARRAY[]::uuid[]) AS message_ids, + COALESCE(event_stats.event_count, 0) AS event_count, + COALESCE(event_stats.event_ids, ARRAY[]::uuid[]) AS event_ids +FROM public.captain_turns t +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS model_call_count, + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_model_calls c + WHERE c.turn_id = t.id +) call_stats ON true +LEFT JOIN LATERAL ( + SELECT c.model, c.backend, c.effort, c.context_tokens, c.context_window_tokens + FROM public.captain_model_calls c + WHERE c.turn_id = t.id + ORDER BY COALESCE(c.ended_at, c.started_at, c.created_at) DESC, c.call_index DESC + LIMIT 1 +) latest_call ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS message_count, + array_agg(m.id ORDER BY m.sequence) AS message_ids + FROM public.captain_messages m + WHERE m.turn_id = t.id +) message_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS event_count, + array_agg(e.id ORDER BY COALESCE(e.occurred_at, e.recorded_at), e.id) AS event_ids + FROM public.captain_events e + WHERE e.turn_id = t.id +) event_stats ON true; + +COMMENT ON VIEW public.captain_session_turns IS + 'Per-turn usage, cost, context and message/event attribution for the SessionInspector turns tab.'; diff --git a/migrations/63_view_session_agents.sql b/migrations/63_view_session_agents.sql new file mode 100644 index 0000000..a898132 --- /dev/null +++ b/migrations/63_view_session_agents.sql @@ -0,0 +1,60 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_agents +WITH (security_barrier = true) +AS +SELECT + s.id, + s.id AS session_id, + s.parent_session_id, + s.root_session_id, + COALESCE(s.root_session_id, s.id) AS thread_id, + s.parent_session_id IS NULL AS is_root, + s.agent_type, + s.description, + s.path AS history_file, + s.source, + s.provider, + s.lifecycle_status, + s.activity_state, + s.health_state, + s.started_at, + s.ended_at, + COALESCE(child_stats.child_count, 0) AS child_count, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd +FROM public.captain_sessions s +LEFT JOIN LATERAL ( + SELECT count(*)::bigint AS child_count + FROM public.captain_sessions child + WHERE child.parent_session_id = s.id +) child_stats ON true +LEFT JOIN LATERAL ( + SELECT + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_turns t + JOIN public.captain_model_calls c ON c.turn_id = t.id + WHERE t.session_id = s.id +) call_stats ON true; + +COMMENT ON VIEW public.captain_session_agents IS + 'Session hierarchy rows with usage and cost for the SessionInspector agents tab.'; diff --git a/migrations/64_view_session_files.sql b/migrations/64_view_session_files.sql new file mode 100644 index 0000000..55d88da --- /dev/null +++ b/migrations/64_view_session_files.sql @@ -0,0 +1,25 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_files +WITH (security_barrier = true) +AS +SELECT + a.id, + a.session_id, + a.turn_id, + a.model_call_id, + a.prompt_run_id, + a.kind, + split_part(a.kind, '.', 2) AS operation, + a.path, + a.digest, + a.content_type, + a.metadata, + a.occurred_at, + a.created_at +FROM public.captain_artifacts a +WHERE a.kind LIKE 'file.%' + AND a.path IS NOT NULL; + +COMMENT ON VIEW public.captain_session_files IS + 'Normalized file.* artifact rows for the SessionInspector files tab.'; diff --git a/migrations/65_view_session_plans.sql b/migrations/65_view_session_plans.sql new file mode 100644 index 0000000..7d80aa2 --- /dev/null +++ b/migrations/65_view_session_plans.sql @@ -0,0 +1,51 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_plans +WITH (security_barrier = true) +AS +SELECT + p.id, + p.source_session_id AS session_id, + p.source_prompt_run_id, + p.source_iteration_id, + p.source_turn_id, + p.title, + p.slug, + p.path, + p.variant, + p.spec_profile, + p.approval_state, + p.approved_revision_id, + p.approval_comment, + p.approved_by, + p.approval_created_at, + p.feedback_at, + p.created_at, + p.updated_at, + latest_revision.id AS latest_revision_id, + latest_revision.revision AS latest_revision, + latest_revision.plan_markdown AS latest_plan_markdown, + latest_revision.content_hash AS latest_content_hash, + latest_revision.feedback AS latest_feedback, + latest_revision.created_by AS latest_created_by, + latest_revision.created_at AS latest_created_at, + approved_revision.revision AS approved_revision, + approved_revision.plan_markdown AS approved_plan_markdown, + approved_revision.content_hash AS approved_content_hash, + COALESCE(approved_revision.id, latest_revision.id) AS current_revision_id, + COALESCE(approved_revision.revision, latest_revision.revision) AS current_revision, + COALESCE(approved_revision.plan_markdown, latest_revision.plan_markdown) AS plan_markdown, + COALESCE(approved_revision.content_hash, latest_revision.content_hash) AS content_hash +FROM public.captain_plans p +LEFT JOIN LATERAL ( + SELECT r.* + FROM public.captain_plan_revisions r + WHERE r.plan_id = p.id + ORDER BY r.revision DESC, r.created_at DESC, r.id DESC + LIMIT 1 +) latest_revision ON true +LEFT JOIN public.captain_plan_revisions approved_revision + ON approved_revision.id = p.approved_revision_id; + +COMMENT ON VIEW public.captain_session_plans IS + 'Plan rows with latest and approved immutable revisions for the SessionInspector plan tab.'; diff --git a/migrations/66_view_session_approvals.sql b/migrations/66_view_session_approvals.sql new file mode 100644 index 0000000..310d5eb --- /dev/null +++ b/migrations/66_view_session_approvals.sql @@ -0,0 +1,30 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_approvals +WITH (security_barrier = true) +AS +SELECT + r.id, + r.session_id, + r.turn_id, + r.prompt_run_id, + r.plan_id, + r.model_call_id, + r.tool_call_id, + r.kind, + r.state, + r.request, + r.response, + r.idempotency_key, + r.requested_by, + r.resolved_by, + r.reason, + r.version, + r.expires_at, + r.created_at, + r.resolved_at +FROM public.captain_turn_requests r +WHERE r.kind IN ('tool_approval', 'plan_exit_approval'); + +COMMENT ON VIEW public.captain_session_approvals IS + 'Tool and plan-exit approval rows for the SessionInspector approvals tab.'; diff --git a/migrations/67_view_session_costs.sql b/migrations/67_view_session_costs.sql new file mode 100644 index 0000000..40c8e19 --- /dev/null +++ b/migrations/67_view_session_costs.sql @@ -0,0 +1,56 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_costs +WITH (security_barrier = true) +AS +SELECT + concat_ws( + ':', + t.session_id::text, + c.model, + c.backend, + COALESCE(c.effort, 'default'), + upper(c.currency) + ) AS id, + t.session_id, + c.model, + c.backend, + c.effort, + upper(c.currency) AS currency, + count(*)::bigint AS model_call_count, + sum(c.input_tokens)::bigint AS input_tokens, + sum(c.output_tokens)::bigint AS output_tokens, + sum(c.reasoning_tokens)::bigint AS reasoning_tokens, + sum(c.cache_read_tokens)::bigint AS cache_read_tokens, + sum(c.cache_write_tokens)::bigint AS cache_write_tokens, + ( + sum(c.input_tokens) + + sum(c.output_tokens) + + sum(c.cache_read_tokens) + + sum(c.cache_write_tokens) + )::bigint AS total_tokens, + sum(c.input_cost) AS input_cost, + sum(c.output_cost) AS output_cost, + sum(c.reasoning_cost) AS reasoning_cost, + sum(c.cache_read_cost) AS cache_read_cost, + sum(c.cache_write_cost) AS cache_write_cost, + sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) AS total_cost, + min(c.started_at) AS first_call_at, + max(c.ended_at) AS last_call_at +FROM public.captain_turns t +JOIN public.captain_model_calls c ON c.turn_id = t.id +GROUP BY + t.session_id, + c.model, + c.backend, + c.effort, + upper(c.currency); + +COMMENT ON VIEW public.captain_session_costs IS + 'Per-session model/backend/effort token and cost totals for the SessionInspector costs tab.'; diff --git a/migrations/68_view_session_events.sql b/migrations/68_view_session_events.sql new file mode 100644 index 0000000..177e7d7 --- /dev/null +++ b/migrations/68_view_session_events.sql @@ -0,0 +1,26 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_session_events +WITH (security_barrier = true) +AS +SELECT + e.id, + e.session_id, + e.turn_id, + e.prompt_run_id, + e.iteration_id, + e.model_call_id, + e.parent_event_id, + e.event_key, + e.stream, + e.sequence, + e.kind, + e.scope, + e.payload, + e.schema_version, + e.occurred_at, + e.recorded_at +FROM public.captain_events e; + +COMMENT ON VIEW public.captain_session_events IS + 'Durable session and turn event rows for SessionInspector metadata and raw-event surfaces.'; diff --git a/migrations/69_view_prompt_run_overview.sql b/migrations/69_view_prompt_run_overview.sql new file mode 100644 index 0000000..55ae798 --- /dev/null +++ b/migrations/69_view_prompt_run_overview.sql @@ -0,0 +1,115 @@ +-- phase: post + +CREATE OR REPLACE VIEW public.captain_prompt_run_overview +WITH (security_barrier = true) +AS +SELECT + r.id, + r.session_id, + r.root_session_id, + r.batch_id, + r.parent_run_id, + r.input_plan_id, + r.input_plan_revision_id, + r.origin, + r.spec_profile, + r.admission_key, + r.rendered_spec, + r.prompt_markdown, + r.verification_markdown, + r.phase, + r.state, + r.current_iteration, + r.result_text, + r.result_json, + r.error, + r.version, + r.queued_at, + r.started_at, + r.finished_at, + r.created_at, + r.updated_at, + CASE + WHEN r.started_at IS NULL THEN NULL + ELSE EXTRACT(EPOCH FROM COALESCE(r.finished_at, clock_timestamp()) - r.started_at) + END AS duration_seconds, + COALESCE(iteration_stats.iteration_count, 0) AS iteration_count, + COALESCE(iteration_stats.succeeded_iteration_count, 0) AS succeeded_iteration_count, + COALESCE(iteration_stats.failed_iteration_count, 0) AS failed_iteration_count, + latest_iteration.id AS latest_iteration_id, + latest_iteration.iteration AS latest_iteration, + latest_iteration.state AS latest_iteration_state, + latest_iteration.feedback AS latest_iteration_feedback, + latest_iteration.verification_result AS latest_verification_result, + latest_iteration.error AS latest_iteration_error, + latest_iteration.started_at AS latest_iteration_started_at, + latest_iteration.finished_at AS latest_iteration_finished_at, + COALESCE(call_stats.model_call_count, 0) AS model_call_count, + COALESCE(call_stats.input_tokens, 0) AS input_tokens, + COALESCE(call_stats.output_tokens, 0) AS output_tokens, + COALESCE(call_stats.reasoning_tokens, 0) AS reasoning_tokens, + COALESCE(call_stats.cache_read_tokens, 0) AS cache_read_tokens, + COALESCE(call_stats.cache_write_tokens, 0) AS cache_write_tokens, + COALESCE(call_stats.input_tokens, 0) + + COALESCE(call_stats.output_tokens, 0) + + COALESCE(call_stats.cache_read_tokens, 0) + + COALESCE(call_stats.cache_write_tokens, 0) AS total_tokens, + COALESCE(call_stats.cost_usd, 0::numeric) AS cost_usd, + COALESCE(plan_stats.plan_count, 0) AS plan_count, + plan_stats.latest_plan_id, + plan_stats.latest_plan_approval_state, + plan_stats.latest_plan_revision, + r.execution_session_id +FROM public.captain_prompt_runs r +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS iteration_count, + count(*) FILTER (WHERE i.state = 'succeeded')::bigint AS succeeded_iteration_count, + count(*) FILTER (WHERE i.state = 'failed')::bigint AS failed_iteration_count + FROM public.captain_prompt_run_iterations i + WHERE i.prompt_run_id = r.id +) iteration_stats ON true +LEFT JOIN LATERAL ( + SELECT i.* + FROM public.captain_prompt_run_iterations i + WHERE i.prompt_run_id = r.id + ORDER BY i.iteration DESC, i.created_at DESC, i.id DESC + LIMIT 1 +) latest_iteration ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS model_call_count, + COALESCE(sum(c.input_tokens), 0)::bigint AS input_tokens, + COALESCE(sum(c.output_tokens), 0)::bigint AS output_tokens, + COALESCE(sum(c.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(sum(c.cache_read_tokens), 0)::bigint AS cache_read_tokens, + COALESCE(sum(c.cache_write_tokens), 0)::bigint AS cache_write_tokens, + COALESCE(sum( + c.input_cost + + c.output_cost + + c.reasoning_cost + + c.cache_read_cost + + c.cache_write_cost + ) FILTER (WHERE upper(c.currency) = 'USD'), 0::numeric) AS cost_usd + FROM public.captain_model_calls c + WHERE c.prompt_run_id = r.id +) call_stats ON true +LEFT JOIN LATERAL ( + SELECT + count(*)::bigint AS plan_count, + (array_agg(p.id ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_id, + (array_agg(p.approval_state ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_approval_state, + (array_agg(pr.revision ORDER BY p.created_at DESC, p.id DESC))[1] AS latest_plan_revision + FROM public.captain_plans p + LEFT JOIN LATERAL ( + SELECT revision.revision + FROM public.captain_plan_revisions revision + WHERE revision.plan_id = p.id + ORDER BY revision.revision DESC, revision.created_at DESC, revision.id DESC + LIMIT 1 + ) pr ON true + WHERE p.source_prompt_run_id = r.id +) plan_stats ON true; + +COMMENT ON VIEW public.captain_prompt_run_overview IS + 'Prompt-run control-plane state with iteration, plan, usage, cost and verification summaries.'; diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index 0a2bc49..50a3637 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -18,7 +18,19 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { "20_prompt_runs_and_plans.pg.hcl", "30_execution.pg.hcl", "40_artifacts_and_outbox.pg.hcl", - "50_views_and_triggers.sql", + "50_constraints.sql", + "51_state_triggers.sql", + "52_outbox_triggers.sql", + "60_view_session_overview.sql", + "61_view_session_transcript.sql", + "62_view_session_turns.sql", + "63_view_session_agents.sql", + "64_view_session_files.sql", + "65_view_session_plans.sql", + "66_view_session_approvals.sql", + "67_view_session_costs.sql", + "68_view_session_events.sql", + "69_view_prompt_run_overview.sql", } for _, name := range expectedFiles { if _, err := fs.Stat(schemaFS, name); err != nil { @@ -63,16 +75,67 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { `column "state"`, `column "version"`, ) - assertContainsAll(t, "50_views_and_triggers.sql", + assertContainsAll(t, "50_constraints.sql", "-- phase: post", - "-- runs: always", - "CREATE OR REPLACE VIEW public.captain_session_overview", - "CREATE OR REPLACE VIEW public.captain_session_turns", - "CREATE OR REPLACE VIEW public.captain_session_plans", - "CREATE OR REPLACE VIEW public.captain_session_costs", - "CREATE OR REPLACE VIEW public.captain_session_events", - "CREATE OR REPLACE VIEW public.captain_prompt_run_overview", + "ALTER CONSTRAINT captain_prompt_runs_input_plan_id_fkey", + "DEFERRABLE INITIALLY DEFERRED", ) + assertContainsAll(t, "51_state_triggers.sql", + "-- phase: post", + "CREATE OR REPLACE FUNCTION public.captain_set_session_state()", + "CREATE TRIGGER captain_sessions_state_before", + "CREATE TRIGGER captain_sessions_updated_at_before", + "REVOKE ALL ON FUNCTION public.captain_sync_prompt_run_iteration() FROM PUBLIC;", + ) + assertContainsAll(t, "52_outbox_triggers.sql", + "-- phase: post", + "CREATE OR REPLACE FUNCTION public.captain_emit_session_change()", + "CREATE TRIGGER captain_sessions_emit_after", + "CREATE TRIGGER captain_outbox_notify_after", + "REVOKE ALL ON FUNCTION public.captain_notify_outbox() FROM PUBLIC;", + ) + for file, view := range map[string]string{ + "60_view_session_overview.sql": "captain_session_overview", + "61_view_session_transcript.sql": "captain_session_transcript", + "62_view_session_turns.sql": "captain_session_turns", + "63_view_session_agents.sql": "captain_session_agents", + "64_view_session_files.sql": "captain_session_files", + "65_view_session_plans.sql": "captain_session_plans", + "66_view_session_approvals.sql": "captain_session_approvals", + "67_view_session_costs.sql": "captain_session_costs", + "68_view_session_events.sql": "captain_session_events", + "69_view_prompt_run_overview.sql": "captain_prompt_run_overview", + } { + assertContainsAll(t, file, + "-- phase: post", + "CREATE OR REPLACE VIEW public."+view, + "COMMENT ON VIEW public."+view, + ) + } +} + +// Hash-gated run-once scripts keep steady-state applies free of DDL; views are +// restored via commons-db view-dependency invalidation, so no script may opt +// back into re-running on every apply. +func TestSchemaBundleHasNoAlwaysRunScripts(t *testing.T) { + t.Parallel() + + entries, err := fs.Glob(schemaFS, "*.sql") + if err != nil { + t.Fatalf("glob embedded migrations: %v", err) + } + if len(entries) == 0 { + t.Fatal("no embedded SQL migrations found") + } + for _, name := range entries { + data, err := fs.ReadFile(schemaFS, name) + if err != nil { + t.Fatalf("read embedded migration %s: %v", name, err) + } + if strings.Contains(string(data), "-- runs: always") { + t.Errorf("%s declares '-- runs: always'; scripts must be hash-gated run-once", name) + } + } } func TestLegacySessionSchemaDetection(t *testing.T) { diff --git a/pkg/database/migration_integration_test.go b/pkg/database/migration_integration_test.go index ed272be..697de45 100644 --- a/pkg/database/migration_integration_test.go +++ b/pkg/database/migration_integration_test.go @@ -34,9 +34,27 @@ func TestCaptainMigrationsAreIdempotentAndShareOnePool(t *testing.T) { require.NoError(t, first.Close(), "closing an injected handle must not close the shared pool") require.NoError(t, sharedSQL.PingContext(t.Context())) + scriptRuns := func() map[string]string { + var rows []struct { + Path string + UpdatedAt string + } + require.NoError(t, shared.Raw(`SELECT path, updated_at::text AS updated_at + FROM schema_migration_scripts WHERE scope = 'captain'`).Scan(&rows).Error) + runs := map[string]string{} + for _, row := range rows { + runs[row.Path] = row.UpdatedAt + } + return runs + } + firstRuns := scriptRuns() + require.NotEmpty(t, firstRuns) + second, err := Open(t.Context(), Config{Gorm: shared, DSN: dsn}) require.NoError(t, err, "applying the Captain bundle twice must be idempotent") require.Same(t, shared, second.Gorm()) + require.Equal(t, firstRuns, scriptRuns(), + "a no-op apply must not re-run any hash-gated script (steady state performs zero DDL)") for _, table := range []string{ "captain_sessions", diff --git a/pkg/database/retry.go b/pkg/database/retry.go new file mode 100644 index 0000000..fa9b0e3 --- /dev/null +++ b/pkg/database/retry.go @@ -0,0 +1,58 @@ +package database + +import ( + "context" + "errors" + "fmt" + "time" +) + +const ( + txRetryMaxAttempts = 5 + txRetryBackoff = 200 * time.Millisecond +) + +// isRetryableTxErr reports whether err is a transient transaction failure that +// re-running an idempotent transaction can clear: a detected deadlock (40P01) +// or a serialization failure (40001). A concurrent migration taking ACCESS +// EXCLUSIVE locks on tables an ingest transaction is writing is the canonical +// cause. The SQLSTATE is read through the SQLState() interface that both +// jackc/pgconn and lib/pq errors satisfy, matching isUniqueViolation. +func isRetryableTxErr(err error) bool { + var sqlState sqlStateError + if !errors.As(err, &sqlState) { + return false + } + switch sqlState.SQLState() { + case "40P01", "40001": + return true + default: + return false + } +} + +// retryTransientTx runs fn up to txRetryMaxAttempts times while it fails with a +// retryable transaction error, backing off linearly between attempts. fn MUST +// be idempotent — it re-runs the whole transaction on each attempt. Context +// cancellation aborts the wait and returns ctx.Err(). +func retryTransientTx(ctx context.Context, desc string, fn func() error) error { + var lastErr error + for attempt := 1; attempt <= txRetryMaxAttempts; attempt++ { + err := fn() + if err == nil { + return nil + } + if !isRetryableTxErr(err) { + return err + } + lastErr = err + if attempt < txRetryMaxAttempts { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt) * txRetryBackoff): + } + } + } + return fmt.Errorf("%s: gave up after %d attempts under lock contention: %w", desc, txRetryMaxAttempts, lastErr) +} diff --git a/pkg/database/retry_test.go b/pkg/database/retry_test.go new file mode 100644 index 0000000..f654bd5 --- /dev/null +++ b/pkg/database/retry_test.go @@ -0,0 +1,98 @@ +package database + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubSQLStateErr satisfies the sqlStateError interface that pgconn and lib/pq +// errors implement, so the classifier can be exercised without a live driver. +type stubSQLStateErr struct{ code string } + +func (e stubSQLStateErr) Error() string { return "sqlstate " + e.code } +func (e stubSQLStateErr) SQLState() string { return e.code } + +func TestIsRetryableTxErr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + {name: "deadlock", err: stubSQLStateErr{"40P01"}, want: true}, + {name: "serialization failure", err: stubSQLStateErr{"40001"}, want: true}, + {name: "wrapped deadlock", err: fmt.Errorf("ingest: %w", stubSQLStateErr{"40P01"}), want: true}, + {name: "unique violation", err: stubSQLStateErr{"23505"}, want: false}, + {name: "lock timeout is migration-only", err: stubSQLStateErr{"55P03"}, want: false}, + {name: "plain error", err: errors.New("boom"), want: false}, + {name: "nil", err: nil, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, isRetryableTxErr(tt.err)) + }) + } +} + +func TestRetryTransientTxSucceedsAfterTransientDeadlocks(t *testing.T) { + t.Parallel() + + attempts := 0 + err := retryTransientTx(t.Context(), "ingest", func() error { + attempts++ + if attempts < 3 { + return stubSQLStateErr{"40P01"} + } + return nil + }) + require.NoError(t, err) + assert.Equal(t, 3, attempts, "must retry until the transaction commits") +} + +func TestRetryTransientTxReturnsNonRetryableImmediately(t *testing.T) { + t.Parallel() + + attempts := 0 + sentinel := stubSQLStateErr{"23505"} + err := retryTransientTx(t.Context(), "ingest", func() error { + attempts++ + return sentinel + }) + require.ErrorIs(t, err, sentinel) + assert.Equal(t, 1, attempts, "a non-retryable error must not be retried") +} + +func TestRetryTransientTxExhaustsAttempts(t *testing.T) { + t.Parallel() + + attempts := 0 + err := retryTransientTx(t.Context(), "ingest", func() error { + attempts++ + return stubSQLStateErr{"40001"} + }) + require.Error(t, err) + assert.Equal(t, txRetryMaxAttempts, attempts) + assert.Contains(t, err.Error(), fmt.Sprintf("gave up after %d attempts", txRetryMaxAttempts)) +} + +func TestRetryTransientTxHonorsContextCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + attempts := 0 + err := retryTransientTx(ctx, "ingest", func() error { + attempts++ + cancel() // cancel before the backoff wait + return stubSQLStateErr{"40P01"} + }) + require.ErrorIs(t, err, context.Canceled) + assert.Equal(t, 1, attempts, "cancellation during backoff must stop retrying") +} diff --git a/pkg/database/session_ingest_store.go b/pkg/database/session_ingest_store.go index 40bd121..dca4f38 100644 --- a/pkg/database/session_ingest_store.go +++ b/pkg/database/session_ingest_store.go @@ -230,37 +230,43 @@ func (db *DB) IngestTranscript(ctx context.Context, input IngestTranscriptInput) return nil, err } var session *Session - err := db.Transaction(ctx, func(tx *DB) error { - var err error - session, err = tx.CreateOrGetSession(ctx, CreateSessionInput{ - ProviderSessionID: input.Session.ProviderSessionID, - Source: input.Session.Source, - HostID: input.Session.HostID, - ParentSessionID: input.Session.ParentSessionID, - Path: input.Session.Path, - Project: input.Session.Project, - CWD: input.Session.CWD, - Title: input.Session.Title, - InitialPrompt: input.Session.InitialPrompt, - Slug: input.Session.Slug, - AgentType: input.Session.AgentType, - Description: input.Session.Description, - CLIVersion: input.Session.CLIVersion, + // The ingest transaction is idempotent (ON CONFLICT upserts plus column + // projection), so a deadlock against a concurrent migration's exclusive + // locks is retried rather than dropping the batch. + err := retryTransientTx(ctx, "ingest Captain transcript", func() error { + session = nil + return db.Transaction(ctx, func(tx *DB) error { + var err error + session, err = tx.CreateOrGetSession(ctx, CreateSessionInput{ + ProviderSessionID: input.Session.ProviderSessionID, + Source: input.Session.Source, + HostID: input.Session.HostID, + ParentSessionID: input.Session.ParentSessionID, + Path: input.Session.Path, + Project: input.Session.Project, + CWD: input.Session.CWD, + Title: input.Session.Title, + InitialPrompt: input.Session.InitialPrompt, + Slug: input.Session.Slug, + AgentType: input.Session.AgentType, + Description: input.Session.Description, + CLIVersion: input.Session.CLIVersion, + }) + if err != nil { + return err + } + if err := tx.projectSessionColumns(ctx, session.ID, input.Session); err != nil { + return err + } + if err := tx.upsertSessionSource(ctx, session.ID, input.Source); err != nil { + return err + } + turnIDs, err := tx.upsertTurns(ctx, session.ID, input.Turns) + if err != nil { + return err + } + return tx.insertMessages(ctx, session.ID, turnIDs, input.Messages) }) - if err != nil { - return err - } - if err := tx.projectSessionColumns(ctx, session.ID, input.Session); err != nil { - return err - } - if err := tx.upsertSessionSource(ctx, session.ID, input.Source); err != nil { - return err - } - turnIDs, err := tx.upsertTurns(ctx, session.ID, input.Turns) - if err != nil { - return err - } - return tx.insertMessages(ctx, session.ID, turnIDs, input.Messages) }) if err != nil { return nil, err From f10bd6a321a539e67f7f86ba45ad74fec2e2b795 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 06:42:28 +0300 Subject: [PATCH 057/131] feat(attachments): add durable multimodal prompt attachments Add content-addressed attachment storage, validation, limits, uploads, and garbage collection for multimodal prompts. Support attachments across CLI, HTTP chat, web workbench, Genkit, and Claude Agent providers with backend capability checks. Include attachment descriptors in cache identity and normalize provider usage accounting to prevent double-counting. --- cmd/captain/main.go | 4 + pkg/ai/attachment_capabilities.go | 114 ++++++++++++ pkg/ai/attachment_capabilities_ginkgo_test.go | 65 +++++++ pkg/ai/attachment_capabilities_suite_test.go | 13 ++ pkg/ai/catalog.go | 2 + pkg/ai/catalog_info.go | 30 ++-- pkg/ai/fallback.go | 6 + pkg/ai/internal/gen-model-registry/main.go | 20 +++ pkg/ai/middleware/caching.go | 5 +- pkg/ai/models_remote.go | 1 + pkg/ai/provider/claudeagent/agent.ts | 76 +++++++- .../claudeagent/attachments_ginkgo_test.go | 43 +++++ .../claudeagent/attachments_suite_test.go | 13 ++ pkg/ai/provider/claudeagent/package.json | 2 +- pkg/ai/provider/claudeagent/provider.go | 10 +- pkg/ai/provider/claudeagent/runner.go | 37 +++- pkg/ai/provider/claudeagent/turn.go | 46 ++++- .../genkit/attachments_ginkgo_test.go | 45 +++++ .../provider/genkit/attachments_suite_test.go | 13 ++ pkg/ai/provider/genkit/genkit.go | 3 +- pkg/ai/provider/genkit/genkit_test.go | 44 ++++- pkg/ai/provider/genkit/mapping.go | 28 ++- pkg/ai/provider/genkit/options.go | 40 ++++- pkg/api/attachment.go | 97 ++++++++++ pkg/api/attachment_ginkgo_test.go | 44 +++++ pkg/api/attachment_suite_test.go | 13 ++ pkg/api/prompt.go | 28 ++- pkg/api/spec.go | 2 +- pkg/attachments/attachments_suite_test.go | 13 ++ pkg/attachments/gc.go | 68 ++++++++ pkg/attachments/limits.go | 48 +++++ pkg/attachments/resolve.go | 127 ++++++++++++++ pkg/attachments/store.go | 165 ++++++++++++++++++ pkg/attachments/store_ginkgo_test.go | 103 +++++++++++ pkg/captainconfig/config.go | 32 +++- pkg/cli/ai.go | 32 +++- pkg/cli/ai_prompt_file.go | 11 +- pkg/cli/attachments.go | 160 +++++++++++++++++ pkg/cli/attachments_gc.go | 126 +++++++++++++ pkg/cli/attachments_ginkgo_test.go | 105 +++++++++++ pkg/cli/attachments_http.go | 88 ++++++++++ pkg/cli/prompt_run.go | 19 ++ pkg/cli/serve.go | 7 + pkg/cli/webapp/src/PromptWorkbench.tsx | 102 ++++++++--- 44 files changed, 1965 insertions(+), 85 deletions(-) create mode 100644 pkg/ai/attachment_capabilities.go create mode 100644 pkg/ai/attachment_capabilities_ginkgo_test.go create mode 100644 pkg/ai/attachment_capabilities_suite_test.go create mode 100644 pkg/ai/provider/claudeagent/attachments_ginkgo_test.go create mode 100644 pkg/ai/provider/claudeagent/attachments_suite_test.go create mode 100644 pkg/ai/provider/genkit/attachments_ginkgo_test.go create mode 100644 pkg/ai/provider/genkit/attachments_suite_test.go create mode 100644 pkg/api/attachment.go create mode 100644 pkg/api/attachment_ginkgo_test.go create mode 100644 pkg/api/attachment_suite_test.go create mode 100644 pkg/attachments/attachments_suite_test.go create mode 100644 pkg/attachments/gc.go create mode 100644 pkg/attachments/limits.go create mode 100644 pkg/attachments/resolve.go create mode 100644 pkg/attachments/store.go create mode 100644 pkg/attachments/store_ginkgo_test.go create mode 100644 pkg/cli/attachments.go create mode 100644 pkg/cli/attachments_gc.go create mode 100644 pkg/cli/attachments_ginkgo_test.go create mode 100644 pkg/cli/attachments_http.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index d667dc7..aa8bdbe 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -123,6 +123,10 @@ func main() { rootCmd.AddCommand(cli.NewServeCommand(version)) + attachmentsCmd := &cobra.Command{Use: "attachments", Short: "Manage durable prompt attachments"} + rootCmd.AddCommand(attachmentsCmd) + clicky.AddNamedCommand("gc", attachmentsCmd, cli.AttachmentsGCOptions{}, cli.RunAttachmentsGC).Short = "Remove old unreferenced attachments" + dodCmd := &cobra.Command{ Use: "dod", Short: "Definition of Done checks", diff --git a/pkg/ai/attachment_capabilities.go b/pkg/ai/attachment_capabilities.go new file mode 100644 index 0000000..5cd8810 --- /dev/null +++ b/pkg/ai/attachment_capabilities.go @@ -0,0 +1,114 @@ +package ai + +import ( + "fmt" + "slices" + "strings" + + "github.com/flanksource/captain/pkg/api" +) + +var adapterInputMediaTypes = map[api.Backend][]string{ + api.BackendGemini: {"image/*", "audio/*", "video/*", "application/pdf"}, + api.BackendAnthropic: {"image/*"}, + api.BackendOpenAI: {"image/*"}, + api.BackendDeepSeek: {}, + api.BackendCodexAgent: {"image/*"}, + api.BackendCodexCLI: {"image/*"}, + api.BackendClaudeAgent: {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}, + api.BackendClaudeCLI: {}, + api.BackendGeminiCLI: {}, + api.BackendClaudeCmux: {}, + api.BackendCodexCmux: {}, +} + +func AdapterInputMediaTypes(backend api.Backend) []string { + return append([]string{}, adapterInputMediaTypes[backend]...) +} + +func ValidateAttachmentCompatibility(models []api.Model, refs []api.AttachmentRef) error { + if len(refs) == 0 { + return nil + } + for _, model := range models { + backend, err := model.ResolveBackend() + if err != nil { + return fmt.Errorf("resolve attachment backend for %s: %w", model.Name, err) + } + accepted := modelInputMediaTypes(backend, model.Name) + for _, ref := range refs { + if !mediaTypeAccepted(accepted, ref.MediaType) { + return fmt.Errorf("%s:%s does not accept %s attachments", backend, model.Name, ref.MediaType) + } + } + } + return nil +} + +func modelInputMediaTypes(backend api.Backend, model string) []string { + if def, ok := RegistryModelDef(backend, model); ok { + return def.InputMediaTypes + } + return AdapterInputMediaTypes(backend) +} + +func clampInputMediaTypes(backend api.Backend, modelTypes []string) []string { + adapterTypes := AdapterInputMediaTypes(backend) + if len(adapterTypes) == 0 || len(modelTypes) == 0 { + return []string{} + } + out := make([]string, 0, len(modelTypes)) + for _, modelType := range modelTypes { + modelType = normalizeInputMediaType(modelType) + if modelType == "" { + continue + } + for _, adapterType := range adapterTypes { + mediaType, ok := intersectMediaTypes(modelType, adapterType) + if ok && !slices.Contains(out, mediaType) { + out = append(out, mediaType) + } + } + } + return out +} + +func intersectMediaTypes(modelType, adapterType string) (string, bool) { + if modelType == adapterType { + return modelType, true + } + if strings.HasSuffix(modelType, "/*") && mediaTypeAccepted([]string{modelType}, adapterType) { + return adapterType, true + } + if strings.HasSuffix(adapterType, "/*") && mediaTypeAccepted([]string{adapterType}, modelType) { + return modelType, true + } + return "", false +} + +func normalizeInputMediaType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "image", "image/*": + return "image/*" + case "audio", "audio/*": + return "audio/*" + case "video", "video/*": + return "video/*" + case "pdf", "application/pdf": + return "application/pdf" + case "text", "text/*": + return "" + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func mediaTypeAccepted(accepted []string, mediaType string) bool { + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + for _, pattern := range accepted { + if pattern == mediaType || strings.HasSuffix(pattern, "/*") && strings.HasPrefix(mediaType, strings.TrimSuffix(pattern, "*")) { + return true + } + } + return false +} diff --git a/pkg/ai/attachment_capabilities_ginkgo_test.go b/pkg/ai/attachment_capabilities_ginkgo_test.go new file mode 100644 index 0000000..feaca11 --- /dev/null +++ b/pkg/ai/attachment_capabilities_ginkgo_test.go @@ -0,0 +1,65 @@ +package ai_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +var _ = Describe("Attachment capabilities", func() { + It("clamps catalog modalities to what the backend adapter can execute", func() { + DeferCleanup(ai.ResetModelCatalog) + model := ai.Model{ + ID: "openai/vision-test", + Backend: ai.BackendOpenAI, + InputMediaTypes: []string{"image/*", "audio/*"}, + } + Expect(ai.SetModelCatalog([]ai.Model{model})).To(Succeed()) + info := ai.CatalogInfo([]string{"openai"}) + Expect(info).To(HaveLen(1)) + Expect(info[0].InputMediaTypes).To(Equal([]string{"image/*"})) + }) + + It("fails the entire multi-model request when one backend is incompatible", func() { + models := []api.Model{ + {Name: "vision-test", Backend: api.BackendCodexAgent}, + {Name: "gemini-3.1-pro", Backend: api.BackendGeminiCLI}, + } + refs := []api.AttachmentRef{{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "image/png"}} + + err := ai.ValidateAttachmentCompatibility(models, refs) + Expect(err).To(MatchError(ContainSubstring("gemini-cli:gemini-3.1-pro does not accept image/png attachments"))) + }) + + It("rejects image formats the Claude Agent SDK cannot encode", func() { + err := ai.ValidateAttachmentCompatibility( + []api.Model{{Name: "claude-sonnet-5", Backend: api.BackendClaudeAgent}}, + []api.AttachmentRef{{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "image/svg+xml"}}, + ) + Expect(err).To(MatchError(ContainSubstring("does not accept image/svg+xml attachments"))) + }) + + It("expands a model image wildcard to the Claude adapter formats", func() { + model := api.Model{Name: "claude-sonnet-5", Backend: api.BackendClaudeAgent} + ref := api.AttachmentRef{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "image/png"} + Expect(ai.ValidateAttachmentCompatibility([]api.Model{model}, []api.AttachmentRef{ref})).To(Succeed()) + }) + + DescribeTable("publishes the adapter capability matrix", + func(backend api.Backend, expected []string) { + Expect(ai.AdapterInputMediaTypes(backend)).To(Equal(expected)) + }, + Entry("Gemini API", api.BackendGemini, []string{"image/*", "audio/*", "video/*", "application/pdf"}), + Entry("Anthropic API", api.BackendAnthropic, []string{"image/*"}), + Entry("OpenAI API", api.BackendOpenAI, []string{"image/*"}), + Entry("DeepSeek API", api.BackendDeepSeek, []string{}), + Entry("Codex agent", api.BackendCodexAgent, []string{"image/*"}), + Entry("Codex CLI", api.BackendCodexCLI, []string{"image/*"}), + Entry("Claude Agent", api.BackendClaudeAgent, []string{"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}), + Entry("Claude CLI", api.BackendClaudeCLI, []string{}), + Entry("Gemini CLI", api.BackendGeminiCLI, []string{}), + Entry("cmux", api.BackendCodexCmux, []string{}), + ) +}) diff --git a/pkg/ai/attachment_capabilities_suite_test.go b/pkg/ai/attachment_capabilities_suite_test.go new file mode 100644 index 0000000..c874b0f --- /dev/null +++ b/pkg/ai/attachment_capabilities_suite_test.go @@ -0,0 +1,13 @@ +package ai_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAttachmentCapabilities(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AI Attachment Capabilities Suite") +} diff --git a/pkg/ai/catalog.go b/pkg/ai/catalog.go index a97e365..4a0c42f 100644 --- a/pkg/ai/catalog.go +++ b/pkg/ai/catalog.go @@ -26,6 +26,7 @@ type Model struct { AdaptiveThinking bool ContextWindow int // max context tokens, for a usage gauge's denominator ReleaseDate string // YYYY-MM-DD release date, when known + InputMediaTypes []string SupportedEfforts []api.Effort DefaultEffort api.Effort Priority int @@ -168,6 +169,7 @@ func normalizeModel(model Model) (Model, error) { if model.Label == "" { model.Label = model.ID } + model.InputMediaTypes = clampInputMediaTypes(model.Backend, model.InputMediaTypes) if model.ReleaseDate != "" { normalized := normalizeReleaseDate(model.ReleaseDate) if normalized == "" { diff --git a/pkg/ai/catalog_info.go b/pkg/ai/catalog_info.go index 5c55ae4..81702a1 100644 --- a/pkg/ai/catalog_info.go +++ b/pkg/ai/catalog_info.go @@ -9,13 +9,14 @@ import ( // 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"` - Temperature bool `json:"temperature"` - Configured bool `json:"configured"` - ContextWindow int `json:"contextWindow"` + ID string `json:"id"` + Provider string `json:"provider"` + Label string `json:"label"` + Reasoning bool `json:"reasoning"` + Temperature bool `json:"temperature"` + Configured bool `json:"configured"` + ContextWindow int `json:"contextWindow"` + InputMediaTypes []string `json:"inputMediaTypes"` } // BackendToProvider maps a backend to the provider string used in API model ids @@ -58,13 +59,14 @@ func catalogInfoFrom(models []Model, configuredProviders []string) []ModelInfo { configured = slices.Contains(configuredProviders, BackendToProvider(m.Backend)) } out[i] = ModelInfo{ - ID: m.ID, - Provider: BackendToProvider(m.Backend), - Label: m.Label, - Reasoning: m.Reasoning, - Temperature: m.Temperature, - Configured: configured, - ContextWindow: m.ContextWindow, + ID: m.ID, + Provider: BackendToProvider(m.Backend), + Label: m.Label, + Reasoning: m.Reasoning, + Temperature: m.Temperature, + Configured: configured, + ContextWindow: m.ContextWindow, + InputMediaTypes: append([]string(nil), m.InputMediaTypes...), } } return out diff --git a/pkg/ai/fallback.go b/pkg/ai/fallback.go index d18887c..fcbb13e 100644 --- a/pkg/ai/fallback.go +++ b/pkg/ai/fallback.go @@ -94,6 +94,9 @@ func (f *fallbackProvider) GetBackend() Backend { // model will not fix a malformed request). When nothing succeeds the primary's // error is returned, as the most actionable one. func (f *fallbackProvider) Execute(ctx context.Context, req Request) (*Response, error) { + if err := ValidateAttachmentCompatibility(f.candidates, req.Prompt.Attachments); err != nil { + return nil, err + } log := LoggerFromContext(ctx, fallbackLog) var firstErr error record := func(err error) { @@ -133,6 +136,9 @@ func (f *fallbackProvider) Execute(ctx context.Context, req Request) (*Response, // once content has been forwarded the stream is committed and any later error is // surfaced to the caller. func (f *fallbackProvider) ExecuteStream(ctx context.Context, req Request) (<-chan Event, error) { + if err := ValidateAttachmentCompatibility(f.candidates, req.Prompt.Attachments); err != nil { + return nil, err + } out := make(chan Event) go func() { defer close(out) diff --git a/pkg/ai/internal/gen-model-registry/main.go b/pkg/ai/internal/gen-model-registry/main.go index 41ffa8c..dd0b247 100644 --- a/pkg/ai/internal/gen-model-registry/main.go +++ b/pkg/ai/internal/gen-model-registry/main.go @@ -42,6 +42,7 @@ type modelsDevReasoningOption struct { } type modelsDevModalities struct { + Input []string `json:"input"` Output []string `json:"output"` } @@ -61,6 +62,7 @@ type generatedModel struct { Reasoning bool `json:"reasoning,omitempty"` Temperature bool `json:"temperature,omitempty"` ContextWindow int `json:"contextWindow,omitempty"` + InputMediaTypes []string `json:"inputMediaTypes,omitempty"` Preferred bool `json:"preferred,omitempty"` AdaptiveThinking bool `json:"adaptiveThinking,omitempty"` Availability []string `json:"availability,omitempty"` @@ -253,11 +255,29 @@ func generatedModelFromModelsDev(provider, id string, model modelsDevModel, expl Reasoning: model.Reasoning, Temperature: model.Temperature, ContextWindow: model.Limit.Context, + InputMediaTypes: deriveInputMediaTypes(model.Modalities.Input), AdaptiveThinking: deriveAdaptiveThinking(provider, model), SupportedEfforts: deriveSupportedEfforts(provider, model), }, true } +func deriveInputMediaTypes(modalities []string) []string { + out := make([]string, 0, len(modalities)) + for _, modality := range modalities { + switch strings.ToLower(strings.TrimSpace(modality)) { + case "image": + out = append(out, "image/*") + case "audio": + out = append(out, "audio/*") + case "video": + out = append(out, "video/*") + case "pdf": + out = append(out, "application/pdf") + } + } + return out +} + func deriveSupportedEfforts(provider string, model modelsDevModel) []string { // Only retain source levels that Captain can submit through the selected // backend. DeepSeek selects reasoning by model ID, while Anthropic's legacy diff --git a/pkg/ai/middleware/caching.go b/pkg/ai/middleware/caching.go index e5e9820..4470512 100644 --- a/pkg/ai/middleware/caching.go +++ b/pkg/ai/middleware/caching.go @@ -22,7 +22,8 @@ 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.User, c.provider.GetModel()) + cacheIdentity := req.Prompt.CacheIdentity() + entry, err := c.cache.Get(cacheIdentity, 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 +51,7 @@ func (c *cachingProvider) Execute(ctx context.Context, req ai.Request) (*ai.Resp cacheEntry := &cache.Entry{ Model: c.provider.GetModel(), - Prompt: req.Prompt.User, + Prompt: cacheIdentity, DurationMS: duration.Milliseconds(), Provider: string(c.provider.GetBackend()), } diff --git a/pkg/ai/models_remote.go b/pkg/ai/models_remote.go index 0c1d689..b648656 100644 --- a/pkg/ai/models_remote.go +++ b/pkg/ai/models_remote.go @@ -24,6 +24,7 @@ type ModelDef struct { CapabilitiesKnown bool `json:"capabilitiesKnown,omitempty"` Reasoning bool `json:"reasoning,omitempty"` Temperature bool `json:"temperature"` + InputMediaTypes []string `json:"inputMediaTypes,omitempty"` SupportedEfforts []api.Effort `json:"supportedEfforts,omitempty"` DefaultEffort api.Effort `json:"defaultEffort,omitempty"` Priority int `json:"priority,omitempty"` diff --git a/pkg/ai/provider/claudeagent/agent.ts b/pkg/ai/provider/claudeagent/agent.ts index 9653937..98b7e6d 100644 --- a/pkg/ai/provider/claudeagent/agent.ts +++ b/pkg/ai/provider/claudeagent/agent.ts @@ -8,7 +8,7 @@ // maxTurns, maxBudgetUsd, permissionMode, resume, approvalMode, // outputSchema} // -> reply {ok:true} -// prompt {text} -> reply {accepted:true} +// prompt {text, attachments?} -> reply {accepted:true} // interrupt -> reply {} // shutdown -> reply {} then exit // server -> client notifications: @@ -146,10 +146,43 @@ class TurnQueue implements AsyncIterable { private waiters: ((r: IteratorResult) => void)[] = []; private ended = false; - push(text: string) { + push(params: PromptParams) { + const content: Exclude = []; + if (params.text) { + content.push({ type: "text", text: params.text }); + } + for (const attachment of params.attachments ?? []) { + if (attachment.mediaType === "application/pdf") { + content.push({ + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: attachment.data, + }, + title: attachment.filename || undefined, + }); + } else if (isClaudeImageMediaType(attachment.mediaType)) { + content.push({ + type: "image", + source: { + type: "base64", + media_type: attachment.mediaType, + data: attachment.data, + }, + }); + } else { + throw new Error( + `unsupported attachment media type: ${attachment.mediaType}`, + ); + } + } const msg: SDKUserMessage = { type: "user", - message: { role: "user", content: text }, + message: { + role: "user", + content, + }, parent_tool_use_id: null, session_id: "", }; @@ -191,6 +224,31 @@ class TurnQueue implements AsyncIterable { let turns: TurnQueue | null = null; let activeQuery: Query | null = null; +interface PromptAttachment { + mediaType: string; + data: string; + filename?: string; +} + +type ClaudeImageMediaType = + | "image/png" + | "image/jpeg" + | "image/gif" + | "image/webp"; + +function isClaudeImageMediaType( + mediaType: string, +): mediaType is ClaudeImageMediaType { + return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes( + mediaType, + ); +} + +interface PromptParams { + text?: string; + attachments?: PromptAttachment[]; +} + function buildOptions(params: InitializeParams): Options { // brokered: the host vets each tool over the can_use_tool round-trip, so the // SDK must consult canUseTool rather than auto-approving. bypassPermissions / @@ -348,13 +406,17 @@ function handleInitialize(id: JsonRpcId, params: InitializeParams) { } } -function handlePrompt(id: JsonRpcId, params: { text?: string }) { +function handlePrompt(id: JsonRpcId, params: PromptParams) { if (!turns) { replyError(id, -32002, "not initialized"); return; } - turns.push(params.text || ""); - reply(id, { accepted: true }); + try { + turns.push(params); + reply(id, { accepted: true }); + } catch (err) { + replyError(id, -32602, `invalid prompt: ${(err as Error)?.message || err}`); + } } async function handleInterrupt(id: JsonRpcId) { @@ -495,7 +557,7 @@ rl.on("line", (line) => { handleInitialize(id, (req.params as InitializeParams) || {}); break; case "prompt": - handlePrompt(id, (req.params as { text?: string }) || {}); + handlePrompt(id, (req.params as PromptParams) || {}); break; case "interrupt": handleInterrupt(id); diff --git a/pkg/ai/provider/claudeagent/attachments_ginkgo_test.go b/pkg/ai/provider/claudeagent/attachments_ginkgo_test.go new file mode 100644 index 0000000..12c369f --- /dev/null +++ b/pkg/ai/provider/claudeagent/attachments_ginkgo_test.go @@ -0,0 +1,43 @@ +package claudeagent + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +var _ = Describe("Claude Agent prompt parameters", func() { + It("derives the required SDK version from the embedded package manifest", func() { + version, err := requiredSDKVersion() + Expect(err).NotTo(HaveOccurred()) + Expect(version).To(Equal("0.3.210")) + }) + + It("materializes the structured attachment bridge source", func() { + directory, err := prepareAgentDir() + Expect(err).NotTo(HaveOccurred()) + content, err := os.ReadFile(filepath.Join(directory, "agent.ts")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("attachments?: PromptAttachment[]")) + }) + + It("encodes prepared image and PDF data as ordered structured inputs", func() { + image := api.AttachmentRef{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "image/png"}. + WithPreparedContent(api.AttachmentContent{Bytes: []byte("image")}) + pdf := api.AttachmentRef{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "application/pdf"}. + WithPreparedContent(api.AttachmentContent{Bytes: []byte("pdf")}) + + params, err := buildPromptParams(ai.Request{Prompt: api.Prompt{User: "inspect", Attachments: []api.AttachmentRef{image, pdf}}}) + Expect(err).NotTo(HaveOccurred()) + Expect(params.Text).To(Equal("inspect")) + Expect(params.Attachments).To(Equal([]promptAttachment{ + {MediaType: "image/png", Data: "aW1hZ2U="}, + {MediaType: "application/pdf", Data: "cGRm"}, + })) + }) +}) diff --git a/pkg/ai/provider/claudeagent/attachments_suite_test.go b/pkg/ai/provider/claudeagent/attachments_suite_test.go new file mode 100644 index 0000000..7712f0f --- /dev/null +++ b/pkg/ai/provider/claudeagent/attachments_suite_test.go @@ -0,0 +1,13 @@ +package claudeagent + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAttachments(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Claude Agent Attachments Suite") +} diff --git a/pkg/ai/provider/claudeagent/package.json b/pkg/ai/provider/claudeagent/package.json index 4add5c4..0695c3f 100644 --- a/pkg/ai/provider/claudeagent/package.json +++ b/pkg/ai/provider/claudeagent/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "latest", + "@anthropic-ai/claude-agent-sdk": "0.3.210", "tsx": "latest", "typescript": "latest" } diff --git a/pkg/ai/provider/claudeagent/provider.go b/pkg/ai/provider/claudeagent/provider.go index 7b12f35..32c49fc 100644 --- a/pkg/ai/provider/claudeagent/provider.go +++ b/pkg/ai/provider/claudeagent/provider.go @@ -411,6 +411,14 @@ func (p *Provider) initializeParams(req ai.Request) initializeParams { resume = p.cfg.SessionID } + // Prefer the per-request budget (like claude-cli, which reads req.Budget.Cost) + // and fall back to the config default, so both Claude backends resolve the + // ceiling from the same source (finding A4). + maxBudget := req.Budget.Cost + if maxBudget == 0 { + maxBudget = p.cfg.Budget.Cost + } + return initializeParams{ Cwd: req.Cwd(), Model: aliasModel(p.model), @@ -418,7 +426,7 @@ func (p *Provider) initializeParams(req ai.Request) initializeParams { AppendSystemPrompt: req.Prompt.AppendSystem, AllowedTools: allowed, MaxTurns: req.Budget.MaxTurns, - MaxBudgetUsd: p.cfg.Budget.Cost, + MaxBudgetUsd: maxBudget, PermissionMode: mode, Resume: resume, ApprovalMode: approvalMode, diff --git a/pkg/ai/provider/claudeagent/runner.go b/pkg/ai/provider/claudeagent/runner.go index 67eccf7..0578d25 100644 --- a/pkg/ai/provider/claudeagent/runner.go +++ b/pkg/ai/provider/claudeagent/runner.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" _ "embed" "encoding/hex" + "encoding/json" "fmt" "os" "os/exec" @@ -63,15 +64,23 @@ func contentHash(data []byte) string { return hex.EncodeToString(h[:]) } -// ensureDependencies installs node_modules the first time (the SDK package -// missing is the signal). It fails loudly when npm is unavailable rather than -// silently degrading — the provider cannot run without the SDK. +// ensureDependencies installs the pinned SDK when it is missing or its version +// differs. It fails loudly when npm is unavailable because the provider cannot +// run without the exact bridge contract declared in package.json. func ensureDependencies(agentDir string) error { - sdkDir := filepath.Join(agentDir, "node_modules", "@anthropic-ai", "claude-agent-sdk") - if _, err := os.Stat(sdkDir); err == nil { - return nil + requiredVersion, err := requiredSDKVersion() + if err != nil { + return err + } + sdkPackage := filepath.Join(agentDir, "node_modules", "@anthropic-ai", "claude-agent-sdk", "package.json") + if data, err := os.ReadFile(sdkPackage); err == nil { + var installed struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &installed) == nil && installed.Version == requiredVersion { + return nil + } } - npmPath, err := exec.LookPath("npm") if err != nil { return fmt.Errorf("npm not found in PATH (required to install the Claude Agent SDK): %w", err) @@ -87,6 +96,20 @@ func ensureDependencies(agentDir string) error { return nil } +func requiredSDKVersion() (string, error) { + var manifest struct { + Dependencies map[string]string `json:"dependencies"` + } + if err := json.Unmarshal([]byte(agentPackageJSON), &manifest); err != nil { + return "", fmt.Errorf("parse embedded Claude Agent package manifest: %w", err) + } + version := strings.TrimSpace(manifest.Dependencies["@anthropic-ai/claude-agent-sdk"]) + if version == "" { + return "", fmt.Errorf("embedded Claude Agent package manifest does not pin @anthropic-ai/claude-agent-sdk") + } + return version, nil +} + // findTsx resolves the tsx runner, preferring the version installed into the // agent dir's node_modules over a global one. func findTsx(agentDir string) (string, error) { diff --git a/pkg/ai/provider/claudeagent/turn.go b/pkg/ai/provider/claudeagent/turn.go index 624b261..79e58a8 100644 --- a/pkg/ai/provider/claudeagent/turn.go +++ b/pkg/ai/provider/claudeagent/turn.go @@ -2,8 +2,10 @@ package claudeagent import ( "context" + "encoding/base64" "encoding/json" "fmt" + "os" "time" "github.com/flanksource/captain/pkg/ai" @@ -32,13 +34,44 @@ type turnState struct { } type promptParams struct { - Text string `json:"text"` + Text string `json:"text"` + Attachments []promptAttachment `json:"attachments,omitempty"` +} + +type promptAttachment struct { + MediaType string `json:"mediaType"` + Data string `json:"data"` + Filename string `json:"filename,omitempty"` } func composePrompt(req ai.Request) string { return req.Prompt.User } +func buildPromptParams(req ai.Request) (promptParams, error) { + params := promptParams{Text: composePrompt(req), Attachments: make([]promptAttachment, 0, len(req.Prompt.Attachments))} + for i, attachment := range req.Prompt.Attachments { + content, ok := attachment.PreparedContent() + if !ok { + return promptParams{}, fmt.Errorf("attachment %d (%s) is not prepared", i+1, attachment.ID) + } + data := content.Bytes + if data == nil && content.Path != "" { + var err error + data, err = os.ReadFile(content.Path) + if err != nil { + return promptParams{}, fmt.Errorf("read prepared attachment %s: %w", attachment.ID, err) + } + } + params.Attachments = append(params.Attachments, promptAttachment{ + MediaType: attachment.MediaType, + Data: base64.StdEncoding.EncodeToString(data), + Filename: attachment.Filename, + }) + } + return params, nil +} + // runTurn owns one turn's event channel: it sends the prompt, forwards mapped // notifications, and emits the terminal result (or a loud error on cancellation // / mid-turn process exit). @@ -62,7 +95,16 @@ func (p *Provider) runTurn(ctx context.Context, req ai.Request, events chan ai.E p.clearActive() }() - if _, err := p.rpc.Call(ctx, methodPrompt, promptParams{Text: composePrompt(req)}); err != nil { + if err := ai.ValidateAttachmentCompatibility([]api.Model{{Name: p.model, Backend: api.BackendClaudeAgent}}, req.Prompt.Attachments); err != nil { + emit(ctx, events, ai.Event{Kind: ai.EventError, Error: err.Error(), Model: p.model}) + return + } + params, err := buildPromptParams(req) + if err != nil { + emit(ctx, events, ai.Event{Kind: ai.EventError, Error: err.Error(), Model: p.model}) + return + } + if _, err := p.rpc.Call(ctx, methodPrompt, params); err != nil { emit(ctx, events, ai.Event{Kind: ai.EventError, Error: fmt.Sprintf("claude-agent prompt failed: %v", err), Model: p.model}) return } diff --git a/pkg/ai/provider/genkit/attachments_ginkgo_test.go b/pkg/ai/provider/genkit/attachments_ginkgo_test.go new file mode 100644 index 0000000..4640bc7 --- /dev/null +++ b/pkg/ai/provider/genkit/attachments_ginkgo_test.go @@ -0,0 +1,45 @@ +package genkit + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" +) + +var _ = Describe("promptParts", func() { + It("preserves text followed by ordered prepared media parts", func() { + first := api.AttachmentRef{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "image/png"}. + WithPreparedContent(api.AttachmentContent{Bytes: []byte("first")}) + second := api.AttachmentRef{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "application/pdf"}. + WithPreparedContent(api.AttachmentContent{Bytes: []byte("second")}) + + parts, err := promptParts(ai.Request{Prompt: api.Prompt{User: "compare", Attachments: []api.AttachmentRef{first, second}}}) + Expect(err).NotTo(HaveOccurred()) + Expect(parts).To(HaveLen(3)) + Expect(parts[0].Kind).To(Equal(gkai.PartText)) + Expect(parts[0].Text).To(Equal("compare")) + Expect(parts[1].ContentType).To(Equal("image/png")) + Expect(parts[1].Text).To(Equal("data:image/png;base64,Zmlyc3Q=")) + Expect(parts[2].ContentType).To(Equal("application/pdf")) + Expect(parts[2].Text).To(Equal("data:application/pdf;base64,c2Vjb25k")) + }) + + It("supports a file-only prompt", func() { + attachment := api.AttachmentRef{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "image/png"}. + WithPreparedContent(api.AttachmentContent{Bytes: []byte("image")}) + parts, err := promptParts(ai.Request{Prompt: api.Prompt{Attachments: []api.AttachmentRef{attachment}}}) + Expect(err).NotTo(HaveOccurred()) + Expect(parts).To(HaveLen(1)) + Expect(parts[0].Kind).To(Equal(gkai.PartMedia)) + }) + + It("fails loudly when resolution was skipped", func() { + attachment := api.AttachmentRef{ID: api.AttachmentIDPrefix + string(make([]byte, 64)), MediaType: "image/png"} + _, err := promptParts(ai.Request{Prompt: api.Prompt{Attachments: []api.AttachmentRef{attachment}}}) + Expect(err).To(MatchError(ContainSubstring("is not prepared"))) + }) +}) diff --git a/pkg/ai/provider/genkit/attachments_suite_test.go b/pkg/ai/provider/genkit/attachments_suite_test.go new file mode 100644 index 0000000..66ba0ba --- /dev/null +++ b/pkg/ai/provider/genkit/attachments_suite_test.go @@ -0,0 +1,13 @@ +package genkit + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAttachments(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Genkit Attachments Suite") +} diff --git a/pkg/ai/provider/genkit/genkit.go b/pkg/ai/provider/genkit/genkit.go index 4c0b829..cf290c4 100644 --- a/pkg/ai/provider/genkit/genkit.go +++ b/pkg/ai/provider/genkit/genkit.go @@ -123,6 +123,7 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e } if cost := p.costUSD(out.Usage); cost > 0 { + out.CostUSD = cost log.Debugf("genkit %s cost: $%.6f (model=%s)", p.backend, cost, p.cfg.Model.Name) } @@ -169,7 +170,7 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai ch <- ai.Event{Kind: ai.EventError, Error: err.Error(), Model: p.cfg.Model.Name} return } - usage := mapUsage(resp.Usage) + usage := mapUsage(resp.Usage, p.backend) ch <- ai.Event{ Kind: ai.EventResult, Success: true, diff --git a/pkg/ai/provider/genkit/genkit_test.go b/pkg/ai/provider/genkit/genkit_test.go index 2a59823..4441f29 100644 --- a/pkg/ai/provider/genkit/genkit_test.go +++ b/pkg/ai/provider/genkit/genkit_test.go @@ -13,22 +13,54 @@ import ( ) func TestMapUsage(t *testing.T) { - got := mapUsage(&gkai.GenerationUsage{ + // genkit reports overlapping buckets differently per backend; mapUsage must + // normalize to captain's disjoint contract (Input excludes cache, Output + // excludes reasoning). + raw := &gkai.GenerationUsage{ InputTokens: 120, OutputTokens: 45, ThoughtsTokens: 30, CachedContentTokens: 17, TotalTokens: 212, - }) + } + // Anthropic: input_tokens already excludes cache and there is no reasoning + // fold, so both buckets pass through unchanged. assert.Equal(t, ai.Usage{ InputTokens: 120, OutputTokens: 45, - ReasoningTokens: 30, // ThoughtsTokens -> ReasoningTokens - CacheReadTokens: 17, // CachedContentTokens -> CacheReadTokens - }, got) + ReasoningTokens: 30, + CacheReadTokens: 17, + }, mapUsage(raw, ai.BackendAnthropic)) + + // Gemini: PromptTokenCount folds in cache → net input; CandidatesTokenCount + // excludes thoughts → output passes through. + assert.Equal(t, ai.Usage{ + InputTokens: 103, // 120 - 17 + OutputTokens: 45, + ReasoningTokens: 30, + CacheReadTokens: 17, + }, mapUsage(raw, ai.BackendGemini)) + + // OpenAI/DeepSeek (compat_oai): prompt_tokens folds in cache AND + // completion_tokens folds in reasoning → net both. + openaiWant := ai.Usage{ + InputTokens: 103, // 120 - 17 + OutputTokens: 15, // 45 - 30 + ReasoningTokens: 30, + CacheReadTokens: 17, + } + assert.Equal(t, openaiWant, mapUsage(raw, ai.BackendOpenAI)) + assert.Equal(t, openaiWant, mapUsage(raw, ai.BackendDeepSeek)) + + // Disjoint invariant: for cache-folding backends InputTokens no longer + // overlaps CacheReadTokens, so pricing cannot bill the cached prefix twice. + for _, backend := range []ai.Backend{ai.BackendGemini, ai.BackendOpenAI, ai.BackendDeepSeek} { + got := mapUsage(raw, backend) + assert.Equal(t, raw.InputTokens, got.InputTokens+got.CacheReadTokens, "backend %s input+cache", backend) + } - assert.Equal(t, ai.Usage{}, mapUsage(nil)) + assert.Equal(t, ai.Usage{}, mapUsage(nil, ai.BackendAnthropic)) } func TestModelRef(t *testing.T) { diff --git a/pkg/ai/provider/genkit/mapping.go b/pkg/ai/provider/genkit/mapping.go index deda376..920239c 100644 --- a/pkg/ai/provider/genkit/mapping.go +++ b/pkg/ai/provider/genkit/mapping.go @@ -56,16 +56,30 @@ func toInputMap(v any) map[string]any { return m } -// mapUsage maps genkit's GenerationUsage onto captain's Usage. Genkit reports -// reasoning via ThoughtsTokens and cache reads via CachedContentTokens; it does -// not expose cache writes. -func mapUsage(u *gkai.GenerationUsage) ai.Usage { +// mapUsage maps genkit's GenerationUsage onto captain's disjoint-bucket Usage. +// genkit folds cache reads into InputTokens for Gemini and the OpenAI-compatible +// backends (OpenAI/DeepSeek), and folds reasoning into OutputTokens for the +// OpenAI-compatible backends; Anthropic reports InputTokens already net of cache, +// and both Anthropic and Gemini report OutputTokens without reasoning. Normalize +// to the disjoint contract so the pricing registry and TotalTokens do not +// double-count. genkit's GenerationUsage has no cache-write field, so +// CacheWriteTokens is always zero here — cache-write spend on the API path is +// invisible upstream (finding C4). +func mapUsage(u *gkai.GenerationUsage, backend ai.Backend) ai.Usage { if u == nil { return ai.Usage{} } + input := u.InputTokens + if backend != ai.BackendAnthropic { + input = ai.NetInputTokens(u.InputTokens, u.CachedContentTokens) + } + output := u.OutputTokens + if backend == ai.BackendOpenAI || backend == ai.BackendDeepSeek { + output = ai.NetOutputTokens(u.OutputTokens, u.ThoughtsTokens) + } return ai.Usage{ - InputTokens: u.InputTokens, - OutputTokens: u.OutputTokens, + InputTokens: input, + OutputTokens: output, ReasoningTokens: u.ThoughtsTokens, CacheReadTokens: u.CachedContentTokens, } @@ -78,7 +92,7 @@ func responseToResponse(resp *gkai.ModelResponse, backend ai.Backend, model stri Text: resp.Text(), Model: model, Backend: backend, - Usage: mapUsage(resp.Usage), + Usage: mapUsage(resp.Usage, backend), Duration: time.Since(start), Raw: resp, } diff --git a/pkg/ai/provider/genkit/options.go b/pkg/ai/provider/genkit/options.go index 3eaac67..3788bec 100644 --- a/pkg/ai/provider/genkit/options.go +++ b/pkg/ai/provider/genkit/options.go @@ -1,11 +1,14 @@ package genkit import ( + "encoding/base64" "encoding/json" "fmt" + "os" "strings" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" gkai "github.com/firebase/genkit/go/ai" ) @@ -58,7 +61,18 @@ func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallbac if req.Prompt.System != "" { opts = append(opts, gkai.WithSystem(req.Prompt.System)) } - opts = append(opts, gkai.WithPrompt(req.Prompt.User)) + if len(req.Prompt.Attachments) == 0 { + opts = append(opts, gkai.WithPrompt(req.Prompt.User)) + } else { + if err := ai.ValidateAttachmentCompatibility([]api.Model{req.Model}, req.Prompt.Attachments); err != nil { + return nil, err + } + parts, err := promptParts(req) + if err != nil { + return nil, err + } + opts = append(opts, gkai.WithMessages(gkai.NewUserMessage(parts...))) + } modelToken := req.Name if modelToken == "" { @@ -88,6 +102,30 @@ func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallbac return opts, nil } +func promptParts(req ai.Request) ([]*gkai.Part, error) { + parts := make([]*gkai.Part, 0, len(req.Prompt.Attachments)+1) + if req.Prompt.User != "" { + parts = append(parts, gkai.NewTextPart(req.Prompt.User)) + } + for i, attachment := range req.Prompt.Attachments { + content, ok := attachment.PreparedContent() + if !ok { + return nil, fmt.Errorf("attachment %d (%s) is not prepared", i+1, attachment.ID) + } + data := content.Bytes + if data == nil && content.Path != "" { + var err error + data, err = os.ReadFile(content.Path) + if err != nil { + return nil, fmt.Errorf("read prepared attachment %s: %w", attachment.ID, err) + } + } + uri := "data:" + attachment.MediaType + ";base64," + base64.StdEncoding.EncodeToString(data) + parts = append(parts, gkai.NewMediaPart(attachment.MediaType, uri)) + } + return parts, nil +} + // backendOutputSchema resolves schemas for native providers whose supported // JSON Schema subset differs from Captain's caller-facing schema. The bool is // false for backends that should retain Genkit's existing WithOutputType or raw diff --git a/pkg/api/attachment.go b/pkg/api/attachment.go new file mode 100644 index 0000000..3bcc4e1 --- /dev/null +++ b/pkg/api/attachment.go @@ -0,0 +1,97 @@ +package api + +import ( + "encoding/hex" + "fmt" + "net/url" + "strings" +) + +const AttachmentIDPrefix = "sha256:" + +// AttachmentRef is the serializable reference to one multimodal prompt input. +// Exactly one source is present before resolution; resolved references use ID. +type AttachmentRef struct { + ID string `json:"id,omitempty" yaml:"id,omitempty" pretty:"label=ID"` + Path string `json:"path,omitempty" yaml:"path,omitempty" pretty:"label=Path"` + URL string `json:"url,omitempty" yaml:"url,omitempty" pretty:"label=URL"` + Filename string `json:"filename,omitempty" yaml:"filename,omitempty" pretty:"label=Filename"` + MediaType string `json:"mediaType,omitempty" yaml:"mediaType,omitempty" pretty:"label=Media Type"` + Size int64 `json:"size,omitempty" yaml:"size,omitempty" pretty:"label=Size"` + SHA256 string `json:"sha256,omitempty" yaml:"sha256,omitempty" pretty:"label=SHA-256"` + + preparedBytes []byte + preparedPath string +} + +// AttachmentContent is runtime-only immutable attachment content. +type AttachmentContent struct { + Bytes []byte + Path string +} + +func (a AttachmentRef) Validate() error { + sources := 0 + for _, source := range []string{a.ID, a.Path, a.URL} { + if strings.TrimSpace(source) != "" { + sources++ + } + } + if sources != 1 { + return fmt.Errorf("attachment requires exactly one source: id, path, or url") + } + if a.ID != "" { + if err := validateAttachmentID(a.ID); err != nil { + return err + } + } + if a.URL != "" { + u, err := url.Parse(a.URL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("attachment url must use http or https: %q", a.URL) + } + } + if a.Size < 0 { + return fmt.Errorf("attachment size cannot be negative") + } + if a.SHA256 != "" { + if err := validateSHA256(a.SHA256); err != nil { + return err + } + } + return nil +} + +func (a AttachmentRef) IsPrepared() bool { + return a.ID != "" && (len(a.preparedBytes) > 0 || a.preparedPath != "") +} + +func (a AttachmentRef) PreparedContent() (AttachmentContent, bool) { + if !a.IsPrepared() { + return AttachmentContent{}, false + } + return AttachmentContent{Bytes: a.preparedBytes, Path: a.preparedPath}, true +} + +func (a AttachmentRef) WithPreparedContent(content AttachmentContent) AttachmentRef { + a.preparedBytes = content.Bytes + a.preparedPath = content.Path + return a +} + +func validateAttachmentID(id string) error { + if !strings.HasPrefix(id, AttachmentIDPrefix) { + return fmt.Errorf("attachment id must start with %q", AttachmentIDPrefix) + } + return validateSHA256(strings.TrimPrefix(id, AttachmentIDPrefix)) +} + +func validateSHA256(value string) error { + if len(value) != 64 { + return fmt.Errorf("attachment sha256 must contain 64 hexadecimal characters") + } + if _, err := hex.DecodeString(value); err != nil { + return fmt.Errorf("attachment sha256 is invalid: %w", err) + } + return nil +} diff --git a/pkg/api/attachment_ginkgo_test.go b/pkg/api/attachment_ginkgo_test.go new file mode 100644 index 0000000..fcfc470 --- /dev/null +++ b/pkg/api/attachment_ginkgo_test.go @@ -0,0 +1,44 @@ +package api_test + +import ( + "strings" + + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AttachmentRef", func() { + It("requires exactly one source", func() { + ref := api.AttachmentRef{Path: "invoice.pdf", URL: "https://example.com/invoice.pdf"} + Expect(ref.Validate()).To(MatchError(ContainSubstring("exactly one source"))) + }) + + It("accepts a durable attachment id", func() { + ref := api.AttachmentRef{ID: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"} + Expect(ref.Validate()).To(Succeed()) + }) +}) + +var _ = Describe("Prompt attachment validation", func() { + It("accepts an attachment-only prompt", func() { + prompt := api.Prompt{Attachments: []api.AttachmentRef{{Path: "invoice.pdf"}}} + Expect(prompt.Validate()).To(Succeed()) + }) + + It("does not classify an attachment-only verification request as verify-only", func() { + spec := api.Spec{ + Prompt: api.Prompt{Attachments: []api.AttachmentRef{{Path: "invoice.pdf"}}}, + Workflow: &api.Workflow{Verify: &api.Verify{}}, + } + Expect(spec.IsVerifyOnly()).To(BeFalse()) + }) + + It("includes durable attachment descriptors in the cache identity", func() { + first := api.Prompt{User: "compare", Attachments: []api.AttachmentRef{{ID: api.AttachmentIDPrefix + strings.Repeat("a", 64), MediaType: "image/png"}}} + second := api.Prompt{User: "compare", Attachments: []api.AttachmentRef{{ID: api.AttachmentIDPrefix + strings.Repeat("b", 64), MediaType: "image/png"}}} + Expect(first.CacheIdentity()).NotTo(Equal(second.CacheIdentity())) + Expect(first.CacheIdentity()).NotTo(ContainSubstring("base64")) + }) +}) diff --git a/pkg/api/attachment_suite_test.go b/pkg/api/attachment_suite_test.go new file mode 100644 index 0000000..96fb3c3 --- /dev/null +++ b/pkg/api/attachment_suite_test.go @@ -0,0 +1,13 @@ +package api_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAttachments(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "API Attachments Suite") +} diff --git a/pkg/api/prompt.go b/pkg/api/prompt.go index 5f2d128..bc93cc1 100644 --- a/pkg/api/prompt.go +++ b/pkg/api/prompt.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "strings" ) // Prompt is the instruction payload: the user prompt plus optional system @@ -37,16 +38,37 @@ type Prompt struct { SchemaStrictness SchemaStrictness `json:"schemaStrictness,omitempty" yaml:"schemaStrictness,omitempty" pretty:"-"` // Metadata is arbitrary caller metadata. (ai.Request.Metadata) Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty" pretty:"label=Metadata"` + // Attachments are ordered multimodal inputs sent with the user prompt. + Attachments []AttachmentRef `json:"attachments,omitempty" yaml:"attachments,omitempty" pretty:"label=Attachments"` +} + +func (p Prompt) CacheIdentity() string { + var identity strings.Builder + identity.WriteString(p.User) + for _, attachment := range p.Attachments { + identity.WriteByte('\n') + identity.WriteString(attachment.ID) + identity.WriteByte('|') + identity.WriteString(attachment.MediaType) + identity.WriteByte('|') + identity.WriteString(attachment.SHA256) + } + return identity.String() } // HasSchema reports whether the prompt requests structured output by either // mechanism (a reflected Go struct or a pre-built JSON schema). func (p Prompt) HasSchema() bool { return p.Schema != nil || len(p.SchemaJSON) > 0 } -// Validate requires a non-empty user prompt. +// Validate requires prompt text or at least one attachment. func (p Prompt) Validate() error { - if p.User == "" { - return fmt.Errorf("prompt text is required") + if p.User == "" && len(p.Attachments) == 0 { + return fmt.Errorf("prompt text is required when no attachment is supplied") + } + for i, attachment := range p.Attachments { + if err := attachment.Validate(); err != nil { + return fmt.Errorf("attachment %d: %w", i+1, err) + } } if err := p.SchemaStrictness.Validate(); err != nil { return err diff --git a/pkg/api/spec.go b/pkg/api/spec.go index de16722..313801a 100644 --- a/pkg/api/spec.go +++ b/pkg/api/spec.go @@ -66,7 +66,7 @@ func (s Spec) Validate() error { // verification — a verify-only run that skips generation and verifies the // current state (e.g. scoring already-committed work). func (s Spec) IsVerifyOnly() bool { - return s.Prompt.User == "" && s.Workflow != nil && s.Workflow.Verify != nil + return s.Prompt.User == "" && len(s.Prompt.Attachments) == 0 && s.Workflow != nil && s.Workflow.Verify != nil } func (s Spec) Cwd() string { diff --git a/pkg/attachments/attachments_suite_test.go b/pkg/attachments/attachments_suite_test.go new file mode 100644 index 0000000..5821f2f --- /dev/null +++ b/pkg/attachments/attachments_suite_test.go @@ -0,0 +1,13 @@ +package attachments_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAttachments(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Attachments Suite") +} diff --git a/pkg/attachments/gc.go b/pkg/attachments/gc.go new file mode 100644 index 0000000..1bc049c --- /dev/null +++ b/pkg/attachments/gc.go @@ -0,0 +1,68 @@ +package attachments + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/flanksource/captain/pkg/api" +) + +type GCResult struct { + RemovedIDs []string `json:"removedIds" pretty:"label=Attachments"` + RemovedBytes int64 `json:"removedBytes" pretty:"label=Bytes"` + DryRun bool `json:"dryRun" pretty:"label=Dry Run"` + RetentionDays int `json:"retentionDays" pretty:"label=Retention Days"` +} + +func (s *Store) GC(referenced map[string]struct{}, retention time.Duration, dryRun bool) (GCResult, error) { + if retention <= 0 { + return GCResult{}, fmt.Errorf("attachment retention must be positive") + } + result := GCResult{DryRun: dryRun, RetentionDays: int(retention.Hours() / 24)} + cutoff := time.Now().Add(-retention) + root := filepath.Join(s.directory, "sha256") + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".attachment-") { + return nil + } + id := api.AttachmentIDPrefix + entry.Name() + if _, ok := referenced[id]; ok { + return nil + } + ref := api.AttachmentRef{ID: id} + if ref.Validate() != nil { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.ModTime().Before(cutoff) { + return nil + } + result.RemovedIDs = append(result.RemovedIDs, id) + result.RemovedBytes += info.Size() + if !dryRun { + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove attachment %s: %w", id, err) + } + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return GCResult{}, fmt.Errorf("scan attachment store: %w", err) + } + sort.Strings(result.RemovedIDs) + return result, nil +} diff --git a/pkg/attachments/limits.go b/pkg/attachments/limits.go new file mode 100644 index 0000000..ec7c2c0 --- /dev/null +++ b/pkg/attachments/limits.go @@ -0,0 +1,48 @@ +package attachments + +const ( + DefaultMaxFileBytes int64 = 20 << 20 + DefaultMaxRequestBytes int64 = 50 << 20 + DefaultMaxFiles = 10 +) + +type Limits struct { + MaxFileBytes int64 + MaxRequestBytes int64 + MaxFiles int +} + +func DefaultLimits() Limits { + return Limits{ + MaxFileBytes: DefaultMaxFileBytes, + MaxRequestBytes: DefaultMaxRequestBytes, + MaxFiles: DefaultMaxFiles, + } +} + +func (l Limits) withDefaults() Limits { + defaults := DefaultLimits() + if l.MaxFileBytes == 0 { + l.MaxFileBytes = defaults.MaxFileBytes + } + if l.MaxRequestBytes == 0 { + l.MaxRequestBytes = defaults.MaxRequestBytes + } + if l.MaxFiles == 0 { + l.MaxFiles = defaults.MaxFiles + } + return l +} + +func (l Limits) validate() error { + if l.MaxFileBytes < 1 { + return invalidLimit("max file bytes", l.MaxFileBytes) + } + if l.MaxRequestBytes < 1 { + return invalidLimit("max request bytes", l.MaxRequestBytes) + } + if l.MaxFiles < 1 { + return invalidLimit("max files", l.MaxFiles) + } + return nil +} diff --git a/pkg/attachments/resolve.go b/pkg/attachments/resolve.go new file mode 100644 index 0000000..0640dcf --- /dev/null +++ b/pkg/attachments/resolve.go @@ -0,0 +1,127 @@ +package attachments + +import ( + "context" + "fmt" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/api" +) + +func (s *Store) Resolve(ctx context.Context, refs []api.AttachmentRef, baseDir string) ([]api.AttachmentRef, error) { + if len(refs) > s.limits.MaxFiles { + return nil, fmt.Errorf("attachment request has %d files and exceeds %d file limit", len(refs), s.limits.MaxFiles) + } + resolved := make([]api.AttachmentRef, 0, len(refs)) + var total int64 + for i, ref := range refs { + if err := ref.Validate(); err != nil { + return nil, fmt.Errorf("attachment %d: %w", i+1, err) + } + prepared, err := s.resolveOne(ctx, ref, baseDir) + if err != nil { + return nil, fmt.Errorf("attachment %d: %w", i+1, err) + } + total += prepared.Size + if total > s.limits.MaxRequestBytes { + return nil, fmt.Errorf("attachments total %d bytes exceeds %d byte request limit", total, s.limits.MaxRequestBytes) + } + resolved = append(resolved, prepared) + } + return resolved, nil +} + +func (s *Store) resolveOne(ctx context.Context, ref api.AttachmentRef, baseDir string) (api.AttachmentRef, error) { + content, filename, err := s.readSource(ctx, ref, baseDir) + if err != nil { + return api.AttachmentRef{}, err + } + mediaType := detectMediaType(content, filename) + if ref.MediaType != "" && canonicalMediaType(ref.MediaType) != mediaType { + return api.AttachmentRef{}, fmt.Errorf("declared media type %s does not match detected %s", ref.MediaType, mediaType) + } + id, path, err := s.persist(content) + if err != nil { + return api.AttachmentRef{}, err + } + digest := strings.TrimPrefix(id, api.AttachmentIDPrefix) + if ref.SHA256 != "" && !strings.EqualFold(ref.SHA256, digest) { + return api.AttachmentRef{}, fmt.Errorf("declared sha256 %s does not match content %s", ref.SHA256, digest) + } + if ref.Filename != "" { + filename = ref.Filename + } + return api.AttachmentRef{ + ID: id, + Filename: filename, + MediaType: mediaType, + Size: int64(len(content)), + SHA256: digest, + }.WithPreparedContent(api.AttachmentContent{Bytes: content, Path: path}), nil +} + +func (s *Store) readSource(ctx context.Context, ref api.AttachmentRef, baseDir string) ([]byte, string, error) { + switch { + case ref.ID != "": + file, err := s.Open(ref.ID) + if err != nil { + return nil, "", err + } + defer file.Close() + content, err := readLimited(file, s.limits.MaxFileBytes) + return content, ref.Filename, err + case ref.Path != "": + path := ref.Path + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + file, err := os.Open(path) + if err != nil { + return nil, "", fmt.Errorf("open attachment path %s: %w", path, err) + } + defer file.Close() + content, err := readLimited(file, s.limits.MaxFileBytes) + return content, filepath.Base(path), err + case ref.URL != "": + request, err := http.NewRequestWithContext(ctx, http.MethodGet, ref.URL, nil) + if err != nil { + return nil, "", fmt.Errorf("create attachment request: %w", err) + } + response, err := s.httpClient.Do(request) + if err != nil { + return nil, "", fmt.Errorf("download attachment: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, "", fmt.Errorf("download attachment: unexpected HTTP status %s", response.Status) + } + content, err := readLimited(response.Body, s.limits.MaxFileBytes) + parsed, _ := url.Parse(ref.URL) + return content, filepath.Base(parsed.Path), err + default: + return nil, "", fmt.Errorf("attachment source is required") + } +} + +func detectMediaType(content []byte, filename string) string { + mediaType := canonicalMediaType(http.DetectContentType(content)) + if mediaType == "application/octet-stream" { + if extensionType := canonicalMediaType(mime.TypeByExtension(filepath.Ext(filename))); extensionType != "" { + return extensionType + } + } + return mediaType +} + +func canonicalMediaType(value string) string { + mediaType, _, err := mime.ParseMediaType(value) + if err != nil { + return strings.ToLower(strings.TrimSpace(value)) + } + return strings.ToLower(mediaType) +} diff --git a/pkg/attachments/store.go b/pkg/attachments/store.go new file mode 100644 index 0000000..e569451 --- /dev/null +++ b/pkg/attachments/store.go @@ -0,0 +1,165 @@ +package attachments + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/flanksource/captain/pkg/api" +) + +type StoreOptions struct { + Directory string + Limits Limits + HTTPClient *http.Client +} + +type Store struct { + directory string + limits Limits + httpClient *http.Client +} + +func NewStore(opts StoreOptions) (*Store, error) { + if strings.TrimSpace(opts.Directory) == "" { + return nil, errors.New("attachment store directory is required") + } + limits := opts.Limits.withDefaults() + if err := limits.validate(); err != nil { + return nil, err + } + directory, err := filepath.Abs(opts.Directory) + if err != nil { + return nil, fmt.Errorf("resolve attachment store directory: %w", err) + } + if err := ensurePrivateDirectory(directory); err != nil { + return nil, err + } + client := opts.HTTPClient + if client == nil { + client = &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(_ *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("attachment download exceeded 5 redirects") + } + return nil + }, + } + } + return &Store{directory: directory, limits: limits, httpClient: client}, nil +} + +func (s *Store) Limits() Limits { return s.limits } + +func (s *Store) Directory() string { return s.directory } + +func (s *Store) Put(reader io.Reader, filename, declaredMediaType string) (api.AttachmentRef, error) { + content, err := readLimited(reader, s.limits.MaxFileBytes) + if err != nil { + return api.AttachmentRef{}, err + } + mediaType := detectMediaType(content, filename) + if declaredMediaType != "" && canonicalMediaType(declaredMediaType) != "application/octet-stream" && canonicalMediaType(declaredMediaType) != mediaType { + return api.AttachmentRef{}, fmt.Errorf("declared media type %s does not match detected %s", declaredMediaType, mediaType) + } + id, path, err := s.persist(content) + if err != nil { + return api.AttachmentRef{}, err + } + digest := strings.TrimPrefix(id, api.AttachmentIDPrefix) + return api.AttachmentRef{ + ID: id, Filename: filename, MediaType: mediaType, + Size: int64(len(content)), SHA256: digest, + }.WithPreparedContent(api.AttachmentContent{Bytes: content, Path: path}), nil +} + +func (s *Store) Path(id string) string { + digest := strings.TrimPrefix(id, api.AttachmentIDPrefix) + if len(digest) < 2 { + return "" + } + return filepath.Join(s.directory, "sha256", digest[:2], digest) +} + +func (s *Store) Open(id string) (*os.File, error) { + ref := api.AttachmentRef{ID: id} + if err := ref.Validate(); err != nil { + return nil, err + } + file, err := os.Open(s.Path(id)) + if err != nil { + return nil, fmt.Errorf("open attachment %s: %w", id, err) + } + return file, nil +} + +func (s *Store) persist(content []byte) (string, string, error) { + digest := fmt.Sprintf("%x", sha256.Sum256(content)) + id := api.AttachmentIDPrefix + digest + path := s.Path(id) + if info, err := os.Stat(path); err == nil { + if !info.Mode().IsRegular() { + return "", "", fmt.Errorf("attachment path %s is not a regular file", path) + } + return id, path, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", "", fmt.Errorf("inspect attachment path %s: %w", path, err) + } + dir := filepath.Dir(path) + if err := ensurePrivateDirectory(dir); err != nil { + return "", "", err + } + tmp, err := os.CreateTemp(dir, ".attachment-*") + if err != nil { + return "", "", fmt.Errorf("create attachment temp file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return "", "", fmt.Errorf("set attachment permissions: %w", err) + } + if _, err := tmp.Write(content); err != nil { + _ = tmp.Close() + return "", "", fmt.Errorf("write attachment: %w", err) + } + if err := tmp.Close(); err != nil { + return "", "", fmt.Errorf("close attachment: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return "", "", fmt.Errorf("publish attachment: %w", err) + } + return id, path, nil +} + +func ensurePrivateDirectory(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return fmt.Errorf("create attachment directory %s: %w", path, err) + } + if err := os.Chmod(path, 0o700); err != nil { + return fmt.Errorf("set attachment directory permissions %s: %w", path, err) + } + return nil +} + +func readLimited(reader io.Reader, limit int64) ([]byte, error) { + content, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > limit { + return nil, fmt.Errorf("attachment exceeds %d byte file limit", limit) + } + return content, nil +} + +func invalidLimit(name string, value any) error { + return fmt.Errorf("attachment %s must be positive, got %v", name, value) +} diff --git a/pkg/attachments/store_ginkgo_test.go b/pkg/attachments/store_ginkgo_test.go new file mode 100644 index 0000000..7086644 --- /dev/null +++ b/pkg/attachments/store_ginkgo_test.go @@ -0,0 +1,103 @@ +package attachments_test + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/attachments" +) + +var _ = Describe("Store", func() { + It("resolves a local file into an immutable content-addressed attachment", func() { + root := GinkgoT().TempDir() + input := filepath.Join(root, "diagram.png") + content := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 512)...) + Expect(os.WriteFile(input, content, 0o600)).To(Succeed()) + + store, err := attachments.NewStore(attachments.StoreOptions{Directory: filepath.Join(root, ".captain", "attachments")}) + Expect(err).NotTo(HaveOccurred()) + resolved, err := store.Resolve(context.Background(), []api.AttachmentRef{{Path: input}}, "") + Expect(err).NotTo(HaveOccurred()) + Expect(resolved).To(HaveLen(1)) + + digest := fmt.Sprintf("%x", sha256.Sum256(content)) + Expect(resolved[0].ID).To(Equal(api.AttachmentIDPrefix + digest)) + Expect(resolved[0].Filename).To(Equal("diagram.png")) + Expect(resolved[0].MediaType).To(Equal("image/png")) + Expect(resolved[0].Size).To(Equal(int64(len(content)))) + Expect(resolved[0].SHA256).To(Equal(digest)) + prepared, ok := resolved[0].PreparedContent() + Expect(ok).To(BeTrue()) + Expect(prepared.Bytes).To(Equal(content)) + Expect(prepared.Path).To(Equal(store.Path(resolved[0].ID))) + + info, err := os.Stat(store.Path(resolved[0].ID)) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + }) + + It("rejects declared media types that disagree with detected content", func() { + root := GinkgoT().TempDir() + input := filepath.Join(root, "invoice.pdf") + Expect(os.WriteFile(input, []byte("plain text, not a PDF"), 0o600)).To(Succeed()) + store, err := attachments.NewStore(attachments.StoreOptions{Directory: filepath.Join(root, "store")}) + Expect(err).NotTo(HaveOccurred()) + + _, err = store.Resolve(context.Background(), []api.AttachmentRef{{Path: input, MediaType: "application/pdf"}}, "") + Expect(err).To(MatchError(ContainSubstring("declared media type application/pdf does not match detected text/plain"))) + }) + + It("enforces file, request, and count limits before execution", func() { + root := GinkgoT().TempDir() + input := filepath.Join(root, "large.txt") + Expect(os.WriteFile(input, []byte("12345"), 0o600)).To(Succeed()) + store, err := attachments.NewStore(attachments.StoreOptions{ + Directory: filepath.Join(root, "store"), + Limits: attachments.Limits{ + MaxFileBytes: 4, + MaxRequestBytes: 8, + MaxFiles: 1, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = store.Resolve(context.Background(), []api.AttachmentRef{{Path: input}}, "") + Expect(err).To(MatchError(ContainSubstring("exceeds 4 byte file limit"))) + _, err = store.Resolve(context.Background(), []api.AttachmentRef{{Path: input}, {Path: input}}, "") + Expect(err).To(MatchError(ContainSubstring("exceeds 1 file limit"))) + }) + + It("garbage-collects only old unreferenced blobs and supports dry-run", func() { + root := GinkgoT().TempDir() + store, err := attachments.NewStore(attachments.StoreOptions{Directory: filepath.Join(root, "store")}) + Expect(err).NotTo(HaveOccurred()) + kept, err := store.Put(strings.NewReader("keep"), "keep.txt", "text/plain") + Expect(err).NotTo(HaveOccurred()) + removed, err := store.Put(strings.NewReader("remove"), "remove.txt", "text/plain") + Expect(err).NotTo(HaveOccurred()) + old := time.Now().Add(-31 * 24 * time.Hour) + Expect(os.Chtimes(store.Path(kept.ID), old, old)).To(Succeed()) + Expect(os.Chtimes(store.Path(removed.ID), old, old)).To(Succeed()) + + dryRun, err := store.GC(map[string]struct{}{kept.ID: {}}, 30*24*time.Hour, true) + Expect(err).NotTo(HaveOccurred()) + Expect(dryRun.RemovedIDs).To(Equal([]string{removed.ID})) + _, err = os.Stat(store.Path(removed.ID)) + Expect(err).NotTo(HaveOccurred()) + + result, err := store.GC(map[string]struct{}{kept.ID: {}}, 30*24*time.Hour, false) + Expect(err).NotTo(HaveOccurred()) + Expect(result.RemovedIDs).To(Equal([]string{removed.ID})) + Expect(store.Path(kept.ID)).To(BeAnExistingFile()) + Expect(store.Path(removed.ID)).NotTo(BeAnExistingFile()) + }) +}) diff --git a/pkg/captainconfig/config.go b/pkg/captainconfig/config.go index c23f355..6cd7c20 100644 --- a/pkg/captainconfig/config.go +++ b/pkg/captainconfig/config.go @@ -16,8 +16,36 @@ import ( ) type Config struct { - AI AIDefaults `yaml:"ai"` - Prompts PromptDefaults `yaml:"prompts"` + AI AIDefaults `yaml:"ai"` + Prompts PromptDefaults `yaml:"prompts"` + Attachments AttachmentDefaults `yaml:"attachments"` +} + +type AttachmentDefaults struct { + Directory string `yaml:"directory,omitempty"` + MaxFileBytes int64 `yaml:"maxFileBytes,omitempty"` + MaxRequestBytes int64 `yaml:"maxRequestBytes,omitempty"` + MaxFiles int `yaml:"maxFiles,omitempty"` + Retention string `yaml:"retention,omitempty"` +} + +func (a AttachmentDefaults) WithDefaults() AttachmentDefaults { + if a.Directory == "" { + a.Directory = ".captain/attachments" + } + if a.MaxFileBytes == 0 { + a.MaxFileBytes = 20 << 20 + } + if a.MaxRequestBytes == 0 { + a.MaxRequestBytes = 50 << 20 + } + if a.MaxFiles == 0 { + a.MaxFiles = 10 + } + if a.Retention == "" { + a.Retention = "30d" + } + return a } type AIDefaults struct { diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index bc225f7..96bcc20 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -175,6 +175,7 @@ type AIPromptOptions struct { 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"` + Attach []string `flag:"attach" help:"Attach a local path or URL (repeatable; RFC 4180 comma-separated values allowed)" short:"A"` MultiModels []string `flag:"multi-models" help:"Run prompt once per runtime selector in parallel, e.g. cli:sonnet-5,cmux:opus (repeatable; comma-separated allowed)" short:"M"` Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"` @@ -232,6 +233,17 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt maxTokens = 4096 } + // Resolve the USD budget onto the request (flag > saved) so backends that read + // req.Budget.Cost — claude-cli, claude-agent — enforce it without relying on a + // later config-side reconciliation that not every path performs (finding A4). + budget, err := parseFloatFlag("budget", o.Budget) + if err != nil { + return ai.Request{}, err + } + if budget == 0 { + budget = saved.BudgetUSD + } + effort := o.Effort if effort == "" { effort = saved.ReasoningEffort @@ -261,7 +273,7 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt return ai.Request{ Prompt: api.Prompt{System: systemPrompt, AppendSystem: appendSystemPrompt, User: userPrompt}, Model: api.Model{Temperature: temperaturePtr, Effort: api.Effort(effort), NoCache: o.NoCache || saved.NoCache}, - Budget: api.Budget{MaxTokens: maxTokens, MaxTurns: o.MaxTurns}, + Budget: api.Budget{Cost: budget, MaxTokens: maxTokens, MaxTurns: o.MaxTurns}, Memory: api.Memory{ Skills: o.SkillDirs, SkipHooks: o.NoHooks || saved.NoHooks, @@ -279,7 +291,12 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt // ToRequest delegates to AIRuntimeOptions.ToRequest, lifting the prompt // fields the prompt-shaped command owns onto the typed request. func (o AIPromptOptions) ToRequest() (ai.Request, error) { - return o.AIRuntimeOptions.ToRequest(o.System, o.AppendSystem, o.Prompt) + req, err := o.AIRuntimeOptions.ToRequest(o.System, o.AppendSystem, o.Prompt) + if err != nil { + return ai.Request{}, err + } + req.Prompt.Attachments, err = attachmentRefsFromFlags(o.Attach) + return req, err } // RunAIPrompt is a deprecated alias for `captain prompt run`. It routes through @@ -303,8 +320,8 @@ func RunAIPrompt(opts AIPromptOptions) (any, error) { if 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 req.Prompt.User == "" && len(req.Prompt.Attachments) == 0 { + return nil, fmt.Errorf("prompt text or attachment required (use --prompt/-p, --attach/-A, 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") @@ -328,6 +345,9 @@ func RunAIPrompt(opts AIPromptOptions) (any, error) { func executePromptRequest(parent context.Context, req ai.Request, cfg ai.Config, timeout time.Duration, noStream bool) (any, error) { ctx, cancel := runContext(parent, req, timeout) defer cancel() + if err := preparePromptAttachments(ctx, &req, cfg); err != nil { + return nil, err + } p, cleanup, err := buildProvider(ctx, &req, cfg) if err != nil { @@ -505,6 +525,7 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) loop, err := ai.RunUntil(ctx, ai.LoopOptions{ Provider: sp, MaxIterations: 1, + MaxCostUSD: req.Budget.Cost, // enforce the USD budget BuildRequest: func(iter int, prev *ai.LoopIteration) (ai.Request, bool) { if iter > 0 { return ai.Request{}, false @@ -539,6 +560,9 @@ 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 loop.StopReason == "max-cost" { + return nil, fmt.Errorf("%w: spent $%.4f of $%.4f budget", ai.ErrBudgetExceeded, loop.TotalCost, req.Budget.Cost) + } if session == "" && len(loop.Iterations) > 0 { session = loop.Iterations[0].SessionID } diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index da1e2a7..ac06e51 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -24,8 +24,10 @@ func resolvePromptTemplate(opts AIPromptOptions, stdin string) (tmpl *prompt.Tem return prompt.Load(opts.Prompt), false, nil case strings.TrimSpace(stdin) != "": return prompt.Load(stdin), true, nil + case len(opts.Attach) > 0: + return prompt.Load(""), false, nil default: - return nil, false, fmt.Errorf("prompt required: pass a .prompt file, --prompt/-p text, or pipe via stdin") + return nil, false, fmt.Errorf("prompt or attachment required: pass a .prompt file, --prompt/-p text, --attach/-A, or pipe via stdin") } } @@ -105,6 +107,13 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque if o.AppendSystem != "" { req.Prompt.AppendSystem = o.AppendSystem } + if len(o.Attach) > 0 { + attachments, err := attachmentRefsFromFlags(o.Attach) + if err != nil { + return base, baseCfg, err + } + req.Prompt.Attachments = append(req.Prompt.Attachments, attachments...) + } if o.PermissionMode != "" { req.Permissions.Mode = api.PermissionMode(o.PermissionMode) diff --git a/pkg/cli/attachments.go b/pkg/cli/attachments.go new file mode 100644 index 0000000..2a72089 --- /dev/null +++ b/pkg/cli/attachments.go @@ -0,0 +1,160 @@ +package cli + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/csv" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/attachments" + "github.com/flanksource/clicky/aichat" +) + +func attachmentRefsFromFlags(values []string) ([]api.AttachmentRef, error) { + refs := make([]api.AttachmentRef, 0, len(values)) + for _, value := range values { + reader := csv.NewReader(strings.NewReader(value)) + reader.FieldsPerRecord = -1 + fields, err := reader.Read() + if err != nil { + return nil, fmt.Errorf("parse --attach value %q: %w", value, err) + } + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + return nil, fmt.Errorf("empty attachment in --attach value %q", value) + } + ref := api.AttachmentRef{Path: field} + switch { + case strings.HasPrefix(field, api.AttachmentIDPrefix): + ref = api.AttachmentRef{ID: field} + case strings.HasPrefix(field, "http://"), strings.HasPrefix(field, "https://"): + ref = api.AttachmentRef{URL: field} + } + if err := ref.Validate(); err != nil { + return nil, err + } + refs = append(refs, ref) + } + } + return refs, nil +} + +type chatAttachmentResolver struct { + store *attachments.Store +} + +func (r chatAttachmentResolver) Resolve(ctx context.Context, inputs []aichat.AttachmentInput) ([]api.AttachmentRef, error) { + limits := r.store.Limits() + if len(inputs) > limits.MaxFiles { + return nil, fmt.Errorf("attachment request has %d files and exceeds %d file limit", len(inputs), limits.MaxFiles) + } + refs := make([]api.AttachmentRef, 0, len(inputs)) + var total int64 + for i, input := range inputs { + id := input.ID + if id == "" { + if _, suffix, ok := strings.Cut(input.URL, "/api/attachments/"); ok { + id = suffix + } + } + if id != "" { + resolved, err := r.store.Resolve(ctx, []api.AttachmentRef{{ + ID: id, Filename: input.Filename, MediaType: input.MediaType, + }}, "") + if err != nil { + return nil, fmt.Errorf("chat attachment %d: %w", i+1, err) + } + refs = append(refs, resolved[0]) + total += resolved[0].Size + if total > limits.MaxRequestBytes { + return nil, fmt.Errorf("attachments total %d bytes exceeds %d byte request limit", total, limits.MaxRequestBytes) + } + continue + } + mediaType, encoded, ok := parseLegacyDataURL(input.URL) + if !ok { + return nil, fmt.Errorf("chat attachment %d must be uploaded before use", i+1) + } + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("chat attachment %d has invalid base64 data: %w", i+1, err) + } + ref, err := r.store.Put(bytes.NewReader(data), input.Filename, mediaType) + if err != nil { + return nil, fmt.Errorf("chat attachment %d: %w", i+1, err) + } + refs = append(refs, ref) + total += ref.Size + if total > limits.MaxRequestBytes { + return nil, fmt.Errorf("attachments total %d bytes exceeds %d byte request limit", total, limits.MaxRequestBytes) + } + } + return refs, nil +} + +func parseLegacyDataURL(value string) (mediaType, encoded string, ok bool) { + header, encoded, ok := strings.Cut(value, ",") + if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(header, ";base64") { + return "", "", false + } + mediaType = strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64") + return mediaType, encoded, mediaType != "" +} + +func preparePromptAttachments(ctx context.Context, req *ai.Request, cfg ai.Config) error { + if len(req.Prompt.Attachments) == 0 { + return nil + } + allPrepared := true + for _, attachment := range req.Prompt.Attachments { + allPrepared = allPrepared && attachment.IsPrepared() + } + if !allPrepared { + baseDir := req.Cwd() + if baseDir == "" { + var err error + baseDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("resolve attachment working directory: %w", err) + } + } + store, err := newAttachmentStore(baseDir) + if err != nil { + return err + } + resolved, err := store.Resolve(ctx, req.Prompt.Attachments, baseDir) + if err != nil { + return err + } + req.Prompt.Attachments = resolved + } + model := req.Model + if model.Name == "" { + model = cfg.Model + } + models := append([]api.Model{model}, model.Fallbacks...) + return ai.ValidateAttachmentCompatibility(models, req.Prompt.Attachments) +} + +func newAttachmentStore(baseDir string) (*attachments.Store, error) { + defaults := loadSavedConfig().Attachments.WithDefaults() + directory := defaults.Directory + if !filepath.IsAbs(directory) { + directory = filepath.Join(baseDir, directory) + } + return attachments.NewStore(attachments.StoreOptions{ + Directory: directory, + Limits: attachments.Limits{ + MaxFileBytes: defaults.MaxFileBytes, + MaxRequestBytes: defaults.MaxRequestBytes, + MaxFiles: defaults.MaxFiles, + }, + }) +} diff --git a/pkg/cli/attachments_gc.go b/pkg/cli/attachments_gc.go new file mode 100644 index 0000000..12b185d --- /dev/null +++ b/pkg/cli/attachments_gc.go @@ -0,0 +1,126 @@ +package cli + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/flanksource/captain/pkg/api" +) + +type AttachmentsGCOptions struct { + DryRun bool `flag:"dry-run" help:"Report eligible attachments without deleting them"` +} + +func RunAttachmentsGC(opts AttachmentsGCOptions) (any, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("resolve working directory: %w", err) + } + store, err := newAttachmentStore(cwd) + if err != nil { + return nil, err + } + retention, err := parseAttachmentRetention(loadSavedConfig().Attachments.WithDefaults().Retention) + if err != nil { + return nil, err + } + referenced, err := collectAttachmentReferences(filepath.Join(cwd, ".captain"), store.Directory()) + if err != nil { + return nil, err + } + databaseReferences, err := collectDatabaseAttachmentReferences(context.Background()) + if err != nil { + return nil, err + } + for id := range databaseReferences { + referenced[id] = struct{}{} + } + return store.GC(referenced, retention, opts.DryRun) +} + +func parseAttachmentRetention(value string) (time.Duration, error) { + value = strings.TrimSpace(value) + if days, ok := strings.CutSuffix(value, "d"); ok { + count, err := strconv.Atoi(days) + if err != nil || count < 1 { + return 0, fmt.Errorf("invalid attachment retention %q: use a positive duration such as 30d", value) + } + return time.Duration(count) * 24 * time.Hour, nil + } + duration, err := time.ParseDuration(value) + if err != nil || duration <= 0 { + return 0, fmt.Errorf("invalid attachment retention %q: use a positive duration such as 30d", value) + } + return duration, nil +} + +var attachmentIDPattern = regexp.MustCompile(regexp.QuoteMeta(api.AttachmentIDPrefix) + `[0-9a-fA-F]{64}`) + +func collectAttachmentReferences(root, storeDirectory string) (map[string]struct{}, error) { + references := map[string]struct{}{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + if entry.IsDir() { + if path == storeDirectory { + return filepath.SkipDir + } + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read attachment reference source %s: %w", path, err) + } + for id := range attachmentReferencesFromContents([]string{string(data)}) { + references[id] = struct{}{} + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("scan attachment references: %w", err) + } + return references, nil +} + +func collectDatabaseAttachmentReferences(ctx context.Context) (map[string]struct{}, error) { + db, err := captainDB(ctx) + if err != nil { + return nil, fmt.Errorf("open attachment reference database: %w", err) + } + var rows []struct { + Content string + } + if err := db.Gorm().WithContext(ctx).Raw(` + SELECT rendered_spec::text AS content FROM captain_prompt_runs + UNION ALL + SELECT payload::text AS content FROM captain_events + `).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("scan database attachment references: %w", err) + } + contents := make([]string, len(rows)) + for i := range rows { + contents[i] = rows[i].Content + } + return attachmentReferencesFromContents(contents), nil +} + +func attachmentReferencesFromContents(contents []string) map[string]struct{} { + references := map[string]struct{}{} + for _, content := range contents { + for _, match := range attachmentIDPattern.FindAllString(content, -1) { + references[strings.ToLower(match)] = struct{}{} + } + } + return references +} diff --git a/pkg/cli/attachments_ginkgo_test.go b/pkg/cli/attachments_ginkgo_test.go new file mode 100644 index 0000000..b5a3cbf --- /dev/null +++ b/pkg/cli/attachments_ginkgo_test.go @@ -0,0 +1,105 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/attachments" + "github.com/flanksource/clicky/aichat" +) + +var _ = Describe("attachment flags", func() { + It("uses RFC 4180 parsing for repeated and comma-separated values", func() { + refs, err := attachmentRefsFromFlags([]string{`"reports/q1,q2.pdf",https://example.com/chart.png`, "notes.pdf"}) + Expect(err).NotTo(HaveOccurred()) + Expect(refs).To(HaveLen(3)) + Expect(refs[0].Path).To(Equal("reports/q1,q2.pdf")) + Expect(refs[1].URL).To(Equal("https://example.com/chart.png")) + Expect(refs[2].Path).To(Equal("notes.pdf")) + }) + + It("rejects an empty CSV field", func() { + _, err := attachmentRefsFromFlags([]string{"one.pdf,"}) + Expect(err).To(MatchError(ContainSubstring("empty attachment"))) + }) +}) + +var _ = Describe("attachment HTTP API", func() { + It("uploads once and serves the durable blob by ID", func() { + store, err := attachments.NewStore(attachments.StoreOptions{Directory: filepath.Join(GinkgoT().TempDir(), "attachments")}) + Expect(err).NotTo(HaveOccurred()) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "diagram.png") + Expect(err).NotTo(HaveOccurred()) + content := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 512)...) + _, err = part.Write(content) + Expect(err).NotTo(HaveOccurred()) + Expect(writer.Close()).To(Succeed()) + + upload := httptest.NewRequest(http.MethodPost, "/api/attachments", &body) + upload.Header.Set("Content-Type", writer.FormDataContentType()) + uploadResponse := httptest.NewRecorder() + handleAttachmentUpload(store)(uploadResponse, upload) + Expect(uploadResponse.Code).To(Equal(http.StatusCreated)) + var ref api.AttachmentRef + Expect(json.Unmarshal(uploadResponse.Body.Bytes(), &ref)).To(Succeed()) + + download := httptest.NewRequest(http.MethodGet, "/api/attachments/"+ref.ID, nil) + download.SetPathValue("id", ref.ID) + downloadResponse := httptest.NewRecorder() + handleAttachmentGet(store)(downloadResponse, download) + Expect(downloadResponse.Code).To(Equal(http.StatusOK)) + Expect(downloadResponse.Body.Bytes()).To(Equal(content)) + Expect(downloadResponse.Header().Get("Content-Type")).To(Equal("image/png")) + }) +}) + +var _ = Describe("chat attachment resolver", func() { + It("migrates a legacy data URL into the durable store", func() { + store, err := attachments.NewStore(attachments.StoreOptions{Directory: filepath.Join(GinkgoT().TempDir(), "attachments")}) + Expect(err).NotTo(HaveOccurred()) + refs, err := (chatAttachmentResolver{store: store}).Resolve(context.Background(), []aichat.AttachmentInput{{ + URL: "data:image/png;base64,iVBORw0KGgo=", Filename: "legacy.png", MediaType: "image/png", + }}) + Expect(err).NotTo(HaveOccurred()) + Expect(refs).To(HaveLen(1)) + Expect(refs[0].ID).To(HavePrefix(api.AttachmentIDPrefix)) + Expect(refs[0].IsPrepared()).To(BeTrue()) + }) + + It("enforces aggregate request limits while migrating legacy parts", func() { + store, err := attachments.NewStore(attachments.StoreOptions{ + Directory: filepath.Join(GinkgoT().TempDir(), "attachments"), + Limits: attachments.Limits{ + MaxFileBytes: 1024, + MaxRequestBytes: 1024, + MaxFiles: 1, + }, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = (chatAttachmentResolver{store: store}).Resolve(context.Background(), []aichat.AttachmentInput{ + {URL: "data:image/png;base64,iVBORw0KGgo=", Filename: "one.png", MediaType: "image/png"}, + {URL: "data:image/png;base64,iVBORw0KGgo=", Filename: "two.png", MediaType: "image/png"}, + }) + Expect(err).To(MatchError(ContainSubstring("exceeds 1 file limit"))) + }) +}) + +var _ = Describe("attachment garbage collection references", func() { + It("retains durable IDs found in database JSON", func() { + id := api.AttachmentIDPrefix + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" + references := attachmentReferencesFromContents([]string{`{"prompt":{"attachments":[{"id":"` + id + `"}]}}`}) + Expect(references).To(HaveKey(strings.ToLower(id))) + }) +}) diff --git a/pkg/cli/attachments_http.go b/pkg/cli/attachments_http.go new file mode 100644 index 0000000..93a8629 --- /dev/null +++ b/pkg/cli/attachments_http.go @@ -0,0 +1,88 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/attachments" +) + +func handleAttachmentUpload(store *attachments.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, store.Limits().MaxRequestBytes) + reader, err := r.MultipartReader() + if err != nil { + writeAttachmentError(w, http.StatusBadRequest, fmt.Errorf("read multipart upload: %w", err)) + return + } + var uploaded *api.AttachmentRef + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + writeAttachmentError(w, http.StatusBadRequest, fmt.Errorf("read multipart upload: %w", err)) + return + } + if part.FormName() != "file" || part.FileName() == "" { + _ = part.Close() + continue + } + if uploaded != nil { + _ = part.Close() + writeAttachmentError(w, http.StatusBadRequest, fmt.Errorf("upload exactly one file per request")) + return + } + ref, err := store.Put(part, part.FileName(), part.Header.Get("Content-Type")) + _ = part.Close() + if err != nil { + writeAttachmentError(w, http.StatusBadRequest, err) + return + } + uploaded = &ref + } + if uploaded == nil { + writeAttachmentError(w, http.StatusBadRequest, fmt.Errorf("multipart field file is required")) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(uploaded) + } +} + +func handleAttachmentGet(store *attachments.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + file, err := store.Open(r.PathValue("id")) + if err != nil { + writeAttachmentError(w, http.StatusNotFound, err) + return + } + defer file.Close() + sample := make([]byte, 512) + read, err := file.Read(sample) + if err != nil && err != io.EOF { + writeAttachmentError(w, http.StatusInternalServerError, err) + return + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + writeAttachmentError(w, http.StatusInternalServerError, err) + return + } + w.Header().Set("Content-Type", http.DetectContentType(sample[:read])) + w.Header().Set("X-Content-Type-Options", "nosniff") + if _, err := io.Copy(w, file); err != nil { + return + } + } +} + +func writeAttachmentError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) +} diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index 92d409f..3f0d226 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -302,6 +302,22 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP if len(models) == 0 { return executeSyncRunSingle(ctx, rendered, opts) } + prepared := rendered.Input + prepared.Model = models[0] + preparedCfg := rendered.Config + preparedCfg.Model = models[0] + if err := preparePromptAttachments(ctx, &prepared, preparedCfg); err != nil { + return PromptRunResult{}, err + } + rendered.Input.Prompt.Attachments = prepared.Prompt.Attachments + allModels := make([]api.Model, 0, len(models)) + for _, model := range models { + allModels = append(allModels, model) + allModels = append(allModels, model.Fallbacks...) + } + if err := ai.ValidateAttachmentCompatibility(allModels, rendered.Input.Prompt.Attachments); err != nil { + return PromptRunResult{}, err + } if rendered.Input.SessionID != "" && len(models) > 1 { return PromptRunResult{}, errors.New("--resume cannot be used with multiple --multi-models variants") } @@ -509,6 +525,9 @@ func runPromptStream(t *task.Task, rendered PromptRenderResult, timeout time.Dur req := rendered.Input cfg := rendered.Config + if err := preparePromptAttachments(ctx, &req, cfg); err != nil { + return failRun(t, stream, err) + } p, cleanup, err := buildProvider(ctx, &req, cfg) if err != nil { return failRun(t, stream, err) diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index b38b030..a85d6e5 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -122,6 +122,10 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve return err } threadStore := newFileThreadStore(opts.ThreadsFile) + attachmentStore, err := newAttachmentStore(cwd) + if err != nil { + return err + } openAPIConfig := &rpc.OpenAPIConfig{ Title: "Captain", Description: "Captain command and agent launcher API.", @@ -151,6 +155,7 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve Agent: aichat.AgentOptions{ Cwd: cwd, }, + AttachmentResolver: chatAttachmentResolver{store: attachmentStore}, }) defer chat.Close() @@ -172,6 +177,8 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve mux.HandleFunc("GET /api/captain/ai/prompt/schema", handlePromptSchema()) mux.HandleFunc("GET /api/captain/secrets/resources", handleSecretResources()) mux.HandleFunc("GET /api/captain/secrets/preview", handleSecretPreview()) + mux.HandleFunc("POST /api/attachments", handleAttachmentUpload(attachmentStore)) + mux.HandleFunc("GET /api/attachments/{id}", handleAttachmentGet(attachmentStore)) // Task tracking: /api/captain/tasks, /tasks/stream, /tasks/{id}; plus the // run-list SSE the clicky-ui useTaskRuns hook subscribes to. task.RegisterHandlers(mux, "/api/captain") diff --git a/pkg/cli/webapp/src/PromptWorkbench.tsx b/pkg/cli/webapp/src/PromptWorkbench.tsx index 5c4a9fa..66fb7bd 100644 --- a/pkg/cli/webapp/src/PromptWorkbench.tsx +++ b/pkg/cli/webapp/src/PromptWorkbench.tsx @@ -245,10 +245,18 @@ type PromptDetailStateAction = | { type: "variables"; detail?: PromptDetail; value: Record } | { type: "variables-validity"; detail?: PromptDetail; value: boolean } | { type: "runtime"; detail?: PromptDetail; value: AISpecRuntimeValue } - | { type: "preview-result"; detail?: PromptDetail; value?: PromptPreviewResult } + | { + type: "preview-result"; + detail?: PromptDetail; + value?: PromptPreviewResult; + } | { type: "active-run"; detail?: PromptDetail; value?: string } | { type: "action-error"; detail?: PromptDetail; value?: string } - | { type: "action-loading"; detail?: PromptDetail; value?: PromptDetailState["actionLoading"] } + | { + type: "action-loading"; + detail?: PromptDetail; + value?: PromptDetailState["actionLoading"]; + } | { type: "saved"; detail?: PromptDetail; content: string }; function promptDetailReducer( @@ -284,7 +292,9 @@ function initialPromptDetailState(detail?: PromptDetail): PromptDetailState { draft: detail?.content ?? "", variables: detail?.inputDefault ?? {}, variablesValid: true, - runtime: detail ? { ...EMPTY_RUNTIME, ...runtimeSelectionFromPrompt(detail) } : { ...EMPTY_RUNTIME }, + runtime: detail + ? { ...EMPTY_RUNTIME, ...runtimeSelectionFromPrompt(detail) } + : { ...EMPTY_RUNTIME }, previewResult: undefined, activeRunID: undefined, actionError: undefined, @@ -419,7 +429,11 @@ export function PromptWorkbench({ onNavigate(`/prompts/${encodeURIComponent(saved.id)}`); } } catch (error) { - dispatchDetailState({ type: "action-error", detail, value: errorMessage(error) }); + dispatchDetailState({ + type: "action-error", + detail, + value: errorMessage(error), + }); } finally { dispatchDetailState({ type: "action-loading", detail, value: undefined }); } @@ -433,12 +447,19 @@ export function PromptWorkbench({ const preview = await submitPromptOperation( promptOps.preview, promptActionParams(detail), - { variables: selectedDetailState.variables, ...runtimePayload(selectedDetailState.runtime, models) }, + { + variables: selectedDetailState.variables, + ...runtimePayload(selectedDetailState.runtime, models), + }, ); dispatchDetailState({ type: "preview-result", detail, value: preview }); dispatchDetailState({ type: "active-run", detail, value: undefined }); } catch (error) { - dispatchDetailState({ type: "action-error", detail, value: errorMessage(error) }); + dispatchDetailState({ + type: "action-error", + detail, + value: errorMessage(error), + }); } finally { dispatchDetailState({ type: "action-loading", detail, value: undefined }); } @@ -452,13 +473,20 @@ export function PromptWorkbench({ const handle = await submitPromptOperation( promptOps.run, promptActionParams(detail), - { variables: selectedDetailState.variables, ...runtimePayload(selectedDetailState.runtime, models) }, + { + variables: selectedDetailState.variables, + ...runtimePayload(selectedDetailState.runtime, models), + }, ); dispatchDetailState({ type: "preview-result", detail, value: undefined }); dispatchDetailState({ type: "active-run", detail, value: handle.runId }); setTab("runner"); } catch (error) { - dispatchDetailState({ type: "action-error", detail, value: errorMessage(error) }); + dispatchDetailState({ + type: "action-error", + detail, + value: errorMessage(error), + }); } finally { dispatchDetailState({ type: "action-loading", detail, value: undefined }); } @@ -473,7 +501,11 @@ export function PromptWorkbench({ onNavigate("/prompts", { replace: true }); await listQuery.refetch(); } catch (error) { - dispatchDetailState({ type: "action-error", detail, value: errorMessage(error) }); + dispatchDetailState({ + type: "action-error", + detail, + value: errorMessage(error), + }); } finally { dispatchDetailState({ type: "action-loading", detail, value: undefined }); } @@ -554,19 +586,29 @@ export function PromptWorkbench({ detail={detail} hasSelection={hasSelection} loading={Boolean(activePromptId && detailQuery.isLoading)} - error={detailQuery.error ?? promptSchemaQuery.error ?? selectedDetailState.actionError} + error={ + detailQuery.error ?? + promptSchemaQuery.error ?? + selectedDetailState.actionError + } tab={tab} onTabChange={(next) => setTab(next as DetailTab)} draft={selectedDetailState.draft} - onDraftChange={(value) => dispatchDetailState({ type: "draft", detail, value })} + onDraftChange={(value) => + dispatchDetailState({ type: "draft", detail, value }) + } variables={selectedDetailState.variables} variablesValid={selectedDetailState.variablesValid} - onVariablesChange={(value) => dispatchDetailState({ type: "variables", detail, value })} + onVariablesChange={(value) => + dispatchDetailState({ type: "variables", detail, value }) + } onVariablesValidityChange={(value) => dispatchDetailState({ type: "variables-validity", detail, value }) } runtime={selectedDetailState.runtime} - onRuntimeChange={(value) => dispatchDetailState({ type: "runtime", detail, value })} + onRuntimeChange={(value) => + dispatchDetailState({ type: "runtime", detail, value }) + } models={models} promptSchema={promptSchemaQuery.data} tools={AGENT_TOOLS} @@ -866,7 +908,8 @@ function PromptDetailPane({ } const scratch = isScratchPrompt(detail); - const activeTab = scratch && (tab === "source" || tab === "schema") ? "runner" : tab; + const activeTab = + scratch && (tab === "source" || tab === "schema") ? "runner" : tab; const tabs = scratch ? [ { id: "runner", label: "Run", icon: UiPlay }, @@ -878,20 +921,21 @@ function PromptDetailPane({ { id: "source", label: "Source", icon: UiCode2 }, { id: "runs", label: "Runs", icon: UiTerminal }, ]; - const schema = scratch ? undefined : normalizeObjectSchema(detail.inputSchema); + const schema = scratch + ? undefined + : normalizeObjectSchema(detail.inputSchema); const backendCliArgs = promptSchema?.backends?.find( (backend) => backend.backend === runtime.backend, )?.args; - const promptReady = !scratch || Boolean(runtime.prompt?.user?.trim()); + const promptReady = + !scratch || + Boolean(runtime.prompt?.user?.trim()) || + Boolean(runtime.prompt?.attachments?.length); return (
- +
{Boolean(error) && ( @@ -928,6 +972,7 @@ function PromptDetailPane({ variables={variables} onVariablesChange={onVariablesChange} onVariablesValidityChange={onVariablesValidityChange} + enableAttachments {...(permissionCatalog ? { permissionCatalog } : {})} {...(schema ? { variablesSchema: schema } : {})} {...(backendCliArgs @@ -946,7 +991,11 @@ function PromptDetailPane({ size="sm" variant="outline" loading={previewLoading} - disabled={!previewEnabled || !promptReady || (!schema && !variablesValid)} + disabled={ + !previewEnabled || + !promptReady || + (!schema && !variablesValid) + } onClick={onPreview} > @@ -955,7 +1004,9 @@ function PromptDetailPane({ + ) : null}
{error && (
@@ -31,6 +42,16 @@ export function PromptRunStream({ runID }: { runID: string }) {
)}
+ {run?.chat && chatState ? ( + + ) : null} {summary && }
); @@ -66,10 +87,14 @@ function RunSummaryFooter({ summary }: { summary: PromptRunSummary }) { if (summary.model) parts.push({ id: "model", label: summary.model }); if (summary.backend) parts.push({ id: "backend", label: summary.backend }); if (summary.duration) parts.push({ id: "duration", label: summary.duration }); - if (summary.inputTokens != null) parts.push({ id: "input-tokens", label: `${summary.inputTokens} in` }); - if (summary.outputTokens != null) parts.push({ id: "output-tokens", label: `${summary.outputTokens} out` }); - if (summary.costUSD != null) parts.push({ id: "cost", label: `$${summary.costUSD.toFixed(4)}` }); - if (summary.sessionId) parts.push({ id: "session", label: `session ${summary.sessionId}` }); + if (summary.inputTokens != null) + parts.push({ id: "input-tokens", label: `${summary.inputTokens} in` }); + if (summary.outputTokens != null) + parts.push({ id: "output-tokens", label: `${summary.outputTokens} out` }); + if (summary.costUSD != null) + parts.push({ id: "cost", label: `$${summary.costUSD.toFixed(4)}` }); + if (summary.sessionId) + parts.push({ id: "session", label: `session ${summary.sessionId}` }); return (
{parts.map((part) => ( diff --git a/pkg/cli/webapp/src/PromptWorkbench.tsx b/pkg/cli/webapp/src/PromptWorkbench.tsx index 66fb7bd..c41a2a0 100644 --- a/pkg/cli/webapp/src/PromptWorkbench.tsx +++ b/pkg/cli/webapp/src/PromptWorkbench.tsx @@ -476,6 +476,7 @@ export function PromptWorkbench({ { variables: selectedDetailState.variables, ...runtimePayload(selectedDetailState.runtime, models), + chat: promptChatEligible(detail, selectedDetailState.runtime), }, ); dispatchDetailState({ type: "preview-result", detail, value: undefined }); @@ -642,6 +643,14 @@ export function PromptWorkbench({ ); } +function promptChatEligible(detail: PromptDetail, runtime: AISpecRuntimeValue) { + return ( + !detail.outputSchema && + !runtime.workflow?.verify && + !runtime.workflow?.postRun + ); +} + function PromptSidebar({ source, onSourceChange, diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx index dbc7fd1..b8ef51d 100644 --- a/pkg/cli/webapp/src/SessionBrowser.tsx +++ b/pkg/cli/webapp/src/SessionBrowser.tsx @@ -8,7 +8,10 @@ import { type AppShellProps, type AppShellNavSection, } from "@flanksource/clicky-ui/components"; -import { SessionInspector } from "@flanksource/clicky-ui/ai"; +import { + SessionChatComposer, + SessionInspector, +} from "@flanksource/clicky-ui/ai"; import { CAPTAIN_SIDEBAR_COLLAPSE_KEY, withProjectScope } from "./shellHelpers"; import { TimingBadge } from "./TimingBadge"; import type { TimingMetric } from "./serverTiming"; @@ -34,6 +37,7 @@ import { type ProjectScope, type SourceFilter, } from "./sessionData"; +import { mergeSessionMessages, useSessionChat } from "./hooks/useSessionChat"; type Navigate = (to: string, opts?: { replace?: boolean }) => void; @@ -106,10 +110,20 @@ function SessionDetailPage({ } bodyActions={
- -
@@ -120,6 +134,7 @@ function SessionDetailPage({ result={detailQuery.data} loading={detailQuery.isLoading} error={detailQuery.error} + onRefresh={() => detailQuery.refetch()} /> ); @@ -154,7 +169,11 @@ function SessionListPage({ actions={actions} bodyHeader={
Sessions
} bodyActions={ - } @@ -171,7 +190,14 @@ function SessionListPage({ total={listQuery.data?.total ?? 0} loading={listQuery.isLoading} error={listQuery.error} - onSelect={(session) => onNavigate(withProjectScope(`/sessions/${encodeURIComponent(session.key)}`, projectScope))} + onSelect={(session) => + onNavigate( + withProjectScope( + `/sessions/${encodeURIComponent(session.key)}`, + projectScope, + ), + ) + } /> ); @@ -208,7 +234,9 @@ function SessionList({ const groups = useMemo( () => groupSessionsByProject( - [...sessions].sort((left, right) => compareSessions(left, right, sort, sortDirection)), + [...sessions].sort((left, right) => + compareSessions(left, right, sort, sortDirection), + ), ), [sessions, sort, sortDirection], ); @@ -242,7 +270,11 @@ function SessionList({
- {loading ? "Loading..." : `${sessions.length} shown / ${total} total`} + + {loading + ? "Loading..." + : `${sessions.length} shown / ${total} total`} +
@@ -251,7 +283,9 @@ function SessionList({ {error ? (
{errorMessage(error)}
) : sessions.length === 0 && !loading ? ( -
No sessions found.
+
+ No sessions found. +
) : ( Loading session...
; + return ( +
Loading session...
+ ); } return (
@@ -288,10 +324,12 @@ function SessionDetail({ result, loading, error, + onRefresh, }: { result?: SessionGetResult; loading: boolean; error: unknown; + onRefresh: () => Promise; }) { if (loading) { return ( @@ -317,33 +355,96 @@ function SessionDetail({ return (
{result.sessions.map((item) => ( - + ))}
); } -function SessionGetItemDetail({ item, single }: { item: SessionGetItem; single: boolean }) { +function SessionGetItemDetail({ + item, + single, + onRefresh, +}: { + item: SessionGetItem; + single: boolean; + onRefresh: () => Promise; +}) { + const chat = useSessionChat({ + initialRunID: item.activeRunId, + sessionID: item.captainId, + initialCapabilities: item.chat, + initialState: item.chatState, + clearOnTerminal: true, + onTerminal: onRefresh, + }); + const detail = useMemo( + () => + item.detail + ? { + ...item.detail, + messages: mergeSessionMessages( + item.detail.messages ?? [], + chat.messages, + ), + } + : undefined, + [chat.messages, item.detail], + ); + const composer = + item.chat?.resume || item.activeRunId ? ( + + ) : undefined; return ( -
+
{!single ? (
-
{item.captainId}
+
+ {item.captainId} +
{item.summary.source} - {item.providerSessionId && provider={item.providerSessionId}} + {item.providerSessionId && ( + provider={item.providerSessionId} + )} {item.host && host={item.host}} - {item.summary.project && project={item.summary.project}} - {item.summary.cwd && {item.summary.cwd}} + {item.summary.project && ( + project={item.summary.project} + )} + {item.summary.cwd && ( + {item.summary.cwd} + )}
) : null} - {item.detail ? ( + {detail ? (
- +
) : ( -
Transcript unavailable.
+
+ Transcript unavailable. +
)}
); @@ -360,18 +461,31 @@ function SessionSummary({ return null; } const values = [ - ["Live", summary ? `${summary.liveSessions}/${summary.totalSessions}` : "--"], + [ + "Live", + summary ? `${summary.liveSessions}/${summary.totalSessions}` : "--", + ], ["Active", summary?.activeSessions ?? 0], ["Alerts", summary?.alertSessions ?? 0], ["Tokens", formatCompactNumber(summary?.totalTokens ?? 0)], ["Cost", formatCost(summary?.costUsd ?? 0)], - ["Context", summary?.lowestContextFree !== undefined ? `${summary.lowestContextFree}%` : "--"], + [ + "Context", + summary?.lowestContextFree !== undefined + ? `${summary.lowestContextFree}%` + : "--", + ], ]; return (
{values.map(([label, value]) => ( -
-
{label}
+
+
+ {label} +
{value}
))} diff --git a/pkg/cli/webapp/src/hooks/usePromptRunStream.ts b/pkg/cli/webapp/src/hooks/usePromptRunStream.ts index f725af0..bb2ff35 100644 --- a/pkg/cli/webapp/src/hooks/usePromptRunStream.ts +++ b/pkg/cli/webapp/src/hooks/usePromptRunStream.ts @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useReducer, useRef, type MutableRefObject } from "react"; +import { + useCallback, + useEffect, + useReducer, + useRef, + type MutableRefObject, +} from "react"; import type { SessionUIMessage } from "@flanksource/clicky-ui/ai"; import { useEventSource } from "./useEventSource"; @@ -8,6 +14,42 @@ export interface PromptRunHandle { status: string; model?: string; backend?: string; + chat?: boolean; + capabilities?: ChatCapabilities; +} + +export interface ChatCapabilities { + interrupt: boolean; + steer: boolean; + followUp: boolean; + resume: boolean; +} + +export interface ChatQueuedMessage { + messageId: string; + text: string; +} + +export interface ChatMessageResponse { + runId: string; + messageId: string; + status: "steered" | "queued" | "started"; + capabilities: ChatCapabilities; +} + +export interface ChatStateFrame { + runId: string; + sessionId?: string; + status: "starting" | "running" | "interrupting" | "idle" | "stopping"; + turn: number; + capabilities: ChatCapabilities; + queued?: ChatQueuedMessage[]; + discardedMessageIds?: string[]; + summary?: PromptRunSummary; +} + +export interface PromptRunFrame extends PromptRunHandle { + sessionId?: string; } /** Terminal payload delivered on the SSE `done` event. */ @@ -24,13 +66,20 @@ export interface PromptRunSummary { error?: string; } -export type PromptRunStreamStatus = "idle" | "connecting" | "streaming" | "done" | "error"; +export type PromptRunStreamStatus = + | "idle" + | "connecting" + | "streaming" + | "done" + | "error"; export interface PromptRunStreamState { messages: SessionUIMessage[]; summary?: PromptRunSummary; status: PromptRunStreamStatus; error?: string; + run?: PromptRunFrame; + chatState?: ChatStateFrame; } const PROMPT_RUN_BASE = "/api/captain/prompt/runs"; @@ -42,6 +91,12 @@ type PromptRunStreamReducerState = PromptRunStreamState & { type PromptRunStreamAction = | { type: "reset"; runID?: string } | { type: "message"; messages: SessionUIMessage[] } + | { type: "run"; run: PromptRunFrame } + | { + type: "chat-state"; + chatState: ChatStateFrame; + messages: SessionUIMessage[]; + } | { type: "done"; summary?: PromptRunSummary } | { type: "error"; error: string }; @@ -49,6 +104,7 @@ type MessageIndex = { byId: Map; order: string[]; autoSeq: number; + discarded: Set; }; function streamReducer( @@ -63,12 +119,25 @@ function streamReducer( status: action.runID ? "connecting" : "idle", error: undefined, done: !action.runID, + run: undefined, + chatState: undefined, + }; + case "run": + return { ...state, run: action.run }; + case "chat-state": + return { + ...state, + chatState: action.chatState, + messages: action.messages, }; case "message": return { ...state, messages: action.messages, - status: state.status === "done" || state.status === "error" ? state.status : "streaming", + status: + state.status === "done" || state.status === "error" + ? state.status + : "streaming", }; case "done": return { @@ -95,6 +164,8 @@ function initialStreamState(): PromptRunStreamReducerState { status: "idle", error: undefined, done: true, + run: undefined, + chatState: undefined, }; } @@ -104,8 +175,15 @@ function initialStreamState(): PromptRunStreamReducerState { * are deduped by message id so the buffered replay a run sends on (re)connect is * idempotent, and the terminal `done`/`error` events stop the connection. */ -export function usePromptRunStream(runID: string | undefined, basePath = PROMPT_RUN_BASE): PromptRunStreamState { - const [state, dispatch] = useReducer(streamReducer, undefined, initialStreamState); +export function usePromptRunStream( + runID: string | undefined, + basePath = PROMPT_RUN_BASE, +): PromptRunStreamState { + const [state, dispatch] = useReducer( + streamReducer, + undefined, + initialStreamState, + ); const messageIndex = useRef(null); useEffect(() => { @@ -114,41 +192,84 @@ export function usePromptRunStream(runID: string | undefined, basePath = PROMPT_ }, [runID]); const onEvent = useCallback((event: string, data: string) => { + if (event === "run") { + const run = parse(data); + if (run) dispatch({ type: "run", run }); + return; + } + if (event === "state") { + const chatState = parse(data); + if (!chatState) return; + const index = getMessageIndex(messageIndex); + index.discarded = new Set(chatState.discardedMessageIds ?? []); + dispatch({ + type: "chat-state", + chatState, + messages: indexedMessages(index), + }); + return; + } if (event === "done") { const sum = parse(data); dispatch({ type: "done", summary: sum }); return; } if (event === "error") { - dispatch({ type: "error", error: parse<{ error?: string }>(data)?.error ?? "run failed" }); + dispatch({ + type: "error", + error: parse<{ error?: string }>(data)?.error ?? "run failed", + }); return; } + if (event !== "entry") return; const message = parse(data); if (!message) return; const index = getMessageIndex(messageIndex); const key = message.id ?? `auto-${index.autoSeq++}`; if (!index.byId.has(key)) index.order.push(key); index.byId.set(key, message); - dispatch({ type: "message", messages: index.order.map((k) => index.byId.get(k)!) }); + dispatch({ type: "message", messages: indexedMessages(index) }); }, []); - const url = runID ? `${basePath}/${encodeURIComponent(runID)}/stream` : undefined; - useEventSource(url, { enabled: Boolean(url) && !state.done, events: ["entry", "done", "error"], onEvent }); + const url = runID + ? `${basePath}/${encodeURIComponent(runID)}/stream` + : undefined; + useEventSource(url, { + enabled: Boolean(url) && !state.done, + events: ["run", "entry", "state", "done", "error"], + onEvent, + }); - return { messages: state.messages, summary: state.summary, status: state.status, error: state.error }; + return { + messages: state.messages, + summary: state.summary, + status: state.status, + error: state.error, + run: state.run, + chatState: state.chatState, + }; } -function getMessageIndex(ref: MutableRefObject): MessageIndex { +function getMessageIndex( + ref: MutableRefObject, +): MessageIndex { if (!ref.current) { ref.current = { byId: new Map(), order: [], autoSeq: 0, + discarded: new Set(), }; } return ref.current; } +function indexedMessages(index: MessageIndex): SessionUIMessage[] { + return index.order + .filter((key) => !index.discarded.has(key)) + .map((key) => index.byId.get(key)!); +} + function parse(data: string): T | undefined { try { return JSON.parse(data) as T; diff --git a/pkg/cli/webapp/src/hooks/useSessionChat.test.ts b/pkg/cli/webapp/src/hooks/useSessionChat.test.ts new file mode 100644 index 0000000..f3ae00a --- /dev/null +++ b/pkg/cli/webapp/src/hooks/useSessionChat.test.ts @@ -0,0 +1,22 @@ +import type { SessionUIMessage } from "@flanksource/clicky-ui/ai"; +import { describe, expect, it } from "vitest"; +import { mergeSessionMessages } from "./useSessionChat"; + +describe("mergeSessionMessages", () => { + it("reconciles an optimistic user message by exact id", () => { + const optimistic: SessionUIMessage = { + id: "message-1", + role: "user", + parts: [{ type: "text", text: "optimistic" }], + }; + const accepted: SessionUIMessage = { + id: "message-1", + role: "user", + parts: [{ type: "text", text: "accepted" }], + }; + + const merged = mergeSessionMessages([optimistic], [accepted]); + + expect(merged).toEqual([accepted]); + }); +}); diff --git a/pkg/cli/webapp/src/hooks/useSessionChat.ts b/pkg/cli/webapp/src/hooks/useSessionChat.ts new file mode 100644 index 0000000..1ff4815 --- /dev/null +++ b/pkg/cli/webapp/src/hooks/useSessionChat.ts @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { SessionUIMessage } from "@flanksource/clicky-ui/ai"; +import { + usePromptRunStream, + type ChatCapabilities, + type ChatMessageResponse, + type ChatStateFrame, +} from "./usePromptRunStream"; + +const EMPTY_CAPABILITIES: ChatCapabilities = { + interrupt: false, + steer: false, + followUp: false, + resume: false, +}; + +export type UseSessionChatOptions = { + initialRunID?: string; + sessionID?: string; + initialCapabilities?: ChatCapabilities; + initialState?: ChatStateFrame; + clearOnTerminal?: boolean; + onTerminal?: () => Promise; +}; + +export function useSessionChat(options: UseSessionChatOptions) { + const [activeRunID, setActiveRunID] = useState(options.initialRunID); + const [optimistic, setOptimistic] = useState([]); + const [actionError, setActionError] = useState(); + const terminalHandled = useRef(undefined); + const stream = usePromptRunStream(activeRunID); + + useEffect(() => { + if (options.initialRunID) setActiveRunID(options.initialRunID); + }, [options.initialRunID]); + + useEffect(() => { + if (!activeRunID || (stream.status !== "done" && stream.status !== "error")) + return; + if (terminalHandled.current === activeRunID) return; + terminalHandled.current = activeRunID; + if (!options.onTerminal) return; + void options + .onTerminal() + .then(() => { + if (options.clearOnTerminal) { + setOptimistic([]); + setActiveRunID(undefined); + } + }) + .catch((error: unknown) => setActionError(errorMessage(error))); + }, [activeRunID, options.clearOnTerminal, options.onTerminal, stream.status]); + + const messages = useMemo( + () => mergeSessionMessages(stream.messages, optimistic), + [optimistic, stream.messages], + ); + const capabilities = + stream.chatState?.capabilities ?? + stream.run?.capabilities ?? + options.initialCapabilities ?? + EMPTY_CAPABILITIES; + const liveChatState = stream.chatState ?? + options.initialState ?? { + runId: activeRunID ?? "", + status: activeRunID ? "starting" : "idle", + turn: 0, + capabilities, + }; + const chatState = + stream.status === "done" || stream.status === "error" + ? { ...liveChatState, status: "idle" as const, queued: [] } + : liveChatState; + + const send = useCallback( + async (text: string) => { + const messageID = crypto.randomUUID(); + const message = userMessage(messageID, text); + setOptimistic((current) => mergeSessionMessages(current, [message])); + setActionError(undefined); + try { + const sessionID = options.sessionID ?? stream.summary?.sessionId; + const active = + activeRunID && stream.status !== "done" && stream.status !== "error"; + let response: ChatMessageResponse; + if (active) { + try { + response = await postChatMessage( + `/api/captain/prompt/runs/${encodeURIComponent(activeRunID)}/message`, + text, + messageID, + ); + } catch (error) { + if ( + !(error instanceof ChatRequestError) || + error.status !== 409 || + !sessionID + ) + throw error; + response = await postChatMessage( + `/api/captain/sessions/${encodeURIComponent(sessionID)}/message`, + text, + messageID, + ); + } + } else { + if (!sessionID) throw new Error("session is not resumable yet"); + response = await postChatMessage( + `/api/captain/sessions/${encodeURIComponent(sessionID)}/message`, + text, + messageID, + ); + } + if (response.runId !== activeRunID) { + terminalHandled.current = undefined; + setActiveRunID(response.runId); + } + } catch (error) { + setOptimistic((current) => + current.filter((item) => item.id !== messageID), + ); + setActionError(errorMessage(error)); + } + }, + [activeRunID, options.sessionID, stream.status, stream.summary?.sessionId], + ); + + const interrupt = useCallback(async () => { + if (!activeRunID) return; + setActionError(undefined); + try { + await postJSON( + `/api/captain/prompt/runs/${encodeURIComponent(activeRunID)}/interrupt`, + {}, + ); + } catch (error) { + setActionError(errorMessage(error)); + } + }, [activeRunID]); + + const stop = useCallback(async () => { + if (!activeRunID) return; + setActionError(undefined); + try { + await postJSON( + `/api/captain/prompt/runs/${encodeURIComponent(activeRunID)}/stop`, + {}, + ); + } catch (error) { + setActionError(errorMessage(error)); + } + }, [activeRunID]); + + return { + ...stream, + activeRunID, + messages, + capabilities, + chatState, + actionError, + send, + interrupt, + stop, + }; +} + +export function mergeSessionMessages( + base: SessionUIMessage[], + additional: SessionUIMessage[], +): SessionUIMessage[] { + const byID = new Map(); + const order: string[] = []; + let sequence = 0; + for (const message of [...base, ...additional]) { + const id = message.id ?? `message-${sequence++}`; + if (!byID.has(id)) order.push(id); + byID.set(id, message); + } + return order.map((id) => byID.get(id)!); +} + +function userMessage(id: string, text: string): SessionUIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +async function postChatMessage(path: string, text: string, messageId: string) { + return postJSON(path, { text, messageId }); +} + +async function postJSON(path: string, body: unknown): Promise { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new ChatRequestError( + response.status, + (await response.text()).trim() || "request failed", + ); + } + return (await response.json()) as T; +} + +class ChatRequestError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/pkg/cli/webapp/src/sessionData.ts b/pkg/cli/webapp/src/sessionData.ts index 9794731..40eef55 100644 --- a/pkg/cli/webapp/src/sessionData.ts +++ b/pkg/cli/webapp/src/sessionData.ts @@ -5,6 +5,10 @@ import type { } from "@flanksource/clicky-ui/ai"; import { apiClient } from "./api"; import { parseServerTiming, type TimingMetric } from "./serverTiming"; +import type { + ChatCapabilities, + ChatStateFrame, +} from "./hooks/usePromptRunStream"; export type SourceFilter = "all" | "claude" | "codex"; export const ALL_PROJECTS_SCOPE = "all"; @@ -61,6 +65,9 @@ export type SessionGetItem = { detailAvailable: boolean; summary: SessionRecord; detail?: UnifiedSession; + activeRunId?: string; + chat?: ChatCapabilities; + chatState?: ChatStateFrame; }; export type SessionGetResult = { @@ -194,7 +201,10 @@ export async function fetchLiveSessions(params: { throw new Error(response.error || "Failed to load sessions."); } const timing = parseServerTiming(response.responseHeaders?.["server-timing"]); - return { ...(response.parsed as SessionListResult), ...(timing.length ? { timing } : {}) }; + return { + ...(response.parsed as SessionListResult), + ...(timing.length ? { timing } : {}), + }; } export async function fetchSessionThroughput(params: { @@ -218,7 +228,10 @@ export async function fetchSessionThroughput(params: { throw new Error(response.error || "Failed to load session throughput."); } const timing = parseServerTiming(response.responseHeaders?.["server-timing"]); - return { ...(response.parsed as SessionThroughputResult), ...(timing.length ? { timing } : {}) }; + return { + ...(response.parsed as SessionThroughputResult), + ...(timing.length ? { timing } : {}), + }; } export async function fetchProjectOptions(): Promise { @@ -252,7 +265,10 @@ export async function fetchSession( throw new Error(response.error || "Failed to load session."); } const timing = parseServerTiming(response.responseHeaders?.["server-timing"]); - return { ...(response.parsed as SessionGetResult), ...(timing.length ? { timing } : {}) }; + return { + ...(response.parsed as SessionGetResult), + ...(timing.length ? { timing } : {}), + }; } /** Sum a unified session's per-bucket costs into a total USD. */ @@ -268,11 +284,14 @@ export function sessionCostTotal(cost: UnifiedCost | undefined): number { } /** Count tool-call parts across a unified session's messages. */ -export function sessionToolCount(messages: SessionUIMessage[] | undefined): number { +export function sessionToolCount( + messages: SessionUIMessage[] | undefined, +): number { let count = 0; for (const message of messages ?? []) { for (const part of message.parts) { - if (part.type === "dynamic-tool" || part.type.startsWith("tool-")) count += 1; + if (part.type === "dynamic-tool" || part.type.startsWith("tool-")) + count += 1; } } return count; @@ -281,7 +300,8 @@ export function sessionToolCount(messages: SessionUIMessage[] | undefined): numb export function unifiedSessionTitle(session: UnifiedSession): string { if (session.title) return session.title; if (session.slug) return session.slug; - if (session.git?.branch) return `${session.git.branch} - ${shortID(session.id)}`; + if (session.git?.branch) + return `${session.git.branch} - ${shortID(session.id)}`; if (session.model) return `${session.model} - ${shortID(session.id)}`; return shortID(session.id); } @@ -326,7 +346,9 @@ export function projectLabel(path: string | undefined) { return parts.slice(-2).join("/") || path; } -export function projectScopeQuery(project: ProjectScope): Record { +export function projectScopeQuery( + project: ProjectScope, +): Record { if (!project || project === ALL_PROJECTS_SCOPE) { return { all: "true" }; } @@ -347,7 +369,10 @@ export function sessionSortTime(session: SessionRecord) { } export function healthRank(session: SessionRecord) { - return Math.max(0, ...(session.health ?? []).map((signal) => severityRank(signal.severity))); + return Math.max( + 0, + ...(session.health ?? []).map((signal) => severityRank(signal.severity)), + ); } export function severityRank(severity: string | undefined) { diff --git a/pkg/cli/webapp/src/test-setup.ts b/pkg/cli/webapp/src/test-setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/pkg/cli/webapp/src/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/pkg/cli/webapp/vitest.config.ts b/pkg/cli/webapp/vitest.config.ts new file mode 100644 index 0000000..9bf0907 --- /dev/null +++ b/pkg/cli/webapp/vitest.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/test-setup.ts"], + }, +}); From 417ce728e57578a9a1d54a41473d4c65e20f7276 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 10:06:40 +0300 Subject: [PATCH 073/131] chore: update generated and lock files --- .gitignore | 1 + pkg/cli/webapp/pnpm-lock.yaml | 771 ++++++++++++++++++++++++++++++++++ 2 files changed, 772 insertions(+) diff --git a/.gitignore b/.gitignore index 7ba4f56..7720de5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ hack/ dist/ .ok/ .okignore +.ginkgo/ diff --git a/pkg/cli/webapp/pnpm-lock.yaml b/pkg/cli/webapp/pnpm-lock.yaml index f9d8b8a..b1bf93d 100644 --- a/pkg/cli/webapp/pnpm-lock.yaml +++ b/pkg/cli/webapp/pnpm-lock.yaml @@ -55,6 +55,12 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.3.1(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)) + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: 19.2.17 version: 19.2.17 @@ -64,6 +70,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.4 version: 5.2.0(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 tailwindcss: specifier: ^4.2.2 version: 4.3.1 @@ -73,9 +82,15 @@ importers: vite: specifier: ^7.1.7 version: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0) + vitest: + specifier: ^3.2.6 + version: 3.2.7(@types/debug@4.1.13)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ai-sdk/gateway@3.0.139': resolution: {integrity: sha512-RFpxyh5j9g7ZMfKxhiwCcF7bGU872o3JvoiIqZbHOM4qkR4RCzeKJF+k+zHomcJYGeUabEbeMXTKNASzz4Toxw==} engines: {node: '>=18'} @@ -101,6 +116,9 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -172,6 +190,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -190,6 +212,34 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -645,6 +695,32 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -657,6 +733,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -753,6 +832,9 @@ packages: '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -804,12 +886,64 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ai@6.0.214: resolution: {integrity: sha512-9MlePEXT5pXtQv4fXqmiR0RG3DZU4Dbv+kU9ktEJC2COi2RH2WvI2GiyG9MuCqgPII6f1w+5kB5fNIiArqPzaQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -823,12 +957,20 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -841,6 +983,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -865,6 +1011,13 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1024,6 +1177,10 @@ packages: dagre-d3-es@7.0.14: resolution: {tarball: https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -1036,9 +1193,16 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} @@ -1053,6 +1217,12 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} @@ -1070,6 +1240,9 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -1089,10 +1262,17 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1147,12 +1327,24 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1160,6 +1352,10 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1186,6 +1382,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1193,6 +1392,18 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1303,9 +1514,19 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1459,6 +1680,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1471,6 +1696,9 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1490,6 +1718,13 @@ packages: path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1507,12 +1742,20 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + re-resizable@6.11.2: resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} peerDependencies: @@ -1533,6 +1776,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -1547,6 +1793,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + regex-recursion@5.1.1: resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==} @@ -1591,12 +1841,19 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1607,6 +1864,9 @@ packages: shiki@1.29.2: resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1614,6 +1874,12 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + streamdown@2.5.0: resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} peerDependencies: @@ -1623,6 +1889,13 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -1637,6 +1910,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -1651,6 +1927,12 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -1659,6 +1941,33 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1719,6 +2028,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1759,9 +2073,82 @@ packages: yaml: optional: true + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1773,6 +2160,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@ai-sdk/gateway@3.0.139(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.12 @@ -1806,6 +2195,14 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1895,6 +2292,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1922,6 +2321,26 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -2227,6 +2646,38 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 18.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -2248,6 +2699,11 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -2369,6 +2825,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.9 @@ -2423,6 +2881,50 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@7.1.4: {} + ai@6.0.214(zod@4.4.3): dependencies: '@ai-sdk/gateway': 3.0.139(zod@4.4.3) @@ -2431,6 +2933,18 @@ snapshots: '@opentelemetry/api': 1.9.1 zod: 4.4.3 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + bail@2.0.2: {} baseline-browser-mapping@2.10.40: {} @@ -2443,10 +2957,20 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + cac@6.7.14: {} + caniuse-lite@1.0.30001799: {} ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -2455,6 +2979,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + clsx@2.1.1: {} comma-separated-tokens@2.0.3: {} @@ -2473,6 +2999,13 @@ snapshots: dependencies: layout-base: 2.0.1 + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): @@ -2659,16 +3192,25 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + dayjs@1.11.21: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 @@ -2681,6 +3223,10 @@ snapshots: dependencies: dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -2696,6 +3242,8 @@ snapshots: entities@6.0.1: {} + es-module-lexer@1.7.0: {} + es-toolkit@1.49.0: {} esbuild@0.28.1: @@ -2733,8 +3281,14 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventsource-parser@3.1.0: {} + expect-type@1.4.0: {} + extend@3.0.2: {} fdir@6.5.0(picomatch@4.0.4): @@ -2843,16 +3397,36 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 import-meta-resolve@4.2.0: {} + indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -2872,10 +3446,41 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + jiti@2.7.0: {} js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-schema@0.4.0: {} @@ -2949,10 +3554,16 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3333,12 +3944,16 @@ snapshots: transitivePeerDependencies: - supports-color + min-indent@1.0.1: {} + ms@2.1.3: {} nanoid@3.3.15: {} node-releases@2.0.50: {} + nwsapi@2.2.24: {} + object-assign@4.1.1: {} oniguruma-to-es@2.3.0: @@ -3365,6 +3980,10 @@ snapshots: path-data-parser@0.1.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -3382,6 +4001,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3390,6 +4015,8 @@ snapshots: property-information@7.2.0: {} + punycode@2.3.1: {} + re-resizable@6.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -3410,6 +4037,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-refresh@0.18.0: {} react-rnd@10.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -3424,6 +4053,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + regex-recursion@5.1.1: dependencies: regex: 5.1.1 @@ -3526,10 +4160,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + rrweb-cssom@0.8.0: {} + rw@1.3.3: {} safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -3547,10 +4187,16 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + streamdown@2.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: clsx: 2.1.1 @@ -3579,6 +4225,14 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -3595,6 +4249,8 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.6.0(react@18.3.1) + symbol-tree@3.2.4: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.1: {} @@ -3603,6 +4259,10 @@ snapshots: throttleit@2.1.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -3610,6 +4270,26 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -3680,6 +4360,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.28.1 @@ -3693,8 +4394,78 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 + vitest@3.2.7(@types/debug@4.1.13)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0) + vite-node: 3.2.4(jiti@2.7.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} zod@4.4.3: {} From 0c94f51b117d822e3a229e37299d916bb6c7058d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 10:30:52 +0300 Subject: [PATCH 074/131] refactor(db): remove legacy session cutover from migrations Remove the legacy session-cache cutover and preflight rejection so Captain migrations apply the native schema directly. This also removes the exported cutover API and legacy validation table. BREAKING CHANGE: Legacy session-cache cutover support and ErrLegacySessionSchema are removed --- migrations/10_sessions.pg.hcl | 86 ---------------- migrations/migrations.go | 67 ++---------- migrations/migrations_test.go | 113 ++++----------------- pkg/cli/database.go | 10 +- pkg/database/database.go | 8 -- pkg/database/migration_integration_test.go | 65 ------------ 6 files changed, 27 insertions(+), 322 deletions(-) diff --git a/migrations/10_sessions.pg.hcl b/migrations/10_sessions.pg.hcl index b075062..ca69797 100644 --- a/migrations/10_sessions.pg.hcl +++ b/migrations/10_sessions.pg.hcl @@ -326,89 +326,3 @@ table "captain_session_processes" { expr = "cpu_percent >= 0 AND memory_percent >= 0 AND (memory_rss_bytes IS NULL OR memory_rss_bytes >= 0)" } } - -// One durable validation row is retained for the explicit legacy cache -// cutover. The source tables themselves are copied to versioned archive tables -// outside the managed HCL realm so they remain a directly usable rollback -// artifact rather than being reshaped by Atlas. -table "captain_legacy_session_cutovers" { - schema = schema.public - - column "cutover_key" { - null = false - type = text - } - column "legacy_sessions_table" { - null = false - type = text - } - column "legacy_prompts_table" { - null = true - type = text - } - column "legacy_session_rows" { - null = false - type = bigint - } - column "legacy_prompt_rows" { - null = false - type = bigint - } - column "imported_session_rows" { - null = false - type = bigint - } - column "imported_prompt_run_rows" { - null = false - type = bigint - } - column "legacy_sessions_checksum" { - null = false - type = text - } - column "legacy_prompts_checksum" { - null = true - type = text - } - column "native_sessions_checksum" { - null = false - type = text - } - column "native_prompt_runs_checksum" { - null = true - type = text - } - column "details" { - null = false - type = jsonb - default = sql("'{}'::jsonb") - } - column "started_at" { - null = false - type = timestamptz - default = sql("now()") - } - column "completed_at" { - null = false - type = timestamptz - } - column "updated_at" { - null = false - type = timestamptz - default = sql("now()") - } - - primary_key { - columns = [column.cutover_key] - } - - check "captain_legacy_session_cutovers_session_count" { - expr = "legacy_session_rows = imported_session_rows" - } - check "captain_legacy_session_cutovers_prompt_count" { - expr = "legacy_prompt_rows = imported_prompt_run_rows" - } - check "captain_legacy_session_cutovers_nonnegative" { - expr = "legacy_session_rows >= 0 AND legacy_prompt_rows >= 0" - } -} diff --git a/migrations/migrations.go b/migrations/migrations.go index 7aa5659..98d5559 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -30,12 +30,6 @@ const ( migrationUnlockTimeout = 5 * time.Second ) -// ErrLegacySessionSchema is returned before reconciliation when the database -// still contains the GORM-managed session summary cache that predates Captain's -// native schema. Callers can use errors.Is to direct users to an explicit -// backfill/cutover instead of allowing Atlas to reshape the table implicitly. -var ErrLegacySessionSchema = errors.New("legacy Captain session cache schema detected") - // schemaFS contains the complete Captain-owned schema. SQL files are colocated // with the HCL so commons-db/migrate applies their declared pre/post phases in // the same migration scope. @@ -48,14 +42,12 @@ type migrationLockHandle interface { } type applyDependencies struct { - acquireLock func(context.Context, string) (migrationLockHandle, error) - rejectLegacy func(context.Context, string) error - migrate func(context.Context, string) error + acquireLock func(context.Context, string) (migrationLockHandle, error) + migrate func(context.Context, string) error } var defaultApplyDependencies = applyDependencies{ - acquireLock: acquireMigrationLock, - rejectLegacy: rejectLegacySessionSchema, + acquireLock: acquireMigrationLock, migrate: func(ctx context.Context, connection string) error { return commonsmigrate.Apply(ctx, connection, schemaFS, commonsmigrate.WithName(Scope), @@ -65,10 +57,10 @@ var defaultApplyDependencies = applyDependencies{ } // Apply reconciles the checked-in HCL schema, then applies the colocated SQL -// migrations. A Captain-specific session advisory lock serializes the legacy -// preflight and the complete migration bundle across processes. It is safe to -// call repeatedly and uses a stable scope so Captain can share a database with -// other independently migrated applications. +// migrations. A Captain-specific session advisory lock serializes the complete +// migration bundle across processes. It is safe to call repeatedly and uses a +// stable scope so Captain can share a database with other independently +// migrated applications. func Apply(ctx context.Context, connection string) error { return apply(ctx, connection, defaultApplyDependencies) } @@ -88,9 +80,6 @@ func apply(ctx context.Context, connection string, deps applyDependencies) (resu } }() - if err := deps.rejectLegacy(ctx, connection); err != nil { - return err - } if err := deps.migrate(ctx, connection); err != nil { return fmt.Errorf("migrate Captain database: %w", err) } @@ -153,45 +142,3 @@ func (lock *migrationLock) Close() error { }) return lock.err } - -func rejectLegacySessionSchema(ctx context.Context, connection string) error { - db, err := commonsdb.NewDB(connection) - if err != nil { - return fmt.Errorf("open Captain migration preflight database: %w", err) - } - defer db.Close() - - rows, err := db.QueryContext(ctx, `SELECT column_name, data_type - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'captain_sessions'`) - if err != nil { - return fmt.Errorf("inspect Captain session schema: %w", err) - } - defer rows.Close() - - columns := map[string]string{} - for rows.Next() { - var name, dataType string - if err := rows.Scan(&name, &dataType); err != nil { - return fmt.Errorf("read Captain session schema: %w", err) - } - columns[name] = dataType - } - if err := rows.Err(); err != nil { - return fmt.Errorf("read Captain session schema: %w", err) - } - if isLegacySessionSchema(columns) { - return fmt.Errorf("%w: public.captain_sessions still uses the legacy path-keyed summary shape; run the explicit legacy session backfill/cutover before applying Captain HCL", ErrLegacySessionSchema) - } - return nil -} - -func isLegacySessionSchema(columns map[string]string) bool { - if _, hasPath := columns["path"]; !hasPath { - return false - } - _, hasLifecycle := columns["lifecycle_status"] - _, hasActivity := columns["activity_state"] - _, hasHealth := columns["health_state"] - return columns["id"] != "uuid" || !hasLifecycle || !hasActivity || !hasHealth -} diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index be43f06..af19492 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -47,9 +47,6 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { `column "activity_state"`, `column "health_state"`, `column "state_version"`, - `table "captain_legacy_session_cutovers"`, - `column "legacy_sessions_checksum"`, - `column "native_sessions_checksum"`, ) assertContainsAll(t, "20_prompt_runs_and_plans.pg.hcl", `table "captain_prompt_runs"`, @@ -149,53 +146,14 @@ func TestSchemaBundleHasNoAlwaysRunScripts(t *testing.T) { } } -func TestLegacySessionSchemaDetection(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - columns map[string]string - legacy bool - }{ - {name: "fresh database", columns: map[string]string{}}, - {name: "unrelated table shape", columns: map[string]string{"id": "text"}}, - { - name: "authoritative schema", - columns: map[string]string{ - "id": "uuid", "path": "text", "lifecycle_status": "USER-DEFINED", - "activity_state": "USER-DEFINED", "health_state": "USER-DEFINED", - }, - }, - { - name: "legacy GORM cache", - columns: map[string]string{"id": "text", "path": "text", "mod_unix": "bigint"}, - legacy: true, - }, - { - name: "partial native conversion", - columns: map[string]string{"id": "uuid", "path": "text", "lifecycle_status": "USER-DEFINED"}, - legacy: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := isLegacySessionSchema(tt.columns); got != tt.legacy { - t.Fatalf("isLegacySessionSchema() = %v, want %v", got, tt.legacy) - } - }) - } -} - -func TestApplyRejectsEmptyConnectionBeforePreflight(t *testing.T) { +func TestApplyRejectsEmptyConnection(t *testing.T) { t.Parallel() if err := Apply(t.Context(), " "); err == nil { t.Fatal("Apply unexpectedly accepted an empty connection string") } } -func TestApplyHoldsMigrationLockAcrossPreflightAndMigration(t *testing.T) { +func TestApplyHoldsMigrationLockAcrossMigration(t *testing.T) { t.Parallel() var events []string @@ -204,10 +162,6 @@ func TestApplyHoldsMigrationLockAcrossPreflightAndMigration(t *testing.T) { events = append(events, "lock") return &recordingMigrationLock{events: &events}, nil }, - rejectLegacy: func(context.Context, string) error { - events = append(events, "preflight") - return nil - }, migrate: func(context.Context, string) error { events = append(events, "migrate") return nil @@ -216,58 +170,28 @@ func TestApplyHoldsMigrationLockAcrossPreflightAndMigration(t *testing.T) { if err != nil { t.Fatalf("apply: %v", err) } - assertEventsEqual(t, events, []string{"lock", "preflight", "migrate", "unlock"}) + assertEventsEqual(t, events, []string{"lock", "migrate", "unlock"}) } -func TestApplyReleasesMigrationLockOnEveryFailurePath(t *testing.T) { +func TestApplyReleasesMigrationLockOnMigrationFailure(t *testing.T) { t.Parallel() - tests := []struct { - name string - preflight error - migration error - wantEvents []string - }{ - { - name: "legacy preflight", - preflight: ErrLegacySessionSchema, - wantEvents: []string{"lock", "preflight", "unlock"}, + var events []string + migrationErr := errors.New("atlas failed") + err := apply(t.Context(), "postgres://captain", applyDependencies{ + acquireLock: func(context.Context, string) (migrationLockHandle, error) { + events = append(events, "lock") + return &recordingMigrationLock{events: &events}, nil }, - { - name: "schema migration", - migration: errors.New("atlas failed"), - wantEvents: []string{"lock", "preflight", "migrate", "unlock"}, + migrate: func(context.Context, string) error { + events = append(events, "migrate") + return migrationErr }, + }) + if !errors.Is(err, migrationErr) { + t.Fatalf("apply error = %v, want errors.Is(_, %v)", err, migrationErr) } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var events []string - err := apply(t.Context(), "postgres://captain", applyDependencies{ - acquireLock: func(context.Context, string) (migrationLockHandle, error) { - events = append(events, "lock") - return &recordingMigrationLock{events: &events}, nil - }, - rejectLegacy: func(context.Context, string) error { - events = append(events, "preflight") - return tt.preflight - }, - migrate: func(context.Context, string) error { - events = append(events, "migrate") - return tt.migration - }, - }) - wantErr := tt.preflight - if wantErr == nil { - wantErr = tt.migration - } - if !errors.Is(err, wantErr) { - t.Fatalf("apply error = %v, want errors.Is(_, %v)", err, wantErr) - } - assertEventsEqual(t, events, tt.wantEvents) - }) - } + assertEventsEqual(t, events, []string{"lock", "migrate", "unlock"}) } func TestApplyReportsLockAcquisitionAndReleaseErrors(t *testing.T) { @@ -294,8 +218,7 @@ func TestApplyReportsLockAcquisitionAndReleaseErrors(t *testing.T) { acquireLock: func(context.Context, string) (migrationLockHandle, error) { return &recordingMigrationLock{err: releaseErr}, nil }, - rejectLegacy: func(context.Context, string) error { return nil }, - migrate: func(context.Context, string) error { return migrationErr }, + migrate: func(context.Context, string) error { return migrationErr }, }) if !errors.Is(err, migrationErr) || !errors.Is(err, releaseErr) { t.Fatalf("apply error = %v, want joined migration and release errors", err) diff --git a/pkg/cli/database.go b/pkg/cli/database.go index bd45509..52e68b5 100644 --- a/pkg/cli/database.go +++ b/pkg/cli/database.go @@ -38,8 +38,7 @@ var captainDBState struct { // captainDB opens (once) the native Captain database: resolve the configured // DSN (gavel-shared or explicit env) or start the shared embedded postgres, -// run migrations — including the idempotent legacy session-cache cutover — and -// wrap the pool. +// run migrations, and wrap the pool. func captainDB(ctx context.Context) (*database.DB, error) { captainDBState.mu.Lock() defer captainDBState.mu.Unlock() @@ -96,14 +95,9 @@ func openCaptainDB(ctx context.Context) (*database.DB, string, string, error) { return nil, "", "", err } log.Debugf("captain database using %s", source) - report, err := database.MigrateWithLegacySessionCutover(ctx, dsn) - if err != nil { + if err := database.Migrate(ctx, dsn); err != nil { return nil, "", "", fmt.Errorf("migrate captain database (%s): %w", source, err) } - if report != nil { - log.Infof("migrated legacy session cache: %d sessions, %d prompt runs", - report.ImportedSessionRows, report.ImportedPromptRunRows) - } gormDB, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) if err != nil { return nil, "", "", fmt.Errorf("open captain database (%s): %w", source, err) diff --git a/pkg/database/database.go b/pkg/database/database.go index 147242b..e22cb53 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -93,14 +93,6 @@ func Migrate(ctx context.Context, dsn string) error { return migrations.Apply(ctx, dsn) } -// MigrateWithLegacySessionCutover explicitly archives and backfills the -// path-keyed session summary cache before applying Captain's authoritative HCL -// schema. Normal Migrate remains fail-closed when it encounters the legacy -// shape so hosts must opt into the data conversion deliberately. -func MigrateWithLegacySessionCutover(ctx context.Context, dsn string) (*migrations.LegacySessionCutoverReport, error) { - return migrations.ApplyWithLegacySessionCutover(ctx, dsn) -} - // Gorm returns the application pool supplied to or opened by Captain. func (db *DB) Gorm() *gorm.DB { if db == nil { diff --git a/pkg/database/migration_integration_test.go b/pkg/database/migration_integration_test.go index 697de45..3efbb99 100644 --- a/pkg/database/migration_integration_test.go +++ b/pkg/database/migration_integration_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "testing" - "github.com/flanksource/captain/migrations" commonsdb "github.com/flanksource/commons-db/db" "github.com/stretchr/testify/require" ) @@ -101,67 +100,3 @@ func TestCaptainMigrationsAreIdempotentAndShareOnePool(t *testing.T) { require.True(t, exists, "%s should exist", view) } } - -func TestCaptainMigrationsRejectLegacySessionCacheWithoutMutation(t *testing.T) { - if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { - t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres migration tests") - } - - dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ - DataDir: filepath.Join(t.TempDir(), "postgres"), - Database: "captain_legacy_preflight", - }) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stop()) }) - - legacy, err := commonsdb.NewGorm(dsn, commonsdb.DefaultGormConfig()) - require.NoError(t, err) - legacySQL, err := legacy.DB() - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, legacySQL.Close()) }) - - require.NoError(t, legacy.Exec(`CREATE TABLE public.captain_sessions ( - path text PRIMARY KEY, - id text, - source text, - mod_unix bigint, - title text - )`).Error) - require.NoError(t, legacy.Exec(`INSERT INTO public.captain_sessions - (path, id, source, mod_unix, title) - VALUES ('/tmp/session.jsonl', 'legacy-session', 'codex', 42, 'preserve me')`).Error) - - _, err = Open(t.Context(), Config{DSN: dsn}) - require.ErrorIs(t, err, migrations.ErrLegacySessionSchema) - require.ErrorContains(t, err, "explicit legacy session backfill/cutover") - - var preserved struct { - Path string - ID string - Source string - ModUnix int64 - Title string - } - require.NoError(t, legacy.Raw(`SELECT path, id, source, mod_unix, title - FROM public.captain_sessions WHERE path = '/tmp/session.jsonl'`).Scan(&preserved).Error) - require.Equal(t, "/tmp/session.jsonl", preserved.Path) - require.Equal(t, "legacy-session", preserved.ID) - require.Equal(t, "codex", preserved.Source) - require.EqualValues(t, 42, preserved.ModUnix) - require.Equal(t, "preserve me", preserved.Title) - require.False(t, legacy.Migrator().HasColumn("captain_sessions", "lifecycle_status")) - require.False(t, legacy.Migrator().HasTable("captain_prompt_runs")) - - var idType string - require.NoError(t, legacy.Raw(`SELECT data_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'captain_sessions' - AND column_name = 'id'`).Scan(&idType).Error) - require.Equal(t, "text", idType) - - var migrationMetadataExists bool - require.NoError(t, legacy.Raw(`SELECT to_regclass('public.schema_migration_scripts') IS NOT NULL`).Scan(&migrationMetadataExists).Error) - require.False(t, migrationMetadataExists, "preflight must fail before commons-db/migrate creates metadata") - -} From c710f88ee45831d0a59c6c23b706a5832aff6f7d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 12:03:53 +0300 Subject: [PATCH 075/131] refactor(db): remove legacy session cache cutover Remove the obsolete legacy session-cache cutover implementation and its integration coverage. BREAKING CHANGE: removes the exported ApplyWithLegacySessionCutover migration API and legacy cache backfill support --- migrations/legacy_cutover.go | 1309 ----------------- migrations/legacy_cutover_integration_test.go | 591 -------- 2 files changed, 1900 deletions(-) delete mode 100644 migrations/legacy_cutover.go delete mode 100644 migrations/legacy_cutover_integration_test.go diff --git a/migrations/legacy_cutover.go b/migrations/legacy_cutover.go deleted file mode 100644 index 703d08a..0000000 --- a/migrations/legacy_cutover.go +++ /dev/null @@ -1,1309 +0,0 @@ -package migrations - -import ( - "context" - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "time" - - commonsdb "github.com/flanksource/commons-db/db" - "github.com/google/uuid" -) - -const ( - legacyCutoverKey = "captain-session-cache-v1" - legacySessionsTable = "captain_sessions" - legacyPromptsTable = "captain_session_prompts" - archiveSessionsTable = "captain_sessions_legacy_v1" - archivePromptsTable = "captain_session_prompts_legacy_v1" - legacyCutoverStateNote = "imported from the legacy Captain session cache" -) - -var ( - legacySessionNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://github.com/flanksource/captain/legacy-session-cache/v1")) - legacyPromptNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://github.com/flanksource/captain/legacy-session-prompt/v1")) - legacySourceNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://github.com/flanksource/captain/legacy-session-source/v1")) -) - -// LegacySessionCutoverReport is the durable validation result for the explicit -// legacy path-keyed session-cache cutover. The same values are stored in -// captain_legacy_session_cutovers; the versioned archive tables remain intact -// as the rollback artifact. -type LegacySessionCutoverReport struct { - CutoverKey string `json:"cutoverKey"` - LegacySessionsTable string `json:"legacySessionsTable"` - LegacyPromptsTable *string `json:"legacyPromptsTable,omitempty"` - LegacySessionRows int64 `json:"legacySessionRows"` - LegacyPromptRows int64 `json:"legacyPromptRows"` - ImportedSessionRows int64 `json:"importedSessionRows"` - ImportedPromptRunRows int64 `json:"importedPromptRunRows"` - LegacySessionsChecksum string `json:"legacySessionsChecksum"` - LegacyPromptsChecksum *string `json:"legacyPromptsChecksum,omitempty"` - NativeSessionsChecksum string `json:"nativeSessionsChecksum"` - NativePromptRunsChecksum *string `json:"nativePromptRunsChecksum,omitempty"` - Details json.RawMessage `json:"details"` - StartedAt time.Time `json:"startedAt"` - CompletedAt time.Time `json:"completedAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type archiveState struct { - hasArchive bool - hasPromptArchive bool -} - -type legacySessionRow struct { - Raw json.RawMessage - Data map[string]any - Path string - ID string - ParentID string - Source string - StartedAt *time.Time - EndedAt *time.Time - UpdatedAt *time.Time - NativeID uuid.UUID - Parent *legacySessionRow - Root *legacySessionRow - Depth int -} - -type legacyPromptRow struct { - Raw json.RawMessage - Data map[string]any - SessionID string - RunID string - NativeID uuid.UUID -} - -// ApplyWithLegacySessionCutover is the explicit host opt-in for replacing the -// old GORM session summary cache with Captain's authoritative HCL schema. It: -// -// 1. validates and copies the legacy tables to versioned archive tables, -// 2. verifies the copies byte-for-byte before dropping the conflicting names, -// 3. applies Captain's commons-db/migrate HCL bundle, -// 4. backfills native sessions and prompt runs, and -// 5. persists and returns a checksum/count validation report. -// -// Every phase is idempotent. A crash after archiving resumes from the archive; -// a crash after migration resumes the upserts. Apply remains conservative and -// continues to reject the legacy shape unless a host calls this API explicitly. -func ApplyWithLegacySessionCutover(ctx context.Context, connection string) (_ *LegacySessionCutoverReport, resultErr error) { - connection = strings.TrimSpace(connection) - if connection == "" { - return nil, errors.New("captain migration connection string is empty") - } - - lock, err := acquireMigrationLock(ctx, connection) - if err != nil { - return nil, fmt.Errorf("acquire Captain migration lock: %w", err) - } - defer func() { - if err := lock.Close(); err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("release Captain migration lock: %w", err)) - } - }() - - db, err := commonsdb.NewDB(connection) - if err != nil { - return nil, fmt.Errorf("open Captain legacy cutover database: %w", err) - } - state, err := archiveLegacySessionCache(ctx, db) - if closeErr := db.Close(); closeErr != nil { - err = errors.Join(err, fmt.Errorf("close Captain legacy cutover database: %w", closeErr)) - } - if err != nil { - return nil, err - } - - if err := defaultApplyDependencies.migrate(ctx, connection); err != nil { - return nil, fmt.Errorf("migrate Captain database after legacy archive: %w", err) - } - - db, err = commonsdb.NewDB(connection) - if err != nil { - return nil, fmt.Errorf("open migrated Captain cutover database: %w", err) - } - defer func() { - if err := db.Close(); err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("close migrated Captain cutover database: %w", err)) - } - }() - - if !state.hasArchive { - exists, err := storedCutoverExists(ctx, db) - if err != nil { - return nil, err - } - if exists { - return nil, fmt.Errorf("captain legacy cutover report exists but rollback table public.%s is missing", archiveSessionsTable) - } - return nil, nil - } - - return backfillLegacySessionCache(ctx, db, state) -} - -func archiveLegacySessionCache(ctx context.Context, db *sql.DB) (archiveState, error) { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return archiveState{}, fmt.Errorf("begin Captain legacy archive transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - sessionsExists, err := relationExists(ctx, tx, legacySessionsTable) - if err != nil { - return archiveState{}, err - } - sessionsArchiveExists, err := relationExists(ctx, tx, archiveSessionsTable) - if err != nil { - return archiveState{}, err - } - promptsExists, err := relationExists(ctx, tx, legacyPromptsTable) - if err != nil { - return archiveState{}, err - } - promptsArchiveExists, err := relationExists(ctx, tx, archivePromptsTable) - if err != nil { - return archiveState{}, err - } - - if promptsArchiveExists && !sessionsArchiveExists { - return archiveState{}, fmt.Errorf("orphaned Captain rollback table public.%s exists without public.%s", archivePromptsTable, archiveSessionsTable) - } - if sessionsArchiveExists { - if sessionsExists { - columns, err := relationColumns(ctx, tx, legacySessionsTable) - if err != nil { - return archiveState{}, err - } - if isLegacySessionSchema(columns) { - return archiveState{}, fmt.Errorf("both live legacy table public.%s and rollback table public.%s exist; refusing to choose or overwrite either", legacySessionsTable, archiveSessionsTable) - } - if !isAuthoritativeSessionSchema(columns) { - return archiveState{}, fmt.Errorf("unknown public.%s shape exists beside the rollback archive; refusing to migrate it", legacySessionsTable) - } - } - if promptsExists { - return archiveState{}, fmt.Errorf("legacy prompt table public.%s still exists beside an already archived session cache", legacyPromptsTable) - } - return archiveState{hasArchive: true, hasPromptArchive: promptsArchiveExists}, tx.Commit() - } - - if !sessionsExists { - if promptsExists { - return archiveState{}, fmt.Errorf("legacy prompt table public.%s exists without public.%s", legacyPromptsTable, legacySessionsTable) - } - return archiveState{}, tx.Commit() - } - - columns, err := relationColumns(ctx, tx, legacySessionsTable) - if err != nil { - return archiveState{}, err - } - if !isLegacySessionSchema(columns) { - if !isAuthoritativeSessionSchema(columns) { - return archiveState{}, fmt.Errorf("unknown public.%s shape; expected the legacy path-keyed cache or Captain's authoritative UUID schema", legacySessionsTable) - } - if promptsExists { - return archiveState{}, fmt.Errorf("stray legacy table public.%s exists beside Captain's authoritative session schema", legacyPromptsTable) - } - return archiveState{}, tx.Commit() - } - // Freeze both cache tables before the first snapshot. Without this lock a - // legacy cache writer could commit between checksum validation and DROP. - if _, err := tx.ExecContext(ctx, `LOCK TABLE public.captain_sessions IN ACCESS EXCLUSIVE MODE`); err != nil { - return archiveState{}, fmt.Errorf("freeze Captain legacy session cache: %w", err) - } - if promptsExists { - if _, err := tx.ExecContext(ctx, `LOCK TABLE public.captain_session_prompts IN ACCESS EXCLUSIVE MODE`); err != nil { - return archiveState{}, fmt.Errorf("freeze Captain legacy prompt cache: %w", err) - } - } - - sessions, sessionRaw, err := loadLegacySessions(ctx, tx, legacySessionsTable) - if err != nil { - return archiveState{}, err - } - prompts, promptRaw, err := loadLegacyPromptsIfPresent(ctx, tx, promptsExists, legacyPromptsTable) - if err != nil { - return archiveState{}, err - } - if err := validateLegacyDataset(sessions, prompts); err != nil { - return archiveState{}, fmt.Errorf("validate Captain legacy session cache: %w", err) - } - - if _, err := tx.ExecContext(ctx, `CREATE TABLE public.captain_sessions_legacy_v1 - (LIKE public.captain_sessions INCLUDING ALL)`); err != nil { - return archiveState{}, fmt.Errorf("create Captain legacy session rollback table: %w", err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_sessions_legacy_v1 - SELECT * FROM public.captain_sessions`); err != nil { - return archiveState{}, fmt.Errorf("copy Captain legacy session rollback data: %w", err) - } - if promptsExists { - if _, err := tx.ExecContext(ctx, `CREATE TABLE public.captain_session_prompts_legacy_v1 - (LIKE public.captain_session_prompts INCLUDING ALL)`); err != nil { - return archiveState{}, fmt.Errorf("create Captain legacy prompt rollback table: %w", err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_session_prompts_legacy_v1 - SELECT * FROM public.captain_session_prompts`); err != nil { - return archiveState{}, fmt.Errorf("copy Captain legacy prompt rollback data: %w", err) - } - } - - _, archivedSessionRaw, err := loadLegacySessions(ctx, tx, archiveSessionsTable) - if err != nil { - return archiveState{}, err - } - if checksumJSONRows(sessionRaw) != checksumJSONRows(archivedSessionRaw) || len(sessionRaw) != len(archivedSessionRaw) { - return archiveState{}, errors.New("captain legacy session rollback copy failed count/checksum validation") - } - if promptsExists { - _, archivedPromptRaw, err := loadLegacyPromptsIfPresent(ctx, tx, true, archivePromptsTable) - if err != nil { - return archiveState{}, err - } - if checksumJSONRows(promptRaw) != checksumJSONRows(archivedPromptRaw) || len(promptRaw) != len(archivedPromptRaw) { - return archiveState{}, errors.New("captain legacy prompt rollback copy failed count/checksum validation") - } - } - - if promptsExists { - if _, err := tx.ExecContext(ctx, `DROP TABLE public.captain_session_prompts`); err != nil { - return archiveState{}, fmt.Errorf("remove validated legacy prompt table name: %w", err) - } - } - if _, err := tx.ExecContext(ctx, `DROP TABLE public.captain_sessions`); err != nil { - return archiveState{}, fmt.Errorf("remove validated legacy session table name: %w", err) - } - if err := tx.Commit(); err != nil { - return archiveState{}, fmt.Errorf("commit Captain legacy rollback archive: %w", err) - } - return archiveState{hasArchive: true, hasPromptArchive: promptsExists}, nil -} - -func isAuthoritativeSessionSchema(columns map[string]string) bool { - return columns["id"] == "uuid" && - columns["lifecycle_status"] == "USER-DEFINED" && - columns["activity_state"] == "USER-DEFINED" && - columns["health_state"] == "USER-DEFINED" -} - -func backfillLegacySessionCache(ctx context.Context, db *sql.DB, state archiveState) (*LegacySessionCutoverReport, error) { - startedAt := time.Now().UTC() - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("begin Captain legacy backfill transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - sessions, sessionRaw, err := loadLegacySessions(ctx, tx, archiveSessionsTable) - if err != nil { - return nil, err - } - prompts, promptRaw, err := loadLegacyPromptsIfPresent(ctx, tx, state.hasPromptArchive, archivePromptsTable) - if err != nil { - return nil, err - } - if err := validateLegacyDataset(sessions, prompts); err != nil { - return nil, fmt.Errorf("revalidate Captain legacy rollback archive: %w", err) - } - if err := setLegacyBackfillEmitTriggers(ctx, tx, false); err != nil { - return nil, err - } - - sort.Slice(sessions, func(i, j int) bool { - if sessions[i].Depth == sessions[j].Depth { - return sessions[i].Path < sessions[j].Path - } - return sessions[i].Depth < sessions[j].Depth - }) - - nativeSessionLines := make([]string, 0, len(sessions)) - nativeSourceLines := make([]string, 0, len(sessions)) - for _, session := range sessions { - line, err := upsertAndValidateNativeSession(ctx, tx, session) - if err != nil { - return nil, err - } - nativeSessionLines = append(nativeSessionLines, string(line)) - sourceLine, err := upsertAndValidateNativeSessionSource(ctx, tx, session) - if err != nil { - return nil, err - } - nativeSourceLines = append(nativeSourceLines, string(sourceLine)) - } - sort.Strings(nativeSessionLines) - sort.Strings(nativeSourceLines) - - nativePromptLines := make([]string, 0, len(prompts)) - for _, prompt := range prompts { - line, err := upsertAndValidateNativePromptRun(ctx, tx, prompt, sessions) - if err != nil { - return nil, err - } - nativePromptLines = append(nativePromptLines, string(line)) - } - sort.Strings(nativePromptLines) - if err := setLegacyBackfillEmitTriggers(ctx, tx, true); err != nil { - return nil, err - } - - // Re-select after every insert and trigger has completed. The durable report - // hashes database state, not the intended inputs returned by the upsert - // helpers, so later drift cannot masquerade as a successful replay. - nativeSessionLines = nativeSessionLines[:0] - nativeSourceLines = nativeSourceLines[:0] - for _, session := range sessions { - line, err := readPersistedNativeRow(ctx, tx, "captain_sessions", session.NativeID) - if err != nil { - return nil, err - } - nativeSessionLines = append(nativeSessionLines, string(line)) - sourceID := uuid.NewSHA1(legacySourceNamespace, []byte(session.Path)) - sourceLine, err := readPersistedNativeRow(ctx, tx, "captain_session_sources", sourceID) - if err != nil { - return nil, err - } - nativeSourceLines = append(nativeSourceLines, string(sourceLine)) - } - nativePromptLines = nativePromptLines[:0] - for _, prompt := range prompts { - line, err := readPersistedNativeRow(ctx, tx, "captain_prompt_runs", prompt.NativeID) - if err != nil { - return nil, err - } - nativePromptLines = append(nativePromptLines, string(line)) - } - sort.Strings(nativeSessionLines) - sort.Strings(nativeSourceLines) - sort.Strings(nativePromptLines) - - nativeSessionStateLines := make([]string, 0, len(nativeSessionLines)+len(nativeSourceLines)) - for _, line := range nativeSessionLines { - nativeSessionStateLines = append(nativeSessionStateLines, "session\x00"+line) - } - for _, line := range nativeSourceLines { - nativeSessionStateLines = append(nativeSessionStateLines, "source\x00"+line) - } - sort.Strings(nativeSessionStateLines) - - completedAt := time.Now().UTC() - sessionMappings := make([]map[string]any, 0, len(sessions)) - providerFallbacks := 0 - legacyPlans := 0 - for _, session := range sessions { - if nestedString(session.Data, "provider", "name") == "" { - providerFallbacks++ - } - if plan, ok := session.Data["plan"].(map[string]any); ok && len(plan) > 0 { - legacyPlans++ - } - mapping := map[string]any{ - "path": session.Path, "legacyId": session.ID, "nativeId": session.NativeID, - "source": session.Source, "provider": legacyProvider(session), "hostId": "local", - } - if session.Parent != nil { - mapping["parentNativeId"] = session.Parent.NativeID - mapping["rootNativeId"] = session.Root.NativeID - } - sessionMappings = append(sessionMappings, mapping) - } - promptMappings := make([]map[string]any, 0, len(prompts)) - for _, prompt := range prompts { - promptMappings = append(promptMappings, map[string]any{ - "legacySessionId": prompt.SessionID, "legacyRunId": prompt.RunID, "nativePromptRunId": prompt.NativeID, - }) - } - archiveSessionColumns, err := relationColumns(ctx, tx, archiveSessionsTable) - if err != nil { - return nil, err - } - archivePromptColumns := map[string]string{} - if state.hasPromptArchive { - archivePromptColumns, err = relationColumns(ctx, tx, archivePromptsTable) - if err != nil { - return nil, err - } - } - details, err := json.Marshal(map[string]any{ - "archiveValidation": "source and rollback rows matched before the conflicting legacy table names were removed", - "identityMapping": "valid legacy UUIDs are preserved; other IDs use UUIDv5 in Captain's fixed legacy-session namespace", - "lifecycleMapping": "ended sessions become succeeded, started sessions without an end become interrupted, otherwise created", - "mappingLedger": map[string]any{ - "sessions": sessionMappings, - "prompts": promptMappings, - }, - "nativeSessionSources": map[string]any{ - "rows": len(nativeSourceLines), - "checksum": checksumStrings(nativeSourceLines), - }, - "schemaFingerprints": map[string]any{ - "sessions": checksumColumns(archiveSessionColumns), - "prompts": checksumColumns(archivePromptColumns), - }, - "warnings": map[string]any{ - "providerFallbackRows": providerFallbacks, - "legacyPlanRows": legacyPlans, - "legacyPlanHandling": "preserved losslessly in native session metadata and rollback archive; no synthetic execution plan was fabricated", - "summaryOnly": "aggregate cost, usage, files, approvals, and message counts remain in native session metadata and the rollback archive", - }, - "rollback": map[string]any{ - "sessionsTable": archiveSessionsTable, - "promptsTable": optionalArchiveName(state.hasPromptArchive), - }, - }) - if err != nil { - return nil, fmt.Errorf("marshal Captain legacy cutover details: %w", err) - } - - report := &LegacySessionCutoverReport{ - CutoverKey: legacyCutoverKey, - LegacySessionsTable: archiveSessionsTable, - LegacyPromptsTable: optionalStringPointer(optionalArchiveName(state.hasPromptArchive)), - LegacySessionRows: int64(len(sessions)), - LegacyPromptRows: int64(len(prompts)), - ImportedSessionRows: int64(len(nativeSessionLines)), - ImportedPromptRunRows: int64(len(nativePromptLines)), - LegacySessionsChecksum: checksumJSONRows(sessionRaw), - LegacyPromptsChecksum: optionalChecksum(state.hasPromptArchive, promptRaw), - NativeSessionsChecksum: checksumStrings(nativeSessionStateLines), - NativePromptRunsChecksum: optionalChecksum(state.hasPromptArchive, stringsToRaw(nativePromptLines)), - Details: details, - StartedAt: startedAt, - CompletedAt: completedAt, - UpdatedAt: completedAt, - } - - existing, exists, err := readCutoverReport(ctx, tx) - if err != nil { - return nil, err - } - if exists { - if !sameCutoverValidation(existing, report) { - return nil, errors.New("captain legacy cutover validation no longer matches the durable completed report") - } - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit repeated Captain legacy session validation: %w", err) - } - return existing, nil - } - if err := insertCutoverReport(ctx, tx, report); err != nil { - return nil, err - } - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit Captain legacy session backfill: %w", err) - } - return report, nil -} - -func upsertAndValidateNativeSessionSource(ctx context.Context, tx *sql.Tx, row *legacySessionRow) (string, error) { - sourceID := uuid.NewSHA1(legacySourceNamespace, []byte(row.Path)) - observedSize := jsonInt64(row.Data, "size") - byteOffset := observedSize - parserVersion := jsonInt64(row.Data, "summary_version") - if parserVersion < 1 { - parserVersion = 1 - } - var observedModTime any - if modUnix := jsonInt64(row.Data, "mod_unix"); modUnix > 0 { - observedModTime = time.Unix(0, modUnix).UTC() - } - createdAt := firstTime(row.StartedAt, row.UpdatedAt) - updatedAt := firstTime(row.UpdatedAt, row.EndedAt, row.StartedAt) - - _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_session_sources ( - id, session_id, source_kind, path, source_identity, parser_version, - byte_offset, observed_size, observed_mod_time, created_at, updated_at - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - ON CONFLICT (id) DO NOTHING`, - sourceID, row.NativeID, row.Source, row.Path, row.ID, parserVersion, - byteOffset, observedSize, observedModTime, createdAt, updatedAt, - ) - if err != nil { - return "", fmt.Errorf("backfill native Captain session source %q: %w", row.Path, err) - } - - actual, err := validatePersistedNativeRow(ctx, tx, "captain_session_sources", sourceID, map[string]any{ - "id": sourceID, "session_id": row.NativeID, "source_kind": row.Source, - "path": row.Path, "source_identity": row.ID, "parser_version": parserVersion, - "byte_offset": byteOffset, "observed_size": observedSize, - "observed_mod_time": observedModTime, "last_event_key": nil, - "created_at": createdAt, "updated_at": updatedAt, - }) - if err != nil { - return "", fmt.Errorf("native Captain session source %s conflicts with legacy path %q: %w", sourceID, row.Path, err) - } - return actual, nil -} - -func upsertAndValidateNativeSession(ctx context.Context, tx *sql.Tx, row *legacySessionRow) (string, error) { - provider := legacyProvider(row) - - var parentID, rootID any - if row.Parent != nil { - parentID = row.Parent.NativeID - rootID = row.Root.NativeID - } - - lifecycle := "created" - if row.EndedAt != nil { - lifecycle = "succeeded" - } else if row.StartedAt != nil { - lifecycle = "interrupted" - } - observedAt := firstTime(row.UpdatedAt, row.EndedAt, row.StartedAt) - createdAt := firstTime(row.StartedAt, row.UpdatedAt) - updatedAt := firstTime(row.UpdatedAt, row.EndedAt, row.StartedAt) - - git := row.Data["git"] - if _, ok := git.(map[string]any); !ok { - git = map[string]any{} - } - gitJSON, err := json.Marshal(git) - if err != nil { - return "", fmt.Errorf("marshal legacy git for session %q: %w", row.ID, err) - } - metadata := map[string]any{ - "legacy_cache": row.Data, - "legacy_cutover": map[string]any{"key": legacyCutoverKey, "archive_table": archiveSessionsTable}, - } - metadataJSON, err := json.Marshal(metadata) - if err != nil { - return "", fmt.Errorf("marshal legacy metadata for session %q: %w", row.ID, err) - } - - _, err = tx.ExecContext(ctx, `INSERT INTO public.captain_sessions ( - id, provider_session_id, source, provider, host_id, - parent_session_id, root_session_id, path, project, cwd, title, - initial_prompt, slug, agent_type, description, cli_version, - lifecycle_status, activity_state, health_state, state_reason, - state_version, state_observed_at, git, metadata, started_at, - ended_at, last_activity_at, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, 'local', - $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, - $16, 'idle', 'healthy', $17, - 0, $18, $19::jsonb, $20::jsonb, $21, - $22, $23, $24, $25 - ) ON CONFLICT (id) DO NOTHING`, - row.NativeID, row.ID, row.Source, provider, - parentID, rootID, row.Path, nullableJSONText(row.Data, "project"), nullableJSONText(row.Data, "cwd"), nullableJSONText(row.Data, "title"), - nullableJSONText(row.Data, "initial_prompt"), nullableJSONText(row.Data, "slug"), nullableJSONText(row.Data, "agent_type"), nullableJSONText(row.Data, "agent_desc"), nestedNullableText(row.Data, "provider", "version"), - lifecycle, legacyCutoverStateNote, observedAt, string(gitJSON), string(metadataJSON), row.StartedAt, - row.EndedAt, observedAt, createdAt, updatedAt, - ) - if err != nil { - return "", fmt.Errorf("backfill native Captain session %q: %w", row.ID, err) - } - - actual, err := validatePersistedNativeRow(ctx, tx, "captain_sessions", row.NativeID, map[string]any{ - "id": row.NativeID, "provider_session_id": row.ID, "source": row.Source, - "provider": provider, "host_id": "local", "parent_session_id": parentID, - "root_session_id": rootID, "path": row.Path, - "project": nullableJSONText(row.Data, "project"), "cwd": nullableJSONText(row.Data, "cwd"), - "title": nullableJSONText(row.Data, "title"), "initial_prompt": nullableJSONText(row.Data, "initial_prompt"), - "slug": nullableJSONText(row.Data, "slug"), "agent_type": nullableJSONText(row.Data, "agent_type"), - "description": nullableJSONText(row.Data, "agent_desc"), - "cli_version": nestedNullableText(row.Data, "provider", "version"), - "lifecycle_status": lifecycle, "activity_state": "idle", "health_state": "healthy", - "state_reason": legacyCutoverStateNote, "state_version": int64(0), "state_observed_at": observedAt, - "git": git, "metadata": metadata, "started_at": row.StartedAt, "ended_at": row.EndedAt, - "last_activity_at": observedAt, "created_at": createdAt, "updated_at": updatedAt, - }) - if err != nil { - return "", fmt.Errorf("native Captain session %s conflicts with legacy identity %q: %w", row.NativeID, row.ID, err) - } - return actual, nil -} - -func upsertAndValidateNativePromptRun(ctx context.Context, tx *sql.Tx, row *legacyPromptRow, sessions []*legacySessionRow) (string, error) { - byID := make(map[string]*legacySessionRow, len(sessions)) - for _, session := range sessions { - byID[session.ID] = session - } - session := byID[row.SessionID] - rootID := session.NativeID - if session.Root != nil { - rootID = session.Root.NativeID - } - - rendered := row.Data["realized"] - if _, ok := rendered.(map[string]any); !ok { - rendered = map[string]any{} - } - renderedMap := rendered.(map[string]any) - renderedMap["x-legacy-cache"] = map[string]any{ - "runId": row.RunID, "model": jsonString(row.Data, "model"), "backend": jsonString(row.Data, "backend"), - } - renderedJSON, err := json.Marshal(renderedMap) - if err != nil { - return "", fmt.Errorf("marshal legacy realized prompt for session %q: %w", row.SessionID, err) - } - createdAt := jsonTime(row.Data, "created_at") - if createdAt == nil { - createdAt = firstTimePointer(session.UpdatedAt, session.EndedAt, session.StartedAt) - } - if createdAt == nil { - now := time.Now().UTC() - createdAt = &now - } - - _, err = tx.ExecContext(ctx, `INSERT INTO public.captain_prompt_runs ( - id, session_id, root_session_id, origin, rendered_spec, prompt_markdown, - phase, state, current_iteration, queued_at, started_at, finished_at, created_at, updated_at - ) VALUES ( - $1, $2, $3, 'legacy-session-cache', $4::jsonb, $5, - 'finished', 'succeeded', 0, $6, $6, $6, $6, $6 - ) ON CONFLICT (id) DO NOTHING`, - row.NativeID, session.NativeID, rootID, string(renderedJSON), promptMarkdown(renderedMap), *createdAt, - ) - if err != nil { - return "", fmt.Errorf("backfill native Captain prompt run %q: %w", row.RunID, err) - } - - actual, err := validatePersistedNativeRow(ctx, tx, "captain_prompt_runs", row.NativeID, map[string]any{ - "id": row.NativeID, "session_id": session.NativeID, "root_session_id": rootID, - "batch_id": nil, "parent_run_id": nil, "input_plan_id": nil, "input_plan_revision_id": nil, - "origin": "legacy-session-cache", "spec_profile": nil, "admission_key": nil, - "rendered_spec": renderedMap, "prompt_markdown": promptMarkdown(renderedMap), - "verification_markdown": nil, "phase": "finished", "state": "succeeded", - "current_iteration": 0, "result_text": nil, "result_json": nil, "error": nil, - "version": int64(0), "queued_at": createdAt, "started_at": createdAt, - "finished_at": createdAt, "created_at": createdAt, "updated_at": createdAt, - }) - if err != nil { - return "", fmt.Errorf("native Captain prompt run %s conflicts with legacy run %q: %w", row.NativeID, row.RunID, err) - } - return actual, nil -} - -func setLegacyBackfillEmitTriggers(ctx context.Context, tx *sql.Tx, enabled bool) error { - value := "on" - if enabled { - value = "off" - } - if _, err := tx.ExecContext(ctx, `SELECT set_config('captain.suppress_session_change', $1, true)`, value); err != nil { - return fmt.Errorf("configure Captain legacy backfill emit suppression: %w", err) - } - return nil -} - -func validatePersistedNativeRow( - ctx context.Context, - tx *sql.Tx, - table string, - id uuid.UUID, - expected map[string]any, -) (string, error) { - if !allowedNativeCutoverRelation(table) { - return "", fmt.Errorf("unsupported native Captain cutover relation %q", table) - } - raw, err := readPersistedNativeRow(ctx, tx, table, id) - if err != nil { - return "", err - } - actual, err := decodeJSONMap(raw) - if err != nil { - return "", fmt.Errorf("decode persisted public.%s row %s: %w", table, id, err) - } - actual = normalizePersistedRow(actual) - expected = normalizePersistedRow(expected) - - keys := make(map[string]struct{}, len(actual)+len(expected)) - for key := range actual { - keys[key] = struct{}{} - } - for key := range expected { - keys[key] = struct{}{} - } - var mismatches []string - for key := range keys { - actualValue, actualExists := actual[key] - expectedValue, expectedExists := expected[key] - if !actualExists || !expectedExists || canonicalJSONValue(actualValue) != canonicalJSONValue(expectedValue) { - mismatches = append(mismatches, key) - } - } - if len(mismatches) != 0 { - sort.Strings(mismatches) - return "", fmt.Errorf("persisted columns differ from archive-derived values: %s", strings.Join(mismatches, ", ")) - } - return string(raw), nil -} - -func readPersistedNativeRow(ctx context.Context, tx *sql.Tx, table string, id uuid.UUID) ([]byte, error) { - if !allowedNativeCutoverRelation(table) { - return nil, fmt.Errorf("unsupported native Captain cutover relation %q", table) - } - var raw []byte - if err := tx.QueryRowContext(ctx, fmt.Sprintf( - `SELECT to_jsonb(row_value)::text FROM public.%s AS row_value WHERE id = $1`, table, - ), id).Scan(&raw); err != nil { - return nil, fmt.Errorf("read persisted public.%s row %s: %w", table, id, err) - } - return raw, nil -} - -func allowedNativeCutoverRelation(table string) bool { - switch table { - case "captain_sessions", "captain_session_sources", "captain_prompt_runs": - return true - default: - return false - } -} - -func normalizePersistedRow(row map[string]any) map[string]any { - normalized := make(map[string]any, len(row)) - for key, value := range row { - normalized[key] = normalizePersistedValue(key, value) - } - return normalized -} - -func normalizePersistedValue(key string, value any) any { - switch typed := value.(type) { - case uuid.UUID: - return typed.String() - case *uuid.UUID: - if typed == nil { - return nil - } - return typed.String() - case time.Time: - return canonicalCutoverTimestamp(typed) - case *time.Time: - if typed == nil { - return nil - } - return canonicalCutoverTimestamp(*typed) - case string: - if isCutoverTimestampColumn(key) { - if parsed, err := time.Parse(time.RFC3339Nano, typed); err == nil { - return canonicalCutoverTimestamp(parsed) - } - } - } - return value -} - -func canonicalCutoverTimestamp(value time.Time) string { - // PostgreSQL timestamptz stores microseconds by truncating sub-microsecond - // precision. Mirror that behavior when comparing archive-derived values to - // rows read back from PostgreSQL; rounding can advance the expected value by - // one microsecond and reject an otherwise exact persisted row. - return value.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano) -} - -func isCutoverTimestampColumn(column string) bool { - switch column { - case "state_observed_at", "started_at", "ended_at", "last_activity_at", "created_at", "updated_at", - "observed_mod_time", "queued_at", "finished_at": - return true - default: - return false - } -} - -func canonicalJSONValue(value any) string { - encoded, err := json.Marshal(value) - if err != nil { - return fmt.Sprintf("", value, err) - } - return string(encoded) -} - -func loadLegacySessions(ctx context.Context, query queryer, table string) ([]*legacySessionRow, []json.RawMessage, error) { - raw, err := loadJSONRows(ctx, query, table, "path") - if err != nil { - return nil, nil, err - } - rows := make([]*legacySessionRow, 0, len(raw)) - for _, value := range raw { - data, err := decodeJSONMap(value) - if err != nil { - return nil, nil, fmt.Errorf("decode public.%s row: %w", table, err) - } - rows = append(rows, &legacySessionRow{ - Raw: value, Data: data, Path: jsonString(data, "path"), ID: jsonString(data, "id"), - ParentID: jsonString(data, "parent_id"), Source: jsonString(data, "source"), - StartedAt: jsonTime(data, "started_at"), EndedAt: jsonTime(data, "ended_at"), UpdatedAt: jsonTime(data, "updated_at"), - }) - } - return rows, raw, nil -} - -func loadLegacyPromptsIfPresent(ctx context.Context, query queryer, exists bool, table string) ([]*legacyPromptRow, []json.RawMessage, error) { - if !exists { - return nil, nil, nil - } - raw, err := loadJSONRows(ctx, query, table, "session_id") - if err != nil { - return nil, nil, err - } - rows := make([]*legacyPromptRow, 0, len(raw)) - for _, value := range raw { - data, err := decodeJSONMap(value) - if err != nil { - return nil, nil, fmt.Errorf("decode public.%s row: %w", table, err) - } - rows = append(rows, &legacyPromptRow{ - Raw: value, Data: data, SessionID: jsonString(data, "session_id"), RunID: jsonString(data, "run_id"), - }) - } - return rows, raw, nil -} - -func validateLegacyDataset(sessions []*legacySessionRow, prompts []*legacyPromptRow) error { - byID := make(map[string]*legacySessionRow, len(sessions)) - byNativeID := make(map[uuid.UUID]string, len(sessions)) - paths := make(map[string]struct{}, len(sessions)) - for _, row := range sessions { - if row.Path == "" || row.ID == "" || row.Source == "" { - return errors.New("each legacy session requires non-empty path, id, and source") - } - if _, exists := paths[row.Path]; exists { - return fmt.Errorf("duplicate legacy session path %q", row.Path) - } - paths[row.Path] = struct{}{} - if _, exists := byID[row.ID]; exists { - return fmt.Errorf("duplicate legacy session id %q", row.ID) - } - byID[row.ID] = row - row.NativeID = legacyNativeSessionID(row.ID) - if previous, exists := byNativeID[row.NativeID]; exists { - return fmt.Errorf("legacy session UUID mapping collision between %q and %q", previous, row.ID) - } - byNativeID[row.NativeID] = row.ID - if row.StartedAt != nil && row.EndedAt != nil && row.EndedAt.Before(*row.StartedAt) { - return fmt.Errorf("legacy session %q ends before it starts", row.ID) - } - } - for _, row := range sessions { - if row.ParentID == "" { - row.Root = row - continue - } - parent := byID[row.ParentID] - if parent == nil { - return fmt.Errorf("legacy session %q references missing parent %q", row.ID, row.ParentID) - } - if parent.Source != row.Source { - return fmt.Errorf("legacy session %q source %q differs from parent %q source %q", row.ID, row.Source, parent.ID, parent.Source) - } - row.Parent = parent - } - - visiting := map[string]bool{} - visited := map[string]bool{} - var resolve func(*legacySessionRow) error - resolve = func(row *legacySessionRow) error { - if visited[row.ID] { - return nil - } - if visiting[row.ID] { - return fmt.Errorf("legacy session parent cycle includes %q", row.ID) - } - visiting[row.ID] = true - if row.Parent == nil { - row.Root, row.Depth = row, 0 - } else { - if err := resolve(row.Parent); err != nil { - return err - } - row.Root, row.Depth = row.Parent.Root, row.Parent.Depth+1 - } - visiting[row.ID] = false - visited[row.ID] = true - return nil - } - for _, row := range sessions { - if err := resolve(row); err != nil { - return err - } - } - - promptIDs := make(map[uuid.UUID]string, len(prompts)) - for _, row := range prompts { - if row.SessionID == "" { - return errors.New("legacy prompt row has an empty session_id") - } - if byID[row.SessionID] == nil { - return fmt.Errorf("legacy prompt for session %q has no archived session", row.SessionID) - } - row.NativeID = legacyNativePromptID(row.SessionID, row.RunID) - if previous, exists := promptIDs[row.NativeID]; exists { - return fmt.Errorf("legacy prompt run identity collision between %q and %q", previous, row.RunID) - } - promptIDs[row.NativeID] = row.RunID - } - return nil -} - -func legacyNativeSessionID(id string) uuid.UUID { - if parsed, err := uuid.Parse(strings.TrimSpace(id)); err == nil { - return parsed - } - return uuid.NewSHA1(legacySessionNamespace, []byte(strings.TrimSpace(id))) -} - -func legacyNativePromptID(sessionID, runID string) uuid.UUID { - if parsed, err := uuid.Parse(strings.TrimSpace(runID)); err == nil { - return parsed - } - return uuid.NewSHA1(legacyPromptNamespace, []byte(strings.TrimSpace(sessionID)+"\x00"+strings.TrimSpace(runID))) -} - -type queryer interface { - QueryContext(context.Context, string, ...any) (*sql.Rows, error) -} - -func loadJSONRows(ctx context.Context, query queryer, table, orderColumn string) ([]json.RawMessage, error) { - if !allowedLegacyRelation(table) || (orderColumn != "path" && orderColumn != "session_id") { - return nil, fmt.Errorf("unsupported Captain legacy relation %q", table) - } - rows, err := query.QueryContext(ctx, fmt.Sprintf(`SELECT to_jsonb(t)::text - FROM public.%s t ORDER BY %s`, table, orderColumn)) - if err != nil { - return nil, fmt.Errorf("read public.%s: %w", table, err) - } - defer rows.Close() - var result []json.RawMessage - for rows.Next() { - var value []byte - if err := rows.Scan(&value); err != nil { - return nil, fmt.Errorf("scan public.%s row: %w", table, err) - } - result = append(result, append(json.RawMessage(nil), value...)) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("read public.%s rows: %w", table, err) - } - return result, nil -} - -func relationExists(ctx context.Context, query interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -}, table string) (bool, error) { - if !allowedLegacyRelation(table) { - return false, fmt.Errorf("unsupported Captain relation %q", table) - } - var exists bool - if err := query.QueryRowContext(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, table).Scan(&exists); err != nil { - return false, fmt.Errorf("inspect public.%s: %w", table, err) - } - return exists, nil -} - -func relationColumns(ctx context.Context, query queryer, table string) (map[string]string, error) { - if !allowedLegacyRelation(table) { - return nil, fmt.Errorf("unsupported Captain relation %q", table) - } - rows, err := query.QueryContext(ctx, `SELECT column_name, data_type - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = $1`, table) - if err != nil { - return nil, fmt.Errorf("inspect public.%s columns: %w", table, err) - } - defer rows.Close() - columns := map[string]string{} - for rows.Next() { - var name, dataType string - if err := rows.Scan(&name, &dataType); err != nil { - return nil, fmt.Errorf("scan public.%s columns: %w", table, err) - } - columns[name] = dataType - } - return columns, rows.Err() -} - -func allowedLegacyRelation(table string) bool { - switch table { - case legacySessionsTable, legacyPromptsTable, archiveSessionsTable, archivePromptsTable: - return true - default: - return false - } -} - -func storedCutoverExists(ctx context.Context, db *sql.DB) (bool, error) { - _, exists, err := readCutoverReport(ctx, db) - return exists, err -} - -type rowQueryer interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - -func readCutoverReport(ctx context.Context, query rowQueryer) (*LegacySessionCutoverReport, bool, error) { - var report LegacySessionCutoverReport - var promptsTable, promptsChecksum, nativePromptsChecksum sql.NullString - var details []byte - err := query.QueryRowContext(ctx, `SELECT - cutover_key, legacy_sessions_table, legacy_prompts_table, - legacy_session_rows, legacy_prompt_rows, imported_session_rows, imported_prompt_run_rows, - legacy_sessions_checksum, legacy_prompts_checksum, native_sessions_checksum, native_prompt_runs_checksum, - details, started_at, completed_at, updated_at - FROM public.captain_legacy_session_cutovers WHERE cutover_key = $1`, legacyCutoverKey).Scan( - &report.CutoverKey, &report.LegacySessionsTable, &promptsTable, - &report.LegacySessionRows, &report.LegacyPromptRows, &report.ImportedSessionRows, &report.ImportedPromptRunRows, - &report.LegacySessionsChecksum, &promptsChecksum, &report.NativeSessionsChecksum, &nativePromptsChecksum, - &details, &report.StartedAt, &report.CompletedAt, &report.UpdatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("read Captain legacy cutover report: %w", err) - } - report.LegacyPromptsTable = nullableStringPointer(promptsTable) - report.LegacyPromptsChecksum = nullableStringPointer(promptsChecksum) - report.NativePromptRunsChecksum = nullableStringPointer(nativePromptsChecksum) - report.Details = append(json.RawMessage(nil), details...) - report.StartedAt = report.StartedAt.UTC() - report.CompletedAt = report.CompletedAt.UTC() - report.UpdatedAt = report.UpdatedAt.UTC() - return &report, true, nil -} - -func sameCutoverValidation(left, right *LegacySessionCutoverReport) bool { - if left == nil || right == nil { - return false - } - return left.CutoverKey == right.CutoverKey && - left.LegacySessionsTable == right.LegacySessionsTable && - equalOptionalString(left.LegacyPromptsTable, right.LegacyPromptsTable) && - left.LegacySessionRows == right.LegacySessionRows && - left.LegacyPromptRows == right.LegacyPromptRows && - left.ImportedSessionRows == right.ImportedSessionRows && - left.ImportedPromptRunRows == right.ImportedPromptRunRows && - left.LegacySessionsChecksum == right.LegacySessionsChecksum && - equalOptionalString(left.LegacyPromptsChecksum, right.LegacyPromptsChecksum) && - left.NativeSessionsChecksum == right.NativeSessionsChecksum && - equalOptionalString(left.NativePromptRunsChecksum, right.NativePromptRunsChecksum) && - equalJSON(left.Details, right.Details) -} - -func equalJSON(left, right []byte) bool { - var leftValue, rightValue any - leftDecoder := json.NewDecoder(strings.NewReader(string(left))) - leftDecoder.UseNumber() - if err := leftDecoder.Decode(&leftValue); err != nil { - return false - } - rightDecoder := json.NewDecoder(strings.NewReader(string(right))) - rightDecoder.UseNumber() - if err := rightDecoder.Decode(&rightValue); err != nil { - return false - } - leftCanonical, err := json.Marshal(leftValue) - if err != nil { - return false - } - rightCanonical, err := json.Marshal(rightValue) - return err == nil && string(leftCanonical) == string(rightCanonical) -} - -func insertCutoverReport(ctx context.Context, tx *sql.Tx, report *LegacySessionCutoverReport) error { - _, err := tx.ExecContext(ctx, `INSERT INTO public.captain_legacy_session_cutovers ( - cutover_key, legacy_sessions_table, legacy_prompts_table, - legacy_session_rows, legacy_prompt_rows, imported_session_rows, imported_prompt_run_rows, - legacy_sessions_checksum, legacy_prompts_checksum, native_sessions_checksum, native_prompt_runs_checksum, - details, started_at, completed_at, updated_at - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15)`, - report.CutoverKey, report.LegacySessionsTable, report.LegacyPromptsTable, - report.LegacySessionRows, report.LegacyPromptRows, report.ImportedSessionRows, report.ImportedPromptRunRows, - report.LegacySessionsChecksum, report.LegacyPromptsChecksum, report.NativeSessionsChecksum, report.NativePromptRunsChecksum, - string(report.Details), report.StartedAt, report.CompletedAt, report.UpdatedAt, - ) - if err != nil { - return fmt.Errorf("store Captain legacy cutover report: %w", err) - } - return nil -} - -func nullableStringPointer(value sql.NullString) *string { - if !value.Valid { - return nil - } - copy := value.String - return © -} - -func equalOptionalString(left, right *string) bool { - if left == nil || right == nil { - return left == right - } - return *left == *right -} - -func checksumJSONRows(rows []json.RawMessage) string { - values := make([]string, len(rows)) - for i := range rows { - values[i] = string(rows[i]) - } - return checksumStrings(values) -} - -func checksumStrings(values []string) string { - hash := sha256.New() - for _, value := range values { - hash.Write([]byte(value)) - hash.Write([]byte{'\n'}) - } - return hex.EncodeToString(hash.Sum(nil)) -} - -func checksumColumns(columns map[string]string) string { - values := make([]string, 0, len(columns)) - for name, dataType := range columns { - values = append(values, name+"\x00"+dataType) - } - sort.Strings(values) - return checksumStrings(values) -} - -func stringsToRaw(values []string) []json.RawMessage { - result := make([]json.RawMessage, len(values)) - for i := range values { - result[i] = json.RawMessage(values[i]) - } - return result -} - -func optionalChecksum(present bool, rows []json.RawMessage) *string { - if !present { - return nil - } - value := checksumJSONRows(rows) - return &value -} - -func optionalArchiveName(present bool) string { - if present { - return archivePromptsTable - } - return "" -} - -func optionalStringPointer(value string) *string { - if value == "" { - return nil - } - return &value -} - -func jsonString(data map[string]any, key string) string { - value, _ := data[key].(string) - return strings.TrimSpace(value) -} - -func jsonInt64(data map[string]any, key string) int64 { - switch value := data[key].(type) { - case json.Number: - parsed, _ := value.Int64() - return parsed - case float64: - return int64(value) - case int64: - return value - default: - return 0 - } -} - -func decodeJSONMap(raw []byte) (map[string]any, error) { - decoder := json.NewDecoder(strings.NewReader(string(raw))) - decoder.UseNumber() - var data map[string]any - if err := decoder.Decode(&data); err != nil { - return nil, err - } - return data, nil -} - -func nestedString(data map[string]any, outer, inner string) string { - nested, _ := data[outer].(map[string]any) - return jsonString(nested, inner) -} - -func legacyProvider(row *legacySessionRow) string { - if provider := nestedString(row.Data, "provider", "name"); provider != "" { - return provider - } - switch row.Source { - case "codex": - return "openai" - case "claude": - return "anthropic" - default: - return row.Source - } -} - -func nullableJSONText(data map[string]any, key string) any { - if value := jsonString(data, key); value != "" { - return value - } - return nil -} - -func nestedNullableText(data map[string]any, outer, inner string) any { - if value := nestedString(data, outer, inner); value != "" { - return value - } - return nil -} - -func jsonTime(data map[string]any, key string) *time.Time { - value := jsonString(data, key) - if value == "" { - return nil - } - parsed, err := time.Parse(time.RFC3339Nano, value) - if err != nil { - return nil - } - parsed = parsed.UTC() - return &parsed -} - -func firstTime(values ...*time.Time) time.Time { - if value := firstTimePointer(values...); value != nil { - return *value - } - return time.Now().UTC() -} - -func firstTimePointer(values ...*time.Time) *time.Time { - for _, value := range values { - if value != nil { - copy := value.UTC() - return © - } - } - return nil -} - -func promptMarkdown(rendered map[string]any) any { - if input, ok := rendered["input"].(map[string]any); ok { - if prompt, ok := input["prompt"].(map[string]any); ok { - if value := jsonString(prompt, "user"); value != "" { - return value - } - } - } - if value := jsonString(rendered, "user"); value != "" { - return value - } - return nil -} diff --git a/migrations/legacy_cutover_integration_test.go b/migrations/legacy_cutover_integration_test.go deleted file mode 100644 index ce40b96..0000000 --- a/migrations/legacy_cutover_integration_test.go +++ /dev/null @@ -1,591 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "testing" - "time" - - commonsdb "github.com/flanksource/commons-db/db" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestApplyWithLegacySessionCutoverPreservesAndBackfillsHistoricalCache(t *testing.T) { - dsn := legacyCutoverTestDSN(t) - ctx := t.Context() - db, err := commonsdb.NewDB(dsn) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, db.Close()) }) - - require.NoError(t, createHistoricalLegacySessionCache(ctx, db)) - require.NoError(t, createUnrelatedSentinel(ctx, db)) - - legacySessions := readJSONRows(t, ctx, db, legacySessionsTable, "path") - legacyPrompts := readJSONRows(t, ctx, db, legacyPromptsTable, "session_id") - legacySessionColumns := readColumnSignatures(t, ctx, db, legacySessionsTable) - legacyPromptColumns := readColumnSignatures(t, ctx, db, legacyPromptsTable) - assert.Equal(t, []string{"path"}, readPrimaryKeyColumns(t, ctx, db, legacySessionsTable)) - assert.Equal(t, []string{"session_id"}, readPrimaryKeyColumns(t, ctx, db, legacyPromptsTable)) - - first, err := ApplyWithLegacySessionCutover(ctx, dsn) - require.NoError(t, err) - require.NotNil(t, first) - assert.Equal(t, legacyCutoverKey, first.CutoverKey) - assert.Equal(t, archiveSessionsTable, first.LegacySessionsTable) - require.NotNil(t, first.LegacyPromptsTable) - assert.Equal(t, archivePromptsTable, *first.LegacyPromptsTable) - assert.EqualValues(t, 2, first.LegacySessionRows) - assert.EqualValues(t, 1, first.LegacyPromptRows) - assert.Equal(t, first.LegacySessionRows, first.ImportedSessionRows) - assert.Equal(t, first.LegacyPromptRows, first.ImportedPromptRunRows) - assert.Equal(t, checksumStrings(legacySessions), first.LegacySessionsChecksum) - promptChecksum := checksumStrings(legacyPrompts) - require.NotNil(t, first.LegacyPromptsChecksum) - assert.Equal(t, promptChecksum, *first.LegacyPromptsChecksum) - assert.NotEmpty(t, first.NativeSessionsChecksum) - require.NotNil(t, first.NativePromptRunsChecksum) - assert.NotEmpty(t, *first.NativePromptRunsChecksum) - actualNativeSessions := readJSONRows(t, ctx, db, "captain_sessions", "id") - actualNativeSources := readJSONRows(t, ctx, db, "captain_session_sources", "id") - actualNativeSessionState := make([]string, 0, len(actualNativeSessions)+len(actualNativeSources)) - for _, row := range actualNativeSessions { - actualNativeSessionState = append(actualNativeSessionState, "session\x00"+row) - } - for _, row := range actualNativeSources { - actualNativeSessionState = append(actualNativeSessionState, "source\x00"+row) - } - sort.Strings(actualNativeSessionState) - assert.Equal(t, checksumStrings(actualNativeSessionState), first.NativeSessionsChecksum) - actualNativePrompts := readJSONRows(t, ctx, db, "captain_prompt_runs", "id") - sort.Strings(actualNativePrompts) - assert.Equal(t, checksumStrings(actualNativePrompts), *first.NativePromptRunsChecksum) - - details, err := decodeJSONMap(first.Details) - require.NoError(t, err) - mappingLedger, ok := details["mappingLedger"].(map[string]any) - require.True(t, ok) - sessionMappings, ok := mappingLedger["sessions"].([]any) - require.True(t, ok) - require.Len(t, sessionMappings, 2) - promptMappings, ok := mappingLedger["prompts"].([]any) - require.True(t, ok) - require.Len(t, promptMappings, 1) - rootMapping, ok := sessionMappings[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", rootMapping["legacyId"]) - assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", rootMapping["nativeId"]) - childMapping, ok := sessionMappings[1].(map[string]any) - require.True(t, ok) - assert.Equal(t, "opaque-child-session", childMapping["legacyId"]) - assert.Equal(t, legacyNativeSessionID("opaque-child-session").String(), childMapping["nativeId"]) - assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", childMapping["parentNativeId"]) - assert.Equal(t, "71d6e735-e02c-46ef-a1bd-e4aff85a27fe", childMapping["rootNativeId"]) - promptMapping, ok := promptMappings[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, legacyNativePromptID("opaque-child-session", "opaque-prompt-run").String(), promptMapping["nativePromptRunId"]) - - schemaFingerprints, ok := details["schemaFingerprints"].(map[string]any) - require.True(t, ok) - archiveSessionColumnTypes, err := relationColumns(ctx, db, archiveSessionsTable) - require.NoError(t, err) - archivePromptColumnTypes, err := relationColumns(ctx, db, archivePromptsTable) - require.NoError(t, err) - assert.Equal(t, checksumColumns(archiveSessionColumnTypes), schemaFingerprints["sessions"]) - assert.Equal(t, checksumColumns(archivePromptColumnTypes), schemaFingerprints["prompts"]) - warnings, ok := details["warnings"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "1", fmt.Sprint(warnings["providerFallbackRows"])) - assert.Equal(t, "0", fmt.Sprint(warnings["legacyPlanRows"])) - assert.NotEmpty(t, warnings["legacyPlanHandling"]) - assert.NotEmpty(t, warnings["summaryOnly"]) - nativeSessionSources, ok := details["nativeSessionSources"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "2", fmt.Sprint(nativeSessionSources["rows"])) - sort.Strings(actualNativeSources) - assert.Equal(t, checksumStrings(actualNativeSources), nativeSessionSources["checksum"]) - var durableDetails []byte - require.NoError(t, db.QueryRowContext(ctx, `SELECT details - FROM public.captain_legacy_session_cutovers WHERE cutover_key = $1`, legacyCutoverKey).Scan(&durableDetails)) - assert.JSONEq(t, string(first.Details), string(durableDetails)) - - assert.False(t, relationExistsForTest(t, ctx, db, legacyPromptsTable)) - assert.Equal(t, legacySessions, readJSONRows(t, ctx, db, archiveSessionsTable, "path")) - assert.Equal(t, legacyPrompts, readJSONRows(t, ctx, db, archivePromptsTable, "session_id")) - assert.Equal(t, legacySessionColumns, readColumnSignatures(t, ctx, db, archiveSessionsTable)) - assert.Equal(t, legacyPromptColumns, readColumnSignatures(t, ctx, db, archivePromptsTable)) - assert.Equal(t, []string{"path"}, readPrimaryKeyColumns(t, ctx, db, archiveSessionsTable)) - assert.Equal(t, []string{"session_id"}, readPrimaryKeyColumns(t, ctx, db, archivePromptsTable)) - assert.False(t, columnExistsForTest(t, ctx, db, archiveSessionsTable, "summary_version")) - assert.False(t, columnExistsForTest(t, ctx, db, archiveSessionsTable, "title")) - assert.False(t, columnExistsForTest(t, ctx, db, archiveSessionsTable, "initial_prompt")) - - rootProviderID := "71d6e735-e02c-46ef-a1bd-e4aff85a27fe" - rootNativeID := uuid.MustParse(rootProviderID) - childProviderID := "opaque-child-session" - childNativeID := legacyNativeSessionID(childProviderID) - - root := readNativeSession(t, ctx, db, rootNativeID) - assert.Equal(t, rootProviderID, root.ProviderSessionID) - assert.Equal(t, "codex", root.Source) - assert.Equal(t, "openai", root.Provider) - assert.Equal(t, "local", root.HostID) - assert.Equal(t, "/legacy/root.jsonl", root.Path) - assert.False(t, root.ParentID.Valid) - assert.False(t, root.RootID.Valid) - assert.Equal(t, "succeeded", root.LifecycleStatus) - assert.Equal(t, legacyCutoverStateNote, root.StateReason) - assert.Equal(t, rootProviderID, nestedMetadataString(t, root.Metadata, "legacy_cache", "id")) - assert.Equal(t, "captain-session-cache-v1", nestedMetadataString(t, root.Metadata, "legacy_cutover", "key")) - - child := readNativeSession(t, ctx, db, childNativeID) - assert.Equal(t, childProviderID, child.ProviderSessionID) - assert.True(t, child.ParentID.Valid) - assert.Equal(t, rootNativeID, child.ParentID.UUID) - assert.True(t, child.RootID.Valid) - assert.Equal(t, rootNativeID, child.RootID.UUID) - assert.Equal(t, "openai", child.Provider) - assert.Equal(t, "database child", nestedMetadataString(t, child.Metadata, "legacy_cache", "agent_desc")) - assert.Equal(t, "legacy-model", nestedMetadataString(t, child.Metadata, "legacy_cache", "model")) - - sources := readNativeSessionSources(t, ctx, db) - require.Len(t, sources, 2) - rootSource := sources["/legacy/root.jsonl"] - assert.Equal(t, uuid.NewSHA1(legacySourceNamespace, []byte(rootSource.Path)), rootSource.ID) - assert.Equal(t, rootNativeID, rootSource.SessionID) - assert.Equal(t, "codex", rootSource.SourceKind) - assert.Equal(t, rootProviderID, rootSource.SourceIdentity) - assert.Equal(t, 1, rootSource.ParserVersion) - assert.Equal(t, int64(101), rootSource.ByteOffset) - assert.Equal(t, int64(101), rootSource.ObservedSize) - assert.True(t, time.Unix(0, 1_000_000_501).UTC().Truncate(time.Microsecond).Equal(rootSource.ObservedModTime)) - childSource := sources["/legacy/child.jsonl"] - assert.Equal(t, uuid.NewSHA1(legacySourceNamespace, []byte(childSource.Path)), childSource.ID) - assert.Equal(t, childNativeID, childSource.SessionID) - assert.Equal(t, "codex", childSource.SourceKind) - assert.Equal(t, childProviderID, childSource.SourceIdentity) - assert.Equal(t, 1, childSource.ParserVersion) - assert.Equal(t, int64(202), childSource.ByteOffset) - assert.Equal(t, int64(202), childSource.ObservedSize) - assert.True(t, time.Unix(0, 2_000_000_999).UTC().Truncate(time.Microsecond).Equal(childSource.ObservedModTime)) - - promptNativeID := legacyNativePromptID(childProviderID, "opaque-prompt-run") - var prompt struct { - SessionID uuid.UUID - RootSessionID uuid.UUID - Origin sql.NullString - PromptMarkdown sql.NullString - Phase string - State string - RenderedSpec []byte - } - require.NoError(t, db.QueryRowContext(ctx, `SELECT session_id, root_session_id, origin, - prompt_markdown, phase, state, rendered_spec - FROM public.captain_prompt_runs WHERE id = $1`, promptNativeID).Scan( - &prompt.SessionID, &prompt.RootSessionID, &prompt.Origin, &prompt.PromptMarkdown, - &prompt.Phase, &prompt.State, &prompt.RenderedSpec, - )) - assert.Equal(t, childNativeID, prompt.SessionID) - assert.Equal(t, rootNativeID, prompt.RootSessionID) - assert.Equal(t, "legacy-session-cache", prompt.Origin.String) - assert.Equal(t, "preserve this realized prompt", prompt.PromptMarkdown.String) - assert.Equal(t, "finished", prompt.Phase) - assert.Equal(t, "succeeded", prompt.State) - var rendered map[string]any - require.NoError(t, json.Unmarshal(prompt.RenderedSpec, &rendered)) - legacyPromptMetadata, ok := rendered["x-legacy-cache"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "opaque-prompt-run", legacyPromptMetadata["runId"]) - assert.Equal(t, "legacy-model", legacyPromptMetadata["model"]) - assert.Equal(t, "codex_cli", legacyPromptMetadata["backend"]) - - second, err := ApplyWithLegacySessionCutover(ctx, dsn) - require.NoError(t, err) - require.NotNil(t, second) - assert.Equal(t, first.LegacySessionsChecksum, second.LegacySessionsChecksum) - assert.Equal(t, first.LegacyPromptsChecksum, second.LegacyPromptsChecksum) - assert.Equal(t, first.NativeSessionsChecksum, second.NativeSessionsChecksum) - assert.Equal(t, first.NativePromptRunsChecksum, second.NativePromptRunsChecksum) - assert.True(t, first.StartedAt.Equal(second.StartedAt), "started_at changed: %s != %s", first.StartedAt, second.StartedAt) - assert.True(t, first.CompletedAt.Equal(second.CompletedAt), "completed_at changed: %s != %s", first.CompletedAt, second.CompletedAt) - assert.True(t, first.UpdatedAt.Equal(second.UpdatedAt), "updated_at changed: %s != %s", first.UpdatedAt, second.UpdatedAt) - assert.JSONEq(t, string(first.Details), string(second.Details)) - require.NoError(t, Apply(ctx, dsn)) - - assert.Equal(t, legacySessions, readJSONRows(t, ctx, db, archiveSessionsTable, "path")) - assert.Equal(t, legacyPrompts, readJSONRows(t, ctx, db, archivePromptsTable, "session_id")) - var sentinel string - require.NoError(t, db.QueryRowContext(ctx, `SELECT payload FROM public.unrelated_gavel_data WHERE id = 1`).Scan(&sentinel)) - assert.Equal(t, "preserve unrelated data", sentinel) - var sessionCount, promptCount, reportCount int - require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM public.captain_sessions`).Scan(&sessionCount)) - require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM public.captain_prompt_runs`).Scan(&promptCount)) - require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM public.captain_legacy_session_cutovers WHERE cutover_key = $1`, legacyCutoverKey).Scan(&reportCount)) - assert.Equal(t, 2, sessionCount) - assert.Equal(t, 1, promptCount) - assert.Equal(t, 1, reportCount) - - // Simulate a crash-resume database containing a conflicting row that owns - // the deterministic native ID. The retry must reject every material prompt - // mismatch instead of accepting the old identity-only columns. - tamperTx, err := db.BeginTx(ctx, nil) - require.NoError(t, err) - _, err = tamperTx.ExecContext(ctx, `SELECT set_config('captain.suppress_session_change', 'on', true)`) - require.NoError(t, err) - _, err = tamperTx.ExecContext(ctx, `UPDATE public.captain_prompt_runs - SET rendered_spec = '{}'::jsonb WHERE id = $1`, promptNativeID) - require.NoError(t, err) - require.NoError(t, tamperTx.Commit()) - _, err = ApplyWithLegacySessionCutover(ctx, dsn) - require.Error(t, err) - assert.ErrorContains(t, err, "rendered_spec") - storedAfterFailure, exists, readErr := readCutoverReport(ctx, db) - require.NoError(t, readErr) - require.True(t, exists) - assert.True(t, second.StartedAt.Equal(storedAfterFailure.StartedAt)) - assert.True(t, second.CompletedAt.Equal(storedAfterFailure.CompletedAt)) - assert.True(t, second.UpdatedAt.Equal(storedAfterFailure.UpdatedAt)) -} - -func TestValidateLegacyDatasetRejectsUnsafeIdentityGraphs(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - sessions func() []*legacySessionRow - prompts func() []*legacyPromptRow - want string - }{ - { - name: "duplicate session id", - sessions: func() []*legacySessionRow { - return []*legacySessionRow{ - {Path: "/one", ID: "duplicate", Source: "codex"}, - {Path: "/two", ID: "duplicate", Source: "claude"}, - } - }, - prompts: func() []*legacyPromptRow { return nil }, - want: `duplicate legacy session id "duplicate"`, - }, - { - name: "native UUID mapping collision", - sessions: func() []*legacySessionRow { - opaque := "opaque" - return []*legacySessionRow{ - {Path: "/opaque", ID: opaque, Source: "codex"}, - {Path: "/uuid", ID: legacyNativeSessionID(opaque).String(), Source: "codex"}, - } - }, - prompts: func() []*legacyPromptRow { return nil }, - want: "UUID mapping collision", - }, - { - name: "orphan parent", - sessions: func() []*legacySessionRow { - return []*legacySessionRow{{Path: "/child", ID: "child", ParentID: "missing", Source: "codex"}} - }, - prompts: func() []*legacyPromptRow { return nil }, - want: `references missing parent "missing"`, - }, - { - name: "parent cycle", - sessions: func() []*legacySessionRow { - return []*legacySessionRow{ - {Path: "/one", ID: "one", ParentID: "two", Source: "codex"}, - {Path: "/two", ID: "two", ParentID: "one", Source: "codex"}, - } - }, - prompts: func() []*legacyPromptRow { return nil }, - want: "parent cycle", - }, - { - name: "cross-source parent", - sessions: func() []*legacySessionRow { - return []*legacySessionRow{ - {Path: "/root", ID: "root", Source: "claude"}, - {Path: "/child", ID: "child", ParentID: "root", Source: "codex"}, - } - }, - prompts: func() []*legacyPromptRow { return nil }, - want: `source "codex" differs from parent "root" source "claude"`, - }, - { - name: "orphan prompt", - sessions: func() []*legacySessionRow { - return []*legacySessionRow{{Path: "/root", ID: "root", Source: "codex"}} - }, - prompts: func() []*legacyPromptRow { - return []*legacyPromptRow{{SessionID: "missing", RunID: "run"}} - }, - want: `legacy prompt for session "missing" has no archived session`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - err := validateLegacyDataset(tt.sessions(), tt.prompts()) - require.Error(t, err) - assert.ErrorContains(t, err, tt.want) - }) - } -} - -type cutoverColumnSignature struct { - Name string - DataType string - Nullable string - DefaultSQL string -} - -type cutoverNativeSession struct { - ProviderSessionID string - Source string - Provider string - HostID string - Path string - ParentID uuid.NullUUID - RootID uuid.NullUUID - LifecycleStatus string - StateReason string - Metadata []byte -} - -type cutoverNativeSessionSource struct { - ID uuid.UUID - SessionID uuid.UUID - SourceKind string - Path string - SourceIdentity string - ParserVersion int - ByteOffset int64 - ObservedSize int64 - ObservedModTime time.Time -} - -func legacyCutoverTestDSN(t *testing.T) string { - t.Helper() - if dsn := os.Getenv("CAPTAIN_DB_TEST_DSN"); dsn != "" { - return dsn - } - if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { - t.Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres legacy cutover tests") - } - dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ - DataDir: filepath.Join(t.TempDir(), "postgres"), - Database: "captain_legacy_cutover", - }) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stop()) }) - return dsn -} - -func createHistoricalLegacySessionCache(ctx context.Context, db *sql.DB) error { - _, err := db.ExecContext(ctx, `CREATE TABLE public.captain_sessions ( - path text PRIMARY KEY, - id text, - parent_id text, - source text, - is_agent boolean, - agent_type text, - agent_desc text, - mod_unix bigint, - size bigint, - project text, - cwd text, - model text, - git jsonb, - provider jsonb, - started_at timestamptz, - ended_at timestamptz, - cost jsonb, - usage jsonb, - files jsonb, - approvals jsonb, - tool_calls bigint, - message_count bigint, - context_tokens bigint, - slug text, - plan_path text, - plan_slug text, - plan jsonb, - updated_at timestamptz - ); - CREATE INDEX idx_captain_sessions_id ON public.captain_sessions(id); - CREATE INDEX idx_captain_sessions_parent_id ON public.captain_sessions(parent_id); - CREATE TABLE public.captain_session_prompts ( - session_id text PRIMARY KEY, - run_id text, - model text, - backend text, - realized jsonb, - created_at timestamptz - )`) - if err != nil { - return err - } - - rootID := "71d6e735-e02c-46ef-a1bd-e4aff85a27fe" - started := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC) - ended := started.Add(5 * time.Minute) - updated := ended.Add(time.Second) - _, err = db.ExecContext(ctx, `INSERT INTO public.captain_sessions ( - path, id, parent_id, source, is_agent, agent_type, agent_desc, - mod_unix, size, project, cwd, model, git, provider, started_at, ended_at, - cost, usage, files, approvals, tool_calls, message_count, context_tokens, - slug, plan_path, plan_slug, plan, updated_at - ) VALUES - ($1,$2,NULL,'codex',false,'root','root session',1000000501,101,'gavel','/repo','codex-root', - '{"branch":"main"}'::jsonb,'{}'::jsonb,$3,$4, - '{"inputCost":1.25,"outputCost":0.5}'::jsonb,'{"inputTokens":100,"outputTokens":20}'::jsonb, - '{"read":["go.mod"]}'::jsonb,'{"approved":1,"denied":0}'::jsonb,2,4,120,'root-slug',NULL,NULL,NULL,$5), - ($6,$7,$2,'codex',true,'worker','database child',2000000999,202,'gavel','/repo','legacy-model', - '{"branch":"feature"}'::jsonb,'{"name":"openai","version":"1.2.3"}'::jsonb,$3,$4, - '{"inputCost":0.25,"outputCost":0.1}'::jsonb,'{"inputTokens":30,"outputTokens":10}'::jsonb, - '{}'::jsonb,'{"approved":0,"denied":1}'::jsonb,1,2,40,'child-slug',NULL,NULL,NULL,$5)`, - "/legacy/root.jsonl", rootID, started, ended, updated, - "/legacy/child.jsonl", "opaque-child-session", - ) - if err != nil { - return err - } - _, err = db.ExecContext(ctx, `INSERT INTO public.captain_session_prompts - (session_id, run_id, model, backend, realized, created_at) - VALUES ($1, 'opaque-prompt-run', 'legacy-model', 'codex_cli', - '{"id":"legacy-prompt","name":"Legacy Prompt","input":{"prompt":{"user":"preserve this realized prompt"}}}'::jsonb, - $2)`, "opaque-child-session", updated) - return err -} - -func createUnrelatedSentinel(ctx context.Context, db *sql.DB) error { - _, err := db.ExecContext(ctx, `CREATE TABLE public.unrelated_gavel_data ( - id bigint PRIMARY KEY, - payload text NOT NULL - ); - INSERT INTO public.unrelated_gavel_data(id, payload) VALUES (1, 'preserve unrelated data')`) - return err -} - -func readJSONRows(t *testing.T, ctx context.Context, db *sql.DB, table, orderColumn string) []string { - t.Helper() - rows, err := db.QueryContext(ctx, fmt.Sprintf(`SELECT to_jsonb(row_value)::text - FROM public.%s AS row_value ORDER BY %s`, table, orderColumn)) - require.NoError(t, err) - defer rows.Close() - var values []string - for rows.Next() { - var value string - require.NoError(t, rows.Scan(&value)) - values = append(values, value) - } - require.NoError(t, rows.Err()) - return values -} - -func readColumnSignatures(t *testing.T, ctx context.Context, db *sql.DB, table string) []cutoverColumnSignature { - t.Helper() - rows, err := db.QueryContext(ctx, `SELECT column_name, data_type, is_nullable, - COALESCE(column_default, '') - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = $1 - ORDER BY ordinal_position`, table) - require.NoError(t, err) - defer rows.Close() - var signatures []cutoverColumnSignature - for rows.Next() { - var signature cutoverColumnSignature - require.NoError(t, rows.Scan(&signature.Name, &signature.DataType, &signature.Nullable, &signature.DefaultSQL)) - signatures = append(signatures, signature) - } - require.NoError(t, rows.Err()) - return signatures -} - -func readPrimaryKeyColumns(t *testing.T, ctx context.Context, db *sql.DB, table string) []string { - t.Helper() - rows, err := db.QueryContext(ctx, `SELECT attribute.attname - FROM pg_catalog.pg_index index - JOIN pg_catalog.pg_class relation ON relation.oid = index.indrelid - JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace - JOIN LATERAL unnest(index.indkey) WITH ORDINALITY AS key(attnum, ordinal) ON true - JOIN pg_catalog.pg_attribute attribute ON attribute.attrelid = relation.oid AND attribute.attnum = key.attnum - WHERE namespace.nspname = 'public' AND relation.relname = $1 AND index.indisprimary - ORDER BY key.ordinal`, table) - require.NoError(t, err) - defer rows.Close() - var columns []string - for rows.Next() { - var column string - require.NoError(t, rows.Scan(&column)) - columns = append(columns, column) - } - require.NoError(t, rows.Err()) - return columns -} - -func readNativeSession(t *testing.T, ctx context.Context, db *sql.DB, id uuid.UUID) cutoverNativeSession { - t.Helper() - var session cutoverNativeSession - require.NoError(t, db.QueryRowContext(ctx, `SELECT provider_session_id, source, provider, host_id, - path, parent_session_id, root_session_id, lifecycle_status, state_reason, metadata - FROM public.captain_sessions WHERE id = $1`, id).Scan( - &session.ProviderSessionID, &session.Source, &session.Provider, &session.HostID, - &session.Path, &session.ParentID, &session.RootID, &session.LifecycleStatus, - &session.StateReason, &session.Metadata, - )) - return session -} - -func readNativeSessionSources(t *testing.T, ctx context.Context, db *sql.DB) map[string]cutoverNativeSessionSource { - t.Helper() - rows, err := db.QueryContext(ctx, `SELECT id, session_id, source_kind, path, source_identity, - parser_version, byte_offset, observed_size, observed_mod_time - FROM public.captain_session_sources ORDER BY path`) - require.NoError(t, err) - defer rows.Close() - sources := map[string]cutoverNativeSessionSource{} - for rows.Next() { - var source cutoverNativeSessionSource - require.NoError(t, rows.Scan( - &source.ID, &source.SessionID, &source.SourceKind, &source.Path, &source.SourceIdentity, - &source.ParserVersion, &source.ByteOffset, &source.ObservedSize, &source.ObservedModTime, - )) - sources[source.Path] = source - } - require.NoError(t, rows.Err()) - return sources -} - -func nestedMetadataString(t *testing.T, raw []byte, object, key string) string { - t.Helper() - var metadata map[string]any - require.NoError(t, json.Unmarshal(raw, &metadata)) - nested, ok := metadata[object].(map[string]any) - require.True(t, ok, "metadata object %q: %s", object, raw) - value, ok := nested[key].(string) - require.True(t, ok, "metadata string %q.%q: %s", object, key, raw) - return value -} - -func relationExistsForTest(t *testing.T, ctx context.Context, db *sql.DB, table string) bool { - t.Helper() - var exists bool - require.NoError(t, db.QueryRowContext(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, table).Scan(&exists)) - return exists -} - -func columnExistsForTest(t *testing.T, ctx context.Context, db *sql.DB, table, column string) bool { - t.Helper() - var exists bool - require.NoError(t, db.QueryRowContext(ctx, `SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 - )`, table, column).Scan(&exists)) - return exists -} From ce019ff2b48433878bf305a34dde24fdaeb62c70 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 12:04:11 +0300 Subject: [PATCH 076/131] feat(ai): add AI adapter diagnostics and concrete runtime selection Expose full AI adapter and model diagnostics in the CLI web app while making runtime selection resolve concrete backends consistently. Preserve Codex custom tool calls and patch paths in history analysis, retain active temporary sessions, and prevent stale backend inheritance when models are overridden. --- pkg/ai/history/codex_messages.go | 4 +- pkg/ai/history/codex_normalize.go | 7 +- pkg/ai/history/codex_normalize_ginkgo_test.go | 19 ++ pkg/ai/history/codex_types.go | 1 + pkg/ai/runtime_selection_ginkgo_test.go | 58 ++++ pkg/ai/runtime_selector.go | 47 ++- pkg/cli/ai.go | 21 +- pkg/cli/ai_prompt_file.go | 37 ++- pkg/cli/ai_prompt_file_test.go | 4 +- pkg/cli/analysis.go | 9 + pkg/cli/analysis_ginkgo_test.go | 19 ++ pkg/cli/apply_patch.go | 42 +++ pkg/cli/model_selection_ginkgo_test.go | 97 ++++++ pkg/cli/prompt_entity.go | 30 +- pkg/cli/prompt_run.go | 9 +- pkg/cli/webapp/src/App.tsx | 6 + pkg/cli/webapp/src/WhoamiPage.test.tsx | 106 +++++++ pkg/cli/webapp/src/WhoamiPage.tsx | 291 ++++++++++++++++++ pkg/cli/webapp/src/shellHelpers.test.ts | 15 + pkg/cli/webapp/src/shellHelpers.ts | 16 +- pkg/monitor/backfill.go | 23 +- pkg/monitor/backfill_test.go | 9 + 22 files changed, 824 insertions(+), 46 deletions(-) create mode 100644 pkg/ai/runtime_selection_ginkgo_test.go create mode 100644 pkg/cli/analysis_ginkgo_test.go create mode 100644 pkg/cli/apply_patch.go create mode 100644 pkg/cli/model_selection_ginkgo_test.go create mode 100644 pkg/cli/webapp/src/WhoamiPage.test.tsx create mode 100644 pkg/cli/webapp/src/WhoamiPage.tsx create mode 100644 pkg/cli/webapp/src/shellHelpers.test.ts diff --git a/pkg/ai/history/codex_messages.go b/pkg/ai/history/codex_messages.go index b20a345..fabc297 100644 --- a/pkg/ai/history/codex_messages.go +++ b/pkg/ai/history/codex_messages.go @@ -10,13 +10,13 @@ import ( func extractResponseItem(event CodexEvent, pendingCall map[string]CodexEvent, cwd, sessionID string) []ToolUse { switch event.Payload.Type { - case "function_call": + case "function_call", "custom_tool_call": pendingCall[event.Payload.CallID] = event return nil case "tool_search_call": pendingCall[event.Payload.CallID] = event return nil - case "function_call_output": + case "function_call_output", "custom_tool_call_output": callEvent, ok := pendingCall[event.Payload.CallID] if !ok { return nil diff --git a/pkg/ai/history/codex_normalize.go b/pkg/ai/history/codex_normalize.go index d289dbd..410e26b 100644 --- a/pkg/ai/history/codex_normalize.go +++ b/pkg/ai/history/codex_normalize.go @@ -114,17 +114,22 @@ func buildToolUse(callEvent, outputEvent CodexEvent, cwd, sessionID string) Tool if timestamp == nil { timestamp = outputEvent.Time() } + var input map[string]any + if callEvent.Payload.Input != "" { + input = map[string]any{"input": callEvent.Payload.Input} + } return NormalizeCodexToolCall(CodexToolCall{ Name: callEvent.Payload.Name, Namespace: callEvent.Payload.Namespace, Arguments: callEvent.Payload.Arguments, + Input: input, Timestamp: timestamp, CWD: cwd, SessionID: sessionID, TurnID: firstNonEmpty(codexEventTurnID(callEvent), codexEventTurnID(outputEvent)), ID: callEvent.Payload.CallID, Response: extractCommandOutput(CodexOutputText(outputEvent.Payload.Output)), - RecordType: "response_item.function_call", + RecordType: "response_item." + callEvent.Payload.Type, }) } diff --git a/pkg/ai/history/codex_normalize_ginkgo_test.go b/pkg/ai/history/codex_normalize_ginkgo_test.go index c6c4b9a..cc7b248 100644 --- a/pkg/ai/history/codex_normalize_ginkgo_test.go +++ b/pkg/ai/history/codex_normalize_ginkgo_test.go @@ -1,6 +1,7 @@ package history import ( + "strings" "testing" . "github.com/onsi/ginkgo/v2" @@ -72,6 +73,24 @@ var _ = Describe("NormalizeCodexToolCall", func() { Expect(use.Input).To(Equal(map[string]any{"arguments": "catalog"})) }) + It("extracts current custom tool calls from rollout history", func() { + stream := strings.Join([]string{ + `{"timestamp":"2026-07-16T10:00:00Z","type":"session_meta","payload":{"id":"session-1","cwd":"/repo"}}`, + `{"timestamp":"2026-07-16T10:00:01Z","type":"response_item","payload":{"type":"custom_tool_call","name":"exec","call_id":"call-patch","input":"const patch = \"*** Begin Patch\\n*** Update File: app.go\\n*** End Patch\"; tools.apply_patch(patch);"}}`, + `{"timestamp":"2026-07-16T10:00:02Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call-patch","output":"Success"}}`, + }, "\n") + + uses, err := ExtractCodexToolUsesFromReader(strings.NewReader(stream)) + Expect(err).ToNot(HaveOccurred()) + Expect(uses).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "Tool": Equal("exec"), + "Input": HaveKeyWithValue("input", ContainSubstring("*** Update File: app.go")), + "SessionID": Equal("session-1"), + "ToolUseID": Equal("call-patch"), + "RecordType": Equal("response_item.custom_tool_call"), + }))) + }) + DescribeTable("normalizes nested error envelopes", func(input, expected string) { Expect(NormalizeCodexError(input)).To(Equal(expected)) diff --git a/pkg/ai/history/codex_types.go b/pkg/ai/history/codex_types.go index 1702fe2..3c91eb5 100644 --- a/pkg/ai/history/codex_types.go +++ b/pkg/ai/history/codex_types.go @@ -72,6 +72,7 @@ type CodexPayload struct { Namespace string `json:"namespace,omitempty"` Arguments json.RawMessage `json:"arguments,omitempty"` CallID string `json:"call_id,omitempty"` + Input string `json:"input,omitempty"` Metadata *CodexMetadata `json:"internal_chat_message_metadata_passthrough,omitempty"` // response_item: function_call_output. Codex emits either a JSON string or diff --git a/pkg/ai/runtime_selection_ginkgo_test.go b/pkg/ai/runtime_selection_ginkgo_test.go new file mode 100644 index 0000000..e334860 --- /dev/null +++ b/pkg/ai/runtime_selection_ginkgo_test.go @@ -0,0 +1,58 @@ +package ai + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/api" +) + +var _ = Describe("runtime model selection", func() { + It("infers a concrete backend for a bare model", func() { + model, err := ResolveModelSelectors(api.Model{Name: "gemini-3.5-flash"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(model.Name).To(Equal("gemini-3.5-flash")) + Expect(model.Backend).To(Equal(api.BackendGemini)) + }) + + It("infers each bare multi-model runtime independently", func() { + models, err := ResolveRuntimeSelectors( + []string{"gemini-3.5-flash,gpt-5.5"}, + api.Model{Name: "opus", Backend: api.BackendClaudeAgent}, + ) + + Expect(err).NotTo(HaveOccurred()) + Expect(models).To(Equal([]api.Model{ + {Name: "gemini-3.5-flash", Backend: api.BackendGemini}, + {Name: "gpt-5.5", Backend: api.BackendOpenAI}, + })) + }) + + It("rejects a known model forced through another family", func() { + _, err := ResolveModelSelectors(api.Model{ + Name: "gemini-3.5-flash", + Backend: api.BackendClaudeAgent, + }) + + Expect(err).To(MatchError(ContainSubstring("gemini"))) + Expect(err).To(MatchError(ContainSubstring("claude-agent"))) + }) + + It("allows an unknown provider model when its backend is explicit", func() { + model, err := ResolveModelSelectors(api.Model{ + Name: "provider-future-model", + Backend: api.BackendGemini, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(model.Name).To(Equal("provider-future-model")) + Expect(model.Backend).To(Equal(api.BackendGemini)) + }) + + It("requires a backend for an unknown bare model", func() { + _, err := ResolveModelSelectors(api.Model{Name: "provider-future-model"}) + + Expect(err).To(MatchError(ContainSubstring("pass an explicit backend"))) + }) +}) diff --git a/pkg/ai/runtime_selector.go b/pkg/ai/runtime_selector.go index ddec93d..7292c48 100644 --- a/pkg/ai/runtime_selector.go +++ b/pkg/ai/runtime_selector.go @@ -32,8 +32,9 @@ func ContainsRuntimeSelector(s string) bool { return false } -// ResolveModelSelectors normalizes selector-prefixed model names on a single -// api.Model, preserving the model's fallback semantics. +// ResolveModelSelectors turns every recognizable model name into a concrete +// model/backend pair while preserving fallback semantics. Unknown model names +// require an explicit backend. func ResolveModelSelectors(model api.Model) (api.Model, error) { model = model.ExpandCSV() resolved, err := resolveSelectorModel(model, "", false) @@ -113,7 +114,11 @@ func resolveSelectorPart(raw, baseName string, forced api.Backend, allowWildcard prefix = strings.ToLower(raw) modelName = strings.TrimSpace(baseName) } else { - return []api.Model{{Name: raw, Backend: forced}}, nil + model, err := resolveBareModel(raw, forced) + if err != nil { + return nil, err + } + return []api.Model{model}, nil } } else { var err error @@ -174,6 +179,42 @@ func resolveSelectorPart(raw, baseName string, forced api.Backend, allowWildcard return []api.Model{{Name: resolved, Backend: backend, Effort: selectorEffort}}, nil } +func resolveBareModel(model string, forced api.Backend) (api.Model, error) { + backend := forced + inferred, inferErr := inferModelBackend(model) + if backend == "" { + if inferErr != nil { + return api.Model{}, inferErr + } + backend = inferred + } else { + if !backend.Valid() { + return api.Model{}, fmt.Errorf("invalid backend %q (valid: %s)", backend, api.BackendList()) + } + if inferErr == nil && inferred.Family() != backend.Family() { + return api.Model{}, fmt.Errorf("model %q belongs to the %s family and cannot use backend %q (%s family)", + model, inferred.Family(), backend, backend.Family()) + } + } + + resolved, err := normalizeSelectorModel(backend, model) + if err != nil { + return api.Model{}, err + } + return api.Model{Name: resolved, Backend: backend}, nil +} + +func inferModelBackend(model string) (api.Backend, error) { + backend, err := api.InferBackend(model) + if err == nil { + return backend, nil + } + if i := strings.LastIndex(model, "/"); i >= 0 { + return api.InferBackend(model[i+1:]) + } + return "", err +} + func splitSelectorEffort(raw, model string) (string, api.Effort, error) { i := strings.LastIndex(model, ":") if i < 0 { diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 0999173..37ac4fd 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -66,25 +66,18 @@ func (o AIProviderOptions) ToConfig() (ai.Config, error) { return ai.Config{}, err } - model := o.Model - if model == "" { - model = saved.Model - } - backend := o.Backend - if backend == "" { - backend = saved.Backend - } - if o.Backend == "" && ai.ContainsRuntimeSelector(model) { - backend = "" - } - if backend != "" && !ai.Backend(backend).Valid() { - return ai.Config{}, fmt.Errorf("invalid --backend %q (valid: %s)", backend, ai.BackendList()) + m := selectModelIdentity( + api.Model{Name: saved.Model, Backend: api.Backend(saved.Backend)}, + api.Model{Name: o.Model, Backend: api.Backend(o.Backend)}, + ) + if m.Backend != "" && !m.Backend.Valid() { + return ai.Config{}, fmt.Errorf("invalid --backend %q (valid: %s)", m.Backend, ai.BackendList()) } if budget == 0 { budget = saved.BudgetUSD } - m := api.Model{Name: model, Backend: ai.Backend(backend), Fallbacks: fallbackModelsFromFlags(o.Fallback)} + m.Fallbacks = fallbackModelsFromFlags(o.Fallback) m, err = ai.ResolveModelSelectors(m) if err != nil { return ai.Config{}, err diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index ac06e51..249793d 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -68,6 +68,9 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque if bm.Name == "" { bm.Name = baseCfg.Model.Name } + if bm.ID == "" { + bm.ID = baseCfg.Model.ID + } if bm.Backend == "" { bm.Backend = baseCfg.Model.Backend } @@ -77,13 +80,13 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque if bm.Effort == "" { bm.Effort = baseCfg.Model.Effort } + identity := selectModelIdentity( + api.Model{Name: saved.Model, Backend: api.Backend(saved.Backend)}, + api.Model{Name: bm.Name, ID: bm.ID, Backend: bm.Backend}, + api.Model{Name: o.Model, Backend: api.Backend(o.Backend)}, + ) m := bm - m.Name = firstNonEmpty(o.Model, bm.Name, saved.Model) - backend := firstNonEmpty(o.Backend, string(bm.Backend), saved.Backend) - if o.Backend == "" && ai.ContainsRuntimeSelector(m.Name) { - backend = "" - } - m.Backend = api.Backend(backend) + m.Name, m.ID, m.Backend = identity.Name, identity.ID, identity.Backend if temperature != 0 { t := temperature m.Temperature = &t @@ -212,6 +215,28 @@ func firstNonEmpty(vals ...string) string { return "" } +// selectModelIdentity applies precedence from lowest to highest while keeping a +// model name and backend coupled. A higher-priority name clears a lower-priority +// backend unless that same layer explicitly supplies one. +func selectModelIdentity(layers ...api.Model) api.Model { + var selected api.Model + for _, layer := range layers { + if layer.Name != "" { + selected.Name = layer.Name + selected.ID = layer.ID + selected.Backend = layer.Backend + continue + } + if layer.ID != "" { + selected.ID = layer.ID + } + if layer.Backend != "" { + selected.Backend = layer.Backend + } + } + return selected +} + // firstPositive returns the first value > 0, or 0 when none qualify. func firstPositive(vals ...int) int { for _, v := range vals { diff --git a/pkg/cli/ai_prompt_file_test.go b/pkg/cli/ai_prompt_file_test.go index d88b76c..0c28493 100644 --- a/pkg/cli/ai_prompt_file_test.go +++ b/pkg/cli/ai_prompt_file_test.go @@ -241,13 +241,13 @@ func TestOverlayCLI_FallbackFlagOverridesFrontmatter(t *testing.T) { base := baseFileReq() base.Model.Fallbacks = []api.Model{{Name: "frontmatter-fallback"}} opts := AIPromptOptions{} - opts.Fallback = []string{"cli-fallback-a", "cli-fallback-b,cli-fallback-c"} // repeatable + comma-split + opts.Fallback = []string{"gemini-3.5-flash", "gpt-5.5,claude-sonnet-5"} // repeatable + comma-split req, _, err := overlayCLI(base, ai.Config{}, opts) if err != nil { t.Fatal(err) } - want := []string{"cli-fallback-a", "cli-fallback-b", "cli-fallback-c"} + want := []string{"gemini-3.5-flash", "gpt-5.5", "claude-sonnet-5"} if got := fallbackNames(req.Model.Fallbacks); !reflect.DeepEqual(got, want) { t.Errorf("fallbacks = %v, want CLI flags %v (override frontmatter)", got, want) } diff --git a/pkg/cli/analysis.go b/pkg/cli/analysis.go index d756cb4..8435771 100644 --- a/pkg/cli/analysis.go +++ b/pkg/cli/analysis.go @@ -55,6 +55,15 @@ func AnalyzeToolUse(t tools.Tool) ToolAnalysis { } case "Bash": a.analyzeBash(base.Input, rel) + command, _ := base.Input["command"].(string) + for _, path := range extractApplyPatchPaths(command) { + a.WritePaths = appendUnique(a.WritePaths, rel(path)) + } + case "exec": + input, _ := base.Input["input"].(string) + for _, path := range extractApplyPatchPaths(input) { + a.WritePaths = appendUnique(a.WritePaths, rel(path)) + } case "WebFetch": if urlStr, ok := base.Input["url"].(string); ok { if u, err := url.Parse(urlStr); err == nil && u.Host != "" { diff --git a/pkg/cli/analysis_ginkgo_test.go b/pkg/cli/analysis_ginkgo_test.go new file mode 100644 index 0000000..4e9d5e2 --- /dev/null +++ b/pkg/cli/analysis_ginkgo_test.go @@ -0,0 +1,19 @@ +package cli + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/claude" +) + +var _ = Describe("tool write-path analysis", func() { + It("extracts apply_patch paths embedded in Codex custom tool input", func() { + analysis := AnalyzeToolUseLegacy(claude.ToolUse{ + Tool: "exec", + Input: map[string]any{"input": `const patch = "*** Begin Patch\n*** Update File: /repo/old.go\n*** Move to: /repo/new.go\n-old := \"*** Update File: /repo/ignored.go\"\n+new := true\n*** Add File: /repo/nested/added.go\n*** End Patch"; tools.apply_patch(patch);`}, + }, "/repo") + + Expect(analysis.WritePaths).To(ConsistOf("old.go", "new.go", "nested/added.go")) + }) +}) diff --git a/pkg/cli/apply_patch.go b/pkg/cli/apply_patch.go new file mode 100644 index 0000000..8148b93 --- /dev/null +++ b/pkg/cli/apply_patch.go @@ -0,0 +1,42 @@ +package cli + +import ( + "encoding/json" + "regexp" + "strings" +) + +var ( + applyPatchFileRE = regexp.MustCompile(`(?m)^\*\*\* (?:Add|Update|Delete) File: ([^\r\n]+)\r?$`) + applyPatchMoveRE = regexp.MustCompile(`(?m)^\*\*\* Move to: ([^\r\n]+)\r?$`) + javascriptStringRE = regexp.MustCompile(`"(?:\\.|[^"\\])*"`) +) + +func extractApplyPatchPaths(input string) []string { + payloads := applyPatchPayloads(input) + var paths []string + for _, payload := range payloads { + for _, pattern := range []*regexp.Regexp{applyPatchFileRE, applyPatchMoveRE} { + for _, match := range pattern.FindAllStringSubmatch(payload, -1) { + if path := strings.TrimSpace(match[1]); path != "" { + paths = append(paths, path) + } + } + } + } + return paths +} + +func applyPatchPayloads(input string) []string { + if !strings.Contains(input, "tools.apply_patch") { + return []string{input} + } + var payloads []string + for _, literal := range javascriptStringRE.FindAllString(input, -1) { + var decoded string + if json.Unmarshal([]byte(literal), &decoded) == nil && strings.Contains(decoded, "*** Begin Patch") { + payloads = append(payloads, decoded) + } + } + return payloads +} diff --git a/pkg/cli/model_selection_ginkgo_test.go b/pkg/cli/model_selection_ginkgo_test.go new file mode 100644 index 0000000..84b888a --- /dev/null +++ b/pkg/cli/model_selection_ginkgo_test.go @@ -0,0 +1,97 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" +) + +var _ = Describe("CLI model selection", func() { + BeforeEach(func() { + path := filepath.Join(GinkgoT().TempDir(), ".captain.yaml") + Expect(os.WriteFile(path, []byte("ai:\n model: opus\n backend: claude-agent\n"), 0o600)).To(Succeed()) + captainconfig.SetPathForTesting(path) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + }) + + It("does not attach the saved Claude backend to an explicit model", func() { + opts := AIPromptOptions{} + opts.Model = "gemini-3.5-flash" + + req, cfg, err := overlayCLI(ai.Request{}, ai.Config{}, opts) + + Expect(err).NotTo(HaveOccurred()) + Expect(req.Model.Name).To(Equal("gemini-3.5-flash")) + Expect(req.Model.Backend).To(Equal(api.BackendGemini)) + Expect(cfg.Model).To(Equal(req.Model)) + }) + + It("uses the same precedence for provider options", func() { + cfg, err := (AIProviderOptions{Model: "gemini-3.5-flash"}).ToConfig() + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Model.Name).To(Equal("gemini-3.5-flash")) + Expect(cfg.Model.Backend).To(Equal(api.BackendGemini)) + }) + + It("keeps the saved model and backend paired when there is no override", func() { + cfg, err := (AIProviderOptions{}).ToConfig() + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Model.Name).To(Equal("claude-opus-4-8")) + Expect(cfg.Model.Backend).To(Equal(api.BackendClaudeAgent)) + }) + + It("does not retain a prompt backend when a structured spec replaces its model", func() { + req := ai.Request{Model: api.Model{Name: "opus", Backend: api.BackendClaudeAgent}} + cfg := ai.Config{Model: req.Model} + + overlayRuntimeSpec(&req, &cfg, api.Spec{Model: api.Model{Name: "gemini-3.5-flash"}}) + applyPromptDefaults(&req, &cfg) + resolved, err := ai.ResolveModelSelectors(cfg.Model) + + Expect(err).NotTo(HaveOccurred()) + Expect(resolved.Name).To(Equal("gemini-3.5-flash")) + Expect(resolved.Backend).To(Equal(api.BackendGemini)) + }) + + It("omits single-runtime identity from multi-model parent labels", func() { + labels := promptTaskLabels(PromptRenderResult{ + Name: "test", Model: "opus", Backend: string(api.BackendClaudeAgent), + }, "multi") + + Expect(labels).To(HaveKeyWithValue("mode", "multi")) + Expect(labels).NotTo(HaveKey("model")) + Expect(labels).NotTo(HaveKey("backend")) + }) + + It("passes a concrete Gemini runtime to a bare multi-model execution", func() { + originalExecute := executePromptRequestFunc + DeferCleanup(func() { executePromptRequestFunc = originalExecute }) + var executed ai.Config + executePromptRequestFunc = func(_ context.Context, _ ai.Request, cfg ai.Config, _ time.Duration, _ bool) (any, error) { + executed = cfg + return AIPromptResult{Text: "ok", Model: cfg.Model.Name, Backend: string(cfg.Model.Backend)}, nil + } + + result, err := executeSyncBatch( + context.Background(), + testRenderedPrompt(api.Model{Name: "opus", Backend: api.BackendClaudeAgent}), + AIPromptOptions{AIRuntimeOptions: AIRuntimeOptions{AIProviderOptions: AIProviderOptions{}}, MultiModels: []string{"gemini-3.5-flash"}}, + ) + + Expect(err).NotTo(HaveOccurred()) + Expect(executed.Model.Name).To(Equal("gemini-3.5-flash")) + Expect(executed.Model.Backend).To(Equal(api.BackendGemini)) + Expect(result.Runs).To(HaveLen(1)) + Expect(result.Runs[0].Selector).To(Equal("gemini:gemini-3.5-flash")) + }) +}) diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index 97e9aed..12d8049 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -508,14 +508,19 @@ func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { if spec.Name != "" { req.Name = spec.Name cfg.Model.Name = spec.Name - } - if spec.ID != "" { req.ID = spec.ID cfg.Model.ID = spec.ID - } - if spec.Backend != "" { req.Backend = spec.Backend cfg.Model.Backend = spec.Backend + } else { + if spec.ID != "" { + req.ID = spec.ID + cfg.Model.ID = spec.ID + } + if spec.Backend != "" { + req.Backend = spec.Backend + cfg.Model.Backend = spec.Backend + } } if spec.Temperature != nil { req.Temperature = spec.Temperature @@ -633,12 +638,21 @@ func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { func applyPromptDefaults(req *ai.Request, cfg *ai.Config) { savedCfg := loadSavedConfig() saved := savedCfg.AI - if req.Name == "" { - req.Name = firstNonEmpty(cfg.Model.Name, saved.Model) + promptModel := req.Model + if promptModel.Name == "" { + promptModel.Name = cfg.Model.Name + } + if promptModel.ID == "" { + promptModel.ID = cfg.Model.ID } - if req.Backend == "" { - req.Backend = api.Backend(firstNonEmpty(string(cfg.Model.Backend), saved.Backend)) + if promptModel.Backend == "" { + promptModel.Backend = cfg.Model.Backend } + identity := selectModelIdentity( + api.Model{Name: saved.Model, Backend: api.Backend(saved.Backend)}, + api.Model{Name: promptModel.Name, ID: promptModel.ID, Backend: promptModel.Backend}, + ) + req.Name, req.ID, req.Backend = identity.Name, identity.ID, identity.Backend if cfg.Model.Effort != api.EffortNone { // An effort-qualified model selector (for example agent:sol:high) // is model-local and intentionally overrides the request-wide flag/default. diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index dcf5c7c..61479b8 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -442,13 +442,14 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP } func promptTaskLabels(rendered PromptRenderResult, mode string) map[string]string { - labels := map[string]string{ - "model": rendered.Model, - "backend": rendered.Backend, - } + labels := map[string]string{} if mode != "" { labels["mode"] = mode } + if mode != "multi" { + labels["model"] = rendered.Model + labels["backend"] = rendered.Backend + } if rendered.Name != "" { labels["prompt"] = rendered.Name } diff --git a/pkg/cli/webapp/src/App.tsx b/pkg/cli/webapp/src/App.tsx index 1c16d73..e3b093e 100644 --- a/pkg/cli/webapp/src/App.tsx +++ b/pkg/cli/webapp/src/App.tsx @@ -15,6 +15,7 @@ import { HomeDashboard } from "./HomeDashboard"; import { PromptWorkbench } from "./PromptWorkbench"; import { SessionBrowser } from "./SessionBrowser"; import { ShellActions } from "./shell"; +import { WhoamiPage } from "./WhoamiPage"; import { captainNavSections, CAPTAIN_SIDEBAR_COLLAPSE_KEY, @@ -77,6 +78,8 @@ export function App() { > {route.kind === "dashboard" ? ( + ) : route.kind === "whoami" ? ( + ) : route.kind === "operations" ? ( { + vi.unstubAllGlobals(); +}); + +describe("WhoamiPage", () => { + it("renders the complete whoami adapter and model details", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(WHOAMI_RESULT), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + renderWhoamiPage(); + + expect(screen.getByRole("heading", { name: "AI adapters" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "API providers" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "CLI agents" })).toBeInTheDocument(); + expect(screen.getByText("gemini", { selector: "h3" })).toBeInTheDocument(); + expect(screen.getByText("Needs setup")).toBeInTheDocument(); + expect( + screen.getByText("set GEMINI_API_KEY or GOOGLE_API_KEY to list models"), + ).toBeInTheDocument(); + expect(screen.getByText("gemini login")).toBeInTheDocument(); + expect(screen.getByText("/home/example/.gemini/oauth_creds.json")).toBeInTheDocument(); + expect(screen.getByText("/usr/local/bin/gemini")).toBeInTheDocument(); + expect(screen.getByText("Gemini 3.5 Flash")).toBeInTheDocument(); + expect(screen.getByText("gemini-3.5-flash")).toBeInTheDocument(); + expect(screen.getByText("2026-05-19")).toBeInTheDocument(); + expect(screen.getByText("low / medium / high")).toBeInTheDocument(); + expect(screen.getByText("Gemini 2.5 Pro")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/whoami?models=true&limit=0", + expect.objectContaining({ method: "POST", body: "{}" }), + ); + }); + + it("surfaces a failed whoami request", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("probe failed", { status: 500 })), + ); + + renderWhoamiPage(); + + expect(await screen.findByText("probe failed")).toBeInTheDocument(); + }); +}); + +function renderWhoamiPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + , + ); +} diff --git a/pkg/cli/webapp/src/WhoamiPage.tsx b/pkg/cli/webapp/src/WhoamiPage.tsx new file mode 100644 index 0000000..a1e8055 --- /dev/null +++ b/pkg/cli/webapp/src/WhoamiPage.tsx @@ -0,0 +1,291 @@ +import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@flanksource/clicky-ui/components"; +import { + Badge, + UiCheck, + UiCloud, + UiFingerprint, + UiKey, + UiRefresh, + UiTerminal, + UiWarningTriangle, +} from "@flanksource/clicky-ui/data"; + +type WhoamiModel = { + id: string; + label?: string; + releaseDate?: string; + reasoning?: boolean; + temperature?: boolean; + inputMediaTypes?: string[]; + supportedEfforts?: string[]; + defaultEffort?: string; + priority?: number; +}; + +type WhoamiAdapter = { + backend: string; + type: "api" | "cli"; + authenticated: boolean; + authMethod?: string; + authDetail?: string; + binary?: string; + binaryMissing?: string; + modelCount: number; + models?: string[]; + modelError?: string; + modelDetails?: WhoamiModel[]; +}; + +type WhoamiResult = { + adapters: WhoamiAdapter[]; +}; + +export function WhoamiPage() { + const query = useQuery({ + queryKey: ["whoami", "full"], + queryFn: fetchWhoami, + }); + + return ( +
+
+
+
+ +
+

AI adapters

+

+ Full whoami status for API providers and local agent runtimes. +

+
+
+ +
+ + {query.isLoading ? ( + Probing adapters and model catalogs... + ) : query.error ? ( + {errorMessage(query.error)} + ) : ( + + )} +
+
+ ); +} + +function WhoamiContent({ adapters }: { adapters: WhoamiAdapter[] }) { + if (adapters.length === 0) return No adapters were reported.; + + const readyCount = adapters.filter(adapterReady).length; + const modelCount = adapters.reduce((total, adapter) => total + adapter.modelCount, 0); + return ( + <> +
+ {adapters.length} adapters + {readyCount} ready + {modelCount} model entries +
+ + + + ); +} + +function AdapterGroup({ + title, + description, + type, + adapters, +}: { + title: string; + description: string; + type: WhoamiAdapter["type"]; + adapters: WhoamiAdapter[]; +}) { + const rows = adapters.filter((adapter) => adapter.type === type); + if (rows.length === 0) return null; + const GroupIcon = type === "api" ? UiCloud : UiTerminal; + + return ( +
+
+ +
+

{title}

+

{description}

+
+
+
+ {rows.map((adapter) => )} +
+
+ ); +} + +function AdapterCard({ adapter }: { adapter: WhoamiAdapter }) { + const ready = adapterReady(adapter); + const models = adapterModels(adapter); + return ( +
+
+
+ {ready ? ( + + ) : ( + + )} +

{adapter.backend}

+ {adapter.type.toUpperCase()} +
+ + {ready ? "Ready" : adapter.authenticated ? "Unavailable" : "Needs setup"} + +
+ +
+ + {adapter.modelError && ( +
+ {adapter.modelError} +
+ )} +
+
+

Models

+ {adapter.modelCount} +
+ {models.length > 0 ? ( +
    + {models.map((model) => )} +
+ ) : ( +
+ No models reported. +
+ )} +
+
+
+ ); +} + +function AdapterDetails({ adapter }: { adapter: WhoamiAdapter }) { + return ( +
+ } /> + {adapter.authDetail && } + {adapter.type === "cli" && ( + } + mono + /> + )} +
+ ); +} + +function Detail({ + label, + value, + icon, + mono, +}: { + label: string; + value: string; + icon?: ReactNode; + mono?: boolean; +}) { + return ( + <> +
{icon}{label}
+
{value}
+ + ); +} + +function ModelRow({ model }: { model: WhoamiModel }) { + return ( +
  • +
    +
    +
    {model.label || model.id}
    +
    {model.id}
    +
    + {model.releaseDate && {model.releaseDate}} +
    +
    + {model.reasoning && reasoning} + {model.temperature && temperature} + {model.supportedEfforts && model.supportedEfforts.length > 0 && ( + Effort: {model.supportedEfforts.join(" / ")} + )} + {model.defaultEffort && Default: {model.defaultEffort}} + {model.inputMediaTypes && model.inputMediaTypes.length > 0 && ( + {model.inputMediaTypes.join(", ")} + )} +
    +
  • + ); +} + +function StateMessage({ + children, + tone = "neutral", +}: { + children: ReactNode; + tone?: "neutral" | "error"; +}) { + const classes = tone === "error" + ? "border-destructive/30 bg-destructive/10 text-destructive" + : "border-border bg-muted/30 text-muted-foreground"; + return
    {children}
    ; +} + +function adapterReady(adapter: WhoamiAdapter) { + return adapter.authenticated && (adapter.type !== "cli" || Boolean(adapter.binary)); +} + +function adapterModels(adapter: WhoamiAdapter): WhoamiModel[] { + if (adapter.modelDetails && adapter.modelDetails.length > 0) return adapter.modelDetails; + return (adapter.models ?? []).map((id) => ({ id })); +} + +async function fetchWhoami(): Promise { + const response = await fetch("/api/v1/whoami?models=true&limit=0", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: "{}", + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Whoami failed with ${response.status}`); + } + return (await response.json()) as WhoamiResult; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/pkg/cli/webapp/src/shellHelpers.test.ts b/pkg/cli/webapp/src/shellHelpers.test.ts new file mode 100644 index 0000000..91ee7b4 --- /dev/null +++ b/pkg/cli/webapp/src/shellHelpers.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { captainNavSections } from "./shellHelpers"; + +describe("captainNavSections", () => { + it("exposes the full whoami route as an active primary destination", () => { + const items = captainNavSections("whoami")[0]?.items ?? []; + const whoami = items.find((item) => item.key === "whoami"); + + expect(whoami).toMatchObject({ + label: "Whoami", + to: "/whoami", + active: true, + }); + }); +}); diff --git a/pkg/cli/webapp/src/shellHelpers.ts b/pkg/cli/webapp/src/shellHelpers.ts index c905391..646ff9e 100644 --- a/pkg/cli/webapp/src/shellHelpers.ts +++ b/pkg/cli/webapp/src/shellHelpers.ts @@ -5,6 +5,7 @@ import type { import { UiActivity, UiFileText, + UiFingerprint, UiHistory, UiRobotAi, UiServer, @@ -16,7 +17,13 @@ import { type ProjectScope, } from "./sessionData"; -export type PrimaryRoute = "dashboard" | "agent" | "sessions" | "prompts" | "operations"; +export type PrimaryRoute = + | "dashboard" + | "agent" + | "sessions" + | "prompts" + | "whoami" + | "operations"; export const CAPTAIN_SIDEBAR_COLLAPSE_KEY = "captain:sidebar:collapsed"; @@ -39,6 +46,13 @@ export function captainNavSections( active: active === "dashboard", }, { key: "agent", label: "Agent", to: "/agent", icon: UiRobotAi, active: active === "agent" }, + { + key: "whoami", + label: "Whoami", + to: "/whoami", + icon: UiFingerprint, + active: active === "whoami", + }, { key: "sessions", label: "Sessions", diff --git a/pkg/monitor/backfill.go b/pkg/monitor/backfill.go index a91fbce..73116fa 100644 --- a/pkg/monitor/backfill.go +++ b/pkg/monitor/backfill.go @@ -67,22 +67,35 @@ func discoverTranscripts() (roots, agents []transcriptRef) { } // isEphemeralClaudeTranscript excludes projects created below a system temp -// root. Go integration tests that launch Claude leave their transcript mirror -// under ~/.claude/projects even after the temporary working directory is gone; -// those fixtures are not durable user sessions and should not be backfilled. +// root whose working directory is already gone. Go integration tests that +// launch Claude leave their transcript mirror under ~/.claude/projects even +// after the temporary working directory is removed; those stale fixtures are +// not durable user sessions and should not be backfilled. A transcript whose +// temp working directory still exists (e.g. a test that is currently running) +// is a real, listable session and must be kept. func isEphemeralClaudeTranscript(projectsDir, path string) bool { rel, err := filepath.Rel(projectsDir, path) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return false } projectDir := strings.SplitN(rel, string(filepath.Separator), 2)[0] + underTemp := false for _, root := range []string{os.TempDir(), "/tmp", "/private/tmp"} { prefix := strings.TrimSuffix(claude.NormalizePath(filepath.Clean(root)), "-") if prefix != "" && (projectDir == prefix || strings.HasPrefix(projectDir, prefix+"-")) { - return true + underTemp = true + break } } - return false + if !underTemp { + return false + } + original := claude.DenormalizePath(projectDir) + if original == "" { + return true + } + _, statErr := os.Stat(original) + return statErr != nil } func ingestChanged(ctx context.Context, ingestor *ingestor, refs []transcriptRef) { diff --git a/pkg/monitor/backfill_test.go b/pkg/monitor/backfill_test.go index 864a4b4..58186d9 100644 --- a/pkg/monitor/backfill_test.go +++ b/pkg/monitor/backfill_test.go @@ -22,6 +22,15 @@ func TestIsEphemeralClaudeTranscript(t *testing.T) { filepath.Join(projectsDir, claude.NormalizePath("/Users/dev/src/project"), "session.jsonl"))) assert.False(t, isEphemeralClaudeTranscript(projectsDir, filepath.Join(t.TempDir(), "outside-projects", "session.jsonl"))) + + // A temp-rooted project whose working directory still exists is a live + // session (e.g. an integration test that is currently running), not a stale + // fixture, and must be kept. + liveProject := filepath.Join(t.TempDir(), "work", "project") + require.NoError(t, os.MkdirAll(liveProject, 0o755)) + assert.False(t, isEphemeralClaudeTranscript(projectsDir, + filepath.Join(projectsDir, claude.NormalizePath(liveProject), "session.jsonl")), + "a temp project whose working directory still exists must be backfilled") } func TestDiscoverTranscriptsSkipsSystemTempClaudeProjects(t *testing.T) { From c8ac467a10005a76012f2a58f746e006dc2830cb Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 13:21:32 +0300 Subject: [PATCH 077/131] feat(api): support caller-supplied tools and approval preferences Introduce a shared tool model, catalog DTO, preference resolution, and approval policy for caller-supplied tools. Expose runtime tool definitions through API configuration and identify providers that support in-process tool execution. --- pkg/ai/tools/catalog.go | 141 +++++++++++++++++++ pkg/ai/tools/tools.go | 272 ++++++++++++++++++++++++++++++++++++ pkg/api/runtime_config.go | 6 + pkg/api/runtime_provider.go | 8 ++ pkg/api/tooldef.go | 35 +++++ 5 files changed, 462 insertions(+) create mode 100644 pkg/ai/tools/catalog.go create mode 100644 pkg/ai/tools/tools.go create mode 100644 pkg/api/tooldef.go diff --git a/pkg/ai/tools/catalog.go b/pkg/ai/tools/catalog.go new file mode 100644 index 0000000..2ebd558 --- /dev/null +++ b/pkg/ai/tools/catalog.go @@ -0,0 +1,141 @@ +package tools + +import "strings" + +// ToolCatalog is the GET /api/chat/tools payload. +type ToolCatalog struct { + Tools []ToolCatalogEntry `json:"tools"` +} + +// ToolCatalogEntry is the frontend-facing DTO for one tool. Unlike ToolInfo it +// keeps method/path/operationName as typed fields, since the tool-preferences UI +// renders them. +type ToolCatalogEntry struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Source string `json:"source"` + Server string `json:"server,omitempty"` + Group string `json:"group,omitempty"` + Parent string `json:"parent,omitempty"` + Icon string `json:"icon,omitempty"` + PreferenceKey string `json:"preferenceKey,omitempty"` + DefaultPermission ToolMode `json:"defaultPermission,omitempty"` + Strict *bool `json:"strict,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + OperationName string `json:"operationName,omitempty"` + InputSchema map[string]any `json:"inputSchema"` + OutputSchema map[string]any `json:"outputSchema,omitempty"` +} + +// CustomCatalogEntry builds the catalog DTO for an app-owned custom tool. The +// method/path/operationName it surfaces come from the definition's Annotations +// (clicky/method, clicky/path, clicky/operation) when present. +func CustomCatalogEntry(def ToolDefinition, name string, schema map[string]any) ToolCatalogEntry { + info := ToolInfo{ + Name: name, + Group: def.Group, + Parent: def.Parent, + Icon: def.Icon, + DefaultPermission: DefaultPermissionMode(def.DefaultPermission), + Strict: def.Strict, + Annotations: def.Annotations, + } + return ToolCatalogEntry{ + Name: name, + Title: def.Name, + Description: def.Description, + Source: "custom", + Group: def.Group, + Parent: def.Parent, + Icon: def.Icon, + PreferenceKey: PreferenceKey(info), + DefaultPermission: DefaultPermissionMode(def.DefaultPermission), + Strict: def.Strict, + Method: def.Annotations["clicky/method"], + Path: def.Annotations["clicky/path"], + OperationName: def.Name, + InputSchema: ObjectSchema(schema), + } +} + +// PreferenceKey returns the key a tool is governed by in the preferences UI: its +// group when grouped, otherwise its own name. +func PreferenceKey(info ToolInfo) string { + if info.Group != "" { + return info.Group + } + return info.Name +} + +// ObjectSchema defaults a nil/typeless schema to an empty JSON object schema. +func ObjectSchema(schema map[string]any) map[string]any { + if schema == nil { + return map[string]any{"type": "object", "properties": map[string]any{}} + } + if _, ok := schema["type"]; !ok { + schema["type"] = "object" + } + if schema["type"] == "object" { + if _, ok := schema["properties"]; !ok { + schema["properties"] = map[string]any{} + } + } + return schema +} + +// StringMetadata returns the first non-empty string value among keys. +func StringMetadata(meta map[string]any, keys ...string) (string, bool) { + for _, key := range keys { + if v, ok := meta[key].(string); ok && v != "" { + return v, true + } + } + return "", false +} + +// ApplyToolMetadata overlays MCP tool metadata (group/parent/icon/permission/ +// strict, incl. the nested com.flanksource.clicky/tool block) onto an entry. +func ApplyToolMetadata(entry *ToolCatalogEntry, meta map[string]any) { + if entry == nil || len(meta) == 0 { + return + } + if group, ok := StringMetadata(meta, "group", "toolGroup"); ok && entry.Group == "" { + entry.Group = group + entry.PreferenceKey = group + } + if parent, ok := StringMetadata(meta, "parent", "toolParent"); ok && entry.Parent == "" { + entry.Parent = parent + } + if icon, ok := StringMetadata(meta, "icon", "toolIcon"); ok && entry.Icon == "" { + entry.Icon = icon + } + if permission, ok := StringMetadata(meta, "defaultPermission", "permission", "defaultMode"); ok { + entry.DefaultPermission = DefaultPermissionMode(ToolMode(permission)) + } + if strict, ok := BoolMetadata(meta, "strict"); ok && entry.Strict == nil { + entry.Strict = &strict + } + if nested, ok := meta["com.flanksource.clicky/tool"].(map[string]any); ok { + ApplyToolMetadata(entry, nested) + } +} + +// BoolMetadata returns the first bool-valued (or "true"/"false" string) key. +func BoolMetadata(meta map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + switch v := meta[key].(type) { + case bool: + return v, true + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true": + return true, true + case "false": + return false, true + } + } + } + return false, false +} diff --git a/pkg/ai/tools/tools.go b/pkg/ai/tools/tools.go new file mode 100644 index 0000000..ef3afab --- /dev/null +++ b/pkg/ai/tools/tools.go @@ -0,0 +1,272 @@ +// Package tools is captain's home for the chat tool registry's data model and +// approval policy: the tool definition/info types, the per-request tool mode and +// preferences, the tool catalog DTO, and the approval-decision logic. It is +// genkit- and clicky-free — the genkit binding (registering these as model tools) +// and the clicky-RPC→tool mapping live in the consumer (clicky/aichat), which +// imports this package. Clicky-specific metadata (the originating verb/method/ +// path) rides in the opaque Annotations map rather than as typed fields. +package tools + +import ( + "context" + "sort" + "strings" +) + +// ToolMode controls how a tool is exposed for one request. +type ToolMode string + +const ( + ToolModeOn ToolMode = "on" + ToolModeAsk ToolMode = "ask" + ToolModeOff ToolMode = "off" + ToolModeAuto ToolMode = "auto" + + // Backward-compatible aliases for the labels older clients send. + ToolModeEnabled ToolMode = ToolModeOn + ToolModeDisabled ToolMode = ToolModeOff +) + +// NormalizeToolMode canonicalizes a mode string, accepting the older +// "enabled"/"disabled" labels. The bool is false for an unrecognized value. +func NormalizeToolMode(mode ToolMode) (ToolMode, bool) { + switch ToolMode(strings.ToLower(strings.TrimSpace(string(mode)))) { + case ToolModeOn, "enabled": + return ToolModeOn, true + case ToolModeAsk: + return ToolModeAsk, true + case ToolModeOff, "disabled": + return ToolModeOff, true + case ToolModeAuto: + return ToolModeAuto, true + default: + return "", false + } +} + +// DefaultPermissionMode resolves a mode to its canonical value, defaulting an +// unset/unknown value to Auto (defer to the approval policy). +func DefaultPermissionMode(mode ToolMode) ToolMode { + if normalized, ok := NormalizeToolMode(mode); ok { + return normalized + } + return ToolModeAuto +} + +// ApprovalDecisionForMode maps a resolved mode to an approve/auto decision. The +// second bool is false only for Auto, which defers to the policy. +func ApprovalDecisionForMode(mode ToolMode) (require bool, handled bool) { + switch DefaultPermissionMode(mode) { + case ToolModeOn: + return false, true + case ToolModeAsk: + return true, true + case ToolModeOff: + return false, true + case ToolModeAuto: + return false, false + default: + return false, false + } +} + +// ToolPreferences carries the clicky-ui tool preference payload. The UI sends +// "on", "ask", "off", or "auto"; "enabled"/"disabled" are accepted for older +// callers. +type ToolPreferences map[string]ToolMode + +// ToolInfo is the concrete tool being considered for approval and preference +// resolution. Clicky-RPC specifics (verb/method/path/operation) live in +// Annotations, not as typed fields. +type ToolInfo struct { + Name string + // Group is the tool-group this tool belongs to. When non-empty the + // preferences UI presents the group as one entry governing every member. + Group string + Parent string + Icon string + DefaultPermission ToolMode + Strict *bool + ReadOnlyHint *bool + DestructiveHint *bool + IdempotentHint *bool + // Annotations carries opaque caller metadata (e.g. clicky/verb, clicky/method, + // clicky/path, clicky/operation) for policies that want the raw values. + Annotations map[string]string +} + +// Annotation returns the named annotation (empty when absent). +func (i ToolInfo) Annotation(key string) string { + if i.Annotations == nil { + return "" + } + return i.Annotations[key] +} + +// ToolDefinition describes an app-owned tool registered alongside clicky RPC and +// MCP tools. Handlers should return JSON-serializable values. +type ToolDefinition struct { + Name string + Description string + InputSchema map[string]any + Parent string + Icon string + DefaultPermission ToolMode + Strict *bool + ReadOnlyHint *bool + DestructiveHint *bool + IdempotentHint *bool + // Group places this custom tool in a tool-group so the preferences UI presents + // it under the group rather than individually. + Group string + // Annotations carries opaque caller metadata (see ToolInfo.Annotations). + Annotations map[string]string + Handler func(context.Context, any) (any, error) +} + +// ApprovalPolicy reports whether a tool call must be approved before it runs. +type ApprovalPolicy func(toolName string, input any) bool + +// ToolApprovalPolicy is the metadata-aware approval hook; it takes precedence +// over ApprovalPolicy when both are configured. +type ToolApprovalPolicy func(tool ToolInfo, input any) bool + +// ApprovalPredicate is the internal gate type shared by the resolvers. +type ApprovalPredicate func(tool ToolInfo, input any) bool + +// ResolveApprovalPolicy picks the effective gate: an explicit tool policy wins, +// then a name-based policy, then an exact-name list, else nil (auto-approve). +func ResolveApprovalPolicy(toolPolicy ToolApprovalPolicy, policy ApprovalPolicy, names []string) ApprovalPredicate { + if toolPolicy != nil { + return ApprovalPredicate(toolPolicy) + } + if policy != nil { + return func(tool ToolInfo, input any) bool { + return policy(tool.Name, input) + } + } + return RequireApprovalFor(names) +} + +// RequireApprovalFor builds a predicate requiring approval for exactly the named +// tools. An empty list yields nil (auto-approve everything). +func RequireApprovalFor(names []string) ApprovalPredicate { + if len(names) == 0 { + return nil + } + set := make(map[string]bool, len(names)) + for _, n := range names { + set[n] = true + } + return func(tool ToolInfo, _ any) bool { + return set[tool.Name] + } +} + +type toolRuntimeConfig struct { + preferences ToolPreferences + defaultApproval ApprovalPredicate +} + +type toolRuntimeContextKey struct{} + +// WithRuntime stores the per-request tool preferences + default approval gate on +// the context so a tool handler can resolve its approval decision. +func WithRuntime(ctx context.Context, prefs ToolPreferences, defaultApproval ApprovalPredicate) context.Context { + return context.WithValue(ctx, toolRuntimeContextKey{}, toolRuntimeConfig{preferences: prefs, defaultApproval: defaultApproval}) +} + +func runtimeConfig(ctx context.Context) (toolRuntimeConfig, bool) { + cfg, ok := ctx.Value(toolRuntimeContextKey{}).(toolRuntimeConfig) + return cfg, ok +} + +// ShouldRequireApproval resolves whether a tool call must be approved, honoring +// (in order) the per-request preference, the tool's default permission, the +// runtime's default approval gate, and finally the supplied fallback. +func ShouldRequireApproval(ctx context.Context, fallback ApprovalPredicate, tool ToolInfo, input any) bool { + if ctx != nil { + if cfg, ok := runtimeConfig(ctx); ok { + if mode, ok := EffectivePreference(cfg.preferences, tool); ok { + if decision, handled := ApprovalDecisionForMode(mode); handled { + return decision + } + } + if decision, handled := ApprovalDecisionForMode(DefaultPermissionMode(tool.DefaultPermission)); handled { + return decision + } + if cfg.defaultApproval != nil { + return cfg.defaultApproval(tool, input) + } + } + } + if decision, handled := ApprovalDecisionForMode(DefaultPermissionMode(tool.DefaultPermission)); handled { + return decision + } + if fallback == nil { + return false + } + return fallback(tool, input) +} + +// EffectivePreference resolves the ToolMode for a tool: an exact tool-name +// preference wins, else the tool's group preference; ungrouped tools resolve by +// their own name. +func EffectivePreference(prefs ToolPreferences, info ToolInfo) (ToolMode, bool) { + if mode, ok := NormalizedPreference(prefs, info.Name); ok { + return mode, true + } + if info.Group != "" { + return NormalizedPreference(prefs, info.Group) + } + return "", false +} + +// NormalizedPreference looks up and normalizes a preference by key. +func NormalizedPreference(prefs ToolPreferences, name string) (ToolMode, bool) { + if len(prefs) == 0 { + return "", false + } + mode, ok := prefs[name] + if !ok { + return "", false + } + return NormalizeToolMode(mode) +} + +// ToolEntry is one row in the tool-preferences UI: a single ungrouped tool, or a +// collapsed group listing its member names. +type ToolEntry struct { + Key string `json:"key"` + Group string `json:"group,omitempty"` + Tools []string `json:"tools"` + Mode ToolMode `json:"mode,omitempty"` +} + +// ListToolEntries collapses grouped tools into one entry per group and leaves +// ungrouped tools individual, sorted by Key. prefs (may be nil) annotates Mode. +func ListToolEntries(infos []ToolInfo, prefs ToolPreferences) []ToolEntry { + groups := map[string][]string{} + var entries []ToolEntry + for _, info := range infos { + if g := info.Group; g != "" { + groups[g] = append(groups[g], info.Name) + continue + } + entry := ToolEntry{Key: info.Name, Tools: []string{info.Name}} + if mode, ok := NormalizedPreference(prefs, info.Name); ok { + entry.Mode = mode + } + entries = append(entries, entry) + } + for group, members := range groups { + sort.Strings(members) + entry := ToolEntry{Key: group, Group: group, Tools: members} + if mode, ok := NormalizedPreference(prefs, group); ok { + entry.Mode = mode + } + entries = append(entries, entry) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Key < entries[j].Key }) + return entries +} diff --git a/pkg/api/runtime_config.go b/pkg/api/runtime_config.go index b1778dd..9e04946 100644 --- a/pkg/api/runtime_config.go +++ b/pkg/api/runtime_config.go @@ -61,4 +61,10 @@ type Config struct { // 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:"-"` + + // Tools are caller-supplied tools exposed to the model and executed + // in-process. Only tool-capable providers (see ToolCapableProvider — today + // the genkit API backends) honour them; other providers, which bring their + // own tool ecosystems, ignore the field. Never serialized (Go closures). + Tools []ToolDefinition `json:"-"` } diff --git a/pkg/api/runtime_provider.go b/pkg/api/runtime_provider.go index 712fefd..3210501 100644 --- a/pkg/api/runtime_provider.go +++ b/pkg/api/runtime_provider.go @@ -32,6 +32,14 @@ type CloseableProvider interface { Close() error } +// ToolCapableProvider is implemented by providers that can expose and execute +// the caller-supplied Config.Tools (rather than only the backend's built-in +// tools). Resolve it with ProviderAs[ToolCapableProvider] to check support +// before relying on Config.Tools being honoured. +type ToolCapableProvider interface { + SupportsCallerTools() bool +} + func ProviderAs[T any](provider Provider) (T, bool) { for provider != nil { if capability, ok := any(provider).(T); ok { diff --git a/pkg/api/tooldef.go b/pkg/api/tooldef.go new file mode 100644 index 0000000..0833878 --- /dev/null +++ b/pkg/api/tooldef.go @@ -0,0 +1,35 @@ +package api + +import "context" + +// ToolHandler runs a caller-supplied tool. input is the model's tool-call +// arguments (the decoded JSON object); the returned value is marshaled back to +// the model as the tool result. Returning an error surfaces it to the model as +// a failed tool call. +type ToolHandler func(ctx context.Context, input map[string]any) (any, error) + +// ToolDefinition is a caller-supplied tool that a tool-capable provider (see +// ToolCapableProvider) exposes to the model and executes in-process. It carries +// a Go handler, so — like CanUseTool — it is a runtime concern that lives on +// Config, never on the serializable Spec, and is never marshaled. +type ToolDefinition struct { + // Name is the tool id the model calls (provider-safe: letters, digits, _-). + Name string + // Description tells the model when/how to use the tool. + Description string + // InputSchema is the JSON Schema (decoded to a map) for the tool arguments. + // Nil means a no-argument tool. + InputSchema map[string]any + // Handler executes the tool in-process. Required. + Handler ToolHandler `json:"-"` + // DefaultPermission gates execution: ToolModeAsk routes the call through + // Config.CanUseTool before the handler runs; any other value auto-runs. + DefaultPermission ToolMode + // Annotations carries opaque caller metadata (e.g. the originating CLI + // verb/method/path) for policies that want the raw values; providers ignore it. + Annotations map[string]string `json:",omitempty"` +} + +// NeedsApproval reports whether a call to this tool must go through +// Config.CanUseTool before running. +func (t ToolDefinition) NeedsApproval() bool { return t.DefaultPermission == ToolModeAsk } From f47896b55506ea639d4c02f222dd8ba4ffe86a49 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 13:22:13 +0300 Subject: [PATCH 078/131] feat(db): add partial lifecycle and hierarchical session retrieval Persist partial batch outcomes and session parent/root relationships so multi-session runs remain queryable as one thread. Exact root lookups now return the complete session tree for downstream clients. --- migrations/00_types.pg.hcl | 2 +- migrations/51_state_triggers.sql | 2 +- migrations/migrations_test.go | 1 + pkg/cli/session_get.go | 53 +++++++++++++++-- pkg/cli/session_get_multi_test.go | 31 +++++++++- pkg/cli/session_record_db.go | 12 ++++ pkg/cli/session_record_db_test.go | 5 ++ pkg/database/session_batch_store.go | 90 +++++++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 pkg/database/session_batch_store.go diff --git a/migrations/00_types.pg.hcl b/migrations/00_types.pg.hcl index e1ee71d..6b1b8a9 100644 --- a/migrations/00_types.pg.hcl +++ b/migrations/00_types.pg.hcl @@ -2,7 +2,7 @@ schema "public" {} enum "captain_session_lifecycle_status" { schema = schema.public - values = ["created", "running", "succeeded", "failed", "cancelled", "interrupted"] + values = ["created", "running", "succeeded", "partial", "failed", "cancelled", "interrupted"] } enum "captain_session_activity_state" { diff --git a/migrations/51_state_triggers.sql b/migrations/51_state_triggers.sql index 3fba565..3e59d16 100644 --- a/migrations/51_state_triggers.sql +++ b/migrations/51_state_triggers.sql @@ -42,7 +42,7 @@ BEGIN IF NEW.lifecycle_status = 'running' THEN NEW.started_at := COALESCE(NEW.started_at, NEW.created_at, observed_at); NEW.ended_at := NULL; - ELSIF NEW.lifecycle_status IN ('succeeded', 'failed', 'cancelled', 'interrupted') THEN + ELSIF NEW.lifecycle_status IN ('succeeded', 'partial', 'failed', 'cancelled', 'interrupted') THEN NEW.started_at := COALESCE(NEW.started_at, NEW.ended_at, NEW.created_at, observed_at); NEW.ended_at := COALESCE(NEW.ended_at, observed_at); END IF; diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index af19492..e380751 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -39,6 +39,7 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { t.Errorf("embedded migration %s: %v", name, err) } } + assertContainsAll(t, "00_types.pg.hcl", `"partial"`) assertContainsAll(t, "10_sessions.pg.hcl", `table "captain_sessions"`, diff --git a/pkg/cli/session_get.go b/pkg/cli/session_get.go index afb2d81..e5738c2 100644 --- a/pkg/cli/session_get.go +++ b/pkg/cli/session_get.go @@ -12,12 +12,15 @@ import ( ) type SessionGetResult struct { - Sessions []SessionGetItem `json:"sessions"` - Total int `json:"total"` + RootSessionID string `json:"rootSessionId,omitempty"` + Sessions []SessionGetItem `json:"sessions"` + Total int `json:"total"` } type SessionGetItem struct { CaptainID string `json:"captainId"` + ParentSessionID string `json:"parentSessionId,omitempty"` + RootSessionID string `json:"rootSessionId,omitempty"` ProviderSessionID string `json:"providerSessionId,omitempty"` Host string `json:"host,omitempty"` DetailAvailable bool `json:"detailAvailable"` @@ -57,9 +60,19 @@ func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResul DetailAvailable: path != "", Summary: recordFromOverview(overview), } + if overview.ParentSessionID != nil { + item.ParentSessionID = overview.ParentSessionID.String() + } + if overview.RootSessionID != nil { + item.RootSessionID = overview.RootSessionID.String() + } capabilities := sessionChatCapabilities(item.Summary) item.Chat = &capabilities - if active, ok := promptChats.getSession(item.ProviderSessionID); ok { + active, ok := promptChats.getRun(item.CaptainID) + if !ok && item.ProviderSessionID != "" { + active, ok = promptChats.getSession(item.ProviderSessionID) + } + if ok { var activeCapabilities ChatCapabilities item.ActiveRunID, activeCapabilities, item.ChatState = active.projection() item.Chat = &activeCapabilities @@ -76,7 +89,11 @@ func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResul } items = append(items, item) } - return SessionGetResult{Sessions: items, Total: len(items)}, nil + rootID := "" + if len(items) > 1 && items[0].ParentSessionID == "" { + rootID = items[0].CaptainID + } + return SessionGetResult{RootSessionID: rootID, Sessions: items, Total: len(items)}, nil } func sessionChatCapabilities(summary SessionRecord) ChatCapabilities { @@ -127,6 +144,34 @@ func (r SessionGetResult) Pretty() clickyapi.Text { return clickyapi.Text{}.Add(list) } +func (r SessionGetResult) Tree() clickyapi.TreeNode { + byParent := map[string][]SessionGetItem{} + for i := range r.Sessions { + byParent[r.Sessions[i].ParentSessionID] = append(byParent[r.Sessions[i].ParentSessionID], r.Sessions[i]) + } + children := make([]clickyapi.TreeNode, 0, len(byParent[""])) + for _, item := range byParent[""] { + children = append(children, sessionGetTreeNode{item: item, byParent: byParent}) + } + return &clickyapi.ConcreteBranchNode{Children: children} +} + +type sessionGetTreeNode struct { + item SessionGetItem + byParent map[string][]SessionGetItem +} + +func (n sessionGetTreeNode) Pretty() clickyapi.Text { return n.item.Pretty() } + +func (n sessionGetTreeNode) GetChildren() []clickyapi.TreeNode { + items := n.byParent[n.item.CaptainID] + children := make([]clickyapi.TreeNode, len(items)) + for i := range items { + children[i] = sessionGetTreeNode{item: items[i], byParent: n.byParent} + } + return children +} + func (i SessionGetItem) Pretty() clickyapi.Text { text := clickyapi.Text{}. Append("Captain ", "text-gray-500"). diff --git a/pkg/cli/session_get_multi_test.go b/pkg/cli/session_get_multi_test.go index 3848233..81f7aad 100644 --- a/pkg/cli/session_get_multi_test.go +++ b/pkg/cli/session_get_multi_test.go @@ -8,6 +8,7 @@ import ( "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" "github.com/flanksource/clicky" + "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -58,6 +59,23 @@ var _ = Describe("session get multi-result output", func() { Expect(store.listCalls).To(BeZero()) }) + It("expands an exact root session ID into its complete thread", func() { + rootID := uuid.MustParse("055781c7-360a-4eb2-80be-452b3937fcfe") + childID := uuid.MustParse("7ca78c55-e280-50ff-a19a-9f355a6fc55e") + store := &sessionGetOverviewStore{ + identity: []database.SessionOverview{{ID: rootID, Source: "captain"}}, + thread: []database.SessionOverview{ + {ID: rootID, Source: "captain"}, + {ID: childID, ParentSessionID: &rootID, RootSessionID: &rootID, Source: "codex"}, + }, + } + + overviews, err := resolveOverviewsByAnyID(context.Background(), store, rootID.String()) + Expect(err).NotTo(HaveOccurred()) + Expect(overviews).To(Equal(store.thread)) + Expect(store.threadRoots).To(Equal([]uuid.UUID{rootID})) + }) + It("renders every match sequentially and preserves metadata-only sessions", func() { result := SessionGetResult{ Sessions: []SessionGetItem{ @@ -126,9 +144,11 @@ var _ = Describe("session get multi-result output", func() { }) type sessionGetOverviewStore struct { - identity []database.SessionOverview - identities []string - listCalls int + identity []database.SessionOverview + thread []database.SessionOverview + identities []string + threadRoots []uuid.UUID + listCalls int } func (s *sessionGetOverviewStore) ListSessionOverviewsByIdentity(_ context.Context, identity string) ([]database.SessionOverview, error) { @@ -140,3 +160,8 @@ func (s *sessionGetOverviewStore) ListSessionOverviews(context.Context, database s.listCalls++ return nil, nil } + +func (s *sessionGetOverviewStore) ListThreadSessionOverviews(_ context.Context, rootID uuid.UUID) ([]database.SessionOverview, error) { + s.threadRoots = append(s.threadRoots, rootID) + return s.thread, nil +} diff --git a/pkg/cli/session_record_db.go b/pkg/cli/session_record_db.go index 46e2e58..d2a15d4 100644 --- a/pkg/cli/session_record_db.go +++ b/pkg/cli/session_record_db.go @@ -8,11 +8,13 @@ import ( "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" + "github.com/google/uuid" ) type sessionOverviewStore interface { ListSessionOverviewsByIdentity(context.Context, string) ([]database.SessionOverview, error) ListSessionOverviews(context.Context, database.SessionOverviewFilter) ([]database.SessionOverview, error) + ListThreadSessionOverviews(context.Context, uuid.UUID) ([]database.SessionOverview, error) } // sessionRecordQuery narrows the DB-backed session record list. @@ -82,6 +84,16 @@ func isSessionIdentityQuery(query string) bool { func resolveOverviewsByAnyID(ctx context.Context, db sessionOverviewStore, id string) ([]database.SessionOverview, error) { overviews, err := db.ListSessionOverviewsByIdentity(ctx, id) if err == nil { + if parsed, parseErr := uuid.Parse(id); parseErr == nil && len(overviews) == 1 && + overviews[0].ID == parsed && overviews[0].ParentSessionID == nil && overviews[0].RootSessionID == nil { + thread, threadErr := db.ListThreadSessionOverviews(ctx, parsed) + if threadErr != nil { + return nil, threadErr + } + if len(thread) > 1 { + return thread, nil + } + } return overviews, nil } if !errors.Is(err, database.ErrSessionNotFound) { diff --git a/pkg/cli/session_record_db_test.go b/pkg/cli/session_record_db_test.go index 78ec03c..0252e0d 100644 --- a/pkg/cli/session_record_db_test.go +++ b/pkg/cli/session_record_db_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/database" + "github.com/google/uuid" ) type sessionOverviewStoreStub struct { @@ -22,6 +23,10 @@ func (s *sessionOverviewStoreStub) ListSessionOverviews(context.Context, databas return nil, nil } +func (s *sessionOverviewStoreStub) ListThreadSessionOverviews(context.Context, uuid.UUID) ([]database.SessionOverview, error) { + return nil, nil +} + func TestResolveOverviewsByAnyIDDoesNotScanOnResolutionFailure(t *testing.T) { store := &sessionOverviewStoreStub{getErr: &database.SessionConflictError{Identity: "ad4c854e"}} _, err := resolveOverviewsByAnyID(t.Context(), store, "ad4c854e") diff --git a/pkg/database/session_batch_store.go b/pkg/database/session_batch_store.go new file mode 100644 index 0000000..b01dffc --- /dev/null +++ b/pkg/database/session_batch_store.go @@ -0,0 +1,90 @@ +package database + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" +) + +const SessionLifecyclePartial SessionLifecycleStatus = "partial" + +type CreateSessionTreeInput struct { + Root CreateSessionInput + Children []CreateSessionInput +} + +type SessionTree struct { + Root Session + Children []Session +} + +func (db *DB) CreateSessionTree(ctx context.Context, input CreateSessionTreeInput) (*SessionTree, error) { + if input.Root.ID == uuid.Nil { + return nil, fmt.Errorf("%w: root ID is required", ErrInvalidSession) + } + if input.Root.ParentSessionID != nil || input.Root.RootSessionID != nil { + return nil, fmt.Errorf("%w: tree root must be canonical", ErrInvalidSession) + } + var tree SessionTree + err := db.Transaction(ctx, func(tx *DB) error { + root, err := tx.CreateOrGetSession(ctx, input.Root) + if err != nil { + return err + } + tree.Root = *root + tree.Children = make([]Session, len(input.Children)) + for i := range input.Children { + child := input.Children[i] + if child.ID == uuid.Nil { + return fmt.Errorf("%w: child %d ID is required", ErrInvalidSession, i+1) + } + if child.ParentSessionID != nil && *child.ParentSessionID != root.ID { + return fmt.Errorf("%w: child %d parent must be root %s", ErrInvalidSession, i+1, root.ID) + } + child.ParentSessionID = &root.ID + created, err := tx.CreateOrGetSession(ctx, child) + if err != nil { + return fmt.Errorf("create child %d: %w", i+1, err) + } + tree.Children[i] = *created + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("create Captain session tree: %w", err) + } + return &tree, nil +} + +func (db *DB) UpdateSessionLifecycle(ctx context.Context, id uuid.UUID, lifecycle SessionLifecycleStatus, reason string) (*Session, error) { + if lifecycle != SessionLifecyclePartial && !validSessionLifecycle(lifecycle) { + return nil, fmt.Errorf("%w: unknown lifecycle status %q", ErrInvalidSession, lifecycle) + } + session, err := db.GetSession(ctx, id) + if err != nil { + return nil, err + } + if lifecycle != SessionLifecyclePartial { + activity := SessionActivityIdle + return db.UpdateSessionState(ctx, UpdateSessionStateInput{ + ID: id, ExpectedVersion: session.StateVersion, LifecycleStatus: &lifecycle, + ActivityState: &activity, StateReason: &reason, + }) + } + updates := map[string]any{ + "lifecycle_status": lifecycle, + "activity_state": SessionActivityIdle, + "state_reason": nullableTrimmed(strings.TrimSpace(reason)), + } + result := db.gorm.WithContext(ctx).Model(&sessionRecord{}). + Where("id = ? AND state_version = ?", id, session.StateVersion).Updates(updates) + if result.Error != nil { + return nil, fmt.Errorf("update Captain batch lifecycle: %w", result.Error) + } + if result.RowsAffected != 1 { + return nil, fmt.Errorf("%w: session %s lifecycle changed concurrently", ErrSessionConflict, id) + } + return db.GetSession(ctx, id) +} From 47c2d6747fdee2234a7d24d94e172be8fee974fb Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 13:49:05 +0300 Subject: [PATCH 079/131] fix(ai): keep preferred backend models visible Keep preferred registry-backed variants visible while continuing to hide non-preferred legacy models. Apply the backend-aware filtering consistently to live CLI results and catalog resolution. --- pkg/ai/catalog_resolve_test.go | 29 +++++++++++++++++++++++++++++ pkg/ai/model_lists.go | 17 ++++++++++------- pkg/cli/ai_models.go | 2 +- pkg/cli/ai_models_test.go | 5 ++++- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/pkg/ai/catalog_resolve_test.go b/pkg/ai/catalog_resolve_test.go index 9076bfd..38e1b96 100644 --- a/pkg/ai/catalog_resolve_test.go +++ b/pkg/ai/catalog_resolve_test.go @@ -129,6 +129,35 @@ func TestResolveModels_LegacyHiddenUnlessFiltered(t *testing.T) { } } +func TestResolveModels_PreferredOpenAIVariantsRemainVisible(t *testing.T) { + stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { + if b != BackendOpenAI { + return nil, nil + } + return []ModelDef{ + {ID: "gpt-5.6-sol", Backend: BackendOpenAI}, + {ID: "gpt-5.6-terra", Backend: BackendOpenAI}, + {ID: "gpt-5.6-luna", Backend: BackendOpenAI}, + {ID: "gpt-5.5-pro", Backend: BackendOpenAI}, + }, nil + }) + t.Setenv("OPENAI_API_KEY", "k") + + rows, err := ResolveModels(context.Background(), ResolveOptions{Backend: BackendOpenAI, UseTokens: true}) + if err != nil { + t.Fatalf("ResolveModels: %v", err) + } + for _, id := range []string{"openai/gpt-5.6-sol", "openai/gpt-5.6-terra", "openai/gpt-5.6-luna"} { + row, ok := hasModelID(rows, id) + if !ok || !row.Live { + t.Errorf("preferred API model %q = %+v, present=%v", id, row, ok) + } + } + if _, ok := hasModelID(rows, "gpt-5.5-pro"); ok { + t.Fatal("non-preferred API variant should remain hidden") + } +} + func TestResolveModels_CacheWrittenAndReused(t *testing.T) { calls := 0 stubLiveFetcher(t, func(b Backend) ([]ModelDef, error) { diff --git a/pkg/ai/model_lists.go b/pkg/ai/model_lists.go index dc26513..ffe322a 100644 --- a/pkg/ai/model_lists.go +++ b/pkg/ai/model_lists.go @@ -118,18 +118,21 @@ func isPrimaryGPTModelID(id string) bool { return sawDigit } -// IsLegacyModelIDForBackend keeps model menus clean for every backend. CLI and -// agent backends receive exact provider IDs too, so code/chat/realtime/audio -// variants are hidden there just as they are for direct API backends. +// IsLegacyModelIDForBackend keeps model menus clean while retaining preferred +// registry models explicitly available to the selected backend. func IsLegacyModelIDForBackend(id string, backend Backend) bool { - if backend == BackendCodexAgent || backend == BackendCodexCLI || backend == BackendCodexCmux { - if _, ok := RegistryModelDef(backend, id); ok { - return false - } + if isPreferredRegistryModelForBackend(backend, id) { + return false } return IsLegacyModelID(id) } +func isPreferredRegistryModelForBackend(backend Backend, model string) bool { + provider := registryProviderForBackend(backend) + entry, ok := lookupRegistryExact(provider, normalizeCodexVariantAlias(model)) + return ok && entry.Preferred && registryModelAvailableForBackend(entry, backend) +} + // CurrentModelsByReleaseDate returns a filtered copy sorted newest first, // retaining the newest few models per family prefix. Known catalog release // dates fill gaps left by provider list endpoints. diff --git a/pkg/cli/ai_models.go b/pkg/cli/ai_models.go index 2b2a3d6..12f3f97 100644 --- a/pkg/cli/ai_models.go +++ b/pkg/cli/ai_models.go @@ -96,7 +96,7 @@ func runLiveModels(opts AIModelsOptions) (any, error) { // Hide legacy/non-chat IDs unless the user asked for them by // name via --filter. Filtering by user intent overrides the // blacklist so "ai models -f gpt-3.5" still works. - if opts.Filter == "" && ai.IsLegacyModelID(m.ID) { + if opts.Filter == "" && ai.IsLegacyModelIDForBackend(m.ID, r.backend) { continue } diff --git a/pkg/cli/ai_models_test.go b/pkg/cli/ai_models_test.go index a930cc5..966ef37 100644 --- a/pkg/cli/ai_models_test.go +++ b/pkg/cli/ai_models_test.go @@ -220,6 +220,9 @@ func TestRunAIModels_HidesLegacyByDefault(t *testing.T) { "data": []map[string]any{ {"id": "gpt-5"}, {"id": "gpt-5.1"}, + {"id": "gpt-5.6-sol"}, + {"id": "gpt-5.6-terra"}, + {"id": "gpt-5.6-luna"}, {"id": "gpt-5-mini"}, {"id": "gpt-5-codex"}, {"id": "gpt-5.5-pro"}, @@ -249,7 +252,7 @@ func TestRunAIModels_HidesLegacyByDefault(t *testing.T) { } res := got.(AIModelsResult) - want := []string{"gpt-5", "gpt-5.1"} + want := []string{"gpt-5", "gpt-5.1", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"} if len(res.Rows) != len(want) { t.Fatalf("rows = %d, want %d (%v)", len(res.Rows), len(want), res.Rows) } From daf9a1e162c136d150b8be8655ebfce57c0e4730 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 13:49:14 +0300 Subject: [PATCH 080/131] feat(ai): add caller-tool execution to Genkit providers Expose configured caller tools through Genkit with bounded execution, approval handling, correlated events, and model-visible errors. Update chat permission checks to use Clicky tool annotations for accurate read-only detection. --- pkg/ai/provider/genkit/genkit.go | 22 ++-- pkg/ai/provider/genkit/options.go | 3 +- pkg/ai/provider/genkit/tools.go | 144 +++++++++++++++++++++++++ pkg/ai/provider/genkit/tools_test.go | 152 +++++++++++++++++++++++++++ pkg/cli/serve.go | 15 ++- 5 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 pkg/ai/provider/genkit/tools.go create mode 100644 pkg/ai/provider/genkit/tools_test.go diff --git a/pkg/ai/provider/genkit/genkit.go b/pkg/ai/provider/genkit/genkit.go index cf290c4..33f92fb 100644 --- a/pkg/ai/provider/genkit/genkit.go +++ b/pkg/ai/provider/genkit/genkit.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "strings" + "sync/atomic" "time" "github.com/flanksource/captain/pkg/ai" @@ -29,10 +30,11 @@ var log = logger.GetLogger("ai") // Provider is a genkit-backed ai.StreamingProvider for one API backend. type Provider struct { - cfg ai.Config - backend ai.Backend - g *gk.Genkit - modelRef string + cfg ai.Config + backend ai.Backend + g *gk.Genkit + modelRef string + toolCallSeq atomic.Uint64 // correlates caller-tool EventToolUse↔EventToolResult } var _ ai.StreamingProvider = (*Provider)(nil) @@ -87,7 +89,7 @@ func (p *Provider) GetBackend() ai.Backend { return p.backend } func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { start := time.Now() - opts, err := generateOptions(p, req, nil) + opts, err := generateOptions(p, req, nil, nil) if err != nil { return nil, err } @@ -158,8 +160,16 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai } return nil } + // emit lets in-process caller-tool execution publish tool_use/permission/ + // tool_result events onto the same stream as the model's text chunks. + emit := func(ev ai.Event) { + select { + case ch <- ev: + case <-ctx.Done(): + } + } - opts, err := generateOptions(p, req, cb) + opts, err := generateOptions(p, req, cb, emit) if err != nil { return nil, err } diff --git a/pkg/ai/provider/genkit/options.go b/pkg/ai/provider/genkit/options.go index f92b7d9..53c777d 100644 --- a/pkg/ai/provider/genkit/options.go +++ b/pkg/ai/provider/genkit/options.go @@ -49,8 +49,9 @@ func modelRef(backend ai.Backend, model string) (string, error) { // system prompt, user prompt, effort config, and (when streaming) the callback. // WithOutputType is added only for the non-streaming structured-output path; // ExecuteStream rejects structured output before calling this. -func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallback) ([]gkai.GenerateOption, error) { +func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallback, emit func(ai.Event)) ([]gkai.GenerateOption, error) { opts := []gkai.GenerateOption{gkai.WithModelName(p.modelRef)} + opts = append(opts, p.toolOptions(emit)...) if p.cfg.Model.Name != "" { req.Name = p.cfg.Model.Name } diff --git a/pkg/ai/provider/genkit/tools.go b/pkg/ai/provider/genkit/tools.go new file mode 100644 index 0000000..b3957e6 --- /dev/null +++ b/pkg/ai/provider/genkit/tools.go @@ -0,0 +1,144 @@ +package genkit + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" +) + +// maxToolTurns caps the model↔tool iteration so a misbehaving loop terminates. +const maxToolTurns = 16 + +// SupportsCallerTools reports that the genkit provider can expose and execute +// api.Config.Tools. It satisfies api.ToolCapableProvider. +func (p *Provider) SupportsCallerTools() bool { return true } + +// toolOptions builds the genkit generate options for the caller-supplied tools +// on the provider config. Each tool runs in-process via its handler; emit, when +// non-nil, receives EventToolUse / EventPermission / EventToolResult as the +// model calls them. Returns nil when there are no caller tools, so a run with +// none is byte-for-byte unchanged. +func (p *Provider) toolOptions(emit func(ai.Event)) []gkai.GenerateOption { + if len(p.cfg.Tools) == 0 { + return nil + } + refs := make([]gkai.ToolRef, 0, len(p.cfg.Tools)) + for i := range p.cfg.Tools { + refs = append(refs, p.genkitTool(p.cfg.Tools[i], emit)) + } + return []gkai.GenerateOption{gkai.WithTools(refs...), gkai.WithMaxTurns(maxToolTurns)} +} + +// genkitTool wraps one api.ToolDefinition as an ephemeral genkit tool. Ephemeral +// (NewToolWithInputSchema, passed via WithTools) rather than genkit.DefineTool: +// the genkit instance is cached and shared per (backend, apiKey), so registering +// tools globally would leak them across unrelated requests. +func (p *Provider) genkitTool(def api.ToolDefinition, emit func(ai.Event)) gkai.ToolRef { + handler := func(tc *gkai.ToolContext, input any) (any, error) { + return p.runTool(tc.Context, def, input, emit) + } + return gkai.NewToolWithInputSchema[any](def.Name, def.Description, def.InputSchema, handler) +} + +// runTool gates a caller tool through Config.CanUseTool (when it needs approval), +// runs its handler, and publishes the use/permission/result events. On a denied +// or errored call it feeds a message back to the model as the tool result so the +// model can react rather than the whole generation failing. +func (p *Provider) runTool(ctx context.Context, def api.ToolDefinition, input any, emit func(ai.Event)) (any, error) { + args := toArgsMap(input) + callID := fmt.Sprintf("tool-%d", p.toolCallSeq.Add(1)) + + if emit != nil { + emit(ai.Event{Kind: ai.EventToolUse, Tool: def.Name, Input: args, ToolCallID: callID, Model: p.cfg.Model.Name}) + } + + if def.NeedsApproval() && p.cfg.CanUseTool != nil { + if emit != nil { + emit(ai.Event{Kind: ai.EventPermission, Tool: def.Name, Input: args, ToolCallID: callID, Model: p.cfg.Model.Name}) + } + decision, err := p.cfg.CanUseTool(ctx, api.PermissionRequest{ + Tool: def.Name, + Input: args, + ToolUseID: callID, + SessionID: p.cfg.SessionID, + }) + if err != nil { + return p.toolDenied(def.Name, callID, err.Error(), emit) + } + if !decision.Allow { + msg := decision.Message + if msg == "" { + msg = "tool call denied" + } + return p.toolDenied(def.Name, callID, msg, emit) + } + if decision.UpdatedInput != nil { + args = decision.UpdatedInput + } + } + + out, err := def.Handler(ctx, args) + if err != nil { + if emit != nil { + emit(ai.Event{Kind: ai.EventToolResult, Tool: def.Name, ToolCallID: callID, Success: false, Text: err.Error(), Model: p.cfg.Model.Name}) + } + // Feed the error back as the tool result rather than aborting the turn. + return map[string]any{"error": err.Error()}, nil + } + + if emit != nil { + emit(ai.Event{Kind: ai.EventToolResult, Tool: def.Name, ToolCallID: callID, Success: true, Text: toolOutputText(out), Model: p.cfg.Model.Name}) + } + return out, nil +} + +// toolDenied emits a failed EventToolResult and returns the deny reason to the +// model as the tool output (not an error, so the model can adapt). +func (p *Provider) toolDenied(name, callID, reason string, emit func(ai.Event)) (any, error) { + if emit != nil { + emit(ai.Event{Kind: ai.EventToolResult, Tool: name, ToolCallID: callID, Success: false, Text: reason, Model: p.cfg.Model.Name}) + } + return map[string]any{"denied": true, "reason": reason}, nil +} + +// toArgsMap normalizes genkit's decoded tool input into a JSON object map. +func toArgsMap(input any) map[string]any { + if input == nil { + return map[string]any{} + } + if m, ok := input.(map[string]any); ok { + return m + } + data, err := json.Marshal(input) + if err != nil { + return map[string]any{} + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return map[string]any{} + } + return m +} + +// toolOutputText renders a handler's return value as the EventToolResult text. +func toolOutputText(out any) string { + if out == nil { + return "" + } + if s, ok := out.(string); ok { + return s + } + data, err := json.Marshal(out) + if err != nil { + return fmt.Sprintf("%v", out) + } + return string(data) +} + +// static assertion: the genkit provider advertises caller-tool support. +var _ api.ToolCapableProvider = (*Provider)(nil) diff --git a/pkg/ai/provider/genkit/tools_test.go b/pkg/ai/provider/genkit/tools_test.go new file mode 100644 index 0000000..db5f032 --- /dev/null +++ b/pkg/ai/provider/genkit/tools_test.go @@ -0,0 +1,152 @@ +package genkit + +import ( + "context" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +func newToolProvider(canUse api.PermissionFunc) *Provider { + return &Provider{cfg: ai.Config{Model: api.Model{Name: "test-model"}, CanUseTool: canUse}} +} + +func collectEvents() (func(ai.Event), *[]ai.Event) { + var events []ai.Event + return func(ev ai.Event) { events = append(events, ev) }, &events +} + +func TestRunToolAutoRunsAndEmitsCorrelatedEvents(t *testing.T) { + p := newToolProvider(nil) + emit, events := collectEvents() + + var gotInput map[string]any + def := api.ToolDefinition{ + Name: "echo", + DefaultPermission: api.ToolModeEnabled, + Handler: func(_ context.Context, in map[string]any) (any, error) { + gotInput = in + return map[string]any{"ok": true}, nil + }, + } + + out, err := p.runTool(context.Background(), def, map[string]any{"x": 1}, emit) + if err != nil { + t.Fatalf("runTool: %v", err) + } + if gotInput["x"] != 1 { + t.Errorf("handler input = %v, want {x:1}", gotInput) + } + if m, ok := out.(map[string]any); !ok || m["ok"] != true { + t.Errorf("out = %v, want {ok:true}", out) + } + + if len(*events) != 2 { + t.Fatalf("events = %d, want 2 (use, result); got %+v", len(*events), *events) + } + use, result := (*events)[0], (*events)[1] + if use.Kind != ai.EventToolUse || result.Kind != ai.EventToolResult { + t.Fatalf("kinds = %q,%q, want tool_use,tool_result", use.Kind, result.Kind) + } + if use.ToolCallID == "" || use.ToolCallID != result.ToolCallID { + t.Errorf("call ids not correlated: use=%q result=%q", use.ToolCallID, result.ToolCallID) + } + if !result.Success { + t.Error("result.Success = false, want true") + } +} + +func TestRunToolApprovalDeniedSkipsHandler(t *testing.T) { + ran := false + deny := func(_ context.Context, _ api.PermissionRequest) (api.PermissionDecision, error) { + return api.PermissionDecision{Allow: false, Message: "nope"}, nil + } + p := newToolProvider(deny) + emit, events := collectEvents() + + def := api.ToolDefinition{ + Name: "danger", + DefaultPermission: api.ToolModeAsk, + Handler: func(context.Context, map[string]any) (any, error) { ran = true; return "ran", nil }, + } + + out, err := p.runTool(context.Background(), def, map[string]any{}, emit) + if err != nil { + t.Fatalf("runTool: %v", err) + } + if ran { + t.Error("handler ran despite denial") + } + if m, ok := out.(map[string]any); !ok || m["denied"] != true { + t.Errorf("out = %v, want denied", out) + } + kinds := eventKinds(*events) + if len(kinds) != 3 || kinds[0] != ai.EventToolUse || kinds[1] != ai.EventPermission || kinds[2] != ai.EventToolResult { + t.Fatalf("event kinds = %v, want [tool_use permission tool_result]", kinds) + } + if (*events)[2].Success { + t.Error("denied result Success = true, want false") + } +} + +func TestRunToolApprovalAllowsAndSubstitutesInput(t *testing.T) { + allow := func(_ context.Context, _ api.PermissionRequest) (api.PermissionDecision, error) { + return api.PermissionDecision{Allow: true, UpdatedInput: map[string]any{"amount": 5}}, nil + } + p := newToolProvider(allow) + emit, _ := collectEvents() + + var seen map[string]any + def := api.ToolDefinition{ + Name: "pay", + DefaultPermission: api.ToolModeAsk, + Handler: func(_ context.Context, in map[string]any) (any, error) { seen = in; return "done", nil }, + } + + if _, err := p.runTool(context.Background(), def, map[string]any{"amount": 1}, emit); err != nil { + t.Fatalf("runTool: %v", err) + } + if seen["amount"] != 5 { + t.Errorf("handler input = %v, want the UpdatedInput amount=5", seen) + } +} + +func TestRunToolHandlerErrorFedBack(t *testing.T) { + p := newToolProvider(nil) + emit, events := collectEvents() + def := api.ToolDefinition{ + Name: "boom", + DefaultPermission: api.ToolModeEnabled, + Handler: func(context.Context, map[string]any) (any, error) { return nil, context.Canceled }, + } + out, err := p.runTool(context.Background(), def, map[string]any{}, emit) + if err != nil { + t.Fatalf("runTool should not surface handler error, got %v", err) + } + if m, ok := out.(map[string]any); !ok || m["error"] == nil { + t.Errorf("out = %v, want {error:...}", out) + } + last := (*events)[len(*events)-1] + if last.Kind != ai.EventToolResult || last.Success { + t.Errorf("last event = %+v, want failed tool_result", last) + } +} + +func TestToolOptionsEmptyWhenNoTools(t *testing.T) { + p := newToolProvider(nil) + if opts := p.toolOptions(nil); opts != nil { + t.Errorf("toolOptions with no tools = %v, want nil", opts) + } + if !p.SupportsCallerTools() { + t.Error("SupportsCallerTools = false, want true") + } +} + +func eventKinds(events []ai.Event) []ai.EventKind { + kinds := make([]ai.EventKind, len(events)) + for i, e := range events { + kinds[i] = e.Kind + } + return kinds +} diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index bbc3c40..b76d8bd 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -355,7 +355,7 @@ func handleThreadFromAgent(store aichat.ThreadStore) http.HandlerFunc { } func captainChatToolEnabled(tool aichat.ToolInfo) bool { - raw := strings.ToLower(strings.TrimSpace(tool.OperationName)) + raw := strings.ToLower(strings.TrimSpace(tool.Annotation("clicky/operation"))) if raw == "" { raw = strings.ToLower(strings.TrimSpace(tool.Name)) } @@ -375,14 +375,19 @@ func captainChatToolEnabled(tool aichat.ToolInfo) bool { } func captainChatRequiresApproval(tool aichat.ToolInfo, _ any) bool { - switch strings.ToUpper(tool.Method) { + switch strings.ToUpper(tool.Annotation("clicky/method")) { case http.MethodGet, http.MethodHead, http.MethodOptions: return false } - if isReadOnlyCaptainTool(tool.ClickyVerb) || + // clicky already derives list/get→On; honor that, then fall back to the raw + // verb/name/operation/path for captain's wider read-verb set. + if tool.DefaultPermission == aichat.ToolModeOn { + return false + } + if isReadOnlyCaptainTool(tool.Annotation("clicky/verb")) || isReadOnlyCaptainTool(tool.Name) || - isReadOnlyCaptainTool(tool.OperationName) || - isReadOnlyCaptainTool(tool.Path) { + isReadOnlyCaptainTool(tool.Annotation("clicky/operation")) || + isReadOnlyCaptainTool(tool.Annotation("clicky/path")) { return false } return true From 7a9483a399c49ed565498a771e107ff72e8dcfff Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 13:49:25 +0300 Subject: [PATCH 081/131] feat(cli): add durable multi-model prompt batch sessions Persist multi-model prompt runs as a canonical session tree with per-runtime child sessions and lifecycle status. Add asynchronous HTTP batch execution, chat capabilities, runtime validation, and batch result metadata. --- pkg/cli/prompt_batch_run.go | 86 ++++++++++++++ pkg/cli/prompt_batch_session.go | 122 ++++++++++++++++++++ pkg/cli/prompt_batch_session_ginkgo_test.go | 84 ++++++++++++++ pkg/cli/prompt_chat.go | 13 ++- pkg/cli/prompt_chat_ginkgo_test.go | 4 +- pkg/cli/prompt_entity.go | 1 + pkg/cli/prompt_run.go | 91 +++++++++++---- pkg/cli/prompt_run_live.go | 10 +- pkg/cli/prompt_run_persist.go | 102 ++++++++++------ pkg/cli/session_record_db.go | 1 + pkg/cli/sessions.go | 1 + pkg/database/session_batch_store.go | 29 +---- pkg/database/session_prompt_store.go | 3 +- 13 files changed, 456 insertions(+), 91 deletions(-) create mode 100644 pkg/cli/prompt_batch_run.go create mode 100644 pkg/cli/prompt_batch_session.go create mode 100644 pkg/cli/prompt_batch_session_ginkgo_test.go diff --git a/pkg/cli/prompt_batch_run.go b/pkg/cli/prompt_batch_run.go new file mode 100644 index 0000000..6fedab8 --- /dev/null +++ b/pkg/cli/prompt_batch_run.go @@ -0,0 +1,86 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/clicky/task" + flanksourceContext "github.com/flanksource/commons/context" +) + +func launchAsyncBatch(ctx context.Context, id string, rendered PromptRenderResult, runtimes []api.Model, chat bool) (PromptRunResult, error) { + if rendered.Input.SessionID != "" { + return PromptRunResult{}, errors.New("a multi-model run cannot resume one provider session") + } + batch, err := createPromptBatchSessions(ctx, rendered, runtimes) + if err != nil { + return PromptRunResult{}, err + } + updatePromptSessionLifecycle(ctx, batch.ID, database.SessionLifecycleRunning, "") + group := task.StartGroup[PromptRunSummary]( + "prompt "+rendered.Name, + task.WithGroupID(batch.ID.String()), + task.WithKind("prompt"), + task.WithLabels(promptTaskLabelsWithID(rendered, id, "multi")), + task.WithConcurrency(len(batch.Runs)), + ) + handles := make([]task.TypedTask[PromptRunSummary], len(batch.Runs)) + result := PromptRunResult{ + BatchID: batch.ID.String(), Status: "running", Chat: chat, + Total: len(batch.Runs), Runs: make([]PromptRunItem, len(batch.Runs)), + } + for i := range batch.Runs { + i := i + run := batch.Runs[i] + binding := promptBinding(batch, i) + variant := renderVariant(rendered, run.Runtime, nil) + runID := run.SessionID.String() + stream := promptRuns.create(runID) + capabilities := chatCapabilitiesForBackend(variant.Backend) + stream.setRun(PromptRunFrame{ + RunID: runID, SessionID: runID, Status: "running", Chat: chat, + Model: variant.Model, Backend: variant.Backend, Capabilities: capabilities, + }) + updatePromptSessionLifecycle(ctx, run.SessionID, database.SessionLifecycleRunning, "") + result.Runs[i] = PromptRunItem{ + RunID: runID, SessionID: runID, Selector: runtimeSelector(run.Runtime), + Status: "running", Model: run.Runtime.Name, Backend: string(run.Runtime.Backend), + Effort: string(run.Runtime.Effort), Chat: chat, Capabilities: capabilities, + } + handles[i] = group.Add(runtimeSelector(run.Runtime), func(_ flanksourceContext.Context, t *task.Task) (PromptRunSummary, error) { + if chat { + chatSession := newChatSession(runID, variant, runtimeTimeout(variant.Input.Budget.Timeout), stream, binding) + promptChats.register(chatSession) + return chatSession.run(t) + } + summary, runErr := runPromptStream(t, variant, runtimeTimeout(variant.Input.Budget.Timeout), runID, stream, binding) + if runErr != nil { + persistPromptRun(context.WithoutCancel(t.Context()), promptRunRecordInput{ + Rendered: variant, RunID: runID, Binding: binding, + Model: run.Runtime.Name, Backend: string(run.Runtime.Backend), Error: runErr.Error(), + }) + } + return summary, runErr + }, task.WithModel(run.Runtime.Name), task.WithPrompt(rendered.Input.Prompt.User)) + } + + task.StartTask("finalize prompt batch", func(_ flanksourceContext.Context, t *task.Task) (PromptRunSummary, error) { + succeeded, failed := 0, 0 + for i := range handles { + summary, runErr := handles[i].GetResult() + if runErr != nil || !summary.Success { + failed++ + } else { + succeeded++ + } + } + reason := fmt.Sprintf("%d succeeded, %d failed", succeeded, failed) + updatePromptSessionLifecycle(context.WithoutCancel(t.Context()), batch.ID, batchLifecycle(succeeded, failed), reason) + t.Success() + return PromptRunSummary{RunID: batch.ID.String(), Success: failed == 0}, nil + }, task.WithIdentity("prompt-batch-finalize-"+batch.ID.String())) + return result, nil +} diff --git a/pkg/cli/prompt_batch_session.go b/pkg/cli/prompt_batch_session.go new file mode 100644 index 0000000..f01bfc9 --- /dev/null +++ b/pkg/cli/prompt_batch_session.go @@ -0,0 +1,122 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/database" + "github.com/google/uuid" +) + +type promptSessionBinding struct { + BatchID uuid.UUID + SessionID uuid.UUID +} + +type promptBatchRun struct { + SessionID uuid.UUID + Runtime api.Model +} + +type promptBatchSession struct { + ID uuid.UUID + Runs []promptBatchRun +} + +func createPromptBatchSessions(ctx context.Context, rendered PromptRenderResult, runtimes []api.Model) (promptBatchSession, error) { + if err := validatePromptRuntimes(runtimes); err != nil { + return promptBatchSession{}, err + } + db, err := captainDB(ctx) + if err != nil { + return promptBatchSession{}, err + } + batch := promptBatchSession{ID: uuid.New(), Runs: make([]promptBatchRun, len(runtimes))} + children := make([]database.CreateSessionInput, len(runtimes)) + for i, runtime := range runtimes { + source := backendSource(runtime.Backend) + if source == "" { + source = "captain" + } + batch.Runs[i] = promptBatchRun{SessionID: uuid.New(), Runtime: runtime} + children[i] = database.CreateSessionInput{ + ID: batch.Runs[i].SessionID, Source: source, + Provider: ai.BackendToProvider(runtime.Backend), HostID: captainHostID(), + CWD: rendered.Input.Cwd(), Title: runtime.Name, InitialPrompt: rendered.Input.Prompt.User, + AgentType: "model", Description: runtimeSelector(runtime), + } + } + _, err = db.CreateSessionTree(ctx, database.CreateSessionTreeInput{ + Root: database.CreateSessionInput{ + ID: batch.ID, Source: "captain", Provider: "multi-model", HostID: captainHostID(), + CWD: rendered.Input.Cwd(), Title: rendered.Name, InitialPrompt: rendered.Input.Prompt.User, + AgentType: "batch", Description: fmt.Sprintf("%d model comparison", len(runtimes)), + }, + Children: children, + }) + if err != nil { + return promptBatchSession{}, err + } + return batch, nil +} + +func validatePromptRuntimes(runtimes []api.Model) error { + if len(runtimes) < 2 { + return fmt.Errorf("multi-model execution requires at least two runtimes") + } + seen := map[string]struct{}{} + for i := range runtimes { + runtime := runtimes[i] + if err := runtime.Validate(); err != nil { + return fmt.Errorf("runtime %d: %w", i+1, err) + } + backend, err := runtime.ResolveBackend() + if err != nil { + return fmt.Errorf("runtime %d: %w", i+1, err) + } + runtimes[i].Backend = backend + key := runtimeSelector(runtimes[i]) + if _, ok := seen[key]; ok { + return fmt.Errorf("runtime %d duplicates %s", i+1, key) + } + seen[key] = struct{}{} + } + return nil +} + +func runtimeSelector(runtime api.Model) string { + parts := []string{string(runtime.Backend), runtime.Name} + if runtime.Effort != api.EffortNone { + parts = append(parts, string(runtime.Effort)) + } + return strings.Join(parts, ":") +} + +func promptBinding(batch promptBatchSession, index int) *promptSessionBinding { + return &promptSessionBinding{BatchID: batch.ID, SessionID: batch.Runs[index].SessionID} +} + +func updatePromptSessionLifecycle(ctx context.Context, id uuid.UUID, lifecycle database.SessionLifecycleStatus, reason string) { + db, err := captainDB(ctx) + if err != nil { + log.Errorf("open database for session %s lifecycle: %v", id, err) + return + } + if _, err := db.UpdateSessionLifecycle(ctx, id, lifecycle, reason); err != nil { + log.Errorf("update session %s lifecycle to %s: %v", id, lifecycle, err) + } +} + +func batchLifecycle(succeeded, failed int) database.SessionLifecycleStatus { + switch { + case failed == 0: + return database.SessionLifecycleSucceeded + case succeeded == 0: + return database.SessionLifecycleFailed + default: + return database.SessionLifecyclePartial + } +} diff --git a/pkg/cli/prompt_batch_session_ginkgo_test.go b/pkg/cli/prompt_batch_session_ginkgo_test.go new file mode 100644 index 0000000..a876063 --- /dev/null +++ b/pkg/cli/prompt_batch_session_ginkgo_test.go @@ -0,0 +1,84 @@ +package cli + +import ( + "os" + "path/filepath" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/database" + commonsdb "github.com/flanksource/commons-db/db" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("prompt batch sessions", func() { + It("creates the batch ID as the canonical root with one child per runtime", func() { + if os.Getenv("CAPTAIN_DB_EMBEDDED_TEST") == "" { + Skip("set CAPTAIN_DB_EMBEDDED_TEST=1 to run embedded-postgres cli tests") + } + dsn, stop, err := commonsdb.StartEmbedded(commonsdb.EmbeddedConfig{ + DataDir: filepath.Join(GinkgoT().TempDir(), "postgres"), Database: "captain_prompt_batch", + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(stop()).To(Succeed()) }) + db, err := database.Open(GinkgoT().Context(), database.Config{DSN: dsn}) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + setCaptainDBForTest(nil) + Expect(db.Close()).To(Succeed()) + }) + setCaptainDBForTest(db) + + rendered := PromptRenderResult{Name: "compare", Backend: "codex-cmux", Model: "gpt-5.6-sol"} + rendered.Input.Prompt.User = "Compare these approaches" + rendered.Input.SetCwd("/workspace/captain") + batch, err := createPromptBatchSessions(GinkgoT().Context(), rendered, []api.Model{ + {Name: "gpt-5.6-sol", Backend: api.BackendCodexCmux, Effort: api.EffortHigh}, + {Name: "gemini-2.5-flash", Backend: api.BackendGemini}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(batch.ID).NotTo(Equal(uuid.Nil)) + Expect(batch.Runs).To(HaveLen(2)) + + root, err := db.GetSession(GinkgoT().Context(), batch.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(root.ParentSessionID).To(BeNil()) + Expect(root.RootSessionID).To(BeNil()) + Expect(root.Source).To(Equal("captain")) + Expect(root.Provider).To(Equal("multi-model")) + Expect(root.AgentType).To(Equal("batch")) + Expect(root.InitialPrompt).To(Equal("Compare these approaches")) + + thread, err := db.ListThreadSessionOverviews(GinkgoT().Context(), batch.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(thread).To(HaveLen(3)) + updatedRoot, err := db.UpdateSessionLifecycle( + GinkgoT().Context(), + batch.ID, + database.SessionLifecyclePartial, + "1 succeeded, 1 failed", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(updatedRoot.LifecycleStatus).To(Equal(database.SessionLifecyclePartial)) + Expect(updatedRoot.StateVersion).To(Equal(int64(1))) + Expect(updatedRoot.EndedAt).NotTo(BeNil()) + result, err := RunSessionGet(GinkgoT().Context(), SessionGetOptions{ID: batch.ID.String()}) + Expect(err).NotTo(HaveOccurred()) + Expect(result.RootSessionID).To(Equal(batch.ID.String())) + Expect(result.Sessions).To(HaveLen(3)) + Expect(result.Tree().GetChildren()).To(HaveLen(1)) + Expect(result.Tree().GetChildren()[0].GetChildren()).To(HaveLen(2)) + for i, run := range batch.Runs { + Expect(run.SessionID).NotTo(Equal(uuid.Nil)) + child, err := db.GetSession(GinkgoT().Context(), run.SessionID) + Expect(err).NotTo(HaveOccurred()) + Expect(child.ParentSessionID).NotTo(BeNil()) + Expect(*child.ParentSessionID).To(Equal(batch.ID)) + Expect(child.RootSessionID).NotTo(BeNil()) + Expect(*child.RootSessionID).To(Equal(batch.ID)) + Expect(child.ProviderSessionID).To(BeEmpty()) + Expect(child.Description).To(Equal(runtimeSelector(batch.Runs[i].Runtime))) + } + }) +}) diff --git a/pkg/cli/prompt_chat.go b/pkg/cli/prompt_chat.go index 23bee61..0f16ae1 100644 --- a/pkg/cli/prompt_chat.go +++ b/pkg/cli/prompt_chat.go @@ -39,12 +39,13 @@ type chatSession struct { interruptedTurn int terminal bool startedAt time.Time + binding *promptSessionBinding } -func newChatSession(runID string, rendered PromptRenderResult, timeout time.Duration, stream *runStream) *chatSession { +func newChatSession(runID string, rendered PromptRenderResult, timeout time.Duration, stream *runStream, binding *promptSessionBinding) *chatSession { capabilities := chatCapabilitiesForBackend(rendered.Backend) chat := &chatSession{ - runID: runID, rendered: rendered, timeout: timeout, stream: stream, + runID: runID, rendered: rendered, timeout: timeout, stream: stream, binding: binding, wake: make(chan struct{}, 1), startedAt: time.Now(), state: ChatStateFrame{ RunID: runID, Status: "starting", Capabilities: capabilities, @@ -385,7 +386,7 @@ func (c *chatSession) persistTurn(req ai.Request, summary PromptRunSummary) { rendered.Input = req persistPromptRun(context.Background(), promptRunRecordInput{ Rendered: rendered, RunID: runID, SessionID: summary.SessionID, - Model: summary.Model, Backend: summary.Backend, + Binding: c.binding, Model: summary.Model, Backend: summary.Backend, }) } @@ -405,6 +406,12 @@ func (c *chatSession) fail(t *task.Task, err error) (PromptRunSummary, error) { c.terminal = true c.mu.Unlock() promptChats.finish(c) + if c.binding != nil { + persistPromptRun(context.Background(), promptRunRecordInput{ + Rendered: c.rendered, RunID: c.runID, Binding: c.binding, + Model: c.rendered.Model, Backend: c.rendered.Backend, Error: err.Error(), + }) + } c.stream.fail(err.Error()) _, _ = t.FailedWithError(err) return PromptRunSummary{RunID: c.runID, Error: err.Error()}, err diff --git a/pkg/cli/prompt_chat_ginkgo_test.go b/pkg/cli/prompt_chat_ginkgo_test.go index 2991825..bac0282 100644 --- a/pkg/cli/prompt_chat_ginkgo_test.go +++ b/pkg/cli/prompt_chat_ginkgo_test.go @@ -26,7 +26,7 @@ func (p *interruptProviderStub) Interrupt(context.Context) error { var _ = Describe("prompt chat lifecycle", func() { It("queues an idle follow-up and publishes its exact message id", func() { stream := newRunStream() - chat := newChatSession("run-1", PromptRenderResult{Backend: string(api.BackendCodexAgent)}, 0, stream) + chat := newChatSession("run-1", PromptRenderResult{Backend: string(api.BackendCodexAgent)}, 0, stream, nil) chat.state.Status = "idle" response, err := chat.send(context.Background(), ChatMessageRequest{Text: "next", MessageID: "message-1"}) @@ -43,7 +43,7 @@ var _ = Describe("prompt chat lifecycle", func() { It("interrupts the active turn and discards queued messages", func() { stream := newRunStream() provider := &interruptProviderStub{} - chat := newChatSession("run-1", PromptRenderResult{Backend: string(api.BackendCodexAgent)}, 0, stream) + chat := newChatSession("run-1", PromptRenderResult{Backend: string(api.BackendCodexAgent)}, 0, stream, nil) chat.provider = provider chat.state.Status = "running" chat.state.Turn = 2 diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index 12d8049..534ff92 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -105,6 +105,7 @@ type PromptWriteRequest struct { type PromptRenderRequest struct { Variables map[string]any `json:"variables,omitempty"` Spec *api.Spec `json:"spec,omitempty"` + Runtimes []api.Model `json:"runtimes,omitempty"` Chat bool `json:"chat,omitempty"` } diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index 61479b8..dd01a75 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -9,6 +9,7 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/database" clickyapi "github.com/flanksource/clicky/api" clickyrpc "github.com/flanksource/clicky/rpc" "github.com/flanksource/clicky/task" @@ -22,6 +23,7 @@ import ( // synchronous result (Text + tokens/cost). One type serves both transports. type PromptRunResult struct { RunID string `json:"runId,omitempty"` + BatchID string `json:"batchId,omitempty"` Status string `json:"status,omitempty" pretty:"label=Status"` Model string `json:"model,omitempty" pretty:"label=Model"` Backend string `json:"backend,omitempty" pretty:"label=Backend"` @@ -44,19 +46,23 @@ type PromptRunResult struct { } type PromptRunItem struct { - Selector string `json:"selector,omitempty" pretty:"label=Selector"` - Status string `json:"status,omitempty" pretty:"label=Status"` - Model string `json:"model,omitempty" pretty:"label=Model"` - Backend string `json:"backend,omitempty" pretty:"label=Backend"` - Text string `json:"text,omitempty" pretty:"label=Response"` - SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` - Dir string `json:"dir,omitempty" pretty:"label=Dir"` - HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` - InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` - OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` - CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` - Duration string `json:"duration,omitempty" pretty:"label=Duration"` - Error string `json:"error,omitempty" pretty:"label=Error"` + RunID string `json:"runId,omitempty" pretty:"label=Run"` + Selector string `json:"selector,omitempty" pretty:"label=Selector"` + Status string `json:"status,omitempty" pretty:"label=Status"` + Model string `json:"model,omitempty" pretty:"label=Model"` + Backend string `json:"backend,omitempty" pretty:"label=Backend"` + Effort string `json:"effort,omitempty" pretty:"label=Effort"` + Chat bool `json:"chat,omitempty"` + Capabilities ChatCapabilities `json:"capabilities,omitempty"` + Text string `json:"text,omitempty" pretty:"label=Response"` + SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + Dir string `json:"dir,omitempty" pretty:"label=Dir"` + HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` + InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` + OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` + CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` + Duration string `json:"duration,omitempty" pretty:"label=Duration"` + Error string `json:"error,omitempty" pretty:"label=Error"` } func (r PromptRunResult) Pretty() clickyapi.Text { @@ -202,15 +208,14 @@ func runPromptAction(ctx context.Context, id string, flags map[string]string) (P var rendered PromptRenderResult var opts AIPromptOptions + var httpReq PromptRenderRequest var chatRequested bool if isHTTP { - if strings.TrimSpace(flags["multi-models"]) != "" { - return PromptRunResult{}, errors.New("--multi-models is only supported on the CLI prompt run path") - } req, err := readRenderRequest(ctx, flags) if err != nil { return PromptRunResult{}, err } + httpReq = req if rendered, err = renderPrompt(ctx, id, req); err != nil { return PromptRunResult{}, err } @@ -237,6 +242,9 @@ func runPromptAction(ctx context.Context, id string, flags map[string]string) (P } if isHTTP { + if len(httpReq.Runtimes) > 0 { + return launchAsyncBatch(ctx, id, rendered, httpReq.Runtimes, chatRequested) + } return launchAsyncRun(id, rendered, chatRequested), nil } return executeSyncRun(ctx, rendered, opts) @@ -263,14 +271,14 @@ func launchAsyncRun(id string, rendered PromptRenderResult, chat bool) PromptRun task.WithLabels(promptTaskLabelsWithID(rendered, id, "")), ) if chat { - chatSession := newChatSession(runID, rendered, timeout, stream) + chatSession := newChatSession(runID, rendered, timeout, stream, nil) promptChats.register(chatSession) group.Add("execute", func(_ flanksourceContext.Context, t *task.Task) (PromptRunSummary, error) { return chatSession.run(t) }) } else { group.Add("execute", func(_ flanksourceContext.Context, t *task.Task) (PromptRunSummary, error) { - return runPromptStream(t, rendered, timeout, runID, stream) + return runPromptStream(t, rendered, timeout, runID, stream, nil) }) } return PromptRunResult{ @@ -305,7 +313,7 @@ func executeSyncRunSingle(ctx context.Context, rendered PromptRenderResult, opts return run.GetResult() } -func executeSyncRunSingleDirect(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions, batchID *uuid.UUID) (PromptRunResult, error) { +func executeSyncRunSingleDirect(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions, binding *promptSessionBinding) (PromptRunResult, error) { out, err := executePromptRequestFunc(ctx, rendered.Input, rendered.Config, runtimeTimeout(rendered.Input.Budget.Timeout), opts.NoStream) if err != nil { return PromptRunResult{}, err @@ -313,7 +321,7 @@ func executeSyncRunSingleDirect(ctx context.Context, rendered PromptRenderResult r, _ := out.(AIPromptResult) persistPromptRun(context.WithoutCancel(ctx), promptRunRecordInput{ Rendered: rendered, SessionID: r.SessionID, Model: r.Model, Backend: r.Backend, - BatchID: batchID, ResultText: r.Text, + Binding: binding, ResultText: r.Text, }) return PromptRunResult{ Status: "completed", @@ -338,6 +346,25 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP if len(models) == 0 { return executeSyncRunSingle(ctx, rendered, opts) } + if len(models) == 1 { + variant := renderVariant(rendered, models[0], fallbackModelsFromFlags(opts.Fallback)) + opts.MultiModels = nil + start := time.Now() + single, runErr := executeSyncRunSingle(ctx, variant, opts) + item := PromptRunItem{ + Selector: runtimeSelector(models[0]), Model: firstNonEmpty(single.Model, models[0].Name), + Backend: firstNonEmpty(single.Backend, string(models[0].Backend)), Effort: string(models[0].Effort), + Text: single.Text, SessionID: single.SessionID, Dir: single.Dir, HistoryFile: single.HistoryFile, + InputTokens: single.InputTokens, OutputTokens: single.OutputTokens, CostUSD: single.CostUSD, Duration: single.Duration, + } + result := PromptRunResult{Total: 1, Runs: []PromptRunItem{item}, Duration: time.Since(start).Round(time.Millisecond).String()} + if runErr != nil { + result.Status, result.Failed, result.Runs[0].Status, result.Runs[0].Error = "failed", 1, "failed", runErr.Error() + return result, runErr + } + result.Status, result.Succeeded, result.Runs[0].Status = "completed", 1, single.Status + return result, nil + } prepared := rendered.Input if err := resolvePromptAttachments(ctx, &prepared); err != nil { return PromptRunResult{}, err @@ -355,7 +382,11 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP } start := time.Now() - batchID := uuid.New() + batch, err := createPromptBatchSessions(ctx, rendered, models) + if err != nil { + return PromptRunResult{}, err + } + updatePromptSessionLifecycle(ctx, batch.ID, database.SessionLifecycleRunning, "") runs := make([]PromptRunItem, len(models)) group := task.StartGroup[PromptRunItem]( "prompt "+rendered.Name, @@ -375,8 +406,11 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP variantOpts := opts variantOpts.MultiModels = nil taskCtx := ai.ContextWithLogger(t.Context(), t) - result, err := executeSyncRunSingleDirect(taskCtx, variant, variantOpts, &batchID) + binding := promptBinding(batch, i) + updatePromptSessionLifecycle(context.WithoutCancel(taskCtx), binding.SessionID, database.SessionLifecycleRunning, "") + result, err := executeSyncRunSingleDirect(taskCtx, variant, variantOpts, binding) item := PromptRunItem{ + RunID: binding.SessionID.String(), Selector: selector, Model: model.Name, Backend: string(model.Backend), @@ -386,15 +420,20 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP item.Status = "failed" item.HistoryFile = historyFileForRun(model.Backend, item.SessionID, item.Dir) item.Error = err.Error() + persistPromptRun(context.WithoutCancel(taskCtx), promptRunRecordInput{ + Rendered: variant, RunID: binding.SessionID.String(), Binding: binding, + Model: model.Name, Backend: string(model.Backend), Error: err.Error(), + }) return item, err } item.Status = result.Status item.Model = firstNonEmpty(result.Model, item.Model) item.Backend = firstNonEmpty(result.Backend, item.Backend) item.Text = result.Text - item.SessionID = result.SessionID + providerSessionID := result.SessionID + item.SessionID = binding.SessionID.String() item.Dir = firstNonEmpty(result.Dir, item.Dir) - item.HistoryFile = firstNonEmpty(result.HistoryFile, historyFileForRun(api.Backend(item.Backend), item.SessionID, item.Dir)) + item.HistoryFile = firstNonEmpty(result.HistoryFile, historyFileForRun(api.Backend(item.Backend), providerSessionID, item.Dir)) item.InputTokens = result.InputTokens item.OutputTokens = result.OutputTokens item.CostUSD = result.CostUSD @@ -412,6 +451,7 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP } result := PromptRunResult{ + BatchID: batch.ID.String(), Status: "completed", Total: len(runs), Runs: runs, @@ -435,6 +475,9 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP default: result.Status = "partial" } + updatePromptSessionLifecycle(context.WithoutCancel(ctx), batch.ID, + batchLifecycle(result.Succeeded, result.Failed), + fmt.Sprintf("%d succeeded, %d failed", result.Succeeded, result.Failed)) if result.Succeeded == 0 && result.Failed > 0 { return result, fmt.Errorf("all %d prompt variants failed", result.Failed) } diff --git a/pkg/cli/prompt_run_live.go b/pkg/cli/prompt_run_live.go index aff6729..387728e 100644 --- a/pkg/cli/prompt_run_live.go +++ b/pkg/cli/prompt_run_live.go @@ -12,7 +12,7 @@ import ( "github.com/flanksource/clicky/task" ) -func runPromptStream(t *task.Task, rendered PromptRenderResult, timeout time.Duration, runID string, stream *runStream) (PromptRunSummary, error) { +func runPromptStream(t *task.Task, rendered PromptRenderResult, timeout time.Duration, runID string, stream *runStream, binding *promptSessionBinding) (PromptRunSummary, error) { ctx, cancel := runContext(t.Context(), rendered.Input, timeout) stream.setCancel(cancel) defer cancel() @@ -67,16 +67,20 @@ func runPromptStream(t *task.Task, rendered PromptRenderResult, timeout time.Dur } passed := verifyPassed(runResult.Verdicts) record := promptRunRecordInput{ - Rendered: rendered, RunID: runID, SessionID: session, + Rendered: rendered, RunID: runID, Binding: binding, SessionID: session, Model: acc.model, Backend: rendered.Backend, } if !passed { record.Error = verifyReason(runResult.Verdicts) } persistPromptRun(context.WithoutCancel(ctx), record) + summarySessionID := session + if binding != nil { + summarySessionID = binding.SessionID.String() + } summary := PromptRunSummary{ RunID: runID, - SessionID: session, + SessionID: summarySessionID, Model: acc.model, Backend: rendered.Backend, InputTokens: acc.usage.InputTokens, diff --git a/pkg/cli/prompt_run_persist.go b/pkg/cli/prompt_run_persist.go index 0cc6507..9fb556d 100644 --- a/pkg/cli/prompt_run_persist.go +++ b/pkg/cli/prompt_run_persist.go @@ -15,6 +15,7 @@ import ( type promptRunRecordInput struct { Rendered PromptRenderResult RunID string + Binding *promptSessionBinding SessionID string Model string Backend string @@ -27,7 +28,7 @@ type promptRunRecordInput struct { // native store and registers the transcript for live tailing. Persistence // failures are reported loudly but never fail the completed run itself. func persistPromptRun(ctx context.Context, input promptRunRecordInput) { - if strings.TrimSpace(input.SessionID) == "" { + if input.Binding == nil && strings.TrimSpace(input.SessionID) == "" { return } source := backendSource(api.Backend(input.Backend)) @@ -39,53 +40,84 @@ func persistPromptRun(ctx context.Context, input promptRunRecordInput) { log.Errorf("persist prompt run for session %s: %v", input.SessionID, err) return } - session, err := db.CreateOrGetSession(ctx, database.CreateSessionInput{ - ProviderSessionID: input.SessionID, Source: source, HostID: captainHostID(), - Provider: ai.BackendToProvider(api.Backend(input.Backend)), CWD: input.Rendered.Input.Cwd(), - }) + var session *database.Session + if input.Binding != nil { + session, err = db.GetSession(ctx, input.Binding.SessionID) + if err == nil && strings.TrimSpace(input.SessionID) != "" { + providerSessionID := strings.TrimSpace(input.SessionID) + session, err = db.UpdateSessionState(ctx, database.UpdateSessionStateInput{ + ID: session.ID, ExpectedVersion: session.StateVersion, ProviderSessionID: &providerSessionID, + }) + } + } else { + session, err = db.CreateOrGetSession(ctx, database.CreateSessionInput{ + ProviderSessionID: input.SessionID, Source: source, HostID: captainHostID(), + Provider: ai.BackendToProvider(api.Backend(input.Backend)), CWD: input.Rendered.Input.Cwd(), + }) + } if err != nil { - log.Errorf("persist prompt run for session %s: %v", input.SessionID, err) + log.Errorf("persist prompt run for session %s: %v", firstNonEmpty(input.SessionID, bindingSessionID(input.Binding)), err) return } - run, err := db.CreatePromptRun(ctx, database.CreatePromptRunInput{ - SessionID: session.ID, - BatchID: input.BatchID, - Origin: "captain", - AdmissionKey: input.RunID, - RenderedSpec: renderedSpecMap(input.Rendered), - Runtime: database.PromptRunRuntime{ - Mode: "run", - Resolved: database.PromptRunRuntimeSelection{ - Provider: ai.BackendToProvider(api.Backend(input.Backend)), Backend: input.Backend, - Model: input.Model, Effort: string(input.Rendered.Config.Model.Effort), + batchID := input.BatchID + if input.Binding != nil { + batchID = &input.Binding.BatchID + } + err = db.Transaction(ctx, func(tx *database.DB) error { + run, createErr := tx.CreatePromptRun(ctx, database.CreatePromptRunInput{ + SessionID: session.ID, + BatchID: batchID, + Origin: "captain", + AdmissionKey: input.RunID, + RenderedSpec: renderedSpecMap(input.Rendered), + Runtime: database.PromptRunRuntime{ + Mode: "run", + Resolved: database.PromptRunRuntimeSelection{ + Provider: ai.BackendToProvider(api.Backend(input.Backend)), Backend: input.Backend, + Model: input.Model, Effort: string(input.Rendered.Config.Model.Effort), + }, }, - }, - PromptMarkdown: input.Rendered.Input.Prompt.User, + PromptMarkdown: input.Rendered.Input.Prompt.User, + }) + if createErr != nil { + return createErr + } + finished := database.PromptRunPhaseFinished + state := database.PromptRunStateSucceeded + if input.Error != "" { + state = database.PromptRunStateFailed + } + update := database.UpdatePromptRunInput{ + ID: run.ID, ExpectedVersion: run.Version, Phase: &finished, State: &state, + } + if input.ResultText != "" { + update.ResultText = &input.ResultText + } + if input.Error != "" { + update.Error = &input.Error + } + _, updateErr := tx.UpdatePromptRun(ctx, update) + return updateErr }) if err != nil { - log.Errorf("persist prompt run for session %s: %v", input.SessionID, err) + log.Errorf("persist prompt run for session %s: %v", firstNonEmpty(input.SessionID, bindingSessionID(input.Binding)), err) return } - finished := database.PromptRunPhaseFinished - state := database.PromptRunStateSucceeded - if input.Error != "" { - state = database.PromptRunStateFailed - } - update := database.UpdatePromptRunInput{ - ID: run.ID, ExpectedVersion: run.Version, Phase: &finished, State: &state, - } - if input.ResultText != "" { - update.ResultText = &input.ResultText - } + lifecycle := database.SessionLifecycleSucceeded if input.Error != "" { - update.Error = &input.Error - } - if _, err := db.UpdatePromptRun(ctx, update); err != nil { - log.Errorf("finalize prompt run %s: %v", run.ID, err) + lifecycle = database.SessionLifecycleFailed } + updatePromptSessionLifecycle(ctx, session.ID, lifecycle, input.Error) trackLaunchedTranscript(input, source) } +func bindingSessionID(binding *promptSessionBinding) string { + if binding == nil { + return "" + } + return binding.SessionID.String() +} + // trackLaunchedTranscript arms the serve monitor's fsnotify tail on the // freshly launched session's transcript so it ingests immediately instead of // waiting for the next process poll or backfill. diff --git a/pkg/cli/session_record_db.go b/pkg/cli/session_record_db.go index d2a15d4..dd11990 100644 --- a/pkg/cli/session_record_db.go +++ b/pkg/cli/session_record_db.go @@ -185,6 +185,7 @@ func recordFromOverview(overview database.SessionOverview) SessionRecord { GitBranch: overviewGitBranch(overview), Provider: metadata.Provider, Backend: stringOr(overview.Backend, ""), + LifecycleStatus: string(overview.LifecycleStatus), CWD: stringOr(overview.CWD, ""), ToolCalls: int(overview.ToolCallCount), Messages: int(overview.MessageCount), diff --git a/pkg/cli/sessions.go b/pkg/cli/sessions.go index 0aebae9..1e740bb 100644 --- a/pkg/cli/sessions.go +++ b/pkg/cli/sessions.go @@ -78,6 +78,7 @@ type SessionRecord struct { GitBranch string `json:"gitBranch,omitempty"` Provider string `json:"provider,omitempty"` Backend string `json:"backend,omitempty"` + LifecycleStatus string `json:"lifecycleStatus,omitempty"` CWD string `json:"cwd,omitempty"` ToolCalls int `json:"toolCalls"` Messages int `json:"messages"` diff --git a/pkg/database/session_batch_store.go b/pkg/database/session_batch_store.go index b01dffc..b72e3d8 100644 --- a/pkg/database/session_batch_store.go +++ b/pkg/database/session_batch_store.go @@ -3,7 +3,6 @@ package database import ( "context" "fmt" - "strings" "github.com/google/uuid" ) @@ -59,32 +58,16 @@ func (db *DB) CreateSessionTree(ctx context.Context, input CreateSessionTreeInpu } func (db *DB) UpdateSessionLifecycle(ctx context.Context, id uuid.UUID, lifecycle SessionLifecycleStatus, reason string) (*Session, error) { - if lifecycle != SessionLifecyclePartial && !validSessionLifecycle(lifecycle) { + if !validSessionLifecycle(lifecycle) { return nil, fmt.Errorf("%w: unknown lifecycle status %q", ErrInvalidSession, lifecycle) } session, err := db.GetSession(ctx, id) if err != nil { return nil, err } - if lifecycle != SessionLifecyclePartial { - activity := SessionActivityIdle - return db.UpdateSessionState(ctx, UpdateSessionStateInput{ - ID: id, ExpectedVersion: session.StateVersion, LifecycleStatus: &lifecycle, - ActivityState: &activity, StateReason: &reason, - }) - } - updates := map[string]any{ - "lifecycle_status": lifecycle, - "activity_state": SessionActivityIdle, - "state_reason": nullableTrimmed(strings.TrimSpace(reason)), - } - result := db.gorm.WithContext(ctx).Model(&sessionRecord{}). - Where("id = ? AND state_version = ?", id, session.StateVersion).Updates(updates) - if result.Error != nil { - return nil, fmt.Errorf("update Captain batch lifecycle: %w", result.Error) - } - if result.RowsAffected != 1 { - return nil, fmt.Errorf("%w: session %s lifecycle changed concurrently", ErrSessionConflict, id) - } - return db.GetSession(ctx, id) + activity := SessionActivityIdle + return db.UpdateSessionState(ctx, UpdateSessionStateInput{ + ID: id, ExpectedVersion: session.StateVersion, LifecycleStatus: &lifecycle, + ActivityState: &activity, StateReason: &reason, + }) } diff --git a/pkg/database/session_prompt_store.go b/pkg/database/session_prompt_store.go index a574abf..22aa6d3 100644 --- a/pkg/database/session_prompt_store.go +++ b/pkg/database/session_prompt_store.go @@ -801,7 +801,8 @@ func sessionFromRecord(record sessionRecord) Session { func validSessionLifecycle(value SessionLifecycleStatus) bool { switch value { case SessionLifecycleCreated, SessionLifecycleRunning, SessionLifecycleSucceeded, - SessionLifecycleFailed, SessionLifecycleCancelled, SessionLifecycleInterrupted: + SessionLifecycleFailed, SessionLifecycleCancelled, SessionLifecycleInterrupted, + SessionLifecyclePartial: return true default: return false From bbea991bcec0529b5dcfc9be9e8fe46d972c6cf7 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 16 Jul 2026 13:49:34 +0300 Subject: [PATCH 082/131] feat(fe): Add multi-model prompt runs Add runtime comparison controls and batch session inspection so users can run, monitor, stop, and chat with multiple model configurations from the prompt workbench. --- pkg/cli/webapp/src/PromptBatchInspector.tsx | 205 ++++++++++++++++++ pkg/cli/webapp/src/PromptRuntimeRows.test.tsx | 28 +++ pkg/cli/webapp/src/PromptRuntimeRows.tsx | 148 +++++++++++++ pkg/cli/webapp/src/PromptWorkbench.tsx | 122 ++++++++++- pkg/cli/webapp/src/SessionBrowser.tsx | 12 + .../webapp/src/hooks/usePromptRunStream.ts | 28 +++ pkg/cli/webapp/src/sessionCollection.test.ts | 53 +++++ pkg/cli/webapp/src/sessionCollection.ts | 133 ++++++++++++ pkg/cli/webapp/src/sessionData.ts | 8 +- 9 files changed, 727 insertions(+), 10 deletions(-) create mode 100644 pkg/cli/webapp/src/PromptBatchInspector.tsx create mode 100644 pkg/cli/webapp/src/PromptRuntimeRows.test.tsx create mode 100644 pkg/cli/webapp/src/PromptRuntimeRows.tsx create mode 100644 pkg/cli/webapp/src/sessionCollection.test.ts create mode 100644 pkg/cli/webapp/src/sessionCollection.ts diff --git a/pkg/cli/webapp/src/PromptBatchInspector.tsx b/pkg/cli/webapp/src/PromptBatchInspector.tsx new file mode 100644 index 0000000..2303bd1 --- /dev/null +++ b/pkg/cli/webapp/src/PromptBatchInspector.tsx @@ -0,0 +1,205 @@ +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Select } from "@flanksource/clicky-ui/components"; +import { + SessionChatComposer, + SessionInspector, + type UnifiedSessionInput, +} from "@flanksource/clicky-ui/ai"; +import { + usePromptRunStream, + type PromptBatchHandle, + type PromptBatchRunHandle, + type PromptRunStreamState, +} from "./hooks/usePromptRunStream"; +import { useSessionChat, mergeSessionMessages } from "./hooks/useSessionChat"; +import { fetchSession } from "./sessionData"; +import { batchChatTargets, batchSessionCollection } from "./sessionCollection"; + +export function PromptBatchInspector({ + handle, + onEdit, +}: { + handle: PromptBatchHandle; + onEdit: () => void; +}) { + const query = useQuery({ + queryKey: ["prompt-batch", handle.batchId], + queryFn: () => fetchSession(handle.batchId), + refetchInterval: 2_000, + }); + const [streams, setStreams] = useState>( + {}, + ); + const updateStream = useCallback( + (runID: string, state: PromptRunStreamState) => + setStreams((current) => + current[runID] === state ? current : { ...current, [runID]: state }, + ), + [], + ); + const liveSessions = useMemo(() => { + const sessions = new Map(); + for (const run of handle.runs) { + const stream = streams[run.runId]; + if (!stream) continue; + const saved = query.data?.sessions.find( + (item) => item.captainId === run.sessionId, + )?.detail; + sessions.set(run.sessionId, { + ...saved, + id: run.sessionId, + source: run.backend || saved?.source || "captain", + title: run.selector || run.model || saved?.title, + backend: run.backend || saved?.backend, + model: run.model || saved?.model, + reasoningEffort: run.effort || saved?.reasoningEffort, + messages: mergeSessionMessages(saved?.messages ?? [], stream.messages), + }); + } + return sessions; + }, [handle.runs, query.data?.sessions, streams]); + const collection = useMemo( + () => batchSessionCollection(handle, query.data, liveSessions), + [handle, liveSessions, query.data], + ); + const targets = useMemo(() => batchChatTargets(handle), [handle]); + const [targetID, setTargetID] = useState(targets[0]?.sessionId); + const target = + targets.find((candidate) => candidate.sessionId === targetID) ?? targets[0]; + const chat = useSessionChat({ + initialRunID: target?.runId, + sessionID: target?.sessionId, + initialCapabilities: target?.capabilities, + onTerminal: async () => { + await query.refetch(); + }, + }); + + useEffect(() => { + if (target && target.sessionId !== targetID) setTargetID(target.sessionId); + }, [target, targetID]); + + return ( +
    + {handle.runs.map((run) => ( + + ))} +
    +
    +
    Multi-model run
    +
    + {handle.batchId} +
    +
    + +
    + {query.error && ( +
    + {errorMessage(query.error)} +
    + )} + { + const run = handle.runs.find( + (candidate) => candidate.sessionId === item.id, + ); + return run ? : undefined; + }} + {...(target + ? { + composer: ( + + Chat target + ({ value: candidate.sessionId, - label: - candidate.selector || - candidate.model || - candidate.sessionId, + label: chatTargetLabel(candidate), }))} + className="h-8 truncate text-xs" onChange={(event) => setTargetID(event.target.value)} /> - +
    } /> ), @@ -151,6 +154,11 @@ export function PromptBatchInspector({ ); } +function chatTargetLabel(target: PromptBatchRunHandle) { + const model = target.model || target.selector || target.sessionId; + return target.effort ? `${model}:${target.effort}` : model; +} + const BatchRunSubscription = memo(function BatchRunSubscription({ run, onChange, diff --git a/pkg/cli/webapp/src/ProviderDefaultsControls.tsx b/pkg/cli/webapp/src/ProviderDefaultsControls.tsx new file mode 100644 index 0000000..4b4a57c --- /dev/null +++ b/pkg/cli/webapp/src/ProviderDefaultsControls.tsx @@ -0,0 +1,207 @@ +import { useMemo, useState } from "react"; +import { Button } from "@flanksource/clicky-ui/components"; + +export type ProviderModel = { + id: string; + label?: string; + supportedEfforts?: string[]; + defaultEffort?: string; +}; + +export type ProviderAdapter = { + backend: string; + type: "api" | "cli"; + authenticated: boolean; + binary?: string; + modelCount: number; + models?: string[]; + modelDetails?: ProviderModel[]; +}; + +export type ProviderDefault = { + agent: string; + model: string; + effort: string; + configured: boolean; +}; + +const PROVIDER_AGENTS: Record = { + anthropic: ["anthropic", "claude-cli", "claude-agent", "claude-cmux"], + openai: ["openai", "codex-cli", "codex-agent", "codex-cmux"], + gemini: ["gemini", "gemini-cli"], + deepseek: ["deepseek"], +}; + +const ALL_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"]; + +export function ProviderDefaultsControls({ + provider, + defaults, + adapters, + active, + onRefresh, +}: { + provider: string; + defaults: ProviderDefault; + adapters: ProviderAdapter[]; + active: boolean; + onRefresh: () => Promise; +}) { + const [agent, setAgent] = useState(defaults.agent); + const [model, setModel] = useState(defaults.model); + const [effort, setEffort] = useState(defaults.effort); + const [pending, setPending] = useState<"defaults" | "active" | null>(null); + const [status, setStatus] = useState<{ tone: "success" | "error"; text: string } | null>(null); + const agentOptions = PROVIDER_AGENTS[provider] ?? [provider]; + const models = useMemo(() => modelsForAgent(adapters, agent, model), [adapters, agent, model]); + const selectedModel = models.find((candidate) => candidate.id === model); + const effortOptions = selectedModel?.supportedEfforts ?? ALL_EFFORTS; + const modelAvailable = models.some((candidate) => candidate.id === model && candidate.available); + + function changeAgent(nextAgent: string) { + setAgent(nextAgent); + const nextModel = modelsForAgent(adapters, nextAgent, "").find((candidate) => candidate.available); + setModel(nextModel?.id ?? ""); + setEffort(nextModel?.defaultEffort ?? ""); + setStatus(null); + } + + function changeModel(nextModel: string) { + setModel(nextModel); + setEffort(models.find((candidate) => candidate.id === nextModel)?.defaultEffort ?? ""); + setStatus(null); + } + + async function saveDefaults() { + setPending("defaults"); + setStatus(null); + try { + await putJSON(`/api/captain/ai/providers/${encodeURIComponent(provider)}/defaults`, { agent, model, effort }); + setStatus({ tone: "success", text: "Provider defaults saved." }); + await onRefresh(); + } catch (error) { + setStatus({ tone: "error", text: errorMessage(error) }); + } finally { + setPending(null); + } + } + + async function setActiveProvider() { + setPending("active"); + setStatus(null); + try { + await putJSON("/api/captain/ai/default-provider", { provider }); + setStatus({ tone: "success", text: `${provider} is now the default provider.` }); + await onRefresh(); + } catch (error) { + setStatus({ tone: "error", text: errorMessage(error) }); + } finally { + setPending(null); + } + } + + return ( +
    +
    +
    +

    Provider defaults

    +

    Used only when a run leaves the field unspecified.

    +
    + {active ? ( + Active default + ) : ( + + )} +
    + + + +
    + +
    + {!modelAvailable && ( +

    Configure this runtime first to load a valid model catalog.

    + )} + {status &&

    {status.text}

    } +
    + ); +} + +type ModelOption = ProviderModel & { available: boolean }; + +function modelsForAgent(adapters: ProviderAdapter[], agent: string, current: string): ModelOption[] { + const adapter = adapters.find((candidate) => candidate.backend === agent); + const models = adapter?.modelDetails?.length + ? adapter.modelDetails + : (adapter?.models ?? []).map((id) => ({ id })); + const options = models.map((model) => ({ ...model, available: true })); + if (current && !options.some((candidate) => candidate.id === current)) { + options.unshift({ id: current, available: false }); + } + return options; +} + +function agentLabel(agent: string, adapters: ProviderAdapter[]) { + const adapter = adapters.find((candidate) => candidate.backend === agent); + const ready = adapter?.authenticated && (adapter.type === "api" || Boolean(adapter.binary)); + return `${agent}${ready ? " — ready" : " — needs setup"}`; +} + +async function putJSON(path: string, body: unknown) { + const response = await fetch(path, { + method: "PUT", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const message = (await response.text()).trim(); + throw new Error(message || `Configuration failed with ${response.status}`); + } + return response.json() as Promise; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/pkg/cli/webapp/src/WhoamiPage.test.tsx b/pkg/cli/webapp/src/WhoamiPage.test.tsx index 7e7d68f..74f2e2f 100644 --- a/pkg/cli/webapp/src/WhoamiPage.test.tsx +++ b/pkg/cli/webapp/src/WhoamiPage.test.tsx @@ -1,9 +1,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WhoamiPage } from "./WhoamiPage"; const WHOAMI_RESULT = { + defaultProvider: "gemini", + providerDefaults: { + gemini: { + agent: "gemini-cli", + model: "gemini-3.5-flash", + effort: "high", + configured: true, + }, + }, adapters: [ { backend: "gemini", @@ -45,6 +54,7 @@ const WHOAMI_RESULT = { }; afterEach(() => { + cleanup(); vi.unstubAllGlobals(); }); @@ -63,6 +73,10 @@ describe("WhoamiPage", () => { expect(screen.getByRole("heading", { name: "AI adapters" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "API providers" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "CLI agents" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Configure API token" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Provider defaults" })).toBeInTheDocument(); + expect(screen.getByText("Active default")).toBeInTheDocument(); + expect(screen.getAllByLabelText(/API token$/)).toHaveLength(1); expect(screen.getByText("gemini", { selector: "h3" })).toBeInTheDocument(); expect(screen.getByText("Needs setup")).toBeInTheDocument(); expect( @@ -71,11 +85,11 @@ describe("WhoamiPage", () => { expect(screen.getByText("gemini login")).toBeInTheDocument(); expect(screen.getByText("/home/example/.gemini/oauth_creds.json")).toBeInTheDocument(); expect(screen.getByText("/usr/local/bin/gemini")).toBeInTheDocument(); - expect(screen.getByText("Gemini 3.5 Flash")).toBeInTheDocument(); + expect(screen.getByText("Gemini 3.5 Flash", { selector: "div" })).toBeInTheDocument(); expect(screen.getByText("gemini-3.5-flash")).toBeInTheDocument(); expect(screen.getByText("2026-05-19")).toBeInTheDocument(); expect(screen.getByText("low / medium / high")).toBeInTheDocument(); - expect(screen.getByText("Gemini 2.5 Pro")).toBeInTheDocument(); + expect(screen.getByText("Gemini 2.5 Pro", { selector: "div" })).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith( "/api/v1/whoami?models=true&limit=0", expect.objectContaining({ method: "POST", body: "{}" }), @@ -92,8 +106,137 @@ describe("WhoamiPage", () => { expect(await screen.findByText("probe failed")).toBeInTheDocument(); }); + + it("validates and saves an API token, clears it, and refreshes whoami", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(WHOAMI_RESULT)) + .mockResolvedValueOnce(jsonResponse({ + provider: "gemini", + valid: true, + saved: true, + source: "captain-vault", + maskedToken: "gemi…cret", + modelCount: 1, + })) + .mockResolvedValueOnce(jsonResponse(WHOAMI_RESULT)); + vi.stubGlobal("fetch", fetchMock); + renderWhoamiPage(); + + const input = await screen.findByLabelText("gemini API token"); + fireEvent.change(input, { target: { value: "gemini-provider-secret" } }); + fireEvent.click(screen.getByRole("button", { name: "Save & test gemini token" })); + + expect(await screen.findByText("Token saved and validated against 1 model.")).toBeInTheDocument(); + expect(input).toHaveValue(""); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/captain/ai/providers/gemini/token", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ token: "gemini-provider-secret" }), + }), + ); + }); + + it("retains a rejected token and shows the provider error", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(WHOAMI_RESULT)) + .mockResolvedValueOnce(new Response("validate gemini credential: HTTP 401", { status: 422 })); + vi.stubGlobal("fetch", fetchMock); + renderWhoamiPage(); + + const input = await screen.findByLabelText("gemini API token"); + fireEvent.change(input, { target: { value: "rejected-provider-secret" } }); + fireEvent.click(screen.getByRole("button", { name: "Save & test gemini token" })); + + expect(await screen.findByText("validate gemini credential: HTTP 401")).toBeInTheDocument(); + expect(input).toHaveValue("rejected-provider-secret"); + }); + + it("tests the current API credential without sending a token", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(WHOAMI_RESULT)) + .mockResolvedValueOnce(jsonResponse({ + provider: "gemini", + valid: true, + saved: false, + source: "environment", + maskedToken: "gemi…cret", + modelCount: 2, + })); + vi.stubGlobal("fetch", fetchMock); + renderWhoamiPage(); + + fireEvent.click(await screen.findByRole("button", { name: "Test current gemini token" })); + + expect(await screen.findByText("Current token is valid for 2 models.")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/captain/ai/providers/gemini/token/test", + expect.objectContaining({ method: "POST", body: "{}" }), + ); + }); + + it("saves model, effort, and agent defaults for the provider", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(WHOAMI_RESULT)) + .mockResolvedValueOnce(jsonResponse({ + provider: "gemini", + agent: "gemini-cli", + model: "gemini-2.5-pro", + effort: "", + active: true, + })) + .mockResolvedValueOnce(jsonResponse(WHOAMI_RESULT)); + vi.stubGlobal("fetch", fetchMock); + renderWhoamiPage(); + + fireEvent.change(await screen.findByLabelText("gemini default model"), { + target: { value: "gemini-2.5-pro" }, + }); + fireEvent.change(screen.getByLabelText("gemini default effort"), { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: "Save defaults" })); + + expect(await screen.findByText("Provider defaults saved.")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/captain/ai/providers/gemini/defaults", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ agent: "gemini-cli", model: "gemini-2.5-pro", effort: "" }), + }), + ); + }); + + it("sets a provider as the active default", async () => { + const inactive = { ...WHOAMI_RESULT, defaultProvider: "anthropic" }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(inactive)) + .mockResolvedValueOnce(jsonResponse({ provider: "gemini" })) + .mockResolvedValueOnce(jsonResponse(WHOAMI_RESULT)); + vi.stubGlobal("fetch", fetchMock); + renderWhoamiPage(); + + fireEvent.click(await screen.findByRole("button", { name: "Set as default" })); + + expect(await screen.findByText("Active default")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "/api/captain/ai/default-provider", + expect.objectContaining({ method: "PUT", body: JSON.stringify({ provider: "gemini" }) }), + ); + }); }); +function jsonResponse(value: unknown) { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + function renderWhoamiPage() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, diff --git a/pkg/cli/webapp/src/WhoamiPage.tsx b/pkg/cli/webapp/src/WhoamiPage.tsx index a1e8055..a90b881 100644 --- a/pkg/cli/webapp/src/WhoamiPage.tsx +++ b/pkg/cli/webapp/src/WhoamiPage.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@flanksource/clicky-ui/components"; import { @@ -11,9 +11,14 @@ import { UiTerminal, UiWarningTriangle, } from "@flanksource/clicky-ui/data"; +import { + ProviderDefaultsControls, + type ProviderAdapter, + type ProviderDefault, + type ProviderModel, +} from "./ProviderDefaultsControls"; -type WhoamiModel = { - id: string; +type WhoamiModel = ProviderModel & { label?: string; releaseDate?: string; reasoning?: boolean; @@ -24,22 +29,28 @@ type WhoamiModel = { priority?: number; }; -type WhoamiAdapter = { - backend: string; - type: "api" | "cli"; - authenticated: boolean; +type WhoamiAdapter = ProviderAdapter & { authMethod?: string; authDetail?: string; binary?: string; binaryMissing?: string; - modelCount: number; - models?: string[]; modelError?: string; modelDetails?: WhoamiModel[]; }; type WhoamiResult = { adapters: WhoamiAdapter[]; + defaultProvider: string; + providerDefaults: Record; +}; + +type ProviderTokenResult = { + provider: string; + valid: boolean; + saved: boolean; + source: string; + maskedToken: string; + modelCount: number; }; export function WhoamiPage() { @@ -77,14 +88,18 @@ export function WhoamiPage() { ) : query.error ? ( {errorMessage(query.error)} ) : ( - + { await query.refetch(); }} + /> )}
    ); } -function WhoamiContent({ adapters }: { adapters: WhoamiAdapter[] }) { +function WhoamiContent({ result, onRefresh }: { result: WhoamiResult; onRefresh: () => Promise }) { + const { adapters, defaultProvider = "anthropic", providerDefaults = {} } = result; if (adapters.length === 0) return No adapters were reported.; const readyCount = adapters.filter(adapterReady).length; @@ -98,15 +113,21 @@ function WhoamiContent({ adapters }: { adapters: WhoamiAdapter[] }) {
    ); @@ -117,11 +138,17 @@ function AdapterGroup({ description, type, adapters, + defaultProvider, + providerDefaults, + onRefresh, }: { title: string; description: string; type: WhoamiAdapter["type"]; adapters: WhoamiAdapter[]; + defaultProvider: string; + providerDefaults: Record; + onRefresh: () => Promise; }) { const rows = adapters.filter((adapter) => adapter.type === type); if (rows.length === 0) return null; @@ -137,13 +164,34 @@ function AdapterGroup({
    - {rows.map((adapter) => )} + {rows.map((adapter) => ( + + ))}
    ); } -function AdapterCard({ adapter }: { adapter: WhoamiAdapter }) { +function AdapterCard({ + adapter, + adapters, + defaults, + active, + onRefresh, +}: { + adapter: WhoamiAdapter; + adapters: WhoamiAdapter[]; + defaults?: ProviderDefault; + active: boolean; + onRefresh: () => Promise; +}) { const ready = adapterReady(adapter); const models = adapterModels(adapter); return ( @@ -165,6 +213,17 @@ function AdapterCard({ adapter }: { adapter: WhoamiAdapter }) {
    + {adapter.type === "api" && } + {adapter.type === "api" && defaults && ( + + )} {adapter.modelError && (
    {adapter.modelError} @@ -190,6 +249,103 @@ function AdapterCard({ adapter }: { adapter: WhoamiAdapter }) { ); } +function ProviderTokenControls({ adapter, onRefresh }: { adapter: WhoamiAdapter; onRefresh: () => Promise }) { + const [token, setToken] = useState(""); + const [pending, setPending] = useState<"save" | "test" | null>(null); + const [status, setStatus] = useState<{ tone: "success" | "error"; text: string } | null>(null); + + async function submit(action: "save" | "test") { + setPending(action); + setStatus(null); + try { + const result = await updateProviderToken(adapter.backend, action, token); + if (action === "save") { + setToken(""); + setStatus({ tone: "success", text: `Token saved and validated against ${result.modelCount} ${pluralModels(result.modelCount)}.` }); + await onRefresh(); + } else { + setStatus({ tone: "success", text: `Current token is valid for ${result.modelCount} ${pluralModels(result.modelCount)}.` }); + } + } catch (error) { + setStatus({ tone: "error", text: errorMessage(error) }); + } finally { + setPending(null); + } + } + + return ( +
    +
    +

    + Configure API token +

    +

    + New tokens are tested before replacing the current credential. +

    +
    + +
    + + +
    + {status && ( +
    + {status.text} +
    + )} +
    + ); +} + +async function updateProviderToken(backend: string, action: "save" | "test", token: string): Promise { + const test = action === "test"; + const response = await fetch( + `/api/captain/ai/providers/${encodeURIComponent(backend)}/token${test ? "/test" : ""}`, + { + method: test ? "POST" : "PUT", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: test ? "{}" : JSON.stringify({ token }), + }, + ); + if (!response.ok) { + const message = (await response.text()).trim(); + throw new Error(message || `Token validation failed with ${response.status}`); + } + return (await response.json()) as ProviderTokenResult; +} + +function pluralModels(count: number) { + return count === 1 ? "model" : "models"; +} + function AdapterDetails({ adapter }: { adapter: WhoamiAdapter }) { return (
    diff --git a/pkg/cli/webapp/src/hooks/useSessionChat.test.ts b/pkg/cli/webapp/src/hooks/useSessionChat.test.ts index f3ae00a..9f91a7b 100644 --- a/pkg/cli/webapp/src/hooks/useSessionChat.test.ts +++ b/pkg/cli/webapp/src/hooks/useSessionChat.test.ts @@ -1,6 +1,7 @@ import type { SessionUIMessage } from "@flanksource/clicky-ui/ai"; import { describe, expect, it } from "vitest"; -import { mergeSessionMessages } from "./useSessionChat"; +import { mergeSessionMessages, resolveChatState } from "./useSessionChat"; +import type { ChatStateFrame } from "./usePromptRunStream"; describe("mergeSessionMessages", () => { it("reconciles an optimistic user message by exact id", () => { @@ -20,3 +21,59 @@ describe("mergeSessionMessages", () => { expect(merged).toEqual([accepted]); }); }); + +describe("resolveChatState", () => { + const capabilities = { + interrupt: true, + steer: false, + followUp: true, + resume: true, + }; + + it("uses a polled idle state when the SSE state is behind on the same turn", () => { + const stream: ChatStateFrame = { + runId: "run-1", + status: "running", + turn: 1, + capabilities, + }; + const polled: ChatStateFrame = { + ...stream, + status: "idle", + }; + + expect(resolveChatState(stream, polled)).toBe(polled); + }); + + it("keeps a newer SSE turn over the previous polled idle state", () => { + const polled: ChatStateFrame = { + runId: "run-1", + status: "idle", + turn: 1, + capabilities, + }; + const stream: ChatStateFrame = { + ...polled, + status: "starting", + turn: 2, + }; + + expect(resolveChatState(stream, polled)).toBe(stream); + }); + + it("keeps the SSE state after a follow-up starts a replacement run", () => { + const polled: ChatStateFrame = { + runId: "run-1", + status: "idle", + turn: 1, + capabilities, + }; + const stream: ChatStateFrame = { + ...polled, + runId: "run-2", + status: "starting", + }; + + expect(resolveChatState(stream, polled)).toBe(stream); + }); +}); diff --git a/pkg/cli/webapp/src/hooks/useSessionChat.ts b/pkg/cli/webapp/src/hooks/useSessionChat.ts index 1ff4815..18838f5 100644 --- a/pkg/cli/webapp/src/hooks/useSessionChat.ts +++ b/pkg/cli/webapp/src/hooks/useSessionChat.ts @@ -55,18 +55,21 @@ export function useSessionChat(options: UseSessionChatOptions) { () => mergeSessionMessages(stream.messages, optimistic), [optimistic, stream.messages], ); + const resolvedChatState = resolveChatState( + stream.chatState, + options.initialState, + ); const capabilities = - stream.chatState?.capabilities ?? + resolvedChatState?.capabilities ?? stream.run?.capabilities ?? options.initialCapabilities ?? EMPTY_CAPABILITIES; - const liveChatState = stream.chatState ?? - options.initialState ?? { - runId: activeRunID ?? "", - status: activeRunID ? "starting" : "idle", - turn: 0, - capabilities, - }; + const liveChatState = resolvedChatState ?? { + runId: activeRunID ?? "", + status: activeRunID ? "starting" : "idle", + turn: 0, + capabilities, + }; const chatState = stream.status === "done" || stream.status === "error" ? { ...liveChatState, status: "idle" as const, queued: [] } @@ -164,6 +167,25 @@ export function useSessionChat(options: UseSessionChatOptions) { }; } +export function resolveChatState( + streamState: ChatStateFrame | undefined, + polledState: ChatStateFrame | undefined, +) { + if (!streamState) return polledState; + if (!polledState) return streamState; + if (streamState.runId !== polledState.runId) return streamState; + if (streamState.turn !== polledState.turn) { + return streamState.turn > polledState.turn ? streamState : polledState; + } + if ( + polledState.status === "idle" && + (streamState.status === "starting" || streamState.status === "running") + ) { + return polledState; + } + return streamState; +} + export function mergeSessionMessages( base: SessionUIMessage[], additional: SessionUIMessage[], diff --git a/pkg/cli/webapp/src/sessionCollection.test.ts b/pkg/cli/webapp/src/sessionCollection.test.ts index c39fe0f..a7b8125 100644 --- a/pkg/cli/webapp/src/sessionCollection.test.ts +++ b/pkg/cli/webapp/src/sessionCollection.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { PromptBatchHandle } from "./hooks/usePromptRunStream"; -import { batchChatTargets, batchSessionCollection } from "./sessionCollection"; +import { + batchChatTargetState, + batchChatTargets, + batchSessionCollection, +} from "./sessionCollection"; const HANDLE: PromptBatchHandle = { batchId: "batch-1", @@ -50,4 +54,42 @@ describe("batch session collection", () => { "session-1", ]); }); + + it("hydrates a chat target from its polled child session state", () => { + const state = { + runId: "run-1", + status: "idle" as const, + turn: 1, + capabilities: { + interrupt: true, + steer: false, + followUp: true, + resume: true, + }, + }; + + expect( + batchChatTargetState( + { + rootSessionId: "batch-1", + total: 2, + sessions: [ + { + captainId: "session-1", + detailAvailable: false, + summary: { + key: "session-1", + id: "provider-session-1", + source: "codex", + toolCalls: 0, + messages: 0, + }, + chatState: state, + }, + ], + }, + HANDLE.runs[0]!, + ), + ).toEqual(state); + }); }); diff --git a/pkg/cli/webapp/src/sessionCollection.ts b/pkg/cli/webapp/src/sessionCollection.ts index 7dbe309..40c0ba0 100644 --- a/pkg/cli/webapp/src/sessionCollection.ts +++ b/pkg/cli/webapp/src/sessionCollection.ts @@ -3,7 +3,10 @@ import type { SessionCollectionItem, UnifiedSessionInput, } from "@flanksource/clicky-ui/ai"; -import type { PromptBatchHandle } from "./hooks/usePromptRunStream"; +import type { + PromptBatchHandle, + PromptBatchRunHandle, +} from "./hooks/usePromptRunStream"; import type { SessionGetItem, SessionGetResult } from "./sessionData"; export function sessionResultCollection( @@ -88,6 +91,15 @@ export function batchChatTargets(handle: PromptBatchHandle) { ); } +export function batchChatTargetState( + result: SessionGetResult | undefined, + target: PromptBatchRunHandle | undefined, +) { + if (!target) return undefined; + return result?.sessions.find((item) => item.captainId === target.sessionId) + ?.chatState; +} + function sessionCollectionItem(item: SessionGetItem): SessionCollectionItem { return { id: item.captainId, diff --git a/pkg/cli/whoami.go b/pkg/cli/whoami.go index 0f7f1d3..cba40b4 100644 --- a/pkg/cli/whoami.go +++ b/pkg/cli/whoami.go @@ -2,6 +2,7 @@ package cli import ( "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/captainconfig" ) // WhoamiOptions and AdapterStatus live in pkg/ai: the adapter probe moved there @@ -14,7 +15,9 @@ type AdapterStatus = ai.AdapterStatus // WhoamiResult is the command's render model: the probed adapters plus // display-only knobs consumed by Pretty(). The knobs are never serialized. type WhoamiResult struct { - Adapters []AdapterStatus `json:"adapters"` + Adapters []AdapterStatus `json:"adapters"` + DefaultProvider string `json:"defaultProvider"` + ProviderDefaults map[string]ProviderDefaultView `json:"providerDefaults"` sampleLimit int showModels bool @@ -25,5 +28,16 @@ func RunWhoami(opts WhoamiOptions) (any, error) { if err != nil { return nil, err } - return WhoamiResult{Adapters: adapters, sampleLimit: opts.Limit, showModels: opts.Models}, nil + config, _, err := captainconfig.Load() + if err != nil { + return nil, err + } + defaults, err := allProviderDefaults(config.AI) + if err != nil { + return nil, err + } + return WhoamiResult{ + Adapters: adapters, DefaultProvider: config.AI.ActiveProvider(), ProviderDefaults: defaults, + sampleLimit: opts.Limit, showModels: opts.Models, + }, nil } diff --git a/pkg/cli/whoami_test.go b/pkg/cli/whoami_test.go index 612e762..606a2e2 100644 --- a/pkg/cli/whoami_test.go +++ b/pkg/cli/whoami_test.go @@ -1,10 +1,12 @@ package cli import ( + "path/filepath" "strings" "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/captainconfig" ) // The adapter probe and its logic tests live in pkg/ai (pkg/ai/adapters_test.go). @@ -95,3 +97,24 @@ func TestRunWhoami_RejectsUnknownBackend(t *testing.T) { t.Fatal("expected error for unknown --backend") } } + +func TestRunWhoamiIncludesProviderDefaults(t *testing.T) { + captainconfig.SetPathForTesting(filepath.Join(t.TempDir(), ".captain.yaml")) + t.Cleanup(func() { captainconfig.SetPathForTesting("") }) + if err := captainconfig.Save(captainconfig.Config{AI: captainconfig.AIDefaults{ + DefaultProvider: "openai", + Providers: map[string]captainconfig.ProviderDefaults{ + "openai": {Agent: "codex-agent", Model: "gpt-5.6-sol", ReasoningEffort: "high"}, + }, + }}); err != nil { + t.Fatalf("seed config: %v", err) + } + result, err := RunWhoami(WhoamiOptions{Models: false}) + if err != nil { + t.Fatalf("RunWhoami: %v", err) + } + got := result.(WhoamiResult) + if got.DefaultProvider != "openai" || got.ProviderDefaults["openai"].Agent != "codex-agent" { + t.Fatalf("whoami defaults = %+v", got) + } +} diff --git a/pkg/credentials/vault.go b/pkg/credentials/vault.go new file mode 100644 index 0000000..58050cf --- /dev/null +++ b/pkg/credentials/vault.go @@ -0,0 +1,159 @@ +package credentials + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +const ( + SourceVault = "captain-vault" + SourceEnvironment = "environment" +) + +type Resolved struct { + Token string + Source string + Detail string +} + +type Vault struct { + path string +} + +var pathOverride string + +func SetPathForTesting(path string) { + pathOverride = path +} + +func Path() (string, error) { + if pathOverride != "" { + return pathOverride, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".config", "captain", "vault"), nil +} + +func DefaultVault() (Vault, error) { + path, err := Path() + if err != nil { + return Vault{}, err + } + return NewVault(path), nil +} + +func NewVault(path string) Vault { + return Vault{path: path} +} + +func (v Vault) Path() string { + return v.path +} + +func (v Vault) Load() (map[string]string, error) { + data, err := os.ReadFile(v.path) + if errors.Is(err, fs.ErrNotExist) { + return map[string]string{}, nil + } + if err != nil { + return nil, fmt.Errorf("read credential vault %s: %w", v.path, err) + } + values := map[string]string{} + if err := json.Unmarshal(data, &values); err != nil { + return nil, fmt.Errorf("parse credential vault %s: %w", v.path, err) + } + return values, nil +} + +func (v Vault) Resolve(provider string, envVars []string, getenv func(string) string) (Resolved, error) { + values, err := v.Load() + if err != nil { + return Resolved{}, err + } + if token := strings.TrimSpace(values[provider]); token != "" { + return Resolved{Token: token, Source: SourceVault}, nil + } + for _, envVar := range envVars { + if token := strings.TrimSpace(getenv(envVar)); token != "" { + return Resolved{Token: token, Source: SourceEnvironment, Detail: envVar}, nil + } + } + return Resolved{}, nil +} + +func (v Vault) Set(provider, token string) error { + provider = strings.TrimSpace(provider) + token = strings.TrimSpace(token) + if provider == "" { + return fmt.Errorf("credential provider cannot be empty") + } + if token == "" { + return fmt.Errorf("credential token cannot be empty") + } + if err := os.MkdirAll(filepath.Dir(v.path), 0o700); err != nil { + return fmt.Errorf("create credential directory: %w", err) + } + if err := os.Chmod(filepath.Dir(v.path), 0o700); err != nil { + return fmt.Errorf("secure credential directory: %w", err) + } + + lock, err := os.OpenFile(v.path+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("open credential vault lock: %w", err) + } + defer func() { _ = lock.Close() }() + if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf("lock credential vault: %w", err) + } + defer func() { _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) }() + + values, err := v.Load() + if err != nil { + return err + } + values[provider] = token + return v.write(values) +} + +func (v Vault) write(values map[string]string) error { + data, err := json.MarshalIndent(values, "", " ") + if err != nil { + return fmt.Errorf("marshal credential vault: %w", err) + } + data = append(data, '\n') + tmp, err := os.CreateTemp(filepath.Dir(v.path), ".vault-*") + if err != nil { + return fmt.Errorf("create credential vault temp file: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("secure credential vault temp file: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write credential vault: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync credential vault: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close credential vault: %w", err) + } + if err := os.Rename(tmpPath, v.path); err != nil { + return fmt.Errorf("replace credential vault: %w", err) + } + return nil +} diff --git a/pkg/credentials/vault_suite_test.go b/pkg/credentials/vault_suite_test.go new file mode 100644 index 0000000..72cf573 --- /dev/null +++ b/pkg/credentials/vault_suite_test.go @@ -0,0 +1,13 @@ +package credentials_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCredentials(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Credentials Suite") +} diff --git a/pkg/credentials/vault_test.go b/pkg/credentials/vault_test.go new file mode 100644 index 0000000..6dcbf19 --- /dev/null +++ b/pkg/credentials/vault_test.go @@ -0,0 +1,93 @@ +package credentials_test + +import ( + "os" + "path/filepath" + "sync" + + "github.com/flanksource/captain/pkg/credentials" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Vault", func() { + var path string + + BeforeEach(func() { + path = filepath.Join(GinkgoT().TempDir(), ".config", "captain", "vault") + }) + + It("treats a missing vault as empty", func() { + values, err := credentials.NewVault(path).Load() + Expect(err).NotTo(HaveOccurred()) + Expect(values).To(BeEmpty()) + }) + + It("writes a private directory and atomically round-trips provider tokens", func() { + vault := credentials.NewVault(path) + Expect(vault.Set("openai", "sk-openai-example")).To(Succeed()) + Expect(vault.Set("anthropic", "sk-ant-example")).To(Succeed()) + + values, err := vault.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(values).To(Equal(map[string]string{ + "anthropic": "sk-ant-example", + "openai": "sk-openai-example", + })) + + dirInfo, err := os.Stat(filepath.Dir(path)) + Expect(err).NotTo(HaveOccurred()) + Expect(dirInfo.Mode().Perm()).To(Equal(os.FileMode(0o700))) + fileInfo, err := os.Stat(path) + Expect(err).NotTo(HaveOccurred()) + Expect(fileInfo.Mode().Perm()).To(Equal(os.FileMode(0o600))) + }) + + It("fails on malformed content without overwriting it", func() { + Expect(os.MkdirAll(filepath.Dir(path), 0o700)).To(Succeed()) + invalid := []byte(`{"openai":`) + Expect(os.WriteFile(path, invalid, 0o600)).To(Succeed()) + + Expect(credentials.NewVault(path).Set("openai", "replacement")).NotTo(Succeed()) + contents, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(contents).To(Equal(invalid)) + }) + + It("serializes concurrent provider updates", func() { + vault := credentials.NewVault(path) + providers := map[string]string{ + "anthropic": "ant-token", + "openai": "openai-token", + "gemini": "gemini-token", + "deepseek": "deepseek-token", + } + var wg sync.WaitGroup + for provider, token := range providers { + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + Expect(vault.Set(provider, token)).To(Succeed()) + }() + } + wg.Wait() + + values, err := vault.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(values).To(Equal(providers)) + }) + + It("resolves vault before environment and reports the source", func() { + vault := credentials.NewVault(path) + Expect(vault.Set("gemini", "vault-token")).To(Succeed()) + + resolved, err := vault.Resolve("gemini", []string{"GEMINI_API_KEY"}, func(string) string { return "env-token" }) + Expect(err).NotTo(HaveOccurred()) + Expect(resolved).To(Equal(credentials.Resolved{Token: "vault-token", Source: credentials.SourceVault})) + + resolved, err = vault.Resolve("openai", []string{"OPENAI_API_KEY"}, func(string) string { return "env-token" }) + Expect(err).NotTo(HaveOccurred()) + Expect(resolved).To(Equal(credentials.Resolved{Token: "env-token", Source: credentials.SourceEnvironment, Detail: "OPENAI_API_KEY"})) + }) +}) From 28986200b36d6cc7b1f5293489dd0682c9cbf676 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 06:49:46 +0300 Subject: [PATCH 091/131] refactor(ai): model providers as descriptors, not duplicated switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model identity was decided by three parsers that disagreed, so the answer depended on which entry point a model string arrived through: `--model agent:sol` errored while the identical `model: agent:sol` in prompt frontmatter resolved, because the compact grammar in pkg/api could not see the codename aliases in pkg/ai. Add pkg/api/registry, a leaf package owning model identity end to end: Backend, Effort, RuntimeMode, Model, the compact grammar, and the embedded catalog. It imports nothing else from captain on purpose — decoding a Spec parses model strings, so the parser cannot sit above pkg/api without a cycle, and splitting that knowledge across both packages is what let the two grammars drift apart. pkg/api re-exports it by alias, so api.Model and api.Backend are unchanged for every caller. Each provider family is now one descriptor holding its env vars, catalog and pricing prefixes, per-mode capabilities, claim prefixes, and generation config. A Backend is exactly a (provider, mode) pair. Aliases (sol/terra/luna) and superseded ids become catalog data rather than hardcoded switches, which is what lets one parser serve both entry points. Deleted, with all callers repointed: InferBackend's prefix switch, backendForMode, selectorBackend, selectorModelFamily, splitModelProvider, ParseModelIdentity's per-provider switch, wildcardBackends, AgentsForProvider, orPrefix, pricingModelID, adapterInputMediaTypes, modelSourceBackend, normalizeCodexVariantAlias, isSupersededRegistryExact, backendAgentSentinel. Resolved models now carry Mode, Streaming, MediaTypes and Resume/Interrupt/ Steer, so callers can ask what an adapter supports without constructing one. Defects this fixes: - CatalogPrefix (googleai) and PricingPrefix (google) are separate fields. Three hand-written copies of that mapping could drift independently, and a wrong namespace does not error — it misses, and the run reports $0. - The catalog and billing priced the same model differently: the catalog tried the bare id first, which the classify-on-miss static table always answers, while billing tried the prefixed OpenRouter key. Both now use one path. - Error classification reads structure (errors.As, net.Error, HTTP status) before prose. strings.Contains(msg, "429") also matched "1429 tokens" and "id=4290", turning ordinary messages into pointless retries. Context-length is classified and deliberately not retryable: the same request fails the same way. - Wildcard fan-out and AgentsForProvider disagreed on ordering; the user-visible wildcard order wins. - Bare codenames resolve wherever a model name is accepted, instead of only behind a mode prefix. Wire formats are unchanged: the 11 Backend strings, the googleai catalog ids, and spec decoding all behave as before — a spec still keeps `opus` as written, and only resolution maps it to an exact id. The model cache gains a schema version so rows resolved under the old pricing rules are not served from disk. Known gap, recorded rather than hidden: gpt-5.6 has no pricing entry under any key upstream (its sol/terra/luna siblings do), so it reports $0. Documented in knownPricingGaps and asserted, so it fails loudly if that changes. Claude-Session-Id: 38be77f6-ad8a-4c7e-8b98-f4298e889645 --- .../workflows/update-llm-model-registry.yml | 6 +- Taskfile.yaml | 2 +- pkg/ai/adapter_models.go | 15 +- pkg/ai/agent.go | 21 +- pkg/ai/attachment_capabilities.go | 107 +-- pkg/ai/catalog_info.go | 21 +- pkg/ai/catalog_resolve.go | 53 +- pkg/ai/errors.go | 37 +- pkg/ai/generation_config.go | 103 +-- pkg/ai/internal/gen-model-registry/main.go | 8 +- .../internal/gen-model-registry/patches.json | 106 ++- pkg/ai/model_lists.go | 15 +- pkg/ai/model_parse_conformance_test.go | 175 +++++ pkg/ai/model_registry.go | 632 ++++-------------- pkg/ai/model_registry_embed.go | 31 - pkg/ai/model_registry_test.go | 64 -- pkg/ai/pricing_coverage_test.go | 97 +++ pkg/ai/provider/genkit/genkit.go | 87 ++- pkg/ai/provider/genkit/options.go | 200 ++++-- pkg/ai/runtime_selection_ginkgo_test.go | 9 +- pkg/ai/runtime_selector.go | 376 +---------- pkg/ai/runtime_selector_test.go | 112 +++- pkg/api/aliases.go | 66 ++ pkg/api/enums.go | 232 +------ pkg/api/enums_test.go | 78 --- pkg/api/registry/backend.go | 153 +++++ pkg/api/registry/backend_test.go | 87 +++ pkg/api/registry/effort.go | 40 ++ pkg/api/registry/effort_support.go | 54 ++ pkg/api/registry/embed.go | 71 ++ pkg/api/registry/embed_test.go | 81 +++ pkg/api/registry/errors.go | 155 +++++ pkg/api/registry/errors_test.go | 136 ++++ pkg/api/registry/generation.go | 99 +++ pkg/api/registry/identity.go | 464 +++++++++++++ pkg/api/registry/media.go | 97 +++ pkg/api/registry/mode.go | 58 ++ pkg/api/{ => registry}/model.go | 110 ++- pkg/api/{ => registry}/model_compact.go | 128 +--- pkg/api/{ => registry}/model_compact_test.go | 25 +- pkg/api/{ => registry}/model_test.go | 4 +- .../registry/models.json} | 21 +- pkg/api/registry/parse.go | 369 ++++++++++ pkg/api/registry/provider.go | 200 ++++++ pkg/api/registry/providers.go | 137 ++++ pkg/api/registry/supported_models.go | 96 +++ pkg/api/spec_merge.go | 36 +- pkg/api/spec_test.go | 25 + 48 files changed, 3518 insertions(+), 1781 deletions(-) create mode 100644 pkg/ai/model_parse_conformance_test.go delete mode 100644 pkg/ai/model_registry_embed.go create mode 100644 pkg/ai/pricing_coverage_test.go create mode 100644 pkg/api/aliases.go create mode 100644 pkg/api/registry/backend.go create mode 100644 pkg/api/registry/backend_test.go create mode 100644 pkg/api/registry/effort.go create mode 100644 pkg/api/registry/effort_support.go create mode 100644 pkg/api/registry/embed.go create mode 100644 pkg/api/registry/embed_test.go create mode 100644 pkg/api/registry/errors.go create mode 100644 pkg/api/registry/errors_test.go create mode 100644 pkg/api/registry/generation.go create mode 100644 pkg/api/registry/identity.go create mode 100644 pkg/api/registry/media.go create mode 100644 pkg/api/registry/mode.go rename pkg/api/{ => registry}/model.go (59%) rename pkg/api/{ => registry}/model_compact.go (58%) rename pkg/api/{ => registry}/model_compact_test.go (81%) rename pkg/api/{ => registry}/model_test.go (98%) rename pkg/{ai/model_registry.json => api/registry/models.json} (98%) create mode 100644 pkg/api/registry/parse.go create mode 100644 pkg/api/registry/provider.go create mode 100644 pkg/api/registry/providers.go create mode 100644 pkg/api/registry/supported_models.go diff --git a/.github/workflows/update-llm-model-registry.yml b/.github/workflows/update-llm-model-registry.yml index 89d0d0d..22fa99d 100644 --- a/.github/workflows/update-llm-model-registry.yml +++ b/.github/workflows/update-llm-model-registry.yml @@ -37,10 +37,10 @@ jobs: - name: Check for registry changes id: changes run: | - if git diff --quiet -- pkg/ai/model_registry.json; then + if git diff --quiet -- pkg/api/registry/models.json; then echo "changed=false" >> "$GITHUB_OUTPUT" else - git diff --stat -- pkg/ai/model_registry.json + git diff --stat -- pkg/api/registry/models.json echo "changed=true" >> "$GITHUB_OUTPUT" fi @@ -50,7 +50,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -B "$PR_BRANCH" - git add pkg/ai/model_registry.json + git add pkg/api/registry/models.json git commit -m "chore: update llm model registry" git push --force-with-lease origin "$PR_BRANCH" diff --git a/Taskfile.yaml b/Taskfile.yaml index 05c573a..13f1d21 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -85,7 +85,7 @@ tasks: models:update: desc: Regenerate the embedded LLM model registry from models.dev + local patches cmds: - - go run ./pkg/ai/internal/gen-model-registry --patches pkg/ai/internal/gen-model-registry/patches.json --output pkg/ai/model_registry.json + - go run ./pkg/ai/internal/gen-model-registry --patches pkg/ai/internal/gen-model-registry/patches.json --output pkg/api/registry/models.json clean: desc: Remove build artifacts diff --git a/pkg/ai/adapter_models.go b/pkg/ai/adapter_models.go index 5084b70..06c0e3b 100644 --- a/pkg/ai/adapter_models.go +++ b/pkg/ai/adapter_models.go @@ -174,19 +174,10 @@ func setRegistryModels(st *AdapterStatus, backend Backend, discoveryErr error) { st.ModelError = fmt.Sprintf("registry has no models for %s", backend) } +// modelSourceBackend maps any backend onto the API backend whose model list it +// draws from: a CLI/agent/cmux adapter serves its provider family's models. func modelSourceBackend(backend Backend) Backend { - switch backend { - case BackendAnthropic, BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: - return BackendAnthropic - case BackendOpenAI, BackendCodexAgent, BackendCodexCLI, BackendCodexCmux: - return BackendOpenAI - case BackendGemini, BackendGeminiCLI: - return BackendGemini - case BackendDeepSeek: - return BackendDeepSeek - default: - return "" - } + return backend.Provider() } func modelsForAdapterBackend(backend Backend, models []ModelDef) []ModelDef { diff --git a/pkg/ai/agent.go b/pkg/ai/agent.go index 0e10380..7a30af2 100644 --- a/pkg/ai/agent.go +++ b/pkg/ai/agent.go @@ -10,6 +10,7 @@ import ( "github.com/flanksource/captain/pkg/ai/pricing" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" ) // Agent is a convenience wrapper over a Provider that offers the named-prompt / @@ -238,23 +239,11 @@ func PriceResponse(backend Backend, model string, resp *Response) Cost { // are still OpenAI/Anthropic/Google under the hood); the bare model is included // as a fallback. func PricingIDs(backend Backend, model string) []string { - prefix := map[Backend]string{ - BackendAnthropic: "anthropic/", - BackendClaudeCLI: "anthropic/", - BackendClaudeAgent: "anthropic/", - BackendClaudeCmux: "anthropic/", - BackendOpenAI: "openai/", - BackendCodexCLI: "openai/", - BackendCodexAgent: "openai/", - BackendCodexCmux: "openai/", - BackendGemini: "google/", - BackendGeminiCLI: "google/", - BackendDeepSeek: "deepseek/", - }[backend] - if prefix != "" { - return []string{prefix + model, model} + p, _, ok := registry.ProviderFor(backend) + if !ok { + return []string{model} } - return []string{model} + return p.PricingIDs(model) } func (r *PromptResponse) errorValue() error { diff --git a/pkg/ai/attachment_capabilities.go b/pkg/ai/attachment_capabilities.go index 5cd8810..0391b94 100644 --- a/pkg/ai/attachment_capabilities.go +++ b/pkg/ai/attachment_capabilities.go @@ -2,28 +2,33 @@ package ai import ( "fmt" - "slices" - "strings" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" ) -var adapterInputMediaTypes = map[api.Backend][]string{ - api.BackendGemini: {"image/*", "audio/*", "video/*", "application/pdf"}, - api.BackendAnthropic: {"image/*"}, - api.BackendOpenAI: {"image/*"}, - api.BackendDeepSeek: {}, - api.BackendCodexAgent: {"image/*"}, - api.BackendCodexCLI: {"image/*"}, - api.BackendClaudeAgent: {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}, - api.BackendClaudeCLI: {}, - api.BackendGeminiCLI: {}, - api.BackendClaudeCmux: {}, - api.BackendCodexCmux: {}, -} +// Which attachment types an adapter can carry is a per-mode provider capability +// (registry.ModeCapabilities.MediaTypes), not a table maintained here. +// AdapterInputMediaTypes returns the attachment types a backend can carry. func AdapterInputMediaTypes(backend api.Backend) []string { - return append([]string{}, adapterInputMediaTypes[backend]...) + p, mode, ok := registry.ProviderFor(backend) + if !ok { + return []string{} + } + return p.AdapterMediaTypes(mode) +} + +func modelInputMediaTypes(backend api.Backend, model string) []string { + p, mode, ok := registry.ProviderFor(backend) + if !ok { + return []string{} + } + return p.MediaTypesFor(mode, model) +} + +func clampInputMediaTypes(backend api.Backend, modelTypes []string) []string { + return registry.ClampMediaTypes(AdapterInputMediaTypes(backend), modelTypes) } func ValidateAttachmentCompatibility(models []api.Model, refs []api.AttachmentRef) error { @@ -37,78 +42,10 @@ func ValidateAttachmentCompatibility(models []api.Model, refs []api.AttachmentRe } accepted := modelInputMediaTypes(backend, model.Name) for _, ref := range refs { - if !mediaTypeAccepted(accepted, ref.MediaType) { + if !registry.MediaTypeAccepted(accepted, ref.MediaType) { return fmt.Errorf("%s:%s does not accept %s attachments", backend, model.Name, ref.MediaType) } } } return nil } - -func modelInputMediaTypes(backend api.Backend, model string) []string { - if def, ok := RegistryModelDef(backend, model); ok { - return def.InputMediaTypes - } - return AdapterInputMediaTypes(backend) -} - -func clampInputMediaTypes(backend api.Backend, modelTypes []string) []string { - adapterTypes := AdapterInputMediaTypes(backend) - if len(adapterTypes) == 0 || len(modelTypes) == 0 { - return []string{} - } - out := make([]string, 0, len(modelTypes)) - for _, modelType := range modelTypes { - modelType = normalizeInputMediaType(modelType) - if modelType == "" { - continue - } - for _, adapterType := range adapterTypes { - mediaType, ok := intersectMediaTypes(modelType, adapterType) - if ok && !slices.Contains(out, mediaType) { - out = append(out, mediaType) - } - } - } - return out -} - -func intersectMediaTypes(modelType, adapterType string) (string, bool) { - if modelType == adapterType { - return modelType, true - } - if strings.HasSuffix(modelType, "/*") && mediaTypeAccepted([]string{modelType}, adapterType) { - return adapterType, true - } - if strings.HasSuffix(adapterType, "/*") && mediaTypeAccepted([]string{adapterType}, modelType) { - return modelType, true - } - return "", false -} - -func normalizeInputMediaType(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "image", "image/*": - return "image/*" - case "audio", "audio/*": - return "audio/*" - case "video", "video/*": - return "video/*" - case "pdf", "application/pdf": - return "application/pdf" - case "text", "text/*": - return "" - default: - return strings.ToLower(strings.TrimSpace(value)) - } -} - -func mediaTypeAccepted(accepted []string, mediaType string) bool { - mediaType = strings.ToLower(strings.TrimSpace(mediaType)) - for _, pattern := range accepted { - if pattern == mediaType || strings.HasSuffix(pattern, "/*") && strings.HasPrefix(mediaType, strings.TrimSuffix(pattern, "*")) { - return true - } - } - return false -} diff --git a/pkg/ai/catalog_info.go b/pkg/ai/catalog_info.go index 81702a1..7756503 100644 --- a/pkg/ai/catalog_info.go +++ b/pkg/ai/catalog_info.go @@ -3,6 +3,8 @@ package ai import ( "os/exec" "slices" + + "github.com/flanksource/captain/pkg/api/registry" ) // ModelInfo is the JSON shape served at GET /api/chat/models so a client model @@ -23,19 +25,18 @@ type ModelInfo struct { // 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. +// BackendToProvider is the provider key the webapp and the session store use. +// +// Its output is a frozen wire format: it is persisted to sessions.provider and +// prompt_runs.runtime.resolved.provider, and /api/chat/models matches on it. The +// API backends map to their catalog namespace ("googleai" for Gemini, not +// "google"); every other backend is its own key verbatim. func BackendToProvider(b Backend) string { - switch b { - case BackendAnthropic: - return "anthropic" - case BackendOpenAI: - return "openai" - case BackendGemini: - return "googleai" - case BackendDeepSeek: - return "deepseek" - default: + p, mode, ok := registry.ProviderFor(b) + if !ok || mode != registry.ModeAPI { return string(b) } + return p.CatalogPrefix } // CatalogInfo returns the static model menu annotated with selectability. API diff --git a/pkg/ai/catalog_resolve.go b/pkg/ai/catalog_resolve.go index 9db5a32..9ab7abc 100644 --- a/pkg/ai/catalog_resolve.go +++ b/pkg/ai/catalog_resolve.go @@ -206,6 +206,17 @@ func filterResolved(rows []ResolvedModel, filter string) []ResolvedModel { return out } +// resolveSchemaVersion invalidates the on-disk model cache when the meaning of a +// cached row changes rather than its inputs. The fingerprint otherwise covers +// only options and API keys, so a resolution change would keep serving rows +// resolved by the old rules until the TTL expired. +// +// Bump this when catalog ids, capability fields, or pricing resolution change. +// - v2: model identity unified on the provider descriptors; catalog pricing now +// resolves through the same prefixed-first path as billing, so cached Claude +// prices from the static fallback table are stale. +const resolveSchemaVersion = "v2" + func resolveFingerprint(opts ResolveOptions) (string, error) { var present []string for _, b := range apiBackends { @@ -219,7 +230,7 @@ func resolveFingerprint(opts ResolveOptions) (string, error) { } } sort.Strings(present) - return fmt.Sprintf("b=%s|tok=%v|keys=%s", opts.Backend, opts.UseTokens, strings.Join(present, ",")), nil + return fmt.Sprintf("v=%s|b=%s|tok=%v|keys=%s", resolveSchemaVersion, opts.Backend, opts.UseTokens, strings.Join(present, ",")), nil } func bareModelID(id string) string { @@ -273,34 +284,20 @@ func AgentCatalogModels(b Backend) []ModelDef { } // lookupPricing tries the bare model id first, then the OpenRouter -// "provider/model" key the registry uses for the major API providers. +// "provider/model" key the registry uses. +// +// The prefix comes from the provider descriptor's PricingPrefix, which is +// separate from CatalogPrefix for exactly one reason: Gemini's catalog namespace +// is "googleai" while OpenRouter keys it under "google". Deriving one from the +// other makes every Gemini price silently resolve to nothing. This used to be +// three hand-written maps (PricingIDs, orPrefix, pricingModelID) that disagreed +// about whether CLI/agent backends get a prefix at all — they do; a codex-run +// model costs what the model costs. 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 + for _, candidate := range PricingIDs(backend, id) { + if info, ok := pricing.GetModelInfo(candidate); 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" - case BackendDeepSeek: - return "deepseek" - } - return "" -} diff --git a/pkg/ai/errors.go b/pkg/ai/errors.go index cd040e9..2c6cfa3 100644 --- a/pkg/ai/errors.go +++ b/pkg/ai/errors.go @@ -3,6 +3,8 @@ package ai import ( "errors" "strings" + + "github.com/flanksource/captain/pkg/api/registry" ) var ( @@ -16,11 +18,23 @@ var ( ErrNoAPIKey = errors.New("API key not found") ) +// ClassifyError normalizes a provider failure into an ErrorClass. Structured +// signals (wrapped sentinels, net.Error, HTTP status) are read before prose. +func ClassifyError(err error) registry.ErrorClass { + return registry.ClassifyError(err) +} + // IsRetryable reports whether err is a transient/overload failure worth retrying // on the same model (see middleware.WithRetry) or falling back to another model -// (see the fallback provider). Provider errors are unstructured strings, so it -// pattern-matches the well-known overload/rate-limit signals; ErrTimeout is -// matched by identity as well. +// (see the fallback provider). +// +// Classification is shared (registry.ClassifyError) so every caller agrees on +// what "transient" means. It reads structure — wrapped sentinels, net.Error, an +// HTTP status — before falling back to bounded text matching. The previous +// implementation matched `strings.Contains(msg, "429")` against raw prose, so +// "1429 tokens" or "id=4290" read as a rate limit and triggered a pointless +// retry. Note a context-length failure is deliberately NOT retryable: the same +// request fails the same way, so retrying only burns tokens. func IsRetryable(err error) bool { if err == nil { return false @@ -28,12 +42,7 @@ func IsRetryable(err error) bool { if errors.Is(err, ErrTimeout) { return true } - msg := err.Error() - return strings.Contains(msg, "rate limit") || - strings.Contains(msg, "429") || - strings.Contains(msg, "503") || - strings.Contains(msg, "overloaded") || - strings.Contains(msg, "timeout") + return registry.ClassifyError(err).Retryable() } // IsModelUnavailable reports provider-confirmed model selection failures. It is @@ -84,6 +93,9 @@ func IsModelUnavailable(err error) bool { // IsMissingAPIKey reports missing credentials without treating rejected or // invalid credentials as recoverable. Cross-backend fallbacks can therefore // skip an unconfigured API provider without hiding a bad key. +// +// It stays narrower than ErrorAuth on purpose: a rejected key is an auth failure +// but is not "unconfigured", and skipping past it would hide a real problem. func IsMissingAPIKey(err error) bool { if err == nil { return false @@ -105,6 +117,13 @@ func IsMissingAPIKey(err error) bool { return false } +// IsContextLengthExceeded reports a request that overflowed the model's context +// window. It is intentionally excluded from IsRetryable and IsFallbackEligible's +// transient set: retrying an oversized request reproduces the failure exactly. +func IsContextLengthExceeded(err error) bool { + return registry.ClassifyError(err) == registry.ErrorContextLength +} + // IsFallbackEligible reports failures for which trying a different explicitly // configured model may help. This includes transient provider failures, model // availability mismatches, missing credentials, and a missing local CLI. diff --git a/pkg/ai/generation_config.go b/pkg/ai/generation_config.go index 3b36a32..6b83e47 100644 --- a/pkg/ai/generation_config.go +++ b/pkg/ai/generation_config.go @@ -1,102 +1,17 @@ package ai -// defaultMaxOutputTokens is the visible-answer budget Anthropic requires on every -// request; the extended-thinking budget is added on top of it. -const defaultMaxOutputTokens = 4096 +import "github.com/flanksource/captain/pkg/api/registry" -// EffortConfig builds the provider-native generation config for one request, -// translating the reasoning effort and temperature into each backend's controls -// and gating them by the model's capabilities (resolved from the registry): +// EffortConfig builds the provider-native generation config for one request. +// The per-provider translation lives on the provider descriptor +// (registry.Provider.GenerationConfig), so it sits next to the capability data +// that gates it rather than in a backend switch here. // -// - effort is dropped for models that do not support reasoning; -// - Anthropic reasoning uses the adaptive schema (thinking:{type:adaptive} + -// output_config.effort) for adaptive models and the legacy enabled schema -// (thinking:{type:enabled, budget_tokens}) otherwise; -// - temperature is dropped for models that do not support it. -// -// It is the single source of truth for this translation, shared by the in-process -// genkit provider and clicky/aichat. Returns nil when there is nothing to send. +// Returns nil when there is nothing to send. func EffortConfig(backend Backend, model string, effort Effort, maxTokens int, temperature *float64) map[string]any { - caps := modelCapabilities(backend, model) - cfg := map[string]any{} - if temperature != nil && caps.Temperature { - cfg["temperature"] = *temperature - } - switch backend { - case BackendOpenAI, BackendCodexAgent, BackendCodexCLI, BackendCodexCmux: - if caps.Reasoning && effort != EffortNone { - cfg["reasoning_effort"] = openaiReasoningEffort(effort) - } - case BackendGemini, BackendGeminiCLI: - if caps.Reasoning && effort != EffortNone { - cfg["thinkingConfig"] = map[string]any{"thinkingBudget": thinkingBudget(effort)} - } - case BackendDeepSeek: - // DeepSeek selects reasoning by model id (deepseek-reasoner vs deepseek-chat), - // not a per-request effort knob, so there is no effort config to send. - case BackendAnthropic, BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: - // Anthropic requires max_tokens on every request. The thinking budget is - // counted inside max_tokens, so reserve room for the visible answer on top - // of it — but only when thinking is actually sent. - base := maxTokens - if base <= 0 { - base = defaultMaxOutputTokens - } - if caps.Reasoning && effort != EffortNone { - budget := thinkingBudget(effort) - cfg["max_tokens"] = base + budget - if caps.AdaptiveThinking { - cfg["thinking"] = map[string]any{"type": "adaptive"} - cfg["output_config"] = map[string]any{"effort": string(effort)} - } else { - cfg["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget} - } - } else { - cfg["max_tokens"] = base - } - } - if len(cfg) == 0 { - return nil - } - return cfg -} - -// modelCapabilities resolves a backend + model token to its registry entry so -// its capability flags can gate the generation config. Aliases and dated -// variants resolve to their canonical entry; an unresolved model reports no -// capabilities (zero value). -func modelCapabilities(backend Backend, model string) registryModel { - provider := registryProviderForBackend(backend) - if provider == "" { - return registryModel{} - } - exact, ok := ResolveExactModelForBackend(backend, model) + p, mode, ok := registry.ProviderFor(backend) if !ok { - return registryModel{} - } - m, _ := lookupRegistryExact(provider, exact) - return m -} - -// thinkingBudget maps a reasoning effort to an extended-thinking token budget -// (shared by Anthropic enabled-thinking and Gemini). xhigh is captain's top tier. -func thinkingBudget(e Effort) int { - switch e { - case EffortLow: - return 2048 - case EffortMedium: - return 8192 - case EffortHigh: - return 24576 - case EffortXHigh, EffortMax, EffortUltra: - return 32768 - default: - return 0 + return nil } -} - -// openaiReasoningEffort passes Captain's model-validated OpenAI effort through -// unchanged. Unsupported model/tier combinations fail before provider execution. -func openaiReasoningEffort(e Effort) string { - return string(e) + return p.GenerationConfig(mode, model, effort, maxTokens, temperature) } diff --git a/pkg/ai/internal/gen-model-registry/main.go b/pkg/ai/internal/gen-model-registry/main.go index dd0b247..4cee5ff 100644 --- a/pkg/ai/internal/gen-model-registry/main.go +++ b/pkg/ai/internal/gen-model-registry/main.go @@ -69,6 +69,12 @@ type generatedModel struct { SupportedEfforts []string `json:"supportedEfforts,omitempty"` DefaultEffort string `json:"defaultEffort,omitempty"` Priority int `json:"priority,omitempty"` + + // Aliases and SupersededBy are patch-only: models.dev has no concept of + // either. They carry the codename and retired-id knowledge that used to be + // hardcoded in pkg/ai, where the spec decoder could not see it. + Aliases []string `json:"aliases,omitempty"` + SupersededBy string `json:"supersededBy,omitempty"` } func main() { @@ -82,7 +88,7 @@ func run() error { var output string var source string var patchPath string - flag.StringVar(&output, "output", "", "Path to write pkg/ai/model_registry.json") + flag.StringVar(&output, "output", "", "Path to write pkg/api/registry/models.json") flag.StringVar(&source, "source", modelsDevAPIURL, "models.dev API URL, local file path, or - for stdin") flag.StringVar(&patchPath, "patches", "", "Path to the per-model JSON merge-patch file") flag.Parse() diff --git a/pkg/ai/internal/gen-model-registry/patches.json b/pkg/ai/internal/gen-model-registry/patches.json index 5ffbee0..f2f5e2b 100644 --- a/pkg/ai/internal/gen-model-registry/patches.json +++ b/pkg/ai/internal/gen-model-registry/patches.json @@ -1,36 +1,98 @@ { - "claude-sonnet-5": { "preferred": true }, - "claude-fable-5": { "preferred": true }, - "claude-opus-4-8": { "preferred": true }, - "claude-sonnet-4-6": { "preferred": true }, - "claude-haiku-4-5": { "preferred": true }, + "claude-sonnet-5": { + "preferred": true + }, + "claude-fable-5": { + "preferred": true + }, + "claude-opus-4-8": { + "preferred": true + }, + "claude-sonnet-4-6": { + "preferred": false + }, + "claude-haiku-4-5": { + "preferred": true + }, "gpt-5.6": { "preferred": true, - "availability": ["api"] + "availability": [ + "api" + ] }, "gpt-5.6-sol": { "preferred": true, - "availability": ["api", "codex"], - "supportedEfforts": ["low", "medium", "high", "xhigh", "max", "ultra"], - "priority": 1 + "availability": [ + "api", + "codex" + ], + "supportedEfforts": [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra" + ], + "priority": 1, + "aliases": [ + "sol" + ] }, "gpt-5.6-terra": { "preferred": true, - "availability": ["api", "codex"], - "supportedEfforts": ["low", "medium", "high", "xhigh", "max", "ultra"], - "priority": 2 + "availability": [ + "api", + "codex" + ], + "supportedEfforts": [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra" + ], + "priority": 2, + "aliases": [ + "terra" + ] }, "gpt-5.6-luna": { "preferred": true, - "availability": ["api", "codex"], - "priority": 3 - }, - "gpt-5.5": { "preferred": true }, - "gemini-3.5-flash": { "preferred": true }, - "gemini-2.5-pro": { "preferred": true }, - "gemini-2.5-flash": { "preferred": true }, - "deepseek-v4-pro": { "preferred": true }, - "deepseek-v4-flash": { "preferred": true }, + "availability": [ + "api", + "codex" + ], + "priority": 3, + "aliases": [ + "luna" + ] + }, + "gpt-5.5": { + "preferred": false + }, + "gemini-3.5-flash": { + "preferred": true + }, + "gemini-2.5-pro": { + "preferred": false + }, + "gemini-2.5-flash": { + "preferred": false + }, + "deepseek-v4-pro": { + "preferred": true + }, + "deepseek-v4-flash": { + "preferred": true + }, "deepseek-reasoner": null, - "deepseek-chat": null + "deepseek-chat": null, + "claude-sonnet-4-5": { + "supersededBy": "claude-sonnet-4-6" + }, + "claude-sonnet-4-5-20250929": { + "supersededBy": "claude-sonnet-4-6" + } } diff --git a/pkg/ai/model_lists.go b/pkg/ai/model_lists.go index ffe322a..b62cdbd 100644 --- a/pkg/ai/model_lists.go +++ b/pkg/ai/model_lists.go @@ -4,6 +4,8 @@ import ( "sort" "strings" "time" + + "github.com/flanksource/captain/pkg/api/registry" ) const currentModelsPerFamily = 3 @@ -128,9 +130,16 @@ func IsLegacyModelIDForBackend(id string, backend Backend) bool { } func isPreferredRegistryModelForBackend(backend Backend, model string) bool { - provider := registryProviderForBackend(backend) - entry, ok := lookupRegistryExact(provider, normalizeCodexVariantAlias(model)) - return ok && entry.Preferred && registryModelAvailableForBackend(entry, backend) + p, mode, ok := registry.ProviderFor(backend) + if !ok { + return false + } + entry, found := p.Lookup(model) + if !found { + return false + } + _, available := p.Availability(mode, entry.ID) + return entry.Preferred && available } // CurrentModelsByReleaseDate returns a filtered copy sorted newest first, diff --git a/pkg/ai/model_parse_conformance_test.go b/pkg/ai/model_parse_conformance_test.go new file mode 100644 index 0000000..94330ca --- /dev/null +++ b/pkg/ai/model_parse_conformance_test.go @@ -0,0 +1,175 @@ +package ai + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/api" +) + +// The parse conformance suite is the single behavioural contract for turning a +// user-written model string into a concrete model/backend/effort. It pins the +// two entry points that reach the parser: +// +// - specPath — `captain ai --model X` and spec/`.captain.yaml` decoding: +// api.Model.Expand (the compact grammar) then ResolveModelSelectors. +// See pkg/cli/provider_defaults.go and pkg/cli/ai.go. +// - frontmatterPath — prompt frontmatter: api.Model.ExpandCSV (no colon +// grammar at all) then ResolveModelSelectors. +// See pkg/cli/prompt_render.go. +// +// These two ran different parsers over the same syntax and disagreed about which +// models existed. They now share one parser (pkg/api/registry), so every case +// below asserts both paths agree — that agreement is the contract. + +func specPath(s string) (api.Model, error) { + m, err := api.Model{Name: s}.Expand() + if err != nil { + return api.Model{}, err + } + return ResolveModelSelectors(m) +} + +func frontmatterPath(s string) (api.Model, error) { + return ResolveModelSelectors(api.Model{Name: s}.ExpandCSV()) +} + +// parseCase is one model string and the model it must resolve to, on both entry +// points. wantErr instead asserts both fail with a message containing it. +type parseCase struct { + in string + want api.Model + wantErr string +} + +func model(name string, backend api.Backend, effort api.Effort) api.Model { + return api.Model{Name: name, Backend: backend, Effort: effort} +} + +// agreedCases are inputs both entry points already resolve identically. These +// are pure regression pins: the unified parser must not change any of them. +var agreedCases = []parseCase{ + // Family aliases resolve to the exact current registry model. + {in: "sonnet", want: model("claude-sonnet-5", api.BackendAnthropic, "")}, + {in: "opus", want: model("claude-opus-4-8", api.BackendAnthropic, "")}, + {in: "fable", want: model("claude-fable-5", api.BackendAnthropic, "")}, + + // A superseded exact id is rewritten to its successor. + {in: "claude-sonnet-4-5", want: model("claude-sonnet-4-6", api.BackendAnthropic, "")}, + + // The catalog prefix is stripped; "models/" likewise. + {in: "anthropic/claude-sonnet-5", want: model("claude-sonnet-5", api.BackendAnthropic, "")}, + {in: "googleai/gemini-3.5-flash", want: model("gemini-3.5-flash", api.BackendGemini, "")}, + {in: "models/gemini-3.5-flash", want: model("gemini-3.5-flash", api.BackendGemini, "")}, + + // Mode prefix and effort suffix. + {in: "cli:sonnet:high", want: model("claude-sonnet-5", api.BackendClaudeCLI, api.EffortHigh)}, + {in: "agent:sonnet", want: model("claude-sonnet-5", api.BackendClaudeAgent, "")}, + + // Codex codenames are catalog aliases, so they resolve wherever a model name + // is accepted. `--model agent:sol` used to error while the identical value in + // prompt frontmatter ran: the compact grammar could not see pkg/ai's alias + // table. This is the divergence the single parser exists to remove. + {in: "agent:sol", want: model("gpt-5.6-sol", api.BackendCodexAgent, "")}, + {in: "agent:sol:high", want: model("gpt-5.6-sol", api.BackendCodexAgent, api.EffortHigh)}, + {in: "api:sol", want: model("gpt-5.6-sol", api.BackendOpenAI, "")}, + // A bare codename resolves too. It used to fail on both paths only because + // the bare path went through an alias-blind claim — an accident of which + // parser ran, not a decision. + {in: "sol", want: model("gpt-5.6-sol", api.BackendOpenAI, "")}, + {in: "terra", want: model("gpt-5.6-terra", api.BackendOpenAI, "")}, + {in: "luna", want: model("gpt-5.6-luna", api.BackendOpenAI, "")}, + + // Bare CLI sentinels are asymmetric and must stay that way: "codex" resolves + // to the latest codex model on the CLI, "claude" stays a literal sentinel on + // the API backend. + {in: "codex", want: model("gpt-5.6-sol", api.BackendCodexCLI, "")}, + {in: "claude", want: model("claude", api.BackendAnthropic, "")}, + + // grok is served through the codex CLI and passes through verbatim. + {in: "grok-2", want: model("grok-2", api.BackendCodexCLI, "")}, + {in: "grok-code-fast-1", want: model("grok-code-fast-1", api.BackendCodexCLI, "")}, + + // A multi-slash id resolves off its LAST segment and keeps its name verbatim, + // so OpenRouter-style proxied names survive. (api.InferBackend alone cannot do + // this; inferModelBackend's last-slash retry is what makes it work.) + {in: "openrouter/anthropic/claude-x", want: model("openrouter/anthropic/claude-x", api.BackendAnthropic, "")}, + + {in: "gemini-3.5-flash", want: model("gemini-3.5-flash", api.BackendGemini, "")}, + {in: "deepseek-chat", want: model("deepseek-chat", api.BackendDeepSeek, "")}, + {in: "gpt-5.6", want: model("gpt-5.6", api.BackendOpenAI, "")}, + {in: "o3", want: model("o3", api.BackendOpenAI, "")}, + + // Unknown models fail loud on both paths, telling the user how to recover. + {in: "totally-unknown", wantErr: "pass an explicit backend"}, + + // sora is a video model captain cannot run. It is claimed by no provider, so + // it fails loud rather than resolving to something unusable. + {in: "sora", wantErr: "pass an explicit backend"}, + {in: "sora-2", wantErr: "pass an explicit backend"}, +} + +func expectModel(got api.Model, want api.Model) { + GinkgoHelper() + Expect(got.Name).To(Equal(want.Name), "model name") + Expect(got.Backend).To(Equal(want.Backend), "backend") + Expect(got.Effort).To(Equal(want.Effort), "effort") +} + +var _ = Describe("model parse conformance", func() { + Describe("inputs both entry points agree on", func() { + for _, tc := range agreedCases { + tc := tc + label := fmt.Sprintf("%q", tc.in) + if tc.wantErr != "" { + It(label+" fails loud on both paths", func() { + _, specErr := specPath(tc.in) + _, fmErr := frontmatterPath(tc.in) + Expect(specErr).To(MatchError(ContainSubstring(tc.wantErr))) + Expect(fmErr).To(MatchError(ContainSubstring(tc.wantErr))) + }) + continue + } + It(label+" resolves identically on both paths", func() { + got, err := specPath(tc.in) + Expect(err).NotTo(HaveOccurred()) + expectModel(got, tc.want) + + got, err = frontmatterPath(tc.in) + Expect(err).NotTo(HaveOccurred()) + expectModel(got, tc.want) + }) + } + }) + + Describe("comma-separated fallback chains", func() { + It("keeps the primary first and resolves each element independently", func() { + got, err := specPath("sonnet,cli:opus:high") + Expect(err).NotTo(HaveOccurred()) + expectModel(got, model("claude-sonnet-5", api.BackendAnthropic, "")) + Expect(got.Fallbacks).To(HaveLen(1)) + expectModel(got.Fallbacks[0], model("claude-opus-4-8", api.BackendClaudeCLI, api.EffortHigh)) + }) + }) + + Describe("wildcard selectors", func() { + It("is rejected for a single model", func() { + _, err := frontmatterPath("*:fable") + Expect(err).To(MatchError(ContainSubstring("only valid for --multi-models"))) + }) + + It("fans out to every backend of the claimed family", func() { + models, err := ResolveRuntimeSelectors([]string{"*:fable"}, api.Model{Name: "sonnet"}) + Expect(err).NotTo(HaveOccurred()) + backends := make([]api.Backend, 0, len(models)) + for _, m := range models { + Expect(m.Name).To(Equal("claude-fable-5")) + backends = append(backends, m.Backend) + } + Expect(backends).To(ContainElement(api.BackendAnthropic)) + Expect(backends).To(ContainElement(api.BackendClaudeAgent)) + }) + }) +}) diff --git a/pkg/ai/model_registry.go b/pkg/ai/model_registry.go index 044d856..d3ce302 100644 --- a/pkg/ai/model_registry.go +++ b/pkg/ai/model_registry.go @@ -1,124 +1,74 @@ package ai import ( - "sort" "strings" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" ) -// ModelIdentity is Captain's parsed model key. It is intentionally provider -// native: aliases and backend prefixes are input conveniences only, never the -// value sent to a backend. -type ModelIdentity struct { - Provider string - Family string - Version string -} - -type registryModel struct { - ID string `json:"id"` - Provider string `json:"provider"` - Family string `json:"family"` - Version string `json:"version"` - Label string `json:"label"` - ReleaseDate string `json:"releaseDate,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` - Temperature bool `json:"temperature,omitempty"` - ContextWindow int `json:"contextWindow,omitempty"` - InputMediaTypes []string `json:"inputMediaTypes,omitempty"` - Preferred bool `json:"preferred,omitempty"` - AdaptiveThinking bool `json:"adaptiveThinking,omitempty"` - Availability []string `json:"availability,omitempty"` - SupportedEfforts []api.Effort `json:"supportedEfforts,omitempty"` - DefaultEffort api.Effort `json:"defaultEffort,omitempty"` - Priority int `json:"priority,omitempty"` -} +// Model identity — claiming a name for a provider, resolving aliases and +// superseded ids, and picking the exact catalog id — lives in pkg/api/registry. +// This file is only the projection of those rows onto pkg/ai's catalog types. +// +// It used to own a second copy of that knowledge (splitModelProvider, +// ParseModelIdentity, normalizeCodexVariantAlias, isSupersededRegistryExact), +// which disagreed with pkg/api's InferBackend about grok, sora, and codenames. -const ( - modelProviderAnthropic = "anthropic" - modelProviderOpenAI = "openai" - modelProviderGoogle = "google" - modelProviderDeepSeek = "deepseek" -) +// ModelIdentity is captain's parsed model key. +type ModelIdentity = registry.ModelIdentity -func registryProviderForBackend(backend api.Backend) string { - switch backend { - case api.BackendAnthropic, api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux: - return modelProviderAnthropic - case api.BackendOpenAI, api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux: - return modelProviderOpenAI - case api.BackendGemini, api.BackendGeminiCLI: - return modelProviderGoogle - case api.BackendDeepSeek: - return modelProviderDeepSeek - default: - return "" - } -} - -func registryBackendForProvider(provider string) Backend { - switch provider { - case modelProviderAnthropic: - return BackendAnthropic - case modelProviderOpenAI: - return BackendOpenAI - case modelProviderGoogle: - return BackendGemini - case modelProviderDeepSeek: - return BackendDeepSeek - default: - return "" - } -} - -func registryProviderPrefix(provider string) string { - switch provider { - case modelProviderGoogle: - return "googleai" - default: - return provider - } -} +// defaultCatalogModelID is the bare id behind DefaultModelID — the one catalog +// row that carries Default: true. +var defaultCatalogModelID = registry.StripProviderPrefix(DefaultModelID) -func registryModelAvailableForBackend(model registryModel, backend Backend) bool { - if len(model.Availability) == 0 { - return true - } - target := "api" - if backend == BackendCodexAgent || backend == BackendCodexCLI || backend == BackendCodexCmux { - target = "codex" - } - for _, available := range model.Availability { - if strings.EqualFold(strings.TrimSpace(available), target) { - return true +// ParseModelIdentity parses a model token into its provider/family/version tuple. +// The token's own provider wins; defaultProvider only decides tokens that claim +// no family of their own. +func ParseModelIdentity(defaultProvider, model string) (ModelIdentity, bool) { + p, token, _, ok := registry.ProviderForToken(model) + if !ok { + if p, ok = registry.ProviderByName(defaultProvider); !ok { + return ModelIdentity{}, false } + token = model } - return false + return p.ParseIdentity(token) } -func registryModelDef(model registryModel, backend Backend) ModelDef { +func registryModelDef(m registry.KnownModel, backend Backend) ModelDef { return ModelDef{ - ID: model.ID, - Name: model.Label, + ID: m.ID, + Name: m.Label, Backend: backend, - ReleaseDate: model.ReleaseDate, + ReleaseDate: m.ReleaseDate, CapabilitiesKnown: true, - Reasoning: model.Reasoning, - Temperature: model.Temperature, - InputMediaTypes: clampInputMediaTypes(backend, model.InputMediaTypes), - SupportedEfforts: append([]api.Effort(nil), model.SupportedEfforts...), - DefaultEffort: model.DefaultEffort, - Priority: model.Priority, + Reasoning: m.Reasoning, + Temperature: m.Temperature, + InputMediaTypes: clampInputMediaTypes(backend, m.InputMediaTypes), + SupportedEfforts: append([]api.Effort(nil), m.SupportedEfforts...), + DefaultEffort: m.DefaultEffort, + Priority: m.Priority, } } // RegistryModelDef returns the registry metadata for an exact model on a // backend. The boolean is false when the model is known but unavailable there. +// +// The lookup is exact (aliases aside) and deliberately does NOT resolve version +// lines: "gpt-5.6" is an API-only base model, and resolving it here would answer +// with its codex-available sibling gpt-5.6-sol and report the base as available +// on Codex. Callers that want resolution call ResolveExactModelForBackend first. func RegistryModelDef(backend Backend, model string) (ModelDef, bool) { - provider := registryProviderForBackend(backend) - entry, ok := lookupRegistryExact(provider, normalizeCodexVariantAlias(model)) - if !ok || !registryModelAvailableForBackend(entry, backend) { + p, mode, ok := registry.ProviderFor(backend) + if !ok { + return ModelDef{}, false + } + entry, found := p.Lookup(model) + if !found { + return ModelDef{}, false + } + if _, available := p.Availability(mode, entry.ID); !available { return ModelDef{}, false } return registryModelDef(entry, backend), true @@ -127,76 +77,101 @@ func RegistryModelDef(backend Backend, model string) (ModelDef, bool) { // RegistryModelAvailability distinguishes an unknown model from a registry // model that is intentionally unavailable on the requested backend. func RegistryModelAvailability(backend Backend, model string) (known, available bool) { - provider := registryProviderForBackend(backend) - entry, ok := lookupRegistryExact(provider, normalizeCodexVariantAlias(model)) + p, mode, ok := registry.ProviderFor(backend) if !ok { return false, false } - return true, registryModelAvailableForBackend(entry, backend) + return p.Availability(mode, model) } -func registryCatalogModels() []Model { - out := make([]Model, 0, len(exactModelRegistry)+5) - for _, m := range exactModelRegistry { +// RegistryModelDefs returns exact, provider-native model IDs for a backend. CLI +// and cmux backends are projected from their parent provider's model registry. +func RegistryModelDefs(backend Backend) []ModelDef { + p, mode, ok := registry.ProviderFor(backend) + if !ok { + return nil + } + out := make([]ModelDef, 0) + for _, m := range p.Models() { if !m.Preferred { continue } - backend := registryBackendForProvider(m.Provider) - if backend == "" || !registryModelAvailableForBackend(m, backend) { + if known, available := p.Availability(mode, m.ID); !known || !available { continue } - out = append(out, Model{ - ID: registryProviderPrefix(m.Provider) + "/" + m.ID, - Backend: backend, - Label: m.Label, - Reasoning: m.Reasoning, - Temperature: m.Temperature, - AdaptiveThinking: m.AdaptiveThinking, - ContextWindow: m.ContextWindow, - ReleaseDate: m.ReleaseDate, - InputMediaTypes: clampInputMediaTypes(backend, m.InputMediaTypes), - SupportedEfforts: append([]api.Effort(nil), m.SupportedEfforts...), - DefaultEffort: m.DefaultEffort, - Priority: m.Priority, - Default: m.Provider == modelProviderAnthropic && m.ID == "claude-sonnet-5", - }) + out = append(out, registryModelDef(m, backend)) } - for _, m := range exactModelRegistry { - if !m.Preferred { + SortModelsByReleaseDateDesc(out) + return out +} + +func registryCatalogModels() []Model { + out := make([]Model, 0, len(registry.KnownModels())+5) + for _, p := range registry.Providers() { + apiBackend, err := p.BackendFor(registry.ModeAPI) + if err != nil { continue } - switch m.Provider { - case modelProviderAnthropic: - if !registryModelAvailableForBackend(m, BackendClaudeAgent) { + for _, m := range p.Models() { + if !m.Preferred { + continue + } + if known, available := p.Availability(registry.ModeAPI, m.ID); !known || !available { continue } out = append(out, Model{ - ID: m.ID, - Backend: BackendClaudeAgent, - Label: "Claude Agent · " + strings.TrimPrefix(m.Label, "Claude "), + ID: p.CatalogPrefix + "/" + m.ID, + Backend: apiBackend, + Label: m.Label, Reasoning: m.Reasoning, Temperature: m.Temperature, AdaptiveThinking: m.AdaptiveThinking, ContextWindow: m.ContextWindow, ReleaseDate: m.ReleaseDate, - InputMediaTypes: clampInputMediaTypes(BackendClaudeAgent, m.InputMediaTypes), + InputMediaTypes: clampInputMediaTypes(apiBackend, m.InputMediaTypes), SupportedEfforts: append([]api.Effort(nil), m.SupportedEfforts...), DefaultEffort: m.DefaultEffort, Priority: m.Priority, + Default: p == registry.Anthropic && m.ID == defaultCatalogModelID, }) - case modelProviderOpenAI: - if !registryModelAvailableForBackend(m, BackendCodexAgent) { + } + } + out = append(out, agentCatalogModels()...) + return out +} + +// agentCatalogModels projects the agent-mode rows, which carry the bare exact id +// (not a catalog-prefixed one) and an agent-flavoured label. +func agentCatalogModels() []Model { + out := make([]Model, 0) + for _, spec := range []struct { + provider *registry.Provider + label func(string) string + }{ + {registry.Anthropic, func(l string) string { return "Claude Agent · " + strings.TrimPrefix(l, "Claude ") }}, + {registry.OpenAI, func(l string) string { return "Codex Agent · " + l }}, + } { + backend, err := spec.provider.BackendFor(registry.ModeAgent) + if err != nil { + continue + } + for _, m := range spec.provider.Models() { + if !m.Preferred { + continue + } + if known, available := spec.provider.Availability(registry.ModeAgent, m.ID); !known || !available { continue } out = append(out, Model{ ID: m.ID, - Backend: BackendCodexAgent, - Label: "Codex Agent · " + m.Label, + Backend: backend, + Label: spec.label(m.Label), Reasoning: m.Reasoning, Temperature: m.Temperature, + AdaptiveThinking: m.AdaptiveThinking && spec.provider == registry.Anthropic, ContextWindow: m.ContextWindow, ReleaseDate: m.ReleaseDate, - InputMediaTypes: clampInputMediaTypes(BackendCodexAgent, m.InputMediaTypes), + InputMediaTypes: clampInputMediaTypes(backend, m.InputMediaTypes), SupportedEfforts: append([]api.Effort(nil), m.SupportedEfforts...), DefaultEffort: m.DefaultEffort, Priority: m.Priority, @@ -206,80 +181,15 @@ func registryCatalogModels() []Model { return out } -// RegistryModelDefs returns exact, provider-native model IDs for a backend. CLI -// and cmux backends are projected from their parent provider's model registry. -func RegistryModelDefs(backend Backend) []ModelDef { - provider := registryProviderForBackend(backend) - if provider == "" { - return nil - } - out := make([]ModelDef, 0, len(exactModelRegistry)) - for _, m := range exactModelRegistry { - if m.Provider != provider || !m.Preferred || !registryModelAvailableForBackend(m, backend) { - continue - } - out = append(out, registryModelDef(m, backend)) - } - SortModelsByReleaseDateDesc(out) - return out -} - // ResolveExactModelForBackend resolves a user/catalog model token into the exact // model ID the selected backend should receive. It accepts old aliases for input // compatibility but never returns an alias. func ResolveExactModelForBackend(backend Backend, model string) (string, bool) { - model = strings.TrimSpace(model) - if model == "" { - return "", false - } - if strings.EqualFold(model, backendAgentSentinel(backend)) { - if m, ok := latestRegistryModel(backend, registryProviderForBackend(backend), ""); ok { - return m.ID, true - } - return model, false - } - model = normalizeCodexVariantAlias(model) - provider := registryProviderForBackend(backend) - if provider == "" { - return stripModelProviderPrefix(model), false - } - if m, ok := lookupRegistryExact(provider, model); ok { - if registryModelAvailableForBackend(m, backend) && !isSupersededRegistryExact(m.ID) { - return m.ID, true - } - } - identity, ok := ParseModelIdentity(provider, model) + p, mode, ok := registry.ProviderFor(backend) if !ok { - return stripModelProviderPrefix(model), false - } - if identity.Provider != provider { - return stripModelProviderPrefix(model), false - } - if shouldResolveLatestVersionLine(identity.Version) { - if m, ok := latestRegistryModelForVersionLine(backend, identity); ok { - return m.ID, true - } - } - if m, ok := resolveRegistryIdentity(backend, identity); ok { - return m.ID, true - } - if identity.Version != "" { - return stripModelProviderPrefix(model), false - } - if m, ok := latestRegistryModel(backend, identity.Provider, identity.Family); ok { - return m.ID, true - } - return stripModelProviderPrefix(model), false -} - -func normalizeCodexVariantAlias(model string) string { - model = strings.TrimSpace(model) - switch strings.ToLower(model) { - case "sol", "terra", "luna": - return "gpt-5.6-" + strings.ToLower(model) - default: - return model + return registry.StripProviderPrefix(model), false } + return p.ResolveExact(mode, model) } // ModelUsesAdaptiveThinking reports whether an Anthropic model uses adaptive @@ -287,324 +197,10 @@ func normalizeCodexVariantAlias(model string) string { // provider-prefixed, and dated model tokens by resolving them to their exact // registry entry first. func ModelUsesAdaptiveThinking(model string) bool { - exact, ok := ResolveExactModelForBackend(BackendAnthropic, model) + exact, ok := registry.Anthropic.ResolveExact(registry.ModeAPI, model) if !ok { return false } - m, ok := lookupRegistryExact(modelProviderAnthropic, exact) + m, ok := registry.Anthropic.Lookup(exact) return ok && m.AdaptiveThinking } - -func backendAgentSentinel(backend Backend) string { - switch backend { - case BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: - return "claude" - case BackendCodexAgent, BackendCodexCLI, BackendCodexCmux: - return "codex" - default: - return "" - } -} - -func lookupRegistryExact(provider, model string) (registryModel, bool) { - needle := canonicalModelToken(stripModelProviderPrefix(model)) - for _, m := range exactModelRegistry { - if m.Provider != provider { - continue - } - if canonicalModelToken(m.ID) == needle { - return m, true - } - } - return registryModel{}, false -} - -func isSupersededRegistryExact(id string) bool { - switch canonicalModelToken(id) { - case "claude-sonnet-4-5", "claude-sonnet-4-5-20250929": - return true - default: - return false - } -} - -// ParseModelIdentity parses a model token into the provider/family/version tuple -// used by the resolver. It is deliberately permissive on input prefixes. -func ParseModelIdentity(defaultProvider, model string) (ModelIdentity, bool) { - provider, token := splitModelProvider(defaultProvider, model) - token = canonicalModelToken(token) - switch provider { - case modelProviderAnthropic: - token = strings.TrimPrefix(token, "claude-agent-") - token = strings.TrimPrefix(token, "claude-code-") - token = strings.TrimPrefix(token, "claude-") - if token == "" { - return ModelIdentity{Provider: provider, Family: "sonnet"}, true - } - family, version, ok := splitKnownFamily(token, []string{"fable", "opus", "sonnet", "haiku"}) - return ModelIdentity{Provider: provider, Family: family, Version: version}, ok - case modelProviderOpenAI: - token = strings.TrimPrefix(token, "codex-agent-") - token = strings.TrimPrefix(token, "codex-") - if token == "" || token == "codex" { - return ModelIdentity{Provider: provider, Family: "gpt"}, true - } - family, version, ok := splitKnownFamily(token, []string{"gpt"}) - return ModelIdentity{Provider: provider, Family: family, Version: version}, ok - case modelProviderGoogle: - token = strings.TrimPrefix(token, "gemini-cli-") - if token == "gemini" { - return ModelIdentity{Provider: provider, Family: "gemini"}, true - } - family, version, ok := splitKnownFamily(token, []string{"gemini"}) - return ModelIdentity{Provider: provider, Family: family, Version: version}, ok - case modelProviderDeepSeek: - if strings.HasPrefix(token, "deepseek") { - version := strings.TrimPrefix(token, "deepseek-") - if version == token { - version = "" - } - return ModelIdentity{Provider: provider, Family: "deepseek", Version: normalizeModelVersion(version)}, true - } - } - return ModelIdentity{}, false -} - -func splitKnownFamily(token string, families []string) (family, version string, ok bool) { - for _, candidate := range families { - if token == candidate { - return candidate, "", true - } - prefix := candidate + "-" - if strings.HasPrefix(token, prefix) { - return candidate, normalizeModelVersion(strings.TrimPrefix(token, prefix)), true - } - } - return "", "", false -} - -func resolveRegistryIdentity(backend Backend, identity ModelIdentity) (registryModel, bool) { - candidates := make([]registryModel, 0) - fallback := make([]registryModel, 0) - for _, m := range exactModelRegistry { - if m.Provider != identity.Provider || m.Family != identity.Family || !registryModelAvailableForBackend(m, backend) { - continue - } - if identity.Version == "" || modelVersionMatches(m.Version, identity.Version) { - if m.Preferred { - candidates = append(candidates, m) - } else if identity.Version != "" { - fallback = append(fallback, m) - } - } - } - if len(candidates) == 0 { - candidates = fallback - } - if len(candidates) == 0 { - return registryModel{}, false - } - sortRegistryModels(candidates) - return candidates[0], true -} - -func latestRegistryModel(backend Backend, provider, family string) (registryModel, bool) { - candidates := make([]registryModel, 0) - for _, m := range exactModelRegistry { - if !m.Preferred || m.Provider != provider || !registryModelAvailableForBackend(m, backend) { - continue - } - if family != "" && m.Family != family { - continue - } - candidates = append(candidates, m) - } - if len(candidates) == 0 { - return registryModel{}, false - } - sortRegistryModels(candidates) - return candidates[0], true -} - -func latestRegistryModelForVersionLine(backend Backend, identity ModelIdentity) (registryModel, bool) { - if identity.Version == "" { - return registryModel{}, false - } - major := strings.Split(normalizeModelVersion(identity.Version), ".")[0] - if major == "" { - return registryModel{}, false - } - candidates := make([]registryModel, 0) - for _, m := range exactModelRegistry { - if m.Provider != identity.Provider || m.Family != identity.Family || !registryModelAvailableForBackend(m, backend) { - continue - } - if modelVersionMatches(m.Version, major) { - candidates = append(candidates, m) - } - } - if len(candidates) == 0 { - return registryModel{}, false - } - sortRegistryModelsForResolution(candidates) - return candidates[0], true -} - -func shouldResolveLatestVersionLine(version string) bool { - version = normalizeModelVersion(version) - if version == "" { - return false - } - for _, part := range strings.Split(version, ".") { - if !isRegistryNumericVersionPart(part) { - return false - } - } - return true -} - -func sortRegistryModels(models []registryModel) { - sort.SliceStable(models, func(i, j int) bool { - left, right := models[i], models[j] - if left.Priority != right.Priority && (left.Priority > 0 || right.Priority > 0) { - if left.Priority == 0 { - return false - } - if right.Priority == 0 { - return true - } - return left.Priority < right.Priority - } - if left.ReleaseDate == right.ReleaseDate { - return compareModelVersions(left.ID, right.ID) > 0 - } - return left.ReleaseDate > right.ReleaseDate - }) -} - -func sortRegistryModelsForResolution(models []registryModel) { - sort.SliceStable(models, func(i, j int) bool { - left, right := models[i], models[j] - if left.Priority != right.Priority && (left.Priority > 0 || right.Priority > 0) { - if left.Priority == 0 { - return false - } - if right.Priority == 0 { - return true - } - return left.Priority < right.Priority - } - if left.ReleaseDate == right.ReleaseDate && left.Preferred != right.Preferred { - return left.Preferred - } - if left.ReleaseDate == right.ReleaseDate { - return compareModelVersions(left.ID, right.ID) > 0 - } - return left.ReleaseDate > right.ReleaseDate - }) -} - -func modelVersionMatches(registryVersion, requested string) bool { - registryVersion = normalizeModelVersion(registryVersion) - requested = normalizeModelVersion(requested) - if requested == "" { - return true - } - if registryVersion == requested { - return true - } - return strings.HasPrefix(registryVersion, requested+".") || strings.HasPrefix(registryVersion, requested+"-") -} - -func splitModelProvider(defaultProvider, model string) (string, string) { - model = strings.TrimSpace(model) - if i := strings.IndexByte(model, '/'); i >= 0 { - prefix := strings.ToLower(strings.TrimSpace(model[:i])) - token := model[i+1:] - switch prefix { - case "anthropic": - return modelProviderAnthropic, token - case "openai": - return modelProviderOpenAI, token - case "google", "googleai": - return modelProviderGoogle, token - case "deepseek": - return modelProviderDeepSeek, token - } - } - token := canonicalModelToken(model) - switch { - case strings.HasPrefix(token, "claude-") || - strings.HasPrefix(token, "opus") || - strings.HasPrefix(token, "sonnet") || - strings.HasPrefix(token, "haiku") || - strings.HasPrefix(token, "fable"): - return modelProviderAnthropic, model - case strings.HasPrefix(token, "codex-") || - strings.HasPrefix(token, "gpt-") || - token == "gpt" || - strings.HasPrefix(token, "o1") || - strings.HasPrefix(token, "o3") || - strings.HasPrefix(token, "o4") || - strings.HasPrefix(token, "sora"): - return modelProviderOpenAI, model - case strings.HasPrefix(token, "gemini"): - return modelProviderGoogle, model - case strings.HasPrefix(token, "deepseek"): - return modelProviderDeepSeek, model - } - return defaultProvider, model -} - -func stripModelProviderPrefix(model string) string { - _, token := splitModelProvider("", model) - return strings.TrimPrefix(strings.TrimSpace(token), "models/") -} - -func canonicalModelToken(model string) string { - model = strings.ToLower(strings.TrimSpace(model)) - model = strings.TrimPrefix(model, "models/") - model = strings.ReplaceAll(model, "_", "-") - model = strings.ReplaceAll(model, " ", "-") - return model -} - -func normalizeModelVersion(version string) string { - version = strings.Trim(strings.ToLower(strings.TrimSpace(version)), "-.") - if version == "" { - return "" - } - parts := strings.Split(version, "-") - if len(parts) == 1 { - return parts[0] - } - i := 0 - out := "" - if isRegistryNumericVersionPart(parts[0]) { - out = parts[0] - i = 1 - if len(parts) > 1 && isRegistryNumericVersionPart(parts[1]) { - out += "." + parts[1] - i = 2 - } - } - if i < len(parts) { - if out != "" { - out += "-" - } - out += strings.Join(parts[i:], "-") - } - return out -} - -func isRegistryNumericVersionPart(part string) bool { - if part == "" { - return false - } - for _, r := range part { - if r < '0' || r > '9' { - return false - } - } - return true -} diff --git a/pkg/ai/model_registry_embed.go b/pkg/ai/model_registry_embed.go deleted file mode 100644 index c6c30ac..0000000 --- a/pkg/ai/model_registry_embed.go +++ /dev/null @@ -1,31 +0,0 @@ -package ai - -import ( - _ "embed" - "encoding/json" - "fmt" -) - -// modelRegistryJSON is the generated model catalog produced by -// `task models:update` (pkg/ai/internal/gen-model-registry). It is the -// models.dev snapshot with pkg/ai/internal/gen-model-registry/patches.json -// overlaid. Regenerate it after editing patches.json. -// -//go:embed model_registry.json -var modelRegistryJSON []byte - -// exactModelRegistry is the parsed model catalog. It is a package-level var (not -// an init) so Go's initialization ordering guarantees it is populated before -// defaultCatalog, which references it via registryCatalogModels(). -var exactModelRegistry = mustParseModelRegistry(modelRegistryJSON) - -func mustParseModelRegistry(data []byte) []registryModel { - var models []registryModel - if err := json.Unmarshal(data, &models); err != nil { - panic(fmt.Sprintf("pkg/ai: invalid embedded model_registry.json: %v", err)) - } - if len(models) == 0 { - panic("pkg/ai: embedded model_registry.json is empty") - } - return models -} diff --git a/pkg/ai/model_registry_test.go b/pkg/ai/model_registry_test.go index 9d3e207..869b3d1 100644 --- a/pkg/ai/model_registry_test.go +++ b/pkg/ai/model_registry_test.go @@ -2,70 +2,6 @@ package ai import "testing" -// TestEmbeddedRegistryLoads guards the go:embed + JSON parse path: the generated -// model_registry.json must load into a non-empty slice whose every entry is -// well-formed and provider-tagged with a known provider. -func TestEmbeddedRegistryLoads(t *testing.T) { - if len(exactModelRegistry) == 0 { - t.Fatal("exactModelRegistry is empty; model_registry.json failed to load") - } - knownProviders := map[string]bool{ - modelProviderAnthropic: true, - modelProviderOpenAI: true, - modelProviderGoogle: true, - modelProviderDeepSeek: true, - } - for _, m := range exactModelRegistry { - if m.ID == "" || m.Provider == "" || m.Family == "" { - t.Errorf("entry %+v is missing id/provider/family", m) - } - if !knownProviders[m.Provider] { - t.Errorf("entry %q has unknown provider %q", m.ID, m.Provider) - } - } -} - -// TestRegistryDerivedCapabilities locks the capability flags the generator -// derives from models.dev: reasoning, temperature support, and the adaptive-vs- -// enabled thinking schema. The opus-4-8/4-7 adaptive values guard the 400 that -// legacy `thinking:{type:enabled}` triggers on those models. -func TestRegistryDerivedCapabilities(t *testing.T) { - cases := []struct { - id string - wantReasoning bool - wantTemp bool - wantPreferred bool - wantAdaptive bool - }{ - {"claude-sonnet-5", true, false, true, true}, - {"claude-fable-5", true, false, true, true}, - {"claude-opus-4-8", true, false, true, true}, - {"claude-opus-4-7", true, false, false, true}, - {"claude-sonnet-4-6", true, true, true, false}, - {"claude-haiku-4-5", true, true, true, false}, - } - for _, tc := range cases { - t.Run(tc.id, func(t *testing.T) { - m, ok := lookupRegistryExact(modelProviderAnthropic, tc.id) - if !ok { - t.Fatalf("model %q not found in registry", tc.id) - } - if m.Reasoning != tc.wantReasoning { - t.Errorf("Reasoning = %v, want %v", m.Reasoning, tc.wantReasoning) - } - if m.Temperature != tc.wantTemp { - t.Errorf("Temperature = %v, want %v", m.Temperature, tc.wantTemp) - } - if m.Preferred != tc.wantPreferred { - t.Errorf("Preferred = %v, want %v", m.Preferred, tc.wantPreferred) - } - if m.AdaptiveThinking != tc.wantAdaptive { - t.Errorf("AdaptiveThinking = %v, want %v", m.AdaptiveThinking, tc.wantAdaptive) - } - }) - } -} - func TestRegistryModelDefsIncludeFableCapabilities(t *testing.T) { for _, backend := range []Backend{BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux} { defs := RegistryModelDefs(backend) diff --git a/pkg/ai/pricing_coverage_test.go b/pkg/ai/pricing_coverage_test.go new file mode 100644 index 0000000..bb7a6d1 --- /dev/null +++ b/pkg/ai/pricing_coverage_test.go @@ -0,0 +1,97 @@ +package ai + +import ( + "testing" + + "github.com/flanksource/captain/pkg/ai/pricing" + "github.com/flanksource/captain/pkg/api/registry" +) + +// knownPricingGaps are preferred catalog models the pricing source does not list +// under any key, so captain reports $0 for them. These are upstream data gaps, +// not prefix bugs: the id resolves to no entry whether or not it is namespaced. +// Listed explicitly so the hole is visible and shrinks when upstream catches up, +// rather than being hidden by a permissive assertion. +var knownPricingGaps = map[string]string{ + "gpt-5.6": "OpenRouter lists the gpt-5.6-{sol,terra,luna} variants but not the api-only base id", +} + +// TestPricingIDsCoverEveryCatalogModel guards the failure mode that motivated +// unifying the pricing prefixes: a wrong namespace does not error, it just +// misses, and the run reports $0. +// +// Gemini is the trap — its catalog namespace is "googleai" but OpenRouter keys +// it under "google" — and captain had three hand-written copies of that mapping +// (PricingIDs, orPrefix, pricingModelID) that could drift apart independently. +func TestPricingIDsCoverEveryCatalogModel(t *testing.T) { + pricing.EnsureLoaded() + + for _, p := range registry.Providers() { + backend, err := p.BackendFor(registry.ModeAPI) + if err != nil { + t.Fatalf("%s has no API backend: %v", p.Name, err) + } + for _, m := range p.Models() { + if !m.Preferred { + continue + } + t.Run(p.Name+"/"+m.ID, func(t *testing.T) { + info, ok := lookupPricing(backend, m.ID) + if reason, gap := knownPricingGaps[m.ID]; gap { + if ok { + t.Fatalf("%s now has pricing — remove it from knownPricingGaps (%s)", m.ID, reason) + } + t.Skipf("known upstream pricing gap: %s", reason) + } + if !ok { + t.Fatalf("no pricing for %s via %v; a missing prefix reports $0 rather than failing", + m.ID, PricingIDs(backend, m.ID)) + } + if info.InputPrice <= 0 && info.OutputPrice <= 0 { + t.Errorf("%s priced at zero (input=%v output=%v)", m.ID, info.InputPrice, info.OutputPrice) + } + }) + } + } +} + +// TestPricingPrefixIsNotCatalogPrefix pins the two namespaces apart. If someone +// "simplifies" PricingPrefix to reuse CatalogPrefix, every Gemini price silently +// resolves to nothing — this fails first. +func TestPricingPrefixIsNotCatalogPrefix(t *testing.T) { + if registry.Google.CatalogPrefix != "googleai" { + t.Errorf("Google.CatalogPrefix = %q, want googleai (genkit/menu namespace)", registry.Google.CatalogPrefix) + } + if registry.Google.PricingPrefix != "google" { + t.Errorf("Google.PricingPrefix = %q, want google (OpenRouter key)", registry.Google.PricingPrefix) + } +} + +// TestPricingAgreesAcrossCatalogAndBilling: the catalog and the cost path must +// price a model identically. They used to run different lookups — the catalog +// tried the bare id first (which the classify-on-miss static table always +// answers for Claude) while billing tried the prefixed key first — so the price +// shown could differ from the price charged. +func TestPricingAgreesAcrossCatalogAndBilling(t *testing.T) { + pricing.EnsureLoaded() + + for _, model := range []string{"claude-sonnet-5", "claude-opus-4-8"} { + t.Run(model, func(t *testing.T) { + catalog, ok := lookupPricing(BackendAnthropic, model) + if !ok { + t.Fatalf("catalog has no price for %s", model) + } + var billing pricing.ModelInfo + for _, id := range PricingIDs(BackendAnthropic, model) { + if info, found := pricing.GetModelInfo(id); found { + billing = info + break + } + } + if catalog.InputPrice != billing.InputPrice || catalog.OutputPrice != billing.OutputPrice { + t.Errorf("catalog prices %s at in=%v/out=%v but billing uses in=%v/out=%v", + model, catalog.InputPrice, catalog.OutputPrice, billing.InputPrice, billing.OutputPrice) + } + }) + } +} diff --git a/pkg/ai/provider/genkit/genkit.go b/pkg/ai/provider/genkit/genkit.go index a303ef5..e61bb07 100644 --- a/pkg/ai/provider/genkit/genkit.go +++ b/pkg/ai/provider/genkit/genkit.go @@ -13,11 +13,13 @@ import ( "encoding/json" "fmt" "strings" - "sync/atomic" + "sync" "time" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/ai/pricing" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" "github.com/flanksource/commons/logger" gkai "github.com/firebase/genkit/go/ai" @@ -30,11 +32,12 @@ var log = logger.GetLogger("ai") // Provider is a genkit-backed ai.StreamingProvider for one API backend. type Provider struct { - cfg ai.Config - backend ai.Backend - g *gk.Genkit - modelRef string - toolCallSeq atomic.Uint64 // correlates caller-tool EventToolUse↔EventToolResult + cfg ai.Config + backend ai.Backend + g *gk.Genkit + modelRef string + toolOptionsMu sync.Mutex + toolCorrelation *toolEventCorrelation } var _ ai.StreamingProvider = (*Provider)(nil) @@ -93,7 +96,7 @@ func (p *Provider) GetBackend() ai.Backend { return p.backend } func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, error) { start := time.Now() - opts, err := generateOptions(p, req, nil, nil) + opts, err := p.correlatedGenerateOptions(req, nil, nil, nil) if err != nil { return nil, err } @@ -113,7 +116,16 @@ func (p *Provider) Execute(ctx context.Context, req ai.Request) (*ai.Response, e } out := responseToResponse(resp, p.backend, p.cfg.Model.Name, start) + if resp.FinishReason == gkai.FinishReasonInterrupted { + out.ToolApproval, err = toolApprovalState(req, resp) + if err != nil { + return nil, err + } + } + if out.ToolApproval != nil { + return out, nil + } if req.Prompt.Schema != nil { if err := resp.Output(req.Prompt.Schema); err != nil { return nil, fmt.Errorf("%w: %v", ai.ErrSchemaValidation, err) @@ -154,8 +166,13 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai } ch := make(chan ai.Event, 16) + correlation := newToolEventCorrelation() cb := func(_ context.Context, chunk *gkai.ModelResponseChunk) error { - for _, ev := range chunkToEvents(chunk, p.cfg.Model.Name) { + events, err := chunkToEvents(chunk, p.cfg.Model.Name, correlation) + if err != nil { + return err + } + for _, ev := range events { select { case ch <- ev: case <-ctx.Done(): @@ -173,7 +190,7 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai } } - opts, err := generateOptions(p, req, cb, emit) + opts, err := p.correlatedGenerateOptions(req, cb, emit, correlation) if err != nil { return nil, err } @@ -185,34 +202,54 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai return } usage := mapUsage(resp.Usage, p.backend) + var approval *api.ToolApprovalState + if resp.FinishReason == gkai.FinishReasonInterrupted { + approval, err = toolApprovalState(req, resp) + if err != nil { + ch <- ai.Event{Kind: ai.EventError, Error: err.Error(), Model: p.cfg.Model.Name} + return + } + } ch <- ai.Event{ - Kind: ai.EventResult, - Success: true, - Usage: &usage, - CostUSD: p.costUSD(usage), - Model: p.cfg.Model.Name, + Kind: ai.EventResult, + Success: true, + Usage: &usage, + CostUSD: p.costUSD(usage), + Model: p.cfg.Model.Name, + ToolApproval: approval, } }() return ch, nil } +func (p *Provider) correlatedGenerateOptions( + req ai.Request, + stream gkai.ModelStreamCallback, + emit func(ai.Event), + correlation *toolEventCorrelation, +) ([]gkai.GenerateOption, error) { + p.toolOptionsMu.Lock() + defer p.toolOptionsMu.Unlock() + p.toolCorrelation = correlation + defer func() { p.toolCorrelation = nil }() + if err := seedToolApprovalCorrelation(req.ToolApproval, correlation); err != nil { + return nil, err + } + return generateOptions(p, req, stream, emit) +} + // pricingModelID maps a backend+model onto the OpenRouter-style id the pricing // registry is keyed on (note: Gemini is google/, not googleai/). +// pricingModelID is the OpenRouter pricing key for a backend+model. It uses the +// provider's PricingPrefix — "google" for Gemini, whose catalog namespace is +// "googleai" (see modelRef). The two must not be derived from one another. func pricingModelID(backend ai.Backend, model string) string { - bare := bareModel(model) - switch backend { - case ai.BackendAnthropic: - return "anthropic/" + bare - case ai.BackendOpenAI: - return "openai/" + bare - case ai.BackendGemini: - return "google/" + bare - case ai.BackendDeepSeek: - return "deepseek/" + bare - default: + p, _, ok := registry.ProviderFor(backend) + if !ok { return model } + return p.PricingIDs(model)[0] } // costUSD prices a generation. Genkit usage carries no cost, so it is computed diff --git a/pkg/ai/provider/genkit/options.go b/pkg/ai/provider/genkit/options.go index 53c777d..26564af 100644 --- a/pkg/ai/provider/genkit/options.go +++ b/pkg/ai/provider/genkit/options.go @@ -5,44 +5,37 @@ import ( "encoding/json" "fmt" "os" - "strings" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" gkai "github.com/firebase/genkit/go/ai" ) // 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. +// either a genkit ref or an OpenRouter pricing key. func bareModel(model string) string { - for _, prefix := range []string{"anthropic/", "openai/", "googleai/", "google/", "deepseek/", "models/"} { - if strings.HasPrefix(model, prefix) { - return strings.TrimPrefix(model, prefix) - } - } - return model + return registry.StripProviderPrefix(model) } // modelRef produces the genkit model reference for a backend+model // (anthropic/, openai/, googleai/). +// +// The namespace is the provider's CatalogPrefix — googleai for Gemini — which is +// deliberately not the same field pricing uses (google). Keeping them as one +// hand-written switch each is how the two drifted apart. func modelRef(backend ai.Backend, model string) (string, error) { if model == "" { return "", fmt.Errorf("genkit provider: model cannot be empty") } - bare := bareModel(model) - switch backend { - case ai.BackendAnthropic: - return "anthropic/" + bare, nil - case ai.BackendOpenAI: - return "openai/" + bare, nil - case ai.BackendGemini: - return "googleai/" + bare, nil - case ai.BackendDeepSeek: - return "deepseek/" + bare, nil - default: + // genkit serves the API modes only; the CLI/agent/cmux backends are driven by + // their own providers. + p, mode, ok := registry.ProviderFor(backend) + if !ok || mode != registry.ModeAPI { return "", fmt.Errorf("genkit provider: unsupported backend %q", backend) } + return p.CatalogPrefix + "/" + bareModel(model), nil } // generateOptions assembles the genkit Generate options for one turn: model, @@ -50,8 +43,15 @@ func modelRef(backend ai.Backend, model string) (string, error) { // WithOutputType is added only for the non-streaming structured-output path; // ExecuteStream rejects structured output before calling this. func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallback, emit func(ai.Event)) ([]gkai.GenerateOption, error) { + if err := req.ValidateRequestMode(); err != nil { + return nil, err + } opts := []gkai.GenerateOption{gkai.WithModelName(p.modelRef)} - opts = append(opts, p.toolOptions(emit)...) + toolOptions, err := p.toolOptions(req.ToolPreferences, emit) + if err != nil { + return nil, err + } + opts = append(opts, toolOptions...) if p.cfg.Model.Name != "" { req.Name = p.cfg.Model.Name } @@ -59,20 +59,49 @@ func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallbac req.ID = p.cfg.Model.ID } - if req.Prompt.System != "" { - opts = append(opts, gkai.WithSystem(req.Prompt.System)) - } - if len(req.Prompt.Attachments) == 0 { - opts = append(opts, gkai.WithPrompt(req.Prompt.User)) - } else { - if err := ai.ValidateAttachmentCompatibility([]api.Model{req.Model}, req.Prompt.Attachments); err != nil { + if req.ToolApproval != nil { + if err := ai.ValidateAttachmentCompatibility([]api.Model{req.Model}, messageAttachments(req.ToolApproval.State.Messages)); err != nil { return nil, err } - parts, err := promptParts(req) + messages, restarts, responses, err := prepareToolApprovalResume(req.ToolApproval) if err != nil { + return nil, fmt.Errorf("genkit tool approval resume: %w", err) + } + opts = append(opts, gkai.WithMessages(messages...)) + if len(restarts) > 0 { + opts = append(opts, gkai.WithToolRestarts(restarts...)) + } + if len(responses) > 0 { + opts = append(opts, gkai.WithToolResponses(responses...)) + } + } else if len(req.Messages) > 0 { + if err := req.ValidateRequestMode(); err != nil { + return nil, err + } + if err := ai.ValidateAttachmentCompatibility([]api.Model{req.Model}, messageAttachments(req.Messages)); err != nil { return nil, err } - opts = append(opts, gkai.WithMessages(gkai.NewUserMessage(parts...))) + messages, err := conversationMessages(req.Messages) + if err != nil { + return nil, err + } + opts = append(opts, gkai.WithMessages(messages...)) + } else { + if req.Prompt.System != "" { + opts = append(opts, gkai.WithSystem(req.Prompt.System)) + } + if len(req.Prompt.Attachments) == 0 { + opts = append(opts, gkai.WithPrompt(req.Prompt.User)) + } else { + if err := ai.ValidateAttachmentCompatibility([]api.Model{req.Model}, req.Prompt.Attachments); err != nil { + return nil, err + } + parts, err := promptParts(req) + if err != nil { + return nil, err + } + opts = append(opts, gkai.WithMessages(gkai.NewUserMessage(parts...))) + } } modelToken := req.Name @@ -109,22 +138,111 @@ func promptParts(req ai.Request) ([]*gkai.Part, error) { parts = append(parts, gkai.NewTextPart(req.Prompt.User)) } for i, attachment := range req.Prompt.Attachments { - content, ok := attachment.PreparedContent() - if !ok { - return nil, fmt.Errorf("attachment %d (%s) is not prepared", i+1, attachment.ID) + part, err := preparedAttachmentPart(attachment, fmt.Sprintf("attachment %d", i+1)) + if err != nil { + return nil, err } - data := content.Bytes - if data == nil && content.Path != "" { - var err error - data, err = os.ReadFile(content.Path) - if err != nil { - return nil, fmt.Errorf("read prepared attachment %s: %w", attachment.ID, err) + parts = append(parts, part) + } + return parts, nil +} + +func conversationMessages(messages []api.Message) ([]*gkai.Message, error) { + if err := api.ValidateMessages(messages); err != nil { + return nil, fmt.Errorf("canonical messages: %w", err) + } + toolNames := map[string]string{} + out := make([]*gkai.Message, 0, len(messages)) + for i, message := range messages { + parts := make([]*gkai.Part, 0, len(message.Parts)) + for j, part := range message.Parts { + switch part.Type { + case api.PartText: + parts = append(parts, gkai.NewTextPart(part.Text)) + case api.PartReasoning: + parts = append(parts, gkai.NewReasoningPart(part.Text, nil)) + case api.PartAttachment: + media, err := preparedAttachmentPart(*part.Attachment, fmt.Sprintf("message %d part %d attachment", i+1, j+1)) + if err != nil { + return nil, err + } + parts = append(parts, media) + case api.PartToolRequest: + input, err := decodePartJSON(part.ToolRequest.Input) + if err != nil { + return nil, fmt.Errorf("message %d part %d tool request input: %w", i+1, j+1, err) + } + toolNames[part.ToolRequest.ToolCallID] = part.ToolRequest.Name + parts = append(parts, gkai.NewToolRequestPart(&gkai.ToolRequest{Ref: part.ToolRequest.ToolCallID, Name: part.ToolRequest.Name, Input: input})) + case api.PartToolResult: + output, err := decodePartJSON(part.ToolResult.Output) + if err != nil { + return nil, fmt.Errorf("message %d part %d tool result output: %w", i+1, j+1, err) + } + if part.ToolResult.Error != "" { + output = map[string]any{"error": part.ToolResult.Error} + } + parts = append(parts, gkai.NewToolResponsePart(&gkai.ToolResponse{Ref: part.ToolResult.ToolCallID, Name: toolNames[part.ToolResult.ToolCallID], Output: output})) } } - uri := "data:" + attachment.MediaType + ";base64," + base64.StdEncoding.EncodeToString(data) - parts = append(parts, gkai.NewMediaPart(attachment.MediaType, uri)) + out = append(out, gkai.NewMessage(genkitRole(message.Role), nil, parts...)) } - return parts, nil + return out, nil +} + +func genkitRole(role api.MessageRole) gkai.Role { + switch role { + case api.RoleSystem: + return gkai.RoleSystem + case api.RoleUser: + return gkai.RoleUser + case api.RoleAssistant: + return gkai.RoleModel + case api.RoleTool: + return gkai.RoleTool + default: + return "" + } +} + +func decodePartJSON(raw json.RawMessage) (any, error) { + if len(raw) == 0 { + return nil, nil + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, err + } + return value, nil +} + +func messageAttachments(messages []api.Message) []api.AttachmentRef { + var attachments []api.AttachmentRef + for _, message := range messages { + for _, part := range message.Parts { + if part.Type == api.PartAttachment && part.Attachment != nil { + attachments = append(attachments, *part.Attachment) + } + } + } + return attachments +} + +func preparedAttachmentPart(attachment api.AttachmentRef, label string) (*gkai.Part, error) { + content, ok := attachment.PreparedContent() + if !ok { + return nil, fmt.Errorf("%s (%s) is not prepared", label, attachment.ID) + } + data := content.Bytes + if data == nil && content.Path != "" { + var err error + data, err = os.ReadFile(content.Path) + if err != nil { + return nil, fmt.Errorf("read prepared attachment %s: %w", attachment.ID, err) + } + } + uri := "data:" + attachment.MediaType + ";base64," + base64.StdEncoding.EncodeToString(data) + return gkai.NewMediaPart(attachment.MediaType, uri), nil } // backendOutputSchema resolves schemas for native providers whose supported diff --git a/pkg/ai/runtime_selection_ginkgo_test.go b/pkg/ai/runtime_selection_ginkgo_test.go index e334860..3700d3c 100644 --- a/pkg/ai/runtime_selection_ginkgo_test.go +++ b/pkg/ai/runtime_selection_ginkgo_test.go @@ -23,10 +23,11 @@ var _ = Describe("runtime model selection", func() { ) Expect(err).NotTo(HaveOccurred()) - Expect(models).To(Equal([]api.Model{ - {Name: "gemini-3.5-flash", Backend: api.BackendGemini}, - {Name: "gpt-5.5", Backend: api.BackendOpenAI}, - })) + Expect(models).To(HaveLen(2)) + Expect(models[0].Name).To(Equal("gemini-3.5-flash")) + Expect(models[0].Backend).To(Equal(api.BackendGemini)) + Expect(models[1].Name).To(Equal("gpt-5.5")) + Expect(models[1].Backend).To(Equal(api.BackendOpenAI)) }) It("rejects a known model forced through another family", func() { diff --git a/pkg/ai/runtime_selector.go b/pkg/ai/runtime_selector.go index 7292c48..a0ec8af 100644 --- a/pkg/ai/runtime_selector.go +++ b/pkg/ai/runtime_selector.go @@ -1,342 +1,34 @@ package ai import ( - "fmt" - "strings" - "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" ) -type selectorFamily string - -const ( - selectorFamilyClaude selectorFamily = "claude" - selectorFamilyCodex selectorFamily = "codex" - selectorFamilyGemini selectorFamily = "gemini" - selectorFamilyDeepSeek selectorFamily = "deepseek" -) +// Model selectors are parsed by pkg/api/registry — the same parser that decodes +// specs and prompt frontmatter. This file used to hold a second implementation of +// the grammar (resolveSelectorPart, selectorBackend, selectorModelFamily, +// wildcardBackends, splitSelectorEffort) over its own family table, which is why +// `--model agent:sol` and `model: agent:sol` in frontmatter disagreed. // ContainsRuntimeSelector reports whether s contains a backend/mode selector such -// as "cmux:gpt-5.6-sol" or "*:sonnet-5". Comma-separated fallback lists are scanned -// item by item. +// as "cmux:gpt-5.6-sol" or "*:sonnet-5". Comma-separated fallback lists are +// scanned item by item. func ContainsRuntimeSelector(s string) bool { - for _, part := range splitSelectorCSV(s) { - prefix, _, ok := strings.Cut(part, ":") - if !ok { - continue - } - if isSelectorPrefix(strings.TrimSpace(prefix)) { - return true - } - } - return false + return registry.ContainsSelector(s) } // ResolveModelSelectors turns every recognizable model name into a concrete // model/backend pair while preserving fallback semantics. Unknown model names // require an explicit backend. func ResolveModelSelectors(model api.Model) (api.Model, error) { - model = model.ExpandCSV() - resolved, err := resolveSelectorModel(model, "", false) - if err != nil { - return api.Model{}, err - } - out := mergeModelRuntime(model, resolved) - out.Fallbacks = make([]api.Model, 0, len(model.Fallbacks)) - for _, fb := range model.Fallbacks { - rfb, err := resolveSelectorModel(fb, "", false) - if err != nil { - return api.Model{}, err - } - out.Fallbacks = append(out.Fallbacks, mergeModelRuntime(fb, rfb)) - } - return out, nil + return registry.ResolveModel(model) } // ResolveRuntimeSelectors expands --multi-models values into concrete runtime // model/backend pairs. Values may be repeated and/or comma-separated. func ResolveRuntimeSelectors(values []string, base api.Model) ([]api.Model, error) { - base, err := ResolveModelSelectors(base) - if err != nil { - return nil, err - } - parts := splitSelectorValues(values) - out := make([]api.Model, 0, len(parts)) - seen := map[string]bool{} - for _, part := range parts { - models, err := resolveSelectorPart(part, base.Name, "", true) - if err != nil { - return nil, err - } - for _, m := range models { - m.Temperature = base.Temperature - if m.Effort == api.EffortNone { - m.Effort = base.Effort - } - m.NoCache = base.NoCache - key := string(m.Backend) + "\x00" + m.Name + "\x00" + string(m.Effort) - if seen[key] { - continue - } - seen[key] = true - out = append(out, m) - } - } - return out, nil -} - -func resolveSelectorModel(model api.Model, baseName string, allowWildcard bool) (api.Model, error) { - if strings.TrimSpace(model.Name) == "" { - return model, nil - } - resolved, err := resolveSelectorPart(model.Name, baseName, model.Backend, allowWildcard) - if err != nil { - return api.Model{}, err - } - if len(resolved) != 1 { - return api.Model{}, fmt.Errorf("wildcard selector %q is only valid for --multi-models", model.Name) - } - return resolved[0], nil -} - -func resolveSelectorPart(raw, baseName string, forced api.Backend, allowWildcard bool) ([]api.Model, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, fmt.Errorf("empty runtime selector") - } - - prefix, modelName, hasPrefix := strings.Cut(raw, ":") - prefix = strings.ToLower(strings.TrimSpace(prefix)) - modelName = strings.TrimSpace(modelName) - selectorEffort := api.EffortNone - if !hasPrefix { - if isSelectorPrefix(raw) { - prefix = strings.ToLower(raw) - modelName = strings.TrimSpace(baseName) - } else { - model, err := resolveBareModel(raw, forced) - if err != nil { - return nil, err - } - return []api.Model{model}, nil - } - } else { - var err error - modelName, selectorEffort, err = splitSelectorEffort(raw, modelName) - if err != nil { - return nil, err - } - } - if !isSelectorPrefix(prefix) { - return nil, fmt.Errorf("unknown runtime selector prefix %q in %q", prefix, raw) - } - if modelName == "" { - modelName = strings.TrimSpace(baseName) - } - if modelName == "" { - return nil, fmt.Errorf("runtime selector %q needs a model, or --model must provide a base model", raw) - } - if prefix == "*" { - if !allowWildcard { - return nil, fmt.Errorf("wildcard selector %q is only valid for --multi-models", raw) - } - family, err := selectorModelFamily(modelName) - if err != nil { - return nil, err - } - backends := wildcardBackends(family) - out := make([]api.Model, 0, len(backends)) - for _, backend := range backends { - resolved, err := normalizeSelectorModel(backend, modelName) - if err != nil { - continue - } - if err := ValidateModelEffort(backend, resolved, selectorEffort); err != nil { - continue - } - out = append(out, api.Model{Name: resolved, Backend: backend, Effort: selectorEffort}) - } - if len(out) == 0 { - return nil, fmt.Errorf("runtime selector %q is not available on any backend", raw) - } - return out, nil - } - - backend, err := selectorBackend(prefix, modelName) - if err != nil { - return nil, err - } - if forced != "" && backend != forced { - return nil, fmt.Errorf("runtime selector %q resolves to backend %q but --backend is %q", raw, backend, forced) - } - resolved, err := normalizeSelectorModel(backend, modelName) - if err != nil { - return nil, fmt.Errorf("runtime selector %q: %w", raw, err) - } - if err := ValidateModelEffort(backend, resolved, selectorEffort); err != nil { - return nil, fmt.Errorf("runtime selector %q: %w", raw, err) - } - return []api.Model{{Name: resolved, Backend: backend, Effort: selectorEffort}}, nil -} - -func resolveBareModel(model string, forced api.Backend) (api.Model, error) { - backend := forced - inferred, inferErr := inferModelBackend(model) - if backend == "" { - if inferErr != nil { - return api.Model{}, inferErr - } - backend = inferred - } else { - if !backend.Valid() { - return api.Model{}, fmt.Errorf("invalid backend %q (valid: %s)", backend, api.BackendList()) - } - if inferErr == nil && inferred.Family() != backend.Family() { - return api.Model{}, fmt.Errorf("model %q belongs to the %s family and cannot use backend %q (%s family)", - model, inferred.Family(), backend, backend.Family()) - } - } - - resolved, err := normalizeSelectorModel(backend, model) - if err != nil { - return api.Model{}, err - } - return api.Model{Name: resolved, Backend: backend}, nil -} - -func inferModelBackend(model string) (api.Backend, error) { - backend, err := api.InferBackend(model) - if err == nil { - return backend, nil - } - if i := strings.LastIndex(model, "/"); i >= 0 { - return api.InferBackend(model[i+1:]) - } - return "", err -} - -func splitSelectorEffort(raw, model string) (string, api.Effort, error) { - i := strings.LastIndex(model, ":") - if i < 0 { - return model, api.EffortNone, nil - } - name := strings.TrimSpace(model[:i]) - effort := api.Effort(strings.ToLower(strings.TrimSpace(model[i+1:]))) - if name == "" || effort == api.EffortNone || !effort.Valid() { - return "", api.EffortNone, fmt.Errorf("invalid effort suffix in runtime selector %q; want one of: low, medium, high, xhigh, max, ultra", raw) - } - return name, effort, nil -} - -func selectorBackend(prefix, model string) (api.Backend, error) { - if b := api.Backend(prefix); b.Valid() { - return b, nil - } - family, err := selectorModelFamily(model) - if err != nil { - return "", err - } - switch prefix { - case "api": - switch family { - case selectorFamilyClaude: - return api.BackendAnthropic, nil - case selectorFamilyCodex: - return api.BackendOpenAI, nil - case selectorFamilyGemini: - return api.BackendGemini, nil - case selectorFamilyDeepSeek: - return api.BackendDeepSeek, nil - } - case "cli": - switch family { - case selectorFamilyClaude: - return api.BackendClaudeCLI, nil - case selectorFamilyCodex: - return api.BackendCodexCLI, nil - case selectorFamilyGemini: - return api.BackendGeminiCLI, nil - } - case "agent", "sdk": - switch family { - case selectorFamilyClaude: - return api.BackendClaudeAgent, nil - case selectorFamilyCodex: - return api.BackendCodexAgent, nil - } - case "cmux": - switch family { - case selectorFamilyClaude: - return api.BackendClaudeCmux, nil - case selectorFamilyCodex: - return api.BackendCodexCmux, nil - } - } - return "", fmt.Errorf("runtime selector %q does not support %s models", prefix, family) -} - -func selectorModelFamily(model string) (selectorFamily, error) { - m := strings.ToLower(strings.TrimSpace(normalizeCodexVariantAlias(model))) - if i := strings.LastIndex(m, "/"); i >= 0 { - m = m[i+1:] - } - switch { - case strings.HasPrefix(m, "claude-"), - strings.HasPrefix(m, "claude-agent-"), - strings.HasPrefix(m, "claude-code-"), - strings.HasPrefix(m, "opus"), - strings.HasPrefix(m, "sonnet"), - strings.HasPrefix(m, "haiku"), - strings.HasPrefix(m, "fable"): - return selectorFamilyClaude, nil - case strings.HasPrefix(m, "gemini-"), strings.HasPrefix(m, "models/gemini-"): - return selectorFamilyGemini, nil - case strings.HasPrefix(m, "deepseek"): - return selectorFamilyDeepSeek, nil - case strings.HasPrefix(m, "codex"), - strings.HasPrefix(m, "gpt-"), - strings.HasPrefix(m, "o1"), - strings.HasPrefix(m, "o3"), - strings.HasPrefix(m, "o4"): - return selectorFamilyCodex, nil - default: - if b, err := api.InferBackend(model); err == nil { - switch b { - case api.BackendAnthropic, api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux: - return selectorFamilyClaude, nil - case api.BackendGemini, api.BackendGeminiCLI: - return selectorFamilyGemini, nil - case api.BackendDeepSeek: - return selectorFamilyDeepSeek, nil - case api.BackendOpenAI, api.BackendCodexCLI, api.BackendCodexAgent, api.BackendCodexCmux: - return selectorFamilyCodex, nil - } - } - return "", fmt.Errorf("cannot infer model family for %q", model) - } -} - -func wildcardBackends(family selectorFamily) []api.Backend { - switch family { - case selectorFamilyClaude: - return []api.Backend{api.BackendAnthropic, api.BackendClaudeAgent, api.BackendClaudeCLI, api.BackendClaudeCmux} - case selectorFamilyCodex: - return []api.Backend{api.BackendOpenAI, api.BackendCodexAgent, api.BackendCodexCLI, api.BackendCodexCmux} - case selectorFamilyGemini: - return []api.Backend{api.BackendGemini, api.BackendGeminiCLI} - case selectorFamilyDeepSeek: - return []api.Backend{api.BackendDeepSeek} - default: - return nil - } -} - -func normalizeSelectorModel(backend api.Backend, model string) (string, error) { - model = normalizeCodexVariantAlias(model) - if known, available := RegistryModelAvailability(backend, model); known && !available { - return "", fmt.Errorf("model %q is not available on backend %q", model, backend) - } - return NormalizeModelForBackend(backend, model), nil + return registry.ResolveMulti(values, base) } // NormalizeModelForBackend maps a catalog/live/provider model id onto the exact @@ -347,49 +39,3 @@ func NormalizeModelForBackend(backend api.Backend, model string) string { resolved, _ := ResolveExactModelForBackend(backend, model) return resolved } - -func mergeModelRuntime(original, resolved api.Model) api.Model { - out := original - if strings.TrimSpace(resolved.Name) != "" { - out.Name = resolved.Name - } - if resolved.ID != "" { - out.ID = resolved.ID - } - if resolved.Backend != "" { - out.Backend = resolved.Backend - } - if resolved.Effort != api.EffortNone { - out.Effort = resolved.Effort - } - return out -} - -func isSelectorPrefix(s string) bool { - s = strings.ToLower(strings.TrimSpace(s)) - switch s { - case "api", "agent", "cli", "sdk", "cmux", "*": - return true - default: - return api.Backend(s).Valid() - } -} - -func splitSelectorValues(values []string) []string { - var out []string - for _, value := range values { - out = append(out, splitSelectorCSV(value)...) - } - return out -} - -func splitSelectorCSV(value string) []string { - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - if p = strings.TrimSpace(p); p != "" { - out = append(out, p) - } - } - return out -} diff --git a/pkg/ai/runtime_selector_test.go b/pkg/ai/runtime_selector_test.go index 08de4ea..70839a8 100644 --- a/pkg/ai/runtime_selector_test.go +++ b/pkg/ai/runtime_selector_test.go @@ -2,9 +2,11 @@ package ai import ( "reflect" + "strings" "testing" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" ) func TestResolveModelSelectors(t *testing.T) { @@ -90,15 +92,15 @@ func TestParseModelIdentity(t *testing.T) { model string want ModelIdentity }{ - {"opus-4-8", ModelIdentity{Provider: modelProviderAnthropic, Family: "opus", Version: "4.8"}}, - {"claude-opus-4-8-3003", ModelIdentity{Provider: modelProviderAnthropic, Family: "opus", Version: "4.8-3003"}}, - {"claude-agent-sonnet", ModelIdentity{Provider: modelProviderAnthropic, Family: "sonnet"}}, - {"openai/gpt-5.5", ModelIdentity{Provider: modelProviderOpenAI, Family: "gpt", Version: "5.5"}}, - {"codex-gpt-5.4", ModelIdentity{Provider: modelProviderOpenAI, Family: "gpt", Version: "5.4"}}, - {"gpt-5.4-mini", ModelIdentity{Provider: modelProviderOpenAI, Family: "gpt", Version: "5.4-mini"}}, + {"opus-4-8", ModelIdentity{Provider: registry.Anthropic.Name, Family: "opus", Version: "4.8"}}, + {"claude-opus-4-8-3003", ModelIdentity{Provider: registry.Anthropic.Name, Family: "opus", Version: "4.8-3003"}}, + {"claude-agent-sonnet", ModelIdentity{Provider: registry.Anthropic.Name, Family: "sonnet"}}, + {"openai/gpt-5.5", ModelIdentity{Provider: registry.OpenAI.Name, Family: "gpt", Version: "5.5"}}, + {"codex-gpt-5.4", ModelIdentity{Provider: registry.OpenAI.Name, Family: "gpt", Version: "5.4"}}, + {"gpt-5.4-mini", ModelIdentity{Provider: registry.OpenAI.Name, Family: "gpt", Version: "5.4-mini"}}, } for _, tc := range cases { - got, ok := ParseModelIdentity(modelProviderAnthropic, tc.model) + got, ok := ParseModelIdentity(registry.Anthropic.Name, tc.model) if !ok { t.Fatalf("ParseModelIdentity(%q) did not parse", tc.model) } @@ -208,13 +210,97 @@ func TestResolveRuntimeSelectors_PerSelectorEffortAndDedup(t *testing.T) { if err != nil { t.Fatalf("ResolveRuntimeSelectors: %v", err) } - want := []api.Model{ - {Name: "gpt-5.6-sol", Backend: api.BackendCodexAgent, Effort: api.EffortHigh}, - {Name: "gpt-5.6-sol", Backend: api.BackendCodexAgent, Effort: api.EffortXHigh}, - {Name: "gpt-5.6-terra", Backend: api.BackendCodexCmux, Effort: api.EffortMax}, + // Compare the identity triple: this pins per-selector effort and dedup. The + // derived capability fields are covered by TestResolvedModelCarriesCapabilities. + type runtime struct { + name string + backend api.Backend + effort api.Effort + } + want := []runtime{ + {"gpt-5.6-sol", api.BackendCodexAgent, api.EffortHigh}, + {"gpt-5.6-sol", api.BackendCodexAgent, api.EffortXHigh}, + {"gpt-5.6-terra", api.BackendCodexCmux, api.EffortMax}, + } + gotRuntimes := make([]runtime, 0, len(got)) + for _, m := range got { + gotRuntimes = append(gotRuntimes, runtime{m.Name, m.Backend, m.Effort}) + } + if !reflect.DeepEqual(gotRuntimes, want) { + t.Fatalf("got %+v, want %+v", gotRuntimes, want) + } +} + +// TestResolvedModelCarriesCapabilities pins that resolution answers what the +// chosen adapter can do, not just which model it is. The values are asserted +// against the known adapters: only the claude agent SDK steers, both agent SDKs +// interrupt, and the claude CLI carries no attachments. +func TestResolvedModelCarriesCapabilities(t *testing.T) { + cases := []struct { + selector string + wantMode registry.RuntimeMode + wantResume bool + wantIntr bool + wantSteer bool + wantMedia []string + }{ + {"agent:sonnet", registry.ModeAgent, true, true, true, []string{"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}}, + {"cli:sonnet", registry.ModeCLI, true, false, false, []string{}}, + {"api:sonnet", registry.ModeAPI, false, false, false, []string{"image/*"}}, + {"agent:sol", registry.ModeAgent, true, true, false, []string{"image/*"}}, + } + for _, tc := range cases { + t.Run(tc.selector, func(t *testing.T) { + got, err := ResolveModelSelectors(api.Model{Name: tc.selector}) + if err != nil { + t.Fatalf("ResolveModelSelectors(%q): %v", tc.selector, err) + } + if got.Mode != tc.wantMode { + t.Errorf("Mode = %q, want %q", got.Mode, tc.wantMode) + } + if !got.Streaming { + t.Error("Streaming = false; every adapter streams") + } + if got.Resume != tc.wantResume || got.Interrupt != tc.wantIntr || got.Steer != tc.wantSteer { + t.Errorf("resume/interrupt/steer = %v/%v/%v, want %v/%v/%v", + got.Resume, got.Interrupt, got.Steer, tc.wantResume, tc.wantIntr, tc.wantSteer) + } + if !reflect.DeepEqual(got.MediaTypes, tc.wantMedia) { + t.Errorf("MediaTypes = %v, want %v", got.MediaTypes, tc.wantMedia) + } + if got.Provider == nil { + t.Fatal("Provider is nil; a resolved model must know its family") + } + }) + } +} + +// TestModeContradictingBackendFailsLoud: Backend is exactly (provider, mode), so +// a spec naming both must not name two different runtimes. +func TestModeContradictingBackendFailsLoud(t *testing.T) { + _, err := ResolveModelSelectors(api.Model{Name: "sonnet", Backend: api.BackendAnthropic, Mode: registry.ModeAgent}) + if err == nil { + t.Fatal("mode agent + backend anthropic should fail loud, not silently pick one") + } + if !strings.Contains(err.Error(), "contradicts") { + t.Errorf("err = %v, want it to name the contradiction", err) + } +} + +// TestModeSelectsBackend: {model, mode} is the object form of the compact +// "mode:model" string and must resolve identically. +func TestModeSelectsBackend(t *testing.T) { + byMode, err := ResolveModelSelectors(api.Model{Name: "sonnet", Mode: registry.ModeAgent}) + if err != nil { + t.Fatalf("mode form: %v", err) + } + byPrefix, err := ResolveModelSelectors(api.Model{Name: "agent:sonnet"}) + if err != nil { + t.Fatalf("compact form: %v", err) } - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %#v, want %#v", got, want) + if byMode.Backend != byPrefix.Backend || byMode.Name != byPrefix.Name { + t.Errorf("mode form = %s/%s, compact form = %s/%s; the two spellings must agree", + byMode.Backend, byMode.Name, byPrefix.Backend, byPrefix.Name) } } diff --git a/pkg/api/aliases.go b/pkg/api/aliases.go new file mode 100644 index 0000000..7ad3856 --- /dev/null +++ b/pkg/api/aliases.go @@ -0,0 +1,66 @@ +package api + +import "github.com/flanksource/captain/pkg/api/registry" + +// Model identity lives in pkg/api/registry — a leaf package, because decoding a +// Spec parses model strings and the parser therefore cannot sit above pkg/api. +// These aliases keep api.Model / api.Backend / api.Effort the names the rest of +// captain uses: an alias is the identical type, methods included, so nothing +// downstream (including pkg/ai's own re-exports) has to know registry exists. + +type ( + Backend = registry.Backend + Effort = registry.Effort + Model = registry.Model + ModelList = registry.ModelList +) + +const ( + BackendAnthropic = registry.BackendAnthropic + BackendGemini = registry.BackendGemini + BackendOpenAI = registry.BackendOpenAI + BackendDeepSeek = registry.BackendDeepSeek + BackendClaudeCLI = registry.BackendClaudeCLI + BackendCodexCLI = registry.BackendCodexCLI + BackendGeminiCLI = registry.BackendGeminiCLI + BackendClaudeAgent = registry.BackendClaudeAgent + BackendCodexAgent = registry.BackendCodexAgent + BackendClaudeCmux = registry.BackendClaudeCmux + BackendCodexCmux = registry.BackendCodexCmux + + AnthropicProvider = registry.AnthropicProvider + OpenAIProvider = registry.OpenAIProvider + GeminiProvider = registry.GeminiProvider + DeepSeekProvider = registry.DeepSeekProvider + + EffortNone = registry.EffortNone + EffortLow = registry.EffortLow + EffortMedium = registry.EffortMedium + EffortHigh = registry.EffortHigh + EffortXHigh = registry.EffortXHigh + EffortMax = registry.EffortMax + EffortUltra = registry.EffortUltra + + CodexAutoReviewModel = registry.CodexAutoReviewModel +) + +// ErrInferBackend marks the "can't infer a backend from this model name" failure +// so callers can enrich it (e.g. with "did you mean" model suggestions). +var ErrInferBackend = registry.ErrInferBackend + +// AllBackends lists every supported backend in canonical order. +func AllBackends() []Backend { return registry.AllBackends() } + +// BackendList renders AllBackends as a comma-separated string for help/error text. +func BackendList() string { return registry.BackendList() } + +// AuthEnvVars returns the environment variables consulted for a backend's API +// key, in priority order. +func AuthEnvVars(b Backend) []string { return registry.AuthEnvVars(b) } + +// InferBackend resolves the backend from a model name prefix, failing loud when +// the name matches nothing. +func InferBackend(model string) (Backend, error) { return registry.InferBackend(model) } + +// AllEfforts lists the non-empty effort tiers in ascending order. +func AllEfforts() []Effort { return registry.AllEfforts() } diff --git a/pkg/api/enums.go b/pkg/api/enums.go index d4bf197..4300e8d 100644 --- a/pkg/api/enums.go +++ b/pkg/api/enums.go @@ -3,209 +3,18 @@ // Setup, Prompt) and the Spec that composes them. It never imports 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. +// +// Model identity itself (Backend, Effort, Model, the compact grammar, the model +// catalog) lives one level down in pkg/api/registry and is re-exported here by +// alias — see aliases.go. Spec decoding parses model strings, so the parser has +// to sit below this package. package api import ( - "errors" "fmt" "strings" ) -// ErrInferBackend marks the "can't infer a backend from this model name" failure -// so callers can enrich it (e.g. with "did you mean" model suggestions). -var ErrInferBackend = errors.New("unable to infer backend from model name") - -// 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" - BackendDeepSeek Backend = "deepseek" - BackendClaudeCLI Backend = "claude-cli" - BackendCodexCLI Backend = "codex-cli" - BackendGeminiCLI Backend = "gemini-cli" - BackendClaudeAgent Backend = "claude-agent" - BackendCodexAgent Backend = "codex-agent" - // BackendClaudeCmux / BackendCodexCmux drive an interactive claude/codex TUI - // inside a tmux/cmux surface (the cmux provider), tailing the session JSONL. - // They are selected explicitly, not inferred from a model name. - BackendClaudeCmux Backend = "claude-cmux" - BackendCodexCmux Backend = "codex-cmux" -) - -const ( - AnthropicProvider = BackendAnthropic - OpenAIProvider = BackendOpenAI - GeminiProvider = BackendGemini - DeepSeekProvider = BackendDeepSeek -) - -// 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, BackendDeepSeek, - BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux, - BackendCodexCLI, BackendCodexAgent, BackendCodexCmux, 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, BackendDeepSeek: - return "api" - default: - return "cli" - } -} - -// Provider returns the direct API provider family that owns a runtime adapter. -// An invalid backend returns the empty value. -func (b Backend) Provider() Backend { - switch b { - case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux: - return AnthropicProvider - case BackendOpenAI, BackendCodexCLI, BackendCodexAgent, BackendCodexCmux: - return OpenAIProvider - case BackendGemini, BackendGeminiCLI: - return GeminiProvider - case BackendDeepSeek: - return DeepSeekProvider - default: - return "" - } -} - -// AgentsForProvider lists the runtime adapters that can serve a provider family. -func AgentsForProvider(provider Backend) []Backend { - switch provider { - case AnthropicProvider: - return []Backend{BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux} - case OpenAIProvider: - return []Backend{BackendOpenAI, BackendCodexCLI, BackendCodexAgent, BackendCodexCmux} - case GeminiProvider: - return []Backend{BackendGemini, BackendGeminiCLI} - case DeepSeekProvider: - return []Backend{BackendDeepSeek} - default: - return nil - } -} - -// AuthEnvVars returns the environment variables consulted for a backend's API -// key, in priority order. Some CLI backends can use a parent provider key, while -// cmux backends are keyless and rely on the local CLI login. -func AuthEnvVars(b Backend) []string { - switch b { - case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent: - return []string{"ANTHROPIC_API_KEY"} - case BackendOpenAI, BackendCodexCLI, BackendCodexAgent: - return []string{"OPENAI_API_KEY"} - case BackendDeepSeek: - return []string{"DEEPSEEK_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-agent-"): - return BackendCodexAgent, nil - case strings.HasPrefix(m, "codex"): - return BackendCodexCLI, nil - case strings.HasPrefix(m, "gemini-cli-"): - return BackendGeminiCLI, nil - } - - switch { - case strings.HasPrefix(m, "claude-"), strings.HasPrefix(m, "opus"), strings.HasPrefix(m, "sonnet"), strings.HasPrefix(m, "haiku"), strings.HasPrefix(m, "fable"): - 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 - case strings.HasPrefix(m, "deepseek"): - return BackendDeepSeek, nil - } - - return "", fmt.Errorf("%w: %s (pass an explicit backend: %s)", ErrInferBackend, model, BackendList()) -} - -// Effort is the per-request reasoning effort. captain owns this enum (including -// Codex's xhigh/max/ultra tiers); "" means backend default. -type Effort string - -const ( - EffortNone Effort = "" - EffortLow Effort = "low" - EffortMedium Effort = "medium" - EffortHigh Effort = "high" - EffortXHigh Effort = "xhigh" - EffortMax Effort = "max" - EffortUltra Effort = "ultra" -) - -// AllEfforts lists the non-empty effort tiers in ascending order. -func AllEfforts() []Effort { - return []Effort{EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra} -} - -// Valid reports whether e is a recognised effort tier (including none/""). -func (e Effort) Valid() bool { - switch e { - case EffortNone, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra: - 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, max, ultra", e) -} - // SchemaStrictness governs what captain does when a structured-output response // fails validation against the request's JSON schema. "" uses the backend // default, while "none" explicitly disables post-response validation. @@ -278,21 +87,34 @@ func (s VerifyScope) Validate() error { type ToolMode string const ( - ToolModeEnabled ToolMode = "enabled" - ToolModeAsk ToolMode = "ask" - ToolModeDisabled ToolMode = "disabled" + ToolModeOn ToolMode = "on" + ToolModeAsk ToolMode = "ask" + ToolModeOff ToolMode = "off" + ToolModeAuto ToolMode = "auto" ) -// Valid reports whether m is a recognised tool mode. -func (m ToolMode) Valid() bool { - switch m { - case ToolModeEnabled, ToolModeAsk, ToolModeDisabled: - return true +// NormalizeToolMode canonicalizes a mode. +func NormalizeToolMode(m ToolMode) (ToolMode, bool) { + switch ToolMode(strings.ToLower(strings.TrimSpace(string(m)))) { + case ToolModeOn: + return ToolModeOn, true + case ToolModeAsk: + return ToolModeAsk, true + case ToolModeOff: + return ToolModeOff, true + case ToolModeAuto: + return ToolModeAuto, true default: - return false + return "", false } } +// Valid reports whether m is a recognised tool mode. +func (m ToolMode) Valid() bool { + _, ok := NormalizeToolMode(m) + return ok +} + // ToolPolicy is the runtime-spec policy map value for one tool. It keeps the // wire shape close to coding-agent UX: auto, ask, allow, deny. type ToolPolicy string diff --git a/pkg/api/enums_test.go b/pkg/api/enums_test.go index a860edd..836eab8 100644 --- a/pkg/api/enums_test.go +++ b/pkg/api/enums_test.go @@ -5,46 +5,6 @@ import ( "testing" ) -func TestInferBackend(t *testing.T) { - cases := map[string]Backend{ - "claude-sonnet-4-6": BackendAnthropic, - "claude-agent-opus": BackendClaudeAgent, - "claude-code-x": BackendClaudeCLI, - "opus-4-8": BackendAnthropic, - "sonnet": BackendAnthropic, - "gpt-5.5": BackendOpenAI, - "o3": BackendOpenAI, - "gemini-2.5-pro": BackendGemini, - "gemini-cli-pro": BackendGeminiCLI, - "codex-gpt-5-codex": BackendCodexCLI, - "grok-2": BackendCodexCLI, - "deepseek-chat": BackendDeepSeek, - } - 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 TestEffortValidateIncludesCodexTiers(t *testing.T) { - for _, e := range []Effort{EffortNone, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra} { - if err := e.Validate(); err != nil { - t.Errorf("Effort(%q).Validate() = %v, want nil", e, err) - } - } - if err := Effort("extreme").Validate(); err == nil { - t.Error("Effort(extreme).Validate() should fail") - } - if want := []Effort{EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra}; !reflect.DeepEqual(AllEfforts(), want) { - t.Errorf("AllEfforts() = %v, want %v", AllEfforts(), want) - } -} - func TestSchemaStrictnessValidate(t *testing.T) { for _, s := range []SchemaStrictness{SchemaStrictnessNone, SchemaStrictnessDisabled, SchemaStrictnessWarning, SchemaStrictnessError, SchemaStrictnessRetry} { if err := s.Validate(); err != nil { @@ -59,44 +19,6 @@ func TestSchemaStrictnessValidate(t *testing.T) { } } -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 TestBackendProviderAndAgents(t *testing.T) { - tests := map[Backend]Backend{ - BackendAnthropic: AnthropicProvider, - BackendClaudeCLI: AnthropicProvider, - BackendClaudeAgent: AnthropicProvider, - BackendClaudeCmux: AnthropicProvider, - BackendOpenAI: OpenAIProvider, - BackendCodexCLI: OpenAIProvider, - BackendCodexAgent: OpenAIProvider, - BackendCodexCmux: OpenAIProvider, - BackendGemini: GeminiProvider, - BackendGeminiCLI: GeminiProvider, - BackendDeepSeek: DeepSeekProvider, - } - for backend, want := range tests { - if got := backend.Provider(); got != want { - t.Errorf("%s.Provider() = %q, want %q", backend, got, want) - } - } - if got := AgentsForProvider(OpenAIProvider); !reflect.DeepEqual(got, []Backend{ - BackendOpenAI, BackendCodexCLI, BackendCodexAgent, BackendCodexCmux, - }) { - t.Fatalf("AgentsForProvider(openai) = %v", got) - } -} - func TestPermissionModeValid(t *testing.T) { if !PermissionMode("").Valid() || !PermissionAcceptEdits.Valid() || PermissionMode("yolo").Valid() { t.Error("PermissionMode.Valid() wrong") diff --git a/pkg/api/registry/backend.go b/pkg/api/registry/backend.go new file mode 100644 index 0000000..675594d --- /dev/null +++ b/pkg/api/registry/backend.go @@ -0,0 +1,153 @@ +// Package registry owns captain's model identity: the provider descriptors, the +// Backend/Effort/RuntimeMode enums, the Model spec type, the compact model +// grammar, and the embedded model catalog. +// +// It is a leaf — it imports nothing else from captain. That is deliberate: +// pkg/api decodes specs (and therefore parses model strings) during +// unmarshalling, so the parser cannot live above pkg/api without a cycle, and +// splitting the knowledge across both packages is exactly what let the compact +// grammar and the selector grammar drift apart. pkg/api re-exports everything +// here via aliases, so api.Model and api.Backend remain the names callers use. +package registry + +import ( + "errors" + "fmt" + "strings" +) + +// ErrInferBackend marks the "can't infer a backend from this model name" failure +// so callers can enrich it (e.g. with "did you mean" model suggestions). +var ErrInferBackend = errors.New("unable to infer backend from model name") + +// Backend is the provider/runtime that serves a request: exactly one +// (Provider, RuntimeMode) pair. This is the canonical definition; pkg/api +// re-exports it via `type Backend = registry.Backend`. +// +// Its string values are frozen — they are persisted in specs, session rows, and +// the webapp wire format. +type Backend string + +const ( + BackendAnthropic Backend = "anthropic" + BackendGemini Backend = "gemini" + BackendOpenAI Backend = "openai" + BackendDeepSeek Backend = "deepseek" + BackendClaudeCLI Backend = "claude-cli" + BackendCodexCLI Backend = "codex-cli" + BackendGeminiCLI Backend = "gemini-cli" + BackendClaudeAgent Backend = "claude-agent" + BackendCodexAgent Backend = "codex-agent" + // BackendClaudeCmux / BackendCodexCmux drive an interactive claude/codex TUI + // inside a tmux/cmux surface (the cmux provider), tailing the session JSONL. + // They are selected explicitly, not inferred from a model name. + BackendClaudeCmux Backend = "claude-cmux" + BackendCodexCmux Backend = "codex-cmux" +) + +const ( + AnthropicProvider = BackendAnthropic + OpenAIProvider = BackendOpenAI + GeminiProvider = BackendGemini + DeepSeekProvider = BackendDeepSeek +) + +// 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, BackendDeepSeek, + BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux, + BackendCodexCLI, BackendCodexAgent, BackendCodexCmux, BackendGeminiCLI, + } +} + +// Valid reports whether b is one of the supported backends. +func (b Backend) Valid() bool { + _, _, ok := ProviderFor(b) + return ok +} + +// Mode returns the runtime mechanism this backend represents, or "" if invalid. +func (b Backend) Mode() RuntimeMode { + _, mode, _ := ProviderFor(b) + return mode +} + +// ModelProvider returns the descriptor for the family that owns this backend, +// or nil when the backend is invalid. +func (b Backend) ModelProvider() *Provider { + p, _, _ := ProviderFor(b) + return p +} + +// 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 { + if b.Mode() == ModeAPI { + return "api" + } + return "cli" +} + +// Provider returns the direct API provider family that owns a runtime adapter. +// An invalid backend returns the empty value. +func (b Backend) Provider() Backend { + p, _, ok := ProviderFor(b) + if !ok { + return "" + } + backend, err := p.BackendFor(ModeAPI) + if err != nil { + return "" + } + return backend +} + +// Family returns the model family a backend serves (claude | codex | gemini | +// deepseek), or "" for an unrecognised backend. +func (b Backend) Family() string { + p, _, ok := ProviderFor(b) + if !ok { + return "" + } + return p.AgentName +} + +// AuthEnvVars returns the environment variables consulted for a backend's API +// key, in priority order. Some CLI backends can use a parent provider key, while +// cmux backends are keyless and rely on the local CLI login. +func AuthEnvVars(b Backend) []string { + p, mode, ok := ProviderFor(b) + if !ok { + return nil + } + if caps, ok := p.Caps(mode); ok && caps.Keyless { + return nil + } + return p.SupportedEnvVars() +} + +// 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 a model name implies, failing loud when no +// provider claims the name (the caller must then pass an explicit backend). +// +// It reads the same claim table as the rest of the parser. It used to carry its +// own prefix switch, which knew about grok but not sora or the codex codenames, +// while pkg/ai's two claim tables each knew a different subset — so the answer +// depended on which entry point you came through. +func InferBackend(model string) (Backend, error) { + p, _, mode, ok := ProviderForToken(model) + if !ok { + return "", fmt.Errorf("%w: %s (pass an explicit backend: %s)", ErrInferBackend, model, BackendList()) + } + return p.BackendFor(mode) +} diff --git a/pkg/api/registry/backend_test.go b/pkg/api/registry/backend_test.go new file mode 100644 index 0000000..852c00b --- /dev/null +++ b/pkg/api/registry/backend_test.go @@ -0,0 +1,87 @@ +package registry + +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, + "opus-4-8": BackendAnthropic, + "sonnet": BackendAnthropic, + "gpt-5.5": BackendOpenAI, + "o3": BackendOpenAI, + "gemini-2.5-pro": BackendGemini, + "gemini-cli-pro": BackendGeminiCLI, + "codex-gpt-5-codex": BackendCodexCLI, + "grok-2": BackendCodexCLI, + "deepseek-chat": BackendDeepSeek, + } + 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 TestEffortValidateIncludesCodexTiers(t *testing.T) { + for _, e := range []Effort{EffortNone, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra} { + if err := e.Validate(); err != nil { + t.Errorf("Effort(%q).Validate() = %v, want nil", e, err) + } + } + if err := Effort("extreme").Validate(); err == nil { + t.Error("Effort(extreme).Validate() should fail") + } + if want := []Effort{EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra}; !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 TestBackendProviderAndAgents(t *testing.T) { + tests := map[Backend]Backend{ + BackendAnthropic: AnthropicProvider, + BackendClaudeCLI: AnthropicProvider, + BackendClaudeAgent: AnthropicProvider, + BackendClaudeCmux: AnthropicProvider, + BackendOpenAI: OpenAIProvider, + BackendCodexCLI: OpenAIProvider, + BackendCodexAgent: OpenAIProvider, + BackendCodexCmux: OpenAIProvider, + BackendGemini: GeminiProvider, + BackendGeminiCLI: GeminiProvider, + BackendDeepSeek: DeepSeekProvider, + } + for backend, want := range tests { + if got := backend.Provider(); got != want { + t.Errorf("%s.Provider() = %q, want %q", backend, got, want) + } + } + // Provider.Backends replaces AgentsForProvider. The two lists it unifies + // disagreed on order (AgentsForProvider put the CLI before the agent, the + // wildcard fan-out the reverse); the user-visible wildcard order wins. + if got := OpenAI.Backends(); !reflect.DeepEqual(got, []Backend{ + BackendOpenAI, BackendCodexAgent, BackendCodexCLI, BackendCodexCmux, + }) { + t.Fatalf("OpenAI.Backends() = %v", got) + } +} diff --git a/pkg/api/registry/effort.go b/pkg/api/registry/effort.go new file mode 100644 index 0000000..cd31f82 --- /dev/null +++ b/pkg/api/registry/effort.go @@ -0,0 +1,40 @@ +package registry + +import "fmt" + +// Effort is the per-request reasoning effort. captain owns this enum (including +// Codex's xhigh/max/ultra tiers); "" means backend default. +type Effort string + +const ( + EffortNone Effort = "" + EffortLow Effort = "low" + EffortMedium Effort = "medium" + EffortHigh Effort = "high" + EffortXHigh Effort = "xhigh" + EffortMax Effort = "max" + EffortUltra Effort = "ultra" +) + +// AllEfforts lists the non-empty effort tiers in ascending order. +func AllEfforts() []Effort { + return []Effort{EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra} +} + +// Valid reports whether e is a recognised effort tier (including none/""). +func (e Effort) Valid() bool { + switch e { + case EffortNone, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra: + 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, max, ultra", e) +} diff --git a/pkg/api/registry/effort_support.go b/pkg/api/registry/effort_support.go new file mode 100644 index 0000000..7b27d02 --- /dev/null +++ b/pkg/api/registry/effort_support.go @@ -0,0 +1,54 @@ +package registry + +import ( + "fmt" + "strings" +) + +// ModelEfforts returns the effort tiers a backend/model pair accepts, when the +// catalog knows the combination. ok is false for models the catalog has never +// heard of, which keeps effort validation permissive for brand-new ids. +// +// The lookup is exact: callers pass an already-resolved id, and resolving again +// here would answer for a sibling model (see RegistryModelDef). +func ModelEfforts(backend Backend, model string) (supported []Effort, defaultEffort Effort, ok bool) { + p, mode, found := ProviderFor(backend) + if !found { + return nil, EffortNone, false + } + m, found := p.Lookup(model) + if !found || !p.availableFor(m, mode) { + return nil, EffortNone, false + } + return append([]Effort(nil), m.SupportedEfforts...), m.DefaultEffort, true +} + +// ValidateEffort enforces model-aware effort tiers without requiring the catalog +// to be exhaustive: an unknown model accepts any valid tier, while a known one +// accepts only the tiers the catalog records for it. +func ValidateEffort(backend Backend, model string, effort Effort) error { + if err := effort.Validate(); err != nil { + return err + } + if effort == EffortNone { + return nil + } + supported, _, known := ModelEfforts(backend, model) + if !known { + return nil + } + if len(supported) == 0 { + return fmt.Errorf("model %q on %s does not support a reasoning effort", model, backend) + } + for _, candidate := range supported { + if candidate == effort { + return nil + } + } + values := make([]string, 0, len(supported)) + for _, candidate := range supported { + values = append(values, string(candidate)) + } + return fmt.Errorf("model %q on %s does not support reasoning effort %q; want one of: %s", + model, backend, effort, strings.Join(values, ", ")) +} diff --git a/pkg/api/registry/embed.go b/pkg/api/registry/embed.go new file mode 100644 index 0000000..8276fd3 --- /dev/null +++ b/pkg/api/registry/embed.go @@ -0,0 +1,71 @@ +package registry + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// modelsJSON is the generated model catalog produced by `task models:update` +// (pkg/ai/internal/gen-model-registry). It is the models.dev snapshot with that +// generator's patches.json overlaid. Regenerate it after editing patches.json. +// +//go:embed models.json +var modelsJSON []byte + +// KnownModel is one row of the generated catalog: a provider-native model id +// plus the capability metadata captain needs to route, price, and gate it. +// +// Aliases and SupersededBy are data, not code, on purpose. They used to be +// hardcoded switches in pkg/ai, which meant pkg/api's spec decoder could not see +// them and rejected models the pkg/ai selector accepted — `--model agent:sol` +// errored while the same value in prompt frontmatter ran. Keeping them here is +// what lets a single parser serve both entry points. +type KnownModel struct { + ID string `json:"id"` + Provider string `json:"provider"` + Family string `json:"family"` + Version string `json:"version"` + Label string `json:"label"` + ReleaseDate string `json:"releaseDate,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Temperature bool `json:"temperature,omitempty"` + ContextWindow int `json:"contextWindow,omitempty"` + InputMediaTypes []string `json:"inputMediaTypes,omitempty"` + Preferred bool `json:"preferred,omitempty"` + AdaptiveThinking bool `json:"adaptiveThinking,omitempty"` + Availability []string `json:"availability,omitempty"` + SupportedEfforts []Effort `json:"supportedEfforts,omitempty"` + DefaultEffort Effort `json:"defaultEffort,omitempty"` + Priority int `json:"priority,omitempty"` + + // Aliases are extra input tokens that resolve to this model — the codenames + // "sol"/"terra"/"luna" on the gpt-5.6-* line. Input conveniences only: an + // alias is never returned to a caller. + Aliases []string `json:"aliases,omitempty"` + // SupersededBy names the model that replaces this one. A superseded id still + // parses, so old specs keep loading, but resolves to its successor. + SupersededBy string `json:"supersededBy,omitempty"` +} + +// knownModels is the parsed model catalog. It is a package-level var (not an +// init) so Go's initialization ordering guarantees it is populated before the +// provider descriptors, which index it. +var knownModels = mustParseModels(modelsJSON) + +func mustParseModels(data []byte) []KnownModel { + var models []KnownModel + if err := json.Unmarshal(data, &models); err != nil { + panic(fmt.Sprintf("pkg/api/registry: invalid embedded models.json: %v", err)) + } + if len(models) == 0 { + panic("pkg/api/registry: embedded models.json is empty") + } + return models +} + +// KnownModels returns every catalog row. The slice is copied so callers cannot +// mutate the registry. +func KnownModels() []KnownModel { + return append([]KnownModel(nil), knownModels...) +} diff --git a/pkg/api/registry/embed_test.go b/pkg/api/registry/embed_test.go new file mode 100644 index 0000000..28f1cb3 --- /dev/null +++ b/pkg/api/registry/embed_test.go @@ -0,0 +1,81 @@ +package registry + +import "testing" + +// TestEmbeddedRegistryLoads guards the go:embed + JSON parse path: the generated +// models.json must load into a non-empty slice whose every entry is well-formed +// and provider-tagged with a known provider. +func TestEmbeddedRegistryLoads(t *testing.T) { + if len(knownModels) == 0 { + t.Fatal("knownModels is empty; models.json failed to load") + } + for _, m := range knownModels { + if m.ID == "" || m.Provider == "" || m.Family == "" { + t.Errorf("entry %+v is missing id/provider/family", m) + } + if _, ok := ProviderByName(m.Provider); !ok { + t.Errorf("entry %q has unknown provider %q", m.ID, m.Provider) + } + } +} + +// TestRegistryDerivedCapabilities locks the capability flags the generator +// derives from models.dev: reasoning, temperature support, and the adaptive-vs- +// enabled thinking schema. The opus-4-8/4-7 adaptive values guard the 400 that +// legacy `thinking:{type:enabled}` triggers on those models. +func TestRegistryDerivedCapabilities(t *testing.T) { + cases := []struct { + id string + wantReasoning bool + wantTemp bool + wantPreferred bool + wantAdaptive bool + }{ + {"claude-sonnet-5", true, false, true, true}, + {"claude-fable-5", true, false, true, true}, + {"claude-opus-4-8", true, false, true, true}, + {"claude-opus-4-7", true, false, false, true}, + {"claude-sonnet-4-6", true, true, true, false}, + {"claude-haiku-4-5", true, true, true, false}, + } + for _, tc := range cases { + t.Run(tc.id, func(t *testing.T) { + m, ok := Anthropic.Lookup(tc.id) + if !ok { + t.Fatalf("model %q not found in registry", tc.id) + } + if m.Reasoning != tc.wantReasoning { + t.Errorf("Reasoning = %v, want %v", m.Reasoning, tc.wantReasoning) + } + if m.Temperature != tc.wantTemp { + t.Errorf("Temperature = %v, want %v", m.Temperature, tc.wantTemp) + } + if m.Preferred != tc.wantPreferred { + t.Errorf("Preferred = %v, want %v", m.Preferred, tc.wantPreferred) + } + if m.AdaptiveThinking != tc.wantAdaptive { + t.Errorf("AdaptiveThinking = %v, want %v", m.AdaptiveThinking, tc.wantAdaptive) + } + }) + } +} + +// TestRegistryDataCarriesAliasesAndSuccessors pins the knowledge that used to be +// hardcoded in pkg/ai (normalizeCodexVariantAlias, isSupersededRegistryExact) and +// is now catalog data, reachable from every entry point. +func TestRegistryDataCarriesAliasesAndSuccessors(t *testing.T) { + for alias, want := range map[string]string{"sol": "gpt-5.6-sol", "terra": "gpt-5.6-terra", "luna": "gpt-5.6-luna"} { + if got := resolveAlias(alias); got != want { + t.Errorf("resolveAlias(%q) = %q, want %q", alias, got, want) + } + } + for _, retired := range []string{"claude-sonnet-4-5", "claude-sonnet-4-5-20250929"} { + m, ok := Anthropic.Lookup(retired) + if !ok { + t.Fatalf("retired model %q missing from registry", retired) + } + if m.SupersededBy != "claude-sonnet-4-6" { + t.Errorf("%q supersededBy = %q, want claude-sonnet-4-6", retired, m.SupersededBy) + } + } +} diff --git a/pkg/api/registry/errors.go b/pkg/api/registry/errors.go new file mode 100644 index 0000000..b8f9456 --- /dev/null +++ b/pkg/api/registry/errors.go @@ -0,0 +1,155 @@ +package registry + +import ( + "context" + "errors" + "net" + "net/url" + "regexp" + "strings" +) + +// ErrorClass is what went wrong with a provider call, normalized across +// providers so retry, fallback, and budget logic can reason about it without +// re-reading error prose. +type ErrorClass string + +const ( + // ErrorNone means "not a recognized provider failure". + ErrorNone ErrorClass = "" + // ErrorRateLimit is a 429 / quota exhaustion. Retryable, ideally after a wait. + ErrorRateLimit ErrorClass = "rate_limit" + // ErrorOverloaded is a 503/529 or explicit overload. Retryable. + ErrorOverloaded ErrorClass = "overloaded" + // ErrorNetwork is a transport failure: dial, reset, EOF, deadline. Retryable. + ErrorNetwork ErrorClass = "network" + // ErrorContextLength means the request exceeded the model's context window. + // NOT retryable: the identical request fails identically, so a retry only + // burns tokens. Recoverable by trimming input or moving to a larger model. + ErrorContextLength ErrorClass = "context_length" + // ErrorAuth is a missing/rejected credential. Not retryable. + ErrorAuth ErrorClass = "auth" + // ErrorModelUnavailable is a provider-confirmed model selection failure. Not + // retryable on the same model; an explicit fallback may still recover. + ErrorModelUnavailable ErrorClass = "model_unavailable" + // ErrorInvalidRequest is a malformed request. Not retryable. + ErrorInvalidRequest ErrorClass = "invalid_request" +) + +// Retryable reports whether the same request may succeed if tried again. +func (c ErrorClass) Retryable() bool { + switch c { + case ErrorRateLimit, ErrorOverloaded, ErrorNetwork: + return true + default: + return false + } +} + +// httpStatusError is implemented by errors that carry a real HTTP status. Status +// is read structurally where available, because reading it out of prose is how +// captain used to classify "1429 tokens" as a rate limit. +type httpStatusError interface{ StatusCode() int } + +// Status codes must stand alone. `strings.Contains(msg, "429")` — the previous +// implementation — also matched "1429 tokens", "id=4290", and "request 5031", +// turning ordinary messages into spurious retries. +// +// \b is not enough either: it treats "-" and "." as boundaries, so "gpt-429-turbo" +// and version strings like "4.29.1" would still match. The delimiter set +// therefore excludes word characters, "." and "-", leaving whitespace, +// punctuation, and string edges. +const ( + statusPrefix = `(^|[^\w.-])` + statusSuffix = `([^\w.-]|$)` +) + +var ( + reRateLimitStatus = regexp.MustCompile(statusPrefix + `429` + statusSuffix) + reOverloadedStatus = regexp.MustCompile(statusPrefix + `(503|529)` + statusSuffix) + reAuthStatus = regexp.MustCompile(statusPrefix + `(401|403)` + statusSuffix) +) + +// ClassifyError applies the provider-independent heuristics: structured signals +// first (wrapped sentinels, net.Error, url.Error, HTTP status), then bounded +// text matching. Providers refine what is left via Provider.ClassifyError. +func ClassifyError(err error) ErrorClass { + if err == nil { + return ErrorNone + } + + // 1. Structure beats prose. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return ErrorNetwork + } + var status httpStatusError + if errors.As(err, &status) { + switch code := status.StatusCode(); { + case code == 429: + return ErrorRateLimit + case code == 503 || code == 529: + return ErrorOverloaded + case code == 401 || code == 403: + return ErrorAuth + case code == 404: + return ErrorModelUnavailable + case code >= 500: + return ErrorOverloaded + case code == 400 || code == 422: + return ErrorInvalidRequest + } + } + var netErr net.Error + if errors.As(err, &netErr) { + return ErrorNetwork + } + var urlErr *url.Error + if errors.As(err, &urlErr) { + return ErrorNetwork + } + + // 2. Bounded text matching for providers that only return prose. + msg := strings.ToLower(err.Error()) + switch { + case containsAny(msg, "context length", "context window", "maximum context", + "context_length_exceeded", "too many tokens", "prompt is too long", + "request too large", "string too long"): + return ErrorContextLength + case reRateLimitStatus.MatchString(msg), strings.Contains(msg, "rate limit"), + strings.Contains(msg, "rate_limit"), strings.Contains(msg, "quota exceeded"), + strings.Contains(msg, "resource_exhausted"), strings.Contains(msg, "too many requests"): + return ErrorRateLimit + case reOverloadedStatus.MatchString(msg), strings.Contains(msg, "overloaded"), + strings.Contains(msg, "server_error"), strings.Contains(msg, "service unavailable"), + strings.Contains(msg, "try again later"): + return ErrorOverloaded + case containsAny(msg, "timeout", "timed out", "connection reset", "connection refused", + "broken pipe", "unexpected eof", "no such host", "tls handshake"): + return ErrorNetwork + case reAuthStatus.MatchString(msg), containsAny(msg, "unauthorized", "invalid api key", + "invalid_api_key", "authentication", "permission denied", "missing api key", + "no api key", "api key is not set", "api_key is not set"): + return ErrorAuth + } + return ErrorNone +} + +// ClassifyError refines the shared verdict with this provider's own error +// vocabulary. Providers set classifyErr only where their wording is genuinely +// their own; everything common stays in the shared heuristics. +func (p *Provider) ClassifyError(err error) ErrorClass { + base := ClassifyError(err) + if err == nil || p.classifyErr == nil { + return base + } + return p.classifyErr(err, base) +} + +func containsAny(haystack string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(haystack, needle) { + return true + } + } + return false +} diff --git a/pkg/api/registry/errors_test.go b/pkg/api/registry/errors_test.go new file mode 100644 index 0000000..ec5511d --- /dev/null +++ b/pkg/api/registry/errors_test.go @@ -0,0 +1,136 @@ +package registry + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "testing" +) + +// statusErr is a provider error carrying a real HTTP status. +type statusErr struct { + code int + msg string +} + +func (e statusErr) Error() string { return e.msg } +func (e statusErr) StatusCode() int { return e.code } + +// TestClassifyErrorReadsStructureNotProse pins that a real status code decides +// the class even when the message says nothing about it. +func TestClassifyErrorReadsStructureNotProse(t *testing.T) { + cases := []struct { + code int + want ErrorClass + }{ + {429, ErrorRateLimit}, + {503, ErrorOverloaded}, + {529, ErrorOverloaded}, + {500, ErrorOverloaded}, + {401, ErrorAuth}, + {403, ErrorAuth}, + {404, ErrorModelUnavailable}, + {400, ErrorInvalidRequest}, + } + for _, tc := range cases { + t.Run(fmt.Sprint(tc.code), func(t *testing.T) { + err := fmt.Errorf("generate: %w", statusErr{code: tc.code, msg: "provider said no"}) + if got := ClassifyError(err); got != tc.want { + t.Errorf("ClassifyError(status %d) = %q, want %q", tc.code, got, tc.want) + } + }) + } +} + +// TestClassifyErrorDoesNotMatchNumbersInProse is the regression this refactor +// exists to prevent. The previous implementation did +// strings.Contains(msg, "429"), so any message that merely contained those +// digits — a token count, an id, a byte offset — was treated as a rate limit and +// retried for nothing. +func TestClassifyErrorDoesNotMatchNumbersInProse(t *testing.T) { + falsePositives := []string{ + "request rejected: 1429 tokens exceeds nothing in particular", + "model gpt-429-turbo is fine", + "trace id=4290 completed", + "finished after 5031 ms", + "embedding dimension 15039 mismatch", + } + for _, msg := range falsePositives { + t.Run(msg, func(t *testing.T) { + if got := ClassifyError(errors.New(msg)); got.Retryable() { + t.Errorf("ClassifyError(%q) = %q, which retries; digits inside prose are not a status code", msg, got) + } + }) + } + + // A real status in prose still classifies: the boundary is the point, not + // refusing to read text at all. + for msg, want := range map[string]ErrorClass{ + "provider returned 429 Too Many Requests": ErrorRateLimit, + "upstream 503 service unavailable": ErrorOverloaded, + "HTTP 401 unauthorized": ErrorAuth, + } { + if got := ClassifyError(errors.New(msg)); got != want { + t.Errorf("ClassifyError(%q) = %q, want %q", msg, got, want) + } + } +} + +func TestClassifyErrorNetworkAndContext(t *testing.T) { + cases := map[string]struct { + err error + want ErrorClass + }{ + "deadline": {context.DeadlineExceeded, ErrorNetwork}, + "canceled": {context.Canceled, ErrorNetwork}, + "url error": {&url.Error{Op: "Post", URL: "https://api.example", Err: errors.New("dial tcp: connect: connection refused")}, ErrorNetwork}, + "net error": {&net.OpError{Op: "dial", Err: errors.New("connection refused")}, ErrorNetwork}, + "reset": {errors.New("read: connection reset by peer"), ErrorNetwork}, + "context len": {errors.New("This model's maximum context length is 200000 tokens"), ErrorContextLength}, + "ctx len code": {errors.New("context_length_exceeded"), ErrorContextLength}, + "prompt long": {errors.New("prompt is too long: 250000 tokens > 200000 maximum"), ErrorContextLength}, + "nil": {nil, ErrorNone}, + "unknown": {errors.New("something else entirely"), ErrorNone}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := ClassifyError(tc.err); got != tc.want { + t.Errorf("ClassifyError(%v) = %q, want %q", tc.err, got, tc.want) + } + }) + } +} + +// TestContextLengthIsNotRetryable: an oversized request fails identically on +// every retry, so retrying it only spends tokens to reach the same error. +func TestContextLengthIsNotRetryable(t *testing.T) { + if ErrorContextLength.Retryable() { + t.Error("ErrorContextLength must not be retryable: the same request fails the same way") + } + // A context-length message that also contains a token count must not be + // rescued into a retryable class by the digits it carries. + err := errors.New("prompt is too long: 429000 tokens > 200000 maximum") + if got := ClassifyError(err); got != ErrorContextLength { + t.Errorf("ClassifyError(%v) = %q, want %q; the token count must not win over the real cause", err, got, ErrorContextLength) + } +} + +func TestRetryableClasses(t *testing.T) { + retryable := map[ErrorClass]bool{ + ErrorRateLimit: true, + ErrorOverloaded: true, + ErrorNetwork: true, + ErrorContextLength: false, + ErrorAuth: false, + ErrorModelUnavailable: false, + ErrorInvalidRequest: false, + ErrorNone: false, + } + for class, want := range retryable { + if got := class.Retryable(); got != want { + t.Errorf("%q.Retryable() = %v, want %v", class, got, want) + } + } +} diff --git a/pkg/api/registry/generation.go b/pkg/api/registry/generation.go new file mode 100644 index 0000000..3461439 --- /dev/null +++ b/pkg/api/registry/generation.go @@ -0,0 +1,99 @@ +package registry + +// defaultMaxOutputTokens is the visible-answer budget Anthropic requires on every +// request; the extended-thinking budget is added on top of it. +const defaultMaxOutputTokens = 4096 + +// GenerationConfig builds the provider-native generation config for one request, +// translating reasoning effort and temperature into this provider's controls and +// gating them by the model's catalog capabilities: +// +// - effort is dropped for models that do not support reasoning; +// - temperature is dropped for models that do not support it; +// - Anthropic reasoning uses the adaptive schema (thinking:{type:adaptive} + +// output_config.effort) for adaptive models and the legacy enabled schema +// (thinking:{type:enabled, budget_tokens}) otherwise. +// +// Returns nil when there is nothing to send. +func (p *Provider) GenerationConfig(mode RuntimeMode, model string, effort Effort, maxTokens int, temperature *float64) map[string]any { + caps := p.modelCaps(mode, model) + cfg := map[string]any{} + if temperature != nil && caps.Temperature { + cfg["temperature"] = *temperature + } + if p.genConfig != nil { + p.genConfig(cfg, caps, effort, maxTokens) + } + if len(cfg) == 0 { + return nil + } + return cfg +} + +// modelCaps resolves a model token to its catalog row so its capability flags can +// gate the generation config. An unresolved model reports no capabilities. +func (p *Provider) modelCaps(mode RuntimeMode, model string) KnownModel { + exact, ok := p.ResolveExact(mode, model) + if !ok { + return KnownModel{} + } + m, _ := p.lookupExact(exact) + return m +} + +// openaiGenerationConfig sends the reasoning tier as-is; captain's effort enum is +// validated against the model before execution. +func openaiGenerationConfig(cfg map[string]any, caps KnownModel, effort Effort, _ int) { + if caps.Reasoning && effort != EffortNone { + cfg["reasoning_effort"] = string(effort) + } +} + +func googleGenerationConfig(cfg map[string]any, caps KnownModel, effort Effort, _ int) { + if caps.Reasoning && effort != EffortNone { + cfg["thinkingConfig"] = map[string]any{"thinkingBudget": thinkingBudget(effort)} + } +} + +// anthropicGenerationConfig always sends max_tokens, which Anthropic requires. +// The thinking budget counts inside max_tokens, so room for the visible answer is +// reserved on top of it — but only when thinking is actually sent. +func anthropicGenerationConfig(cfg map[string]any, caps KnownModel, effort Effort, maxTokens int) { + base := maxTokens + if base <= 0 { + base = defaultMaxOutputTokens + } + if !caps.Reasoning || effort == EffortNone { + cfg["max_tokens"] = base + return + } + budget := thinkingBudget(effort) + cfg["max_tokens"] = base + budget + if caps.AdaptiveThinking { + cfg["thinking"] = map[string]any{"type": "adaptive"} + cfg["output_config"] = map[string]any{"effort": string(effort)} + return + } + cfg["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget} +} + +// DeepSeek selects reasoning by model id (deepseek-reasoner vs deepseek-chat) +// rather than a per-request knob, so it contributes no effort config — hence no +// genConfig hook on that descriptor. + +// thinkingBudget maps a reasoning effort to an extended-thinking token budget +// (shared by Anthropic enabled-thinking and Gemini). xhigh is captain's top tier. +func thinkingBudget(e Effort) int { + switch e { + case EffortLow: + return 2048 + case EffortMedium: + return 8192 + case EffortHigh: + return 24576 + case EffortXHigh, EffortMax, EffortUltra: + return 32768 + default: + return 0 + } +} diff --git a/pkg/api/registry/identity.go b/pkg/api/registry/identity.go new file mode 100644 index 0000000..0cfa8f8 --- /dev/null +++ b/pkg/api/registry/identity.go @@ -0,0 +1,464 @@ +package registry + +import ( + "sort" + "strings" +) + +// ModelIdentity is captain's parsed model key. It is deliberately provider +// native: aliases and mode prefixes are input conveniences only, never the value +// sent to a backend. +type ModelIdentity struct { + Provider string + Family string + Version string +} + +// ProviderForToken returns the provider that claims a model token, along with +// the token stripped of any provider namespace and the mode its own prefix +// implies. It is the single claim step: what InferBackend, splitModelProvider, +// and selectorModelFamily each used to answer differently. +// +// An explicit namespace ("anthropic/…", "googleai/…") wins. Otherwise the +// providers are tried in canonical order and the first claim wins; failing that, +// a multi-segment id is retried on its last segment so proxied names such as +// "openrouter/anthropic/claude-x" still resolve. +func ProviderForToken(token string) (*Provider, string, RuntimeMode, bool) { + token = strings.TrimSpace(token) + if token == "" { + return nil, "", "", false + } + // Claim on the alias' target: a codename such as "sol" names no family by + // itself. Doing this here rather than deep in the resolver is what makes + // `--model sol` and `--model agent:sol` agree — the selector grammar used to + // resolve aliases before claiming while the compact grammar did not, so the + // two disagreed about whether the model existed at all. + if aliased := resolveAlias(token); aliased != token { + token = aliased + } + if i := strings.IndexByte(token, '/'); i >= 0 { + if p, ok := ProviderByName(token[:i]); ok { + rest := strings.TrimPrefix(token[i+1:], "models/") + mode, claimed := p.claim(rest) + if !claimed { + mode = ModeAPI + } + return p, rest, mode, true + } + } + canonical := canonicalModelToken(token) + for _, p := range Providers() { + if mode, ok := p.claim(canonical); ok { + return p, strings.TrimPrefix(token, "models/"), mode, true + } + } + // Retry on the last path segment: an id proxied through another namespace + // ("openrouter/anthropic/claude-x") keeps its full name but resolves off the + // family it actually names. + if i := strings.LastIndexByte(token, '/'); i >= 0 { + for _, p := range Providers() { + if mode, ok := p.claim(canonicalModelToken(token[i+1:])); ok { + return p, token, mode, true + } + } + } + return nil, "", "", false +} + +// StripProviderPrefix removes any known provider namespace from a model id. +func StripProviderPrefix(model string) string { + p, token, _, ok := ProviderForToken(model) + if !ok { + return strings.TrimPrefix(strings.TrimSpace(model), "models/") + } + return strings.TrimPrefix(strings.TrimSpace(p.bareID(token)), "models/") +} + +func canonicalModelToken(model string) string { + model = strings.ToLower(strings.TrimSpace(model)) + model = strings.TrimPrefix(model, "models/") + model = strings.ReplaceAll(model, "_", "-") + model = strings.ReplaceAll(model, " ", "-") + return model +} + +// resolveAlias maps a codename onto its exact model id ("sol" → "gpt-5.6-sol"). +// Aliases are registry data, so every entry point sees them. +func resolveAlias(model string) string { + needle := canonicalModelToken(model) + for _, m := range knownModels { + for _, alias := range m.Aliases { + if canonicalModelToken(alias) == needle { + return m.ID + } + } + } + return strings.TrimSpace(model) +} + +// ParseIdentity parses a model token into this provider's family/version tuple. +// It is deliberately permissive about input prefixes. +func (p *Provider) ParseIdentity(model string) (ModelIdentity, bool) { + token := canonicalModelToken(p.bareID(model)) + for _, trim := range p.identityTrim { + token = strings.TrimPrefix(token, trim) + } + for _, empty := range p.emptyTokens { + if token == empty && p.emptyFamily != "" { + return ModelIdentity{Provider: p.Name, Family: p.emptyFamily}, true + } + } + family, version, ok := splitKnownFamily(token, p.families) + return ModelIdentity{Provider: p.Name, Family: family, Version: version}, ok +} + +func splitKnownFamily(token string, families []string) (family, version string, ok bool) { + for _, candidate := range families { + if token == candidate { + return candidate, "", true + } + prefix := candidate + "-" + if strings.HasPrefix(token, prefix) { + return candidate, normalizeModelVersion(strings.TrimPrefix(token, prefix)), true + } + } + return "", "", false +} + +// availableFor reports whether a catalog row is offered on a mode. The registry +// annotates rows with "api" or "codex" availability; the codex modes are the +// only ones that read the codex list. +func (p *Provider) availableFor(m KnownModel, mode RuntimeMode) bool { + if len(m.Availability) == 0 { + return true + } + target := "api" + if p == OpenAI && mode != ModeAPI { + target = "codex" + } + for _, available := range m.Availability { + if strings.EqualFold(strings.TrimSpace(available), target) { + return true + } + } + return false +} + +func (p *Provider) lookupExact(model string) (KnownModel, bool) { + needle := canonicalModelToken(StripProviderPrefix(model)) + for _, m := range knownModels { + if m.Provider != p.Name { + continue + } + if canonicalModelToken(m.ID) == needle { + return m, true + } + } + return KnownModel{}, false +} + +// ResolveExact resolves a user-written model token into the exact id this +// provider's mode should receive. It accepts aliases, namespaces, superseded +// ids, and family shorthands, and never returns any of them. +// +// ok is false when the token could not be resolved to a catalog row; the token +// is still returned (namespace-stripped) so an unknown-but-explicit model still +// reaches its backend — that is how new provider models work before the catalog +// snapshot catches up. +func (p *Provider) ResolveExact(mode RuntimeMode, model string) (string, bool) { + model = strings.TrimSpace(model) + if model == "" { + return "", false + } + // A bare coding-agent sentinel ("codex") means "this provider's current + // model". It is not honoured on the API mode, where "claude" stays a literal. + if mode != ModeAPI && p.AgentName != "" && strings.EqualFold(model, p.AgentName) { + if m, ok := p.latestModel(mode, ""); ok { + return m.ID, true + } + return model, false + } + model = resolveAlias(model) + if m, ok := p.lookupExact(model); ok { + if m.SupersededBy != "" { + if successor, ok := p.lookupExact(m.SupersededBy); ok && p.availableFor(successor, mode) { + return successor.ID, true + } + } else if p.availableFor(m, mode) { + return m.ID, true + } + } + identity, ok := p.ParseIdentity(model) + if !ok { + return StripProviderPrefix(model), false + } + if shouldResolveLatestVersionLine(identity.Version) { + if m, ok := p.latestModelForVersionLine(mode, identity); ok { + return m.ID, true + } + } + if m, ok := p.resolveIdentity(mode, identity); ok { + return m.ID, true + } + if identity.Version != "" { + return StripProviderPrefix(model), false + } + if m, ok := p.latestModel(mode, identity.Family); ok { + return m.ID, true + } + return StripProviderPrefix(model), false +} + +// Availability distinguishes an unknown model from a catalog model that is +// intentionally not offered on a mode. +func (p *Provider) Availability(mode RuntimeMode, model string) (known, available bool) { + m, ok := p.lookupExact(resolveAlias(model)) + if !ok { + return false, false + } + return true, p.availableFor(m, mode) +} + +// Lookup returns the catalog row for a model id on this provider, accepting +// aliases and provider namespaces. +func (p *Provider) Lookup(model string) (KnownModel, bool) { + return p.lookupExact(resolveAlias(model)) +} + +func (p *Provider) resolveIdentity(mode RuntimeMode, identity ModelIdentity) (KnownModel, bool) { + candidates := make([]KnownModel, 0) + fallback := make([]KnownModel, 0) + for _, m := range knownModels { + if m.Provider != identity.Provider || m.Family != identity.Family || !p.availableFor(m, mode) { + continue + } + if identity.Version == "" || modelVersionMatches(m.Version, identity.Version) { + if m.Preferred { + candidates = append(candidates, m) + } else if identity.Version != "" { + fallback = append(fallback, m) + } + } + } + if len(candidates) == 0 { + candidates = fallback + } + if len(candidates) == 0 { + return KnownModel{}, false + } + sortModels(candidates) + return candidates[0], true +} + +func (p *Provider) latestModel(mode RuntimeMode, family string) (KnownModel, bool) { + candidates := make([]KnownModel, 0) + for _, m := range knownModels { + if !m.Preferred || m.Provider != p.Name || !p.availableFor(m, mode) { + continue + } + if family != "" && m.Family != family { + continue + } + candidates = append(candidates, m) + } + if len(candidates) == 0 { + return KnownModel{}, false + } + sortModels(candidates) + return candidates[0], true +} + +func (p *Provider) latestModelForVersionLine(mode RuntimeMode, identity ModelIdentity) (KnownModel, bool) { + if identity.Version == "" { + return KnownModel{}, false + } + major := strings.Split(normalizeModelVersion(identity.Version), ".")[0] + if major == "" { + return KnownModel{}, false + } + candidates := make([]KnownModel, 0) + for _, m := range knownModels { + if m.Provider != identity.Provider || m.Family != identity.Family || !p.availableFor(m, mode) { + continue + } + if modelVersionMatches(m.Version, major) { + candidates = append(candidates, m) + } + } + if len(candidates) == 0 { + return KnownModel{}, false + } + sortModelsForResolution(candidates) + return candidates[0], true +} + +func shouldResolveLatestVersionLine(version string) bool { + version = normalizeModelVersion(version) + if version == "" { + return false + } + for _, part := range strings.Split(version, ".") { + if !isNumericVersionPart(part) { + return false + } + } + return true +} + +func sortModels(models []KnownModel) { + sort.SliceStable(models, func(i, j int) bool { + left, right := models[i], models[j] + if priority := comparePriority(left, right); priority != 0 { + return priority < 0 + } + if left.ReleaseDate == right.ReleaseDate { + return compareModelVersions(left.ID, right.ID) > 0 + } + return left.ReleaseDate > right.ReleaseDate + }) +} + +func sortModelsForResolution(models []KnownModel) { + sort.SliceStable(models, func(i, j int) bool { + left, right := models[i], models[j] + if priority := comparePriority(left, right); priority != 0 { + return priority < 0 + } + if left.ReleaseDate == right.ReleaseDate && left.Preferred != right.Preferred { + return left.Preferred + } + if left.ReleaseDate == right.ReleaseDate { + return compareModelVersions(left.ID, right.ID) > 0 + } + return left.ReleaseDate > right.ReleaseDate + }) +} + +// comparePriority orders two rows by explicit priority, returning <0 when left +// sorts first, >0 when right does, and 0 when priority does not decide. Priority +// 0 means "unset" and always loses to an explicit priority. +func comparePriority(left, right KnownModel) int { + if left.Priority == right.Priority || (left.Priority == 0 && right.Priority == 0) { + return 0 + } + if left.Priority == 0 { + return 1 + } + if right.Priority == 0 { + return -1 + } + return left.Priority - right.Priority +} + +func modelVersionMatches(registryVersion, requested string) bool { + registryVersion = normalizeModelVersion(registryVersion) + requested = normalizeModelVersion(requested) + if requested == "" { + return true + } + if registryVersion == requested { + return true + } + return strings.HasPrefix(registryVersion, requested+".") || strings.HasPrefix(registryVersion, requested+"-") +} + +func normalizeModelVersion(version string) string { + version = strings.Trim(strings.ToLower(strings.TrimSpace(version)), "-.") + if version == "" { + return "" + } + parts := strings.Split(version, "-") + if len(parts) == 1 { + return parts[0] + } + i := 0 + out := "" + if isNumericVersionPart(parts[0]) { + out = parts[0] + i = 1 + if len(parts) > 1 && isNumericVersionPart(parts[1]) { + out += "." + parts[1] + i = 2 + } + } + if i < len(parts) { + if out != "" { + out += "-" + } + out += strings.Join(parts[i:], "-") + } + return out +} + +func isNumericVersionPart(part string) bool { + if part == "" { + return false + } + for _, r := range part { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// compareModelVersions orders two model ids by their embedded numeric version +// tokens ("claude-sonnet-4-6" > "claude-sonnet-4-5"). +func compareModelVersions(left, right string) int { + lv := modelVersionNumbers(left) + rv := modelVersionNumbers(right) + if len(lv) == 0 || len(rv) == 0 { + return 0 + } + maxLen := len(lv) + if len(rv) > maxLen { + maxLen = len(rv) + } + for i := 0; i < maxLen; i++ { + l, r := 0, 0 + if i < len(lv) { + l = lv[i] + } + if i < len(rv) { + r = rv[i] + } + if l != r { + return l - r + } + } + return 0 +} + +func modelVersionNumbers(id string) []int { + id = StripProviderPrefix(id) + parts := strings.Split(strings.ToLower(id), "-") + var out []int + for _, part := range parts { + if !isModelVersionToken(part) { + continue + } + for _, piece := range strings.Split(part, ".") { + if piece == "" { + continue + } + n := 0 + for _, r := range piece { + n = n*10 + int(r-'0') + } + out = append(out, n) + } + } + return out +} + +func isModelVersionToken(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if (r < '0' || r > '9') && r != '.' { + return false + } + } + return true +} diff --git a/pkg/api/registry/media.go b/pkg/api/registry/media.go new file mode 100644 index 0000000..01584d8 --- /dev/null +++ b/pkg/api/registry/media.go @@ -0,0 +1,97 @@ +package registry + +import ( + "slices" + "strings" +) + +// MediaTypesFor returns the attachment types a model accepts on a mode: the +// model's own catalog types intersected with the adapter's ceiling. An adapter +// cannot carry what it cannot send, and a model cannot read what it does not +// understand, so the answer is the intersection of the two. +func (p *Provider) MediaTypesFor(mode RuntimeMode, model string) []string { + caps, ok := p.Caps(mode) + if !ok { + return []string{} + } + modelTypes := caps.MediaTypes + if m, found := p.Lookup(model); found && len(m.InputMediaTypes) > 0 { + modelTypes = m.InputMediaTypes + } + return ClampMediaTypes(caps.MediaTypes, modelTypes) +} + +// AdapterMediaTypes returns a mode's attachment ceiling. +func (p *Provider) AdapterMediaTypes(mode RuntimeMode) []string { + caps, ok := p.Caps(mode) + if !ok { + return []string{} + } + return append([]string{}, caps.MediaTypes...) +} + +// ClampMediaTypes intersects a model's declared media types with an adapter's +// supported set, expanding wildcards on either side. +func ClampMediaTypes(adapterTypes, modelTypes []string) []string { + if len(adapterTypes) == 0 || len(modelTypes) == 0 { + return []string{} + } + out := make([]string, 0, len(modelTypes)) + for _, modelType := range modelTypes { + modelType = NormalizeMediaType(modelType) + if modelType == "" { + continue + } + for _, adapterType := range adapterTypes { + mediaType, ok := intersectMediaTypes(modelType, adapterType) + if ok && !slices.Contains(out, mediaType) { + out = append(out, mediaType) + } + } + } + return out +} + +func intersectMediaTypes(modelType, adapterType string) (string, bool) { + if modelType == adapterType { + return modelType, true + } + if strings.HasSuffix(modelType, "/*") && MediaTypeAccepted([]string{modelType}, adapterType) { + return adapterType, true + } + if strings.HasSuffix(adapterType, "/*") && MediaTypeAccepted([]string{adapterType}, modelType) { + return modelType, true + } + return "", false +} + +// NormalizeMediaType canonicalizes the shorthand forms the catalog uses. +// text/* normalizes to empty: prompt text is not an attachment. +func NormalizeMediaType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "image", "image/*": + return "image/*" + case "audio", "audio/*": + return "audio/*" + case "video", "video/*": + return "video/*" + case "pdf", "application/pdf": + return "application/pdf" + case "text", "text/*": + return "" + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +// MediaTypeAccepted reports whether a media type matches any accepted pattern, +// honouring "type/*" wildcards. +func MediaTypeAccepted(accepted []string, mediaType string) bool { + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + for _, pattern := range accepted { + if pattern == mediaType || strings.HasSuffix(pattern, "/*") && strings.HasPrefix(mediaType, strings.TrimSuffix(pattern, "*")) { + return true + } + } + return false +} diff --git a/pkg/api/registry/mode.go b/pkg/api/registry/mode.go new file mode 100644 index 0000000..1ed93d7 --- /dev/null +++ b/pkg/api/registry/mode.go @@ -0,0 +1,58 @@ +package registry + +import "strings" + +// RuntimeMode is the mechanism that serves a model: called directly over HTTP, +// driven through an installed CLI binary, run via an agent SDK subprocess, or +// piloted in an interactive TUI inside a cmux surface. +// +// A Backend is exactly a (Provider, RuntimeMode) pair — "claude-agent" is +// anthropic×agent. Backend remains the serialized form (it is written to specs, +// session rows, and the webapp wire format); RuntimeMode is the axis captain +// reasons about, and previously existed only in the frontend's RuntimeModePicker +// while Go smeared it across Backend.Kind, isMode, isSelectorPrefix, +// backendForMode, selectorBackend, and runtimeLogIdentity. +type RuntimeMode string + +const ( + ModeAPI RuntimeMode = "api" + ModeCLI RuntimeMode = "cli" + ModeAgent RuntimeMode = "agent" + ModeCmux RuntimeMode = "cmux" +) + +// AllRuntimeModes lists the modes in canonical order: the order a wildcard +// selector ("*:opus") fans out over, most-capable mechanism first. +// +// The two hand-written lists this replaces disagreed here — AgentsForProvider +// listed the CLI before the agent, wildcardBackends the reverse — and only the +// wildcard order was user-visible. That order wins. +func AllRuntimeModes() []RuntimeMode { + return []RuntimeMode{ModeAPI, ModeAgent, ModeCLI, ModeCmux} +} + +// ParseRuntimeMode normalizes a mode token from the compact grammar. "sdk" is +// accepted as an input alias for agent but is never emitted. +func ParseRuntimeMode(s string) (RuntimeMode, bool) { + switch RuntimeMode(strings.ToLower(strings.TrimSpace(s))) { + case ModeAPI: + return ModeAPI, true + case ModeCLI: + return ModeCLI, true + case ModeAgent, "sdk": + return ModeAgent, true + case ModeCmux: + return ModeCmux, true + default: + return "", false + } +} + +// RuntimeModeList renders the modes as comma-separated text for help/errors. +func RuntimeModeList() string { + parts := make([]string, len(AllRuntimeModes())) + for i, m := range AllRuntimeModes() { + parts[i] = string(m) + } + return strings.Join(parts, ", ") +} diff --git a/pkg/api/model.go b/pkg/api/registry/model.go similarity index 59% rename from pkg/api/model.go rename to pkg/api/registry/model.go index 0d9c81c..ffff7b2 100644 --- a/pkg/api/model.go +++ b/pkg/api/registry/model.go @@ -1,4 +1,4 @@ -package api +package registry import ( "fmt" @@ -43,6 +43,58 @@ type Model struct { // or via the --fallback flag / fallbacks: frontmatter. Each entry may be a // compact string ("agent:opus:high") or the object form (see ModelList). Fallbacks ModelList `json:"fallbacks,omitempty" yaml:"fallbacks,omitempty" pretty:"label=Fallbacks"` + + // Mode is the runtime mechanism: api | cli | agent | cmux. It is both an input + // and a derived value — `{model: sonnet, mode: agent}` is the object form of + // the compact "agent:sonnet" — and Backend is exactly (provider, Mode). A Mode + // that contradicts an explicit Backend fails Validate rather than being + // silently reconciled. + Mode RuntimeMode `json:"mode,omitempty" yaml:"mode,omitempty" jsonschema:"enum=api,enum=cli,enum=agent,enum=cmux" pretty:"label=Mode"` + + // The fields below are capabilities of the resolved provider×mode, filled in + // by Resolve. They are outputs: writing them in a spec does not change what + // the adapter can do, so Validate rejects any value that contradicts the + // resolved truth rather than letting a spec claim a capability it lacks. + // The runtime interface assertions remain authoritative at execution time. + + // Streaming reports that the adapter streams incremental events. + Streaming bool `json:"streaming,omitempty" yaml:"streaming,omitempty" jsonschema:"readOnly" pretty:"label=Streaming"` + // MediaTypes are the attachment types this model accepts on this adapter — + // the model's own declared types clamped by the adapter's ceiling. + MediaTypes []string `json:"mediaTypes,omitempty" yaml:"mediaTypes,omitempty" jsonschema:"readOnly" pretty:"label=Media Types"` + // Resume reports that a prior session can be continued by id. + Resume bool `json:"resume,omitempty" yaml:"resume,omitempty" jsonschema:"readOnly" pretty:"label=Resume"` + // Interrupt reports that a running turn can be interrupted. + Interrupt bool `json:"interrupt,omitempty" yaml:"interrupt,omitempty" jsonschema:"readOnly" pretty:"label=Interrupt"` + // Steer reports that a running turn accepts mid-flight steering. + Steer bool `json:"steer,omitempty" yaml:"steer,omitempty" jsonschema:"readOnly" pretty:"label=Steer"` + + // Provider is the descriptor that owns this model. Never serialized: it holds + // the whole catalog, so emitting it would inline the registry into every spec. + // Reach it from a decoded Model with Backend.ModelProvider(). + Provider *Provider `json:"-" yaml:"-"` +} + +// Capabilities fills in Mode, Provider, and the capability flags from the +// resolved backend. Resolve applies it; callers that build a Model by hand can +// call it to get the same enrichment. +func (m Model) Capabilities() Model { + p, mode, ok := ProviderFor(m.Backend) + if !ok { + return m + } + caps, ok := p.Caps(mode) + if !ok { + return m + } + m.Provider = p + m.Mode = mode + m.Streaming = caps.Streaming + m.Resume = caps.Resume + m.Interrupt = caps.Interrupt + m.Steer = caps.Steer + m.MediaTypes = p.MediaTypesFor(mode, m.Name) + return m } // ResolveBackend returns Backend when set, otherwise infers it from Name. @@ -86,17 +138,71 @@ func (m Model) Validate() error { } // validateKnobs range-checks the per-request inference knobs shared by a primary -// model and each fallback (backend/temperature/effort), independent of Name. +// model and each fallback (backend/mode/temperature/effort), independent of Name. func (m Model) validateKnobs() error { if m.Backend != "" && !m.Backend.Valid() { return fmt.Errorf("invalid backend %q (valid: %s)", m.Backend, BackendList()) } + if err := m.validateMode(); err != nil { + return err + } 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() } +// validateMode rejects a mode that is unknown, or that contradicts an explicit +// backend. Backend is exactly (provider, mode), so `backend: anthropic` with +// `mode: agent` names two different runtimes — reconciling it silently would +// pick one of them behind the user's back. +func (m Model) validateMode() error { + if m.Mode == "" { + return nil + } + if _, ok := ParseRuntimeMode(string(m.Mode)); !ok { + return fmt.Errorf("invalid mode %q (valid: %s)", m.Mode, RuntimeModeList()) + } + if m.Backend == "" { + return nil + } + if actual := m.Backend.Mode(); actual != m.Mode { + return fmt.Errorf("mode %q contradicts backend %q, which is mode %q; set one or the other", + m.Mode, m.Backend, actual) + } + return nil +} + +// Merge overlays o's set fields onto m, returning the result. It backs Spec +// merging in pkg/api, where a later layer overrides an earlier one field by field. +func (m Model) Merge(o Model) Model { + if o.Name != "" { + m.Name = o.Name + } + if o.ID != "" { + m.ID = o.ID + } + if o.Backend != "" { + m.Backend = o.Backend + } + if o.Temperature != nil { + m.Temperature = o.Temperature + } + if o.Effort != "" { + m.Effort = o.Effort + } + if o.Mode != "" { + m.Mode = o.Mode + } + if o.NoCache { + m.NoCache = true + } + if len(o.Fallbacks) > 0 { + m.Fallbacks = o.Fallbacks + } + return m +} + // ExpandCSV moves any comma-separated tail of Name into name-only Fallbacks // (prepended, so CSV order is preserved ahead of an explicit Fallbacks list), // leaving Name as the single primary. It is idempotent: a single-model Name is diff --git a/pkg/api/model_compact.go b/pkg/api/registry/model_compact.go similarity index 58% rename from pkg/api/model_compact.go rename to pkg/api/registry/model_compact.go index fac48c8..1ec28da 100644 --- a/pkg/api/model_compact.go +++ b/pkg/api/registry/model_compact.go @@ -1,4 +1,4 @@ -package api +package registry import ( "bytes" @@ -25,113 +25,51 @@ import ( // element is disambiguated by content: a known effort tail is model:effort, a // known mode head is mode:model. -// mechanismModes are the compact `mode:` keywords, mapped (with the model family) -// to a concrete backend by backendForMode. -func isMode(s string) bool { - switch s { - case "api", "cli", "agent", "sdk", "cmux": - return true - } - return false -} - -func isEffort(s string) bool { - switch Effort(s) { - case EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax, EffortUltra: - return true - } - return false -} - -// Family returns the model family a backend serves (claude | codex | gemini | -// deepseek), or "" for an unrecognised backend. -func (b Backend) Family() string { - switch b { - case BackendAnthropic, BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux: - return "claude" - case BackendOpenAI, BackendCodexCLI, BackendCodexAgent, BackendCodexCmux: - return "codex" - case BackendGemini, BackendGeminiCLI: - return "gemini" - case BackendDeepSeek: - return "deepseek" - } - return "" -} - -// backendForMode resolves a compact `mode` keyword plus a model name to a concrete -// backend: the model's family (inferred from its name) combined with the mechanism. -func backendForMode(mode, modelName string) (Backend, error) { - if mode == "sdk" { - mode = "agent" +// backendForPrefix resolves an element's prefix plus its model name to a concrete +// backend: the provider that claims the model, combined with the mechanism the +// prefix names. +func backendForPrefix(prefix, modelName string) (Backend, error) { + p, _, _, ok := ProviderForToken(modelName) + if !ok { + return "", fmt.Errorf("mode %q: %w: %s (pass an explicit backend: %s)", prefix, ErrUnknownModel, modelName, BackendList()) } - inferred, err := InferBackend(modelName) + mode, err := resolveMode(prefix, ModeAPI, p, modelName) if err != nil { - return "", fmt.Errorf("mode %q: %w", mode, err) + return "", err } - family := inferred.Family() - table := map[string]map[string]Backend{ - "api": {"claude": BackendAnthropic, "codex": BackendOpenAI, "gemini": BackendGemini, "deepseek": BackendDeepSeek}, - "cli": {"claude": BackendClaudeCLI, "codex": BackendCodexCLI, "gemini": BackendGeminiCLI}, - "agent": {"claude": BackendClaudeAgent, "codex": BackendCodexAgent}, - "cmux": {"claude": BackendClaudeCmux, "codex": BackendCodexCmux}, - } - byFamily := table[mode] - if byFamily == nil { - return "", fmt.Errorf("unknown mode %q (valid: api, cli, agent, cmux)", mode) - } - backend, ok := byFamily[family] - if !ok { - return "", fmt.Errorf("mode %q is not supported for %s models (%q)", mode, family, modelName) - } - return backend, nil + return p.BackendFor(mode) } -// parseCompactElement parses one `[mode:]model[:effort]` element. +// parseCompactElement parses one `[prefix:]model[:effort]` element. +// +// It resolves the BACKEND but deliberately leaves Name as written: a decoded +// spec keeps the model the user asked for ("opus"), and only the later resolve +// step (ResolveModel) maps it onto an exact catalog id. The spec is a request, +// not a resolution, so decoding must not bake today's catalog snapshot into it. func parseCompactElement(s string) (Model, error) { s = strings.TrimSpace(s) if s == "" { return Model{}, fmt.Errorf("empty model") } - tokens := strings.Split(s, ":") - for i := range tokens { - tokens[i] = strings.TrimSpace(tokens[i]) - } - var m Model - switch len(tokens) { - case 1: - m.Name = tokens[0] - case 2: - switch { - case isEffort(tokens[1]): - m.Name, m.Effort = tokens[0], Effort(tokens[1]) - case isMode(tokens[0]): - backend, err := backendForMode(tokens[0], tokens[1]) - if err != nil { - return Model{}, err - } - m.Name, m.Backend = tokens[1], backend - default: - return Model{}, fmt.Errorf("ambiguous compact model %q (expected model:effort or mode:model)", s) - } - case 3: - if !isMode(tokens[0]) { - return Model{}, fmt.Errorf("invalid mode %q in %q (valid: api, cli, agent, cmux)", tokens[0], s) - } - if !isEffort(tokens[2]) { - return Model{}, fmt.Errorf("invalid effort %q in %q", tokens[2], s) - } - backend, err := backendForMode(tokens[0], tokens[1]) - if err != nil { - return Model{}, err - } - m.Name, m.Effort, m.Backend = tokens[1], Effort(tokens[2]), backend - default: - return Model{}, fmt.Errorf("invalid compact model %q (too many ':' segments)", s) + prefix, name, effort, err := splitElement(s, "") + if err != nil { + return Model{}, err } - if m.Name == "" { + if name == "" { return Model{}, fmt.Errorf("model name required in %q", s) } + m := Model{Name: name, Effort: effort} + if prefix == "" { + return m, nil + } + if prefix == "*" { + return Model{}, fmt.Errorf("wildcard selector %q is only valid for --multi-models", s) + } + backend, err := backendForPrefix(prefix, name) + if err != nil { + return Model{}, err + } + m.Backend = backend return m, nil } diff --git a/pkg/api/model_compact_test.go b/pkg/api/registry/model_compact_test.go similarity index 81% rename from pkg/api/model_compact_test.go rename to pkg/api/registry/model_compact_test.go index 4c029b5..cdffb08 100644 --- a/pkg/api/model_compact_test.go +++ b/pkg/api/registry/model_compact_test.go @@ -1,4 +1,4 @@ -package api +package registry import ( "encoding/json" @@ -129,26 +129,3 @@ func TestModelList_Unmarshal(t *testing.T) { }) } -// TestSpec_FallbacksCompact pins the end-to-end config shape: a Spec whose model -// has compact-string fallbacks, plus the primary parsed via Expand. -func TestSpec_FallbacksCompact(t *testing.T) { - var spec Spec - src := "model: opus\neffort: high\nfallbacks:\n - agent:sonnet:medium\n - api:gpt-5.5\n" - if err := yaml.Unmarshal([]byte(src), &spec); err != nil { - t.Fatal(err) - } - if spec.Model.Name != "opus" || spec.Model.Effort != EffortHigh { - t.Fatalf("primary = %+v", spec.Model) - } - if len(spec.Model.Fallbacks) != 2 { - t.Fatalf("fallbacks = %+v", spec.Model.Fallbacks) - } - if spec.Model.Fallbacks[0].Name != "sonnet" || spec.Model.Fallbacks[0].Backend != BackendClaudeAgent { - t.Errorf("fb0 = %+v", spec.Model.Fallbacks[0]) - } - // Candidates flattens primary + fallbacks in order. - cands := spec.Model.Candidates() - if len(cands) != 3 || cands[0].Name != "opus" || cands[1].Name != "sonnet" || cands[2].Name != "gpt-5.5" { - t.Errorf("candidates = %+v", cands) - } -} diff --git a/pkg/api/model_test.go b/pkg/api/registry/model_test.go similarity index 98% rename from pkg/api/model_test.go rename to pkg/api/registry/model_test.go index 55217c9..ced4108 100644 --- a/pkg/api/model_test.go +++ b/pkg/api/registry/model_test.go @@ -1,4 +1,4 @@ -package api +package registry import ( "encoding/json" @@ -9,6 +9,8 @@ import ( "gopkg.in/yaml.v3" ) +func floatPtr(f float64) *float64 { return &f } + func modelNames(models []Model) []string { if len(models) == 0 { return nil diff --git a/pkg/ai/model_registry.json b/pkg/api/registry/models.json similarity index 98% rename from pkg/ai/model_registry.json rename to pkg/api/registry/models.json index ccfca5c..fe437f2 100644 --- a/pkg/ai/model_registry.json +++ b/pkg/api/registry/models.json @@ -195,7 +195,8 @@ "inputMediaTypes": [ "image/*", "application/pdf" - ] + ], + "supersededBy": "claude-sonnet-4-6" }, { "id": "claude-sonnet-4-5-20250929", @@ -210,7 +211,8 @@ "inputMediaTypes": [ "image/*", "application/pdf" - ] + ], + "supersededBy": "claude-sonnet-4-6" }, { "id": "gpt-5.6", @@ -262,7 +264,10 @@ "xhigh", "max" ], - "priority": 3 + "priority": 3, + "aliases": [ + "luna" + ] }, { "id": "gpt-5.6-sol", @@ -290,7 +295,10 @@ "max", "ultra" ], - "priority": 1 + "priority": 1, + "aliases": [ + "sol" + ] }, { "id": "gpt-5.6-terra", @@ -318,7 +326,10 @@ "max", "ultra" ], - "priority": 2 + "priority": 2, + "aliases": [ + "terra" + ] }, { "id": "gpt-5.5", diff --git a/pkg/api/registry/parse.go b/pkg/api/registry/parse.go new file mode 100644 index 0000000..d88395e --- /dev/null +++ b/pkg/api/registry/parse.go @@ -0,0 +1,369 @@ +package registry + +import ( + "errors" + "fmt" + "strings" +) + +// ErrUnknownModel marks "no provider claims this model name". Callers enrich it +// with "did you mean" suggestions, so it stays a wrapped sentinel. +var ErrUnknownModel = ErrInferBackend + +// The model grammar +// +// sonnet → {model: claude-sonnet-5, backend: anthropic} +// sonnet:high → + effort high +// agent:sonnet:high → + backend claude-agent +// claude-cmux:opus → an explicit backend name works as a prefix too +// *:fable → every backend of the claiming family (multi only) +// opus:high, sonnet:medium → primary opus:high with a sonnet:medium fallback +// +// One element is `[prefix:]model[:effort]`, where prefix is a runtime mode +// (api | cli | agent | cmux; sdk aliases agent), a concrete backend name, or "*". +// A two-segment element is disambiguated by content: a known effort tail is +// model:effort, a known prefix head is prefix:model. +// +// This is the only implementation of the grammar. It previously existed twice — +// parseCompactElement in pkg/api and resolveSelectorPart in pkg/ai — over two +// different claim tables, so `--model agent:sol` and `model: agent:sol` in +// frontmatter disagreed about whether the model existed. + +// ParseOptions tunes one parse. +type ParseOptions struct { + // Backend forces a backend. A prefix that resolves elsewhere is an error. + Backend Backend + // Mode forces the runtime mechanism when the element carries no prefix of its + // own — the object form of "agent:sonnet". An explicit prefix still wins. + Mode RuntimeMode + // BaseName supplies the model for a bare prefix ("cli:" with --model sonnet). + BaseName string + // AllowWildcard permits "*" fan-out. Only --multi-models sets it. + AllowWildcard bool +} + +// ParseModel parses one compact element into a concrete model. A wildcard, or +// anything that resolves to more than one model, is an error here — use +// ParseModelMulti. +func ParseModel(s string) (Model, error) { + models, err := ParseModelElement(s, ParseOptions{}) + if err != nil { + return Model{}, err + } + if len(models) != 1 { + return Model{}, fmt.Errorf("wildcard selector %q is only valid for --multi-models", s) + } + return models[0], nil +} + +// ParseModelMulti expands one element, fanning a "*" prefix out across every +// backend of the claiming family that actually offers the model. +func ParseModelMulti(s string, opts ParseOptions) ([]Model, error) { + opts.AllowWildcard = true + return ParseModelElement(s, opts) +} + +// ParseModelElement parses one `[prefix:]model[:effort]` element. +func ParseModelElement(raw string, opts ParseOptions) ([]Model, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty model") + } + + prefix, name, effort, err := splitElement(raw, opts.BaseName) + if err != nil { + return nil, err + } + if name == "" { + return nil, fmt.Errorf("runtime selector %q needs a model, or --model must provide a base model", raw) + } + + if prefix == "*" { + if !opts.AllowWildcard { + return nil, fmt.Errorf("wildcard selector %q is only valid for --multi-models", raw) + } + return expandWildcard(raw, name, effort) + } + + p, _, tokenMode, ok := ProviderForToken(name) + if !ok { + // An unclaimed model name is fine as long as the caller named a backend: + // that is how a brand-new provider model works before the catalog + // snapshot knows about it. Without one, fail loud. + if opts.Backend == "" { + return nil, fmt.Errorf("%w: %s (pass an explicit backend: %s)", ErrUnknownModel, name, BackendList()) + } + forced, mode, found := ProviderFor(opts.Backend) + if !found { + return nil, fmt.Errorf("invalid backend %q (valid: %s)", opts.Backend, BackendList()) + } + p, tokenMode = forced, mode + } + + // An explicit prefix wins over Model.Mode, which wins over the mode the model + // name itself implies. + if prefix == "" && opts.Mode != "" { + tokenMode = opts.Mode + } + mode, err := resolveMode(prefix, tokenMode, p, name) + if err != nil { + return nil, err + } + backend, err := p.BackendFor(mode) + if err != nil { + return nil, err + } + if opts.Backend != "" && backend != opts.Backend { + if prefix == "" { + // A bare model whose family does not match an explicitly requested + // backend: report the family clash, not the mode. + forced, _, ok := ProviderFor(opts.Backend) + if !ok { + return nil, fmt.Errorf("invalid backend %q (valid: %s)", opts.Backend, BackendList()) + } + if forced != p { + return nil, fmt.Errorf("model %q belongs to the %s family and cannot use backend %q (%s family)", + name, p.AgentName, opts.Backend, forced.AgentName) + } + // Same family, different mode: honour the explicit backend. + backend = opts.Backend + mode = modeOf(opts.Backend) + } else { + return nil, fmt.Errorf("runtime selector %q resolves to backend %q but --backend is %q", raw, backend, opts.Backend) + } + } + + resolved, err := resolveOn(p, mode, name) + if err != nil { + return nil, fmt.Errorf("runtime selector %q: %w", raw, err) + } + if err := ValidateEffort(backend, resolved, effort); err != nil { + return nil, fmt.Errorf("runtime selector %q: %w", raw, err) + } + return []Model{Model{Name: resolved, Backend: backend, Effort: effort}.Capabilities()}, nil +} + +// splitElement pulls the optional prefix and effort suffix off an element. +func splitElement(raw, baseName string) (prefix, name string, effort Effort, err error) { + tokens := strings.Split(raw, ":") + for i := range tokens { + tokens[i] = strings.TrimSpace(tokens[i]) + } + switch len(tokens) { + case 1: + // A bare prefix ("cli") borrows the base model. + if isPrefix(tokens[0]) && baseName != "" { + return strings.ToLower(tokens[0]), strings.TrimSpace(baseName), EffortNone, nil + } + return "", tokens[0], EffortNone, nil + case 2: + switch { + case Effort(tokens[1]).Valid() && tokens[1] != "": + return "", tokens[0], Effort(tokens[1]), nil + case isPrefix(tokens[0]): + return strings.ToLower(tokens[0]), tokens[1], EffortNone, nil + default: + return "", "", EffortNone, fmt.Errorf("ambiguous compact model %q (expected model:effort or mode:model)", raw) + } + case 3: + if !isPrefix(tokens[0]) { + return "", "", EffortNone, fmt.Errorf("invalid mode %q in %q (valid: %s)", tokens[0], raw, RuntimeModeList()) + } + if !Effort(tokens[2]).Valid() || tokens[2] == "" { + return "", "", EffortNone, fmt.Errorf("invalid effort %q in %q", tokens[2], raw) + } + return strings.ToLower(tokens[0]), tokens[1], Effort(tokens[2]), nil + default: + return "", "", EffortNone, fmt.Errorf("invalid compact model %q (too many ':' segments)", raw) + } +} + +// resolveMode reconciles the element's prefix with the mode the model name +// itself implies. An explicit prefix always wins. +func resolveMode(prefix string, tokenMode RuntimeMode, p *Provider, name string) (RuntimeMode, error) { + if prefix == "" { + return tokenMode, nil + } + if b := Backend(prefix); b.Valid() { + owner, mode, _ := ProviderFor(b) + if owner != p { + return "", fmt.Errorf("model %q belongs to the %s family and cannot use backend %q (%s family)", + name, p.AgentName, b, owner.AgentName) + } + return mode, nil + } + mode, ok := ParseRuntimeMode(prefix) + if !ok { + return "", fmt.Errorf("unknown runtime selector prefix %q (valid: %s, a backend name, or *)", prefix, RuntimeModeList()) + } + return mode, nil +} + +func expandWildcard(raw, name string, effort Effort) ([]Model, error) { + p, _, _, ok := ProviderForToken(name) + if !ok { + return nil, fmt.Errorf("%w: %s (pass an explicit backend: %s)", ErrUnknownModel, name, BackendList()) + } + out := make([]Model, 0, len(p.Modes())) + for _, mode := range p.Modes() { + resolved, err := resolveOn(p, mode, name) + if err != nil { + continue + } + backend, err := p.BackendFor(mode) + if err != nil { + continue + } + if err := ValidateEffort(backend, resolved, effort); err != nil { + continue + } + out = append(out, Model{Name: resolved, Backend: backend, Effort: effort}.Capabilities()) + } + if len(out) == 0 { + return nil, fmt.Errorf("runtime selector %q is not available on any backend", raw) + } + return out, nil +} + +// resolveOn maps a model token onto the exact id a provider's mode accepts, +// failing loud when the catalog knows the model but not on that mode. +func resolveOn(p *Provider, mode RuntimeMode, name string) (string, error) { + backend, err := p.BackendFor(mode) + if err != nil { + return "", err + } + if known, available := p.Availability(mode, name); known && !available { + return "", fmt.Errorf("model %q is not available on backend %q", name, backend) + } + resolved, _ := p.ResolveExact(mode, name) + return resolved, nil +} + +// isPrefix reports whether a token can lead an element: a runtime mode, a +// concrete backend name, or the wildcard. +func isPrefix(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "*" { + return true + } + if _, ok := ParseRuntimeMode(s); ok { + return true + } + return Backend(s).Valid() +} + +// ContainsSelector reports whether s carries a prefix selector such as +// "cmux:gpt-5.6-sol" or "*:sonnet-5". Comma-separated lists are scanned per item. +func ContainsSelector(s string) bool { + for _, part := range splitCSV(s) { + prefix, _, ok := strings.Cut(part, ":") + if !ok { + continue + } + if isPrefix(prefix) { + return true + } + } + return false +} + +func modeOf(b Backend) RuntimeMode { + _, mode, _ := ProviderFor(b) + return mode +} + +// ResolveModel resolves a Model in place: its Name is parsed as a compact +// element and its fallbacks each resolved independently. It is the entry point +// for both `--model` and spec/frontmatter decoding, which is the whole point — +// they used to run different parsers. +func ResolveModel(m Model) (Model, error) { + m = m.ExpandCSV() + if strings.TrimSpace(m.Name) == "" { + return m, nil + } + if err := m.validateMode(); err != nil { + return Model{}, err + } + resolved, err := ParseModelElement(m.Name, ParseOptions{Backend: m.Backend, Mode: m.Mode}) + if err != nil { + return Model{}, err + } + if len(resolved) != 1 { + return Model{}, fmt.Errorf("wildcard selector %q is only valid for --multi-models", m.Name) + } + out := mergeResolved(m, resolved[0]) + out.Fallbacks = make(ModelList, 0, len(m.Fallbacks)) + for _, fb := range m.Fallbacks { + if strings.TrimSpace(fb.Name) == "" { + out.Fallbacks = append(out.Fallbacks, fb) + continue + } + rfb, err := ParseModelElement(fb.Name, ParseOptions{Backend: fb.Backend}) + if err != nil { + return Model{}, err + } + if len(rfb) != 1 { + return Model{}, fmt.Errorf("wildcard selector %q is only valid for --multi-models", fb.Name) + } + out.Fallbacks = append(out.Fallbacks, mergeResolved(fb, rfb[0])) + } + return out, nil +} + +// ResolveMulti expands --multi-models values into concrete runtime model/backend +// pairs. Values may be repeated and/or comma-separated, a bare prefix ("cmux") +// borrows the base model, and duplicates are dropped. Each result inherits the +// base model's temperature/cache settings, and its effort when the element does +// not set one. +func ResolveMulti(values []string, base Model) ([]Model, error) { + base, err := ResolveModel(base) + if err != nil { + return nil, err + } + out := make([]Model, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + for _, part := range splitCSV(value) { + models, err := ParseModelMulti(part, ParseOptions{BaseName: base.Name}) + if err != nil { + return nil, err + } + for _, m := range models { + m.Temperature = base.Temperature + if m.Effort == EffortNone { + m.Effort = base.Effort + } + m.NoCache = base.NoCache + key := string(m.Backend) + "\x00" + m.Name + "\x00" + string(m.Effort) + if seen[key] { + continue + } + seen[key] = true + out = append(out, m) + } + } + } + return out, nil +} + +func mergeResolved(original, resolved Model) Model { + out := original + if strings.TrimSpace(resolved.Name) != "" { + out.Name = resolved.Name + } + if resolved.ID != "" { + out.ID = resolved.ID + } + if resolved.Backend != "" { + out.Backend = resolved.Backend + } + if resolved.Effort != EffortNone { + out.Effort = resolved.Effort + } + // Capabilities are derived, never carried over from the request: whatever the + // caller wrote for them is replaced by what the resolved adapter can do. + return out.Capabilities() +} + +// IsUnknownModel reports whether err is the "no provider claims this" failure. +func IsUnknownModel(err error) bool { return errors.Is(err, ErrUnknownModel) } diff --git a/pkg/api/registry/provider.go b/pkg/api/registry/provider.go new file mode 100644 index 0000000..7902b87 --- /dev/null +++ b/pkg/api/registry/provider.go @@ -0,0 +1,200 @@ +package registry + +import ( + "fmt" + "sort" + "strings" +) + +// ModeCapabilities is one provider×mode cell: the Backend that pair serializes +// to, plus what that adapter can actually do. +// +// The runtime interface assertions (StreamingProvider, InterruptibleProvider, +// SteerableProvider) stay authoritative at execution time; this table is the +// static declaration callers can consult before constructing a provider, and the +// reason capabilities can be answered without spinning one up. +type ModeCapabilities struct { + // Backend is the serialized enum value for this provider×mode pair, e.g. + // anthropic×agent → "claude-agent". These strings are frozen: they are + // persisted in specs, session rows, and the webapp wire format. + Backend Backend + // Streaming: the adapter can stream incremental events. + Streaming bool + // Resume: the adapter can resume a prior session by id. + Resume bool + // Interrupt: the adapter implements InterruptibleProvider. + Interrupt bool + // Steer: the adapter implements SteerableProvider. + Steer bool + // MediaTypes is the adapter's attachment ceiling. A model's own declared + // types are clamped against it — the adapter cannot carry what it cannot send. + MediaTypes []string + // Keyless marks modes that never consult EnvVars because they ride the local + // CLI's own login (cmux). + Keyless bool +} + +// modeToken forces a RuntimeMode from a model-name prefix: "claude-code-…" is +// the CLI, "codex-agent-…" is the agent SDK. Matched longest-prefix-first. +type modeToken struct { + prefix string + mode RuntimeMode +} + +// Provider describes one model family — its auth env vars, catalog and pricing +// namespaces, per-mode capabilities, and the model rows it owns. +// +// It is a struct rather than an interface on purpose: there are exactly four +// instances and they are entirely data. An interface would invite divergent +// implementations, which is precisely the failure this type exists to end — this +// knowledge used to live in InferBackend, backendForMode, selectorBackend, +// splitModelProvider, selectorModelFamily, AuthEnvVars, AgentsForProvider, +// PricingIDs, orPrefix, pricingModelID, and adapterInputMediaTypes, which +// disagreed with each other. +type Provider struct { + // Name is the canonical provider key: anthropic | openai | google | deepseek. + Name string + // AgentName is the coding-agent sentinel: a bare "claude"/"codex" on a + // non-API mode means "this provider's current model". + AgentName string + // CatalogPrefix namespaces catalog/menu/genkit ids. Google's is "googleai". + CatalogPrefix string + // PricingPrefix namespaces OpenRouter-style pricing keys. Google's is + // "google" — deliberately NOT CatalogPrefix. Deriving one from the other is + // how pricing lookups silently missed and reported $0. + PricingPrefix string + // EnvVars are the API-key environment variables in priority order. + EnvVars []string + + // modes is the per-mode capability table. A missing mode means this provider + // does not support it (google has no agent or cmux row). + modes map[RuntimeMode]ModeCapabilities + // modeTokens force a mode from a model-name prefix. + modeTokens []modeToken + // claimPrefixes are the bare model-name prefixes this provider claims. A + // claim with no modeToken hit lands on ModeAPI. + claimPrefixes []string + // identityTrim are prefixes stripped before the family split. + identityTrim []string + // families are the family names this provider's model ids are built from. + families []string + // emptyTokens are tokens that name the provider but no family; they resolve + // to emptyFamily. + emptyTokens []string + emptyFamily string + + // genConfig translates effort into this provider's native request controls. + // nil means the provider has no per-request effort knob (DeepSeek). + genConfig func(cfg map[string]any, caps KnownModel, effort Effort, maxTokens int) + // classifyErr refines the shared error classification for this provider's own + // error vocabulary. nil means the shared heuristics are enough. + classifyErr func(err error, base ErrorClass) ErrorClass +} + +// SupportedEnvVars returns the auth environment variables for this provider, in +// priority order. +func (p *Provider) SupportedEnvVars() []string { + return append([]string(nil), p.EnvVars...) +} + +// Modes lists the runtime modes this provider supports, in canonical order. +func (p *Provider) Modes() []RuntimeMode { + out := make([]RuntimeMode, 0, len(p.modes)) + for _, m := range AllRuntimeModes() { + if _, ok := p.modes[m]; ok { + out = append(out, m) + } + } + return out +} + +// Caps returns the capability row for a mode. ok is false when this provider +// does not serve that mode. +func (p *Provider) Caps(mode RuntimeMode) (ModeCapabilities, bool) { + caps, ok := p.modes[mode] + return caps, ok +} + +// BackendFor maps a mode onto the serialized Backend, failing loud when the +// provider does not serve it. +func (p *Provider) BackendFor(mode RuntimeMode) (Backend, error) { + caps, ok := p.modes[mode] + if !ok { + // AgentName, not Name: users speak in families ("gemini models"), not + // provider keys ("google models"). + return "", fmt.Errorf("mode %q is not supported for %s models (supported: %s)", + mode, p.AgentName, modeListOf(p.Modes())) + } + return caps.Backend, nil +} + +// Backends lists every Backend this provider serves, in canonical mode order. +func (p *Provider) Backends() []Backend { + out := make([]Backend, 0, len(p.modes)) + for _, m := range p.Modes() { + out = append(out, p.modes[m].Backend) + } + return out +} + +// PricingIDs returns candidate pricing keys for a model, most specific first. +func (p *Provider) PricingIDs(model string) []string { + bare := p.bareID(model) + return []string{p.PricingPrefix + "/" + bare, bare} +} + +// Models returns this provider's catalog rows. +func (p *Provider) Models() []KnownModel { + out := make([]KnownModel, 0, len(knownModels)) + for _, m := range knownModels { + if m.Provider == p.Name { + out = append(out, m) + } + } + return out +} + +// claim reports whether this provider owns a model token and which mode the +// token's own prefix implies. Mode tokens are matched longest-first so +// "codex-agent-x" resolves to the agent SDK rather than the codex CLI. +func (p *Provider) claim(token string) (RuntimeMode, bool) { + t := strings.ToLower(strings.TrimSpace(token)) + for _, mt := range p.modeTokens { + if strings.HasPrefix(t, mt.prefix) { + return mt.mode, true + } + } + for _, prefix := range p.claimPrefixes { + if strings.HasPrefix(t, prefix) { + return ModeAPI, true + } + } + return "", false +} + +// bareID strips this provider's catalog namespace (and the Gemini "models/" +// namespace) from an id. +func (p *Provider) bareID(model string) string { + model = strings.TrimSpace(model) + for _, prefix := range []string{p.CatalogPrefix + "/", p.PricingPrefix + "/", p.Name + "/", "models/"} { + model = strings.TrimPrefix(model, prefix) + } + return model +} + +func modeListOf(modes []RuntimeMode) string { + parts := make([]string, len(modes)) + for i, m := range modes { + parts[i] = string(m) + } + return strings.Join(parts, ", ") +} + +// sortModeTokens orders a provider's mode tokens longest-prefix-first so the +// most specific match wins regardless of declaration order. +func sortModeTokens(tokens []modeToken) []modeToken { + sort.SliceStable(tokens, func(i, j int) bool { + return len(tokens[i].prefix) > len(tokens[j].prefix) + }) + return tokens +} diff --git a/pkg/api/registry/providers.go b/pkg/api/registry/providers.go new file mode 100644 index 0000000..ed425c6 --- /dev/null +++ b/pkg/api/registry/providers.go @@ -0,0 +1,137 @@ +package registry + +import "strings" + +// The provider descriptors. Everything captain used to rediscover with string +// prefixes and per-backend switches is a field here. +// +// Media types come from what each adapter can actually carry; Interrupt/Steer +// mirror the InterruptibleProvider/SteerableProvider implementations in +// pkg/ai/provider (steer: claude-agent only; interrupt: claude-agent and +// codex-agent). Streaming is true everywhere because every adapter implements +// ExecuteStream; it is declared rather than assumed so a future non-streaming +// adapter has an honest place to say so. +var ( + Anthropic = &Provider{ + Name: "anthropic", + AgentName: "claude", + CatalogPrefix: "anthropic", + PricingPrefix: "anthropic", + EnvVars: []string{"ANTHROPIC_API_KEY"}, + modes: map[RuntimeMode]ModeCapabilities{ + ModeAPI: {Backend: BackendAnthropic, Streaming: true, MediaTypes: []string{"image/*"}}, + ModeCLI: {Backend: BackendClaudeCLI, Streaming: true, Resume: true}, + ModeAgent: {Backend: BackendClaudeAgent, Streaming: true, Resume: true, Interrupt: true, Steer: true, MediaTypes: []string{"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}}, + ModeCmux: {Backend: BackendClaudeCmux, Streaming: true, Resume: true, Keyless: true}, + }, + modeTokens: sortModeTokens([]modeToken{ + {prefix: "claude-agent", mode: ModeAgent}, + {prefix: "claude-code", mode: ModeCLI}, + }), + claimPrefixes: []string{"claude", "opus", "sonnet", "haiku", "fable"}, + identityTrim: []string{"claude-agent-", "claude-code-", "claude-"}, + families: []string{"fable", "opus", "sonnet", "haiku"}, + emptyTokens: []string{""}, + emptyFamily: "sonnet", + genConfig: anthropicGenerationConfig, + } + + OpenAI = &Provider{ + Name: "openai", + AgentName: "codex", + CatalogPrefix: "openai", + PricingPrefix: "openai", + EnvVars: []string{"OPENAI_API_KEY"}, + modes: map[RuntimeMode]ModeCapabilities{ + ModeAPI: {Backend: BackendOpenAI, Streaming: true, MediaTypes: []string{"image/*"}}, + ModeCLI: {Backend: BackendCodexCLI, Streaming: true, Resume: true, MediaTypes: []string{"image/*"}}, + ModeAgent: {Backend: BackendCodexAgent, Streaming: true, Resume: true, Interrupt: true, MediaTypes: []string{"image/*"}}, + ModeCmux: {Backend: BackendCodexCmux, Streaming: true, Resume: true, Keyless: true}, + }, + // A bare "codex" is the CLI, not the API — the asymmetry with "claude" + // (which stays on the API) is long-standing user-visible behaviour. + // "grok-" is served through the codex CLI. + modeTokens: sortModeTokens([]modeToken{ + {prefix: "codex-agent", mode: ModeAgent}, + {prefix: "codex", mode: ModeCLI}, + {prefix: "grok-", mode: ModeCLI}, + }), + claimPrefixes: []string{"gpt-", "o1", "o3", "o4"}, + identityTrim: []string{"codex-agent-", "codex-"}, + families: []string{"gpt"}, + emptyTokens: []string{"", "codex"}, + emptyFamily: "gpt", + genConfig: openaiGenerationConfig, + } + + Google = &Provider{ + Name: "google", + AgentName: "gemini", + // googleai for catalog/menu/genkit ids, google for pricing keys. These + // are independent on purpose; see the Provider doc. + CatalogPrefix: "googleai", + PricingPrefix: "google", + EnvVars: []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, + modes: map[RuntimeMode]ModeCapabilities{ + ModeAPI: {Backend: BackendGemini, Streaming: true, MediaTypes: []string{"image/*", "audio/*", "video/*", "application/pdf"}}, + ModeCLI: {Backend: BackendGeminiCLI, Streaming: true}, + }, + modeTokens: sortModeTokens([]modeToken{ + {prefix: "gemini-cli", mode: ModeCLI}, + }), + claimPrefixes: []string{"gemini", "models/gemini"}, + identityTrim: []string{"gemini-cli-"}, + families: []string{"gemini"}, + genConfig: googleGenerationConfig, + } + + DeepSeek = &Provider{ + Name: "deepseek", + AgentName: "deepseek", + CatalogPrefix: "deepseek", + PricingPrefix: "deepseek", + EnvVars: []string{"DEEPSEEK_API_KEY"}, + modes: map[RuntimeMode]ModeCapabilities{ + // DeepSeek selects reasoning by model id (deepseek-reasoner vs + // deepseek-chat) and ships no attachment support. + ModeAPI: {Backend: BackendDeepSeek, Streaming: true}, + }, + claimPrefixes: []string{"deepseek"}, + families: []string{"deepseek"}, + } +) + +// Providers returns the provider families in canonical claim order. Parse walks +// this slice and the FIRST provider to claim a token wins, so the order is a +// contract, not an accident. The claim prefixes are disjoint today; the order +// also fixes AllBackends ordering. +func Providers() []*Provider { + return []*Provider{Anthropic, OpenAI, Google, DeepSeek} +} + +// ProviderByName resolves a provider by Name, CatalogPrefix, or PricingPrefix, +// so both "google" and "googleai" find Google. +func ProviderByName(name string) (*Provider, bool) { + name = strings.ToLower(strings.TrimSpace(name)) + for _, p := range Providers() { + if name == p.Name || name == p.CatalogPrefix || name == p.PricingPrefix { + return p, true + } + } + return nil, false +} + +// ProviderFor returns the descriptor that owns a backend, and the mode that +// backend represents. It replaces Backend.Provider, Backend.Kind, Backend.Family, +// registryProviderForBackend, and modelSourceBackend — one reverse lookup over +// the same table the forward direction uses. +func ProviderFor(b Backend) (*Provider, RuntimeMode, bool) { + for _, p := range Providers() { + for _, mode := range p.Modes() { + if p.modes[mode].Backend == b { + return p, mode, true + } + } + } + return nil, "", false +} diff --git a/pkg/api/registry/supported_models.go b/pkg/api/registry/supported_models.go new file mode 100644 index 0000000..85786bd --- /dev/null +++ b/pkg/api/registry/supported_models.go @@ -0,0 +1,96 @@ +package registry + +import ( + "context" + "sort" + "sync" +) + +// liveModelHooks are the optional live model-listing functions, keyed by +// provider name. pkg/ai installs them from its provider init(), mirroring the +// existing RegisterProvider/factories seam: the HTTP calls and key resolution +// they need live above this package, but the merged view belongs here. +var ( + liveModelMu sync.RWMutex + liveModelHooks = map[string]func(context.Context) ([]KnownModel, error){} +) + +// RegisterLiveModels installs a live model-listing hook for a provider. Passing +// nil clears it. Registration is global and read by SupportedModels. +func RegisterLiveModels(provider string, fn func(context.Context) ([]KnownModel, error)) { + liveModelMu.Lock() + defer liveModelMu.Unlock() + if fn == nil { + delete(liveModelHooks, provider) + return + } + liveModelHooks[provider] = fn +} + +func (p *Provider) liveModels(ctx context.Context) ([]KnownModel, error) { + liveModelMu.RLock() + fn := liveModelHooks[p.Name] + liveModelMu.RUnlock() + if fn == nil { + return nil, nil + } + return fn(ctx) +} + +// SupportedModels returns the models this provider offers, as resolved Models +// carrying their backend, mode, and capabilities. +// +// The catalog snapshot is the floor: a live listing (when a hook is registered +// and the call succeeds) adds models the snapshot has not caught up with and +// refreshes what it knows, but a live failure degrades to the snapshot rather +// than emptying the list. Results are ordered newest-first. +// +// Models are reported on the provider's API mode by default; SupportedModelsFor +// answers for a specific mode. +func (p *Provider) SupportedModels(ctx context.Context) []Model { + return p.SupportedModelsFor(ctx, ModeAPI) +} + +// SupportedModelsFor returns the models this provider offers on one mode. +func (p *Provider) SupportedModelsFor(ctx context.Context, mode RuntimeMode) []Model { + backend, err := p.BackendFor(mode) + if err != nil { + return nil + } + + merged := map[string]KnownModel{} + order := make([]string, 0) + add := func(m KnownModel) { + if !p.availableFor(m, mode) { + return + } + if _, seen := merged[m.ID]; !seen { + order = append(order, m.ID) + } + merged[m.ID] = m + } + for _, m := range p.Models() { + if m.Preferred { + add(m) + } + } + // A live listing refines the snapshot; it never replaces it, so a provider + // outage cannot empty the catalog. + if live, err := p.liveModels(ctx); err == nil { + for _, m := range live { + if m.Provider == "" { + m.Provider = p.Name + } + add(m) + } + } + + out := make([]Model, 0, len(order)) + for _, id := range order { + out = append(out, Model{Name: id, Backend: backend}.Capabilities()) + } + sort.SliceStable(out, func(i, j int) bool { + return merged[out[i].Name].ReleaseDate > merged[out[j].Name].ReleaseDate + }) + return out +} diff --git a/pkg/api/spec_merge.go b/pkg/api/spec_merge.go index bbcb46c..28bf8a8 100644 --- a/pkg/api/spec_merge.go +++ b/pkg/api/spec_merge.go @@ -14,11 +14,20 @@ package api // those tools. Boolean toggles (NoCache, Skip*) follow zero=unset: an override // can turn a flag on but not off, since false is indistinguishable from absent. func (s Spec) Merge(override Spec) Spec { - s.Model = s.merge(override.Model) + s.Model = s.Model.Merge(override.Model) s.Prompt = s.Prompt.merge(override.Prompt) + if len(override.Messages) > 0 { + s.Messages = override.Messages + } s.Budget = s.Budget.merge(override.Budget) s.Memory = s.Memory.merge(override.Memory) s.Permissions = s.Permissions.merge(override.Permissions) + if len(override.ToolPreferences) > 0 { + s.ToolPreferences = override.ToolPreferences + } + if override.ToolApproval != nil { + s.ToolApproval = override.ToolApproval + } if override.Setup != nil { s.Setup = override.Setup } @@ -34,31 +43,6 @@ func (s Spec) Merge(override Spec) Spec { return s } -func (m Model) merge(o Model) Model { - if o.Name != "" { - m.Name = o.Name - } - if o.ID != "" { - m.ID = o.ID - } - if o.Backend != "" { - m.Backend = o.Backend - } - if o.Temperature != nil { - m.Temperature = o.Temperature - } - if o.Effort != "" { - m.Effort = o.Effort - } - if o.NoCache { - m.NoCache = true - } - if len(o.Fallbacks) > 0 { - m.Fallbacks = o.Fallbacks - } - return m -} - func (p Prompt) merge(o Prompt) Prompt { if o.User != "" { p.User = o.User diff --git a/pkg/api/spec_test.go b/pkg/api/spec_test.go index d6caaac..e2f92b6 100644 --- a/pkg/api/spec_test.go +++ b/pkg/api/spec_test.go @@ -158,3 +158,28 @@ func TestSpec_Validate(t *testing.T) { }) } } + +// TestSpec_FallbacksCompact pins the end-to-end config shape: a Spec whose model +// has compact-string fallbacks, plus the primary parsed via Expand. The grammar +// itself is covered in pkg/api/registry; this pins that Spec decoding reaches it. +func TestSpec_FallbacksCompact(t *testing.T) { + var spec Spec + src := "model: opus\neffort: high\nfallbacks:\n - agent:sonnet:medium\n - api:gpt-5.5\n" + if err := yaml.Unmarshal([]byte(src), &spec); err != nil { + t.Fatal(err) + } + if spec.Model.Name != "opus" || spec.Model.Effort != EffortHigh { + t.Fatalf("primary = %+v", spec.Model) + } + if len(spec.Model.Fallbacks) != 2 { + t.Fatalf("fallbacks = %+v", spec.Model.Fallbacks) + } + if spec.Model.Fallbacks[0].Name != "sonnet" || spec.Model.Fallbacks[0].Backend != BackendClaudeAgent { + t.Errorf("fb0 = %+v", spec.Model.Fallbacks[0]) + } + // Candidates flattens primary + fallbacks in order. + cands := spec.Model.Candidates() + if len(cands) != 3 || cands[0].Name != "opus" || cands[1].Name != "sonnet" || cands[2].Name != "gpt-5.5" { + t.Errorf("candidates = %+v", cands) + } +} From 466fee6a5a073734ff552e059be0a0e5087009e6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 08:55:02 +0300 Subject: [PATCH 092/131] feat(ai): Update provider defaults and remove Grok mode Update Claude and OpenAI fallback selections to current preferred models, and remove unsupported Grok CLI mode detection. --- pkg/ai/provider/claude_cli.go | 2 +- pkg/api/registry/providers.go | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/ai/provider/claude_cli.go b/pkg/ai/provider/claude_cli.go index b31aede..a18f4d6 100644 --- a/pkg/ai/provider/claude_cli.go +++ b/pkg/ai/provider/claude_cli.go @@ -20,7 +20,7 @@ type ClaudeCLI struct { func NewClaudeCLI(model string) *ClaudeCLI { if strings.TrimSpace(model) == "" { - model = "claude-sonnet-5" + model = "opus" } model = ai.NormalizeModelForBackend(ai.BackendClaudeCLI, model) return &ClaudeCLI{model: model} diff --git a/pkg/api/registry/providers.go b/pkg/api/registry/providers.go index ed425c6..b6ca9e9 100644 --- a/pkg/api/registry/providers.go +++ b/pkg/api/registry/providers.go @@ -32,7 +32,7 @@ var ( identityTrim: []string{"claude-agent-", "claude-code-", "claude-"}, families: []string{"fable", "opus", "sonnet", "haiku"}, emptyTokens: []string{""}, - emptyFamily: "sonnet", + emptyFamily: "opus", genConfig: anthropicGenerationConfig, } @@ -54,13 +54,12 @@ var ( modeTokens: sortModeTokens([]modeToken{ {prefix: "codex-agent", mode: ModeAgent}, {prefix: "codex", mode: ModeCLI}, - {prefix: "grok-", mode: ModeCLI}, }), claimPrefixes: []string{"gpt-", "o1", "o3", "o4"}, identityTrim: []string{"codex-agent-", "codex-"}, families: []string{"gpt"}, emptyTokens: []string{"", "codex"}, - emptyFamily: "gpt", + emptyFamily: "gpt-5.6-sol", genConfig: openaiGenerationConfig, } From 781ed4f9a6df5e5525cc985fe649c22a45f94055 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 08:55:13 +0300 Subject: [PATCH 093/131] feat(ai): Add resumable tool approvals and canonical conversations Add provider-neutral conversation messages, tool preference resolution, and resumable approval state across Genkit execution. Correlate tool lifecycle events with provider call references and preserve completed sibling calls during resume. BREAKING CHANGE: remove legacy "enabled" and "disabled" tool mode labels. --- pkg/ai/provider/genkit/approval.go | 220 +++++++++++++++ pkg/ai/provider/genkit/mapping.go | 167 +++++++++++- .../provider/genkit/messages_ginkgo_test.go | 65 +++++ .../genkit/tool_approval_ginkgo_test.go | 175 ++++++++++++ .../genkit/tool_lifecycle_ginkgo_test.go | 130 +++++++++ .../genkit/tool_preferences_ginkgo_test.go | 116 ++++++++ pkg/ai/provider/genkit/tools.go | 193 ++++++++++++-- pkg/ai/provider/genkit/tools_test.go | 22 +- pkg/ai/tools/preferences_ginkgo_test.go | 36 +++ pkg/ai/tools/tools.go | 39 +-- pkg/ai/tools/tools_suite_test.go | 13 + pkg/api/message.go | 222 +++++++++++++++ pkg/api/message_ginkgo_test.go | 77 ++++++ pkg/api/permissions.go | 11 +- pkg/api/permissions_test.go | 4 +- pkg/api/runtime_event.go | 2 + pkg/api/spec.go | 62 ++++- pkg/api/tool_approval.go | 252 ++++++++++++++++++ pkg/api/tool_approval_ginkgo_test.go | 111 ++++++++ pkg/api/tool_preferences_ginkgo_test.go | 72 +++++ pkg/api/tooldef.go | 45 +++- pkg/api/workflow_test.go | 22 +- 22 files changed, 1963 insertions(+), 93 deletions(-) create mode 100644 pkg/ai/provider/genkit/approval.go create mode 100644 pkg/ai/provider/genkit/messages_ginkgo_test.go create mode 100644 pkg/ai/provider/genkit/tool_approval_ginkgo_test.go create mode 100644 pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go create mode 100644 pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go create mode 100644 pkg/ai/tools/preferences_ginkgo_test.go create mode 100644 pkg/ai/tools/tools_suite_test.go create mode 100644 pkg/api/message.go create mode 100644 pkg/api/message_ginkgo_test.go create mode 100644 pkg/api/tool_approval.go create mode 100644 pkg/api/tool_approval_ginkgo_test.go create mode 100644 pkg/api/tool_preferences_ginkgo_test.go diff --git a/pkg/ai/provider/genkit/approval.go b/pkg/ai/provider/genkit/approval.go new file mode 100644 index 0000000..3f8bfaf --- /dev/null +++ b/pkg/ai/provider/genkit/approval.go @@ -0,0 +1,220 @@ +package genkit + +import ( + "encoding/json" + "fmt" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" +) + +func toolApprovalState(req ai.Request, response *gkai.ModelResponse) (*api.ToolApprovalState, error) { + if response == nil || response.FinishReason != gkai.FinishReasonInterrupted || response.Message == nil { + return nil, fmt.Errorf("genkit approval state requires an interrupted model response") + } + messages, err := approvalRequestMessages(req) + if err != nil { + return nil, err + } + assistant, calls, err := interruptedAssistantMessage(response.Message) + if err != nil { + return nil, err + } + state := &api.ToolApprovalState{Messages: append(messages, assistant), Calls: calls} + if err := state.Validate(); err != nil { + return nil, fmt.Errorf("genkit approval state: %w", err) + } + return state, nil +} + +func approvalRequestMessages(req ai.Request) ([]api.Message, error) { + if len(req.Messages) > 0 { + if err := api.ValidateMessages(req.Messages); err != nil { + return nil, fmt.Errorf("canonical messages: %w", err) + } + return append([]api.Message(nil), req.Messages...), nil + } + messages := make([]api.Message, 0, 2) + if req.Prompt.System != "" { + messages = append(messages, api.Message{Role: api.RoleSystem, Parts: []api.Part{{Type: api.PartText, Text: req.Prompt.System}}}) + } + parts := make([]api.Part, 0, len(req.Prompt.Attachments)+1) + if req.Prompt.User != "" { + parts = append(parts, api.Part{Type: api.PartText, Text: req.Prompt.User}) + } + for i := range req.Prompt.Attachments { + attachment := req.Prompt.Attachments[i] + parts = append(parts, api.Part{Type: api.PartAttachment, Attachment: &attachment}) + } + if len(parts) == 0 { + return nil, fmt.Errorf("genkit approval state has no user prompt") + } + return append(messages, api.Message{Role: api.RoleUser, Parts: parts}), nil +} + +func interruptedAssistantMessage(message *gkai.Message) (api.Message, []api.ToolApprovalCall, error) { + if message.Role != gkai.RoleModel { + return api.Message{}, nil, fmt.Errorf("genkit interrupted message has role %q, expected model", message.Role) + } + assistant := api.Message{Role: api.RoleAssistant, Parts: make([]api.Part, 0, len(message.Content))} + calls := make([]api.ToolApprovalCall, 0, len(message.Content)) + for i, part := range message.Content { + switch { + case part.IsText(): + if part.Text != "" { + assistant.Parts = append(assistant.Parts, api.Part{Type: api.PartText, Text: part.Text}) + } + case part.IsReasoning(): + if part.Text != "" { + assistant.Parts = append(assistant.Parts, api.Part{Type: api.PartReasoning, Text: part.Text}) + } + case part.IsToolRequest() && part.ToolRequest != nil: + call, err := interruptedApprovalCall(part) + if err != nil { + return api.Message{}, nil, fmt.Errorf("interrupted message part %d: %w", i+1, err) + } + calls = append(calls, call) + request := call.Request + assistant.Parts = append(assistant.Parts, api.Part{Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ + ToolCallID: request.ToolCallID, Name: request.Tool, Input: request.Input, + }}) + default: + return api.Message{}, nil, fmt.Errorf("interrupted message part %d has unsupported content", i+1) + } + } + return assistant, calls, nil +} + +func interruptedApprovalCall(part *gkai.Part) (api.ToolApprovalCall, error) { + request := part.ToolRequest + input, err := json.Marshal(request.Input) + if err != nil { + return api.ToolApprovalCall{}, fmt.Errorf("marshal tool %q input: %w", request.Name, err) + } + call := api.ToolApprovalCall{Request: api.ToolApprovalRequest{ToolCallID: request.Ref, Tool: request.Name, Input: input}} + pendingOutput, resolved := part.Metadata["pendingOutput"] + _, interrupted := part.Metadata["interrupt"] + if resolved == interrupted { + return api.ToolApprovalCall{}, fmt.Errorf("tool call %q must be either interrupted or resolved", request.Ref) + } + if resolved { + output, err := json.Marshal(pendingOutput) + if err != nil { + return api.ToolApprovalCall{}, fmt.Errorf("marshal completed tool %q output: %w", request.Name, err) + } + call.Result = &api.ToolResult{ToolCallID: request.Ref, Output: output} + } + return call, nil +} + +func prepareToolApprovalResume(resume *api.ToolApprovalResume) ([]*gkai.Message, []*gkai.Part, []*gkai.Part, error) { + if resume == nil { + return nil, nil, nil, fmt.Errorf("tool approval resume is required") + } + if err := resume.Validate(); err != nil { + return nil, nil, nil, err + } + messages, err := conversationMessages(resume.State.Messages) + if err != nil { + return nil, nil, nil, err + } + requests := genkitApprovalRequests(messages[len(messages)-1]) + decisions := make(map[string]api.ToolApprovalDecision, len(resume.Decisions)) + for _, decision := range resume.Decisions { + decisions[decision.ToolCallID] = decision + } + restarts := make([]*gkai.Part, 0, len(resume.Decisions)) + responses := make([]*gkai.Part, 0, len(resume.Decisions)) + for _, call := range resume.State.Calls { + part := requests[call.Request.ToolCallID] + if call.Result != nil { + output, err := approvalResultOutput(call.Result) + if err != nil { + return nil, nil, nil, err + } + part.Metadata = map[string]any{"pendingOutput": output} + continue + } + decision := decisions[call.Request.ToolCallID] + switch decision.Action { + case api.ToolApprovalApprove: + input, err := approvalRestartInput(call.Request, decision) + if err != nil { + return nil, nil, nil, err + } + restart := gkai.NewToolRequestPart(&gkai.ToolRequest{Name: call.Request.Tool, Ref: call.Request.ToolCallID, Input: input}) + restart.Metadata = map[string]any{"resumed": map[string]any{"captainApproval": true}} + restarts = append(restarts, restart) + case api.ToolApprovalDeny: + reason := decision.Message + if reason == "" { + reason = "tool call denied" + } + responses = append(responses, gkai.NewToolResponsePart(&gkai.ToolResponse{ + Name: call.Request.Tool, Ref: call.Request.ToolCallID, Output: map[string]any{"denied": true, "reason": reason}, + })) + case api.ToolApprovalRespond: + output, err := approvalResultOutput(decision.Result) + if err != nil { + return nil, nil, nil, err + } + responses = append(responses, gkai.NewToolResponsePart(&gkai.ToolResponse{Name: call.Request.Tool, Ref: call.Request.ToolCallID, Output: output})) + } + } + return messages, restarts, responses, nil +} + +func genkitApprovalRequests(message *gkai.Message) map[string]*gkai.Part { + requests := make(map[string]*gkai.Part) + for _, part := range message.Content { + if part.IsToolRequest() && part.ToolRequest != nil { + requests[part.ToolRequest.Ref] = part + } + } + return requests +} + +func approvalRestartInput(request api.ToolApprovalRequest, decision api.ToolApprovalDecision) (any, error) { + if len(decision.Input) > 0 { + return decodePartJSON(decision.Input) + } + return decodePartJSON(request.Input) +} + +func approvalResultOutput(result *api.ToolResult) (any, error) { + if result.Error != "" { + return map[string]any{"error": result.Error}, nil + } + return decodePartJSON(result.Output) +} + +func seedToolApprovalCorrelation(resume *api.ToolApprovalResume, correlation *toolEventCorrelation) error { + if resume == nil || correlation == nil { + return nil + } + decisions := make(map[string]api.ToolApprovalDecision, len(resume.Decisions)) + for _, decision := range resume.Decisions { + decisions[decision.ToolCallID] = decision + } + for _, call := range resume.State.Calls { + input, err := decodePartJSON(call.Request.Input) + if err != nil { + return fmt.Errorf("seed approval call %q: %w", call.Request.ToolCallID, err) + } + if decision, ok := decisions[call.Request.ToolCallID]; ok && len(decision.Input) > 0 { + input, err = decodePartJSON(decision.Input) + if err != nil { + return fmt.Errorf("seed approval decision %q: %w", call.Request.ToolCallID, err) + } + } + request := &gkai.ToolRequest{Name: call.Request.Tool, Ref: call.Request.ToolCallID, Input: input} + if call.Result == nil && decisions[call.Request.ToolCallID].Action == api.ToolApprovalApprove { + correlation.observeRequest(request) + continue + } + correlation.seedResolved(request) + } + return nil +} diff --git a/pkg/ai/provider/genkit/mapping.go b/pkg/ai/provider/genkit/mapping.go index 920239c..a7f017b 100644 --- a/pkg/ai/provider/genkit/mapping.go +++ b/pkg/ai/provider/genkit/mapping.go @@ -2,6 +2,9 @@ package genkit import ( "encoding/json" + "fmt" + "reflect" + "sync" "time" "github.com/flanksource/captain/pkg/ai" @@ -9,12 +12,26 @@ import ( gkai "github.com/firebase/genkit/go/ai" ) -// chunkToEvents translates a streamed genkit chunk into captain stream events: -// text parts -> EventText, reasoning parts -> EventThinking, and completed -// (non-partial) tool requests -> EventToolUse. -func chunkToEvents(chunk *gkai.ModelResponseChunk, model string) []ai.Event { +type toolEventCorrelation struct { + mu sync.Mutex + pending []*gkai.ToolRequest + started map[string]string + finished map[string]bool +} + +func newToolEventCorrelation() *toolEventCorrelation { + return &toolEventCorrelation{ + started: make(map[string]string), + finished: make(map[string]bool), + } +} + +// chunkToEvents translates text and reasoning while retaining provider tool +// requests for the in-process handler. Tool lifecycle events are emitted only +// by runTool, after the handler can correlate the call to its provider ref. +func chunkToEvents(chunk *gkai.ModelResponseChunk, model string, correlation *toolEventCorrelation) ([]ai.Event, error) { if chunk == nil { - return nil + return nil, nil } var events []ai.Event for _, p := range chunk.Content { @@ -23,17 +40,139 @@ func chunkToEvents(chunk *gkai.ModelResponseChunk, model string) []ai.Event { events = append(events, ai.Event{Kind: ai.EventText, Text: p.Text, Model: model}) case p.IsReasoning() && p.Text != "": events = append(events, ai.Event{Kind: ai.EventThinking, Text: p.Text, Model: model}) - case p.IsToolRequest() && p.ToolRequest != nil && !p.ToolRequest.Partial: - tr := p.ToolRequest - events = append(events, ai.Event{ - Kind: ai.EventToolUse, - Tool: tr.Name, - Input: toInputMap(tr.Input), - Model: model, - }) + case p.IsToolRequest() && p.ToolRequest != nil: + if correlation != nil { + correlation.observeRequest(p.ToolRequest) + } + case p.IsToolResponse() && p.ToolResponse != nil && !p.IsPartial(): + if correlation == nil { + return nil, fmt.Errorf("genkit tool response %q has no correlation state", p.ToolResponse.Ref) + } + if err := correlation.observeResponse(p.ToolResponse); err != nil { + return nil, err + } + } + } + return events, nil +} + +func (c *toolEventCorrelation) observeRequest(request *gkai.ToolRequest) { + if request == nil || request.Partial || request.Name == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for _, pending := range c.pending { + if pending == request || (request.Ref != "" && pending.Ref == request.Ref) { + return + } + } + if request.Ref != "" { + if _, ok := c.started[request.Ref]; ok { + return + } + } + c.pending = append(c.pending, request) +} + +func (c *toolEventCorrelation) begin(name string, input map[string]any) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + matchingName := make([]int, 0, 1) + exact := make([]int, 0, 1) + for i, request := range c.pending { + if request.Name != name { + continue + } + matchingName = append(matchingName, i) + if requestInput, ok := toolRequestInput(request.Input); ok && reflect.DeepEqual(requestInput, input) { + exact = append(exact, i) + } + } + + index, err := correlatedRequestIndex(name, matchingName, exact) + if err != nil { + return "", err + } + request := c.pending[index] + if request.Ref == "" { + return "", fmt.Errorf("genkit tool %q provider request has no call reference", name) + } + if _, exists := c.started[request.Ref]; exists { + return "", fmt.Errorf("genkit tool request %q started more than once", request.Ref) + } + c.pending = append(c.pending[:index], c.pending[index+1:]...) + c.started[request.Ref] = name + return request.Ref, nil +} + +func correlatedRequestIndex(name string, matchingName, exact []int) (int, error) { + switch { + case len(exact) == 1: + return exact[0], nil + case len(exact) > 1: + return 0, fmt.Errorf("genkit tool %q has multiple provider requests with the same input", name) + case len(matchingName) == 1: + return matchingName[0], nil + case len(matchingName) > 1: + return 0, fmt.Errorf("genkit tool %q has multiple provider requests that cannot be correlated by input", name) + default: + return 0, fmt.Errorf("genkit tool %q execution has no correlated provider request", name) + } +} + +func (c *toolEventCorrelation) finish(ref string) error { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.started[ref]; !ok { + return fmt.Errorf("genkit tool result %q has no correlated provider request", ref) + } + if c.finished[ref] { + return fmt.Errorf("genkit tool result %q emitted more than once", ref) + } + c.finished[ref] = true + return nil +} + +func (c *toolEventCorrelation) seedResolved(request *gkai.ToolRequest) { + c.mu.Lock() + defer c.mu.Unlock() + c.started[request.Ref] = request.Name + c.finished[request.Ref] = true +} + +func (c *toolEventCorrelation) observeResponse(response *gkai.ToolResponse) error { + c.mu.Lock() + defer c.mu.Unlock() + if response.Ref == "" { + return fmt.Errorf("genkit tool response has no provider call reference") + } + name, ok := c.started[response.Ref] + if !ok { + return fmt.Errorf("genkit received uncorrelated tool response %q", response.Ref) + } + if name != response.Name { + return fmt.Errorf("genkit tool response %q names %q, expected %q", response.Ref, response.Name, name) + } + if !c.finished[response.Ref] { + return fmt.Errorf("genkit tool response %q arrived before its result", response.Ref) + } + delete(c.started, response.Ref) + delete(c.finished, response.Ref) + return nil +} + +func toolRequestInput(input any) (map[string]any, bool) { + if text, ok := input.(string); ok { + var decoded map[string]any + if err := json.Unmarshal([]byte(text), &decoded); err != nil { + return nil, false } + return decoded, true } - return events + decoded := toInputMap(input) + return decoded, decoded != nil } // toInputMap normalizes a genkit tool-request input (typed any) into the diff --git a/pkg/ai/provider/genkit/messages_ginkgo_test.go b/pkg/ai/provider/genkit/messages_ginkgo_test.go new file mode 100644 index 0000000..fabab57 --- /dev/null +++ b/pkg/ai/provider/genkit/messages_ginkgo_test.go @@ -0,0 +1,65 @@ +package genkit + +import ( + "encoding/json" + "strings" + + "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("canonical message projection", func() { + It("projects multimodal history without flattening typed parts", func() { + attachment := api.AttachmentRef{ID: api.AttachmentIDPrefix + strings.Repeat("a", 64), MediaType: "image/png"}. + WithPreparedContent(api.AttachmentContent{Bytes: []byte("image")}) + messages, err := conversationMessages([]api.Message{ + {Role: api.RoleSystem, Parts: []api.Part{{Type: api.PartText, Text: "Be precise."}}}, + {Role: api.RoleUser, Parts: []api.Part{ + {Type: api.PartText, Text: "Inspect this."}, + {Type: api.PartAttachment, Attachment: &attachment}, + }}, + {Role: api.RoleAssistant, Parts: []api.Part{ + {Type: api.PartReasoning, Text: "I should inspect it."}, + {Type: api.PartText, Text: "Done."}, + }}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(messages).To(HaveLen(3)) + Expect(messages[0].Role).To(Equal(gkai.RoleSystem)) + Expect(messages[1].Role).To(Equal(gkai.RoleUser)) + Expect(messages[1].Content).To(HaveLen(2)) + Expect(messages[1].Content[1]).To(SatisfyAll( + HaveField("Kind", gkai.PartMedia), + HaveField("ContentType", "image/png"), + HaveField("Text", "data:image/png;base64,aW1hZ2U="), + )) + Expect(messages[2].Role).To(Equal(gkai.RoleModel)) + Expect(messages[2].Content[0].Kind).To(Equal(gkai.PartReasoning)) + }) + + It("correlates canonical tool requests and results", func() { + messages, err := conversationMessages([]api.Message{ + {Role: api.RoleUser, Parts: []api.Part{{Type: api.PartText, Text: "List stacks."}}}, + {Role: api.RoleAssistant, Parts: []api.Part{{Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ + ToolCallID: "call-1", Name: "stack_list", Input: json.RawMessage(`{"limit":2}`), + }}}}, + {Role: api.RoleTool, Parts: []api.Part{{Type: api.PartToolResult, ToolResult: &api.ToolResult{ + ToolCallID: "call-1", Output: json.RawMessage(`{"items":["a","b"]}`), + }}}}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(messages).To(HaveLen(3)) + request := messages[1].Content[0].ToolRequest + Expect(request).To(Equal(&gkai.ToolRequest{Ref: "call-1", Name: "stack_list", Input: map[string]any{"limit": float64(2)}})) + result := messages[2].Content[0].ToolResponse + Expect(result).To(Equal(&gkai.ToolResponse{Ref: "call-1", Name: "stack_list", Output: map[string]any{"items": []any{"a", "b"}}})) + }) + + It("does not fall back to the single-turn prompt for an invalid conversation", func() { + _, err := conversationMessages([]api.Message{{Role: api.RoleUser, Parts: []api.Part{{Type: api.PartReasoning, Text: "invalid"}}}}) + Expect(err).To(MatchError(ContainSubstring("user message cannot contain reasoning"))) + }) +}) diff --git a/pkg/ai/provider/genkit/tool_approval_ginkgo_test.go b/pkg/ai/provider/genkit/tool_approval_ginkgo_test.go new file mode 100644 index 0000000..16f545b --- /dev/null +++ b/pkg/ai/provider/genkit/tool_approval_ginkgo_test.go @@ -0,0 +1,175 @@ +package genkit + +import ( + "context" + "encoding/json" + "sync/atomic" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" + gk "github.com/firebase/genkit/go/genkit" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Genkit resumable tool approval", func() { + It("suspends an ask-mode tool instead of executing it without a broker", func() { + provider := newToolProvider(nil) + correlation := newToolEventCorrelation() + correlation.observeRequest(&gkai.ToolRequest{Name: "invoice_update", Ref: "call-update", Input: map[string]any{"amount": 10}}) + events := make([]ai.Event, 0, 2) + ran := false + + _, err := provider.runTool(context.Background(), api.ToolDefinition{ + Name: "invoice_update", + DefaultPermission: api.ToolModeAsk, + Handler: func(context.Context, map[string]any) (any, error) { + ran = true + return "updated", nil + }, + }, map[string]any{"amount": 10}, func(event ai.Event) { events = append(events, event) }, correlation) + + interrupted, metadata := gkai.IsToolInterruptError(err) + Expect(interrupted).To(BeTrue()) + Expect(metadata).To(Equal(map[string]any{"approvalRequired": true})) + Expect(ran).To(BeFalse()) + Expect(events).To(HaveLen(2)) + Expect(events[0].Kind).To(Equal(ai.EventToolUse)) + Expect(events[1].Kind).To(Equal(ai.EventPermission)) + Expect(events[1].ToolCallID).To(Equal("call-update")) + }) + + It("serializes an interrupted turn with pending and completed sibling calls", func() { + request := api.Spec{Prompt: api.Prompt{User: "Update then inspect."}} + pending := gkai.NewToolRequestPart(&gkai.ToolRequest{Name: "invoice_update", Ref: "call-update", Input: map[string]any{"amount": 10}}) + pending.Metadata = map[string]any{"interrupt": map[string]any{"approvalRequired": true}} + completed := gkai.NewToolRequestPart(&gkai.ToolRequest{Name: "invoice_get", Ref: "call-read", Input: map[string]any{"id": "inv-1"}}) + completed.Metadata = map[string]any{"pendingOutput": map[string]any{"amount": 10}} + response := &gkai.ModelResponse{Message: gkai.NewModelMessage(pending, completed), FinishReason: gkai.FinishReasonInterrupted} + + state, err := toolApprovalState(request, response) + Expect(err).NotTo(HaveOccurred()) + Expect(state.Validate()).To(Succeed()) + Expect(state.Messages).To(HaveLen(2)) + Expect(state.Pending()).To(Equal([]api.ToolApprovalRequest{{ + ToolCallID: "call-update", Tool: "invoice_update", Input: json.RawMessage(`{"amount":10}`), + }})) + Expect(state.Calls[1].Result).NotTo(BeNil()) + Expect(state.Calls[1].Result.Output).To(MatchJSON(`{"amount":10}`)) + }) + + It("restarts an approved call with edited input and never replays completed siblings", func(ctx SpecContext) { + var updateRuns atomic.Int32 + var readRuns atomic.Int32 + var updatedAmount atomic.Int32 + genkit := gk.Init(ctx) + modelRef := "test/resumable-approval" + gk.DefineModel(genkit, modelRef, &gkai.ModelOptions{Supports: &gkai.ModelSupports{Tools: true, Multiturn: true}}, + func(_ context.Context, request *gkai.ModelRequest, stream gkai.ModelStreamCallback) (*gkai.ModelResponse, error) { + last := request.Messages[len(request.Messages)-1] + if last.Role == gkai.RoleTool { + if stream != nil { + Expect(stream(ctx, &gkai.ModelResponseChunk{Role: gkai.RoleModel, Content: []*gkai.Part{gkai.NewTextPart("done")}})).To(Succeed()) + } + return &gkai.ModelResponse{Message: gkai.NewModelTextMessage("done"), FinishReason: gkai.FinishReasonStop}, nil + } + message := gkai.NewModelMessage( + gkai.NewToolRequestPart(&gkai.ToolRequest{Name: "invoice_get", Ref: "call-read", Input: map[string]any{"id": "inv-1"}}), + gkai.NewToolRequestPart(&gkai.ToolRequest{Name: "invoice_update", Ref: "call-update", Input: map[string]any{"amount": 10}}), + ) + if stream != nil { + Expect(stream(ctx, &gkai.ModelResponseChunk{Role: gkai.RoleModel, Content: message.Content})).To(Succeed()) + } + return &gkai.ModelResponse{Message: message, FinishReason: gkai.FinishReasonStop}, nil + }) + + provider := &Provider{ + cfg: ai.Config{ + Model: api.Model{Name: "resumable-approval", Backend: api.BackendOpenAI}, + Tools: []api.ToolDefinition{ + {Name: "invoice_get", DefaultPermission: api.ToolModeOn, Handler: func(context.Context, map[string]any) (any, error) { + readRuns.Add(1) + return map[string]any{"amount": 10}, nil + }}, + {Name: "invoice_update", DefaultPermission: api.ToolModeAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { + updateRuns.Add(1) + updatedAmount.Store(int32(input["amount"].(float64))) + return map[string]any{"updated": true}, nil + }}, + }, + }, + backend: api.BackendOpenAI, + g: genkit, modelRef: modelRef, + } + + firstStream, err := provider.ExecuteStream(ctx, api.Spec{Prompt: api.Prompt{User: "Update then inspect."}}) + Expect(err).NotTo(HaveOccurred()) + var state *api.ToolApprovalState + for event := range firstStream { + Expect(event.Kind).NotTo(Equal(ai.EventError), event.Error) + if event.Kind == ai.EventResult { + state = event.ToolApproval + } + } + Expect(state).NotTo(BeNil()) + Expect(state.Pending()).To(HaveLen(1)) + Expect(readRuns.Load()).To(Equal(int32(1))) + Expect(updateRuns.Load()).To(BeZero()) + + resumedStream, err := provider.ExecuteStream(ctx, api.Spec{ToolApproval: &api.ToolApprovalResume{ + State: *state, + Decisions: []api.ToolApprovalDecision{{ + ToolCallID: "call-update", Tool: "invoice_update", Action: api.ToolApprovalApprove, + Input: json.RawMessage(`{"amount":12}`), + }}, + }}) + Expect(err).NotTo(HaveOccurred()) + var resumed []ai.Event + sawDone := false + for event := range resumedStream { + resumed = append(resumed, event) + Expect(event.Kind).NotTo(Equal(ai.EventError), event.Error) + if event.Kind == ai.EventText && event.Text == "done" { + sawDone = true + } + } + Expect(sawDone).To(BeTrue()) + Expect(resumed[len(resumed)-1].Kind).To(Equal(ai.EventResult)) + Expect(resumed[len(resumed)-1].ToolApproval).To(BeNil()) + Expect(readRuns.Load()).To(Equal(int32(1))) + Expect(updateRuns.Load()).To(Equal(int32(1))) + Expect(updatedAmount.Load()).To(Equal(int32(12))) + }) + + It("maps deny and externally-resolved calls to native responses", func() { + state := api.ToolApprovalState{ + Messages: []api.Message{ + {Role: api.RoleUser, Parts: []api.Part{{Type: api.PartText, Text: "Change it."}}}, + {Role: api.RoleAssistant, Parts: []api.Part{ + {Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ToolCallID: "call-deny", Name: "invoice_delete", Input: json.RawMessage(`{"id":"inv-1"}`)}}, + {Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ToolCallID: "call-respond", Name: "invoice_update", Input: json.RawMessage(`{"amount":10}`)}}, + }}, + }, + Calls: []api.ToolApprovalCall{ + {Request: api.ToolApprovalRequest{ToolCallID: "call-deny", Tool: "invoice_delete", Input: json.RawMessage(`{"id":"inv-1"}`)}}, + {Request: api.ToolApprovalRequest{ToolCallID: "call-respond", Tool: "invoice_update", Input: json.RawMessage(`{"amount":10}`)}}, + }, + } + resume := &api.ToolApprovalResume{State: state, Decisions: []api.ToolApprovalDecision{ + {ToolCallID: "call-deny", Tool: "invoice_delete", Action: api.ToolApprovalDeny, Message: "keep it"}, + {ToolCallID: "call-respond", Tool: "invoice_update", Action: api.ToolApprovalRespond, Result: &api.ToolResult{ + ToolCallID: "call-respond", Output: json.RawMessage(`{"updated":true}`), + }}, + }} + + messages, restarts, responses, err := prepareToolApprovalResume(resume) + Expect(err).NotTo(HaveOccurred()) + Expect(restarts).To(BeEmpty()) + Expect(responses).To(HaveLen(2)) + Expect(messages[len(messages)-1].Role).To(Equal(gkai.RoleModel)) + Expect(responses[0].ToolResponse.Output).To(Equal(map[string]any{"denied": true, "reason": "keep it"})) + Expect(responses[1].ToolResponse.Output).To(Equal(map[string]any{"updated": true})) + }) +}) diff --git a/pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go b/pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go new file mode 100644 index 0000000..7d77327 --- /dev/null +++ b/pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go @@ -0,0 +1,130 @@ +package genkit + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" +) + +var _ = Describe("Genkit tool event correlation", func() { + var ( + provider *Provider + events []ai.Event + emit func(ai.Event) + tool api.ToolDefinition + ) + + BeforeEach(func() { + provider = newToolProvider(nil) + events = nil + emit = func(event ai.Event) { events = append(events, event) } + tool = api.ToolDefinition{ + Name: "lookup", + DefaultPermission: api.ToolModeOn, + Handler: func(_ context.Context, input map[string]any) (any, error) { + return map[string]any{"city": input["city"]}, nil + }, + } + }) + + It("uses the Anthropic provider reference for one lifecycle", func() { + correlation := newToolEventCorrelation() + request := &gkai.ToolRequest{Name: tool.Name, Ref: "toolu_123", Input: map[string]any{"city": "Cape Town"}} + + mapped, err := chunkToEvents(toolRequestChunk(request), provider.GetModel(), correlation) + Expect(err).NotTo(HaveOccurred()) + Expect(mapped).To(BeEmpty()) + mapped, err = chunkToEvents(toolRequestChunk(request), provider.GetModel(), correlation) + Expect(err).NotTo(HaveOccurred()) + Expect(mapped).To(BeEmpty()) + + _, err = provider.runTool(context.Background(), tool, request.Input, emit, correlation) + Expect(err).NotTo(HaveOccurred()) + Expect(events).To(HaveLen(2)) + Expect(events[0].Kind).To(Equal(ai.EventToolUse)) + Expect(events[0].ToolCallID).To(Equal(request.Ref)) + Expect(events[1].Kind).To(Equal(ai.EventToolResult)) + Expect(events[1].ToolCallID).To(Equal(request.Ref)) + + mapped, err = chunkToEvents(toolResponseChunk(tool.Name, request.Ref), provider.GetModel(), correlation) + Expect(err).NotTo(HaveOccurred()) + Expect(mapped).To(BeEmpty()) + }) + + It("ignores OpenAI argument deltas after retaining the provider reference", func() { + correlation := newToolEventCorrelation() + first := &gkai.ToolRequest{Name: tool.Name, Ref: "call_123", Input: `{"city"`} + delta := &gkai.ToolRequest{Input: `:"Cape Town"}`} + + _, err := chunkToEvents(toolRequestChunk(first), provider.GetModel(), correlation) + Expect(err).NotTo(HaveOccurred()) + _, err = chunkToEvents(toolRequestChunk(delta), provider.GetModel(), correlation) + Expect(err).NotTo(HaveOccurred()) + + _, err = provider.runTool(context.Background(), tool, map[string]any{"city": "Cape Town"}, emit, correlation) + Expect(err).NotTo(HaveOccurred()) + Expect(events).To(HaveLen(2)) + Expect(events[0].ToolCallID).To(Equal(first.Ref)) + Expect(events[1].ToolCallID).To(Equal(first.Ref)) + }) + + It("preserves a Gemini reference assigned after its chunk was observed", func() { + correlation := newToolEventCorrelation() + request := &gkai.ToolRequest{Name: tool.Name, Input: map[string]any{"city": "Cape Town"}} + + _, err := chunkToEvents(toolRequestChunk(request), provider.GetModel(), correlation) + Expect(err).NotTo(HaveOccurred()) + request.Ref = "genkit-assigned-ref" + + _, err = provider.runTool(context.Background(), tool, request.Input, emit, correlation) + Expect(err).NotTo(HaveOccurred()) + Expect(events).To(HaveLen(2)) + Expect(events[0].ToolCallID).To(Equal(request.Ref)) + Expect(events[1].ToolCallID).To(Equal(request.Ref)) + }) + + It("keeps permission events on the provider reference", func() { + provider = newToolProvider(func(_ context.Context, request api.PermissionRequest) (api.PermissionDecision, error) { + Expect(request.ToolUseID).To(Equal("toolu_approved")) + return api.PermissionDecision{Allow: true}, nil + }) + tool.DefaultPermission = api.ToolModeAsk + correlation := newToolEventCorrelation() + request := &gkai.ToolRequest{Name: tool.Name, Ref: "toolu_approved", Input: map[string]any{"city": "Cape Town"}} + _, err := chunkToEvents(toolRequestChunk(request), provider.GetModel(), correlation) + Expect(err).NotTo(HaveOccurred()) + + _, err = provider.runTool(context.Background(), tool, request.Input, emit, correlation) + Expect(err).NotTo(HaveOccurred()) + Expect(events).To(HaveLen(3)) + Expect(events[1].Kind).To(Equal(ai.EventPermission)) + Expect(events[1].ToolCallID).To(Equal(request.Ref)) + }) + + It("fails loudly when a tool response has no correlated request", func() { + correlation := newToolEventCorrelation() + _, err := chunkToEvents(toolResponseChunk(tool.Name, "missing"), provider.GetModel(), correlation) + Expect(err).To(MatchError(ContainSubstring(`uncorrelated tool response "missing"`))) + }) + + It("fails loudly when execution has no provider request", func() { + correlation := newToolEventCorrelation() + _, err := provider.runTool(context.Background(), tool, map[string]any{"city": "Cape Town"}, emit, correlation) + Expect(err).To(MatchError(ContainSubstring(`no correlated provider request`))) + Expect(events).To(BeEmpty()) + }) +}) + +func toolRequestChunk(request *gkai.ToolRequest) *gkai.ModelResponseChunk { + return &gkai.ModelResponseChunk{Role: gkai.RoleModel, Content: []*gkai.Part{gkai.NewToolRequestPart(request)}} +} + +func toolResponseChunk(name, ref string) *gkai.ModelResponseChunk { + return &gkai.ModelResponseChunk{Role: gkai.RoleTool, Content: []*gkai.Part{gkai.NewToolResponsePart(&gkai.ToolResponse{Name: name, Ref: ref, Output: "ok"})}} +} diff --git a/pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go b/pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go new file mode 100644 index 0000000..14f8220 --- /dev/null +++ b/pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go @@ -0,0 +1,116 @@ +package genkit + +import ( + "context" + "fmt" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Genkit tool policy", func() { + noop := func(context.Context, map[string]any) (any, error) { return "ok", nil } + + It("resolves tool preferences before exposing tools", func() { + defs := []api.ToolDefinition{ + {Name: "invoice_list", Group: "billing", DefaultPermission: api.ToolModeAsk, Handler: noop}, + {Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolModeOn, Handler: noop}, + {Name: "search", DefaultPermission: api.ToolModeOff, Handler: noop}, + } + + selected, err := resolveToolDefinitions(defs, api.ToolPreferences{ + "billing": api.ToolModeOff, + "invoice_list": api.ToolModeOn, + "search": api.ToolModeAsk, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(selected).To(HaveLen(2)) + Expect(selected[0].Name).To(Equal("invoice_list")) + Expect(selected[0].NeedsApproval()).To(BeFalse()) + Expect(selected[1].Name).To(Equal("search")) + Expect(selected[1].NeedsApproval()).To(BeTrue()) + }) + + It("lets tool-level ask override group-level on and invokes Captain approval", func() { + approved := 0 + provider := newToolProvider(func(_ context.Context, request api.PermissionRequest) (api.PermissionDecision, error) { + approved++ + Expect(request.Tool).To(Equal("invoice_delete")) + return api.PermissionDecision{Allow: true}, nil + }) + def := api.ToolDefinition{ + Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolModeOn, Handler: noop, + } + selected, err := resolveToolDefinitions([]api.ToolDefinition{def}, api.ToolPreferences{ + "billing": api.ToolModeOn, + "invoice_delete": api.ToolModeAsk, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(selected).To(HaveLen(1)) + + _, err = runCorrelatedTool(provider, selected[0], map[string]any{}, func(ai.Event) {}) + Expect(err).NotTo(HaveOccurred()) + Expect(approved).To(Equal(1)) + }) + + It("rejects invalid preferences instead of silently using defaults", func() { + _, err := resolveToolDefinitions([]api.ToolDefinition{{Name: "search", Handler: noop}}, api.ToolPreferences{"search": "sometimes"}) + Expect(err).To(MatchError(ContainSubstring(`invalid tool preference "sometimes" for "search"`))) + }) + + It("rejects an invalid tool default even when a preference disables the tool", func() { + _, err := resolveToolDefinitions([]api.ToolDefinition{ + {Name: "search", DefaultPermission: "sometimes", Handler: noop}, + }, api.ToolPreferences{"search": api.ToolModeOff}) + Expect(err).To(MatchError(ContainSubstring(`tool "search" has invalid default permission "sometimes"`))) + }) + + It("caps Anthropic strict tools deterministically without dropping tools", func() { + defs := make([]api.ToolDefinition, 0, anthropicMaxStrictTools+3) + for i := 0; i < anthropicMaxStrictTools+2; i++ { + defs = append(defs, api.ToolDefinition{ + Name: fmt.Sprintf("readonly_%02d", i), Strict: boolPointer(true), ReadOnlyHint: boolPointer(true), Handler: noop, + }) + } + defs = append(defs, api.ToolDefinition{ + Name: "destructive", Strict: boolPointer(true), DestructiveHint: boolPointer(true), Handler: noop, + }) + + shaped := anthropicStrictToolDefinitions(defs) + Expect(shaped).To(HaveLen(len(defs))) + strictNames := make([]string, 0, anthropicMaxStrictTools) + for _, def := range shaped { + Expect(def.Strict).NotTo(BeNil()) + if *def.Strict { + strictNames = append(strictNames, def.Name) + } + } + Expect(strictNames).To(HaveLen(anthropicMaxStrictTools)) + Expect(strictNames).To(ContainElement("destructive")) + Expect(strictNames).NotTo(ContainElements("readonly_19", "readonly_20", "readonly_21")) + Expect(*defs[0].Strict).To(BeTrue(), "shaping must not mutate Config.Tools") + }) + + It("writes explicit Anthropic strict metadata onto every Genkit tool", func() { + provider := newToolProvider(nil) + strict := api.ToolDefinition{Name: "strict", Strict: boolPointer(true), Handler: noop} + loose := api.ToolDefinition{Name: "loose", Strict: boolPointer(false), Handler: noop} + + Expect(genkitStrictMetadata(provider.genkitTool(strict, nil, nil))).To(BeTrue()) + Expect(genkitStrictMetadata(provider.genkitTool(loose, nil, nil))).To(BeFalse()) + }) +}) + +func genkitStrictMetadata(ref gkai.ToolRef) bool { + tool, ok := ref.(gkai.Tool) + Expect(ok).To(BeTrue()) + strict, ok := tool.Definition().Metadata["strict"].(bool) + Expect(ok).To(BeTrue()) + return strict +} + +func boolPointer(value bool) *bool { return &value } diff --git a/pkg/ai/provider/genkit/tools.go b/pkg/ai/provider/genkit/tools.go index a5bc074..4eb5f84 100644 --- a/pkg/ai/provider/genkit/tools.go +++ b/pkg/ai/provider/genkit/tools.go @@ -4,15 +4,20 @@ import ( "context" "encoding/json" "fmt" + "sort" "github.com/flanksource/captain/pkg/ai" + captools "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/api" gkai "github.com/firebase/genkit/go/ai" ) // maxToolTurns caps the model↔tool iteration so a misbehaving loop terminates. -const maxToolTurns = 16 +const ( + maxToolTurns = 16 + anthropicMaxStrictTools = 20 +) // SupportsCallerTools reports that the genkit provider can expose and execute // api.Config.Tools. It satisfies api.ToolCapableProvider. @@ -23,44 +28,173 @@ func (p *Provider) SupportsCallerTools() bool { return true } // non-nil, receives EventToolUse / EventPermission / EventToolResult as the // model calls them. Returns nil when there are no caller tools, so a run with // none is byte-for-byte unchanged. -func (p *Provider) toolOptions(emit func(ai.Event)) []gkai.GenerateOption { - if len(p.cfg.Tools) == 0 { - return nil +func (p *Provider) toolOptions(preferences api.ToolPreferences, emit func(ai.Event)) ([]gkai.GenerateOption, error) { + definitions, err := resolveToolDefinitions(p.cfg.Tools, preferences) + if err != nil { + return nil, err + } + if len(definitions) == 0 { + return nil, nil + } + if p.backend == ai.BackendAnthropic { + if count := explicitStrictToolCount(definitions); count > anthropicMaxStrictTools { + log.Warnf("Anthropic supports at most %d strict tools; keeping %d of %d explicit opt-ins strict and sending %d as non-strict", anthropicMaxStrictTools, anthropicMaxStrictTools, count, count-anthropicMaxStrictTools) + } + definitions = anthropicStrictToolDefinitions(definitions) + } + refs := make([]gkai.ToolRef, 0, len(definitions)) + for i := range definitions { + refs = append(refs, p.genkitTool(definitions[i], emit, p.toolCorrelation)) + } + return []gkai.GenerateOption{gkai.WithTools(refs...), gkai.WithMaxTurns(maxToolTurns)}, nil +} + +func resolveToolDefinitions(definitions []api.ToolDefinition, preferences api.ToolPreferences) ([]api.ToolDefinition, error) { + if err := preferences.Validate(); err != nil { + return nil, err + } + selected := make([]api.ToolDefinition, 0, len(definitions)) + for _, definition := range definitions { + if definition.Name == "" { + return nil, fmt.Errorf("genkit tool name cannot be empty") + } + if definition.Handler == nil { + return nil, fmt.Errorf("genkit tool %q has no handler", definition.Name) + } + mode, err := effectiveToolMode(definition, preferences) + if err != nil { + return nil, err + } + if mode == api.ToolModeOff { + continue + } + definition.DefaultPermission = mode + selected = append(selected, definition) + } + return selected, nil +} + +func effectiveToolMode(definition api.ToolDefinition, preferences api.ToolPreferences) (api.ToolMode, error) { + defaultMode := api.ToolModeAuto + if definition.DefaultPermission != "" { + var ok bool + defaultMode, ok = api.NormalizeToolMode(definition.DefaultPermission) + if !ok { + return "", fmt.Errorf("genkit tool %q has invalid default permission %q", definition.Name, definition.DefaultPermission) + } + } + info := captools.ToolInfo{Name: definition.Name, Group: definition.Group} + if preferred, ok := captools.EffectivePreference(preferences, info); ok && preferred != api.ToolModeAuto { + return preferred, nil + } + return defaultMode, nil +} + +func anthropicStrictToolDefinitions(definitions []api.ToolDefinition) []api.ToolDefinition { + type candidate struct { + index int + def api.ToolDefinition + } + candidates := make([]candidate, 0, len(definitions)) + for i, definition := range definitions { + if definition.Strict != nil && *definition.Strict { + candidates = append(candidates, candidate{index: i, def: definition}) + } + } + sort.SliceStable(candidates, func(i, j int) bool { + if left, right := strictRiskRank(candidates[i].def), strictRiskRank(candidates[j].def); left != right { + return left < right + } + if candidates[i].def.Name != candidates[j].def.Name { + return candidates[i].def.Name < candidates[j].def.Name + } + return candidates[i].index < candidates[j].index + }) + strict := make(map[int]bool, min(len(candidates), anthropicMaxStrictTools)) + for _, candidate := range candidates[:min(len(candidates), anthropicMaxStrictTools)] { + strict[candidate.index] = true } - refs := make([]gkai.ToolRef, 0, len(p.cfg.Tools)) - for i := range p.cfg.Tools { - refs = append(refs, p.genkitTool(p.cfg.Tools[i], emit)) + shaped := make([]api.ToolDefinition, len(definitions)) + for i, definition := range definitions { + value := strict[i] + definition.Strict = &value + shaped[i] = definition + } + return shaped +} + +func explicitStrictToolCount(definitions []api.ToolDefinition) int { + count := 0 + for _, definition := range definitions { + if definition.Strict != nil && *definition.Strict { + count++ + } + } + return count +} + +func strictRiskRank(definition api.ToolDefinition) int { + switch { + case definition.DestructiveHint != nil && *definition.DestructiveHint: + return 0 + case definition.IdempotentHint != nil && !*definition.IdempotentHint: + return 1 + case definition.ReadOnlyHint != nil && !*definition.ReadOnlyHint: + return 2 + case definition.ReadOnlyHint == nil: + return 3 + default: + return 4 } - return []gkai.GenerateOption{gkai.WithTools(refs...), gkai.WithMaxTurns(maxToolTurns)} } // genkitTool wraps one api.ToolDefinition as an ephemeral genkit tool. Ephemeral // (NewTool, passed via WithTools) rather than genkit.DefineTool: // the genkit instance is cached and shared per (backend, apiKey), so registering // tools globally would leak them across unrelated requests. -func (p *Provider) genkitTool(def api.ToolDefinition, emit func(ai.Event)) gkai.ToolRef { +func (p *Provider) genkitTool(def api.ToolDefinition, emit func(ai.Event), correlation *toolEventCorrelation) gkai.ToolRef { handler := func(tc *gkai.ToolContext, input any) (any, error) { - return p.runTool(tc.Context, def, input, emit) + return p.runTool(tc.Context, def, input, emit, correlation) + } + options := []gkai.ToolOption{gkai.WithInputSchema(def.InputSchema)} + if def.Strict != nil { + options = append(options, gkai.WithStrictSchema(*def.Strict)) } - return gkai.NewTool(def.Name, def.Description, handler, gkai.WithInputSchema(def.InputSchema)) + return gkai.NewTool(def.Name, def.Description, handler, options...) } // runTool gates a caller tool through Config.CanUseTool (when it needs approval), // runs its handler, and publishes the use/permission/result events. On a denied // or errored call it feeds a message back to the model as the tool result so the // model can react rather than the whole generation failing. -func (p *Provider) runTool(ctx context.Context, def api.ToolDefinition, input any, emit func(ai.Event)) (any, error) { +func (p *Provider) runTool( + ctx context.Context, + def api.ToolDefinition, + input any, + emit func(ai.Event), + correlation *toolEventCorrelation, +) (any, error) { args := toArgsMap(input) - callID := fmt.Sprintf("tool-%d", p.toolCallSeq.Add(1)) - + callID := "" if emit != nil { + if correlation == nil { + return nil, fmt.Errorf("genkit tool %q execution has no correlation state", def.Name) + } + var err error + callID, err = correlation.begin(def.Name, args) + if err != nil { + return nil, err + } emit(ai.Event{Kind: ai.EventToolUse, Tool: def.Name, Input: args, ToolCallID: callID, Model: p.cfg.Model.Name}) } - if def.NeedsApproval() && p.cfg.CanUseTool != nil { + if def.NeedsApproval() && !gkai.IsToolResumed(ctx) { if emit != nil { emit(ai.Event{Kind: ai.EventPermission, Tool: def.Name, Input: args, ToolCallID: callID, Model: p.cfg.Model.Name}) } + if p.cfg.CanUseTool == nil { + return nil, gkai.NewToolInterruptError(map[string]any{"approvalRequired": true}) + } decision, err := p.cfg.CanUseTool(ctx, api.PermissionRequest{ Tool: def.Name, Input: args, @@ -68,14 +202,14 @@ func (p *Provider) runTool(ctx context.Context, def api.ToolDefinition, input an SessionID: p.cfg.SessionID, }) if err != nil { - return p.toolDenied(def.Name, callID, err.Error(), emit) + return p.toolDenied(def.Name, callID, err.Error(), emit, correlation) } if !decision.Allow { msg := decision.Message if msg == "" { msg = "tool call denied" } - return p.toolDenied(def.Name, callID, msg, emit) + return p.toolDenied(def.Name, callID, msg, emit, correlation) } if decision.UpdatedInput != nil { args = decision.UpdatedInput @@ -84,28 +218,39 @@ func (p *Provider) runTool(ctx context.Context, def api.ToolDefinition, input an out, err := def.Handler(ctx, args) if err != nil { - if emit != nil { - emit(ai.Event{Kind: ai.EventToolResult, Tool: def.Name, ToolCallID: callID, Success: false, Text: err.Error(), Model: p.cfg.Model.Name}) + if emitErr := p.emitToolResult(ai.Event{Kind: ai.EventToolResult, Tool: def.Name, ToolCallID: callID, Success: false, Text: err.Error(), Model: p.cfg.Model.Name}, emit, correlation); emitErr != nil { + return nil, emitErr } // Feed the error back as the tool result rather than aborting the turn. return map[string]any{"error": err.Error()}, nil } - if emit != nil { - emit(ai.Event{Kind: ai.EventToolResult, Tool: def.Name, ToolCallID: callID, Success: true, Text: toolOutputText(out), Model: p.cfg.Model.Name}) + if err := p.emitToolResult(ai.Event{Kind: ai.EventToolResult, Tool: def.Name, ToolCallID: callID, Success: true, Text: toolOutputText(out), Model: p.cfg.Model.Name}, emit, correlation); err != nil { + return nil, err } return out, nil } // toolDenied emits a failed EventToolResult and returns the deny reason to the // model as the tool output (not an error, so the model can adapt). -func (p *Provider) toolDenied(name, callID, reason string, emit func(ai.Event)) (any, error) { - if emit != nil { - emit(ai.Event{Kind: ai.EventToolResult, Tool: name, ToolCallID: callID, Success: false, Text: reason, Model: p.cfg.Model.Name}) +func (p *Provider) toolDenied(name, callID, reason string, emit func(ai.Event), correlation *toolEventCorrelation) (any, error) { + if err := p.emitToolResult(ai.Event{Kind: ai.EventToolResult, Tool: name, ToolCallID: callID, Success: false, Text: reason, Model: p.cfg.Model.Name}, emit, correlation); err != nil { + return nil, err } return map[string]any{"denied": true, "reason": reason}, nil } +func (p *Provider) emitToolResult(event ai.Event, emit func(ai.Event), correlation *toolEventCorrelation) error { + if emit == nil { + return nil + } + if err := correlation.finish(event.ToolCallID); err != nil { + return err + } + emit(event) + return nil +} + // toArgsMap normalizes genkit's decoded tool input into a JSON object map. func toArgsMap(input any) map[string]any { if input == nil { diff --git a/pkg/ai/provider/genkit/tools_test.go b/pkg/ai/provider/genkit/tools_test.go index db5f032..87c87cf 100644 --- a/pkg/ai/provider/genkit/tools_test.go +++ b/pkg/ai/provider/genkit/tools_test.go @@ -6,6 +6,8 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" + + gkai "github.com/firebase/genkit/go/ai" ) func newToolProvider(canUse api.PermissionFunc) *Provider { @@ -24,14 +26,14 @@ func TestRunToolAutoRunsAndEmitsCorrelatedEvents(t *testing.T) { var gotInput map[string]any def := api.ToolDefinition{ Name: "echo", - DefaultPermission: api.ToolModeEnabled, + DefaultPermission: api.ToolModeOn, Handler: func(_ context.Context, in map[string]any) (any, error) { gotInput = in return map[string]any{"ok": true}, nil }, } - out, err := p.runTool(context.Background(), def, map[string]any{"x": 1}, emit) + out, err := runCorrelatedTool(p, def, map[string]any{"x": 1}, emit) if err != nil { t.Fatalf("runTool: %v", err) } @@ -71,7 +73,7 @@ func TestRunToolApprovalDeniedSkipsHandler(t *testing.T) { Handler: func(context.Context, map[string]any) (any, error) { ran = true; return "ran", nil }, } - out, err := p.runTool(context.Background(), def, map[string]any{}, emit) + out, err := runCorrelatedTool(p, def, map[string]any{}, emit) if err != nil { t.Fatalf("runTool: %v", err) } @@ -104,7 +106,7 @@ func TestRunToolApprovalAllowsAndSubstitutesInput(t *testing.T) { Handler: func(_ context.Context, in map[string]any) (any, error) { seen = in; return "done", nil }, } - if _, err := p.runTool(context.Background(), def, map[string]any{"amount": 1}, emit); err != nil { + if _, err := runCorrelatedTool(p, def, map[string]any{"amount": 1}, emit); err != nil { t.Fatalf("runTool: %v", err) } if seen["amount"] != 5 { @@ -117,10 +119,10 @@ func TestRunToolHandlerErrorFedBack(t *testing.T) { emit, events := collectEvents() def := api.ToolDefinition{ Name: "boom", - DefaultPermission: api.ToolModeEnabled, + DefaultPermission: api.ToolModeOn, Handler: func(context.Context, map[string]any) (any, error) { return nil, context.Canceled }, } - out, err := p.runTool(context.Background(), def, map[string]any{}, emit) + out, err := runCorrelatedTool(p, def, map[string]any{}, emit) if err != nil { t.Fatalf("runTool should not surface handler error, got %v", err) } @@ -135,7 +137,7 @@ func TestRunToolHandlerErrorFedBack(t *testing.T) { func TestToolOptionsEmptyWhenNoTools(t *testing.T) { p := newToolProvider(nil) - if opts := p.toolOptions(nil); opts != nil { + if opts, err := p.toolOptions(nil, nil); err != nil || opts != nil { t.Errorf("toolOptions with no tools = %v, want nil", opts) } if !p.SupportsCallerTools() { @@ -150,3 +152,9 @@ func eventKinds(events []ai.Event) []ai.EventKind { } return kinds } + +func runCorrelatedTool(p *Provider, def api.ToolDefinition, input any, emit func(ai.Event)) (any, error) { + correlation := newToolEventCorrelation() + correlation.observeRequest(&gkai.ToolRequest{Name: def.Name, Ref: "provider-call-1", Input: input}) + return p.runTool(context.Background(), def, input, emit, correlation) +} diff --git a/pkg/ai/tools/preferences_ginkgo_test.go b/pkg/ai/tools/preferences_ginkgo_test.go new file mode 100644 index 0000000..97850e1 --- /dev/null +++ b/pkg/ai/tools/preferences_ginkgo_test.go @@ -0,0 +1,36 @@ +package tools_test + +import ( + "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tool preference resolution", func() { + It("prefers an exact tool entry over its group", func() { + mode, ok := tools.EffectivePreference(api.ToolPreferences{ + "billing": api.ToolModeOff, + "invoice_delete": api.ToolModeAsk, + }, tools.ToolInfo{Name: "invoice_delete", Group: "billing"}) + + Expect(ok).To(BeTrue()) + Expect(mode).To(Equal(api.ToolModeAsk)) + }) + + It("normalizes only the canonical modes", func() { + on, ok := tools.NormalizedPreference(api.ToolPreferences{"search": api.ToolModeOn}, "search") + Expect(ok).To(BeTrue()) + Expect(on).To(Equal(api.ToolModeOn)) + + off, ok := tools.NormalizedPreference(api.ToolPreferences{"search": api.ToolModeOff}, "search") + Expect(ok).To(BeTrue()) + Expect(off).To(Equal(api.ToolModeOff)) + + _, ok = tools.NormalizedPreference(api.ToolPreferences{"search": "enabled"}, "search") + Expect(ok).To(BeFalse()) + _, ok = tools.NormalizedPreference(api.ToolPreferences{"search": "disabled"}, "search") + Expect(ok).To(BeFalse()) + }) +}) diff --git a/pkg/ai/tools/tools.go b/pkg/ai/tools/tools.go index ef3afab..530ca8b 100644 --- a/pkg/ai/tools/tools.go +++ b/pkg/ai/tools/tools.go @@ -10,38 +10,24 @@ package tools import ( "context" "sort" - "strings" + + "github.com/flanksource/captain/pkg/api" ) // ToolMode controls how a tool is exposed for one request. -type ToolMode string +type ToolMode = api.ToolMode const ( - ToolModeOn ToolMode = "on" - ToolModeAsk ToolMode = "ask" - ToolModeOff ToolMode = "off" - ToolModeAuto ToolMode = "auto" - - // Backward-compatible aliases for the labels older clients send. - ToolModeEnabled ToolMode = ToolModeOn - ToolModeDisabled ToolMode = ToolModeOff + ToolModeOn = api.ToolModeOn + ToolModeAsk = api.ToolModeAsk + ToolModeOff = api.ToolModeOff + ToolModeAuto = api.ToolModeAuto ) -// NormalizeToolMode canonicalizes a mode string, accepting the older -// "enabled"/"disabled" labels. The bool is false for an unrecognized value. +// NormalizeToolMode canonicalizes a mode string. The bool is false for an +// unrecognized value. func NormalizeToolMode(mode ToolMode) (ToolMode, bool) { - switch ToolMode(strings.ToLower(strings.TrimSpace(string(mode)))) { - case ToolModeOn, "enabled": - return ToolModeOn, true - case ToolModeAsk: - return ToolModeAsk, true - case ToolModeOff, "disabled": - return ToolModeOff, true - case ToolModeAuto: - return ToolModeAuto, true - default: - return "", false - } + return api.NormalizeToolMode(mode) } // DefaultPermissionMode resolves a mode to its canonical value, defaulting an @@ -71,9 +57,8 @@ func ApprovalDecisionForMode(mode ToolMode) (require bool, handled bool) { } // ToolPreferences carries the clicky-ui tool preference payload. The UI sends -// "on", "ask", "off", or "auto"; "enabled"/"disabled" are accepted for older -// callers. -type ToolPreferences map[string]ToolMode +// "on", "ask", "off", or "auto". +type ToolPreferences = api.ToolPreferences // ToolInfo is the concrete tool being considered for approval and preference // resolution. Clicky-RPC specifics (verb/method/path/operation) live in diff --git a/pkg/ai/tools/tools_suite_test.go b/pkg/ai/tools/tools_suite_test.go new file mode 100644 index 0000000..42a1dde --- /dev/null +++ b/pkg/ai/tools/tools_suite_test.go @@ -0,0 +1,13 @@ +package tools_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTools(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AI Tools Suite") +} diff --git a/pkg/api/message.go b/pkg/api/message.go new file mode 100644 index 0000000..5fb84b5 --- /dev/null +++ b/pkg/api/message.go @@ -0,0 +1,222 @@ +package api + +import ( + "encoding/json" + "fmt" + "strings" +) + +// MessageRole identifies the author of one canonical conversation message. +type MessageRole string + +const ( + RoleSystem MessageRole = "system" + RoleUser MessageRole = "user" + RoleAssistant MessageRole = "assistant" + RoleTool MessageRole = "tool" +) + +func (r MessageRole) Valid() bool { + switch r { + case RoleSystem, RoleUser, RoleAssistant, RoleTool: + return true + default: + return false + } +} + +// PartType identifies the provider-neutral content carried by a message part. +type PartType string + +const ( + PartText PartType = "text" + PartReasoning PartType = "reasoning" + PartAttachment PartType = "attachment" + PartToolRequest PartType = "tool-request" + PartToolResult PartType = "tool-result" +) + +func (t PartType) Valid() bool { + switch t { + case PartText, PartReasoning, PartAttachment, PartToolRequest, PartToolResult: + return true + default: + return false + } +} + +// ToolRequest is one assistant request to execute a named tool. ToolCallID is +// stable across the corresponding ToolResult and provider projection. +type ToolRequest struct { + ToolCallID string `json:"toolCallId" yaml:"toolCallId"` + Name string `json:"name" yaml:"name"` + Input json.RawMessage `json:"input,omitempty" yaml:"input,omitempty"` +} + +// ToolResult is the output of a prior ToolRequest. Error is set instead of +// Output when tool execution failed. +type ToolResult struct { + ToolCallID string `json:"toolCallId" yaml:"toolCallId"` + Output json.RawMessage `json:"output,omitempty" yaml:"output,omitempty"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +// Part is one typed content item in a canonical Message. Type selects exactly +// one payload: Text, Attachment, ToolRequest, or ToolResult. +type Part struct { + Type PartType `json:"type" yaml:"type"` + Text string `json:"text,omitempty" yaml:"text,omitempty"` + Attachment *AttachmentRef `json:"attachment,omitempty" yaml:"attachment,omitempty"` + ToolRequest *ToolRequest `json:"toolRequest,omitempty" yaml:"toolRequest,omitempty"` + ToolResult *ToolResult `json:"toolResult,omitempty" yaml:"toolResult,omitempty"` +} + +// Message is one provider-neutral conversation entry. +type Message struct { + Role MessageRole `json:"role" yaml:"role"` + Parts []Part `json:"parts" yaml:"parts"` +} + +func (p Part) Validate() error { + if !p.Type.Valid() { + return fmt.Errorf("invalid part type %q", p.Type) + } + if err := p.validatePayloadShape(); err != nil { + return err + } + switch p.Type { + case PartText, PartReasoning: + if strings.TrimSpace(p.Text) == "" { + return fmt.Errorf("%s text is required", p.Type) + } + case PartAttachment: + if err := p.Attachment.Validate(); err != nil { + return fmt.Errorf("attachment: %w", err) + } + case PartToolRequest: + if strings.TrimSpace(p.ToolRequest.ToolCallID) == "" { + return fmt.Errorf("tool request call ID is required") + } + if strings.TrimSpace(p.ToolRequest.Name) == "" { + return fmt.Errorf("tool request name is required") + } + if len(p.ToolRequest.Input) > 0 && !json.Valid(p.ToolRequest.Input) { + return fmt.Errorf("tool request input must be valid JSON") + } + case PartToolResult: + if strings.TrimSpace(p.ToolResult.ToolCallID) == "" { + return fmt.Errorf("tool result call ID is required") + } + if len(p.ToolResult.Output) > 0 && !json.Valid(p.ToolResult.Output) { + return fmt.Errorf("tool result output must be valid JSON") + } + if len(p.ToolResult.Output) > 0 && p.ToolResult.Error != "" { + return fmt.Errorf("tool result output and error are mutually exclusive") + } + } + return nil +} + +func (p Part) validatePayloadShape() error { + textSet := p.Text != "" + attachmentSet := p.Attachment != nil + requestSet := p.ToolRequest != nil + resultSet := p.ToolResult != nil + switch p.Type { + case PartText, PartReasoning: + if attachmentSet || requestSet || resultSet { + return fmt.Errorf("%s part contains an incompatible payload", p.Type) + } + case PartAttachment: + if !attachmentSet || textSet || requestSet || resultSet { + return fmt.Errorf("attachment part must contain only an attachment") + } + case PartToolRequest: + if !requestSet || textSet || attachmentSet || resultSet { + return fmt.Errorf("tool-request part must contain only a tool request") + } + case PartToolResult: + if !resultSet || textSet || attachmentSet || requestSet { + return fmt.Errorf("tool-result part must contain only a tool result") + } + } + return nil +} + +// ValidateMessages validates role/part compatibility and tool transcript +// correlation. Tool results must directly follow the assistant message whose +// stable call IDs they resolve. +func ValidateMessages(messages []Message) error { + if len(messages) == 0 { + return fmt.Errorf("at least one message is required") + } + requestIDs := map[string]bool{} + resultIDs := map[string]bool{} + for i, message := range messages { + if !message.Role.Valid() { + return fmt.Errorf("message %d: invalid role %q", i+1, message.Role) + } + if len(message.Parts) == 0 { + return fmt.Errorf("message %d (%s): at least one part is required", i+1, message.Role) + } + for j, part := range message.Parts { + if err := part.Validate(); err != nil { + return fmt.Errorf("message %d part %d: %w", i+1, j+1, err) + } + if !roleAllowsPart(message.Role, part.Type) { + return fmt.Errorf("message %d part %d: %s message cannot contain %s", i+1, j+1, message.Role, part.Type) + } + if part.Type == PartToolRequest { + if requestIDs[part.ToolRequest.ToolCallID] { + return fmt.Errorf("message %d part %d: duplicate tool request call ID %q", i+1, j+1, part.ToolRequest.ToolCallID) + } + requestIDs[part.ToolRequest.ToolCallID] = true + } + if part.Type == PartToolResult { + if resultIDs[part.ToolResult.ToolCallID] { + return fmt.Errorf("message %d part %d: duplicate tool result call ID %q", i+1, j+1, part.ToolResult.ToolCallID) + } + resultIDs[part.ToolResult.ToolCallID] = true + } + } + if message.Role == RoleTool { + if err := validateToolMessage(messages, i); err != nil { + return fmt.Errorf("message %d: %w", i+1, err) + } + } + } + return nil +} + +func roleAllowsPart(role MessageRole, part PartType) bool { + switch role { + case RoleSystem: + return part == PartText + case RoleUser: + return part == PartText || part == PartAttachment + case RoleAssistant: + return part == PartText || part == PartReasoning || part == PartToolRequest + case RoleTool: + return part == PartToolResult + default: + return false + } +} + +func validateToolMessage(messages []Message, index int) error { + if index == 0 || messages[index-1].Role != RoleAssistant { + return fmt.Errorf("tool message must immediately follow an assistant message") + } + preceding := map[string]bool{} + for _, part := range messages[index-1].Parts { + if part.Type == PartToolRequest && part.ToolRequest != nil { + preceding[part.ToolRequest.ToolCallID] = true + } + } + for _, part := range messages[index].Parts { + if part.Type == PartToolResult && part.ToolResult != nil && !preceding[part.ToolResult.ToolCallID] { + return fmt.Errorf("tool call %q was not requested by the preceding assistant message", part.ToolResult.ToolCallID) + } + } + return nil +} diff --git a/pkg/api/message_ginkgo_test.go b/pkg/api/message_ginkgo_test.go new file mode 100644 index 0000000..976a5a3 --- /dev/null +++ b/pkg/api/message_ginkgo_test.go @@ -0,0 +1,77 @@ +package api_test + +import ( + "encoding/json" + "strings" + + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Canonical messages", func() { + validMessages := func() []api.Message { + return []api.Message{ + {Role: api.RoleSystem, Parts: []api.Part{{Type: api.PartText, Text: "Be precise."}}}, + {Role: api.RoleUser, Parts: []api.Part{ + {Type: api.PartText, Text: "Inspect this image."}, + {Type: api.PartAttachment, Attachment: &api.AttachmentRef{ID: api.AttachmentIDPrefix + strings.Repeat("a", 64), MediaType: "image/png"}}, + }}, + {Role: api.RoleAssistant, Parts: []api.Part{ + {Type: api.PartReasoning, Text: "I should inspect its dimensions."}, + {Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ToolCallID: "call-1", Name: "image_dimensions", Input: json.RawMessage(`{"attachment":"a"}`)}}, + }}, + {Role: api.RoleTool, Parts: []api.Part{ + {Type: api.PartToolResult, ToolResult: &api.ToolResult{ToolCallID: "call-1", Output: json.RawMessage(`{"width":640,"height":480}`)}}, + }}, + {Role: api.RoleAssistant, Parts: []api.Part{{Type: api.PartText, Text: "It is 640 by 480."}}}, + } + } + + It("validates a multimodal tool transcript", func() { + Expect(api.ValidateMessages(validMessages())).To(Succeed()) + }) + + DescribeTable("rejects parts on incompatible roles", + func(role api.MessageRole, part api.Part, want string) { + err := api.ValidateMessages([]api.Message{{Role: role, Parts: []api.Part{part}}}) + Expect(err).To(MatchError(ContainSubstring(want))) + }, + Entry("system reasoning", api.RoleSystem, api.Part{Type: api.PartReasoning, Text: "thought"}, "system message cannot contain reasoning"), + Entry("user reasoning", api.RoleUser, api.Part{Type: api.PartReasoning, Text: "thought"}, "user message cannot contain reasoning"), + Entry("assistant attachment", api.RoleAssistant, api.Part{Type: api.PartAttachment, Attachment: &api.AttachmentRef{Path: "image.png"}}, "assistant message cannot contain attachment"), + Entry("tool text", api.RoleTool, api.Part{Type: api.PartText, Text: "result"}, "tool message cannot contain text"), + ) + + It("requires a tool result to immediately follow and match an assistant request", func() { + messages := validMessages() + messages[3].Parts[0].ToolResult.ToolCallID = "unknown" + Expect(api.ValidateMessages(messages)).To(MatchError(ContainSubstring(`tool call "unknown" was not requested by the preceding assistant message`))) + + messages = validMessages() + messages = append(messages[:3], api.Message{Role: api.RoleUser, Parts: []api.Part{{Type: api.PartText, Text: "continue"}}}, messages[3]) + Expect(api.ValidateMessages(messages)).To(MatchError(ContainSubstring("tool message must immediately follow an assistant message"))) + }) + + It("requires stable unique tool call IDs", func() { + messages := validMessages() + messages[2].Parts[1].ToolRequest.ToolCallID = "" + Expect(api.ValidateMessages(messages)).To(MatchError(ContainSubstring("tool request call ID is required"))) + + messages = validMessages() + messages[2].Parts = append(messages[2].Parts, api.Part{Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ + ToolCallID: "call-1", Name: "other", Input: json.RawMessage(`{}`), + }}) + Expect(api.ValidateMessages(messages)).To(MatchError(ContainSubstring(`duplicate tool request call ID "call-1"`))) + }) + + It("keeps conversation and single-turn prompt as mutually exclusive request modes", func() { + spec := api.Spec{Model: api.Model{Name: "gpt-5", Backend: api.BackendOpenAI}, Messages: validMessages()} + Expect(spec.Validate()).To(Succeed()) + Expect(spec.IsVerifyOnly()).To(BeFalse()) + + spec.Prompt.User = "silent fallback" + Expect(spec.Validate()).To(MatchError(ContainSubstring("prompt body and messages are mutually exclusive"))) + }) +}) diff --git a/pkg/api/permissions.go b/pkg/api/permissions.go index 0a43beb..99198c4 100644 --- a/pkg/api/permissions.go +++ b/pkg/api/permissions.go @@ -64,6 +64,11 @@ func (p Permissions) Validate() error { 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: on, ask, off, auto)", mode, tool) + } + } for tool, policy := range p.Tools.Policies() { if !policy.Valid() { return fmt.Errorf("invalid tool policy %q for tool %q (valid: auto, ask, allow, deny)", policy, tool) @@ -105,11 +110,11 @@ func (t Tools) Policies() map[string]ToolPolicy { continue } switch mode { - case ToolModeEnabled: + case ToolModeOn: out[tool] = ToolPolicyAuto case ToolModeAsk: out[tool] = ToolPolicyAsk - case ToolModeDisabled: + case ToolModeOff: out[tool] = ToolPolicyDeny } } @@ -215,7 +220,7 @@ func (t *Tools) applyPolicy(tool string, policy ToolPolicy) { if t.Modes == nil { t.Modes = map[string]ToolMode{} } - t.Modes[tool] = ToolModeEnabled + t.Modes[tool] = ToolModeOn } } diff --git a/pkg/api/permissions_test.go b/pkg/api/permissions_test.go index 394a0aa..7b07e8e 100644 --- a/pkg/api/permissions_test.go +++ b/pkg/api/permissions_test.go @@ -16,7 +16,7 @@ func TestPermissions_JSONPolicyShape(t *testing.T) { Deny: []string{"Bash"}, Modes: map[string]ToolMode{ "WebSearch": ToolModeAsk, - "Write": ToolModeEnabled, + "Write": ToolModeOn, }, }, MCP: MCP{ @@ -56,7 +56,7 @@ func TestPermissions_JSONLegacyInput(t *testing.T) { "tools": { "allow": ["Read"], "deny": ["Bash"], - "modes": {"Edit": "ask", "Write": "enabled"} + "modes": {"Edit": "ask", "Write": "on"} }, "mcp": {"servers": ["filesystem", "gavel"], "gavel": "disabled"}, "plugins": ["/plugins"], diff --git a/pkg/api/runtime_event.go b/pkg/api/runtime_event.go index 5c4ea5f..f54c49a 100644 --- a/pkg/api/runtime_event.go +++ b/pkg/api/runtime_event.go @@ -10,6 +10,7 @@ type Response struct { Text string StructuredData any TerminalOutcome *TerminalOutcome + ToolApproval *ToolApprovalState Model string Backend Backend Usage Usage @@ -71,6 +72,7 @@ type Event struct { // is raw JSON because the streaming contract does not know the caller's Go // type — the buffered Execute path unmarshals it into Request.Prompt.Schema. StructuredData json.RawMessage + ToolApproval *ToolApprovalState // Raw carries the backend-native event (e.g. claude.HistoryEntry for the // claude_cli stream) so renderers can use the rich pretty-printers in diff --git a/pkg/api/spec.go b/pkg/api/spec.go index 313801a..a320645 100644 --- a/pkg/api/spec.go +++ b/pkg/api/spec.go @@ -17,11 +17,16 @@ import ( // 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"` - Setup *shell.Setup `json:"setup,omitempty" yaml:"setup,omitempty"` + Prompt Prompt `json:"prompt" yaml:"prompt"` + Messages []Message `json:"messages,omitempty" yaml:"messages,omitempty" pretty:"-"` + Budget Budget `json:"budget,omitempty" yaml:"budget,omitempty"` + Memory Memory `json:"memory,omitempty" yaml:"memory,omitempty"` + Permissions Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty"` + // ToolPreferences is the serializable per-turn tool/group selection policy. + // Executable tool handlers remain in Config.Tools. + ToolPreferences ToolPreferences `json:"toolPreferences,omitempty" yaml:"toolPreferences,omitempty" pretty:"-"` + ToolApproval *ToolApprovalResume `json:"toolApproval,omitempty" yaml:"toolApproval,omitempty" pretty:"-"` + Setup *shell.Setup `json:"setup,omitempty" yaml:"setup,omitempty"` // Workflow declares the generate→verify loop (verification + finalize) around // the run. Absent = single generation, no verification. @@ -41,9 +46,29 @@ func (s Spec) Validate() error { if err := s.Model.Validate(); err != nil { return fmt.Errorf("model: %w", err) } - // A verify-only spec (no body, workflow.verify present) legitimately has an - // empty prompt; only its strictness setting is checked. - if s.IsVerifyOnly() { + if s.ToolApproval != nil { + if s.hasPromptBody() || len(s.Messages) > 0 { + return fmt.Errorf("tool approval resume state, prompt body, and messages are mutually exclusive request modes") + } + if err := s.ToolApproval.Validate(); err != nil { + return fmt.Errorf("tool approval: %w", err) + } + if err := s.Prompt.SchemaStrictness.Validate(); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } else if len(s.Messages) > 0 { + if err := s.ValidateRequestMode(); err != nil { + return err + } + if err := ValidateMessages(s.Messages); err != nil { + return fmt.Errorf("messages: %w", err) + } + if err := s.Prompt.SchemaStrictness.Validate(); err != nil { + return fmt.Errorf("prompt: %w", err) + } + // A verify-only spec (no body, workflow.verify present) legitimately has an + // empty prompt; only its strictness setting is checked. + } else if s.IsVerifyOnly() { if err := s.Prompt.SchemaStrictness.Validate(); err != nil { return fmt.Errorf("prompt: %w", err) } @@ -56,6 +81,9 @@ func (s Spec) Validate() error { if err := s.Permissions.Validate(); err != nil { return fmt.Errorf("permissions: %w", err) } + if err := s.ToolPreferences.Validate(); err != nil { + return err + } if err := s.Workflow.Validate(); err != nil { return fmt.Errorf("workflow: %w", err) } @@ -66,7 +94,23 @@ func (s Spec) Validate() error { // verification — a verify-only run that skips generation and verifies the // current state (e.g. scoring already-committed work). func (s Spec) IsVerifyOnly() bool { - return s.Prompt.User == "" && len(s.Prompt.Attachments) == 0 && s.Workflow != nil && s.Workflow.Verify != nil + return s.ToolApproval == nil && len(s.Messages) == 0 && s.Prompt.User == "" && len(s.Prompt.Attachments) == 0 && s.Workflow != nil && s.Workflow.Verify != nil +} + +func (s Spec) hasPromptBody() bool { + return s.Prompt.User != "" || s.Prompt.System != "" || s.Prompt.AppendSystem != "" || len(s.Prompt.Attachments) > 0 +} + +// ValidateRequestMode rejects mixing canonical conversation history with the +// single-turn prompt body. +func (s Spec) ValidateRequestMode() error { + if s.ToolApproval != nil && (len(s.Messages) > 0 || s.hasPromptBody()) { + return fmt.Errorf("tool approval resume state, prompt body, and messages are mutually exclusive request modes") + } + if len(s.Messages) > 0 && s.hasPromptBody() { + return fmt.Errorf("prompt body and messages are mutually exclusive request modes") + } + return nil } func (s Spec) Cwd() string { diff --git a/pkg/api/tool_approval.go b/pkg/api/tool_approval.go new file mode 100644 index 0000000..9b9a383 --- /dev/null +++ b/pkg/api/tool_approval.go @@ -0,0 +1,252 @@ +package api + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// ToolApprovalAction selects how a suspended tool call is resolved. +type ToolApprovalAction string + +const ( + ToolApprovalApprove ToolApprovalAction = "approve" + ToolApprovalDeny ToolApprovalAction = "deny" + ToolApprovalRespond ToolApprovalAction = "respond" +) + +// ToolApprovalRequest is the serializable identity and input of a tool call +// that may require a later approval decision. +type ToolApprovalRequest struct { + ToolCallID string `json:"toolCallId" yaml:"toolCallId"` + Tool string `json:"tool" yaml:"tool"` + Input json.RawMessage `json:"input,omitempty" yaml:"input,omitempty"` +} + +// ToolApprovalCall records one tool request from the interrupted model turn. +// Result is set when the tool completed before a sibling call suspended the +// turn; such calls must not be executed again when the turn resumes. +type ToolApprovalCall struct { + Request ToolApprovalRequest `json:"request" yaml:"request"` + Result *ToolResult `json:"result,omitempty" yaml:"result,omitempty"` +} + +// ToolApprovalState is the durable state returned when a model turn suspends. +// Messages is the complete provider-neutral conversation ending with the +// assistant tool requests; Calls records which requests are pending or done. +type ToolApprovalState struct { + Messages []Message `json:"messages" yaml:"messages"` + Calls []ToolApprovalCall `json:"calls" yaml:"calls"` +} + +// ToolApprovalDecision resolves one pending call. Approve may replace Input; +// Deny may carry a Message; Respond supplies an already-computed Result. +type ToolApprovalDecision struct { + ToolCallID string `json:"toolCallId" yaml:"toolCallId"` + Tool string `json:"tool" yaml:"tool"` + Action ToolApprovalAction `json:"action" yaml:"action"` + Input json.RawMessage `json:"input,omitempty" yaml:"input,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Result *ToolResult `json:"result,omitempty" yaml:"result,omitempty"` +} + +// ToolApprovalResume carries durable suspension state and exactly one decision +// for every pending call into a later request. +type ToolApprovalResume struct { + State ToolApprovalState `json:"state" yaml:"state"` + Decisions []ToolApprovalDecision `json:"decisions" yaml:"decisions"` +} + +func (r ToolApprovalRequest) Validate() error { + if strings.TrimSpace(r.ToolCallID) == "" { + return fmt.Errorf("tool call ID is required") + } + if strings.TrimSpace(r.Tool) == "" { + return fmt.Errorf("tool name is required for call %q", r.ToolCallID) + } + if len(r.Input) > 0 && !json.Valid(r.Input) { + return fmt.Errorf("tool call %q input must be valid JSON", r.ToolCallID) + } + if err := validateApprovalInput(r.Input); err != nil { + return fmt.Errorf("tool call %q input: %w", r.ToolCallID, err) + } + return nil +} + +// Pending returns the unresolved calls in their original model-request order. +func (s ToolApprovalState) Pending() []ToolApprovalRequest { + pending := make([]ToolApprovalRequest, 0, len(s.Calls)) + for _, call := range s.Calls { + if call.Result == nil { + pending = append(pending, call.Request) + } + } + return pending +} + +func (s ToolApprovalState) Validate() error { + if err := ValidateMessages(s.Messages); err != nil { + return fmt.Errorf("approval messages: %w", err) + } + if len(s.Calls) == 0 { + return fmt.Errorf("approval state must contain at least one tool call") + } + requests, err := approvalMessageRequests(s.Messages) + if err != nil { + return err + } + seen := make(map[string]bool, len(s.Calls)) + pending := 0 + for i, call := range s.Calls { + if err := call.Request.Validate(); err != nil { + return fmt.Errorf("approval call %d: %w", i+1, err) + } + id := call.Request.ToolCallID + if seen[id] { + return fmt.Errorf("duplicate approval call %q", id) + } + seen[id] = true + messageRequest, ok := requests[id] + if !ok { + return fmt.Errorf("approval call %q is absent from the final assistant message", id) + } + if messageRequest.Name != call.Request.Tool { + return fmt.Errorf("approval call %q tool %q does not match message tool %q", id, call.Request.Tool, messageRequest.Name) + } + if !equalJSON(messageRequest.Input, call.Request.Input) { + return fmt.Errorf("approval call %q input does not match the final assistant message", id) + } + if call.Result == nil { + pending++ + continue + } + if err := validateApprovalResult(id, call.Result); err != nil { + return err + } + } + if len(seen) != len(requests) { + return fmt.Errorf("approval state must account for every tool request in the final assistant message") + } + if pending == 0 { + return fmt.Errorf("approval state has no pending tool calls") + } + return nil +} + +func (r ToolApprovalResume) Validate() error { + if err := r.State.Validate(); err != nil { + return err + } + calls := make(map[string]ToolApprovalCall, len(r.State.Calls)) + for _, call := range r.State.Calls { + calls[call.Request.ToolCallID] = call + } + seen := make(map[string]bool, len(r.Decisions)) + for i, decision := range r.Decisions { + if seen[decision.ToolCallID] { + return fmt.Errorf("duplicate decision for tool call %q", decision.ToolCallID) + } + seen[decision.ToolCallID] = true + call, ok := calls[decision.ToolCallID] + if !ok { + return fmt.Errorf("decision references unknown tool call %q", decision.ToolCallID) + } + if call.Result != nil { + return fmt.Errorf("tool call %q is already resolved", decision.ToolCallID) + } + if decision.Tool != call.Request.Tool { + return fmt.Errorf("decision tool %q does not match pending tool %q", decision.Tool, call.Request.Tool) + } + if err := decision.validatePayload(); err != nil { + return fmt.Errorf("decision %d for tool call %q: %w", i+1, decision.ToolCallID, err) + } + } + for _, call := range r.State.Calls { + if call.Result == nil && !seen[call.Request.ToolCallID] { + return fmt.Errorf("missing decision for pending tool call %q", call.Request.ToolCallID) + } + } + return nil +} + +func (d ToolApprovalDecision) validatePayload() error { + switch d.Action { + case ToolApprovalApprove: + if d.Message != "" || d.Result != nil { + return fmt.Errorf("approve decision can only replace tool input") + } + if len(d.Input) > 0 && !json.Valid(d.Input) { + return fmt.Errorf("approve decision input must be valid JSON") + } + if err := validateApprovalInput(d.Input); err != nil { + return fmt.Errorf("approve decision input: %w", err) + } + case ToolApprovalDeny: + if len(d.Input) > 0 || d.Result != nil { + return fmt.Errorf("deny decision can only carry a message") + } + case ToolApprovalRespond: + if len(d.Input) > 0 { + return fmt.Errorf("respond decision cannot replace tool input") + } + if d.Message != "" { + return fmt.Errorf("respond decision cannot carry a denial message") + } + if d.Result == nil { + return fmt.Errorf("respond decision requires a tool result") + } + if err := validateApprovalResult(d.ToolCallID, d.Result); err != nil { + return err + } + default: + return fmt.Errorf("invalid approval action %q (valid: approve, deny, respond)", d.Action) + } + return nil +} + +func approvalMessageRequests(messages []Message) (map[string]ToolRequest, error) { + last := messages[len(messages)-1] + if last.Role != RoleAssistant { + return nil, fmt.Errorf("approval messages must end with an assistant tool request") + } + requests := make(map[string]ToolRequest) + for _, part := range last.Parts { + if part.Type == PartToolRequest && part.ToolRequest != nil { + requests[part.ToolRequest.ToolCallID] = *part.ToolRequest + } + } + if len(requests) == 0 { + return nil, fmt.Errorf("approval messages must end with an assistant tool request") + } + return requests, nil +} + +func validateApprovalResult(callID string, result *ToolResult) error { + if result.ToolCallID != callID { + return fmt.Errorf("tool result call ID %q does not match approval call %q", result.ToolCallID, callID) + } + return (Part{Type: PartToolResult, ToolResult: result}).Validate() +} + +func equalJSON(left, right json.RawMessage) bool { + if len(left) == 0 || len(right) == 0 { + return len(left) == len(right) + } + var a, b any + return json.Unmarshal(left, &a) == nil && json.Unmarshal(right, &b) == nil && reflect.DeepEqual(a, b) +} + +func validateApprovalInput(input json.RawMessage) error { + if len(input) == 0 { + return nil + } + var value map[string]any + if err := json.Unmarshal(input, &value); err != nil { + return fmt.Errorf("must be a JSON object") + } + if value == nil { + return fmt.Errorf("must be a JSON object") + } + return nil +} diff --git a/pkg/api/tool_approval_ginkgo_test.go b/pkg/api/tool_approval_ginkgo_test.go new file mode 100644 index 0000000..36474e5 --- /dev/null +++ b/pkg/api/tool_approval_ginkgo_test.go @@ -0,0 +1,111 @@ +package api_test + +import ( + "encoding/json" + + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Resumable tool approval", func() { + validState := func() api.ToolApprovalState { + return api.ToolApprovalState{ + Messages: []api.Message{ + {Role: api.RoleUser, Parts: []api.Part{{Type: api.PartText, Text: "Update and inspect the invoice."}}}, + {Role: api.RoleAssistant, Parts: []api.Part{ + {Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ToolCallID: "call-update", Name: "invoice_update", Input: json.RawMessage(`{"amount":10}`)}}, + {Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ToolCallID: "call-read", Name: "invoice_get", Input: json.RawMessage(`{"id":"inv-1"}`)}}, + }}, + }, + Calls: []api.ToolApprovalCall{ + {Request: api.ToolApprovalRequest{ToolCallID: "call-update", Tool: "invoice_update", Input: json.RawMessage(`{"amount":10}`)}}, + { + Request: api.ToolApprovalRequest{ToolCallID: "call-read", Tool: "invoice_get", Input: json.RawMessage(`{"id":"inv-1"}`)}, + Result: &api.ToolResult{ToolCallID: "call-read", Output: json.RawMessage(`{"amount":10}`)}, + }, + }, + } + } + + It("round-trips pending and already-completed calls", func() { + state := validState() + data, err := json.Marshal(state) + Expect(err).NotTo(HaveOccurred()) + + var decoded api.ToolApprovalState + Expect(json.Unmarshal(data, &decoded)).To(Succeed()) + Expect(decoded.Validate()).To(Succeed()) + Expect(decoded.Calls).To(Equal(state.Calls)) + Expect(decoded.Pending()).To(Equal([]api.ToolApprovalRequest{state.Calls[0].Request})) + }) + + It("accepts one approve decision with edited input for each pending call", func() { + resume := api.ToolApprovalResume{ + State: validState(), + Decisions: []api.ToolApprovalDecision{{ + ToolCallID: "call-update", + Tool: "invoice_update", + Action: api.ToolApprovalApprove, + Input: json.RawMessage(`{"amount":12}`), + }}, + } + Expect(resume.Validate()).To(Succeed()) + + spec := api.Spec{Model: api.Model{Name: "gpt-5", Backend: api.BackendOpenAI}, ToolApproval: &resume} + Expect(spec.Validate()).To(Succeed()) + }) + + It("compares tool inputs structurally regardless of object key order", func() { + state := validState() + state.Messages[1].Parts[0].ToolRequest.Input = json.RawMessage(`{"currency":"USD","amount":10}`) + state.Calls[0].Request.Input = json.RawMessage(`{"amount":10,"currency":"USD"}`) + Expect(state.Validate()).To(Succeed()) + + state.Calls[0].Request.Input = json.RawMessage(`{"amount":11,"currency":"USD"}`) + Expect(state.Validate()).To(MatchError(ContainSubstring("input does not match"))) + }) + + DescribeTable("rejects invalid continuation decisions", + func(mutate func(*api.ToolApprovalResume), want string) { + resume := api.ToolApprovalResume{ + State: validState(), + Decisions: []api.ToolApprovalDecision{{ + ToolCallID: "call-update", Tool: "invoice_update", Action: api.ToolApprovalApprove, + }}, + } + mutate(&resume) + Expect(resume.Validate()).To(MatchError(ContainSubstring(want))) + }, + Entry("missing", func(resume *api.ToolApprovalResume) { resume.Decisions = nil }, `missing decision for pending tool call "call-update"`), + Entry("duplicate", func(resume *api.ToolApprovalResume) { resume.Decisions = append(resume.Decisions, resume.Decisions[0]) }, `duplicate decision for tool call "call-update"`), + Entry("unknown call", func(resume *api.ToolApprovalResume) { resume.Decisions[0].ToolCallID = "call-other" }, `decision references unknown tool call "call-other"`), + Entry("mismatched tool", func(resume *api.ToolApprovalResume) { resume.Decisions[0].Tool = "invoice_delete" }, `decision tool "invoice_delete" does not match pending tool "invoice_update"`), + Entry("completed replay", func(resume *api.ToolApprovalResume) { + resume.Decisions[0] = api.ToolApprovalDecision{ToolCallID: "call-read", Tool: "invoice_get", Action: api.ToolApprovalApprove} + }, `tool call "call-read" is already resolved`), + ) + + It("requires deny and respond decisions to carry only their own payload", func() { + state := validState() + deny := api.ToolApprovalResume{State: state, Decisions: []api.ToolApprovalDecision{{ + ToolCallID: "call-update", Tool: "invoice_update", Action: api.ToolApprovalDeny, Message: "not now", + }}} + Expect(deny.Validate()).To(Succeed()) + + respond := api.ToolApprovalResume{State: state, Decisions: []api.ToolApprovalDecision{{ + ToolCallID: "call-update", Tool: "invoice_update", Action: api.ToolApprovalRespond, + Result: &api.ToolResult{ToolCallID: "call-update", Output: json.RawMessage(`{"queued":true}`)}, + }}} + Expect(respond.Validate()).To(Succeed()) + + respond.Decisions[0].Input = json.RawMessage(`{"amount":12}`) + Expect(respond.Validate()).To(MatchError(ContainSubstring("respond decision cannot replace tool input"))) + + approve := api.ToolApprovalResume{State: state, Decisions: []api.ToolApprovalDecision{{ + ToolCallID: "call-update", Tool: "invoice_update", Action: api.ToolApprovalApprove, Input: json.RawMessage(`[12]`), + }}} + Expect(approve.Validate()).To(MatchError(ContainSubstring("approve decision input: must be a JSON object"))) + }) +}) diff --git a/pkg/api/tool_preferences_ginkgo_test.go b/pkg/api/tool_preferences_ginkgo_test.go new file mode 100644 index 0000000..67805b8 --- /dev/null +++ b/pkg/api/tool_preferences_ginkgo_test.go @@ -0,0 +1,72 @@ +package api_test + +import ( + "encoding/json" + + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tool preferences", func() { + It("survives a Spec JSON round trip", func() { + in := api.Spec{ + Model: api.Model{Name: "claude-sonnet-4-6", Backend: api.BackendAnthropic}, + Prompt: api.Prompt{User: "inspect invoices"}, + ToolPreferences: api.ToolPreferences{"billing": api.ToolModeAsk, "invoice_delete": api.ToolModeOff}, + } + + encoded, err := json.Marshal(in) + Expect(err).NotTo(HaveOccurred()) + var wire map[string]any + Expect(json.Unmarshal(encoded, &wire)).To(Succeed()) + Expect(wire["toolPreferences"]).To(Equal(map[string]any{ + "billing": "ask", + "invoice_delete": "off", + })) + + var out api.Spec + Expect(json.Unmarshal(encoded, &out)).To(Succeed()) + Expect(out.ToolPreferences).To(Equal(in.ToolPreferences)) + }) + + It("replaces base preferences when an override supplies per-turn preferences", func() { + base := api.Spec{ToolPreferences: api.ToolPreferences{ + "billing": api.ToolModeAsk, + "search": api.ToolModeOff, + }} + override := api.Spec{ToolPreferences: api.ToolPreferences{ + "billing": api.ToolModeOn, + }} + + Expect(base.Merge(override).ToolPreferences).To(Equal(api.ToolPreferences{ + "billing": api.ToolModeOn, + })) + Expect(base.Merge(api.Spec{}).ToolPreferences).To(Equal(base.ToolPreferences)) + }) + + It("rejects an unknown preference before provider execution", func() { + spec := api.Spec{ + Model: api.Model{Name: "claude-sonnet-4-6", Backend: api.BackendAnthropic}, + Prompt: api.Prompt{User: "inspect invoices"}, + ToolPreferences: api.ToolPreferences{"billing": "sometimes"}, + } + + Expect(spec.Validate()).To(MatchError(ContainSubstring(`invalid tool preference "sometimes" for "billing"`))) + }) + + It("rejects removed enabled and disabled labels", func() { + for _, mode := range []api.ToolMode{"enabled", "disabled"} { + spec := api.Spec{ + Model: api.Model{Name: "claude-sonnet-4-6", Backend: api.BackendAnthropic}, + Prompt: api.Prompt{User: "inspect invoices"}, + ToolPreferences: api.ToolPreferences{"billing": mode}, + } + Expect(spec.Validate()).To(MatchError(ContainSubstring(`invalid tool preference`))) + + permissions := api.Permissions{Tools: api.Tools{Modes: map[string]api.ToolMode{"billing": mode}}} + Expect(permissions.Validate()).To(MatchError(ContainSubstring(`invalid tool mode`))) + } + }) +}) diff --git a/pkg/api/tooldef.go b/pkg/api/tooldef.go index 0833878..717a9a0 100644 --- a/pkg/api/tooldef.go +++ b/pkg/api/tooldef.go @@ -1,6 +1,27 @@ package api -import "context" +import ( + "context" + "fmt" +) + +// ToolPreferences selects the effective per-turn mode for a tool name or group. +// An exact tool-name entry takes precedence over its group entry. +type ToolPreferences map[string]ToolMode + +// Validate rejects unknown modes before a provider request is assembled. +func (p ToolPreferences) Validate() error { + if _, exists := p[""]; exists { + return fmt.Errorf("tool preference key cannot be empty") + } + for _, key := range sortedKeys(p) { + mode := p[key] + if _, ok := NormalizeToolMode(mode); !ok { + return fmt.Errorf("invalid tool preference %q for %q (valid: on, ask, off, auto)", mode, key) + } + } + return nil +} // ToolHandler runs a caller-supplied tool. input is the model's tool-call // arguments (the decoded JSON object); the returned value is marshaled back to @@ -20,10 +41,23 @@ type ToolDefinition struct { // InputSchema is the JSON Schema (decoded to a map) for the tool arguments. // Nil means a no-argument tool. InputSchema map[string]any + // Group is the preference key shared by related tools. A tool-name preference + // overrides a group preference. + Group string + // Parent and Icon retain presentation metadata for catalogs without affecting + // provider execution. + Parent string + Icon string + // Strict opts this tool into provider strict-schema enforcement. Safety hints + // prioritize tools when a provider caps strict-tool definitions. + Strict *bool + ReadOnlyHint *bool + DestructiveHint *bool + IdempotentHint *bool // Handler executes the tool in-process. Required. Handler ToolHandler `json:"-"` - // DefaultPermission gates execution: ToolModeAsk routes the call through - // Config.CanUseTool before the handler runs; any other value auto-runs. + // DefaultPermission controls exposure: off omits the tool, ask routes calls + // through Config.CanUseTool, on auto-runs, and auto defers to runtime policy. DefaultPermission ToolMode // Annotations carries opaque caller metadata (e.g. the originating CLI // verb/method/path) for policies that want the raw values; providers ignore it. @@ -32,4 +66,7 @@ type ToolDefinition struct { // NeedsApproval reports whether a call to this tool must go through // Config.CanUseTool before running. -func (t ToolDefinition) NeedsApproval() bool { return t.DefaultPermission == ToolModeAsk } +func (t ToolDefinition) NeedsApproval() bool { + mode, ok := NormalizeToolMode(t.DefaultPermission) + return ok && mode == ToolModeAsk +} diff --git a/pkg/api/workflow_test.go b/pkg/api/workflow_test.go index 00e2bbe..ea9ca67 100644 --- a/pkg/api/workflow_test.go +++ b/pkg/api/workflow_test.go @@ -68,9 +68,25 @@ func TestSpecSchemaIncludesWorkflow(t *testing.T) { t.Errorf("reflected spec schema missing %q", want) } } - for _, gone := range []string{"\"output\"", "\"Output\""} { - if strings.Contains(string(data), gone) { - t.Errorf("reflected spec schema still contains removed workflow field %s", gone) + var reflected map[string]any + if err := json.Unmarshal(data, &reflected); err != nil { + t.Fatalf("decode schema: %v", err) + } + definitions, ok := reflected["$defs"].(map[string]any) + if !ok { + t.Fatalf("reflected spec schema has no $defs object") + } + workflow, ok := definitions["Workflow"].(map[string]any) + if !ok { + t.Fatalf("reflected spec schema has no Workflow definition") + } + properties, ok := workflow["properties"].(map[string]any) + if !ok { + t.Fatalf("reflected Workflow schema has no properties object") + } + for _, gone := range []string{"output", "Output"} { + if _, exists := properties[gone]; exists { + t.Errorf("reflected Workflow schema still contains removed field %q", gone) } } } From be85ec23a9921502b3d113ab967b8aac95ce392b Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 08:55:24 +0300 Subject: [PATCH 094/131] feat(aichat): add AI SDK chat transport and MCP-backed service Add Captain-owned AI SDK v6 chat transport with SSE streaming, tool approvals, attachments, thread persistence, runtime budgets, provider configuration, and MCP tool discovery. Move CLI chat integration onto the new service and remove the legacy string-based tool approval policy. BREAKING CHANGE: replace github.com/flanksource/clicky/aichat imports with github.com/flanksource/captain/pkg/aichat and use structured tool approval state --- pkg/aichat/aichat_suite_test.go | 13 + pkg/aichat/events.go | 362 +++++++++++++++++++++ pkg/aichat/mcp_provider.go | 202 ++++++++++++ pkg/aichat/mcp_provider_ginkgo_test.go | 159 +++++++++ pkg/aichat/messages.go | 198 ++++++++++++ pkg/aichat/persistence.go | 228 +++++++++++++ pkg/aichat/provider_config.go | 66 ++++ pkg/aichat/resolver.go | 58 ++++ pkg/aichat/runtime_settings.go | 55 ++++ pkg/aichat/service.go | 233 ++++++++++++++ pkg/aichat/service_ginkgo_test.go | 427 +++++++++++++++++++++++++ pkg/aichat/sse.go | 102 ++++++ pkg/aichat/stream_ginkgo_test.go | 265 +++++++++++++++ pkg/aichat/threads.go | 154 +++++++++ pkg/aichat/threads_http.go | 106 ++++++ pkg/aichat/tools.go | 120 +++++++ pkg/aichat/wire.go | 103 ++++++ pkg/aichat/wire_ginkgo_test.go | 93 ++++++ pkg/cli/attachments.go | 2 +- pkg/cli/attachments_ginkgo_test.go | 2 +- pkg/cli/chat_thread_store.go | 7 +- pkg/cli/chat_thread_store_test.go | 6 +- pkg/cli/serve.go | 127 +------- pkg/cli/serve_chat.go | 147 +++++++++ pkg/cli/webapp/src/ChatLayer.tsx | 1 - 25 files changed, 3112 insertions(+), 124 deletions(-) create mode 100644 pkg/aichat/aichat_suite_test.go create mode 100644 pkg/aichat/events.go create mode 100644 pkg/aichat/mcp_provider.go create mode 100644 pkg/aichat/mcp_provider_ginkgo_test.go create mode 100644 pkg/aichat/messages.go create mode 100644 pkg/aichat/persistence.go create mode 100644 pkg/aichat/provider_config.go create mode 100644 pkg/aichat/resolver.go create mode 100644 pkg/aichat/runtime_settings.go create mode 100644 pkg/aichat/service.go create mode 100644 pkg/aichat/service_ginkgo_test.go create mode 100644 pkg/aichat/sse.go create mode 100644 pkg/aichat/stream_ginkgo_test.go create mode 100644 pkg/aichat/threads.go create mode 100644 pkg/aichat/threads_http.go create mode 100644 pkg/aichat/tools.go create mode 100644 pkg/aichat/wire.go create mode 100644 pkg/aichat/wire_ginkgo_test.go create mode 100644 pkg/cli/serve_chat.go diff --git a/pkg/aichat/aichat_suite_test.go b/pkg/aichat/aichat_suite_test.go new file mode 100644 index 0000000..f0547fe --- /dev/null +++ b/pkg/aichat/aichat_suite_test.go @@ -0,0 +1,13 @@ +package aichat_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAIChat(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AI Chat Transport Suite") +} diff --git a/pkg/aichat/events.go b/pkg/aichat/events.go new file mode 100644 index 0000000..b87cff6 --- /dev/null +++ b/pkg/aichat/events.go @@ -0,0 +1,362 @@ +package aichat + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + + "github.com/flanksource/captain/pkg/api" +) + +type toolState struct { + name string + input map[string]any + approvalRequested bool +} + +type eventStream struct { + writer *SSEWriter + blockType string + blockID string + nextBlock int + tools map[string]toolState + metadata *MessageMetadata + sessionID string + model string + terminal bool + finished bool +} + +// WriteEventStream translates a Captain event channel into one complete UI Message Stream. +// Translation errors are written as an error part before the stream is closed and are +// also returned so malformed provider output cannot pass silently. +func WriteEventStream(writer *SSEWriter, events <-chan api.Event) error { + stream := &eventStream{writer: writer, tools: map[string]toolState{}} + if err := stream.start(); err != nil { + return err + } + for event := range events { + if err := stream.event(event); err != nil { + return stream.fail(err) + } + } + if err := stream.finish(); err != nil { + return stream.fail(err) + } + return nil +} + +func (s *eventStream) start() error { + if err := s.writer.WritePart(Part{Type: "start"}); err != nil { + return err + } + return s.writer.WritePart(Part{Type: "start-step"}) +} + +func (s *eventStream) event(event api.Event) error { + if s.terminal { + return fmt.Errorf("event %q received after terminal event", event.Kind) + } + if event.SessionID != "" { + s.sessionID = event.SessionID + } + if event.Model != "" { + s.model = event.Model + } + switch event.Kind { + case api.EventText: + return s.delta("text", event.Text) + case api.EventThinking: + return s.delta("reasoning", event.Text) + case api.EventToolUse: + return s.toolUse(event) + case api.EventPermission: + return s.permission(event) + case api.EventToolResult: + return s.toolResult(event) + case api.EventSystem: + return nil + case api.EventResult: + return s.result(event) + case api.EventError: + return s.providerError(event) + default: + return fmt.Errorf("unsupported Captain event kind %q", event.Kind) + } +} + +func (s *eventStream) delta(kind, delta string) error { + if delta == "" { + return nil + } + if s.blockType != kind { + if err := s.closeBlock(); err != nil { + return err + } + s.blockType = kind + s.blockID = fmt.Sprintf("%s-%d", kind, s.nextBlock) + s.nextBlock++ + if err := s.writer.WritePart(Part{Type: kind + "-start", ID: s.blockID}); err != nil { + return err + } + } + return s.writer.WritePart(Part{Type: kind + "-delta", ID: s.blockID, Delta: delta}) +} + +func (s *eventStream) toolUse(event api.Event) error { + if err := s.closeBlock(); err != nil { + return err + } + if event.Tool == "" { + return fmt.Errorf("tool use has no tool name") + } + if event.ToolCallID == "" { + return fmt.Errorf("tool use %q has no tool call id", event.Tool) + } + if _, exists := s.tools[event.ToolCallID]; exists { + return fmt.Errorf("duplicate tool call id %q", event.ToolCallID) + } + input := event.Input + if input == nil { + input = map[string]any{} + } + if err := s.writer.WritePart(Part{ + Type: "tool-input-available", ToolCallID: event.ToolCallID, + ToolName: event.Tool, Input: input, Dynamic: true, + }); err != nil { + return err + } + s.tools[event.ToolCallID] = toolState{name: event.Tool, input: event.Input} + return nil +} + +func (s *eventStream) permission(event api.Event) error { + if err := s.closeBlock(); err != nil { + return err + } + state, err := s.correlatedTool("permission", event) + if err != nil { + return err + } + if state.approvalRequested { + return fmt.Errorf("duplicate permission for tool call %q", event.ToolCallID) + } + state.approvalRequested = true + s.tools[event.ToolCallID] = state + return s.writer.WritePart(Part{ + Type: "tool-approval-request", ApprovalID: event.ToolCallID, ToolCallID: event.ToolCallID, + }) +} + +func (s *eventStream) toolResult(event api.Event) error { + if err := s.closeBlock(); err != nil { + return err + } + if _, err := s.correlatedTool("result", event); err != nil { + return err + } + output := map[string]any{"output": event.Text} + if !event.Success { + output["isError"] = true + } + if err := s.writer.WritePart(Part{Type: "tool-output-available", ToolCallID: event.ToolCallID, Output: output}); err != nil { + return err + } + delete(s.tools, event.ToolCallID) + return nil +} + +func (s *eventStream) correlatedTool(kind string, event api.Event) (toolState, error) { + if event.ToolCallID == "" { + return toolState{}, fmt.Errorf("%s for tool %q has no tool call id", kind, event.Tool) + } + state, exists := s.tools[event.ToolCallID] + if !exists { + return toolState{}, fmt.Errorf("%s for tool call %q has no matching tool use", kind, event.ToolCallID) + } + if event.Tool != "" && event.Tool != state.name { + return toolState{}, fmt.Errorf("%s for tool call %q names %q, want %q", kind, event.ToolCallID, event.Tool, state.name) + } + return state, nil +} + +func (s *eventStream) result(event api.Event) error { + if err := s.closeBlock(); err != nil { + return err + } + if err := s.unresolvedToolError(event.ToolApproval != nil); err != nil { + return err + } + if event.ToolApproval != nil { + if err := event.ToolApproval.Validate(); err != nil { + return fmt.Errorf("validate tool approval state: %w", err) + } + if err := s.validateApprovalCorrelation(event.ToolApproval); err != nil { + return err + } + if err := s.writer.WritePart(Part{Type: "data-tool-approval", Data: event.ToolApproval}); err != nil { + return err + } + } else { + data := any(map[string]any{"success": event.Success}) + if len(event.StructuredData) > 0 { + if err := json.Unmarshal(event.StructuredData, &data); err != nil { + return fmt.Errorf("decode structured result: %w", err) + } + } + if err := s.writer.WritePart(Part{Type: "data-result", Data: data}); err != nil { + return err + } + } + success := event.Success + s.metadata = &MessageMetadata{ + ProviderSessionID: s.sessionID, + Model: s.model, + Cost: event.CostUSD, + Success: &success, + } + if event.Usage != nil { + s.metadata.Usage = usageMetadata(*event.Usage) + s.metadata.ContextTokens = event.Usage.InputTokens + } + s.terminal = true + return nil +} + +func (s *eventStream) validateApprovalCorrelation(approval *api.ToolApprovalState) error { + seen := make(map[string]bool) + pending := approval.Pending() + sort.Slice(pending, func(i, j int) bool { return pending[i].ToolCallID < pending[j].ToolCallID }) + for _, request := range pending { + state, ok := s.tools[request.ToolCallID] + if !ok || !state.approvalRequested { + return fmt.Errorf("approval state call %q has no matching streamed approval request", request.ToolCallID) + } + if request.Tool != state.name { + return fmt.Errorf("approval state call %q names %q, want %q", request.ToolCallID, request.Tool, state.name) + } + if !approvalInputMatches(request.Input, state.input) { + return fmt.Errorf("approval state call %q input does not match the streamed tool request", request.ToolCallID) + } + seen[request.ToolCallID] = true + } + streamed := make([]string, 0, len(s.tools)) + for id, state := range s.tools { + if state.approvalRequested && !seen[id] { + streamed = append(streamed, id) + } + } + if len(streamed) > 0 { + sort.Strings(streamed) + return fmt.Errorf("streamed approval request %q is absent from the approval state", streamed[0]) + } + return nil +} + +func approvalInputMatches(raw json.RawMessage, input map[string]any) bool { + if len(raw) == 0 { + return len(input) == 0 + } + var stateInput any + if json.Unmarshal(raw, &stateInput) != nil { + return false + } + streamedRaw, err := json.Marshal(input) + if err != nil { + return false + } + var streamedInput any + if json.Unmarshal(streamedRaw, &streamedInput) != nil { + return false + } + return reflect.DeepEqual(stateInput, streamedInput) +} + +func usageMetadata(usage api.Usage) *UsageMetadata { + return &UsageMetadata{ + InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens, + ReasoningTokens: usage.ReasoningTokens, CacheReadTokens: usage.CacheReadTokens, + CacheWriteTokens: usage.CacheWriteTokens, TotalTokens: usage.TotalTokens(), + } +} + +func (s *eventStream) providerError(event api.Event) error { + if err := s.closeBlock(); err != nil { + return err + } + if event.Error == "" { + return fmt.Errorf("captain error event has no error message") + } + s.tools = map[string]toolState{} + s.terminal = true + return s.writer.WritePart(Part{Type: "error", ErrorText: event.Error}) +} + +func (s *eventStream) closeBlock() error { + if s.blockType == "" { + return nil + } + err := s.writer.WritePart(Part{Type: s.blockType + "-end", ID: s.blockID}) + s.blockType = "" + s.blockID = "" + return err +} + +func (s *eventStream) unresolvedToolError(allowApproval bool) error { + ids := make([]string, 0, len(s.tools)) + for id, state := range s.tools { + if allowApproval && state.approvalRequested { + continue + } + ids = append(ids, id) + } + if len(ids) == 0 { + return nil + } + sort.Strings(ids) + if !allowApproval { + return fmt.Errorf("tool call %q ended without a result", ids[0]) + } + return fmt.Errorf("tool call %q ended without a result or approval request", ids[0]) +} + +func (s *eventStream) finish() error { + if s.finished { + return fmt.Errorf("AI SDK event stream is already finished") + } + if err := s.closeBlock(); err != nil { + return err + } + if !s.terminal { + if err := s.unresolvedToolError(true); err != nil { + return err + } + } + if err := s.writer.WritePart(Part{Type: "finish-step"}); err != nil { + return err + } + if err := s.writer.WritePart(Part{Type: "finish", MessageMetadata: s.metadata}); err != nil { + return err + } + s.finished = true + return s.writer.Done() +} + +func (s *eventStream) fail(cause error) error { + if s.finished { + return cause + } + if err := s.closeBlock(); err != nil { + return err + } + if err := s.writer.WritePart(Part{Type: "error", ErrorText: cause.Error()}); err != nil { + return err + } + s.terminal = true + s.tools = map[string]toolState{} + if err := s.finish(); err != nil { + return err + } + return cause +} diff --git a/pkg/aichat/mcp_provider.go b/pkg/aichat/mcp_provider.go new file mode 100644 index 0000000..38aa238 --- /dev/null +++ b/pkg/aichat/mcp_provider.go @@ -0,0 +1,202 @@ +package aichat + +import ( + "context" + "errors" + "fmt" + "maps" + "sync" + + genkitai "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/mcp" + + aitools "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" +) + +// MCPServer configures one external MCP server. Exactly one transport must be +// set: Command for stdio, or URL for SSE/streamable HTTP. +type MCPServer struct { + Name string + Command string + Args []string + Env []string + URL string + Headers map[string]string + StreamableHTTP bool +} + +type MCPToolProviderOptions struct { + Servers []MCPServer +} + +type mcpClient interface { + GetActiveTools(context.Context, *genkit.Genkit) ([]genkitai.Tool, error) + Disconnect() error +} + +type mcpClientFactory func(mcp.MCPClientOptions) (mcpClient, error) + +// MCPToolProvider owns explicit Genkit MCP clients and their projected Captain +// tool definitions. Call Close when the mounted chat service shuts down. +type MCPToolProvider struct { + options MCPToolProviderOptions + clientFactory mcpClientFactory + + mu sync.Mutex + clients map[string]mcpClient + tools *ToolSet +} + +func NewMCPToolProvider(options MCPToolProviderOptions) *MCPToolProvider { + options.Servers = append([]MCPServer(nil), options.Servers...) + return &MCPToolProvider{ + options: options, + clientFactory: func(options mcp.MCPClientOptions) (mcpClient, error) { + return mcp.NewGenkitMCPClient(options) + }, + clients: map[string]mcpClient{}, + } +} + +func (p *MCPToolProvider) ToolSet(ctx context.Context) (ToolSet, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.tools != nil { + return cloneToolSet(*p.tools), nil + } + if err := p.validate(); err != nil { + return ToolSet{}, err + } + + set, err := p.load(ctx) + if err != nil { + return ToolSet{}, errors.Join(err, p.closeClientsLocked()) + } + p.tools = &set + return cloneToolSet(set), nil +} + +func (p *MCPToolProvider) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + p.tools = nil + return p.closeClientsLocked() +} + +func (p *MCPToolProvider) validate() error { + seen := make(map[string]bool, len(p.options.Servers)) + for _, server := range p.options.Servers { + if server.Name == "" { + return fmt.Errorf("MCP server name is required") + } + if seen[server.Name] { + return fmt.Errorf("duplicate MCP server %q", server.Name) + } + seen[server.Name] = true + if (server.Command == "") == (server.URL == "") { + return fmt.Errorf("MCP server %q must configure exactly one of Command or URL", server.Name) + } + } + return nil +} + +func (p *MCPToolProvider) load(ctx context.Context) (ToolSet, error) { + set := ToolSet{} + seen := make(map[string]string) + for _, server := range p.options.Servers { + client, err := p.client(server) + if err != nil { + return ToolSet{}, fmt.Errorf("connect MCP server %q: %w", server.Name, err) + } + tools, err := client.GetActiveTools(ctx, nil) + if err != nil { + return ToolSet{}, fmt.Errorf("discover MCP tools from server %q: %w", server.Name, err) + } + for _, tool := range tools { + if prior, exists := seen[tool.Name()]; exists { + return ToolSet{}, fmt.Errorf("duplicate MCP tool definition %q from servers %q and %q", tool.Name(), prior, server.Name) + } + seen[tool.Name()] = server.Name + definition, catalog, err := projectMCPTool(server.Name, tool) + if err != nil { + return ToolSet{}, err + } + set.Definitions = append(set.Definitions, definition) + set.Catalog = append(set.Catalog, catalog) + } + } + return set, nil +} + +func (p *MCPToolProvider) client(server MCPServer) (mcpClient, error) { + if client := p.clients[server.Name]; client != nil { + return client, nil + } + options := mcp.MCPClientOptions{Name: server.Name} + if server.Command != "" { + options.Stdio = &mcp.StdioConfig{ + Command: server.Command, + Args: append([]string(nil), server.Args...), + Env: append([]string(nil), server.Env...), + } + } else if server.StreamableHTTP { + options.StreamableHTTP = &mcp.StreamableHTTPConfig{BaseURL: server.URL, Headers: maps.Clone(server.Headers)} + } else { + options.SSE = &mcp.SSEConfig{BaseURL: server.URL, Headers: maps.Clone(server.Headers)} + } + client, err := p.clientFactory(options) + if err != nil { + return nil, err + } + p.clients[server.Name] = client + return client, nil +} + +func (p *MCPToolProvider) closeClientsLocked() error { + var errs []error + for _, server := range p.options.Servers { + client := p.clients[server.Name] + if client == nil { + continue + } + if err := client.Disconnect(); err != nil { + errs = append(errs, fmt.Errorf("disconnect MCP server %q: %w", server.Name, err)) + } + delete(p.clients, server.Name) + } + return errors.Join(errs...) +} + +func projectMCPTool(server string, tool genkitai.Tool) (api.ToolDefinition, aitools.ToolCatalogEntry, error) { + definition := tool.Definition() + if definition == nil { + return api.ToolDefinition{}, aitools.ToolCatalogEntry{}, fmt.Errorf("MCP tool %q from server %q has no definition", tool.Name(), server) + } + inputSchema := maps.Clone(definition.InputSchema) + catalog := aitools.ToolCatalogEntry{ + Name: definition.Name, Title: definition.Name, Description: definition.Description, + Source: "mcp", Server: server, PreferenceKey: definition.Name, + DefaultPermission: api.ToolModeAuto, InputSchema: aitools.ObjectSchema(inputSchema), + OutputSchema: maps.Clone(definition.OutputSchema), + } + aitools.ApplyToolMetadata(&catalog, definition.Metadata) + toolDefinition := api.ToolDefinition{ + Name: definition.Name, Description: definition.Description, + InputSchema: maps.Clone(catalog.InputSchema), Group: catalog.Group, + Parent: catalog.Parent, Icon: catalog.Icon, Strict: catalog.Strict, + DefaultPermission: api.ToolMode(catalog.DefaultPermission), + Handler: func(ctx context.Context, input map[string]any) (any, error) { + return tool.RunRaw(ctx, input) + }, + } + return toolDefinition, catalog, nil +} + +func cloneToolSet(set ToolSet) ToolSet { + return ToolSet{ + Definitions: append([]api.ToolDefinition(nil), set.Definitions...), + Catalog: append([]aitools.ToolCatalogEntry(nil), set.Catalog...), + } +} diff --git a/pkg/aichat/mcp_provider_ginkgo_test.go b/pkg/aichat/mcp_provider_ginkgo_test.go new file mode 100644 index 0000000..01810e8 --- /dev/null +++ b/pkg/aichat/mcp_provider_ginkgo_test.go @@ -0,0 +1,159 @@ +package aichat + +import ( + "context" + "errors" + "fmt" + + genkitai "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeMCPClient struct { + tools []genkitai.Tool + discoverErr error + discoveries int + disconnects int + disconnect error +} + +func (f *fakeMCPClient) GetActiveTools(context.Context, *genkit.Genkit) ([]genkitai.Tool, error) { + f.discoveries++ + return f.tools, f.discoverErr +} + +func (f *fakeMCPClient) Disconnect() error { + f.disconnects++ + return f.disconnect +} + +var _ = Describe("Captain MCP tool provider", func() { + It("caches explicit clients and projects executable Genkit tools", func(ctx SpecContext) { + client := &fakeMCPClient{tools: []genkitai.Tool{newFakeMCPTool("weather_lookup")}} + provider := NewMCPToolProvider(MCPToolProviderOptions{ + Servers: []MCPServer{{Name: "weather", URL: "https://example.com/mcp", StreamableHTTP: true}}, + }) + DeferCleanup(provider.Close) + created := 0 + provider.clientFactory = func(options mcp.MCPClientOptions) (mcpClient, error) { + created++ + Expect(options.Name).To(Equal("weather")) + Expect(options.StreamableHTTP.BaseURL).To(Equal("https://example.com/mcp")) + return client, nil + } + + first, err := provider.ToolSet(ctx) + Expect(err).NotTo(HaveOccurred()) + second, err := provider.ToolSet(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(created).To(Equal(1)) + Expect(client.discoveries).To(Equal(1)) + Expect(second.Definitions).To(HaveLen(1)) + Expect(second.Definitions[0].Name).To(Equal(first.Definitions[0].Name)) + Expect(second.Catalog).To(Equal(first.Catalog)) + Expect(first.Definitions).To(HaveLen(1)) + Expect(first.Definitions[0].Name).To(Equal("weather_lookup")) + Expect(first.Catalog).To(HaveLen(1)) + Expect(first.Catalog[0].Source).To(Equal("mcp")) + Expect(first.Catalog[0].Server).To(Equal("weather")) + + result, err := first.Definitions[0].Handler(ctx, map[string]any{"city": "Cape Town"}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(map[string]any{"city": "Cape Town"})) + }) + + It("fails the whole load and disconnects clients when server tools collide", func(ctx SpecContext) { + clients := []*fakeMCPClient{ + {tools: []genkitai.Tool{newFakeMCPTool("duplicate")}}, + {tools: []genkitai.Tool{newFakeMCPTool("duplicate")}}, + } + provider := NewMCPToolProvider(MCPToolProviderOptions{Servers: []MCPServer{ + {Name: "first", Command: "first-server"}, + {Name: "second", Command: "second-server"}, + }}) + created := 0 + provider.clientFactory = func(mcp.MCPClientOptions) (mcpClient, error) { + client := clients[created] + created++ + return client, nil + } + + _, err := provider.ToolSet(ctx) + Expect(err).To(MatchError(ContainSubstring("duplicate MCP tool definition \"duplicate\""))) + Expect(clients[0].disconnects).To(Equal(1)) + Expect(clients[1].disconnects).To(Equal(1)) + }) + + It("reports the failing server and closes earlier clients", func(ctx SpecContext) { + first := &fakeMCPClient{tools: []genkitai.Tool{newFakeMCPTool("first_tool")}} + provider := NewMCPToolProvider(MCPToolProviderOptions{Servers: []MCPServer{ + {Name: "first", Command: "first-server"}, + {Name: "broken", Command: "broken-server"}, + }}) + provider.clientFactory = func(options mcp.MCPClientOptions) (mcpClient, error) { + if options.Name == "broken" { + return nil, errors.New("connection refused") + } + return first, nil + } + + _, err := provider.ToolSet(ctx) + Expect(err).To(MatchError(ContainSubstring(`connect MCP server "broken": connection refused`))) + Expect(first.disconnects).To(Equal(1)) + }) + + It("reports discovery failures and deterministically closes every client", func(ctx SpecContext) { + clients := map[string]*fakeMCPClient{ + "first": {tools: []genkitai.Tool{newFakeMCPTool("first_tool")}}, + "broken": {discoverErr: errors.New("list failed")}, + } + provider := NewMCPToolProvider(MCPToolProviderOptions{Servers: []MCPServer{ + {Name: "first", Command: "first-server"}, + {Name: "broken", Command: "broken-server"}, + }}) + provider.clientFactory = func(options mcp.MCPClientOptions) (mcpClient, error) { + return clients[options.Name], nil + } + + _, err := provider.ToolSet(ctx) + Expect(err).To(MatchError(ContainSubstring(`discover MCP tools from server "broken": list failed`))) + Expect(clients["first"].disconnects).To(Equal(1)) + Expect(clients["broken"].disconnects).To(Equal(1)) + + Expect(provider.Close()).To(Succeed()) + Expect(clients["first"].disconnects).To(Equal(1)) + Expect(clients["broken"].disconnects).To(Equal(1)) + }) + + It("returns every disconnect error with its server name", func(ctx SpecContext) { + clients := map[string]*fakeMCPClient{ + "first": {disconnect: errors.New("first close")}, + "second": {disconnect: errors.New("second close")}, + } + provider := NewMCPToolProvider(MCPToolProviderOptions{Servers: []MCPServer{ + {Name: "first", Command: "first-server"}, + {Name: "second", Command: "second-server"}, + }}) + provider.clientFactory = func(options mcp.MCPClientOptions) (mcpClient, error) { + return clients[options.Name], nil + } + _, err := provider.ToolSet(ctx) + Expect(err).NotTo(HaveOccurred()) + + err = provider.Close() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`disconnect MCP server "first": first close`)) + Expect(err.Error()).To(ContainSubstring(`disconnect MCP server "second": second close`)) + Expect(clients["first"].disconnects).To(Equal(1)) + Expect(clients["second"].disconnects).To(Equal(1)) + }) +}) + +func newFakeMCPTool(name string) genkitai.Tool { + return genkitai.NewTool(name, fmt.Sprintf("Run %s", name), func(_ *genkitai.ToolContext, input map[string]any) (map[string]any, error) { + return input, nil + }) +} diff --git a/pkg/aichat/messages.go b/pkg/aichat/messages.go new file mode 100644 index 0000000..fe2d0f5 --- /dev/null +++ b/pkg/aichat/messages.go @@ -0,0 +1,198 @@ +package aichat + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/api" +) + +// AttachmentInput is an untrusted browser file reference to be resolved into a +// prepared Captain attachment before provider execution. +type AttachmentInput struct { + ID string + URL string + Filename string + MediaType string +} + +// AttachmentResolver prepares browser references for provider consumption. +type AttachmentResolver interface { + Resolve(context.Context, []AttachmentInput) ([]api.AttachmentRef, error) +} + +type partLocation struct{ message, part int } + +func (s *Service) resolveAttachments(ctx context.Context, messages []UIMessage) (map[partLocation]api.AttachmentRef, error) { + inputs := make([]AttachmentInput, 0) + locations := make([]partLocation, 0) + for messageIndex, message := range messages { + for partIndex, part := range message.Parts { + if part.Type != "file" { + continue + } + if message.Role != string(api.RoleUser) { + return nil, fmt.Errorf("message %d: file parts require user role", messageIndex+1) + } + inputs = append(inputs, AttachmentInput{ + ID: part.AttachmentID, URL: part.URL, Filename: part.Filename, MediaType: part.MediaType, + }) + locations = append(locations, partLocation{message: messageIndex, part: partIndex}) + } + } + if len(inputs) == 0 { + return nil, nil + } + if s.options.Attachments == nil { + return nil, fmt.Errorf("chat attachments require an attachment resolver") + } + refs, err := s.options.Attachments.Resolve(ctx, inputs) + if err != nil { + return nil, fmt.Errorf("resolve chat attachments: %w", err) + } + if len(refs) != len(inputs) { + return nil, fmt.Errorf("attachment resolver returned %d results for %d inputs", len(refs), len(inputs)) + } + resolved := make(map[partLocation]api.AttachmentRef, len(refs)) + for i, ref := range refs { + if err := ref.Validate(); err != nil { + return nil, fmt.Errorf("resolved attachment %d: %w", i+1, err) + } + if !ref.IsPrepared() { + return nil, fmt.Errorf("resolved attachment %d is not prepared", i+1) + } + resolved[locations[i]] = ref + } + return resolved, nil +} + +func requestSpec(request ChatRequest, settings RuntimeSettings, attachments map[partLocation]api.AttachmentRef) (api.Spec, error) { + model := strings.TrimSpace(request.Model) + if model == "" { + model = strings.TrimSpace(settings.DefaultModel) + } + if model == "" { + model = strings.TrimSpace(settings.Spec.Name) + } + if model == "" { + return api.Spec{}, fmt.Errorf("chat model is required") + } + spec := settings.Spec.Merge(api.Spec{ + Model: api.Model{Name: model, Effort: request.ReasoningEffort, Temperature: request.Temperature}, + Budget: request.Budget, + ToolPreferences: request.ToolPreferences, + ToolApproval: request.ToolApproval, + Permissions: api.Permissions{Mode: request.PermissionMode}, + SessionID: request.ProviderSessionID, + }) + spec.Prompt.User = "" + spec.Prompt.System = "" + spec.Prompt.AppendSystem = "" + spec.Prompt.Attachments = nil + if request.ToolApproval == nil { + messages, err := canonicalMessages(request.Messages, attachments) + if err != nil { + return api.Spec{}, err + } + system, err := requestSystem(settings.System, request) + if err != nil { + return api.Spec{}, err + } + if system != "" { + messages = append([]api.Message{{Role: api.RoleSystem, Parts: []api.Part{{Type: api.PartText, Text: system}}}}, messages...) + } + spec.Messages = messages + } else { + spec.Messages = nil + } + if err := spec.Validate(); err != nil { + return api.Spec{}, err + } + return spec, nil +} + +func canonicalMessages(messages []UIMessage, attachments map[partLocation]api.AttachmentRef) ([]api.Message, error) { + out := make([]api.Message, 0, len(messages)) + for messageIndex, message := range messages { + role := api.MessageRole(message.Role) + parts := make([]api.Part, 0, len(message.Parts)) + results := make([]api.Part, 0) + for partIndex, part := range message.Parts { + mapped, result, err := canonicalPart(role, part, attachments[partLocation{message: messageIndex, part: partIndex}]) + if err != nil { + return nil, fmt.Errorf("message %d part %d: %w", messageIndex+1, partIndex+1, err) + } + if mapped != nil { + parts = append(parts, *mapped) + } + if result != nil { + results = append(results, *result) + } + } + if len(parts) == 0 { + return nil, fmt.Errorf("message %d (%s) has no provider content", messageIndex+1, role) + } + out = append(out, api.Message{Role: role, Parts: parts}) + if len(results) > 0 { + out = append(out, api.Message{Role: api.RoleTool, Parts: results}) + } + } + return out, nil +} + +func canonicalPart(role api.MessageRole, part UIPart, attachment api.AttachmentRef) (*api.Part, *api.Part, error) { + switch { + case part.Type == "text": + return &api.Part{Type: api.PartText, Text: part.Text}, nil, nil + case part.Type == "reasoning": + return &api.Part{Type: api.PartReasoning, Text: part.Text}, nil, nil + case part.Type == "file": + return &api.Part{Type: api.PartAttachment, Attachment: &attachment}, nil, nil + case part.IsTool(): + if role != api.RoleAssistant { + return nil, nil, fmt.Errorf("tool parts require assistant role") + } + request := &api.Part{Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ + ToolCallID: part.ToolCallID, Name: part.EffectiveToolName(), Input: part.Input, + }} + if part.State != "output-available" && part.State != "output-error" { + return request, nil, nil + } + result := &api.Part{Type: api.PartToolResult, ToolResult: &api.ToolResult{ + ToolCallID: part.ToolCallID, Output: part.Output, + }} + if part.State == "output-error" { + result.ToolResult.Output = nil + result.ToolResult.Error = part.ErrorText + } + return request, result, nil + case part.Type == "step-start" || strings.HasPrefix(part.Type, "data-") || strings.HasPrefix(part.Type, "source-"): + return nil, nil, nil + default: + return nil, nil, fmt.Errorf("unsupported AI SDK part type %q", part.Type) + } +} + +func requestSystem(base string, request ChatRequest) (string, error) { + sections := make([]string, 0, 2) + if strings.TrimSpace(base) != "" { + sections = append(sections, strings.TrimSpace(base)) + } + contextSections := make([]string, 0, 2) + if strings.TrimSpace(request.Context) != "" { + contextSections = append(contextSections, strings.TrimSpace(request.Context)) + } + if len(request.ContextItems) > 0 { + payload, err := json.Marshal(request.ContextItems) + if err != nil { + return "", fmt.Errorf("marshal chat context items: %w", err) + } + contextSections = append(contextSections, "Structured context items JSON:\n"+string(payload)) + } + if len(contextSections) > 0 { + sections = append(sections, "Current UI context:\n"+strings.Join(contextSections, "\n")) + } + return strings.Join(sections, "\n\n"), nil +} diff --git a/pkg/aichat/persistence.go b/pkg/aichat/persistence.go new file mode 100644 index 0000000..ef0d8d4 --- /dev/null +++ b/pkg/aichat/persistence.go @@ -0,0 +1,228 @@ +package aichat + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/flanksource/captain/pkg/api" +) + +type assistantMessageBuilder struct { + message UIMessage + toolParts map[string]int + sessionID string + model string +} + +func newAssistantMessageBuilder(chatID string) *assistantMessageBuilder { + id := "" + if chatID != "" { + id = chatID + "-assistant" + } + return &assistantMessageBuilder{ + message: UIMessage{ID: id, Role: string(api.RoleAssistant), Parts: []UIPart{}}, + toolParts: map[string]int{}, + } +} + +func (s *Service) persistedEvents(ctx context.Context, request ChatRequest, source <-chan api.Event) <-chan api.Event { + if request.ThreadID == "" { + return source + } + out := make(chan api.Event) + go func() { + defer close(out) + builder := newAssistantMessageBuilder(request.ID) + persisted := false + for event := range source { + if err := builder.apply(event); err != nil { + sendEvent(ctx, out, api.Event{Kind: api.EventError, Error: err.Error(), Model: event.Model}) + return + } + if !persisted && event.Kind != api.EventResult && event.Kind != api.EventError && event.SessionID != "" { + if err := s.persistEvent(ctx, request.ThreadID, event); err != nil { + sendEvent(ctx, out, api.Event{Kind: api.EventError, Error: err.Error(), Model: event.Model}) + return + } + } + if !persisted && (event.Kind == api.EventResult || event.Kind == api.EventError) { + if err := s.persistCompletedTurn(ctx, request.ThreadID, builder, event); err != nil { + sendEvent(ctx, out, api.Event{Kind: api.EventError, Error: err.Error(), Model: event.Model}) + return + } + persisted = true + } + if !sendEvent(ctx, out, event) { + return + } + } + if !persisted && len(builder.message.Parts) > 0 { + if err := s.options.Threads.AppendMessage(ctx, request.ThreadID, builder.message); err != nil { + sendEvent(ctx, out, api.Event{Kind: api.EventError, Error: fmt.Sprintf("persist assistant message: %v", err)}) + } + } + }() + return out +} + +func sendEvent(ctx context.Context, target chan<- api.Event, event api.Event) bool { + select { + case target <- event: + return true + case <-ctx.Done(): + return false + } +} + +func (s *Service) persistCompletedTurn(ctx context.Context, threadID string, builder *assistantMessageBuilder, event api.Event) error { + if err := s.persistEvent(ctx, threadID, event); err != nil { + return err + } + if len(builder.message.Parts) == 0 { + return fmt.Errorf("completed assistant turn has no message parts") + } + if err := s.options.Threads.AppendMessage(ctx, threadID, builder.message); err != nil { + return fmt.Errorf("persist assistant message: %w", err) + } + return nil +} + +func (b *assistantMessageBuilder) apply(event api.Event) error { + if event.SessionID != "" { + b.sessionID = event.SessionID + } + if event.Model != "" { + b.model = event.Model + } + switch event.Kind { + case api.EventText: + b.appendText("text", event.Text) + case api.EventThinking: + b.appendText("reasoning", event.Text) + case api.EventToolUse: + return b.toolUse(event) + case api.EventPermission: + return b.permission(event) + case api.EventToolResult: + return b.toolResult(event) + case api.EventResult: + return b.result(event) + case api.EventError: + payload, err := json.Marshal(map[string]string{"error": event.Error}) + if err != nil { + return err + } + b.message.Parts = append(b.message.Parts, UIPart{Type: "data-error", Data: payload}) + case api.EventSystem: + } + return nil +} + +func (b *assistantMessageBuilder) appendText(partType, text string) { + if text == "" { + return + } + if len(b.message.Parts) > 0 && b.message.Parts[len(b.message.Parts)-1].Type == partType { + b.message.Parts[len(b.message.Parts)-1].Text += text + return + } + b.message.Parts = append(b.message.Parts, UIPart{Type: partType, Text: text}) +} + +func (b *assistantMessageBuilder) toolUse(event api.Event) error { + if event.ToolCallID == "" || event.Tool == "" { + return fmt.Errorf("persist tool use requires a tool name and call ID") + } + if _, exists := b.toolParts[event.ToolCallID]; exists { + return fmt.Errorf("persist duplicate tool call %q", event.ToolCallID) + } + input, err := json.Marshal(event.Input) + if err != nil { + return fmt.Errorf("marshal tool %q input: %w", event.Tool, err) + } + b.toolParts[event.ToolCallID] = len(b.message.Parts) + b.message.Parts = append(b.message.Parts, UIPart{ + Type: "dynamic-tool", ToolName: event.Tool, ToolCallID: event.ToolCallID, + State: "input-available", Input: input, + }) + return nil +} + +func (b *assistantMessageBuilder) permission(event api.Event) error { + part, err := b.toolPart(event.ToolCallID, event.Tool) + if err != nil { + return err + } + part.State = "approval-requested" + part.Approval = &Approval{ID: event.ToolCallID} + return nil +} + +func (b *assistantMessageBuilder) toolResult(event api.Event) error { + part, err := b.toolPart(event.ToolCallID, event.Tool) + if err != nil { + return err + } + if event.Success { + part.State = "output-available" + part.Output, err = jsonValue(event.Text) + if err != nil { + return fmt.Errorf("marshal tool %q output: %w", event.Tool, err) + } + return nil + } + part.State = "output-error" + part.ErrorText = event.Text + return nil +} + +func (b *assistantMessageBuilder) toolPart(callID, name string) (*UIPart, error) { + index, ok := b.toolParts[callID] + if !ok { + return nil, fmt.Errorf("persist tool event %q has no matching tool use", callID) + } + part := &b.message.Parts[index] + if name != "" && part.ToolName != name { + return nil, fmt.Errorf("persist tool event %q names %q, want %q", callID, name, part.ToolName) + } + return part, nil +} + +func (b *assistantMessageBuilder) result(event api.Event) error { + dataType := "data-result" + data := event.StructuredData + if event.ToolApproval != nil { + dataType = "data-tool-approval" + var err error + data, err = json.Marshal(event.ToolApproval) + if err != nil { + return fmt.Errorf("marshal tool approval state: %w", err) + } + } else if len(data) == 0 { + var err error + data, err = json.Marshal(map[string]bool{"success": event.Success}) + if err != nil { + return fmt.Errorf("marshal result state: %w", err) + } + } + b.message.Parts = append(b.message.Parts, UIPart{Type: dataType, Data: data}) + success := event.Success + b.message.Metadata = &MessageMetadata{ + ProviderSessionID: b.sessionID, Model: b.model, Cost: event.CostUSD, Success: &success, + } + if event.Usage != nil { + b.message.Metadata.Usage = usageMetadata(*event.Usage) + b.message.Metadata.ContextTokens = event.Usage.InputTokens + } + return nil +} + +func jsonValue(text string) (json.RawMessage, error) { + raw := json.RawMessage(text) + if json.Valid(raw) { + return raw, nil + } + payload, err := json.Marshal(text) + return payload, err +} diff --git a/pkg/aichat/provider_config.go b/pkg/aichat/provider_config.go new file mode 100644 index 0000000..0094108 --- /dev/null +++ b/pkg/aichat/provider_config.go @@ -0,0 +1,66 @@ +package aichat + +import ( + "context" + "fmt" + "reflect" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +// ProviderConfigRequest carries the canonically resolved model and the runtime +// config assembled by the chat service. +type ProviderConfigRequest struct { + Model api.Model + Config api.Config +} + +// ProviderConfigSource supplies request-scoped provider identities and +// credentials without owning provider resolution or construction. +type ProviderConfigSource interface { + ConfiguredProviders(context.Context) ([]api.Backend, error) + ProviderConfig(context.Context, ProviderConfigRequest) (api.Config, error) +} + +func (s *Service) annotateConfiguredModels(ctx context.Context, models ModelCatalogResponse) error { + if s.options.ProviderConfig == nil { + return nil + } + backends, err := s.options.ProviderConfig.ConfiguredProviders(ctx) + if err != nil { + return fmt.Errorf("load configured chat providers: %w", err) + } + configured := make(map[string]bool, len(backends)) + for _, backend := range backends { + if backend == "" { + return fmt.Errorf("configured chat provider backend is required") + } + configured[ai.BackendToProvider(backend)] = true + } + for i := range models { + models[i].Configured = models[i].Configured || configured[models[i].Provider] + } + return nil +} + +func (s *Service) resolveProvider(ctx context.Context, config api.Config) (api.StreamingProvider, error) { + if s.options.ProviderConfig != nil { + resolved, err := ai.ResolveModelSelectors(config.Model) + if err != nil { + return nil, fmt.Errorf("resolve chat model: %w", err) + } + config.Model = resolved + config, err = s.options.ProviderConfig.ProviderConfig(ctx, ProviderConfigRequest{ + Model: resolved, Config: config, + }) + if err != nil { + return nil, fmt.Errorf("load chat provider config for %s: %w", resolved.Backend, err) + } + if !reflect.DeepEqual(config.Model, resolved) { + return nil, fmt.Errorf("provider config source changed the resolved chat model from %q (%s) to %q (%s)", + resolved.Name, resolved.Backend, config.Model.Name, config.Model.Backend) + } + } + return s.resolver.Provider(ctx, config) +} diff --git a/pkg/aichat/resolver.go b/pkg/aichat/resolver.go new file mode 100644 index 0000000..9db2702 --- /dev/null +++ b/pkg/aichat/resolver.go @@ -0,0 +1,58 @@ +package aichat + +import ( + "context" + "fmt" + + "github.com/flanksource/captain/pkg/ai" + _ "github.com/flanksource/captain/pkg/ai/provider" + "github.com/flanksource/captain/pkg/api" +) + +// Resolver is the injectable boundary around Captain's model catalog and +// provider construction. The default implementation delegates to Captain's +// canonical resolver; tests may replace it with a fake provider. +type Resolver interface { + Models(context.Context) (ModelCatalogResponse, error) + Provider(context.Context, api.Config) (api.StreamingProvider, error) +} + +type captainResolver struct{} + +func (captainResolver) Models(_ context.Context) (ModelCatalogResponse, error) { + configured := make([]string, 0, 4) + for _, backend := range []api.Backend{ + api.BackendAnthropic, api.BackendOpenAI, api.BackendGemini, api.BackendDeepSeek, + } { + resolved, err := ai.ResolveAPIKey(backend) + if err != nil { + return nil, fmt.Errorf("resolve %s credentials: %w", backend, err) + } + if resolved.Token != "" { + configured = append(configured, ai.BackendToProvider(backend)) + } + } + return ai.LiveCatalogInfo(configured) +} + +func (captainResolver) Provider(_ context.Context, config api.Config) (api.StreamingProvider, error) { + provider, err := ai.NewProvider(config) + if err != nil { + return nil, err + } + streaming, ok := api.ProviderAs[api.StreamingProvider](provider) + if !ok { + if closeErr := closeProvider(provider); closeErr != nil { + return nil, fmt.Errorf("backend %q does not support streaming; close provider: %w", provider.GetBackend(), closeErr) + } + return nil, fmt.Errorf("backend %q does not support streaming", provider.GetBackend()) + } + return streaming, nil +} + +func closeProvider(provider api.Provider) error { + if closer, ok := api.ProviderAs[api.CloseableProvider](provider); ok { + return closer.Close() + } + return nil +} diff --git a/pkg/aichat/runtime_settings.go b/pkg/aichat/runtime_settings.go new file mode 100644 index 0000000..0e9fff1 --- /dev/null +++ b/pkg/aichat/runtime_settings.go @@ -0,0 +1,55 @@ +package aichat + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type requestError struct { + status int + text string +} + +func (e requestError) Error() string { return e.text } + +func requestErrorStatus(err error) int { + if typed, ok := err.(requestError); ok { + return typed.status + } + return http.StatusBadRequest +} + +func enforceRuntimeSettings(request ChatRequest, settings RuntimeSettings) error { + if settings.MonthlyBudgetUSD > 0 && settings.CurrentMonthCostUSD >= settings.MonthlyBudgetUSD { + return requestError{status: http.StatusPaymentRequired, text: fmt.Sprintf( + "chat monthly cost budget exhausted: $%.4f used of $%.4f", + settings.CurrentMonthCostUSD, settings.MonthlyBudgetUSD, + )} + } + if settings.MonthlyTokenBudget > 0 && settings.CurrentMonthTokens >= settings.MonthlyTokenBudget { + return requestError{status: http.StatusPaymentRequired, text: fmt.Sprintf( + "chat monthly token budget exhausted: %d used of %d", + settings.CurrentMonthTokens, settings.MonthlyTokenBudget, + )} + } + if settings.MaxInputTokens <= 0 { + return nil + } + raw, err := json.Marshal(struct { + Messages []UIMessage `json:"messages,omitempty"` + Context string `json:"context,omitempty"` + ContextItems []ChatContextItem `json:"contextItems,omitempty"` + }{Messages: request.Messages, Context: request.Context, ContextItems: request.ContextItems}) + if err != nil { + return fmt.Errorf("estimate chat input tokens: %w", err) + } + estimated := (len(raw) + 3) / 4 + if estimated > settings.MaxInputTokens { + return requestError{status: http.StatusRequestEntityTooLarge, text: fmt.Sprintf( + "chat input is about %d tokens, exceeding the configured per-turn limit of %d", + estimated, settings.MaxInputTokens, + )} + } + return nil +} diff --git a/pkg/aichat/service.go b/pkg/aichat/service.go new file mode 100644 index 0000000..66e4d65 --- /dev/null +++ b/pkg/aichat/service.go @@ -0,0 +1,233 @@ +package aichat + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + aitools "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/commons/logger" +) + +var serviceLog = logger.GetLogger("aichat") + +// RuntimeSettings are application-owned defaults and provider construction +// settings evaluated for each request. +type RuntimeSettings struct { + DefaultModel string + System string + Spec api.Spec + ProviderConfig api.Config + MaxInputTokens int + MonthlyTokenBudget int + CurrentMonthTokens int + MonthlyBudgetUSD float64 + CurrentMonthCostUSD float64 +} + +// RuntimeSettingsProvider supplies request-scoped application settings. +type RuntimeSettingsProvider interface { + RuntimeSettings(context.Context) (RuntimeSettings, error) +} + +type RuntimeSettingsProviderFunc func(context.Context) (RuntimeSettings, error) + +func (f RuntimeSettingsProviderFunc) RuntimeSettings(ctx context.Context) (RuntimeSettings, error) { + return f(ctx) +} + +// ServiceOptions injects every application-owned chat dependency. A nil +// Resolver uses Captain's canonical model/provider resolver. +type ServiceOptions struct { + Resolver Resolver + ProviderConfig ProviderConfigSource + Settings RuntimeSettingsProvider + Tools ToolProvider + MCP ToolProvider + Attachments AttachmentResolver + Threads ThreadStore +} + +// Service is Captain's AI SDK-compatible HTTP chat service. +type Service struct { + options ServiceOptions + resolver Resolver +} + +func NewService(options ServiceOptions) *Service { + resolver := options.Resolver + if resolver == nil { + resolver = captainResolver{} + } + return &Service{options: options, resolver: resolver} +} + +func (s *Service) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/chat", s.handleChat) + mux.HandleFunc("GET /api/chat/models", s.handleModels) + mux.HandleFunc("GET /api/chat/tools", s.handleTools) + s.registerThreadRoutes(mux) + return mux +} + +func (s *Service) handleModels(w http.ResponseWriter, request *http.Request) { + models, err := s.resolver.Models(request.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + if err := s.annotateConfiguredModels(request.Context(), models); err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + if err := writeJSON(w, http.StatusOK, models); err != nil { + serviceLog.Errorf("write chat models response: %v", err) + } +} + +func (s *Service) handleTools(w http.ResponseWriter, request *http.Request) { + set, err := s.loadTools(request.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := writeJSON(w, http.StatusOK, aitools.ToolCatalog{Tools: set.Catalog}); err != nil { + serviceLog.Errorf("write chat tools response: %v", err) + } +} + +func (s *Service) handleChat(w http.ResponseWriter, request *http.Request) { + var chat ChatRequest + if err := json.NewDecoder(request.Body).Decode(&chat); err != nil { + http.Error(w, fmt.Sprintf("invalid chat request: %v", err), http.StatusBadRequest) + return + } + settings, err := s.runtimeSettings(request.Context()) + if err != nil { + http.Error(w, fmt.Sprintf("load chat runtime settings: %v", err), http.StatusInternalServerError) + return + } + if err := enforceRuntimeSettings(chat, settings); err != nil { + http.Error(w, err.Error(), requestErrorStatus(err)) + return + } + if err := s.resolveThreadSession(request.Context(), &chat); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + attachments, err := s.resolveAttachments(request.Context(), chat.Messages) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + spec, err := requestSpec(chat, settings, attachments) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + set, err := s.loadTools(request.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := s.persistIncoming(request.Context(), chat); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + config := settings.ProviderConfig + config.Model = spec.Model + config.Budget = spec.Budget + config.SessionID = spec.SessionID + config.Tools = set.Definitions + provider, err := s.resolveProvider(request.Context(), config) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer func() { + if closeErr := closeProvider(provider); closeErr != nil { + serviceLog.Errorf("close chat provider: %v", closeErr) + } + }() + if len(set.Definitions) > 0 { + capability, ok := api.ProviderAs[api.ToolCapableProvider](provider) + if !ok || !capability.SupportsCallerTools() { + http.Error(w, fmt.Sprintf("backend %q does not support caller tools", provider.GetBackend()), http.StatusBadRequest) + return + } + } + streamContext, cancel := context.WithCancel(request.Context()) + defer cancel() + events, err := provider.ExecuteStream(streamContext, spec) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writer, err := NewSSEWriter(w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := WriteEventStream(writer, s.persistedEvents(streamContext, chat, events)); err != nil { + serviceLog.Errorf("stream chat response: %v", err) + } +} + +func (s *Service) runtimeSettings(ctx context.Context) (RuntimeSettings, error) { + if s.options.Settings == nil { + return RuntimeSettings{}, nil + } + return s.options.Settings.RuntimeSettings(ctx) +} + +func (s *Service) resolveThreadSession(ctx context.Context, request *ChatRequest) error { + if request.ThreadID == "" { + return nil + } + if s.options.Threads == nil { + return fmt.Errorf("thread persistence is not configured") + } + thread, err := s.options.Threads.Get(ctx, request.ThreadID) + if err != nil { + return err + } + if request.ProviderSessionID == "" { + request.ProviderSessionID = thread.ProviderSessionID + } + return nil +} + +func (s *Service) persistIncoming(ctx context.Context, request ChatRequest) error { + if request.ThreadID == "" || len(request.Messages) == 0 { + return nil + } + last := request.Messages[len(request.Messages)-1] + if strings.EqualFold(last.Role, string(api.RoleUser)) { + return s.options.Threads.AppendMessage(ctx, request.ThreadID, last) + } + return nil +} + +func (s *Service) persistEvent(ctx context.Context, threadID string, event api.Event) error { + if event.SessionID != "" { + if err := s.options.Threads.SetProviderSession(ctx, threadID, event.SessionID); err != nil { + return fmt.Errorf("persist provider session: %w", err) + } + } + if event.Kind != api.EventResult || event.Usage == nil { + return nil + } + _, err := s.options.Threads.AddUsage(ctx, threadID, TurnUsage{ + InputTokens: event.Usage.InputTokens, OutputTokens: event.Usage.OutputTokens, + ReasoningTokens: event.Usage.ReasoningTokens, CacheReadTokens: event.Usage.CacheReadTokens, + CacheWriteTokens: event.Usage.CacheWriteTokens, CostUSD: event.CostUSD, + }) + if err != nil { + return fmt.Errorf("persist thread usage: %w", err) + } + return nil +} diff --git a/pkg/aichat/service_ginkgo_test.go b/pkg/aichat/service_ginkgo_test.go new file mode 100644 index 0000000..587fa89 --- /dev/null +++ b/pkg/aichat/service_ginkgo_test.go @@ -0,0 +1,427 @@ +package aichat_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/aichat" + "github.com/flanksource/captain/pkg/api" +) + +type fakeResolver struct { + models aichat.ModelCatalogResponse + provider *fakeStreamingProvider + configs []api.Config +} + +type fakeProviderConfigSource struct { + backends []api.Backend + config api.Config + requests []aichat.ProviderConfigRequest + resolve func(aichat.ProviderConfigRequest) (api.Config, error) +} + +func (f *fakeProviderConfigSource) ConfiguredProviders(context.Context) ([]api.Backend, error) { + return append([]api.Backend(nil), f.backends...), nil +} + +func (f *fakeProviderConfigSource) ProviderConfig(_ context.Context, request aichat.ProviderConfigRequest) (api.Config, error) { + f.requests = append(f.requests, request) + if f.resolve != nil { + return f.resolve(request) + } + config := request.Config + config.APIKey = f.config.APIKey + config.APIURL = f.config.APIURL + return config, nil +} + +func (f *fakeResolver) Models(context.Context) (aichat.ModelCatalogResponse, error) { + return f.models, nil +} + +func (f *fakeResolver) Provider(_ context.Context, config api.Config) (api.StreamingProvider, error) { + f.configs = append(f.configs, config) + return f.provider, nil +} + +type fakeStreamingProvider struct { + events []api.Event + specs []api.Spec + execute func(context.Context, api.Spec) (<-chan api.Event, error) +} + +func (f *fakeStreamingProvider) Execute(context.Context, api.Spec) (*api.Response, error) { + return nil, fmt.Errorf("buffered execution is not used by chat") +} + +func (f *fakeStreamingProvider) ExecuteStream(ctx context.Context, spec api.Spec) (<-chan api.Event, error) { + f.specs = append(f.specs, spec) + if f.execute != nil { + return f.execute(ctx, spec) + } + events := make(chan api.Event, len(f.events)) + for _, event := range f.events { + events <- event + } + close(events) + return events, nil +} + +func (f *fakeStreamingProvider) GetModel() string { return "test-model" } +func (f *fakeStreamingProvider) GetBackend() api.Backend { return api.BackendOpenAI } +func (f *fakeStreamingProvider) SupportsCallerTools() bool { return true } + +type fakeAttachmentResolver struct{} + +func (fakeAttachmentResolver) Resolve(_ context.Context, inputs []aichat.AttachmentInput) ([]api.AttachmentRef, error) { + refs := make([]api.AttachmentRef, len(inputs)) + for i, input := range inputs { + refs[i] = api.AttachmentRef{ + ID: api.AttachmentIDPrefix + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Filename: input.Filename, MediaType: input.MediaType, + }.WithPreparedContent(api.AttachmentContent{Bytes: []byte("image")}) + } + return refs, nil +} + +func requestJSON(method, path string, body any) *http.Request { + var payload bytes.Buffer + Expect(json.NewEncoder(&payload).Encode(body)).To(Succeed()) + return httptest.NewRequest(method, path, &payload) +} + +var _ = Describe("Captain aichat service", func() { + It("annotates the model catalog with request-scoped configured providers", func() { + resolver := &fakeResolver{models: aichat.ModelCatalogResponse{ + {ID: "anthropic/claude-sonnet", Provider: "anthropic", Label: "Claude"}, + {ID: "openai/gpt", Provider: "openai", Label: "GPT"}, + }} + source := &fakeProviderConfigSource{backends: []api.Backend{api.BackendOpenAI}} + service := aichat.NewService(aichat.ServiceOptions{Resolver: resolver, ProviderConfig: source}) + + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/chat/models", nil)) + Expect(response.Code).To(Equal(http.StatusOK)) + var models aichat.ModelCatalogResponse + Expect(json.Unmarshal(response.Body.Bytes(), &models)).To(Succeed()) + Expect(models).To(HaveLen(2)) + Expect(models[0].Configured).To(BeFalse()) + Expect(models[1].Configured).To(BeTrue()) + }) + + It("applies request-scoped credentials after canonical model selection", func() { + provider := &fakeStreamingProvider{events: []api.Event{ + {Kind: api.EventText, Text: "done"}, + {Kind: api.EventResult, Success: true, Model: "gpt-5.4"}, + }} + resolver := &fakeResolver{provider: provider} + source := &fakeProviderConfigSource{ + backends: []api.Backend{api.BackendOpenAI}, + config: api.Config{APIKey: "request-token", APIURL: "https://tenant-x.example/ai"}, + } + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, ProviderConfig: source, + Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { + return aichat.RuntimeSettings{DefaultModel: "api:gpt-5.4"}, nil + }), + }) + + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + Messages: []aichat.UIMessage{{Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "hello"}}}}, + })) + Expect(response.Code).To(Equal(http.StatusOK)) + Expect(source.requests).To(HaveLen(1)) + Expect(source.requests[0].Model.Backend).To(Equal(api.BackendOpenAI)) + Expect(source.requests[0].Model.Name).To(Equal("gpt-5.4")) + Expect(resolver.configs).To(HaveLen(1)) + Expect(resolver.configs[0].APIKey).To(Equal("request-token")) + Expect(resolver.configs[0].APIURL).To(Equal("https://tenant-x.example/ai")) + }) + + It("rejects provider configuration that changes the canonical resolved model", func() { + resolver := &fakeResolver{provider: &fakeStreamingProvider{}} + source := &fakeProviderConfigSource{resolve: func(request aichat.ProviderConfigRequest) (api.Config, error) { + config := request.Config + config.Model = api.Model{Name: "claude-sonnet-4-6", Backend: api.BackendAnthropic} + return config, nil + }} + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, ProviderConfig: source, + Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { + return aichat.RuntimeSettings{DefaultModel: "api:gpt-5.4"}, nil + }), + }) + + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + Messages: []aichat.UIMessage{{Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "hello"}}}}, + })) + Expect(response.Code).To(Equal(http.StatusServiceUnavailable)) + Expect(response.Body.String()).To(ContainSubstring("provider config source changed the resolved chat model")) + Expect(resolver.configs).To(BeEmpty()) + }) + + It("merges request budget overrides without erasing runtime defaults", func() { + provider := &fakeStreamingProvider{events: []api.Event{ + {Kind: api.EventText, Text: "done"}, + {Kind: api.EventResult, Success: true, Model: "test-model"}, + }} + resolver := &fakeResolver{provider: provider} + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, + Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { + return aichat.RuntimeSettings{DefaultModel: "openai/test-model", Spec: api.Spec{ + Budget: api.Budget{Cost: 5, MaxTokens: 2_000, MaxTurns: 3}, + }}, nil + }), + }) + + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + Messages: []aichat.UIMessage{{Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "hello"}}}}, + Budget: api.Budget{MaxTokens: 1_000}, + })) + Expect(response.Code).To(Equal(http.StatusOK)) + Expect(provider.specs).To(HaveLen(1)) + Expect(provider.specs[0].Budget).To(Equal(api.Budget{Cost: 5, MaxTokens: 1_000, MaxTurns: 3})) + }) + + It("rejects exhausted runtime budgets before provider construction", func() { + resolver := &fakeResolver{provider: &fakeStreamingProvider{}} + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, + Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { + return aichat.RuntimeSettings{ + DefaultModel: "openai/test-model", MonthlyBudgetUSD: 10, CurrentMonthCostUSD: 10, + }, nil + }), + }) + + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + Messages: []aichat.UIMessage{{Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "hello"}}}}, + })) + Expect(response.Code).To(Equal(http.StatusPaymentRequired)) + Expect(resolver.configs).To(BeEmpty()) + }) + + It("serves models and tools from injected Captain seams", func() { + resolver := &fakeResolver{models: aichat.ModelCatalogResponse{{ + ID: "openai/test-model", Provider: "openai", Label: "Test", Configured: true, + }}} + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, + Tools: aichat.ToolProviderFunc(func(context.Context) (aichat.ToolSet, error) { + return aichat.ToolSet{Definitions: []api.ToolDefinition{{ + Name: "invoice_get", Group: "billing", Description: "Get invoice", + InputSchema: map[string]any{"type": "object"}, + Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, + }}}, nil + }), + MCP: aichat.ToolProviderFunc(func(context.Context) (aichat.ToolSet, error) { + return aichat.ToolSet{Definitions: []api.ToolDefinition{{ + Name: "docs_search", Group: "docs", Description: "Search documentation", + Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, + }}}, nil + }), + }) + + models := httptest.NewRecorder() + service.Handler().ServeHTTP(models, httptest.NewRequest(http.MethodGet, "/api/chat/models", nil)) + Expect(models.Code).To(Equal(http.StatusOK)) + Expect(models.Body.String()).To(MatchJSON(`[{"id":"openai/test-model","provider":"openai","label":"Test","reasoning":false,"temperature":false,"configured":true,"contextWindow":0,"inputMediaTypes":null}]`)) + + tools := httptest.NewRecorder() + service.Handler().ServeHTTP(tools, httptest.NewRequest(http.MethodGet, "/api/chat/tools", nil)) + Expect(tools.Code).To(Equal(http.StatusOK)) + var catalog aichat.ToolCatalogResponse + Expect(json.Unmarshal(tools.Body.Bytes(), &catalog)).To(Succeed()) + Expect(catalog.Tools).To(HaveLen(2)) + Expect(catalog.Tools[0].Name).To(Equal("invoice_get")) + Expect(catalog.Tools[0].Source).To(Equal("custom")) + Expect(catalog.Tools[1].Name).To(Equal("docs_search")) + Expect(catalog.Tools[1].Source).To(Equal("mcp")) + }) + + It("maps the HTTP request directly into api.Spec and streams provider events", func() { + provider := &fakeStreamingProvider{events: []api.Event{ + {Kind: api.EventText, Text: "done"}, + {Kind: api.EventResult, Success: true, Model: "test-model"}, + }} + resolver := &fakeResolver{provider: provider} + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, + Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { + return aichat.RuntimeSettings{ + DefaultModel: "openai/test-model", System: "Use application tools.", + ProviderConfig: api.Config{APIURL: "https://example.com/ai", ProjectName: "tenant-x"}, + }, nil + }), + Attachments: fakeAttachmentResolver{}, + Tools: aichat.ToolProviderFunc(func(context.Context) (aichat.ToolSet, error) { + return aichat.ToolSet{Definitions: []api.ToolDefinition{{ + Name: "invoice_get", Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, + }}}, nil + }), + }) + request := aichat.ChatRequest{ + Messages: []aichat.UIMessage{{Role: "user", Parts: []aichat.UIPart{ + {Type: "text", Text: "inspect"}, + {Type: "file", URL: "https://example.com/image.png", Filename: "image.png", MediaType: "image/png"}, + }}}, + Context: "invoice editor", ToolPreferences: api.ToolPreferences{"billing": api.ToolModeAsk}, + ReasoningEffort: api.EffortHigh, PermissionMode: api.PermissionAcceptEdits, + } + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", request)) + + Expect(response.Code).To(Equal(http.StatusOK)) + Expect(response.Header().Get("x-vercel-ai-ui-message-stream")).To(Equal("v1")) + Expect(response.Body.String()).To(ContainSubstring(`"delta":"done"`)) + Expect(provider.specs).To(HaveLen(1)) + spec := provider.specs[0] + Expect(spec.Model.Name).To(Equal("openai/test-model")) + Expect(spec.Model.Effort).To(Equal(api.EffortHigh)) + Expect(spec.ToolPreferences).To(Equal(api.ToolPreferences{"billing": api.ToolModeAsk})) + Expect(spec.Permissions.Mode).To(Equal(api.PermissionAcceptEdits)) + Expect(spec.Messages).To(HaveLen(2)) + Expect(spec.Messages[0]).To(Equal(api.Message{Role: api.RoleSystem, Parts: []api.Part{{ + Type: api.PartText, Text: "Use application tools.\n\nCurrent UI context:\ninvoice editor", + }}})) + Expect(spec.Messages[1].Parts).To(HaveLen(2)) + Expect(spec.Messages[1].Parts[1].Attachment).NotTo(BeNil()) + Expect(spec.Messages[1].Parts[1].Attachment.IsPrepared()).To(BeTrue()) + Expect(resolver.configs).To(HaveLen(1)) + Expect(resolver.configs[0].Tools).To(HaveLen(1)) + Expect(resolver.configs[0].APIURL).To(Equal("https://example.com/ai")) + Expect(resolver.configs[0].ProjectName).To(Equal("tenant-x")) + }) + + It("passes a durable approval resume without rebuilding conversation messages", func() { + state := api.ToolApprovalState{ + Messages: []api.Message{ + {Role: api.RoleUser, Parts: []api.Part{{Type: api.PartText, Text: "pay"}}}, + {Role: api.RoleAssistant, Parts: []api.Part{{Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ + ToolCallID: "call-1", Name: "invoice_pay", Input: json.RawMessage(`{"id":"inv-1"}`), + }}}}, + }, + Calls: []api.ToolApprovalCall{{Request: api.ToolApprovalRequest{ + ToolCallID: "call-1", Tool: "invoice_pay", Input: json.RawMessage(`{"id":"inv-1"}`), + }}}, + } + resume := &api.ToolApprovalResume{State: state, Decisions: []api.ToolApprovalDecision{{ + ToolCallID: "call-1", Tool: "invoice_pay", Action: api.ToolApprovalApprove, + }}} + provider := &fakeStreamingProvider{events: []api.Event{{Kind: api.EventResult, Success: true}}} + service := aichat.NewService(aichat.ServiceOptions{Resolver: &fakeResolver{provider: provider}}) + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + Model: "openai/test-model", ToolApproval: resume, + })) + + Expect(response.Code).To(Equal(http.StatusOK)) + Expect(provider.specs).To(HaveLen(1)) + Expect(provider.specs[0].ToolApproval).To(Equal(resume)) + Expect(provider.specs[0].Messages).To(BeNil()) + }) + + It("serves thread CRUD through the injected persistence store", func() { + service := aichat.NewService(aichat.ServiceOptions{Resolver: &fakeResolver{}, Threads: aichat.NewMemoryThreadStore()}) + create := httptest.NewRecorder() + service.Handler().ServeHTTP(create, requestJSON(http.MethodPost, "/api/chat/threads", map[string]string{"title": "Review"})) + Expect(create.Code).To(Equal(http.StatusCreated)) + var thread aichat.Thread + Expect(json.Unmarshal(create.Body.Bytes(), &thread)).To(Succeed()) + + get := httptest.NewRecorder() + service.Handler().ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/chat/threads/"+thread.ID, nil)) + Expect(get.Code).To(Equal(http.StatusOK)) + + remove := httptest.NewRecorder() + service.Handler().ServeHTTP(remove, httptest.NewRequest(http.MethodDelete, "/api/chat/threads/"+thread.ID, nil)) + Expect(remove.Code).To(Equal(http.StatusNoContent)) + }) + + It("persists the completed assistant event stream with tool results and metadata", func() { + store := aichat.NewMemoryThreadStore() + thread, err := store.Create(context.Background(), "Review") + Expect(err).NotTo(HaveOccurred()) + usage := &api.Usage{InputTokens: 12, OutputTokens: 4} + provider := &fakeStreamingProvider{events: []api.Event{ + {Kind: api.EventText, Text: "checking"}, + {Kind: api.EventToolUse, Tool: "invoice_get", ToolCallID: "call-1", Input: map[string]any{"id": "inv-1"}}, + {Kind: api.EventToolResult, Tool: "invoice_get", ToolCallID: "call-1", Text: `{"status":"draft"}`, Success: true}, + {Kind: api.EventResult, Success: true, SessionID: "session-1", Model: "test-model", Usage: usage, CostUSD: 0.25}, + }} + service := aichat.NewService(aichat.ServiceOptions{Resolver: &fakeResolver{provider: provider}, Threads: store}) + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + ID: "chat-1", ThreadID: thread.ID, Model: "openai/test-model", + Messages: []aichat.UIMessage{{Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "inspect"}}}}, + })) + Expect(response.Code).To(Equal(http.StatusOK)) + + stored, err := store.Get(context.Background(), thread.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(stored.Messages).To(HaveLen(2)) + assistant := stored.Messages[1] + Expect(assistant.Role).To(Equal("assistant")) + Expect(assistant.Parts).To(HaveLen(3)) + Expect(assistant.Parts[0].Type).To(Equal("text")) + Expect(assistant.Parts[0].Text).To(Equal("checking")) + Expect(assistant.Parts[1].Type).To(Equal("dynamic-tool")) + Expect(assistant.Parts[1].State).To(Equal("output-available")) + Expect(assistant.Parts[1].ToolCallID).To(Equal("call-1")) + Expect(assistant.Parts[1].Output).To(MatchJSON(`{"status":"draft"}`)) + Expect(assistant.Parts[2].Type).To(Equal("data-result")) + Expect(assistant.Metadata).NotTo(BeNil()) + Expect(assistant.Metadata.ProviderSessionID).To(Equal("session-1")) + Expect(stored.ProviderSessionID).To(Equal("session-1")) + Expect(stored.TotalInputTokens).To(Equal(12)) + Expect(stored.TotalCostUSD).To(Equal(0.25)) + }) + + It("cancels the provider and persistence forwarder when the SSE consumer stops", func() { + store := aichat.NewMemoryThreadStore() + thread, err := store.Create(context.Background(), "Cancellation") + Expect(err).NotTo(HaveOccurred()) + exited := make(chan struct{}) + provider := &fakeStreamingProvider{} + provider.execute = func(ctx context.Context, _ api.Spec) (<-chan api.Event, error) { + events := make(chan api.Event) + go func() { + defer close(exited) + defer close(events) + for _, event := range []api.Event{ + {Kind: api.EventToolUse, Tool: "invoice_get", ToolCallID: "call-1"}, + {Kind: api.EventToolUse, Tool: "invoice_get", ToolCallID: "call-1"}, + {Kind: api.EventText, Text: "must not block"}, + } { + select { + case events <- event: + case <-ctx.Done(): + return + } + } + }() + return events, nil + } + service := aichat.NewService(aichat.ServiceOptions{Resolver: &fakeResolver{provider: provider}, Threads: store}) + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + ThreadID: thread.ID, Model: "openai/test-model", + Messages: []aichat.UIMessage{{Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "inspect"}}}}, + })) + Eventually(exited).Should(BeClosed()) + Expect(response.Body.String()).To(ContainSubstring("persist duplicate tool call")) + }) +}) diff --git a/pkg/aichat/sse.go b/pkg/aichat/sse.go new file mode 100644 index 0000000..5f9c29f --- /dev/null +++ b/pkg/aichat/sse.go @@ -0,0 +1,102 @@ +package aichat + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// Part is one AI SDK v6 UI Message Stream chunk. +type Part struct { + Type string `json:"type"` + + MessageID string `json:"messageId,omitempty"` + ID string `json:"id,omitempty"` + Delta string `json:"delta,omitempty"` + + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + Input any `json:"input,omitempty"` + Output any `json:"output,omitempty"` + Dynamic bool `json:"dynamic,omitempty"` + ApprovalID string `json:"approvalId,omitempty"` + + Data any `json:"data,omitempty"` + ErrorText string `json:"errorText,omitempty"` + MessageMetadata *MessageMetadata `json:"messageMetadata,omitempty"` +} + +// UsageMetadata is Captain usage normalized for frontend message metadata. +type UsageMetadata struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + ReasoningTokens int `json:"reasoningTokens"` + CacheReadTokens int `json:"cacheReadTokens"` + CacheWriteTokens int `json:"cacheWriteTokens"` + TotalTokens int `json:"totalTokens"` +} + +// MessageMetadata is attached to the assistant UIMessage by the finish part. +type MessageMetadata struct { + ProviderSessionID string `json:"providerSessionId,omitempty"` + Model string `json:"model,omitempty"` + Usage *UsageMetadata `json:"usage,omitempty"` + Cost float64 `json:"cost,omitempty"` + ContextTokens int `json:"contextTokens,omitempty"` + Success *bool `json:"success,omitempty"` +} + +// SSEWriter writes AI SDK v6 chunks using Server-Sent Events framing. +type SSEWriter struct { + w http.ResponseWriter + flusher http.Flusher + done bool +} + +// NewSSEWriter initializes a flushable HTTP response for the UI Message Stream protocol. +func NewSSEWriter(w http.ResponseWriter) (*SSEWriter, error) { + flusher, ok := w.(http.Flusher) + if !ok { + return nil, fmt.Errorf("response writer does not support flushing") + } + headers := w.Header() + headers.Set("Content-Type", "text/event-stream") + headers.Set("x-vercel-ai-ui-message-stream", "v1") + headers.Set("Cache-Control", "no-cache") + headers.Set("Connection", "keep-alive") + headers.Set("x-accel-buffering", "no") + w.WriteHeader(http.StatusOK) + return &SSEWriter{w: w, flusher: flusher}, nil +} + +// WritePart writes and flushes one JSON chunk. +func (w *SSEWriter) WritePart(part Part) error { + if w.done { + return fmt.Errorf("AI SDK stream is already done") + } + if part.Type == "" { + return fmt.Errorf("AI SDK stream part type is required") + } + payload, err := json.Marshal(part) + if err != nil { + return fmt.Errorf("marshal AI SDK stream part %q: %w", part.Type, err) + } + if _, err := fmt.Fprintf(w.w, "data: %s\n\n", payload); err != nil { + return fmt.Errorf("write AI SDK stream part %q: %w", part.Type, err) + } + w.flusher.Flush() + return nil +} + +// Done writes and flushes the literal stream terminator. +func (w *SSEWriter) Done() error { + if w.done { + return fmt.Errorf("AI SDK stream is already done") + } + w.done = true + if _, err := fmt.Fprint(w.w, "data: [DONE]\n\n"); err != nil { + return fmt.Errorf("write AI SDK stream terminator: %w", err) + } + w.flusher.Flush() + return nil +} diff --git a/pkg/aichat/stream_ginkgo_test.go b/pkg/aichat/stream_ginkgo_test.go new file mode 100644 index 0000000..b5819df --- /dev/null +++ b/pkg/aichat/stream_ginkgo_test.go @@ -0,0 +1,265 @@ +package aichat_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/aichat" + "github.com/flanksource/captain/pkg/api" +) + +type flushRecorder struct { + *httptest.ResponseRecorder + flushes int +} + +func (r *flushRecorder) Flush() { r.flushes++ } + +type nonFlushWriter struct { + header http.Header +} + +func (w *nonFlushWriter) Header() http.Header { return w.header } +func (w *nonFlushWriter) Write([]byte) (int, error) { return 0, nil } +func (w *nonFlushWriter) WriteHeader(statusCode int) {} + +func recordEvents(events ...api.Event) (*flushRecorder, error) { + recorder := &flushRecorder{ResponseRecorder: httptest.NewRecorder()} + writer, err := aichat.NewSSEWriter(recorder) + if err != nil { + return recorder, err + } + channel := make(chan api.Event, len(events)) + for _, event := range events { + channel <- event + } + close(channel) + return recorder, aichat.WriteEventStream(writer, channel) +} + +func decodedDataLines(body string) []map[string]any { + parts := []map[string]any{} + for line := range strings.SplitSeq(body, "\n") { + if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + continue + } + var part map[string]any + Expect(json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &part)).To(Succeed()) + parts = append(parts, part) + } + return parts +} + +func partTypes(parts []map[string]any) []string { + types := make([]string, len(parts)) + for i, part := range parts { + types[i], _ = part["type"].(string) + } + return types +} + +func pendingApprovalState(calls ...api.ToolApprovalRequest) *api.ToolApprovalState { + parts := make([]api.Part, len(calls)) + stateCalls := make([]api.ToolApprovalCall, len(calls)) + for i, call := range calls { + parts[i] = api.Part{Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ + ToolCallID: call.ToolCallID, Name: call.Tool, Input: call.Input, + }} + stateCalls[i] = api.ToolApprovalCall{Request: call} + } + return &api.ToolApprovalState{ + Messages: []api.Message{ + {Role: api.RoleUser, Parts: []api.Part{{Type: api.PartText, Text: "update"}}}, + {Role: api.RoleAssistant, Parts: parts}, + }, + Calls: stateCalls, + } +} + +var _ = Describe("AI SDK v6 event stream", func() { + It("rejects a response writer that cannot stream", func() { + _, err := aichat.NewSSEWriter(&nonFlushWriter{header: http.Header{}}) + Expect(err).To(MatchError("response writer does not support flushing")) + }) + + It("sets the protocol headers and flushes every frame", func() { + recorder, err := recordEvents(api.Event{Kind: api.EventResult, Success: true}) + Expect(err).NotTo(HaveOccurred()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + Expect(recorder.Header().Get("Content-Type")).To(Equal("text/event-stream")) + Expect(recorder.Header().Get("x-vercel-ai-ui-message-stream")).To(Equal("v1")) + Expect(recorder.Header().Get("Cache-Control")).To(Equal("no-cache")) + Expect(recorder.Header().Get("x-accel-buffering")).To(Equal("no")) + Expect(recorder.flushes).To(Equal(6)) + Expect(recorder.Body.String()).To(HaveSuffix("data: [DONE]\n\n")) + }) + + It("keeps every text, reasoning, tool, approval, result, and finish part ordered", func() { + usage := &api.Usage{InputTokens: 100, OutputTokens: 40, ReasoningTokens: 10, CacheReadTokens: 5} + recorder, err := recordEvents( + api.Event{Kind: api.EventSystem, SessionID: "session-1", Model: "claude-sonnet"}, + api.Event{Kind: api.EventThinking, Text: "check"}, + api.Event{Kind: api.EventThinking, Text: "ing"}, + api.Event{Kind: api.EventText, Text: "I will inspect."}, + api.Event{Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_get", Input: map[string]any{"id": "inv-1"}}, + api.Event{Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_get"}, + api.Event{Kind: api.EventToolResult, ToolCallID: "call-1", Tool: "invoice_get", Text: `{"status":"draft"}`, Success: true}, + api.Event{Kind: api.EventText, Text: "It is a draft."}, + api.Event{Kind: api.EventResult, SessionID: "session-1", Model: "claude-sonnet", Usage: usage, CostUSD: 0.0125, Success: true, StructuredData: json.RawMessage(`{"invoiceId":"inv-1"}`)}, + ) + Expect(err).NotTo(HaveOccurred()) + parts := decodedDataLines(recorder.Body.String()) + Expect(partTypes(parts)).To(Equal([]string{ + "start", "start-step", + "reasoning-start", "reasoning-delta", "reasoning-delta", "reasoning-end", + "text-start", "text-delta", "text-end", + "tool-input-available", "tool-approval-request", "tool-output-available", + "text-start", "text-delta", "text-end", + "data-result", "finish-step", "finish", + })) + Expect(parts[2]).To(HaveKeyWithValue("id", "reasoning-0")) + Expect(parts[6]).To(HaveKeyWithValue("id", "text-1")) + Expect(parts[9]).To(SatisfyAll( + HaveKeyWithValue("toolCallId", "call-1"), + HaveKeyWithValue("toolName", "invoice_get"), + HaveKeyWithValue("dynamic", true), + )) + Expect(parts[10]).To(SatisfyAll( + HaveKeyWithValue("approvalId", "call-1"), + HaveKeyWithValue("toolCallId", "call-1"), + )) + Expect(parts[11]["output"]).To(Equal(map[string]any{"output": `{"status":"draft"}`})) + Expect(parts[15]["data"]).To(Equal(map[string]any{"invoiceId": "inv-1"})) + Expect(parts[17]["messageMetadata"]).To(Equal(map[string]any{ + "providerSessionId": "session-1", + "model": "claude-sonnet", + "usage": map[string]any{ + "inputTokens": 100.0, "outputTokens": 40.0, "reasoningTokens": 10.0, + "cacheReadTokens": 5.0, "cacheWriteTokens": 0.0, "totalTokens": 155.0, + }, + "cost": 0.0125, + "contextTokens": 100.0, + "success": true, + })) + }) + + It("turns a Captain error event into a closed, valid UI stream", func() { + recorder, err := recordEvents( + api.Event{Kind: api.EventText, Text: "partial"}, + api.Event{Kind: api.EventError, Error: "provider disconnected"}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(partTypes(decodedDataLines(recorder.Body.String()))).To(Equal([]string{ + "start", "start-step", "text-start", "text-delta", "text-end", + "error", "finish-step", "finish", + })) + Expect(decodedDataLines(recorder.Body.String())[5]).To(HaveKeyWithValue("errorText", "provider disconnected")) + Expect(recorder.Body.String()).To(HaveSuffix("data: [DONE]\n\n")) + }) + + It("finishes a suspended turn with its approval card still pending", func() { + recorder, err := recordEvents( + api.Event{Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_update", Input: map[string]any{"id": "inv-1"}}, + api.Event{Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_update"}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(partTypes(decodedDataLines(recorder.Body.String()))).To(Equal([]string{ + "start", "start-step", "tool-input-available", "tool-approval-request", "finish-step", "finish", + })) + }) + + It("carries durable Captain approval state across the AI SDK boundary", func() { + approval := pendingApprovalState(api.ToolApprovalRequest{ + ToolCallID: "call-1", Tool: "invoice_update", Input: json.RawMessage(`{"id":"inv-1"}`), + }) + recorder, err := recordEvents( + api.Event{Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_update", Input: map[string]any{"id": "inv-1"}}, + api.Event{Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_update"}, + api.Event{Kind: api.EventResult, Success: true, ToolApproval: approval}, + ) + Expect(err).NotTo(HaveOccurred()) + parts := decodedDataLines(recorder.Body.String()) + Expect(partTypes(parts)).To(Equal([]string{ + "start", "start-step", "tool-input-available", "tool-approval-request", + "data-tool-approval", "finish-step", "finish", + })) + Expect(parts[4]["data"]).To(HaveKeyWithValue("calls", HaveLen(1))) + }) + + DescribeTable("rejects approval state that does not match the streamed pending tools", + func(state *api.ToolApprovalState, message string) { + recorder, err := recordEvents( + api.Event{Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_update", Input: map[string]any{"id": "inv-1"}}, + api.Event{Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_update"}, + api.Event{Kind: api.EventResult, Success: true, ToolApproval: state}, + ) + Expect(err).To(MatchError(message)) + Expect(partTypes(decodedDataLines(recorder.Body.String()))).To(ContainElement("error")) + }, + Entry("different call ID", pendingApprovalState(api.ToolApprovalRequest{ + ToolCallID: "call-2", Tool: "invoice_update", Input: json.RawMessage(`{"id":"inv-1"}`), + }), `approval state call "call-2" has no matching streamed approval request`), + Entry("different tool name", pendingApprovalState(api.ToolApprovalRequest{ + ToolCallID: "call-1", Tool: "invoice_delete", Input: json.RawMessage(`{"id":"inv-1"}`), + }), `approval state call "call-1" names "invoice_delete", want "invoice_update"`), + Entry("different tool input", pendingApprovalState(api.ToolApprovalRequest{ + ToolCallID: "call-1", Tool: "invoice_update", Input: json.RawMessage(`{"id":"inv-2"}`), + }), `approval state call "call-1" input does not match the streamed tool request`), + Entry("extra pending call", pendingApprovalState( + api.ToolApprovalRequest{ToolCallID: "call-1", Tool: "invoice_update", Input: json.RawMessage(`{"id":"inv-1"}`)}, + api.ToolApprovalRequest{ToolCallID: "call-2", Tool: "invoice_delete", Input: json.RawMessage(`{"id":"inv-2"}`)}, + ), `approval state call "call-2" has no matching streamed approval request`), + ) + + It("rejects an approval state missing a streamed pending tool", func() { + state := pendingApprovalState(api.ToolApprovalRequest{ + ToolCallID: "call-1", Tool: "invoice_update", Input: json.RawMessage(`{"id":"inv-1"}`), + }) + _, err := recordEvents( + api.Event{Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_update", Input: map[string]any{"id": "inv-1"}}, + api.Event{Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_update"}, + api.Event{Kind: api.EventToolUse, ToolCallID: "call-2", Tool: "invoice_delete", Input: map[string]any{"id": "inv-2"}}, + api.Event{Kind: api.EventPermission, ToolCallID: "call-2", Tool: "invoice_delete"}, + api.Event{Kind: api.EventResult, Success: true, ToolApproval: state}, + ) + Expect(err).To(MatchError(`streamed approval request "call-2" is absent from the approval state`)) + }) + + DescribeTable("fails loud and emits an error part for malformed correlation", + func(events []api.Event, message string) { + recorder, err := recordEvents(events...) + Expect(err).To(MatchError(message)) + parts := decodedDataLines(recorder.Body.String()) + Expect(partTypes(parts)).To(ContainElement("error")) + Expect(parts[len(parts)-1]).To(HaveKeyWithValue("type", "finish")) + Expect(recorder.Body.String()).To(HaveSuffix("data: [DONE]\n\n")) + }, + Entry("missing tool call id", []api.Event{{Kind: api.EventToolUse, Tool: "invoice_get"}}, `tool use "invoice_get" has no tool call id`), + Entry("duplicate tool call", []api.Event{ + {Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_get"}, + {Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_get"}, + }, `duplicate tool call id "call-1"`), + Entry("orphan permission", []api.Event{{Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_get"}}, `permission for tool call "call-1" has no matching tool use`), + Entry("orphan result", []api.Event{{Kind: api.EventToolResult, ToolCallID: "call-1", Tool: "invoice_get"}}, `result for tool call "call-1" has no matching tool use`), + Entry("duplicate permission", []api.Event{ + {Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_update"}, + {Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_update"}, + {Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_update"}, + }, `duplicate permission for tool call "call-1"`), + Entry("mismatched tool name", []api.Event{ + {Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_get"}, + {Kind: api.EventToolResult, ToolCallID: "call-1", Tool: "invoice_delete"}, + }, `result for tool call "call-1" names "invoice_delete", want "invoice_get"`), + Entry("terminal result while approval is unresolved", []api.Event{ + {Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_update"}, + {Kind: api.EventPermission, ToolCallID: "call-1", Tool: "invoice_update"}, + {Kind: api.EventResult, Success: true}, + }, `tool call "call-1" ended without a result`), + Entry("dangling tool", []api.Event{{Kind: api.EventToolUse, ToolCallID: "call-1", Tool: "invoice_get"}}, `tool call "call-1" ended without a result or approval request`), + ) +}) diff --git a/pkg/aichat/threads.go b/pkg/aichat/threads.go new file mode 100644 index 0000000..c802106 --- /dev/null +++ b/pkg/aichat/threads.go @@ -0,0 +1,154 @@ +package aichat + +import ( + "context" + "fmt" + "sort" + "sync" + "time" +) + +type Thread struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Messages []UIMessage `json:"messages"` + + TotalInputTokens int `json:"totalInputTokens"` + TotalOutputTokens int `json:"totalOutputTokens"` + TotalReasoningTokens int `json:"totalReasoningTokens"` + TotalCacheReadTokens int `json:"totalCacheReadTokens"` + TotalCacheWriteTokens int `json:"totalCacheWriteTokens"` + TotalCostUSD float64 `json:"totalCostUsd"` + LastContextTokens int `json:"lastContextTokens"` + ProviderSessionID string `json:"providerSessionId,omitempty"` +} + +type TurnUsage struct { + InputTokens int + OutputTokens int + ReasoningTokens int + CacheReadTokens int + CacheWriteTokens int + CostUSD float64 +} + +// ThreadStore is the persistence boundary for chat history, provider session +// identity, and cumulative usage. Implementations must be concurrency-safe. +type ThreadStore interface { + Create(context.Context, string) (*Thread, error) + List(context.Context) ([]*Thread, error) + Get(context.Context, string) (*Thread, error) + AppendMessage(context.Context, string, UIMessage) error + Delete(context.Context, string) error + SetProviderSession(context.Context, string, string) error + AddUsage(context.Context, string, TurnUsage) (*Thread, error) +} + +type memoryThreadStore struct { + mu sync.Mutex + seq int + threads map[string]*Thread +} + +func NewMemoryThreadStore() ThreadStore { + return &memoryThreadStore{threads: map[string]*Thread{}} +} + +func (s *memoryThreadStore) Create(_ context.Context, title string) (*Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.seq++ + now := time.Now() + thread := &Thread{ID: fmt.Sprintf("thread-%d", s.seq), Title: title, CreatedAt: now, UpdatedAt: now, Messages: []UIMessage{}} + s.threads[thread.ID] = thread + return cloneThread(thread), nil +} + +func (s *memoryThreadStore) List(context.Context) ([]*Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + threads := make([]*Thread, 0, len(s.threads)) + for _, thread := range s.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 *memoryThreadStore) Get(_ context.Context, id string) (*Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + thread, ok := s.threads[id] + if !ok { + return nil, fmt.Errorf("thread %q not found", id) + } + return cloneThread(thread), nil +} + +func (s *memoryThreadStore) AppendMessage(_ context.Context, id string, message UIMessage) error { + s.mu.Lock() + defer s.mu.Unlock() + thread, err := s.thread(id) + if err != nil { + return err + } + thread.Messages = append(thread.Messages, message) + thread.UpdatedAt = time.Now() + return nil +} + +func (s *memoryThreadStore) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.threads[id]; !ok { + return fmt.Errorf("thread %q not found", id) + } + delete(s.threads, id) + return nil +} + +func (s *memoryThreadStore) SetProviderSession(_ context.Context, id, sessionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + thread, err := s.thread(id) + if err != nil { + return err + } + thread.ProviderSessionID = sessionID + thread.UpdatedAt = time.Now() + return nil +} + +func (s *memoryThreadStore) AddUsage(_ context.Context, id string, usage TurnUsage) (*Thread, error) { + s.mu.Lock() + defer s.mu.Unlock() + thread, err := s.thread(id) + if err != nil { + return nil, err + } + thread.TotalInputTokens += usage.InputTokens + thread.TotalOutputTokens += usage.OutputTokens + thread.TotalReasoningTokens += usage.ReasoningTokens + thread.TotalCacheReadTokens += usage.CacheReadTokens + thread.TotalCacheWriteTokens += usage.CacheWriteTokens + thread.TotalCostUSD += usage.CostUSD + thread.LastContextTokens = usage.InputTokens + thread.UpdatedAt = time.Now() + return cloneThread(thread), nil +} + +func (s *memoryThreadStore) thread(id string) (*Thread, error) { + thread, ok := s.threads[id] + if !ok { + return nil, fmt.Errorf("thread %q not found", id) + } + return thread, nil +} + +func cloneThread(thread *Thread) *Thread { + copy := *thread + copy.Messages = append([]UIMessage(nil), thread.Messages...) + return © +} diff --git a/pkg/aichat/threads_http.go b/pkg/aichat/threads_http.go new file mode 100644 index 0000000..9840cf1 --- /dev/null +++ b/pkg/aichat/threads_http.go @@ -0,0 +1,106 @@ +package aichat + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +func (s *Service) registerThreadRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/chat/threads", s.handleCreateThread) + mux.HandleFunc("GET /api/chat/threads", s.handleListThreads) + mux.HandleFunc("GET /api/chat/threads/{id}", s.handleGetThread) + mux.HandleFunc("DELETE /api/chat/threads/{id}", s.handleDeleteThread) +} + +func (s *Service) threadStore(w http.ResponseWriter) ThreadStore { + if s.options.Threads == nil { + http.Error(w, "thread persistence is not configured", http.StatusNotImplemented) + return nil + } + return s.options.Threads +} + +func (s *Service) handleCreateThread(w http.ResponseWriter, request *http.Request) { + store := s.threadStore(w) + if store == nil { + return + } + body := struct { + Title string `json:"title"` + }{} + if request.Body != nil { + if err := json.NewDecoder(request.Body).Decode(&body); err != nil && err != io.EOF { + http.Error(w, fmt.Sprintf("invalid thread request: %v", err), http.StatusBadRequest) + return + } + } + if body.Title == "" { + body.Title = "New conversation" + } + thread, err := store.Create(request.Context(), body.Title) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := writeJSON(w, http.StatusCreated, thread); err != nil { + serviceLog.Errorf("write created chat thread: %v", err) + } +} + +func (s *Service) handleListThreads(w http.ResponseWriter, request *http.Request) { + store := s.threadStore(w) + if store == nil { + return + } + threads, err := store.List(request.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := writeJSON(w, http.StatusOK, threads); err != nil { + serviceLog.Errorf("write chat thread list: %v", err) + } +} + +func (s *Service) handleGetThread(w http.ResponseWriter, request *http.Request) { + store := s.threadStore(w) + if store == nil { + return + } + thread, err := store.Get(request.Context(), request.PathValue("id")) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + if err := writeJSON(w, http.StatusOK, thread); err != nil { + serviceLog.Errorf("write chat thread %q: %v", request.PathValue("id"), err) + } +} + +func (s *Service) handleDeleteThread(w http.ResponseWriter, request *http.Request) { + store := s.threadStore(w) + if store == nil { + return + } + if err := store.Delete(request.Context(), request.PathValue("id")); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func writeJSON(w http.ResponseWriter, status int, value any) error { + payload, err := json.Marshal(value) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write(append(payload, '\n')); err != nil { + return fmt.Errorf("write JSON response: %w", err) + } + return nil +} diff --git a/pkg/aichat/tools.go b/pkg/aichat/tools.go new file mode 100644 index 0000000..f25cba1 --- /dev/null +++ b/pkg/aichat/tools.go @@ -0,0 +1,120 @@ +package aichat + +import ( + "context" + "fmt" + + aitools "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" +) + +// ToolSet couples executable Captain definitions with their optional canonical +// frontend catalog rows. When Catalog is empty it is projected from Definitions. +type ToolSet struct { + Definitions []api.ToolDefinition + Catalog []aitools.ToolCatalogEntry +} + +// ToolProvider loads an application or MCP tool set for the current request. +type ToolProvider interface { + ToolSet(context.Context) (ToolSet, error) +} + +type ToolProviderFunc func(context.Context) (ToolSet, error) + +func (f ToolProviderFunc) ToolSet(ctx context.Context) (ToolSet, error) { return f(ctx) } + +func StaticToolProvider(definitions []api.ToolDefinition) ToolProvider { + return ToolProviderFunc(func(context.Context) (ToolSet, error) { + return ToolSet{Definitions: append([]api.ToolDefinition(nil), definitions...)}, nil + }) +} + +func CombineToolProviders(providers ...ToolProvider) ToolProvider { + return ToolProviderFunc(func(ctx context.Context) (ToolSet, error) { + combined := ToolSet{} + seenDefinitions := map[string]bool{} + seenCatalog := map[string]bool{} + for i, provider := range providers { + if provider == nil { + continue + } + set, err := provider.ToolSet(ctx) + if err != nil { + return ToolSet{}, fmt.Errorf("load tool provider %d: %w", i+1, err) + } + if err := appendToolSet(&combined, set, "application", seenDefinitions, seenCatalog); err != nil { + return ToolSet{}, err + } + } + return combined, nil + }) +} + +func (s *Service) loadTools(ctx context.Context) (ToolSet, error) { + combined := ToolSet{} + seenDefinitions := map[string]bool{} + seenCatalog := map[string]bool{} + for _, source := range []struct { + name string + provider ToolProvider + }{{name: "custom", provider: s.options.Tools}, {name: "mcp", provider: s.options.MCP}} { + if source.provider == nil { + continue + } + set, err := source.provider.ToolSet(ctx) + if err != nil { + return ToolSet{}, fmt.Errorf("load %s tools: %w", source.name, err) + } + if err := appendToolSet(&combined, set, source.name, seenDefinitions, seenCatalog); err != nil { + return ToolSet{}, err + } + } + return combined, nil +} + +func appendToolSet(combined *ToolSet, set ToolSet, source string, seenDefinitions, seenCatalog map[string]bool) error { + for _, definition := range set.Definitions { + if definition.Name == "" { + return fmt.Errorf("%s tool name is required", source) + } + if definition.Handler == nil { + return fmt.Errorf("%s tool %q handler is required", source, definition.Name) + } + if seenDefinitions[definition.Name] { + return fmt.Errorf("duplicate tool definition %q", definition.Name) + } + seenDefinitions[definition.Name] = true + combined.Definitions = append(combined.Definitions, definition) + } + catalog := set.Catalog + if len(catalog) == 0 { + catalog = make([]aitools.ToolCatalogEntry, 0, len(set.Definitions)) + for _, definition := range set.Definitions { + entry := aitools.CustomCatalogEntry(toolDefinitionForCatalog(definition), definition.Name, definition.InputSchema) + entry.Source = source + catalog = append(catalog, entry) + } + } + for _, entry := range catalog { + if entry.Name == "" { + return fmt.Errorf("%s tool catalog name is required", source) + } + if seenCatalog[entry.Name] { + return fmt.Errorf("duplicate tool catalog entry %q", entry.Name) + } + seenCatalog[entry.Name] = true + combined.Catalog = append(combined.Catalog, entry) + } + return nil +} + +func toolDefinitionForCatalog(definition api.ToolDefinition) aitools.ToolDefinition { + return aitools.ToolDefinition{ + Name: definition.Name, Description: definition.Description, InputSchema: definition.InputSchema, + Group: definition.Group, Parent: definition.Parent, Icon: definition.Icon, + DefaultPermission: definition.DefaultPermission, Strict: definition.Strict, + ReadOnlyHint: definition.ReadOnlyHint, DestructiveHint: definition.DestructiveHint, + IdempotentHint: definition.IdempotentHint, Annotations: definition.Annotations, + } +} diff --git a/pkg/aichat/wire.go b/pkg/aichat/wire.go new file mode 100644 index 0000000..42f7f6c --- /dev/null +++ b/pkg/aichat/wire.go @@ -0,0 +1,103 @@ +// Package aichat implements the transport-facing AI SDK v6 chat protocol. +package aichat + +import ( + "encoding/json" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" +) + +// ChatRequest is the body posted by the AI SDK DefaultChatTransport. +type ChatRequest struct { + ID string `json:"id,omitempty"` + Messages []UIMessage `json:"messages"` + Model string `json:"model,omitempty"` + ReasoningEffort api.Effort `json:"reasoningEffort,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Budget api.Budget `json:"budget,omitempty"` + ToolPreferences api.ToolPreferences `json:"toolPreferences,omitempty"` + PermissionMode api.PermissionMode `json:"permissionMode,omitempty"` + ToolApproval *api.ToolApprovalResume `json:"toolApproval,omitempty"` + + Context string `json:"context,omitempty"` + ContextItems []ChatContextItem `json:"contextItems,omitempty"` + + ThreadID string `json:"threadId,omitempty"` + ProviderSessionID string `json:"providerSessionId,omitempty"` +} + +// ChatContextItem carries app-owned structured state alongside its readable label. +type ChatContextItem struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + Fields map[string]string `json:"fields,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// UIMessage is the AI SDK v6 message wire shape. +type UIMessage struct { + ID string `json:"id,omitempty"` + Role string `json:"role"` + Parts []UIPart `json:"parts"` + Metadata *MessageMetadata `json:"metadata,omitempty"` +} + +// UIPart models the input part variants Captain consumes. +type UIPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + + MediaType string `json:"mediaType,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + AttachmentID string `json:"attachmentId,omitempty"` + + ToolName string `json:"toolName,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + State string `json:"state,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + ErrorText string `json:"errorText,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Approval *Approval `json:"approval,omitempty"` +} + +// Approval is the AI SDK tool approval envelope attached to a tool UI part. +type Approval struct { + ID string `json:"id"` + Approved *bool `json:"approved,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// IsTool reports whether the part is a static or dynamic tool part. +func (p UIPart) IsTool() bool { + return p.Type == "dynamic-tool" || strings.HasPrefix(p.Type, "tool-") +} + +// EffectiveToolName returns a dynamic tool's explicit name or a static tool's type suffix. +func (p UIPart) EffectiveToolName() string { + if p.ToolName != "" { + return p.ToolName + } + name, ok := strings.CutPrefix(p.Type, "tool-") + if !ok { + return "" + } + return name +} + +// ModelCatalogResponse reuses Captain's canonical frontend model catalog rows. +type ModelCatalogResponse = []ai.ModelInfo + +// ModelCatalogEntry is Captain's canonical frontend model catalog row. +type ModelCatalogEntry = ai.ModelInfo + +// ToolCatalogResponse reuses Captain's canonical frontend tool catalog. +type ToolCatalogResponse = tools.ToolCatalog + +// ToolCatalogEntry is Captain's canonical frontend tool catalog row. +type ToolCatalogEntry = tools.ToolCatalogEntry diff --git a/pkg/aichat/wire_ginkgo_test.go b/pkg/aichat/wire_ginkgo_test.go new file mode 100644 index 0000000..b7a6090 --- /dev/null +++ b/pkg/aichat/wire_ginkgo_test.go @@ -0,0 +1,93 @@ +package aichat_test + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/aichat" + "github.com/flanksource/captain/pkg/api" +) + +var _ = Describe("AI SDK v6 wire types", func() { + It("decodes the DefaultChatTransport request without losing UI parts", func() { + const body = `{ + "id":"chat-1", + "messages":[{"id":"message-1","role":"assistant","parts":[ + {"type":"reasoning","text":"checking"}, + {"type":"dynamic-tool","toolName":"invoice_get","toolCallId":"call-1","state":"approval-responded","input":{"id":"inv-1"},"approval":{"id":"approval-1","approved":true}}, + {"type":"file","mediaType":"application/pdf","url":"https://example.com/invoice.pdf","filename":"invoice.pdf","attachmentId":"att_sha256_abc"} + ]}], + "model":"anthropic/claude-sonnet", + "reasoningEffort":"high", + "temperature":0, + "budget":{"cost":1.5,"maxTokens":2048,"maxTurns":4}, + "toolPreferences":{"billing":"ask","invoice_get":"on"}, + "context":"invoice editor", + "contextItems":[{"id":"inv-1","type":"invoice","label":"Invoice 1","fields":{"status":"draft"},"payload":{"total":25}}], + "threadId":"thread-1", + "providerSessionId":"session-1", + "permissionMode":"acceptEdits" + }` + + var request aichat.ChatRequest + Expect(json.Unmarshal([]byte(body), &request)).To(Succeed()) + Expect(request.ID).To(Equal("chat-1")) + Expect(request.Model).To(Equal("anthropic/claude-sonnet")) + Expect(request.ReasoningEffort).To(Equal(api.EffortHigh)) + Expect(request.Temperature).NotTo(BeNil()) + Expect(*request.Temperature).To(Equal(0.0)) + Expect(request.Budget).To(Equal(api.Budget{Cost: 1.5, MaxTokens: 2048, MaxTurns: 4})) + Expect(request.ToolPreferences).To(Equal(api.ToolPreferences{ + "billing": api.ToolModeAsk, + "invoice_get": api.ToolModeOn, + })) + Expect(request.PermissionMode).To(Equal(api.PermissionAcceptEdits)) + Expect(request.ToolApproval).To(BeNil()) + Expect(request.ThreadID).To(Equal("thread-1")) + Expect(request.ProviderSessionID).To(Equal("session-1")) + Expect(request.ContextItems).To(HaveLen(1)) + Expect(request.ContextItems[0].Payload).To(MatchJSON(`{"total":25}`)) + + Expect(request.Messages).To(HaveLen(1)) + Expect(request.Messages[0].Parts).To(HaveLen(3)) + tool := request.Messages[0].Parts[1] + Expect(tool.IsTool()).To(BeTrue()) + Expect(tool.EffectiveToolName()).To(Equal("invoice_get")) + Expect(tool.Approval).NotTo(BeNil()) + Expect(tool.Approval.Approved).NotTo(BeNil()) + Expect(*tool.Approval.Approved).To(BeTrue()) + Expect(aichat.UIPart{Type: "tool-static_lookup"}.EffectiveToolName()).To(Equal("static_lookup")) + Expect(aichat.UIPart{Type: "text"}.EffectiveToolName()).To(BeEmpty()) + }) + + It("rejects the removed string tool approval policy", func() { + var request aichat.ChatRequest + Expect(json.Unmarshal([]byte(`{"messages":[],"toolApproval":"manual"}`), &request)).To( + MatchError(ContainSubstring("cannot unmarshal string")), + ) + }) + + It("publishes stable model and tool catalog response shapes", func() { + strict := true + models := aichat.ModelCatalogResponse{{ + ID: "openai/gpt", Provider: "openai", Label: "GPT", Reasoning: true, + Temperature: true, Configured: true, ContextWindow: 128000, + InputMediaTypes: []string{"image/*"}, + }} + tools := aichat.ToolCatalogResponse{Tools: []aichat.ToolCatalogEntry{{ + Name: "invoice_get", Source: "custom", Group: "billing", + PreferenceKey: "billing", DefaultPermission: api.ToolModeAsk, + Strict: &strict, Method: "GET", Path: "/invoices/{id}", + OperationName: "invoice get", InputSchema: map[string]any{"type": "object"}, + }}} + + modelJSON, err := json.Marshal(models) + Expect(err).NotTo(HaveOccurred()) + Expect(modelJSON).To(MatchJSON(`[{"id":"openai/gpt","provider":"openai","label":"GPT","reasoning":true,"temperature":true,"configured":true,"contextWindow":128000,"inputMediaTypes":["image/*"]}]`)) + toolJSON, err := json.Marshal(tools) + Expect(err).NotTo(HaveOccurred()) + Expect(toolJSON).To(MatchJSON(`{"tools":[{"name":"invoice_get","source":"custom","group":"billing","preferenceKey":"billing","defaultPermission":"ask","strict":true,"method":"GET","path":"/invoices/{id}","operationName":"invoice get","inputSchema":{"type":"object"}}]}`)) + }) +}) diff --git a/pkg/cli/attachments.go b/pkg/cli/attachments.go index 03cd36a..89ee891 100644 --- a/pkg/cli/attachments.go +++ b/pkg/cli/attachments.go @@ -11,9 +11,9 @@ import ( "strings" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/aichat" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/attachments" - "github.com/flanksource/clicky/aichat" ) func attachmentRefsFromFlags(values []string) ([]api.AttachmentRef, error) { diff --git a/pkg/cli/attachments_ginkgo_test.go b/pkg/cli/attachments_ginkgo_test.go index 9abd483..d13a819 100644 --- a/pkg/cli/attachments_ginkgo_test.go +++ b/pkg/cli/attachments_ginkgo_test.go @@ -16,9 +16,9 @@ import ( . "github.com/onsi/gomega" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/aichat" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/attachments" - "github.com/flanksource/clicky/aichat" ) var _ = Describe("attachment flags", func() { diff --git a/pkg/cli/chat_thread_store.go b/pkg/cli/chat_thread_store.go index fb9a24c..13aaf5c 100644 --- a/pkg/cli/chat_thread_store.go +++ b/pkg/cli/chat_thread_store.go @@ -13,7 +13,7 @@ import ( "sync" "time" - "github.com/flanksource/clicky/aichat" + "github.com/flanksource/captain/pkg/aichat" ) type fileThreadStore struct { @@ -152,7 +152,10 @@ func (s *fileThreadStore) AddUsage(_ context.Context, id string, usage aichat.Tu } thread.TotalInputTokens += usage.InputTokens thread.TotalOutputTokens += usage.OutputTokens - thread.TotalCostUsd += usage.CostUSD + thread.TotalReasoningTokens += usage.ReasoningTokens + thread.TotalCacheReadTokens += usage.CacheReadTokens + thread.TotalCacheWriteTokens += usage.CacheWriteTokens + thread.TotalCostUSD += usage.CostUSD thread.LastContextTokens = usage.InputTokens thread.UpdatedAt = time.Now() if err := s.saveLocked(state); err != nil { diff --git a/pkg/cli/chat_thread_store_test.go b/pkg/cli/chat_thread_store_test.go index a404ae9..df36a59 100644 --- a/pkg/cli/chat_thread_store_test.go +++ b/pkg/cli/chat_thread_store_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/flanksource/clicky/aichat" + "github.com/flanksource/captain/pkg/aichat" ) func TestFileThreadStorePersistsThreads(t *testing.T) { @@ -57,8 +57,8 @@ func TestFileThreadStorePersistsThreads(t *testing.T) { 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) + 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) diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index 651788d..e4f9812 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -8,7 +8,6 @@ import ( "io" "io/fs" "net/http" - "net/url" "os" "os/exec" "os/signal" @@ -19,10 +18,10 @@ import ( "syscall" "time" + "github.com/flanksource/captain/pkg/aichat" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/monitor" - "github.com/flanksource/clicky/aichat" "github.com/flanksource/clicky/rpc" rpchttp "github.com/flanksource/clicky/rpc/http" "github.com/flanksource/clicky/task" @@ -46,6 +45,7 @@ type ServeOptions struct { Open bool ThreadsFile string PromptDirs []string + MCPServers []aichat.MCPServer } func NewServeCommand(version string) *cobra.Command { @@ -140,19 +140,15 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve addCaptainPromptRunPaths(openAPISpec) addCaptainProviderTokenPaths(openAPISpec) addCaptainProviderDefaultsPaths(openAPISpec) - 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, - }, - AttachmentResolver: chatAttachmentResolver{store: attachmentStore}, - }) - defer chat.Close() + chat, mcpTools, err := newCaptainChatService(ctx, rootCmd, opts, cwd, threadStore, attachmentStore) + if err != nil { + return err + } + defer func() { + if err := mcpTools.Close(); err != nil { + log.Errorf("close Captain MCP tools: %v", err) + } + }() // Prompt runs are tracked as clicky task groups. Disable terminal rendering: // serve runs on a TTY, so otherwise the task manager draws progress bars over @@ -295,107 +291,6 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve } } -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.Annotation("clicky/operation"))) - 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.Annotation("clicky/method")) { - case http.MethodGet, http.MethodHead, http.MethodOptions: - return false - } - // clicky already derives list/get→On; honor that, then fall back to the raw - // verb/name/operation/path for captain's wider read-verb set. - if tool.DefaultPermission == aichat.ToolModeOn { - return false - } - if isReadOnlyCaptainTool(tool.Annotation("clicky/verb")) || - isReadOnlyCaptainTool(tool.Name) || - isReadOnlyCaptainTool(tool.Annotation("clicky/operation")) || - isReadOnlyCaptainTool(tool.Annotation("clicky/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 { diff --git a/pkg/cli/serve_chat.go b/pkg/cli/serve_chat.go new file mode 100644 index 0000000..bc24639 --- /dev/null +++ b/pkg/cli/serve_chat.go @@ -0,0 +1,147 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/aichat" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/attachments" + clickyaichat "github.com/flanksource/clicky/aichat" + "github.com/flanksource/commons-db/shell" + "github.com/spf13/cobra" +) + +func newCaptainChatService( + ctx context.Context, + rootCmd *cobra.Command, + opts ServeOptions, + cwd string, + threadStore aichat.ThreadStore, + attachmentStore *attachments.Store, +) (*aichat.Service, *aichat.MCPToolProvider, error) { + chatTools, err := clickyaichat.NewCobraToolProvider(clickyaichat.CobraToolProviderOptions{ + Root: rootCmd, Filter: captainChatToolEnabled, Permission: captainChatToolPermission, + }) + if err != nil { + return nil, nil, err + } + mcpTools := aichat.NewMCPToolProvider(aichat.MCPToolProviderOptions{Servers: opts.MCPServers}) + if _, err := mcpTools.ToolSet(ctx); err != nil { + return nil, nil, err + } + chat := aichat.NewService(aichat.ServiceOptions{ + Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { + return aichat.RuntimeSettings{ + DefaultModel: "agent:sol", + 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.", + Spec: api.Spec{Setup: &shell.Setup{Cwd: cwd}}, + }, nil + }), + Tools: chatTools, MCP: mcpTools, Threads: threadStore, + Attachments: chatAttachmentResolver{store: attachmentStore}, + }) + return chat, mcpTools, nil +} + +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 tools.ToolInfo) bool { + raw := strings.ToLower(strings.TrimSpace(tool.Annotation("clicky/operation"))) + 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 captainChatToolPermission(tool tools.ToolInfo) api.ToolMode { + switch strings.ToUpper(tool.Annotation("clicky/method")) { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return api.ToolModeOn + } + if tool.DefaultPermission == api.ToolModeOn { + return api.ToolModeOn + } + if isReadOnlyCaptainTool(tool.Annotation("clicky/verb")) || + isReadOnlyCaptainTool(tool.Name) || + isReadOnlyCaptainTool(tool.Annotation("clicky/operation")) || + isReadOnlyCaptainTool(tool.Annotation("clicky/path")) { + return api.ToolModeOn + } + return api.ToolModeAsk +} + +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 +} diff --git a/pkg/cli/webapp/src/ChatLayer.tsx b/pkg/cli/webapp/src/ChatLayer.tsx index fa092e9..0a51ff5 100644 --- a/pkg/cli/webapp/src/ChatLayer.tsx +++ b/pkg/cli/webapp/src/ChatLayer.tsx @@ -24,7 +24,6 @@ export function ChatLayer() { modelsApi: "/api/chat/models", defaultModel: "claude-sonnet-5", enableAttachments: true, - toolApproval: "manual", suggestions: [ "Summarize this run", "Show recent changed files", From 127914fface3772feee60d920be03e5d0c8194d0 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 08:55:34 +0300 Subject: [PATCH 095/131] refactor(cli): split prompt entity implementation into focused modules Split prompt source discovery, rendering, and spec handling into focused files while preserving existing behavior. Move the rendering coverage alongside its implementation for clearer maintenance. --- pkg/cli/prompt_entity.go | 1106 --------------------------------- pkg/cli/prompt_entity_test.go | 209 ------- pkg/cli/prompt_records.go | 376 +++++++++++ pkg/cli/prompt_render.go | 288 +++++++++ pkg/cli/prompt_render_test.go | 219 +++++++ pkg/cli/prompt_sources.go | 254 ++++++++ pkg/cli/prompt_spec.go | 241 +++++++ 7 files changed, 1378 insertions(+), 1315 deletions(-) create mode 100644 pkg/cli/prompt_records.go create mode 100644 pkg/cli/prompt_render.go create mode 100644 pkg/cli/prompt_render_test.go create mode 100644 pkg/cli/prompt_sources.go create mode 100644 pkg/cli/prompt_spec.go diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index 1b8aa69..baca890 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -2,32 +2,21 @@ package cli import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" "errors" "fmt" - "io" "io/fs" "net/http" "os" "path/filepath" "sort" - "strconv" "strings" "sync" - "time" "github.com/flanksource/captain/pkg/ai" - promptlib "github.com/flanksource/captain/pkg/ai/prompt" "github.com/flanksource/captain/pkg/api" - "github.com/flanksource/captain/pkg/captainconfig" - "github.com/flanksource/captain/pkg/collections" "github.com/flanksource/clicky" clickyapi "github.com/flanksource/clicky/api" clickyrpc "github.com/flanksource/clicky/rpc" - dp "github.com/google/dotprompt/go/dotprompt" ) type promptDirsContextKey struct{} @@ -363,1098 +352,3 @@ func renderPromptAction(ctx context.Context, id string, flags map[string]string) } return renderPromptCLI(ctx, id, opts, flags["vars"], readStdinIfCLI(ctx)) } - -// renderPrompt is the HTTP/Spec render path: overlay a structured api.Spec (the -// web UI's rich runtime overrides) onto the rendered template. -func renderPrompt(ctx context.Context, id string, renderReq PromptRenderRequest) (PromptRenderResult, error) { - if strings.TrimSpace(id) == "" { - return renderEphemeralPrompt(renderReq) - } - record, err := resolvePromptRecord(ctx, id) - if err != nil { - return PromptRenderResult{}, err - } - content, err := readPromptContent(record) - if err != nil { - return PromptRenderResult{}, err - } - vars := renderReq.Variables - if vars == nil { - vars = map[string]any{} - } - req, cfg, err := promptlib.Load(content).Render(vars, nil) - if err != nil { - return PromptRenderResult{}, err - } - req.Prompt.Source = record.Rel - if renderReq.Spec != nil { - overlayRuntimeSpec(&req, &cfg, *renderReq.Spec) - } - if err := applyPromptDefaults(&req, &cfg); err != nil { - return PromptRenderResult{}, err - } - cwd, err := os.Getwd() - if err != nil { - return PromptRenderResult{}, fmt.Errorf("get working directory: %w", err) - } - if err := normalizePromptContextDir(&req, cwd); err != nil { - return PromptRenderResult{}, err - } - return finalizeRenderResult(record, content, req, cfg) -} - -func renderEphemeralPrompt(renderReq PromptRenderRequest) (PromptRenderResult, error) { - record := promptRecord{ - Source: promptSource{Kind: "ephemeral", ID: "ephemeral", Label: "Ephemeral"}, - ID: "", - Path: "", - Rel: "scratch.prompt", - } - content := ephemeralPromptContent() - var req ai.Request - var cfg ai.Config - if renderReq.Spec != nil { - overlayRuntimeSpec(&req, &cfg, *renderReq.Spec) - } - if req.Prompt.Source == "" { - req.Prompt.Source = "" - } - if err := applyPromptDefaults(&req, &cfg); err != nil { - return PromptRenderResult{}, err - } - cwd, err := os.Getwd() - if err != nil { - return PromptRenderResult{}, fmt.Errorf("get working directory: %w", err) - } - if err := normalizePromptContextDir(&req, cwd); err != nil { - return PromptRenderResult{}, err - } - return finalizeRenderResult(record, content, req, cfg) -} - -func ephemeralPromptContent() string { - return `--- -name: Scratch Prompt -description: Ephemeral prompt ---- -{{role "user"}} -` -} - -// renderPromptCLI is the CLI render path: load from id | .prompt filepath | -p | -// stdin and overlay the flat CLI flags (overlayCLI). -func renderPromptCLI(ctx context.Context, id string, opts AIPromptOptions, varsJSON, stdin string) (PromptRenderResult, error) { - content, source, usedStdin, record, err := loadPromptContent(ctx, id, opts, stdin) - if err != nil { - return PromptRenderResult{}, err - } - vars, err := promptVars(opts, varsJSON, stdin, usedStdin) - if err != nil { - return PromptRenderResult{}, err - } - req, cfg, err := renderLoadedContent(content, source, vars, opts) - if err != nil { - return PromptRenderResult{}, err - } - return finalizeRenderResult(record, content, req, cfg) -} - -// finalizeRenderResult packages the rendered request/config + prompt detail into -// a PromptRenderResult and sets the validation error (shared by both paths). -func finalizeRenderResult(record promptRecord, content string, req ai.Request, cfg ai.Config) (PromptRenderResult, error) { - // Normalize a comma-separated model into a clean primary + fallbacks so the - // displayed Model is a single name, then catch a mistyped model (primary or any - // fallback) at render time, not just on run. - req.Model = req.ExpandCSV() - cfg.Model = cfg.Model.ExpandCSV() - var err error - req.Model, err = ai.ResolveModelSelectors(req.Model) - if err != nil { - return PromptRenderResult{}, err - } - cfg.Model, err = ai.ResolveModelSelectors(cfg.Model) - if err != nil { - return PromptRenderResult{}, err - } - for _, c := range cfg.Model.Candidates() { - warnIfLikelyModelTypo(c.Name) - } - detail, err := promptDetailFromContent(record, content) - if err != nil { - return PromptRenderResult{}, err - } - result := PromptRenderResult{ - ID: detail.ID, - Name: detail.Name, - Model: cfg.Model.Name, - Backend: string(cfg.Model.Backend), - User: req.Prompt.User, - System: req.Prompt.System, - Input: req, - Config: cfg, - InputSchema: detail.InputSchema, - InputDefault: detail.InputDefault, - OutputSchema: detail.OutputSchema, - } - switch { - case req.Prompt.User == "" && len(req.Prompt.Attachments) == 0 && !req.IsVerifyOnly(): - result.ValidationError = "prompt text or attachment required" - case cfg.Model.Name == "": - result.ValidationError = "no model: set prompt frontmatter, pass a model override, or run 'captain configure'" - default: - if err := req.Validate(); err != nil { - result.ValidationError = err.Error() - } - } - return result, nil -} - -func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { - if spec.Name != "" { - req.Name = spec.Name - cfg.Model.Name = spec.Name - req.ID = spec.ID - cfg.Model.ID = spec.ID - req.Backend = spec.Backend - cfg.Model.Backend = spec.Backend - } else { - if spec.ID != "" { - req.ID = spec.ID - cfg.Model.ID = spec.ID - } - if spec.Backend != "" { - req.Backend = spec.Backend - cfg.Model.Backend = spec.Backend - } - } - if spec.Temperature != nil { - req.Temperature = spec.Temperature - cfg.Model.Temperature = spec.Temperature - } - if spec.Effort != "" { - req.Effort = spec.Effort - cfg.Model.Effort = spec.Effort - } - if len(spec.Fallbacks) > 0 { - req.Fallbacks = spec.Fallbacks - cfg.Model.Fallbacks = spec.Fallbacks - } - req.NoCache = req.NoCache || spec.NoCache - cfg.NoCache = cfg.NoCache || spec.NoCache - if spec.Budget.Cost > 0 { - req.Budget.Cost = spec.Budget.Cost - cfg.Budget.Cost = spec.Budget.Cost - } - if spec.Budget.MaxTokens > 0 { - req.Budget.MaxTokens = spec.Budget.MaxTokens - cfg.Budget.MaxTokens = spec.Budget.MaxTokens - } - if spec.Budget.MaxTurns > 0 { - req.Budget.MaxTurns = spec.Budget.MaxTurns - cfg.Budget.MaxTurns = spec.Budget.MaxTurns - } - if spec.Budget.Timeout != "" { - req.Budget.Timeout = spec.Budget.Timeout - cfg.Budget.Timeout = spec.Budget.Timeout - } - - if spec.Prompt.User != "" { - req.Prompt.User = spec.Prompt.User - } - if spec.Prompt.System != "" { - req.Prompt.System = spec.Prompt.System - } - if spec.Prompt.AppendSystem != "" { - req.Prompt.AppendSystem = spec.Prompt.AppendSystem - } - if spec.Prompt.Source != "" { - req.Prompt.Source = spec.Prompt.Source - } - req.Prompt.Metadata = mergeStringMaps(req.Prompt.Metadata, spec.Prompt.Metadata) - if len(spec.Prompt.SchemaJSON) > 0 { - req.Prompt.SchemaJSON = spec.Prompt.SchemaJSON - } - if spec.Prompt.SchemaStrictness != "" { - req.Prompt.SchemaStrictness = spec.Prompt.SchemaStrictness - } - if spec.Workflow != nil { - req.Workflow = spec.Workflow - } - - if spec.Permissions.Mode != "" { - req.Permissions.Mode = spec.Permissions.Mode - } - req.Permissions.Presets = mergePresets(req.Permissions.Presets, spec.Permissions.Presets) - toolPolicies := spec.Permissions.Tools.Policies() - if len(toolPolicies) > 0 { - req.Permissions.Tools.Allow = nil - req.Permissions.Tools.Deny = nil - req.Permissions.Tools.Modes = nil - for _, tool := range sortedStringKeys(toolPolicies) { - switch toolPolicies[tool] { - case api.ToolPolicyAllow: - req.Permissions.Tools.Allow = append(req.Permissions.Tools.Allow, tool) - case api.ToolPolicyDeny: - req.Permissions.Tools.Deny = append(req.Permissions.Tools.Deny, tool) - case api.ToolPolicyAsk: - if req.Permissions.Tools.Modes == nil { - req.Permissions.Tools.Modes = map[string]api.ToolMode{} - } - req.Permissions.Tools.Modes[tool] = api.ToolModeAsk - case api.ToolPolicyAuto: - if req.Permissions.Tools.Modes == nil { - req.Permissions.Tools.Modes = map[string]api.ToolMode{} - } - req.Permissions.Tools.Modes[tool] = api.ToolModeEnabled - } - } - } - req.Permissions.Tools.Modes = mergeToolModes(req.Permissions.Tools.Modes, spec.Permissions.Tools.Modes) - req.Permissions.MCP.Disabled = req.Permissions.MCP.Disabled || spec.Permissions.MCP.Disabled - if servers := spec.Permissions.MCP.EnabledServers(); len(servers) > 0 { - req.Permissions.MCP.Servers = servers - } - if len(spec.Permissions.Plugins) > 0 { - req.Permissions.Plugins = enabledResourcePolicies(spec.Permissions.Plugins) - } - - skills := append([]string(nil), spec.Memory.Skills...) - skills = append(skills, spec.Permissions.Skills.Enabled()...) - if len(skills) > 0 { - req.Memory.Skills = dedupeStrings(skills) - } - req.Memory.SkipProject = req.Memory.SkipProject || spec.Memory.SkipProject - req.Memory.SkipUser = req.Memory.SkipUser || spec.Memory.SkipUser - req.Memory.SkipSkills = req.Memory.SkipSkills || spec.Memory.SkipSkills - req.Memory.SkipHooks = req.Memory.SkipHooks || spec.Memory.SkipHooks - req.Memory.SkipMemory = req.Memory.SkipMemory || spec.Memory.SkipMemory - req.Memory.Bare = req.Memory.Bare || spec.Memory.Bare - - if spec.Setup != nil { - req.Setup = spec.Setup - } - - if spec.SessionID != "" { - req.SessionID = spec.SessionID - cfg.SessionID = spec.SessionID - } -} - -func applyPromptDefaults(req *ai.Request, cfg *ai.Config) error { - savedCfg := loadSavedConfig() - saved := savedCfg.AI - promptModel := req.Model - if promptModel.Name == "" { - promptModel.Name = cfg.Model.Name - } - if promptModel.ID == "" { - promptModel.ID = cfg.Model.ID - } - if promptModel.Backend == "" { - promptModel.Backend = cfg.Model.Backend - } - identity := selectModelIdentity( - api.Model{Name: promptModel.Name, ID: promptModel.ID, Backend: promptModel.Backend}, - ) - req.Name, req.ID, req.Backend = identity.Name, identity.ID, identity.Backend - if cfg.Model.Effort != api.EffortNone { - // An effort-qualified model selector (for example agent:sol:high) - // is model-local and intentionally overrides the request-wide flag/default. - req.Effort = cfg.Model.Effort - } else if req.Effort == "" { - req.Effort = cfg.Model.Effort - } - resolved, err := applyProviderDefaults(req.Model, saved) - if err != nil { - return err - } - req.Model = resolved - req.NoCache = req.NoCache || saved.NoCache - if req.Budget.MaxTokens == 0 { - req.Budget.MaxTokens = firstPositive(cfg.Budget.MaxTokens, saved.MaxTokens, 4096) - } - if req.Budget.Cost == 0 { - req.Budget.Cost = firstPositiveFloat(cfg.Budget.Cost, saved.BudgetUSD) - } - - cfg.Model = req.Model - cfg.Budget = req.Budget - cfg.NoCache = req.NoCache - if isZeroSchemaRepair(cfg.SchemaRepair) { - cfg.SchemaRepair = schemaRepairConfig(savedCfg.Prompts.SchemaRepair) - } - return nil -} - -func mergeStringMaps(base, overlay map[string]string) map[string]string { - if len(overlay) == 0 { - return base - } - out := make(map[string]string, collections.SafeAdd(len(base), len(overlay))) - for k, v := range base { - out[k] = v - } - for k, v := range overlay { - out[k] = v - } - return out -} - -func mergeToolModes(base, overlay map[string]api.ToolMode) map[string]api.ToolMode { - if len(overlay) == 0 { - return base - } - out := make(map[string]api.ToolMode, collections.SafeAdd(len(base), len(overlay))) - for k, v := range base { - out[k] = v - } - for k, v := range overlay { - out[k] = v - } - return out -} - -func mergePresets(base, overlay []api.Preset) []api.Preset { - if len(overlay) == 0 { - return base - } - seen := make(map[api.Preset]bool, collections.SafeAdd(len(base), len(overlay))) - out := make([]api.Preset, 0, collections.SafeAdd(len(base), len(overlay))) - for _, preset := range base { - if seen[preset] { - continue - } - seen[preset] = true - out = append(out, preset) - } - for _, preset := range overlay { - if seen[preset] { - continue - } - seen[preset] = true - out = append(out, preset) - } - return out -} - -func sortedStringKeys[V any](m map[string]V) []string { - keys := make([]string, 0, len(m)) - for key := range m { - if key != "" { - keys = append(keys, key) - } - } - sort.Strings(keys) - return keys -} - -func enabledResourcePolicies(in api.ResourcePolicies) api.ResourcePolicies { - out := api.ResourcePolicies{} - for _, key := range sortedStringKeys(in) { - if in[key] == api.ResourceEnabled { - out[key] = api.ResourceEnabled - } - } - return out -} - -func dedupeStrings(in []string) []string { - seen := map[string]bool{} - var out []string - for _, item := range in { - if item == "" || seen[item] { - continue - } - seen[item] = true - out = append(out, item) - } - return out -} - -func readRenderRequest(ctx context.Context, flags map[string]string) (PromptRenderRequest, error) { - var req PromptRenderRequest - if err := decodePromptBody(ctx, map[string]any{}, &req); err != nil { - return PromptRenderRequest{}, err - } - if req.Variables == nil { - req.Variables = map[string]any{} - } - if err := mergePromptActionFlags(&req, flags); err != nil { - return PromptRenderRequest{}, err - } - return req, nil -} - -func mergePromptActionFlags(req *PromptRenderRequest, flags map[string]string) error { - if len(flags) == 0 { - return nil - } - if raw := strings.TrimSpace(flags["vars"]); raw != "" { - var vars map[string]any - if err := json.Unmarshal([]byte(raw), &vars); err != nil { - return fmt.Errorf("parse --vars JSON: %w", err) - } - req.Variables = vars - } - if v := strings.TrimSpace(flags["model"]); v != "" { - ensureRenderSpec(req).Name = v - } - if v := strings.TrimSpace(flags["fallback"]); v != "" { - ensureRenderSpec(req).Fallbacks = fallbackModelsFromFlags([]string{v}) - } - if v := strings.TrimSpace(flags["backend"]); v != "" { - ensureRenderSpec(req).Backend = api.Backend(v) - } - if v := strings.TrimSpace(flags["timeout"]); v != "" { - ensureRenderSpec(req).Budget.Timeout = v - } - if v := strings.TrimSpace(flags["max-tokens"]); v != "" { - n, err := strconv.Atoi(v) - if err != nil { - return fmt.Errorf("invalid --max-tokens %q: %w", v, err) - } - ensureRenderSpec(req).Budget.MaxTokens = n - } - return nil -} - -func ensureRenderSpec(req *PromptRenderRequest) *api.Spec { - if req.Spec == nil { - req.Spec = &api.Spec{} - } - return req.Spec -} - -func decodePromptBody(ctx context.Context, flat map[string]any, dst any) error { - if r, ok := clickyrpc.RequestFromContext(ctx); ok && r.Body != nil { - body, err := io.ReadAll(r.Body) - if err != nil { - return fmt.Errorf("read request body: %w", err) - } - r.Body = io.NopCloser(strings.NewReader(string(body))) - if len(strings.TrimSpace(string(body))) > 0 { - decoder := json.NewDecoder(strings.NewReader(string(body))) - decoder.DisallowUnknownFields() - if err := decoder.Decode(dst); err != nil { - return fmt.Errorf("decode request body: %w", err) - } - return nil - } - } - if len(flat) == 0 { - return nil - } - data, err := json.Marshal(flat) - if err != nil { - return err - } - decoder := json.NewDecoder(strings.NewReader(string(data))) - decoder.DisallowUnknownFields() - if err := decoder.Decode(dst); err != nil { - return fmt.Errorf("decode command body: %w", err) - } - return nil -} - -func runtimeTimeout(raw string) time.Duration { - timeout, _ := time.ParseDuration(raw) - if timeout <= 0 { - return 120 * time.Second - } - return timeout -} - -func listPromptRecords(ctx context.Context) ([]promptRecord, error) { - sources, err := buildPromptSources(ctx) - if err != nil { - return nil, err - } - var records []promptRecord - for _, source := range sources { - recs, err := listPromptRecordsFromSource(source) - if err != nil { - return nil, err - } - records = append(records, recs...) - } - return records, nil -} - -func listPromptRecordsFromSource(source promptSource) ([]promptRecord, error) { - var records []promptRecord - add := func(rel string, info fs.FileInfo) { - rel = filepath.ToSlash(rel) - path := rel - if source.Root != "" { - path = filepath.Join(source.Root, filepath.FromSlash(rel)) - } - updatedAt := "" - if info != nil && !info.ModTime().IsZero() { - updatedAt = info.ModTime().Format(time.RFC3339) - } - records = append(records, promptRecord{ - Source: source, - ID: encodePromptID(source.Kind, source.ID, rel), - Path: path + "\x00" + updatedAt, - Rel: rel, - }) - } - - if source.FS != nil { - err := fs.WalkDir(source.FS, source.WalkRoot, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(path, ".prompt") { - return nil - } - info, _ := d.Info() - add(path, info) - return nil - }) - return records, err - } - - err := filepath.WalkDir(source.Root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - name := d.Name() - if d.IsDir() { - if name == ".git" || name == "node_modules" || name == "vendor" || name == "dist" { - return filepath.SkipDir - } - return nil - } - if d.Type()&fs.ModeSymlink != 0 || !strings.HasSuffix(name, ".prompt") { - return nil - } - rel, err := filepath.Rel(source.Root, path) - if err != nil { - return err - } - info, _ := d.Info() - add(rel, info) - return nil - }) - if errors.Is(err, fs.ErrNotExist) && source.Implicit { - return records, nil - } - return records, err -} - -func resolvePromptRecord(ctx context.Context, id string) (promptRecord, error) { - if looksLikePromptPath(id) { - return filePromptRecord(id) - } - ref, err := decodePromptID(id) - if err != nil { - return promptRecord{}, err - } - sources, err := buildPromptSources(ctx) - if err != nil { - return promptRecord{}, err - } - for _, source := range sources { - if source.Kind != ref.Kind || source.ID != ref.SourceID { - continue - } - path := ref.RelPath - if source.Root != "" { - path = filepath.Join(source.Root, filepath.FromSlash(ref.RelPath)) - } - return promptRecord{Source: source, ID: id, Path: path, Rel: ref.RelPath}, nil - } - return promptRecord{}, fmt.Errorf("prompt source %q not found", ref.SourceID) -} - -// looksLikePromptPath reports whether id is a filesystem path rather than a -// base64 registry id. Registry ids are base64-raw-url (no ".", "/", or leading -// "."), so a .prompt suffix, a path separator, or a leading "." marks a path. -func looksLikePromptPath(id string) bool { - return strings.HasSuffix(id, ".prompt") || - strings.ContainsRune(id, os.PathSeparator) || - strings.HasPrefix(id, ".") -} - -// filePromptRecord resolves an ad-hoc .prompt file path (not a registered id) -// into a record readable via readPromptContent/safeLocalPromptPath. Mirrors the -// captain-ai-prompt file loader so `captain prompt run|render ./x.prompt` works. -func filePromptRecord(id string) (promptRecord, error) { - abs, err := filepath.Abs(id) - if err != nil { - return promptRecord{}, err - } - info, err := os.Stat(abs) - if err != nil { - return promptRecord{}, fmt.Errorf("prompt file %s: %w", id, err) - } - if info.IsDir() { - return promptRecord{}, fmt.Errorf("%s is a directory, not a .prompt file", id) - } - return promptRecord{ - Source: promptSource{Kind: "file", ID: "file", Label: "File", Root: filepath.Dir(abs)}, - ID: id, - Path: abs, - Rel: filepath.Base(abs), - }, nil -} - -func promptSummary(record promptRecord) (PromptSummary, error) { - content, err := readPromptContent(record) - if err != nil { - return PromptSummary{}, err - } - summary, err := promptSummaryFromContent(record, content) - if err != nil { - summary = basePromptSummary(record) - summary.ParseError = err.Error() - } - if idx := strings.LastIndex(record.Path, "\x00"); idx >= 0 { - summary.UpdatedAt = strings.TrimPrefix(record.Path[idx+1:], "\x00") - } - return summary, nil -} - -func promptDetail(record promptRecord) (PromptDetail, error) { - content, err := readPromptContent(record) - if err != nil { - return PromptDetail{}, err - } - return promptDetailFromContent(record, content) -} - -func promptDetailFromContent(record promptRecord, content string) (PromptDetail, error) { - summary, err := promptSummaryFromContent(record, content) - if err != nil { - return PromptDetail{}, err - } - inspection, err := inspectPrompt(content, nil) - if err != nil { - return PromptDetail{}, err - } - return PromptDetail{ - PromptSummary: summary, - Content: content, - InputSchema: inspection.InputSchema, - InputDefault: inspection.InputDefault, - OutputSchema: inspection.OutputSchema, - Metadata: inspection.Metadata, - }, nil -} - -func promptSummaryFromContent(record promptRecord, content string) (PromptSummary, error) { - tmpl := promptlib.Load(content) - req, cfg, err := tmpl.Render(map[string]any{}, nil) - if err != nil { - return PromptSummary{}, err - } - inspection, err := inspectPrompt(content, nil) - if err != nil { - return PromptSummary{}, err - } - summary := basePromptSummary(record) - if v, ok := inspection.Metadata["name"].(string); ok && strings.TrimSpace(v) != "" { - summary.Name = strings.TrimSpace(v) - } - if v, ok := inspection.Metadata["description"].(string); ok { - summary.Description = strings.TrimSpace(v) - } - summary.Model = firstNonEmpty(cfg.Model.Name, req.Name) - summary.Backend = firstNonEmpty(string(cfg.Model.Backend), string(req.Backend)) - summary.Variables = inspection.Variables - return summary, nil -} - -func basePromptSummary(record promptRecord) PromptSummary { - name := strings.TrimSuffix(filepath.Base(record.Rel), ".prompt") - return PromptSummary{ - ID: record.ID, - Name: name, - SourceKind: record.Source.Kind, - SourceID: record.Source.ID, - Source: record.Source.Label, - Path: displayPromptPath(record), - RelPath: record.Rel, - Writable: record.Source.Writable, - } -} - -func displayPromptPath(record promptRecord) string { - if idx := strings.LastIndex(record.Path, "\x00"); idx >= 0 { - return record.Path[:idx] - } - return record.Path -} - -func readPromptContent(record promptRecord) (string, error) { - if record.Source.FS != nil { - data, err := fs.ReadFile(record.Source.FS, record.Rel) - if err != nil { - return "", fmt.Errorf("read embedded prompt %s: %w", record.Rel, err) - } - return string(data), nil - } - full, err := safeLocalPromptPath(record.Source, record.Rel) - if err != nil { - return "", err - } - data, err := os.ReadFile(full) - if err != nil { - return "", fmt.Errorf("read prompt %s: %w", full, err) - } - return string(data), nil -} - -func inspectPrompt(content string, data map[string]any) (promptInspection, error) { - if data == nil { - data = map[string]any{} - } - rendered, err := dp.NewDotprompt(nil).Render(content, &dp.DataArgument{Input: data}, nil) - if err != nil { - return promptInspection{}, err - } - metadata := map[string]any{} - if rendered.Raw != nil { - for k, v := range rendered.Raw { - metadata[k] = v - } - } - if rendered.Name != "" { - metadata["name"] = rendered.Name - } - if rendered.Description != "" { - metadata["description"] = rendered.Description - } - if rendered.Model != "" { - metadata["model"] = rendered.Model - } - inputSchema := anyToMap(rendered.Input.Schema) - inputDefault := map[string]any{} - for k, v := range rendered.Input.Default { - inputDefault[k] = v - } - return promptInspection{ - Metadata: metadata, - InputSchema: inputSchema, - InputDefault: inputDefault, - OutputSchema: anyToMap(rendered.Output.Schema), - Variables: variablesFromSchema(inputSchema), - }, nil -} - -func anyToMap(v any) map[string]any { - if v == nil { - return nil - } - data, err := json.Marshal(v) - if err != nil { - return nil - } - var out map[string]any - if err := json.Unmarshal(data, &out); err != nil { - return nil - } - return out -} - -func variablesFromSchema(schema map[string]any) []PromptVariable { - props, _ := schema["properties"].(map[string]any) - if len(props) == 0 { - return nil - } - required := map[string]bool{} - if raw, ok := schema["required"].([]any); ok { - for _, item := range raw { - if s, ok := item.(string); ok { - required[s] = true - } - } - } - keys := make([]string, 0, len(props)) - for k := range props { - keys = append(keys, k) - } - sort.Strings(keys) - var vars []PromptVariable - for _, name := range keys { - prop, _ := props[name].(map[string]any) - item := PromptVariable{Name: name, Required: required[name]} - if v, ok := prop["type"].(string); ok { - item.Type = v - } - if v, ok := prop["description"].(string); ok { - item.Description = v - } - vars = append(vars, item) - } - return vars -} - -func promptSourceMatches(summary PromptSummary, source string) bool { - switch source { - case "", "all": - return true - case "embedded": - return summary.SourceKind == "embedded" - case "local": - return summary.SourceKind == "local" - default: - return summary.SourceID == source || strings.EqualFold(summary.Source, source) - } -} - -func promptMatches(summary PromptSummary, filter string) bool { - if filter == "" { - return true - } - haystack := strings.ToLower(strings.Join([]string{ - summary.Name, - summary.Description, - summary.Source, - summary.Path, - summary.RelPath, - summary.Model, - summary.Backend, - }, "\n")) - return strings.Contains(haystack, filter) -} - -func buildPromptSources(ctx context.Context) ([]promptSource, error) { - sources := []promptSource{{ - Kind: "embedded", - ID: "embedded", - Label: "Embedded examples", - WalkRoot: "testdata", - FS: promptlib.Examples, - Writable: false, - }} - - seen := map[string]bool{} - addLocal := func(raw, base string) error { - dir, err := resolvePromptDir(raw, base) - if err != nil { - return err - } - if seen[dir] { - return nil - } - seen[dir] = true - sources = append(sources, promptSource{ - Kind: "local", - ID: hashPromptDir(dir), - Label: dir, - Root: dir, - Writable: true, - }) - return nil - } - - cfg, exists, err := captainconfig.Load() - if err != nil { - return nil, err - } - if exists { - configPath, err := captainconfig.Path() - if err != nil { - return nil, err - } - base := filepath.Dir(configPath) - for _, dir := range cfg.Prompts.Dirs { - if err := addLocal(dir, base); err != nil { - return nil, err - } - } - } - cwd, err := os.Getwd() - if err != nil { - return nil, err - } - for _, dir := range promptDirsFromContext(ctx) { - if err := addLocal(dir, cwd); err != nil { - return nil, err - } - } - if _, ok := firstWritableSource(sources); !ok { - dir := filepath.Join(cwd, ".captain", "prompts") - sources = append(sources, promptSource{ - Kind: "local", - ID: hashPromptDir(dir), - Label: dir, - Root: dir, - Writable: true, - Implicit: true, - }) - } - return sources, nil -} - -func promptDirsFromContext(ctx context.Context) []string { - if ctx == nil { - return nil - } - if dirs, ok := ctx.Value(promptDirsContextKey{}).([]string); ok { - return dirs - } - return nil -} - -func resolvePromptDir(raw, base string) (string, error) { - dir := strings.TrimSpace(raw) - if dir == "" { - return "", fmt.Errorf("prompt dir cannot be empty") - } - if strings.HasPrefix(dir, "~") { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - switch { - case dir == "~": - dir = home - case strings.HasPrefix(dir, "~/"): - dir = filepath.Join(home, dir[2:]) - default: - return "", fmt.Errorf("unsupported home-relative prompt dir %q", raw) - } - } - if !filepath.IsAbs(dir) { - dir = filepath.Join(base, dir) - } - abs, err := filepath.Abs(dir) - if err != nil { - return "", err - } - info, err := os.Stat(abs) - if err != nil { - return "", fmt.Errorf("prompt dir %s: %w", abs, err) - } - if !info.IsDir() { - return "", fmt.Errorf("prompt dir %s is not a directory", abs) - } - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - abs = resolved - } - return filepath.Clean(abs), nil -} - -func writableSourceByID(sources []promptSource, id string) (promptSource, bool) { - for _, source := range sources { - if source.ID == id && source.Writable { - return source, true - } - } - return promptSource{}, false -} - -func firstWritableSource(sources []promptSource) (promptSource, bool) { - for _, source := range sources { - if source.Writable { - return source, true - } - } - return promptSource{}, false -} - -func safeLocalPromptPath(source promptSource, rel string) (string, error) { - cleanRel := strings.TrimPrefix(filepath.Clean(filepath.FromSlash(rel)), string(filepath.Separator)) - if cleanRel == "." || cleanRel == "" || filepath.IsAbs(rel) || strings.HasPrefix(cleanRel, ".."+string(filepath.Separator)) || cleanRel == ".." { - return "", fmt.Errorf("invalid prompt path %q", rel) - } - if filepath.Ext(cleanRel) != ".prompt" { - return "", fmt.Errorf("prompt path must end with .prompt") - } - full := filepath.Join(source.Root, cleanRel) - abs, err := filepath.Abs(full) - if err != nil { - return "", err - } - relToRoot, err := filepath.Rel(source.Root, abs) - if err != nil { - return "", err - } - if strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) || relToRoot == ".." || filepath.IsAbs(relToRoot) { - return "", fmt.Errorf("prompt path escapes source root") - } - if info, err := os.Lstat(abs); err == nil && info.Mode()&fs.ModeSymlink != 0 { - return "", fmt.Errorf("prompt symlinks are not supported") - } - return abs, nil -} - -func normalizeWriteRelPath(relPath, name string) (string, error) { - rel := strings.TrimSpace(relPath) - if rel == "" { - rel = slugPromptName(name) - } - if rel == "" { - return "", fmt.Errorf("prompt name or path required") - } - if !strings.HasSuffix(rel, ".prompt") { - rel += ".prompt" - } - rel = filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) - if strings.HasPrefix(rel, "../") || rel == ".." || strings.HasPrefix(rel, "/") { - return "", fmt.Errorf("invalid prompt path %q", relPath) - } - return rel, nil -} - -func slugPromptName(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - var b strings.Builder - lastDash := false - for _, r := range name { - ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') - if ok { - b.WriteRune(r) - lastDash = false - continue - } - if !lastDash { - b.WriteRune('-') - lastDash = true - } - } - return strings.Trim(b.String(), "-") -} - -func encodePromptID(kind, sourceID, rel string) string { - return base64.RawURLEncoding.EncodeToString([]byte(kind + "\x00" + sourceID + "\x00" + filepath.ToSlash(rel))) -} - -func decodePromptID(id string) (promptRef, error) { - data, err := base64.RawURLEncoding.DecodeString(id) - if err != nil { - return promptRef{}, fmt.Errorf("invalid prompt id: %w", err) - } - parts := strings.SplitN(string(data), "\x00", 3) - if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - return promptRef{}, fmt.Errorf("invalid prompt id") - } - return promptRef{Kind: parts[0], SourceID: parts[1], RelPath: filepath.ToSlash(parts[2])}, nil -} - -func hashPromptDir(dir string) string { - sum := sha256.Sum256([]byte(dir)) - return hex.EncodeToString(sum[:])[:12] -} - -func ValidatePromptDirs(dirs []string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - for _, dir := range dirs { - if _, err := resolvePromptDir(dir, cwd); err != nil { - return err - } - } - return nil -} - -var _ clicky.EntityItem = PromptSummary{} -var _ clickyapi.TableProvider = PromptSummary{} diff --git a/pkg/cli/prompt_entity_test.go b/pkg/cli/prompt_entity_test.go index 35d30ce..5758621 100644 --- a/pkg/cli/prompt_entity_test.go +++ b/pkg/cli/prompt_entity_test.go @@ -8,10 +8,7 @@ import ( "strings" "testing" - "github.com/flanksource/captain/pkg/ai" - "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" - "github.com/flanksource/commons-db/shell" ) func TestPromptEntityListsEmbeddedExamples(t *testing.T) { @@ -297,209 +294,3 @@ func findEmbeddedPrompt(ctx context.Context, relPath string) (PromptSummary, err } return PromptSummary{}, fmt.Errorf("embedded prompt %q not found", relPath) } - -func TestRenderPromptAppliesRuntimeSpec(t *testing.T) { - isolateCaptainConfig(t) - - dir := t.TempDir() - cwd := t.TempDir() - t.Chdir(cwd) - ctx := ContextWithPromptDirs(context.Background(), []string{dir}) - content := `--- -name: Runtime Spec -model: claude-sonnet-4-6 ---- -{{role "user"}} -Hello {{name}} -` - created, err := createPrompt(ctx, map[string]any{ - "name": "Runtime Spec", - "content": content, - }) - if err != nil { - t.Fatalf("createPrompt() err = %v", err) - } - - temp := 0.2 - rendered, err := renderPrompt(ctx, created.ID, PromptRenderRequest{ - Variables: map[string]any{"name": "Ada"}, - Spec: &api.Spec{ - Model: api.Model{ - Name: "gpt-4o", - ID: "openai/gpt-4o", - Backend: api.BackendOpenAI, - Temperature: &temp, - Effort: api.EffortLow, - NoCache: true, - }, - Prompt: api.Prompt{ - System: "runtime system", - AppendSystem: "runtime append", - Source: "runtime-source", - Metadata: map[string]string{"surface": "prompt-ui"}, - }, - Budget: api.Budget{Cost: 0.5, MaxTokens: 1234, MaxTurns: 4, Timeout: "90s"}, - Permissions: api.Permissions{ - Mode: api.PermissionAcceptEdits, - Presets: []api.Preset{api.PresetEdit}, - Tools: api.Tools{ - Allow: []string{"Read"}, - Deny: []string{"Bash"}, - Modes: map[string]api.ToolMode{"Bash": api.ToolModeDisabled}, - }, - MCP: api.MCP{ - Disabled: true, - Servers: []string{"filesystem"}, - Modes: api.ResourcePolicies{"gavel": api.ResourceDisabled}, - }, - Plugins: api.ResourcePolicies{"/plugins": api.ResourceEnabled}, - Skills: api.ResourcePolicies{"/permission-skills": api.ResourceEnabled}, - }, - Memory: api.Memory{ - Skills: []string{"/skills"}, - SkipUser: true, - SkipMemory: true, - Bare: true, - }, - Setup: &shell.Setup{ - Cwd: "workspace", - DotEnv: []string{".env"}, - Checkout: &shell.Checkout{ - Mode: shell.CheckoutLocal, - Path: "/repo", - Ref: "abc123", - Worktree: &shell.Worktree{ - Mode: shell.WorktreeNew, - Prefix: "runtime-branch", - Keep: true, - }, - }, - }, - SessionID: "sess-runtime", - }, - }) - if err != nil { - t.Fatalf("renderPrompt() err = %v", err) - } - if rendered.ValidationError != "" { - t.Fatalf("render validation error = %q", rendered.ValidationError) - } - if rendered.Model != "gpt-4o" || rendered.Backend != "openai" { - t.Fatalf("rendered model/backend = %s/%s, want gpt-4o/openai", rendered.Model, rendered.Backend) - } - if rendered.Config.Model.ID != "openai/gpt-4o" { - t.Fatalf("config model ID = %q, want openai/gpt-4o", rendered.Config.Model.ID) - } - if rendered.Input.Temperature == nil || *rendered.Input.Temperature != temp { - t.Fatalf("temperature = %v, want %v", rendered.Input.Temperature, temp) - } - if rendered.Input.Budget.Cost != 0.5 || rendered.Input.Budget.MaxTokens != 1234 || - rendered.Input.Budget.MaxTurns != 4 || rendered.Input.Budget.Timeout != "90s" { - t.Fatalf("budget = %+v, want cost/maxTokens override", rendered.Input.Budget) - } - if !rendered.Input.Model.NoCache || !rendered.Config.NoCache { - t.Fatalf("noCache = input %v config %v, want true", rendered.Input.Model.NoCache, rendered.Config.NoCache) - } - if rendered.Input.Prompt.System != "runtime system" || rendered.Input.Prompt.AppendSystem != "runtime append" { - t.Fatalf("prompt system fields = %+v, want runtime overrides", rendered.Input.Prompt) - } - if rendered.Input.Prompt.Source != "runtime-source" || rendered.Input.Prompt.Metadata["surface"] != "prompt-ui" { - t.Fatalf("prompt source/metadata = %+v, want runtime overrides", rendered.Input.Prompt) - } - if rendered.Input.Permissions.Mode != api.PermissionAcceptEdits || - rendered.Input.Permissions.Tools.Modes["Bash"] != api.ToolModeDisabled || - !rendered.Input.Permissions.MCP.Disabled { - t.Fatalf("permissions = %+v, want runtime overrides", rendered.Input.Permissions) - } - if rendered.Input.Permissions.Plugins["/plugins"] != api.ResourceEnabled { - t.Fatalf("plugins = %+v, want enabled runtime plugin", rendered.Input.Permissions.Plugins) - } - if !strings.Contains(strings.Join(rendered.Input.Memory.Skills, ","), "/permission-skills") { - t.Fatalf("skills = %+v, want permission skills merged into memory skills", rendered.Input.Memory.Skills) - } - if !rendered.Input.Memory.SkipUser || !rendered.Input.Memory.SkipMemory || !rendered.Input.Memory.Bare { - t.Fatalf("memory = %+v, want runtime overrides", rendered.Input.Memory) - } - if rendered.Input.Cwd() != filepath.Join(cwd, "workspace") { - t.Fatalf("setup cwd = %q, want cwd-relative runtime dir", rendered.Input.Cwd()) - } - if rendered.Input.Setup == nil || rendered.Input.Setup.Checkout == nil || rendered.Input.Setup.Checkout.Ref != "abc123" { - t.Fatalf("setup checkout = %+v, want runtime git checkout overlay", rendered.Input.Setup) - } - if rendered.Input.Setup.Checkout.Worktree == nil || !rendered.Input.Setup.Checkout.Worktree.Keep { - t.Fatalf("worktree setup = %+v, want runtime worktree overlay", rendered.Input.Setup.Checkout.Worktree) - } - if rendered.Input.SessionID != "sess-runtime" { - t.Fatalf("runtime session = input=%+v", rendered.Input) - } -} - -func TestRenderPromptEphemeralSpec(t *testing.T) { - isolateCaptainConfig(t) - - cwd := t.TempDir() - t.Chdir(cwd) - temp := 0.1 - rendered, err := renderPrompt(context.Background(), "", PromptRenderRequest{ - Spec: &api.Spec{ - Model: api.Model{ - Name: "gpt-5.5", - Backend: api.BackendCodexAgent, - Temperature: &temp, - Effort: api.EffortHigh, - }, - Prompt: api.Prompt{ - System: "scratch system", - User: "Draft a deployment plan", - }, - Budget: api.Budget{Timeout: "2h"}, - }, - }) - if err != nil { - t.Fatalf("renderPrompt() err = %v", err) - } - if rendered.ValidationError != "" { - t.Fatalf("render validation error = %q", rendered.ValidationError) - } - if rendered.ID != "" || rendered.Name != "Scratch Prompt" { - t.Fatalf("rendered prompt identity = id %q name %q, want scratch prompt", rendered.ID, rendered.Name) - } - if rendered.User != "Draft a deployment plan" || rendered.System != "scratch system" { - t.Fatalf("rendered prompt = user %q system %q", rendered.User, rendered.System) - } - if rendered.Model != "gpt-5.5" || rendered.Backend != "codex-agent" { - t.Fatalf("rendered model/backend = %s/%s, want gpt-5.5/codex-agent", rendered.Model, rendered.Backend) - } - if rendered.Input.Prompt.Source != "" { - t.Fatalf("prompt source = %q, want ", rendered.Input.Prompt.Source) - } - if rendered.Input.Cwd() != cwd { - t.Fatalf("setup cwd = %q, want %q", rendered.Input.Cwd(), cwd) - } -} - -func TestApplyPromptDefaultsSelectorEffortWins(t *testing.T) { - isolateCaptainConfig(t) - req := ai.Request{Model: api.Model{Effort: api.EffortLow}} - cfg := ai.Config{Model: api.Model{ - Name: "gpt-5.6-sol", - Backend: api.BackendCodexAgent, - Effort: api.EffortHigh, - }} - if err := applyPromptDefaults(&req, &cfg); err != nil { - t.Fatalf("applyPromptDefaults: %v", err) - } - if req.Name != "gpt-5.6-sol" || req.Backend != api.BackendCodexAgent || req.Effort != api.EffortHigh { - t.Fatalf("request = %+v, want selector model/effort", req.Model) - } - if cfg.Model.Effort != api.EffortHigh { - t.Fatalf("config effort = %q, want high", cfg.Model.Effort) - } -} - -func isolateCaptainConfig(t *testing.T) { - t.Helper() - path := filepath.Join(t.TempDir(), ".captain.yaml") - captainconfig.SetPathForTesting(path) - t.Cleanup(func() { captainconfig.SetPathForTesting("") }) -} diff --git a/pkg/cli/prompt_records.go b/pkg/cli/prompt_records.go new file mode 100644 index 0000000..c57017f --- /dev/null +++ b/pkg/cli/prompt_records.go @@ -0,0 +1,376 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + promptlib "github.com/flanksource/captain/pkg/ai/prompt" + dp "github.com/google/dotprompt/go/dotprompt" +) + +func listPromptRecords(ctx context.Context) ([]promptRecord, error) { + sources, err := buildPromptSources(ctx) + if err != nil { + return nil, err + } + var records []promptRecord + for _, source := range sources { + recs, err := listPromptRecordsFromSource(source) + if err != nil { + return nil, err + } + records = append(records, recs...) + } + return records, nil +} + +func listPromptRecordsFromSource(source promptSource) ([]promptRecord, error) { + var records []promptRecord + add := func(rel string, info fs.FileInfo) { + rel = filepath.ToSlash(rel) + path := rel + if source.Root != "" { + path = filepath.Join(source.Root, filepath.FromSlash(rel)) + } + updatedAt := "" + if info != nil && !info.ModTime().IsZero() { + updatedAt = info.ModTime().Format(time.RFC3339) + } + records = append(records, promptRecord{ + Source: source, + ID: encodePromptID(source.Kind, source.ID, rel), + Path: path + "\x00" + updatedAt, + Rel: rel, + }) + } + + if source.FS != nil { + err := fs.WalkDir(source.FS, source.WalkRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".prompt") { + return nil + } + info, _ := d.Info() + add(path, info) + return nil + }) + return records, err + } + + err := filepath.WalkDir(source.Root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + name := d.Name() + if d.IsDir() { + if name == ".git" || name == "node_modules" || name == "vendor" || name == "dist" { + return filepath.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 || !strings.HasSuffix(name, ".prompt") { + return nil + } + rel, err := filepath.Rel(source.Root, path) + if err != nil { + return err + } + info, _ := d.Info() + add(rel, info) + return nil + }) + if errors.Is(err, fs.ErrNotExist) && source.Implicit { + return records, nil + } + return records, err +} + +func resolvePromptRecord(ctx context.Context, id string) (promptRecord, error) { + if looksLikePromptPath(id) { + return filePromptRecord(id) + } + ref, err := decodePromptID(id) + if err != nil { + return promptRecord{}, err + } + sources, err := buildPromptSources(ctx) + if err != nil { + return promptRecord{}, err + } + for _, source := range sources { + if source.Kind != ref.Kind || source.ID != ref.SourceID { + continue + } + path := ref.RelPath + if source.Root != "" { + path = filepath.Join(source.Root, filepath.FromSlash(ref.RelPath)) + } + return promptRecord{Source: source, ID: id, Path: path, Rel: ref.RelPath}, nil + } + return promptRecord{}, fmt.Errorf("prompt source %q not found", ref.SourceID) +} + +// looksLikePromptPath reports whether id is a filesystem path rather than a +// base64 registry id. Registry ids are base64-raw-url (no ".", "/", or leading +// "."), so a .prompt suffix, a path separator, or a leading "." marks a path. +func looksLikePromptPath(id string) bool { + return strings.HasSuffix(id, ".prompt") || + strings.ContainsRune(id, os.PathSeparator) || + strings.HasPrefix(id, ".") +} + +// filePromptRecord resolves an ad-hoc .prompt file path (not a registered id) +// into a record readable via readPromptContent/safeLocalPromptPath. Mirrors the +// captain-ai-prompt file loader so `captain prompt run|render ./x.prompt` works. +func filePromptRecord(id string) (promptRecord, error) { + abs, err := filepath.Abs(id) + if err != nil { + return promptRecord{}, err + } + info, err := os.Stat(abs) + if err != nil { + return promptRecord{}, fmt.Errorf("prompt file %s: %w", id, err) + } + if info.IsDir() { + return promptRecord{}, fmt.Errorf("%s is a directory, not a .prompt file", id) + } + return promptRecord{ + Source: promptSource{Kind: "file", ID: "file", Label: "File", Root: filepath.Dir(abs)}, + ID: id, + Path: abs, + Rel: filepath.Base(abs), + }, nil +} + +func promptSummary(record promptRecord) (PromptSummary, error) { + content, err := readPromptContent(record) + if err != nil { + return PromptSummary{}, err + } + summary, err := promptSummaryFromContent(record, content) + if err != nil { + summary = basePromptSummary(record) + summary.ParseError = err.Error() + } + if idx := strings.LastIndex(record.Path, "\x00"); idx >= 0 { + summary.UpdatedAt = strings.TrimPrefix(record.Path[idx+1:], "\x00") + } + return summary, nil +} + +func promptDetail(record promptRecord) (PromptDetail, error) { + content, err := readPromptContent(record) + if err != nil { + return PromptDetail{}, err + } + return promptDetailFromContent(record, content) +} + +func promptDetailFromContent(record promptRecord, content string) (PromptDetail, error) { + summary, err := promptSummaryFromContent(record, content) + if err != nil { + return PromptDetail{}, err + } + inspection, err := inspectPrompt(content, nil) + if err != nil { + return PromptDetail{}, err + } + return PromptDetail{ + PromptSummary: summary, + Content: content, + InputSchema: inspection.InputSchema, + InputDefault: inspection.InputDefault, + OutputSchema: inspection.OutputSchema, + Metadata: inspection.Metadata, + }, nil +} + +func promptSummaryFromContent(record promptRecord, content string) (PromptSummary, error) { + tmpl := promptlib.Load(content) + req, cfg, err := tmpl.Render(map[string]any{}, nil) + if err != nil { + return PromptSummary{}, err + } + inspection, err := inspectPrompt(content, nil) + if err != nil { + return PromptSummary{}, err + } + summary := basePromptSummary(record) + if v, ok := inspection.Metadata["name"].(string); ok && strings.TrimSpace(v) != "" { + summary.Name = strings.TrimSpace(v) + } + if v, ok := inspection.Metadata["description"].(string); ok { + summary.Description = strings.TrimSpace(v) + } + summary.Model = firstNonEmpty(cfg.Model.Name, req.Name) + summary.Backend = firstNonEmpty(string(cfg.Model.Backend), string(req.Backend)) + summary.Variables = inspection.Variables + return summary, nil +} + +func basePromptSummary(record promptRecord) PromptSummary { + name := strings.TrimSuffix(filepath.Base(record.Rel), ".prompt") + return PromptSummary{ + ID: record.ID, + Name: name, + SourceKind: record.Source.Kind, + SourceID: record.Source.ID, + Source: record.Source.Label, + Path: displayPromptPath(record), + RelPath: record.Rel, + Writable: record.Source.Writable, + } +} + +func displayPromptPath(record promptRecord) string { + if idx := strings.LastIndex(record.Path, "\x00"); idx >= 0 { + return record.Path[:idx] + } + return record.Path +} + +func readPromptContent(record promptRecord) (string, error) { + if record.Source.FS != nil { + data, err := fs.ReadFile(record.Source.FS, record.Rel) + if err != nil { + return "", fmt.Errorf("read embedded prompt %s: %w", record.Rel, err) + } + return string(data), nil + } + full, err := safeLocalPromptPath(record.Source, record.Rel) + if err != nil { + return "", err + } + data, err := os.ReadFile(full) + if err != nil { + return "", fmt.Errorf("read prompt %s: %w", full, err) + } + return string(data), nil +} + +func inspectPrompt(content string, data map[string]any) (promptInspection, error) { + if data == nil { + data = map[string]any{} + } + rendered, err := dp.NewDotprompt(nil).Render(content, &dp.DataArgument{Input: data}, nil) + if err != nil { + return promptInspection{}, err + } + metadata := map[string]any{} + if rendered.Raw != nil { + for k, v := range rendered.Raw { + metadata[k] = v + } + } + if rendered.Name != "" { + metadata["name"] = rendered.Name + } + if rendered.Description != "" { + metadata["description"] = rendered.Description + } + if rendered.Model != "" { + metadata["model"] = rendered.Model + } + inputSchema := anyToMap(rendered.Input.Schema) + inputDefault := map[string]any{} + for k, v := range rendered.Input.Default { + inputDefault[k] = v + } + return promptInspection{ + Metadata: metadata, + InputSchema: inputSchema, + InputDefault: inputDefault, + OutputSchema: anyToMap(rendered.Output.Schema), + Variables: variablesFromSchema(inputSchema), + }, nil +} + +func anyToMap(v any) map[string]any { + if v == nil { + return nil + } + data, err := json.Marshal(v) + if err != nil { + return nil + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return out +} + +func variablesFromSchema(schema map[string]any) []PromptVariable { + props, _ := schema["properties"].(map[string]any) + if len(props) == 0 { + return nil + } + required := map[string]bool{} + if raw, ok := schema["required"].([]any); ok { + for _, item := range raw { + if s, ok := item.(string); ok { + required[s] = true + } + } + } + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + var vars []PromptVariable + for _, name := range keys { + prop, _ := props[name].(map[string]any) + item := PromptVariable{Name: name, Required: required[name]} + if v, ok := prop["type"].(string); ok { + item.Type = v + } + if v, ok := prop["description"].(string); ok { + item.Description = v + } + vars = append(vars, item) + } + return vars +} + +func promptSourceMatches(summary PromptSummary, source string) bool { + switch source { + case "", "all": + return true + case "embedded": + return summary.SourceKind == "embedded" + case "local": + return summary.SourceKind == "local" + default: + return summary.SourceID == source || strings.EqualFold(summary.Source, source) + } +} + +func promptMatches(summary PromptSummary, filter string) bool { + if filter == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.Name, + summary.Description, + summary.Source, + summary.Path, + summary.RelPath, + summary.Model, + summary.Backend, + }, "\n")) + return strings.Contains(haystack, filter) +} diff --git a/pkg/cli/prompt_render.go b/pkg/cli/prompt_render.go new file mode 100644 index 0000000..c550ddd --- /dev/null +++ b/pkg/cli/prompt_render.go @@ -0,0 +1,288 @@ +package cli + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/flanksource/captain/pkg/ai" + promptlib "github.com/flanksource/captain/pkg/ai/prompt" + "github.com/flanksource/captain/pkg/api" +) + +// renderPrompt is the HTTP/Spec render path: overlay a structured api.Spec (the +// web UI's rich runtime overrides) onto the rendered template. +func renderPrompt(ctx context.Context, id string, renderReq PromptRenderRequest) (PromptRenderResult, error) { + if strings.TrimSpace(id) == "" { + return renderEphemeralPrompt(renderReq) + } + record, err := resolvePromptRecord(ctx, id) + if err != nil { + return PromptRenderResult{}, err + } + content, err := readPromptContent(record) + if err != nil { + return PromptRenderResult{}, err + } + vars := renderReq.Variables + if vars == nil { + vars = map[string]any{} + } + req, cfg, err := promptlib.Load(content).Render(vars, nil) + if err != nil { + return PromptRenderResult{}, err + } + req.Prompt.Source = record.Rel + if renderReq.Spec != nil { + overlayRuntimeSpec(&req, &cfg, *renderReq.Spec) + } + if err := applyPromptDefaults(&req, &cfg); err != nil { + return PromptRenderResult{}, err + } + cwd, err := os.Getwd() + if err != nil { + return PromptRenderResult{}, fmt.Errorf("get working directory: %w", err) + } + if err := normalizePromptContextDir(&req, cwd); err != nil { + return PromptRenderResult{}, err + } + return finalizeRenderResult(record, content, req, cfg) +} + +func renderEphemeralPrompt(renderReq PromptRenderRequest) (PromptRenderResult, error) { + record := promptRecord{ + Source: promptSource{Kind: "ephemeral", ID: "ephemeral", Label: "Ephemeral"}, + ID: "", + Path: "", + Rel: "scratch.prompt", + } + content := ephemeralPromptContent() + var req ai.Request + var cfg ai.Config + if renderReq.Spec != nil { + overlayRuntimeSpec(&req, &cfg, *renderReq.Spec) + } + if req.Prompt.Source == "" { + req.Prompt.Source = "" + } + if err := applyPromptDefaults(&req, &cfg); err != nil { + return PromptRenderResult{}, err + } + cwd, err := os.Getwd() + if err != nil { + return PromptRenderResult{}, fmt.Errorf("get working directory: %w", err) + } + if err := normalizePromptContextDir(&req, cwd); err != nil { + return PromptRenderResult{}, err + } + return finalizeRenderResult(record, content, req, cfg) +} + +func ephemeralPromptContent() string { + return `--- +name: Scratch Prompt +description: Ephemeral prompt +--- +{{role "user"}} +` +} + +// renderPromptCLI is the CLI render path: load from id | .prompt filepath | -p | +// stdin and overlay the flat CLI flags (overlayCLI). +func renderPromptCLI(ctx context.Context, id string, opts AIPromptOptions, varsJSON, stdin string) (PromptRenderResult, error) { + content, source, usedStdin, record, err := loadPromptContent(ctx, id, opts, stdin) + if err != nil { + return PromptRenderResult{}, err + } + vars, err := promptVars(opts, varsJSON, stdin, usedStdin) + if err != nil { + return PromptRenderResult{}, err + } + req, cfg, err := renderLoadedContent(content, source, vars, opts) + if err != nil { + return PromptRenderResult{}, err + } + return finalizeRenderResult(record, content, req, cfg) +} + +// finalizeRenderResult packages the rendered request/config + prompt detail into +// a PromptRenderResult and sets the validation error (shared by both paths). +func finalizeRenderResult(record promptRecord, content string, req ai.Request, cfg ai.Config) (PromptRenderResult, error) { + // Normalize a comma-separated model into a clean primary + fallbacks so the + // displayed Model is a single name, then catch a mistyped model (primary or any + // fallback) at render time, not just on run. + req.Model = req.ExpandCSV() + cfg.Model = cfg.Model.ExpandCSV() + var err error + req.Model, err = ai.ResolveModelSelectors(req.Model) + if err != nil { + return PromptRenderResult{}, err + } + cfg.Model, err = ai.ResolveModelSelectors(cfg.Model) + if err != nil { + return PromptRenderResult{}, err + } + for _, c := range cfg.Model.Candidates() { + warnIfLikelyModelTypo(c.Name) + } + detail, err := promptDetailFromContent(record, content) + if err != nil { + return PromptRenderResult{}, err + } + result := PromptRenderResult{ + ID: detail.ID, + Name: detail.Name, + Model: cfg.Model.Name, + Backend: string(cfg.Model.Backend), + User: req.Prompt.User, + System: req.Prompt.System, + Input: req, + Config: cfg, + InputSchema: detail.InputSchema, + InputDefault: detail.InputDefault, + OutputSchema: detail.OutputSchema, + } + switch { + case req.Prompt.User == "" && len(req.Prompt.Attachments) == 0 && !req.IsVerifyOnly(): + result.ValidationError = "prompt text or attachment required" + case cfg.Model.Name == "": + result.ValidationError = "no model: set prompt frontmatter, pass a model override, or run 'captain configure'" + default: + if err := req.Validate(); err != nil { + result.ValidationError = err.Error() + } + } + return result, nil +} + +func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { + if spec.Name != "" { + req.Name = spec.Name + cfg.Model.Name = spec.Name + req.ID = spec.ID + cfg.Model.ID = spec.ID + req.Backend = spec.Backend + cfg.Model.Backend = spec.Backend + } else { + if spec.ID != "" { + req.ID = spec.ID + cfg.Model.ID = spec.ID + } + if spec.Backend != "" { + req.Backend = spec.Backend + cfg.Model.Backend = spec.Backend + } + } + if spec.Temperature != nil { + req.Temperature = spec.Temperature + cfg.Model.Temperature = spec.Temperature + } + if spec.Effort != "" { + req.Effort = spec.Effort + cfg.Model.Effort = spec.Effort + } + if len(spec.Fallbacks) > 0 { + req.Fallbacks = spec.Fallbacks + cfg.Model.Fallbacks = spec.Fallbacks + } + req.NoCache = req.NoCache || spec.NoCache + cfg.NoCache = cfg.NoCache || spec.NoCache + if spec.Budget.Cost > 0 { + req.Budget.Cost = spec.Budget.Cost + cfg.Budget.Cost = spec.Budget.Cost + } + if spec.Budget.MaxTokens > 0 { + req.Budget.MaxTokens = spec.Budget.MaxTokens + cfg.Budget.MaxTokens = spec.Budget.MaxTokens + } + if spec.Budget.MaxTurns > 0 { + req.Budget.MaxTurns = spec.Budget.MaxTurns + cfg.Budget.MaxTurns = spec.Budget.MaxTurns + } + if spec.Budget.Timeout != "" { + req.Budget.Timeout = spec.Budget.Timeout + cfg.Budget.Timeout = spec.Budget.Timeout + } + + if spec.Prompt.User != "" { + req.Prompt.User = spec.Prompt.User + } + if spec.Prompt.System != "" { + req.Prompt.System = spec.Prompt.System + } + if spec.Prompt.AppendSystem != "" { + req.Prompt.AppendSystem = spec.Prompt.AppendSystem + } + if spec.Prompt.Source != "" { + req.Prompt.Source = spec.Prompt.Source + } + req.Prompt.Metadata = mergeStringMaps(req.Prompt.Metadata, spec.Prompt.Metadata) + if len(spec.Prompt.SchemaJSON) > 0 { + req.Prompt.SchemaJSON = spec.Prompt.SchemaJSON + } + if spec.Prompt.SchemaStrictness != "" { + req.Prompt.SchemaStrictness = spec.Prompt.SchemaStrictness + } + if spec.Workflow != nil { + req.Workflow = spec.Workflow + } + + if spec.Permissions.Mode != "" { + req.Permissions.Mode = spec.Permissions.Mode + } + req.Permissions.Presets = mergePresets(req.Permissions.Presets, spec.Permissions.Presets) + toolPolicies := spec.Permissions.Tools.Policies() + if len(toolPolicies) > 0 { + req.Permissions.Tools.Allow = nil + req.Permissions.Tools.Deny = nil + req.Permissions.Tools.Modes = nil + for _, tool := range sortedStringKeys(toolPolicies) { + switch toolPolicies[tool] { + case api.ToolPolicyAllow: + req.Permissions.Tools.Allow = append(req.Permissions.Tools.Allow, tool) + case api.ToolPolicyDeny: + req.Permissions.Tools.Deny = append(req.Permissions.Tools.Deny, tool) + case api.ToolPolicyAsk: + if req.Permissions.Tools.Modes == nil { + req.Permissions.Tools.Modes = map[string]api.ToolMode{} + } + req.Permissions.Tools.Modes[tool] = api.ToolModeAsk + case api.ToolPolicyAuto: + if req.Permissions.Tools.Modes == nil { + req.Permissions.Tools.Modes = map[string]api.ToolMode{} + } + req.Permissions.Tools.Modes[tool] = api.ToolModeOn + } + } + } + req.Permissions.Tools.Modes = mergeToolModes(req.Permissions.Tools.Modes, spec.Permissions.Tools.Modes) + req.Permissions.MCP.Disabled = req.Permissions.MCP.Disabled || spec.Permissions.MCP.Disabled + if servers := spec.Permissions.MCP.EnabledServers(); len(servers) > 0 { + req.Permissions.MCP.Servers = servers + } + if len(spec.Permissions.Plugins) > 0 { + req.Permissions.Plugins = enabledResourcePolicies(spec.Permissions.Plugins) + } + + skills := append([]string(nil), spec.Memory.Skills...) + skills = append(skills, spec.Permissions.Skills.Enabled()...) + if len(skills) > 0 { + req.Memory.Skills = dedupeStrings(skills) + } + req.Memory.SkipProject = req.Memory.SkipProject || spec.Memory.SkipProject + req.Memory.SkipUser = req.Memory.SkipUser || spec.Memory.SkipUser + req.Memory.SkipSkills = req.Memory.SkipSkills || spec.Memory.SkipSkills + req.Memory.SkipHooks = req.Memory.SkipHooks || spec.Memory.SkipHooks + req.Memory.SkipMemory = req.Memory.SkipMemory || spec.Memory.SkipMemory + req.Memory.Bare = req.Memory.Bare || spec.Memory.Bare + + if spec.Setup != nil { + req.Setup = spec.Setup + } + + if spec.SessionID != "" { + req.SessionID = spec.SessionID + cfg.SessionID = spec.SessionID + } +} diff --git a/pkg/cli/prompt_render_test.go b/pkg/cli/prompt_render_test.go new file mode 100644 index 0000000..7599c21 --- /dev/null +++ b/pkg/cli/prompt_render_test.go @@ -0,0 +1,219 @@ +package cli + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/commons-db/shell" +) + +func TestRenderPromptAppliesRuntimeSpec(t *testing.T) { + isolateCaptainConfig(t) + + dir := t.TempDir() + cwd := t.TempDir() + t.Chdir(cwd) + ctx := ContextWithPromptDirs(context.Background(), []string{dir}) + content := `--- +name: Runtime Spec +model: claude-sonnet-4-6 +--- +{{role "user"}} +Hello {{name}} +` + created, err := createPrompt(ctx, map[string]any{ + "name": "Runtime Spec", + "content": content, + }) + if err != nil { + t.Fatalf("createPrompt() err = %v", err) + } + + temp := 0.2 + rendered, err := renderPrompt(ctx, created.ID, PromptRenderRequest{ + Variables: map[string]any{"name": "Ada"}, + Spec: &api.Spec{ + Model: api.Model{ + Name: "gpt-4o", + ID: "openai/gpt-4o", + Backend: api.BackendOpenAI, + Temperature: &temp, + Effort: api.EffortLow, + NoCache: true, + }, + Prompt: api.Prompt{ + System: "runtime system", + AppendSystem: "runtime append", + Source: "runtime-source", + Metadata: map[string]string{"surface": "prompt-ui"}, + }, + Budget: api.Budget{Cost: 0.5, MaxTokens: 1234, MaxTurns: 4, Timeout: "90s"}, + Permissions: api.Permissions{ + Mode: api.PermissionAcceptEdits, + Presets: []api.Preset{api.PresetEdit}, + Tools: api.Tools{ + Allow: []string{"Read"}, + Deny: []string{"Bash"}, + Modes: map[string]api.ToolMode{"Bash": api.ToolModeOff}, + }, + MCP: api.MCP{ + Disabled: true, + Servers: []string{"filesystem"}, + Modes: api.ResourcePolicies{"gavel": api.ResourceDisabled}, + }, + Plugins: api.ResourcePolicies{"/plugins": api.ResourceEnabled}, + Skills: api.ResourcePolicies{"/permission-skills": api.ResourceEnabled}, + }, + Memory: api.Memory{ + Skills: []string{"/skills"}, + SkipUser: true, + SkipMemory: true, + Bare: true, + }, + Setup: &shell.Setup{ + Cwd: "workspace", + DotEnv: []string{".env"}, + Checkout: &shell.Checkout{ + Mode: shell.CheckoutLocal, + Path: "/repo", + Ref: "abc123", + Worktree: &shell.Worktree{ + Mode: shell.WorktreeNew, + Prefix: "runtime-branch", + Keep: true, + }, + }, + }, + SessionID: "sess-runtime", + }, + }) + if err != nil { + t.Fatalf("renderPrompt() err = %v", err) + } + if rendered.ValidationError != "" { + t.Fatalf("render validation error = %q", rendered.ValidationError) + } + if rendered.Model != "gpt-4o" || rendered.Backend != "openai" { + t.Fatalf("rendered model/backend = %s/%s, want gpt-4o/openai", rendered.Model, rendered.Backend) + } + if rendered.Config.Model.ID != "openai/gpt-4o" { + t.Fatalf("config model ID = %q, want openai/gpt-4o", rendered.Config.Model.ID) + } + if rendered.Input.Temperature == nil || *rendered.Input.Temperature != temp { + t.Fatalf("temperature = %v, want %v", rendered.Input.Temperature, temp) + } + if rendered.Input.Budget.Cost != 0.5 || rendered.Input.Budget.MaxTokens != 1234 || + rendered.Input.Budget.MaxTurns != 4 || rendered.Input.Budget.Timeout != "90s" { + t.Fatalf("budget = %+v, want cost/maxTokens override", rendered.Input.Budget) + } + if !rendered.Input.Model.NoCache || !rendered.Config.NoCache { + t.Fatalf("noCache = input %v config %v, want true", rendered.Input.Model.NoCache, rendered.Config.NoCache) + } + if rendered.Input.Prompt.System != "runtime system" || rendered.Input.Prompt.AppendSystem != "runtime append" { + t.Fatalf("prompt system fields = %+v, want runtime overrides", rendered.Input.Prompt) + } + if rendered.Input.Prompt.Source != "runtime-source" || rendered.Input.Prompt.Metadata["surface"] != "prompt-ui" { + t.Fatalf("prompt source/metadata = %+v, want runtime overrides", rendered.Input.Prompt) + } + if rendered.Input.Permissions.Mode != api.PermissionAcceptEdits || + rendered.Input.Permissions.Tools.Modes["Bash"] != api.ToolModeOff || + !rendered.Input.Permissions.MCP.Disabled { + t.Fatalf("permissions = %+v, want runtime overrides", rendered.Input.Permissions) + } + if rendered.Input.Permissions.Plugins["/plugins"] != api.ResourceEnabled { + t.Fatalf("plugins = %+v, want enabled runtime plugin", rendered.Input.Permissions.Plugins) + } + if !strings.Contains(strings.Join(rendered.Input.Memory.Skills, ","), "/permission-skills") { + t.Fatalf("skills = %+v, want permission skills merged into memory skills", rendered.Input.Memory.Skills) + } + if !rendered.Input.Memory.SkipUser || !rendered.Input.Memory.SkipMemory || !rendered.Input.Memory.Bare { + t.Fatalf("memory = %+v, want runtime overrides", rendered.Input.Memory) + } + if rendered.Input.Cwd() != filepath.Join(cwd, "workspace") { + t.Fatalf("setup cwd = %q, want cwd-relative runtime dir", rendered.Input.Cwd()) + } + if rendered.Input.Setup == nil || rendered.Input.Setup.Checkout == nil || rendered.Input.Setup.Checkout.Ref != "abc123" { + t.Fatalf("setup checkout = %+v, want runtime git checkout overlay", rendered.Input.Setup) + } + if rendered.Input.Setup.Checkout.Worktree == nil || !rendered.Input.Setup.Checkout.Worktree.Keep { + t.Fatalf("worktree setup = %+v, want runtime worktree overlay", rendered.Input.Setup.Checkout.Worktree) + } + if rendered.Input.SessionID != "sess-runtime" { + t.Fatalf("runtime session = input=%+v", rendered.Input) + } +} + +func TestRenderPromptEphemeralSpec(t *testing.T) { + isolateCaptainConfig(t) + + cwd := t.TempDir() + t.Chdir(cwd) + temp := 0.1 + rendered, err := renderPrompt(context.Background(), "", PromptRenderRequest{ + Spec: &api.Spec{ + Model: api.Model{ + Name: "gpt-5.5", + Backend: api.BackendCodexAgent, + Temperature: &temp, + Effort: api.EffortHigh, + }, + Prompt: api.Prompt{ + System: "scratch system", + User: "Draft a deployment plan", + }, + Budget: api.Budget{Timeout: "2h"}, + }, + }) + if err != nil { + t.Fatalf("renderPrompt() err = %v", err) + } + if rendered.ValidationError != "" { + t.Fatalf("render validation error = %q", rendered.ValidationError) + } + if rendered.ID != "" || rendered.Name != "Scratch Prompt" { + t.Fatalf("rendered prompt identity = id %q name %q, want scratch prompt", rendered.ID, rendered.Name) + } + if rendered.User != "Draft a deployment plan" || rendered.System != "scratch system" { + t.Fatalf("rendered prompt = user %q system %q", rendered.User, rendered.System) + } + if rendered.Model != "gpt-5.5" || rendered.Backend != "codex-agent" { + t.Fatalf("rendered model/backend = %s/%s, want gpt-5.5/codex-agent", rendered.Model, rendered.Backend) + } + if rendered.Input.Prompt.Source != "" { + t.Fatalf("prompt source = %q, want ", rendered.Input.Prompt.Source) + } + if rendered.Input.Cwd() != cwd { + t.Fatalf("setup cwd = %q, want %q", rendered.Input.Cwd(), cwd) + } +} + +func TestApplyPromptDefaultsSelectorEffortWins(t *testing.T) { + isolateCaptainConfig(t) + req := ai.Request{Model: api.Model{Effort: api.EffortLow}} + cfg := ai.Config{Model: api.Model{ + Name: "gpt-5.6-sol", + Backend: api.BackendCodexAgent, + Effort: api.EffortHigh, + }} + if err := applyPromptDefaults(&req, &cfg); err != nil { + t.Fatalf("applyPromptDefaults: %v", err) + } + if req.Name != "gpt-5.6-sol" || req.Backend != api.BackendCodexAgent || req.Effort != api.EffortHigh { + t.Fatalf("request = %+v, want selector model/effort", req.Model) + } + if cfg.Model.Effort != api.EffortHigh { + t.Fatalf("config effort = %q, want high", cfg.Model.Effort) + } +} + +func isolateCaptainConfig(t *testing.T) { + t.Helper() + path := filepath.Join(t.TempDir(), ".captain.yaml") + captainconfig.SetPathForTesting(path) + t.Cleanup(func() { captainconfig.SetPathForTesting("") }) +} diff --git a/pkg/cli/prompt_sources.go b/pkg/cli/prompt_sources.go new file mode 100644 index 0000000..b81e88a --- /dev/null +++ b/pkg/cli/prompt_sources.go @@ -0,0 +1,254 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + promptlib "github.com/flanksource/captain/pkg/ai/prompt" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/clicky" + clickyapi "github.com/flanksource/clicky/api" +) + +func buildPromptSources(ctx context.Context) ([]promptSource, error) { + sources := []promptSource{{ + Kind: "embedded", + ID: "embedded", + Label: "Embedded examples", + WalkRoot: "testdata", + FS: promptlib.Examples, + Writable: false, + }} + + seen := map[string]bool{} + addLocal := func(raw, base string) error { + dir, err := resolvePromptDir(raw, base) + if err != nil { + return err + } + if seen[dir] { + return nil + } + seen[dir] = true + sources = append(sources, promptSource{ + Kind: "local", + ID: hashPromptDir(dir), + Label: dir, + Root: dir, + Writable: true, + }) + return nil + } + + cfg, exists, err := captainconfig.Load() + if err != nil { + return nil, err + } + if exists { + configPath, err := captainconfig.Path() + if err != nil { + return nil, err + } + base := filepath.Dir(configPath) + for _, dir := range cfg.Prompts.Dirs { + if err := addLocal(dir, base); err != nil { + return nil, err + } + } + } + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + for _, dir := range promptDirsFromContext(ctx) { + if err := addLocal(dir, cwd); err != nil { + return nil, err + } + } + if _, ok := firstWritableSource(sources); !ok { + dir := filepath.Join(cwd, ".captain", "prompts") + sources = append(sources, promptSource{ + Kind: "local", + ID: hashPromptDir(dir), + Label: dir, + Root: dir, + Writable: true, + Implicit: true, + }) + } + return sources, nil +} + +func promptDirsFromContext(ctx context.Context) []string { + if ctx == nil { + return nil + } + if dirs, ok := ctx.Value(promptDirsContextKey{}).([]string); ok { + return dirs + } + return nil +} + +func resolvePromptDir(raw, base string) (string, error) { + dir := strings.TrimSpace(raw) + if dir == "" { + return "", fmt.Errorf("prompt dir cannot be empty") + } + if strings.HasPrefix(dir, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + switch { + case dir == "~": + dir = home + case strings.HasPrefix(dir, "~/"): + dir = filepath.Join(home, dir[2:]) + default: + return "", fmt.Errorf("unsupported home-relative prompt dir %q", raw) + } + } + if !filepath.IsAbs(dir) { + dir = filepath.Join(base, dir) + } + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("prompt dir %s: %w", abs, err) + } + if !info.IsDir() { + return "", fmt.Errorf("prompt dir %s is not a directory", abs) + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + return filepath.Clean(abs), nil +} + +func writableSourceByID(sources []promptSource, id string) (promptSource, bool) { + for _, source := range sources { + if source.ID == id && source.Writable { + return source, true + } + } + return promptSource{}, false +} + +func firstWritableSource(sources []promptSource) (promptSource, bool) { + for _, source := range sources { + if source.Writable { + return source, true + } + } + return promptSource{}, false +} + +func safeLocalPromptPath(source promptSource, rel string) (string, error) { + cleanRel := strings.TrimPrefix(filepath.Clean(filepath.FromSlash(rel)), string(filepath.Separator)) + if cleanRel == "." || cleanRel == "" || filepath.IsAbs(rel) || strings.HasPrefix(cleanRel, ".."+string(filepath.Separator)) || cleanRel == ".." { + return "", fmt.Errorf("invalid prompt path %q", rel) + } + if filepath.Ext(cleanRel) != ".prompt" { + return "", fmt.Errorf("prompt path must end with .prompt") + } + full := filepath.Join(source.Root, cleanRel) + abs, err := filepath.Abs(full) + if err != nil { + return "", err + } + relToRoot, err := filepath.Rel(source.Root, abs) + if err != nil { + return "", err + } + if strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) || relToRoot == ".." || filepath.IsAbs(relToRoot) { + return "", fmt.Errorf("prompt path escapes source root") + } + if info, err := os.Lstat(abs); err == nil && info.Mode()&fs.ModeSymlink != 0 { + return "", fmt.Errorf("prompt symlinks are not supported") + } + return abs, nil +} + +func normalizeWriteRelPath(relPath, name string) (string, error) { + rel := strings.TrimSpace(relPath) + if rel == "" { + rel = slugPromptName(name) + } + if rel == "" { + return "", fmt.Errorf("prompt name or path required") + } + if !strings.HasSuffix(rel, ".prompt") { + rel += ".prompt" + } + rel = filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) + if strings.HasPrefix(rel, "../") || rel == ".." || strings.HasPrefix(rel, "/") { + return "", fmt.Errorf("invalid prompt path %q", relPath) + } + return rel, nil +} + +func slugPromptName(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + var b strings.Builder + lastDash := false + for _, r := range name { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteRune('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + +func encodePromptID(kind, sourceID, rel string) string { + return base64.RawURLEncoding.EncodeToString([]byte(kind + "\x00" + sourceID + "\x00" + filepath.ToSlash(rel))) +} + +func decodePromptID(id string) (promptRef, error) { + data, err := base64.RawURLEncoding.DecodeString(id) + if err != nil { + return promptRef{}, fmt.Errorf("invalid prompt id: %w", err) + } + parts := strings.SplitN(string(data), "\x00", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return promptRef{}, fmt.Errorf("invalid prompt id") + } + return promptRef{Kind: parts[0], SourceID: parts[1], RelPath: filepath.ToSlash(parts[2])}, nil +} + +func hashPromptDir(dir string) string { + sum := sha256.Sum256([]byte(dir)) + return hex.EncodeToString(sum[:])[:12] +} + +func ValidatePromptDirs(dirs []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + for _, dir := range dirs { + if _, err := resolvePromptDir(dir, cwd); err != nil { + return err + } + } + return nil +} + +var _ clicky.EntityItem = PromptSummary{} +var _ clickyapi.TableProvider = PromptSummary{} diff --git a/pkg/cli/prompt_spec.go b/pkg/cli/prompt_spec.go new file mode 100644 index 0000000..0d94414 --- /dev/null +++ b/pkg/cli/prompt_spec.go @@ -0,0 +1,241 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/collections" + clickyrpc "github.com/flanksource/clicky/rpc" +) + +func applyPromptDefaults(req *ai.Request, cfg *ai.Config) error { + savedCfg := loadSavedConfig() + saved := savedCfg.AI + promptModel := req.Model + if promptModel.Name == "" { + promptModel.Name = cfg.Model.Name + } + if promptModel.ID == "" { + promptModel.ID = cfg.Model.ID + } + if promptModel.Backend == "" { + promptModel.Backend = cfg.Model.Backend + } + identity := selectModelIdentity( + api.Model{Name: promptModel.Name, ID: promptModel.ID, Backend: promptModel.Backend}, + ) + req.Name, req.ID, req.Backend = identity.Name, identity.ID, identity.Backend + if cfg.Model.Effort != api.EffortNone { + // An effort-qualified model selector (for example agent:sol:high) + // is model-local and intentionally overrides the request-wide flag/default. + req.Effort = cfg.Model.Effort + } else if req.Effort == "" { + req.Effort = cfg.Model.Effort + } + resolved, err := applyProviderDefaults(req.Model, saved) + if err != nil { + return err + } + req.Model = resolved + req.NoCache = req.NoCache || saved.NoCache + if req.Budget.MaxTokens == 0 { + req.Budget.MaxTokens = firstPositive(cfg.Budget.MaxTokens, saved.MaxTokens, 4096) + } + if req.Budget.Cost == 0 { + req.Budget.Cost = firstPositiveFloat(cfg.Budget.Cost, saved.BudgetUSD) + } + + cfg.Model = req.Model + cfg.Budget = req.Budget + cfg.NoCache = req.NoCache + if isZeroSchemaRepair(cfg.SchemaRepair) { + cfg.SchemaRepair = schemaRepairConfig(savedCfg.Prompts.SchemaRepair) + } + return nil +} + +func mergeStringMaps(base, overlay map[string]string) map[string]string { + if len(overlay) == 0 { + return base + } + out := make(map[string]string, collections.SafeAdd(len(base), len(overlay))) + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + out[k] = v + } + return out +} + +func mergeToolModes(base, overlay map[string]api.ToolMode) map[string]api.ToolMode { + if len(overlay) == 0 { + return base + } + out := make(map[string]api.ToolMode, collections.SafeAdd(len(base), len(overlay))) + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + out[k] = v + } + return out +} + +func mergePresets(base, overlay []api.Preset) []api.Preset { + if len(overlay) == 0 { + return base + } + seen := make(map[api.Preset]bool, collections.SafeAdd(len(base), len(overlay))) + out := make([]api.Preset, 0, collections.SafeAdd(len(base), len(overlay))) + for _, preset := range base { + if seen[preset] { + continue + } + seen[preset] = true + out = append(out, preset) + } + for _, preset := range overlay { + if seen[preset] { + continue + } + seen[preset] = true + out = append(out, preset) + } + return out +} + +func sortedStringKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for key := range m { + if key != "" { + keys = append(keys, key) + } + } + sort.Strings(keys) + return keys +} + +func enabledResourcePolicies(in api.ResourcePolicies) api.ResourcePolicies { + out := api.ResourcePolicies{} + for _, key := range sortedStringKeys(in) { + if in[key] == api.ResourceEnabled { + out[key] = api.ResourceEnabled + } + } + return out +} + +func dedupeStrings(in []string) []string { + seen := map[string]bool{} + var out []string + for _, item := range in { + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} + +func readRenderRequest(ctx context.Context, flags map[string]string) (PromptRenderRequest, error) { + var req PromptRenderRequest + if err := decodePromptBody(ctx, map[string]any{}, &req); err != nil { + return PromptRenderRequest{}, err + } + if req.Variables == nil { + req.Variables = map[string]any{} + } + if err := mergePromptActionFlags(&req, flags); err != nil { + return PromptRenderRequest{}, err + } + return req, nil +} + +func mergePromptActionFlags(req *PromptRenderRequest, flags map[string]string) error { + if len(flags) == 0 { + return nil + } + if raw := strings.TrimSpace(flags["vars"]); raw != "" { + var vars map[string]any + if err := json.Unmarshal([]byte(raw), &vars); err != nil { + return fmt.Errorf("parse --vars JSON: %w", err) + } + req.Variables = vars + } + if v := strings.TrimSpace(flags["model"]); v != "" { + ensureRenderSpec(req).Name = v + } + if v := strings.TrimSpace(flags["fallback"]); v != "" { + ensureRenderSpec(req).Fallbacks = fallbackModelsFromFlags([]string{v}) + } + if v := strings.TrimSpace(flags["backend"]); v != "" { + ensureRenderSpec(req).Backend = api.Backend(v) + } + if v := strings.TrimSpace(flags["timeout"]); v != "" { + ensureRenderSpec(req).Budget.Timeout = v + } + if v := strings.TrimSpace(flags["max-tokens"]); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("invalid --max-tokens %q: %w", v, err) + } + ensureRenderSpec(req).Budget.MaxTokens = n + } + return nil +} + +func ensureRenderSpec(req *PromptRenderRequest) *api.Spec { + if req.Spec == nil { + req.Spec = &api.Spec{} + } + return req.Spec +} + +func decodePromptBody(ctx context.Context, flat map[string]any, dst any) error { + if r, ok := clickyrpc.RequestFromContext(ctx); ok && r.Body != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("read request body: %w", err) + } + r.Body = io.NopCloser(strings.NewReader(string(body))) + if len(strings.TrimSpace(string(body))) > 0 { + decoder := json.NewDecoder(strings.NewReader(string(body))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return fmt.Errorf("decode request body: %w", err) + } + return nil + } + } + if len(flat) == 0 { + return nil + } + data, err := json.Marshal(flat) + if err != nil { + return err + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return fmt.Errorf("decode command body: %w", err) + } + return nil +} + +func runtimeTimeout(raw string) time.Duration { + timeout, _ := time.ParseDuration(raw) + if timeout <= 0 { + return 120 * time.Second + } + return timeout +} From 9db9d9db818f24c5505b2247c69aece8542d47be Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 09:41:14 +0300 Subject: [PATCH 096/131] fix(api): Omit empty prompt specification fields during serialization Prevent empty prompt sections and required string fields from producing noisy JSON/YAML output while preserving populated values. --- pkg/api/prompt.go | 2 +- pkg/api/registry/model.go | 2 +- pkg/api/spec.go | 102 ++++++++++++++++++++++++++++ pkg/api/spec_marshal_ginkgo_test.go | 43 ++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 pkg/api/spec_marshal_ginkgo_test.go diff --git a/pkg/api/prompt.go b/pkg/api/prompt.go index bc93cc1..3172c9d 100644 --- a/pkg/api/prompt.go +++ b/pkg/api/prompt.go @@ -12,7 +12,7 @@ import ( // 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"` + User string `json:"user,omitempty" yaml:"user,omitempty" 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) diff --git a/pkg/api/registry/model.go b/pkg/api/registry/model.go index ffff7b2..426289d 100644 --- a/pkg/api/registry/model.go +++ b/pkg/api/registry/model.go @@ -17,7 +17,7 @@ const CodexAutoReviewModel = "codex-auto-review" 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"` + Name string `json:"model,omitempty" yaml:"model,omitempty" 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. diff --git a/pkg/api/spec.go b/pkg/api/spec.go index a320645..8e8c19d 100644 --- a/pkg/api/spec.go +++ b/pkg/api/spec.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "fmt" + "reflect" "strings" "github.com/flanksource/commons-db/shell" @@ -41,6 +43,106 @@ type Spec struct { CLIArgs map[string]any `json:"cliArgs,omitempty" yaml:"cliArgs,omitempty"` } +type specMarshal struct { + Model `json:",inline" yaml:",inline"` + Prompt *Prompt `json:"prompt,omitempty" yaml:"prompt,omitempty"` + Messages []Message `json:"messages,omitempty" yaml:"messages,omitempty"` + Budget *Budget `json:"budget,omitempty" yaml:"budget,omitempty"` + Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` + Permissions *Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty"` + Preferences *ToolPreferences `json:"toolPreferences,omitempty" yaml:"toolPreferences,omitempty"` + Approval *ToolApprovalResume `json:"toolApproval,omitempty" yaml:"toolApproval,omitempty"` + Setup *shell.Setup `json:"setup,omitempty" yaml:"setup,omitempty"` + Workflow *Workflow `json:"workflow,omitempty" yaml:"workflow,omitempty"` + SessionID string `json:"sessionId,omitempty" yaml:"sessionId,omitempty"` + CLIArgs map[string]any `json:"cliArgs,omitempty" yaml:"cliArgs,omitempty"` +} + +func isEmpty(value reflect.Value) bool { + if !value.IsValid() { + return true + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return true + } + value = value.Elem() + } + if value.CanInterface() { + switch typed := value.Interface().(type) { + case Tools: + return len(typed.Policies()) == 0 + case MCP: + return !typed.Disabled && len(typed.Servers) == 0 && len(typed.Modes) == 0 + } + } + switch value.Kind() { + case reflect.Array: + for i := range value.Len() { + if !isEmpty(value.Index(i)) { + return false + } + } + return true + case reflect.Map, reflect.Slice, reflect.String: + return value.Len() == 0 + case reflect.Struct: + exported := 0 + for i := range value.NumField() { + field := value.Type().Field(i) + if !field.IsExported() || field.Tag.Get("json") == "-" || field.Tag.Get("yaml") == "-" { + continue + } + exported++ + if !isEmpty(value.Field(i)) { + return false + } + } + return exported > 0 || value.IsZero() + default: + return value.IsZero() + } +} + +func omitEmptyValue[T any](value T) *T { + if isEmpty(reflect.ValueOf(value)) { + return nil + } + return &value +} + +func omitEmptyPointer[T any](value *T) *T { + if isEmpty(reflect.ValueOf(value)) { + return nil + } + return value +} + +func (s Spec) marshalValue() specMarshal { + return specMarshal{ + Model: s.Model, + Prompt: omitEmptyValue(s.Prompt), + Messages: s.Messages, + Budget: omitEmptyValue(s.Budget), + Memory: omitEmptyValue(s.Memory), + Permissions: omitEmptyValue(s.Permissions), + Preferences: omitEmptyValue(s.ToolPreferences), + Approval: omitEmptyPointer(s.ToolApproval), + Setup: omitEmptyPointer(s.Setup), + Workflow: omitEmptyPointer(s.Workflow), + SessionID: s.SessionID, + CLIArgs: s.CLIArgs, + } +} + +func (s Spec) MarshalJSON() ([]byte, error) { + return json.Marshal(s.marshalValue()) +} + +func (s Spec) MarshalYAML() (any, error) { + return s.marshalValue(), nil +} + // Validate runs each component's validation, failing loud on the first error. func (s Spec) Validate() error { if err := s.Model.Validate(); err != nil { diff --git a/pkg/api/spec_marshal_ginkgo_test.go b/pkg/api/spec_marshal_ginkgo_test.go new file mode 100644 index 0000000..ec0b511 --- /dev/null +++ b/pkg/api/spec_marshal_ginkgo_test.go @@ -0,0 +1,43 @@ +package api + +import ( + "encoding/json" + + "github.com/flanksource/commons-db/shell" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" +) + +var _ = Describe("Spec serialization", func() { + DescribeTable("omits empty strings and sections", + func(marshal func(any) ([]byte, error), decode func([]byte, any) error) { + encoded, err := marshal(Spec{ + Memory: Memory{Skills: []string{}}, + Permissions: Permissions{Tools: Tools{Modes: map[string]ToolMode{}}}, + Setup: &shell.Setup{}, + Workflow: &Workflow{}, + }) + Expect(err).NotTo(HaveOccurred()) + + var decoded map[string]any + Expect(decode(encoded, &decoded)).To(Succeed()) + Expect(decoded).To(BeEmpty()) + }, + Entry("JSON", json.Marshal, json.Unmarshal), + Entry("YAML", yaml.Marshal, yaml.Unmarshal), + ) + + DescribeTable("preserves non-empty values while omitting empty neighbors", + func(marshal func(any) ([]byte, error), decode func([]byte, any) error) { + encoded, err := marshal(Spec{Model: Model{Name: "opus"}}) + Expect(err).NotTo(HaveOccurred()) + + var decoded map[string]any + Expect(decode(encoded, &decoded)).To(Succeed()) + Expect(decoded).To(Equal(map[string]any{"model": "opus"})) + }, + Entry("JSON", json.Marshal, json.Unmarshal), + Entry("YAML", yaml.Marshal, yaml.Unmarshal), + ) +}) From 1cc7db8a3c0aac07f47d09b5da62cb3d7b1c5651 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 15:55:05 +0300 Subject: [PATCH 097/131] fix(ai): resolve the bare codex sentinel on the api mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--model api:codex` resolved to the literal string "codex" on backend openai rather than to a model: emptyFamily was set to a model id (gpt-5.6-sol), but it is matched against KnownModel.Family, so it matched no catalog row and the token fell through unresolved. It is a family name — "gpt". Which gpt model a bare "codex" lands on is decided by catalog priority (gpt-5.6-sol carries priority 1), not by naming it in emptyFamily, so the default is unchanged: api:codex and cli:codex both resolve to gpt-5.6-sol. Only the api mode was affected. The other modes take the agent-sentinel shortcut, which reads the catalog directly and never consults emptyFamily — which is why `codex` looked fine while `api:codex` did not. Pin the sentinel on both modes in the parse conformance suite, and pin the behaviour of the surrounding provider-defaults change: grok is no longer claimed by any provider, so assert it fails loud rather than dropping the cases (a silent re-claim would route models to a backend captain no longer drives); and a family-less claude token now resolves through emptyFamily to opus. --- pkg/ai/model_parse_conformance_test.go | 16 +++++++++++++--- pkg/ai/provider/claudeagent/mapping_test.go | 7 +++++-- pkg/api/registry/backend_test.go | 7 ++++++- pkg/api/registry/providers.go | 9 +++++++-- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/pkg/ai/model_parse_conformance_test.go b/pkg/ai/model_parse_conformance_test.go index 94330ca..0cba1e0 100644 --- a/pkg/ai/model_parse_conformance_test.go +++ b/pkg/ai/model_parse_conformance_test.go @@ -88,9 +88,13 @@ var agreedCases = []parseCase{ {in: "codex", want: model("gpt-5.6-sol", api.BackendCodexCLI, "")}, {in: "claude", want: model("claude", api.BackendAnthropic, "")}, - // grok is served through the codex CLI and passes through verbatim. - {in: "grok-2", want: model("grok-2", api.BackendCodexCLI, "")}, - {in: "grok-code-fast-1", want: model("grok-code-fast-1", api.BackendCodexCLI, "")}, + // A sentinel with an explicit mode resolves too. This does NOT go through the + // agent-sentinel shortcut (that one only fires off the API mode), so it lands + // on the provider's emptyFamily — which must be a family name. An id there + // matched no catalog row and left "api:codex" as the literal "codex". + {in: "api:codex", want: model("gpt-5.6-sol", api.BackendOpenAI, "")}, + {in: "cli:codex", want: model("gpt-5.6-sol", api.BackendCodexCLI, "")}, + // A multi-slash id resolves off its LAST segment and keeps its name verbatim, // so OpenRouter-style proxied names survive. (api.InferBackend alone cannot do @@ -109,6 +113,12 @@ var agreedCases = []parseCase{ // it fails loud rather than resolving to something unusable. {in: "sora", wantErr: "pass an explicit backend"}, {in: "sora-2", wantErr: "pass an explicit backend"}, + + // grok mode was removed from the codex CLI, so no provider claims it. Pinned + // as failing rather than deleted: silently re-claiming grok would route models + // to a backend captain no longer drives. + {in: "grok-2", wantErr: "pass an explicit backend"}, + {in: "grok-code-fast-1", wantErr: "pass an explicit backend"}, } func expectModel(got api.Model, want api.Model) { diff --git a/pkg/ai/provider/claudeagent/mapping_test.go b/pkg/ai/provider/claudeagent/mapping_test.go index 28b79ad..9d568d8 100644 --- a/pkg/ai/provider/claudeagent/mapping_test.go +++ b/pkg/ai/provider/claudeagent/mapping_test.go @@ -219,8 +219,11 @@ func TestAliasModel(t *testing.T) { "claude-agent-opus": "claude-opus-4-8", "claude-code-haiku": "claude-haiku-4-5", "claude-sonnet-4-5": "claude-sonnet-4-6", - "claude-agent-": "claude-sonnet-5", - "": "claude-sonnet-5", + // A bare family-less token resolves through the provider's emptyFamily, + // which is opus (see registry/providers.go). The "" case is short-circuited + // by aliasModel itself and so still answers sonnet. + "claude-agent-": "claude-opus-4-8", + "": "claude-sonnet-5", "claude-agent-opus-4-1": "claude-opus-4-8", } for in, want := range cases { diff --git a/pkg/api/registry/backend_test.go b/pkg/api/registry/backend_test.go index 852c00b..36d47d5 100644 --- a/pkg/api/registry/backend_test.go +++ b/pkg/api/registry/backend_test.go @@ -17,7 +17,6 @@ func TestInferBackend(t *testing.T) { "gemini-2.5-pro": BackendGemini, "gemini-cli-pro": BackendGeminiCLI, "codex-gpt-5-codex": BackendCodexCLI, - "grok-2": BackendCodexCLI, "deepseek-chat": BackendDeepSeek, } for model, want := range cases { @@ -29,6 +28,12 @@ func TestInferBackend(t *testing.T) { if _, err := InferBackend("totally-unknown"); err == nil { t.Error("InferBackend(unknown) should fail loud") } + // grok is no longer claimed by any provider (the codex CLI's grok mode was + // removed). Pin the removal: a silent return of grok claiming would otherwise + // resurrect a backend captain no longer routes. + if _, err := InferBackend("grok-2"); err == nil { + t.Error("InferBackend(grok-2) should fail loud: grok mode was removed") + } } func TestEffortValidateIncludesCodexTiers(t *testing.T) { diff --git a/pkg/api/registry/providers.go b/pkg/api/registry/providers.go index b6ca9e9..b2dce7e 100644 --- a/pkg/api/registry/providers.go +++ b/pkg/api/registry/providers.go @@ -59,8 +59,13 @@ var ( identityTrim: []string{"codex-agent-", "codex-"}, families: []string{"gpt"}, emptyTokens: []string{"", "codex"}, - emptyFamily: "gpt-5.6-sol", - genConfig: openaiGenerationConfig, + // A family name, not a model id: emptyFamily is matched against + // KnownModel.Family, so an id here matches no row and the token falls + // through unresolved ("api:codex" stayed the literal "codex"). Which gpt + // model a bare "codex" lands on is decided by priority in the catalog — + // gpt-5.6-sol carries priority 1 — not by naming it here. + emptyFamily: "gpt", + genConfig: openaiGenerationConfig, } Google = &Provider{ From f2afde78b5a984174a3b6ec30c821dc71cf929f4 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 15:55:33 +0300 Subject: [PATCH 098/131] refactor(ai): extract model flag parsing into pkg/aiflags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every consumer that re-declares its own model flags and passes a model *string* loses what captain's structured Model already knew: gavel's `commit --model "agent:gpt-5.6-luna:medium"` runs as api:gpt-5.6-luna, because a populated Spec is flattened to .Model.Name and captain re-infers the backend from the bare name. Captain's own AIProviderOptions already solved this and gavel already embeds it for `lint --ai-fix` — but it lives in pkg/cli, which drags ~1400 packages (Postgres, k8s, AWS, go-git), so the paths that most need it hand-rolled their own instead. Move the model flags into pkg/aiflags, a leaf: 5 packages, against pkg/cli's 1449. Two facts make that cheap. api.Model is a type alias for registry.Model, so a package importing only the registry returns values interchangeable with api.Model at every call site. And captainconfig only ever used api.Backend, api.InferBackend and api.AnthropicProvider — all registry aliases — so pointing it at the registry drops it from 1171 packages to 4 and lets aiflags read ~/.captain.yaml directly, with no injection hook or interface. ModelFlags is exactly the flags that project onto registry.Model, and nothing else. Session ids, API keys and budgets describe the request, not the model, and keeping them out is what lets any command embed it without inheriting flags it has no business owning — `gavel todos run` has its own bool --resume and --max-budget. --api-key and --budget stay on AIProviderOptions, which now embeds ModelFlags; clicky promotes embedded flags at any depth, so captain's flag surface is unchanged. Adds --mode (api|cli|agent|cmux, sdk aliased to agent): the object form of the compact "agent:sonnet" prefix, so a backend can be chosen without string grammar. Effort and Temperature move off AIRuntimeOptions onto ModelFlags — leaving them would bind --effort twice and panic cobra at init. The mode is normalized before Model.validateMode, or --mode sdk would report a contradiction against backend claude-agent that does not exist; and an explicit --mode now suppresses the saved agent default, which would otherwise contradict it. LoadDefaults reports a broken config instead of swallowing it: whether to warn and carry on with zero defaults is a CLI policy, and it stays in pkg/cli where it already lived. That also keeps commons/logger — 55 packages of prometheus and fsnotify — out of the leaf. The struct only moved; the resolution is the same pipeline, with ai.ValidateModelEffort and ai.ResolveModelSelectors substituted for the registry functions they already delegate to. The 8 keyed literals it breaks are all in tests; no product code constructs these structs by key. --- pkg/aiflags/defaults.go | 185 +++++++++++++++++++++++ pkg/aiflags/flags.go | 201 +++++++++++++++++++++++++ pkg/captainconfig/config.go | 18 +-- pkg/cli/ai.go | 51 ++++--- pkg/cli/ai_test.go | 15 +- pkg/cli/attachments_ginkgo_test.go | 3 +- pkg/cli/model_selection_ginkgo_test.go | 3 +- 7 files changed, 434 insertions(+), 42 deletions(-) create mode 100644 pkg/aiflags/defaults.go create mode 100644 pkg/aiflags/flags.go diff --git a/pkg/aiflags/defaults.go b/pkg/aiflags/defaults.go new file mode 100644 index 0000000..c7f622c --- /dev/null +++ b/pkg/aiflags/defaults.go @@ -0,0 +1,185 @@ +package aiflags + +import ( + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/captainconfig" +) + +// ProviderDefaultView is one provider's effective saved defaults, with the legacy +// flat config projected in and validated. +type ProviderDefaultView struct { + Agent string `json:"agent"` + Model string `json:"model"` + Effort string `json:"effort"` + Configured bool `json:"configured"` +} + +// LoadDefaults reads the saved AI defaults from ~/.captain.yaml. +// +// It reports a broken config rather than swallowing it: whether to degrade to zero +// defaults and carry on is a CLI policy, not a library's call. captain's own +// commands make that choice in pkg/cli (loadSavedAI warns and continues); callers +// wanting the same behaviour pass their own AIDefaults to ResolveWith. +// +// Deliberately no logger here — commons/logger pulls ~55 packages (prometheus, +// fsnotify, …) and would cost this leaf its entire reason for existing. +func LoadDefaults() (captainconfig.AIDefaults, error) { + cfg, _, err := captainconfig.Load() + if err != nil { + return captainconfig.AIDefaults{}, err + } + return cfg.AI, nil +} + +// EffectiveDefaults resolves one provider's saved defaults: the per-provider block +// back-filled from the legacy flat keys, with the agent defaulting to the provider +// itself and the model to that agent's built-in default. +func EffectiveDefaults(saved captainconfig.AIDefaults, provider registry.Backend) (ProviderDefaultView, error) { + if provider.Provider() != provider { + return ProviderDefaultView{}, fmt.Errorf("invalid provider %q", provider) + } + configured, exists := saved.Providers[string(provider)] + legacy := saved.Provider(string(provider)) + if configured.Agent == "" { + configured.Agent = legacy.Agent + } + if configured.Model == "" { + configured.Model = legacy.Model + } + if configured.ReasoningEffort == "" { + configured.ReasoningEffort = legacy.ReasoningEffort + } + agent := registry.Backend(strings.TrimSpace(configured.Agent)) + if agent == "" { + agent = provider + } + if agent.Provider() != provider { + return ProviderDefaultView{}, fmt.Errorf("agent %q does not belong to provider %q", agent, provider) + } + model := strings.TrimSpace(configured.Model) + if model == "" { + model = DefaultModelFor(agent) + } + effort := registry.Effort(strings.TrimSpace(configured.ReasoningEffort)) + if err := registry.ValidateEffort(agent, model, effort); err != nil { + return ProviderDefaultView{}, err + } + return ProviderDefaultView{ + Agent: string(agent), Model: model, Effort: string(effort), Configured: exists, + }, nil +} + +// ApplyDefaults fills a model's unset fields from the saved per-provider defaults, +// primary and fallbacks alike. It expects an already-expanded model (see the +// package doc) and does not resolve — the caller resolves once, afterwards. +func ApplyDefaults(model registry.Model, saved captainconfig.AIDefaults) (registry.Model, error) { + var err error + if strings.TrimSpace(model.Name) != "" { + if model, err = model.Expand(); err != nil { + return registry.Model{}, err + } + } + if model, err = applyCandidateDefaults(model, saved, true); err != nil { + return registry.Model{}, err + } + for i := range model.Fallbacks { + fallback := model.Fallbacks[i] + if fallback, err = fallback.Expand(); err != nil { + return registry.Model{}, fmt.Errorf("fallback[%d]: %w", i, err) + } + // allowActive=false: a fallback must not silently become the active + // provider's model — that would make the fallback chain a no-op. + if fallback, err = applyCandidateDefaults(fallback, saved, false); err != nil { + return registry.Model{}, fmt.Errorf("fallback[%d]: %w", i, err) + } + model.Fallbacks[i] = fallback + } + return model, nil +} + +func applyCandidateDefaults(model registry.Model, saved captainconfig.AIDefaults, allowActive bool) (registry.Model, error) { + provider := model.Backend.Provider() + if provider == "" && strings.TrimSpace(model.Name) != "" { + backend, err := registry.InferBackend(model.Name) + if err != nil { + return registry.Model{}, err + } + provider = backend.Provider() + } + if provider == "" && allowActive { + provider = registry.Backend(saved.ActiveProvider()) + } + if provider == "" { + return registry.Model{}, fmt.Errorf("provider cannot be resolved for model %q", model.Name) + } + defaults, err := EffectiveDefaults(saved, provider) + if err != nil { + return registry.Model{}, err + } + // An explicit --mode owns the mechanism. Taking the saved agent here would make + // `--mode cli` against a saved claude-agent default fail as "mode cli + // contradicts backend claude-agent" — a contradiction the user never wrote. + if model.Backend == "" && model.Mode == "" { + model.Backend = registry.Backend(defaults.Agent) + } + if strings.TrimSpace(model.Name) == "" { + model.Name = defaults.Model + } + if model.Effort == registry.EffortNone { + model.Effort = registry.Effort(defaults.Effort) + } + // Permissive when the backend is still unset (--mode without --backend): the + // authoritative effort check runs against the real backend during resolution. + if err := registry.ValidateEffort(model.Backend, model.Name, model.Effort); err != nil { + return registry.Model{}, err + } + return model, nil +} + +// AllProviderDefaults resolves every provider's effective defaults. +func AllProviderDefaults(saved captainconfig.AIDefaults) (map[string]ProviderDefaultView, error) { + out := make(map[string]ProviderDefaultView, len(registry.Providers())) + for _, p := range registry.Providers() { + provider, err := p.BackendFor(registry.ModeAPI) + if err != nil { + return nil, err + } + defaults, err := EffectiveDefaults(saved, provider) + if err != nil { + return nil, err + } + out[string(provider)] = defaults + } + return out, nil +} + +// DefaultModelFor returns a hard-coded picker default per backend that seeds the +// form. CLI/agent backends use exact provider model IDs from the catalog 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. +// +// This stays a hand-maintained table rather than "the catalog's newest preferred +// model": these are the values `captain configure` seeds and users have saved, and +// deriving them would silently move every unconfigured user's default whenever the +// catalog snapshot updates. +func DefaultModelFor(b registry.Backend) string { + switch b { + case registry.BackendAnthropic: + return "claude-sonnet-5" + case registry.BackendClaudeCLI, registry.BackendClaudeAgent, registry.BackendClaudeCmux: + return "claude-sonnet-5" + case registry.BackendOpenAI: + return "gpt-5.6" + case registry.BackendDeepSeek: + return "deepseek-reasoner" + case registry.BackendCodexCLI, registry.BackendCodexAgent, registry.BackendCodexCmux: + return "gpt-5.6-sol" + case registry.BackendGemini, registry.BackendGeminiCLI: + return "gemini-3.5-flash" + } + return "" +} diff --git a/pkg/aiflags/flags.go b/pkg/aiflags/flags.go new file mode 100644 index 0000000..17f7d61 --- /dev/null +++ b/pkg/aiflags/flags.go @@ -0,0 +1,201 @@ +// Package aiflags is captain's model-selection flag surface, extracted so any +// clicky-based CLI can embed it and get captain's exact model semantics. +// +// Its contract with clicky is the struct tags — there is no interface to satisfy; +// embed ModelFlags anonymously and the flags bind themselves. +// +// It is deliberately a leaf: registry (the model catalog) and captainconfig +// (~/.captain.yaml) are its only captain imports, and both are yaml-only. It must +// never import pkg/api, pkg/ai, or pkg/cli — pkg/cli alone pulls ~1000 packages +// (k8s, AWS, Postgres), which no downstream should inherit to parse `--model`. +// That is affordable because api.Model is a type ALIAS for registry.Model, so the +// Model returned here is interchangeable with api.Model at every call site. +// +// # The invariant +// +// Expand, then Merge, then Resolve — once, at the end. +// +// Merging an unexpanded override onto a base silently keeps the base's backend: +// base{Backend: claude-agent}.Merge(Model{Name: "api:opus"}) still says +// claude-agent, and resolution then fails or, worse, runs the wrong runtime. +// Expand sets Backend only when the string carries a prefix, so a bare +// `--model opus` correctly inherits the base's backend while `api:opus` +// correctly overrides it. Every ladder in this package and its callers follows +// that order; departing from it is how a model string loses its mode again. +package aiflags + +import ( + "fmt" + "strconv" + "strings" + + "github.com/flanksource/captain/pkg/api/registry" + "github.com/flanksource/captain/pkg/captainconfig" +) + +// ModelFlags is the model-selection flag surface: the 1:1 flag projection of +// registry.Model and nothing else. Session ids, API keys and budgets are +// deliberately absent — they belong to the request/config, not to a model, and +// keeping them out is what lets any command embed this without inheriting flags +// it has no business owning. +// +// Embed it ANONYMOUSLY — clicky's binder only recurses anonymous fields (it does +// so at any depth), and a named field would silently bind nothing. +// +// Every field is string/[]string/bool on purpose: clicky's binder switches on the +// concrete type and registers NO flag at all for a named type, so Effort and Mode +// cannot be registry.Effort/registry.RuntimeMode here. They are parsed and +// validated in Resolve instead. +// +// Flag names are global to a command — clicky does not prefix embedded structs — +// so this struct owns --model/-m, --fallback, --backend/-b, --mode, --effort, +// --temperature and --no-cache outright. An embedder that redeclares any of them +// panics cobra at init. +// +// No field carries a `default:` tag: defaults only materialize through cobra +// binding, so a directly-constructed ModelFlags would disagree with a bound one. +// Zero means unset, everywhere. +type ModelFlags struct { + Model string `flag:"model" short:"m" help:"Model name(s), e.g. claude-sonnet-5, a compact selector like agent:opus:high, or a comma-separated primary,fallback list (defaults to the value saved by 'captain configure')"` + Fallback []string `flag:"fallback" help:"Model to try if the primary is unavailable (repeatable; comma-separated allowed)"` + Backend string `flag:"backend" short:"b" help:"Force backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|gemini-cli (default: inferred from model or saved by 'captain configure')"` + Mode string `flag:"mode" help:"Runtime mechanism: api|cli|agent|cmux (sdk aliases agent). Combined with the model's family to pick a backend; contradicting --backend or a mode prefix on --model fails loud"` + Effort string `flag:"effort" help:"Reasoning effort: low|medium|high|xhigh|max|ultra (model-dependent)"` + Temperature string `flag:"temperature" help:"Sampling temperature (0.0-2.0)"` + NoCache bool `flag:"no-cache" help:"Disable response caching"` +} + +// ToModel projects the flags onto a Model and expands any compact selector, but +// does NOT resolve it against the catalog. That is the merge-safe form: callers +// layering flags over a spec must Merge before resolving (see the package doc). +func (f ModelFlags) ToModel() (registry.Model, error) { + m := registry.Model{ + Name: strings.TrimSpace(f.Model), + Backend: registry.Backend(strings.TrimSpace(f.Backend)), + Effort: registry.Effort(strings.TrimSpace(f.Effort)), + NoCache: f.NoCache, + } + if m.Backend != "" && !m.Backend.Valid() { + return registry.Model{}, fmt.Errorf("invalid --backend %q (valid: %s)", f.Backend, registry.BackendList()) + } + if err := m.Effort.Validate(); err != nil { + return registry.Model{}, fmt.Errorf("invalid --effort %q: %w", f.Effort, err) + } + // Normalize the mode BEFORE it can reach Model.validateMode: that check + // compares Mode against Backend.Mode() by equality, so an un-normalized "sdk" + // alongside backend claude-agent would report a contradiction that isn't real. + if s := strings.TrimSpace(f.Mode); s != "" { + mode, ok := registry.ParseRuntimeMode(s) + if !ok { + return registry.Model{}, fmt.Errorf("invalid --mode %q (valid: %s)", f.Mode, registry.RuntimeModeList()) + } + m.Mode = mode + } + temp, err := f.Temp() + if err != nil { + return registry.Model{}, err + } + m.Temperature = temp + m.Fallbacks = FallbackModels(f.Fallback) + + if m.Name != "" { + if m, err = m.Expand(); err != nil { + return registry.Model{}, err + } + } + return m, nil +} + +// Resolve is the one-call path: flags + ~/.captain.yaml → a fully resolved Model. +// A broken config surfaces as an error; callers that prefer to warn and carry on +// with zero defaults load their own and use ResolveWith. +func (f ModelFlags) Resolve() (registry.Model, error) { + saved, err := LoadDefaults() + if err != nil { + return registry.Model{}, err + } + return f.ResolveWith(saved) +} + +// ResolveWith is the pure core: no ambient I/O, no globals. Saved defaults arrive +// as a parameter so tests and spec-overlaying callers can drive it directly. +func (f ModelFlags) ResolveWith(saved captainconfig.AIDefaults) (registry.Model, error) { + m, err := f.ToModel() + if err != nil { + return registry.Model{}, err + } + if !f.NoCache && saved.NoCache { + m.NoCache = true + } + if m, err = ApplyDefaults(m, saved); err != nil { + return registry.Model{}, err + } + return registry.ResolveModel(m) +} + +// Overlay layers the flags over an already-structured base (a spec's model), +// flags winning field by field, and resolves the result once. +// +// This is the entry point for callers that have both a config/spec and flags — +// which is most of them. Doing it by hand is how a caller ends up merging an +// unexpanded name onto a populated backend and losing the mode. +func (f ModelFlags) Overlay(base registry.Model) (registry.Model, error) { + saved, err := LoadDefaults() + if err != nil { + return registry.Model{}, err + } + return f.OverlayWith(base, saved) +} + +// OverlayWith is Overlay with saved defaults injected. +func (f ModelFlags) OverlayWith(base registry.Model, saved captainconfig.AIDefaults) (registry.Model, error) { + over, err := f.ToModel() + if err != nil { + return registry.Model{}, err + } + merged, err := ApplyDefaults(base.Merge(over), saved) + if err != nil { + return registry.Model{}, err + } + return registry.ResolveModel(merged) +} + +// Temp parses --temperature. A nil result means unset: an explicit 0 and "unset" +// must stay distinguishable, so providers can tell "sample deterministically" +// from "use the model's default". +func (f ModelFlags) Temp() (*float64, error) { + v, err := parseFloat("temperature", f.Temperature) + if err != nil || v == 0 { + return nil, err + } + if v < 0 || v > 2 { + return nil, fmt.Errorf("invalid --temperature %v (valid: 0.0-2.0)", v) + } + return &v, nil +} + +// FallbackModels turns repeatable/comma-separated --fallback values into models. +func FallbackModels(flags []string) registry.ModelList { + var out registry.ModelList + for _, flag := range flags { + for _, name := range strings.Split(flag, ",") { + if name = strings.TrimSpace(name); name != "" { + out = append(out, registry.Model{Name: name}) + } + } + } + return out +} + +// parseFloat parses a numeric string flag, failing loud rather than silently +// coercing malformed input to zero. +func parseFloat(name, val string) (float64, error) { + if strings.TrimSpace(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 +} diff --git a/pkg/captainconfig/config.go b/pkg/captainconfig/config.go index 30866e6..d5278ef 100644 --- a/pkg/captainconfig/config.go +++ b/pkg/captainconfig/config.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" "golang.org/x/sys/unix" "gopkg.in/yaml.v3" ) @@ -80,23 +80,23 @@ type ProviderDefaults struct { } func (a AIDefaults) ActiveProvider() string { - if provider := api.Backend(strings.TrimSpace(a.DefaultProvider)); provider != "" && provider.Provider() == provider { + if provider := registry.Backend(strings.TrimSpace(a.DefaultProvider)); provider != "" && provider.Provider() == provider { return string(provider) } - if provider := api.Backend(strings.TrimSpace(a.Backend)).Provider(); provider != "" { + if provider := registry.Backend(strings.TrimSpace(a.Backend)).Provider(); provider != "" { return string(provider) } - if backend, err := api.InferBackend(strings.TrimSpace(a.Model)); err == nil { + if backend, err := registry.InferBackend(strings.TrimSpace(a.Model)); err == nil { return string(backend.Provider()) } - return string(api.AnthropicProvider) + return string(registry.AnthropicProvider) } func (a AIDefaults) legacyProvider() string { - if provider := api.Backend(strings.TrimSpace(a.Backend)).Provider(); provider != "" { + if provider := registry.Backend(strings.TrimSpace(a.Backend)).Provider(); provider != "" { return string(provider) } - if backend, err := api.InferBackend(strings.TrimSpace(a.Model)); err == nil { + if backend, err := registry.InferBackend(strings.TrimSpace(a.Model)); err == nil { return string(backend.Provider()) } return strings.TrimSpace(a.DefaultProvider) @@ -108,8 +108,8 @@ func (a AIDefaults) Provider(provider string) ProviderDefaults { if provider != a.legacyProvider() { return defaults } - legacyAgent := api.Backend(strings.TrimSpace(a.Backend)) - if defaults.Agent == "" && legacyAgent.Provider() == api.Backend(provider) { + legacyAgent := registry.Backend(strings.TrimSpace(a.Backend)) + if defaults.Agent == "" && legacyAgent.Provider() == registry.Backend(provider) { defaults.Agent = string(legacyAgent) } if defaults.Model == "" { diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 8bb7c7a..7af7752 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -11,6 +11,7 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/ai/middleware" "github.com/flanksource/captain/pkg/ai/pricing" + "github.com/flanksource/captain/pkg/aiflags" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/claude" @@ -36,13 +37,23 @@ func loadSavedConfig() captainconfig.Config { return cfg } +// AIProviderOptions binds model selection plus the two knobs that belong to the +// request rather than the model: the API key and the spend budget. +// +// The model flags themselves live in pkg/aiflags — a leaf any clicky CLI can embed +// without inheriting pkg/cli's ~1000 transitive packages. Embedding it here keeps +// captain's flag surface unchanged (clicky promotes embedded flags at any depth) +// while giving downstream repos the same parsing captain uses. type AIProviderOptions struct { - Model string `flag:"model" help:"Model name(s), e.g. claude-sonnet-5 or a comma-separated primary,fallback list like claude-sonnet-5,gpt-4o (defaults to the value saved by 'captain configure')" short:"m"` - Fallback []string `flag:"fallback" help:"Model to try if the primary is unavailable (repeatable; comma-separated allowed)"` - Backend string `flag:"backend" help:"Force backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|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, OPENAI_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY, DEEPSEEK_API_KEY)"` - NoCache bool `flag:"no-cache" help:"Disable response caching"` - Budget string `flag:"budget" help:"Max spend in USD, 0=unlimited" default:"0"` + aiflags.ModelFlags + + APIKey string `flag:"api-key" help:"API key (env: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY, DEEPSEEK_API_KEY)"` + Budget string `flag:"budget" help:"Max spend in USD, 0=unlimited" default:"0"` +} + +// BudgetUSD parses --budget, failing loud on malformed input. +func (o AIProviderOptions) BudgetUSD() (float64, error) { + return parseFloatFlag("budget", o.Budget) } // parseFloatFlag parses a numeric string flag, returning a descriptive error @@ -61,25 +72,18 @@ func parseFloatFlag(name, val string) (float64, error) { func (o AIProviderOptions) ToConfig() (ai.Config, error) { savedCfg := loadSavedConfig() saved := savedCfg.AI - budget, err := parseFloatFlag("budget", o.Budget) + budget, err := o.BudgetUSD() if err != nil { return ai.Config{}, err } - - m := api.Model{Name: o.Model, Backend: api.Backend(o.Backend)} - if m.Backend != "" && !m.Backend.Valid() { - return ai.Config{}, fmt.Errorf("invalid --backend %q (valid: %s)", m.Backend, ai.BackendList()) - } if budget == 0 { budget = saved.BudgetUSD } - m.Fallbacks = fallbackModelsFromFlags(o.Fallback) - m, err = applyProviderDefaults(m, saved) - if err != nil { - return ai.Config{}, err - } - m, err = ai.ResolveModelSelectors(m) + // One resolve: flags → Model → saved per-provider defaults → catalog. The + // warn-and-continue policy for a broken config stays here (loadSavedConfig), so + // aiflags can hand the error back instead of swallowing it. + m, err := o.ResolveWith(saved) if err != nil { return ai.Config{}, err } @@ -123,11 +127,12 @@ func isZeroSchemaRepair(c api.SchemaRepairConfig) bool { 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 (0.0-2.0)" default:"0"` - Effort string `flag:"effort" help:"Reasoning effort: low|medium|high|xhigh|max|ultra (model-dependent)"` - 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)"` + // Effort and Temperature are NOT here: they describe the model and so live on + // the embedded aiflags.ModelFlags, promoted through AIProviderOptions. + // Redeclaring them would bind --effort twice and panic cobra at init. + MaxTokens int `flag:"max-tokens" help:"Maximum output tokens (0 = saved default or 4096)"` + 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)"` diff --git a/pkg/cli/ai_test.go b/pkg/cli/ai_test.go index a8715d8..7875e20 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "github.com/flanksource/captain/pkg/aiflags" "os" "path/filepath" "reflect" @@ -238,10 +239,10 @@ func TestAIRuntimeOptions_ToRequest_ValidationErrors(t *testing.T) { 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") { + if _, err := (AIProviderOptions{ModelFlags: aiflags.ModelFlags{Model: "claude-x", Backend: "nope"}}).ToConfig(); err == nil || !strings.Contains(err.Error(), "backend") { t.Fatalf("expected invalid backend error, got %v", err) } - if _, err := (AIProviderOptions{Model: "claude-x", Budget: "free"}).ToConfig(); err == nil || !strings.Contains(err.Error(), "budget") { + if _, err := (AIProviderOptions{ModelFlags: aiflags.ModelFlags{Model: "claude-x"}, Budget: "free"}).ToConfig(); err == nil || !strings.Contains(err.Error(), "budget") { t.Fatalf("expected invalid budget error, got %v", err) } } @@ -311,7 +312,7 @@ func TestAIRuntimeOptions_ToRequest_MaxTokensPrecedence(t *testing.T) { func TestAIRuntimeOptions_ToRequest_OverlaysSaved(t *testing.T) { seedSavedAI(t, "ai:\n noMCP: true\n noHooks: true\n noSkills: true\n noUser: true\n noProject: true\n noMemory: true\n maxTokens: 16000\n reasoningEffort: low\n") - opts := AIRuntimeOptions{Effort: "high"} // flag overrides saved low + opts := AIRuntimeOptions{AIProviderOptions: AIProviderOptions{ModelFlags: aiflags.ModelFlags{Effort: "high"}}} // flag overrides saved low req, err := opts.ToRequest("sys", "", "user") if err != nil { t.Fatalf("ToRequest: %v", err) @@ -475,10 +476,8 @@ func TestRunStreaming_StructuredResultIsReturnedOnce(t *testing.T) { func defaultPromptOptions(t *testing.T) AIPromptOptions { t.Helper() return AIPromptOptions{ - AIRuntimeOptions: AIRuntimeOptions{ - Temperature: "0", - }, - Timeout: "120s", - Prompt: "hello", + AIRuntimeOptions: AIRuntimeOptions{AIProviderOptions: AIProviderOptions{ModelFlags: aiflags.ModelFlags{Temperature: "0"}}}, + Timeout: "120s", + Prompt: "hello", } } diff --git a/pkg/cli/attachments_ginkgo_test.go b/pkg/cli/attachments_ginkgo_test.go index d13a819..f760289 100644 --- a/pkg/cli/attachments_ginkgo_test.go +++ b/pkg/cli/attachments_ginkgo_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "github.com/flanksource/captain/pkg/aiflags" "mime/multipart" "net/http" "net/http/httptest" @@ -58,7 +59,7 @@ var _ = Describe("attachment flags", func() { It("renders an attachment-only canonical prompt", func() { rendered, err := renderPromptCLI(context.Background(), "", AIPromptOptions{ - AIRuntimeOptions: AIRuntimeOptions{AIProviderOptions: AIProviderOptions{Model: "gemini-2.5-pro"}}, + AIRuntimeOptions: AIRuntimeOptions{AIProviderOptions: AIProviderOptions{ModelFlags: aiflags.ModelFlags{Model: "gemini-2.5-pro"}}}, Attach: []string{"diagram.png"}, }, "", "") Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/cli/model_selection_ginkgo_test.go b/pkg/cli/model_selection_ginkgo_test.go index 107b809..a87b39d 100644 --- a/pkg/cli/model_selection_ginkgo_test.go +++ b/pkg/cli/model_selection_ginkgo_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "github.com/flanksource/captain/pkg/aiflags" "os" "path/filepath" "time" @@ -35,7 +36,7 @@ var _ = Describe("CLI model selection", func() { }) It("uses the same precedence for provider options", func() { - cfg, err := (AIProviderOptions{Model: "gemini-3.5-flash"}).ToConfig() + cfg, err := (AIProviderOptions{ModelFlags: aiflags.ModelFlags{Model: "gemini-3.5-flash"}}).ToConfig() Expect(err).NotTo(HaveOccurred()) Expect(cfg.Model.Name).To(Equal("gemini-3.5-flash")) From dac574f4ff3abfd570bcf2b4c52de458a71e935d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 15:55:50 +0300 Subject: [PATCH 099/131] refactor(aichat)!: drop RuntimeSettings.DefaultModel for Spec.Model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DefaultModel was a bare model name sitting beside a structured Spec — the same lossy pattern captain fixes elsewhere, leaking into captain's own API. It could not carry a backend, mode or effort, so whatever it named was re-inferred from the string; and when both it and Spec.Model were set, one silently won. Deleting it makes the contradiction unrepresentable: Spec.Model can say {Name: "sol", Mode: agent} and mean it. Also expand the request's model before merging it over the settings Spec. Merging an unexpanded name keeps the Spec's backend, so a compact selector like "agent:sol" would have run whatever backend the Spec already named — the exact bug this pattern causes. Expand sets the backend only when the string carries a prefix, so a bare name still inherits and a prefixed one still overrides. BREAKING: RuntimeSettings.DefaultModel is removed. Callers set Spec.Model instead. Out-of-tree consumers (clicky/aichat, commons-db/cmd/query) pin older captain versions and are unaffected until they bump. --- pkg/aichat/messages.go | 12 ++++++++---- pkg/aichat/service.go | 8 +++++++- pkg/aichat/service_ginkgo_test.go | 13 ++++++++----- pkg/cli/serve_chat.go | 7 +++++-- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/pkg/aichat/messages.go b/pkg/aichat/messages.go index fe2d0f5..fb9c1a5 100644 --- a/pkg/aichat/messages.go +++ b/pkg/aichat/messages.go @@ -70,17 +70,21 @@ func (s *Service) resolveAttachments(ctx context.Context, messages []UIMessage) func requestSpec(request ChatRequest, settings RuntimeSettings, attachments map[partLocation]api.AttachmentRef) (api.Spec, error) { model := strings.TrimSpace(request.Model) - if model == "" { - model = strings.TrimSpace(settings.DefaultModel) - } if model == "" { model = strings.TrimSpace(settings.Spec.Name) } if model == "" { return api.Spec{}, fmt.Errorf("chat model is required") } + // Expand before merging: a compact selector ("agent:sol") carries its own + // backend, and merging it unexpanded would keep settings.Spec's backend and run + // a different runtime than the caller asked for. + override, err := api.Model{Name: model, Effort: request.ReasoningEffort, Temperature: request.Temperature}.Expand() + if err != nil { + return api.Spec{}, fmt.Errorf("invalid chat model %q: %w", model, err) + } spec := settings.Spec.Merge(api.Spec{ - Model: api.Model{Name: model, Effort: request.ReasoningEffort, Temperature: request.Temperature}, + Model: override, Budget: request.Budget, ToolPreferences: request.ToolPreferences, ToolApproval: request.ToolApproval, diff --git a/pkg/aichat/service.go b/pkg/aichat/service.go index 66e4d65..b7c08d3 100644 --- a/pkg/aichat/service.go +++ b/pkg/aichat/service.go @@ -16,8 +16,14 @@ var serviceLog = logger.GetLogger("aichat") // RuntimeSettings are application-owned defaults and provider construction // settings evaluated for each request. +// RuntimeSettings is the request-scoped application configuration for a chat. +// +// The default model lives in Spec.Model — there is deliberately no DefaultModel +// string beside it. A bare name next to a structured Spec is the lossy pattern: +// it cannot carry a backend/mode/effort, so whatever it named got re-inferred, and +// when both were set they could silently disagree. Spec.Model can say +// {Name: "sol", Mode: ModeAgent} and mean it. type RuntimeSettings struct { - DefaultModel string System string Spec api.Spec ProviderConfig api.Config diff --git a/pkg/aichat/service_ginkgo_test.go b/pkg/aichat/service_ginkgo_test.go index 587fa89..299ad6f 100644 --- a/pkg/aichat/service_ginkgo_test.go +++ b/pkg/aichat/service_ginkgo_test.go @@ -130,7 +130,7 @@ var _ = Describe("Captain aichat service", func() { service := aichat.NewService(aichat.ServiceOptions{ Resolver: resolver, ProviderConfig: source, Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { - return aichat.RuntimeSettings{DefaultModel: "api:gpt-5.4"}, nil + return aichat.RuntimeSettings{Spec: api.Spec{Model: api.Model{Name: "api:gpt-5.4"}}}, nil }), }) @@ -157,7 +157,7 @@ var _ = Describe("Captain aichat service", func() { service := aichat.NewService(aichat.ServiceOptions{ Resolver: resolver, ProviderConfig: source, Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { - return aichat.RuntimeSettings{DefaultModel: "api:gpt-5.4"}, nil + return aichat.RuntimeSettings{Spec: api.Spec{Model: api.Model{Name: "api:gpt-5.4"}}}, nil }), }) @@ -179,7 +179,8 @@ var _ = Describe("Captain aichat service", func() { service := aichat.NewService(aichat.ServiceOptions{ Resolver: resolver, Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { - return aichat.RuntimeSettings{DefaultModel: "openai/test-model", Spec: api.Spec{ + return aichat.RuntimeSettings{Spec: api.Spec{ + Model: api.Model{Name: "openai/test-model"}, Budget: api.Budget{Cost: 5, MaxTokens: 2_000, MaxTurns: 3}, }}, nil }), @@ -201,7 +202,8 @@ var _ = Describe("Captain aichat service", func() { Resolver: resolver, Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { return aichat.RuntimeSettings{ - DefaultModel: "openai/test-model", MonthlyBudgetUSD: 10, CurrentMonthCostUSD: 10, + Spec: api.Spec{Model: api.Model{Name: "openai/test-model"}}, + MonthlyBudgetUSD: 10, CurrentMonthCostUSD: 10, }, nil }), }) @@ -262,7 +264,8 @@ var _ = Describe("Captain aichat service", func() { Resolver: resolver, Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { return aichat.RuntimeSettings{ - DefaultModel: "openai/test-model", System: "Use application tools.", + Spec: api.Spec{Model: api.Model{Name: "openai/test-model"}}, + System: "Use application tools.", ProviderConfig: api.Config{APIURL: "https://example.com/ai", ProjectName: "tenant-x"}, }, nil }), diff --git a/pkg/cli/serve_chat.go b/pkg/cli/serve_chat.go index bc24639..ca95395 100644 --- a/pkg/cli/serve_chat.go +++ b/pkg/cli/serve_chat.go @@ -11,6 +11,7 @@ import ( "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/aichat" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/api/registry" "github.com/flanksource/captain/pkg/attachments" clickyaichat "github.com/flanksource/clicky/aichat" "github.com/flanksource/commons-db/shell" @@ -38,10 +39,12 @@ func newCaptainChatService( chat := aichat.NewService(aichat.ServiceOptions{ Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { return aichat.RuntimeSettings{ - DefaultModel: "agent:sol", 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.", - Spec: api.Spec{Setup: &shell.Setup{Cwd: cwd}}, + Spec: api.Spec{ + Model: api.Model{Name: "sol", Mode: registry.ModeAgent}, + Setup: &shell.Setup{Cwd: cwd}, + }, }, nil }), Tools: chatTools, MCP: mcpTools, Threads: threadStore, From eb6dafbb8ab74c36bf6b38eee2ee780718f05876 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Jul 2026 16:39:35 +0300 Subject: [PATCH 100/131] refactor(ai): drop single-letter shorthands from ModelFlags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -m and -b made the struct un-embeddable by the commands that most need it: flag names are global to a command and clicky has no prefixing, so gavel commit — whose -m is --message, the git convention — panicked cobra at init the moment it embedded this. Shorthands are a per-command UX choice and the letters are scarce; a struct meant to be embedded everywhere cannot squat them. captain ai loses -m/--model and -b/--backend as shorthands; the long forms are unchanged. --- pkg/aiflags/flags.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/aiflags/flags.go b/pkg/aiflags/flags.go index 17f7d61..aba66a6 100644 --- a/pkg/aiflags/flags.go +++ b/pkg/aiflags/flags.go @@ -48,17 +48,22 @@ import ( // validated in Resolve instead. // // Flag names are global to a command — clicky does not prefix embedded structs — -// so this struct owns --model/-m, --fallback, --backend/-b, --mode, --effort, +// so this struct owns --model, --fallback, --backend, --mode, --effort, // --temperature and --no-cache outright. An embedder that redeclares any of them // panics cobra at init. // +// It deliberately claims NO single-letter shorthands. Shorthands are per-command +// UX and the letters are scarce: -m here meant `gavel commit` could not embed this +// at all, because -m is its --message (the git convention). A struct meant to be +// embedded everywhere cannot squat them. +// // No field carries a `default:` tag: defaults only materialize through cobra // binding, so a directly-constructed ModelFlags would disagree with a bound one. // Zero means unset, everywhere. type ModelFlags struct { - Model string `flag:"model" short:"m" help:"Model name(s), e.g. claude-sonnet-5, a compact selector like agent:opus:high, or a comma-separated primary,fallback list (defaults to the value saved by 'captain configure')"` + Model string `flag:"model" help:"Model name(s), e.g. claude-sonnet-5, a compact selector like agent:opus:high, or a comma-separated primary,fallback list (defaults to the value saved by 'captain configure')"` Fallback []string `flag:"fallback" help:"Model to try if the primary is unavailable (repeatable; comma-separated allowed)"` - Backend string `flag:"backend" short:"b" help:"Force backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|gemini-cli (default: inferred from model or saved by 'captain configure')"` + Backend string `flag:"backend" help:"Force backend: anthropic|gemini|openai|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|gemini-cli (default: inferred from model or saved by 'captain configure')"` Mode string `flag:"mode" help:"Runtime mechanism: api|cli|agent|cmux (sdk aliases agent). Combined with the model's family to pick a backend; contradicting --backend or a mode prefix on --model fails loud"` Effort string `flag:"effort" help:"Reasoning effort: low|medium|high|xhigh|max|ultra (model-dependent)"` Temperature string `flag:"temperature" help:"Sampling temperature (0.0-2.0)"` From c8d82f2e6a072341e964531c52866199ebbedf50 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 20 Jul 2026 09:00:38 +0300 Subject: [PATCH 101/131] chore: update generated and lock files --- pkg/cli/webapp/dist/.gitkeep | 0 pkg/cli/webapp/dist/index.html | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 pkg/cli/webapp/dist/.gitkeep diff --git a/pkg/cli/webapp/dist/.gitkeep b/pkg/cli/webapp/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/cli/webapp/dist/index.html b/pkg/cli/webapp/dist/index.html index 3293267..a48eaf9 100644 --- a/pkg/cli/webapp/dist/index.html +++ b/pkg/cli/webapp/dist/index.html @@ -4,8 +4,8 @@ Captain - - + +
    From 4683831b794eb727edde757cf604e63298abbe48 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 20 Jul 2026 09:18:08 +0300 Subject: [PATCH 102/131] fix(claude): prevent duplicate skill listing counts Prefer the names array count when available and avoid rendering duplicate count fields when redundant metadata is present. Add regression coverage for all supported input combinations. --- pkg/claude/tools/event_renderers.go | 6 +++-- pkg/claude/tools/stream_test.go | 35 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/pkg/claude/tools/event_renderers.go b/pkg/claude/tools/event_renderers.go index b2764fe..8c3476a 100644 --- a/pkg/claude/tools/event_renderers.go +++ b/pkg/claude/tools/event_renderers.go @@ -246,9 +246,11 @@ func (t *SkillListingTool) ExtractPath() string { return "" } func (t *SkillListingTool) Detail() api.Textable { return t.BaseTool.Detail() } func (t *SkillListingTool) Pretty() api.Text { text := eventText(icons.Info, "skills", "text-teal-500 font-medium") - appendListCount(&text, "count", t.Input["names"]) + if n := listLen(t.Input["names"]); n > 0 { + return text.Append(fmt.Sprintf(" count=%d", n), "text-gray-500") + } if count := eventInt(t.Input["skillCount"]); count > 0 { - text = text.Append(fmt.Sprintf(" count=%d", count), "text-gray-500") + return text.Append(fmt.Sprintf(" count=%d", count), "text-gray-500") } return text } diff --git a/pkg/claude/tools/stream_test.go b/pkg/claude/tools/stream_test.go index 7f36a2e..145372f 100644 --- a/pkg/claude/tools/stream_test.go +++ b/pkg/claude/tools/stream_test.go @@ -1,6 +1,7 @@ package tools import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -94,6 +95,40 @@ func TestNewTool_DispatchSyntheticTypes(t *testing.T) { } } +// TestSkillListingTool_PrettyEmitsSingleCount guards against the row rendering +// "count=N count=N" when a listing carries both the names array and the +// redundant skillCount scalar, as Claude Code transcripts do. +func TestSkillListingTool_PrettyEmitsSingleCount(t *testing.T) { + for _, tc := range []struct { + name string + input map[string]any + want string + }{ + { + name: "names and skillCount both present", + input: map[string]any{"names": []any{"a", "b"}, "skillCount": float64(29)}, + want: " count=2", + }, + { + name: "only skillCount present", + input: map[string]any{"skillCount": float64(29)}, + want: " count=29", + }, + { + name: "only names present", + input: map[string]any{"names": []any{"a", "b", "c"}}, + want: " count=3", + }, + } { + t.Run(tc.name, func(t *testing.T) { + pretty := NewTool(BaseTool{RawTool: "SkillListing", Input: tc.input}).Pretty().String() + assert.Equal(t, 1, strings.Count(pretty, "count="), + "expected exactly one count= field in %q", pretty) + assert.Contains(t, pretty, tc.want) + }) + } +} + func TestUserShellCommandTool_PrettyAndDetail(t *testing.T) { tool := NewTool(BaseTool{ RawTool: "UserShellCommand", From d0610bc1c440090f42cd9ecdbbd6c315e64ff912 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Mon, 20 Jul 2026 09:18:24 +0300 Subject: [PATCH 103/131] feat(cli): add prompt runtime matrices and session discovery tooling Enable prompt files and the workbench to define parallel runtime matrices, resolve bare prompt names, edit schemas, and preserve structured model output through execution and session history. Add global Cmd/Ctrl+K search for sessions and prompts, plus clearer bounded transcript paging, aggregate prompt-run hydration, token/history summaries, and context metering. Wire Monaco/YAML support and keep generated webapp assets ignored. --- .gitignore | 3 +- docs/src/pages/prompts/format.mdx | 6 +- docs/src/pages/prompts/runtime.mdx | 18 + docs/src/pages/prompts/sources-api.mdx | 3 +- pkg/ai/prompt/document.go | 56 ++- pkg/ai/prompt/prompt.go | 2 +- pkg/cli/ai.go | 93 ++-- pkg/cli/ai_test.go | 35 ++ pkg/cli/attachments_ginkgo_test.go | 17 + pkg/cli/model_selection_ginkgo_test.go | 29 +- pkg/cli/prompt_entity.go | 12 +- pkg/cli/prompt_records.go | 73 +++- pkg/cli/prompt_render.go | 59 ++- pkg/cli/prompt_run.go | 225 ++-------- pkg/cli/prompt_run_history.go | 13 +- pkg/cli/prompt_run_live.go | 10 +- pkg/cli/prompt_run_persist.go | 4 + pkg/cli/prompt_run_persist_test.go | 2 + pkg/cli/prompt_run_result.go | 175 ++++++++ pkg/cli/prompt_run_test.go | 27 +- pkg/cli/prompt_runtimes_ginkgo_test.go | 166 +++++++ pkg/cli/prompt_source.go | 13 +- pkg/cli/serve.go | 9 +- pkg/cli/session_get.go | 317 +++++++++++--- pkg/cli/session_get_compact_test.go | 145 ++++++ pkg/cli/session_get_multi_test.go | 104 +++++ pkg/cli/session_list_render.go | 16 +- pkg/cli/session_list_render_test.go | 29 +- pkg/cli/session_record_db.go | 4 +- pkg/cli/sessions.go | 21 +- pkg/cli/structured_output.go | 38 ++ pkg/cli/webapp/package.json | 4 +- pkg/cli/webapp/src/App.tsx | 24 +- pkg/cli/webapp/src/CommandPalette.test.tsx | 259 +++++++++++ pkg/cli/webapp/src/CommandPalette.tsx | 412 ++++++++++++++++++ .../webapp/src/PromptSchemaEditor.test.tsx | 161 +++++++ pkg/cli/webapp/src/PromptSchemaEditor.tsx | 183 ++++++++ pkg/cli/webapp/src/PromptWorkbench.test.ts | 150 +++++++ pkg/cli/webapp/src/PromptWorkbench.tsx | 390 +++++++---------- pkg/cli/webapp/src/SessionBrowser.tsx | 26 ++ pkg/cli/webapp/src/main.tsx | 6 +- pkg/cli/webapp/src/monacoWorkers.ts | 7 + pkg/cli/webapp/src/promptData.ts | 109 +++++ pkg/cli/webapp/src/promptSchemaSource.test.ts | 123 ++++++ pkg/cli/webapp/src/promptSchemaSource.ts | 91 ++++ pkg/cli/webapp/src/sessionData.ts | 29 ++ pkg/cli/webapp/src/test-setup.ts | 7 + pkg/cli/webapp/src/vite-env.d.ts | 1 + pkg/cli/webapp/vite.config.ts | 11 +- pkg/cli/webapp/vitest.config.ts | 1 + pkg/session/pretty.go | 142 +++++- pkg/session/pretty_compact_test.go | 244 +++++++++++ pkg/session/pretty_helpers.go | 4 +- pkg/session/pretty_test.go | 8 +- pkg/session/session.go | 14 + 55 files changed, 3486 insertions(+), 644 deletions(-) create mode 100644 pkg/cli/prompt_run_result.go create mode 100644 pkg/cli/prompt_runtimes_ginkgo_test.go create mode 100644 pkg/cli/session_get_compact_test.go create mode 100644 pkg/cli/structured_output.go create mode 100644 pkg/cli/webapp/src/CommandPalette.test.tsx create mode 100644 pkg/cli/webapp/src/CommandPalette.tsx create mode 100644 pkg/cli/webapp/src/PromptSchemaEditor.test.tsx create mode 100644 pkg/cli/webapp/src/PromptSchemaEditor.tsx create mode 100644 pkg/cli/webapp/src/PromptWorkbench.test.ts create mode 100644 pkg/cli/webapp/src/monacoWorkers.ts create mode 100644 pkg/cli/webapp/src/promptData.ts create mode 100644 pkg/cli/webapp/src/promptSchemaSource.test.ts create mode 100644 pkg/cli/webapp/src/promptSchemaSource.ts create mode 100644 pkg/cli/webapp/src/vite-env.d.ts create mode 100644 pkg/session/pretty_compact_test.go diff --git a/.gitignore b/.gitignore index 7720de5..8389c11 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ pkg/cli/webapp/node_modules/ pkg/cli/webapp/*.tsbuildinfo # goreleaser output at repo root. /dist/ +dist/ # The built webapp under pkg/cli/webapp/dist is gitignored — it's a large # minified bundle whose vite build needs a local clicky-ui link: dependency # that isn't available in CI. A global `dist/` ignore excludes the directory, @@ -23,10 +24,8 @@ pkg/cli/webapp/*.tsbuildinfo !pkg/cli/webapp/dist/ pkg/cli/webapp/dist/* !pkg/cli/webapp/dist/.gitkeep -!pkg/cli/webapp/dist/index.html .grite/ hack/ -dist/ .ok/ .okignore .ginkgo/ diff --git a/docs/src/pages/prompts/format.mdx b/docs/src/pages/prompts/format.mdx index a91eb9b..6a87159 100644 --- a/docs/src/pages/prompts/format.mdx +++ b/docs/src/pages/prompts/format.mdx @@ -42,6 +42,11 @@ A `.prompt` file has YAML frontmatter followed by a Handlebars body. dotprompt o Captain spec Decoded into `api.Spec` with strict field validation. + + runtimes + Captain execution metadata + Declares the default parallel runtime matrix without becoming part of the request sent to each model. + @@ -99,4 +104,3 @@ Refactor: ## Structured output Captain does not turn a bare dotprompt `output` schema into a provider schema target. Structured output is attached by Go callers that pass a concrete output type to `Render`; providers derive the JSON schema from that Go type. - diff --git a/docs/src/pages/prompts/runtime.mdx b/docs/src/pages/prompts/runtime.mdx index 634c510..c620d96 100644 --- a/docs/src/pages/prompts/runtime.mdx +++ b/docs/src/pages/prompts/runtime.mdx @@ -74,3 +74,21 @@ The prompt file provides the base request, but it is not the last word. Captain The prompt workbench keeps runtime selection catalog-backed. The UI groups by provider family first, then mode/backend, then the model list filtered to that backend. Prompt files can still name `model` and `backend` directly when the runtime should be pinned. +## Parallel runtimes + +A prompt can declare its default comparison matrix at the root. Running the file without `-M` dispatches all declared runtimes concurrently, and the workbench opens them as editable runtime rows. Each entry accepts the compact model selector or object form, and a matrix must contain at least two distinct runtimes. + + + +The matrix supplies defaults rather than locking the prompt. Explicit CLI `-M` selectors or HTTP/workbench `runtimes` replace the declared list for that run. A singular `model` is optional when the prompt declares a runtime matrix. diff --git a/docs/src/pages/prompts/sources-api.mdx b/docs/src/pages/prompts/sources-api.mdx index c5b49d0..1febf69 100644 --- a/docs/src/pages/prompts/sources-api.mdx +++ b/docs/src/pages/prompts/sources-api.mdx @@ -25,6 +25,8 @@ Captain discovers prompt sources in this order: Embedded prompts are read-only. Saving an embedded prompt from the workbench writes a local fork, stripping the embedded walk root so `testdata/commit.prompt` becomes `commit.prompt`. +CLI prompt commands accept an opaque prompt ID, an explicit file path, or a bare filename with or without the `.prompt` extension. Bare filenames are resolved across the discovered sources. An existing file in the current directory takes precedence; if more than one discovered source contains the same filename, Captain reports the ambiguity and requires an ID or explicit path. + ## Entity records \n---\n", // or the body alone when there is no frontmatter. The output satisfies the // dotprompt frontmatter grammar so Parse can read it back. diff --git a/pkg/ai/prompt/prompt.go b/pkg/ai/prompt/prompt.go index e3806f3..d945b8a 100644 --- a/pkg/ai/prompt/prompt.go +++ b/pkg/ai/prompt/prompt.go @@ -178,7 +178,7 @@ func decodeSpecFrontmatter(raw map[string]any, req *ai.Request) error { specRaw := map[string]any{} for key, value := range raw { switch key { - case "config", "input", "output", "name", "description": + case "config", "input", "output", "name", "description", "runtimes": continue default: specRaw[key] = value diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 7af7752..bf4d63c 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -180,17 +180,18 @@ type AIPromptOptions struct { } type AIPromptResult struct { - 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"` - HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` - 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"` + Text string `json:"text" pretty:"label=Response"` + StructuredOutput map[string]any `json:"structuredOutput,omitempty" pretty:"-"` + 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"` + HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` + 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 @@ -446,17 +447,26 @@ func runBuffered(ctx context.Context, p ai.Provider, req ai.Request) (any, error backend := firstNonEmpty(string(resp.Backend), string(p.GetBackend()), string(req.Backend)) input := resolvedPromptInput(req, model, backend, req.SessionID) dir := actualRunDir(input) + structuredOutput, err := structuredOutputMap(resp.StructuredData) + if err != nil { + return nil, err + } + text, err := structuredOutputText(resp.Text, structuredOutput) + if err != nil { + return nil, err + } return AIPromptResult{ - Text: resp.Text, - Model: model, - Backend: backend, - Dir: dir, - SessionID: input.SessionID, - HistoryFile: historyFileForRun(api.Backend(backend), input.SessionID, dir), - Input: input, - InputTokens: resp.Usage.InputTokens, - Output: resp.Usage.OutputTokens, - Duration: time.Since(start).Round(time.Millisecond).String(), + Text: text, + StructuredOutput: structuredOutput, + Model: model, + Backend: backend, + Dir: dir, + SessionID: input.SessionID, + HistoryFile: historyFileForRun(api.Backend(backend), input.SessionID, dir), + Input: input, + InputTokens: resp.Usage.InputTokens, + Output: resp.Usage.OutputTokens, + Duration: time.Since(start).Round(time.Millisecond).String(), }, nil } @@ -466,12 +476,14 @@ func runBuffered(ctx context.Context, p ai.Provider, req ai.Request) (any, error func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) (any, error) { start := time.Now() var ( - text string - usage ai.Usage - cost float64 - backend = string(sp.GetBackend()) - model = sp.GetModel() - session = req.SessionID + text string + usage ai.Usage + cost float64 + backend = string(sp.GetBackend()) + model = sp.GetModel() + session = req.SessionID + structuredOutput map[string]any + structuredErr error ) renderer := newLineRenderer(os.Stderr, 8) loop, err := ai.RunUntil(ctx, ai.LoopOptions{ @@ -498,6 +510,7 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) if ev.Kind == ai.EventResult { if len(ev.StructuredData) > 0 { text = string(ev.StructuredData) + structuredOutput, structuredErr = structuredOutputMap(ev.StructuredData) } if ev.Usage != nil { usage = *ev.Usage @@ -509,6 +522,9 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) if err != nil { return nil, err } + if structuredErr != nil { + return nil, structuredErr + } if loop.StopReason == "error" { return nil, fmt.Errorf("streaming loop stopped: %s", loop.StopReason) } @@ -521,17 +537,18 @@ func runStreaming(ctx context.Context, sp ai.StreamingProvider, req ai.Request) input := resolvedPromptInput(req, model, backend, session) dir := actualRunDir(input) return AIPromptResult{ - Text: text, - Model: model, - Backend: backend, - Dir: dir, - SessionID: input.SessionID, - HistoryFile: historyFileForRun(api.Backend(backend), input.SessionID, dir), - Input: input, - InputTokens: usage.InputTokens, - Output: usage.OutputTokens, - CostUSD: cost, - Duration: time.Since(start).Round(time.Millisecond).String(), + Text: text, + StructuredOutput: structuredOutput, + Model: model, + Backend: backend, + Dir: dir, + SessionID: input.SessionID, + HistoryFile: historyFileForRun(api.Backend(backend), input.SessionID, dir), + Input: input, + InputTokens: usage.InputTokens, + Output: usage.OutputTokens, + CostUSD: cost, + Duration: time.Since(start).Round(time.Millisecond).String(), }, nil } diff --git a/pkg/cli/ai_test.go b/pkg/cli/ai_test.go index 7875e20..79f6e52 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -31,6 +31,21 @@ func (promptResultProvider) Execute(context.Context, ai.Request) (*ai.Response, }, nil } +type structuredPromptResultProvider struct{} + +func (structuredPromptResultProvider) GetModel() string { return "claude-sonnet-4-6" } + +func (structuredPromptResultProvider) GetBackend() ai.Backend { return ai.BackendAnthropic } + +func (structuredPromptResultProvider) Execute(context.Context, ai.Request) (*ai.Response, error) { + return &ai.Response{ + Text: `{"answer":"42"}`, + StructuredData: json.RawMessage(`{"answer":"42"}`), + Model: "claude-sonnet-4-6", + Backend: ai.BackendAnthropic, + }, nil +} + type promptResultStreamingProvider struct{} func (promptResultStreamingProvider) GetModel() string { return "gpt-5-codex" } @@ -422,6 +437,23 @@ func TestRunBuffered_JSONIncludesFullInputSpec(t *testing.T) { } } +func TestRunBuffered_PreservesStructuredOutput(t *testing.T) { + got, err := runBuffered(context.Background(), structuredPromptResultProvider{}, ai.Request{}) + if err != nil { + t.Fatal(err) + } + result, ok := got.(AIPromptResult) + if !ok { + t.Fatalf("runBuffered returned %T, want AIPromptResult", got) + } + if result.Text != `{"answer":"42"}` { + t.Fatalf("Text = %q, want JSON transcript text", result.Text) + } + if result.StructuredOutput["answer"] != "42" { + t.Fatalf("StructuredOutput = %#v, want decoded answer", result.StructuredOutput) + } +} + func TestRunStreaming_JSONIncludesFullInputSpec(t *testing.T) { req := ai.Request{ Model: api.Model{Name: "gpt-5-codex", Backend: api.BackendCodexCLI, Effort: api.EffortHigh}, @@ -469,6 +501,9 @@ func TestRunStreaming_StructuredResultIsReturnedOnce(t *testing.T) { if result.Text != `{"answer":"42"}` { t.Fatalf("Text = %q, want authoritative structured JSON once", result.Text) } + if result.StructuredOutput["answer"] != "42" { + t.Fatalf("StructuredOutput = %#v, want decoded answer", result.StructuredOutput) + } }) } } diff --git a/pkg/cli/attachments_ginkgo_test.go b/pkg/cli/attachments_ginkgo_test.go index f760289..90ee9b9 100644 --- a/pkg/cli/attachments_ginkgo_test.go +++ b/pkg/cli/attachments_ginkgo_test.go @@ -67,6 +67,23 @@ var _ = Describe("attachment flags", func() { Expect(rendered.Input.Prompt.Attachments).To(Equal([]api.AttachmentRef{{Path: "diagram.png"}})) }) + It("carries prompt workbench attachments into the rendered backend request", func() { + attachment := api.AttachmentRef{ + ID: api.AttachmentIDPrefix + strings.Repeat("a", 64), + Filename: "diagram.png", + MediaType: "image/png", + Size: 512, + } + rendered, err := renderPrompt(context.Background(), "", PromptRenderRequest{Spec: &api.Spec{ + Model: api.Model{Name: "gemini-2.5-pro", Backend: api.BackendGemini}, + Prompt: api.Prompt{Attachments: []api.AttachmentRef{attachment}}, + }}) + + Expect(err).NotTo(HaveOccurred()) + Expect(rendered.ValidationError).To(BeEmpty()) + Expect(rendered.Input.Prompt.Attachments).To(Equal([]api.AttachmentRef{attachment})) + }) + It("reports an unsupported batch attachment as one failed model", func() { dir := GinkgoT().TempDir() path := filepath.Join(dir, "diagram.png") diff --git a/pkg/cli/model_selection_ginkgo_test.go b/pkg/cli/model_selection_ginkgo_test.go index a87b39d..2c6b8f5 100644 --- a/pkg/cli/model_selection_ginkgo_test.go +++ b/pkg/cli/model_selection_ginkgo_test.go @@ -2,17 +2,16 @@ package cli import ( "context" - "github.com/flanksource/captain/pkg/aiflags" "os" "path/filepath" "time" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/aiflags" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) var _ = Describe("CLI model selection", func() { @@ -95,4 +94,26 @@ var _ = Describe("CLI model selection", func() { Expect(result.Runs).To(HaveLen(1)) Expect(result.Runs[0].Selector).To(Equal("gemini:gemini-3.5-flash")) }) + + It("replaces the configured runtime identity for a multi-model variant", func() { + configured := api.Model{Name: "gpt-5.6-luna", Backend: api.BackendCodexCLI}.Capabilities() + selected := api.Model{Name: "gemini-3.5-flash", Backend: api.BackendGemini, Effort: api.EffortHigh}.Capabilities() + + variant := renderVariant(testRenderedPrompt(configured), selected, nil).Config.Model + + Expect(variant.Validate()).To(Succeed()) + Expect(variant).To(Equal(selected)) + }) + + It("preserves runtime fallbacks when no CLI fallback override is set", func() { + selected := api.Model{ + Name: "gemini-3.5-flash", + Backend: api.BackendGemini, + Fallbacks: api.ModelList{{Name: "gemini-3-flash", Backend: api.BackendGemini}}, + } + + variant := renderVariant(testRenderedPrompt(api.Model{}), selected, nil).Config.Model + + Expect(variant.Fallbacks).To(Equal(selected.Fallbacks)) + }) }) diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index baca890..135629c 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -47,6 +47,7 @@ type PromptSummary struct { Writable bool `json:"writable"` Model string `json:"model,omitempty"` Backend string `json:"backend,omitempty"` + Runtimes []api.Model `json:"runtimes,omitempty"` Variables []PromptVariable `json:"variables,omitempty"` ParseError string `json:"parseError,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` @@ -110,13 +111,15 @@ type PromptRenderResult struct { InputSchema map[string]any `json:"inputSchema,omitempty"` InputDefault map[string]any `json:"inputDefault,omitempty"` OutputSchema map[string]any `json:"outputSchema,omitempty"` + Runtimes []api.Model `json:"runtimes,omitempty"` ValidationError string `json:"validationError,omitempty"` } // PromptActionFlags is the full flag surface for `captain prompt run|render` — // the same knobs as `captain ai prompt` (AIRuntimeOptions) plus the prompt-body -// fields — so the two commands are one. The positional (a .prompt filepath or a -// registry id) is the prompt source; --prompt/-p and stdin are alternatives. +// fields — so the two commands are one. The positional (a discovered name, +// .prompt filepath, or registry id) is the prompt source; --prompt/-p and stdin +// are alternatives. type PromptActionFlags struct { AIRuntimeOptions @@ -162,6 +165,7 @@ type promptInspection struct { InputSchema map[string]any InputDefault map[string]any OutputSchema map[string]any + Runtimes []api.Model Variables []PromptVariable } @@ -176,10 +180,10 @@ func RegisterPromptEntity() { UpdateWithContext(updatePrompt). DeleteWithContext(deletePrompt). WithAction(clicky.ActionWithFlagsAndContext("render", PromptActionFlags{}, renderPromptAction). - WithShort("Render a prompt (id, .prompt file, --prompt/-p, or stdin) without calling a model"). + WithShort("Render a prompt (id, name, .prompt file, --prompt/-p, or stdin) without calling a model"). WithOptionalID()). WithAction(clicky.ActionWithFlagsAndContext("run", PromptActionFlags{}, runPromptAction). - WithShort("Run a prompt (id, .prompt file, --prompt/-p, or stdin)"). + WithShort("Run a prompt (id, name, .prompt file, --prompt/-p, or stdin)"). WithOptionalID()). Register() }) diff --git a/pkg/cli/prompt_records.go b/pkg/cli/prompt_records.go index c57017f..664a530 100644 --- a/pkg/cli/prompt_records.go +++ b/pkg/cli/prompt_records.go @@ -99,28 +99,68 @@ func listPromptRecordsFromSource(source promptSource) ([]promptRecord, error) { } func resolvePromptRecord(ctx context.Context, id string) (promptRecord, error) { + id = strings.TrimSpace(id) if looksLikePromptPath(id) { - return filePromptRecord(id) - } - ref, err := decodePromptID(id) - if err != nil { - return promptRecord{}, err + record, err := filePromptRecord(id) + if err == nil { + return record, nil + } + if !isBarePromptFilename(id) || !errors.Is(err, fs.ErrNotExist) { + return promptRecord{}, err + } } sources, err := buildPromptSources(ctx) if err != nil { return promptRecord{}, err } + ref, decodeErr := decodePromptID(id) + if decodeErr == nil { + for _, source := range sources { + if source.Kind != ref.Kind || source.ID != ref.SourceID { + continue + } + path := ref.RelPath + if source.Root != "" { + path = filepath.Join(source.Root, filepath.FromSlash(ref.RelPath)) + } + return promptRecord{Source: source, ID: id, Path: path, Rel: ref.RelPath}, nil + } + return promptRecord{}, fmt.Errorf("prompt source %q not found", ref.SourceID) + } + return resolvePromptRecordByName(sources, id) +} + +func resolvePromptRecordByName(sources []promptSource, name string) (promptRecord, error) { + bareName := strings.TrimSuffix(name, ".prompt") + var matches []promptRecord for _, source := range sources { - if source.Kind != ref.Kind || source.ID != ref.SourceID { - continue + records, err := listPromptRecordsFromSource(source) + if err != nil { + return promptRecord{}, err } - path := ref.RelPath - if source.Root != "" { - path = filepath.Join(source.Root, filepath.FromSlash(ref.RelPath)) + for _, record := range records { + recordName := strings.TrimSuffix(filepath.Base(record.Rel), ".prompt") + if recordName == bareName { + matches = append(matches, record) + } + } + } + switch len(matches) { + case 0: + return promptRecord{}, fmt.Errorf("prompt %q not found", name) + case 1: + return matches[0], nil + default: + paths := make([]string, len(matches)) + for i, match := range matches { + paths[i] = match.Source.Label + ":" + match.Rel } - return promptRecord{Source: source, ID: id, Path: path, Rel: ref.RelPath}, nil + return promptRecord{}, fmt.Errorf("prompt name %q is ambiguous (%s); use a prompt id or path", name, strings.Join(paths, ", ")) } - return promptRecord{}, fmt.Errorf("prompt source %q not found", ref.SourceID) +} + +func isBarePromptFilename(id string) bool { + return filepath.Base(id) == id && !strings.HasPrefix(id, ".") } // looksLikePromptPath reports whether id is a filesystem path rather than a @@ -217,6 +257,10 @@ func promptSummaryFromContent(record promptRecord, content string) (PromptSummar } summary.Model = firstNonEmpty(cfg.Model.Name, req.Name) summary.Backend = firstNonEmpty(string(cfg.Model.Backend), string(req.Backend)) + summary.Runtimes, err = resolvePromptRuntimes(inspection.Runtimes, cfg.Model) + if err != nil { + return PromptSummary{}, err + } summary.Variables = inspection.Variables return summary, nil } @@ -269,6 +313,10 @@ func inspectPrompt(content string, data map[string]any) (promptInspection, error if err != nil { return promptInspection{}, err } + doc, err := promptlib.Parse(content) + if err != nil { + return promptInspection{}, err + } metadata := map[string]any{} if rendered.Raw != nil { for k, v := range rendered.Raw { @@ -294,6 +342,7 @@ func inspectPrompt(content string, data map[string]any) (promptInspection, error InputSchema: inputSchema, InputDefault: inputDefault, OutputSchema: anyToMap(rendered.Output.Schema), + Runtimes: doc.Runtimes, Variables: variablesFromSchema(inputSchema), }, nil } diff --git a/pkg/cli/prompt_render.go b/pkg/cli/prompt_render.go index c550ddd..0e81ba7 100644 --- a/pkg/cli/prompt_render.go +++ b/pkg/cli/prompt_render.go @@ -47,7 +47,7 @@ func renderPrompt(ctx context.Context, id string, renderReq PromptRenderRequest) if err := normalizePromptContextDir(&req, cwd); err != nil { return PromptRenderResult{}, err } - return finalizeRenderResult(record, content, req, cfg) + return finalizeRenderResult(record, content, req, cfg, renderReq.Runtimes) } func renderEphemeralPrompt(renderReq PromptRenderRequest) (PromptRenderResult, error) { @@ -76,7 +76,7 @@ func renderEphemeralPrompt(renderReq PromptRenderRequest) (PromptRenderResult, e if err := normalizePromptContextDir(&req, cwd); err != nil { return PromptRenderResult{}, err } - return finalizeRenderResult(record, content, req, cfg) + return finalizeRenderResult(record, content, req, cfg, renderReq.Runtimes) } func ephemeralPromptContent() string { @@ -88,8 +88,8 @@ description: Ephemeral prompt ` } -// renderPromptCLI is the CLI render path: load from id | .prompt filepath | -p | -// stdin and overlay the flat CLI flags (overlayCLI). +// renderPromptCLI is the CLI render path: load from id | discovered name | +// .prompt filepath | -p | stdin and overlay the flat CLI flags (overlayCLI). func renderPromptCLI(ctx context.Context, id string, opts AIPromptOptions, varsJSON, stdin string) (PromptRenderResult, error) { content, source, usedStdin, record, err := loadPromptContent(ctx, id, opts, stdin) if err != nil { @@ -103,12 +103,22 @@ func renderPromptCLI(ctx context.Context, id string, opts AIPromptOptions, varsJ if err != nil { return PromptRenderResult{}, err } - return finalizeRenderResult(record, content, req, cfg) + result, err := finalizeRenderResult(record, content, req, cfg, nil) + if err != nil { + return PromptRenderResult{}, err + } + if len(opts.MultiModels) > 0 { + result.Runtimes, err = ai.ResolveRuntimeSelectors(opts.MultiModels, result.Config.Model) + if err != nil { + return PromptRenderResult{}, err + } + } + return result, nil } // finalizeRenderResult packages the rendered request/config + prompt detail into // a PromptRenderResult and sets the validation error (shared by both paths). -func finalizeRenderResult(record promptRecord, content string, req ai.Request, cfg ai.Config) (PromptRenderResult, error) { +func finalizeRenderResult(record promptRecord, content string, req ai.Request, cfg ai.Config, runtimeOverride []api.Model) (PromptRenderResult, error) { // Normalize a comma-separated model into a clean primary + fallbacks so the // displayed Model is a single name, then catch a mistyped model (primary or any // fallback) at render time, not just on run. @@ -130,6 +140,14 @@ func finalizeRenderResult(record promptRecord, content string, req ai.Request, c if err != nil { return PromptRenderResult{}, err } + runtimes := detail.Runtimes + if len(runtimeOverride) > 0 { + runtimes = runtimeOverride + } + runtimes, err = resolvePromptRuntimes(runtimes, cfg.Model) + if err != nil { + return PromptRenderResult{}, err + } result := PromptRenderResult{ ID: detail.ID, Name: detail.Name, @@ -142,11 +160,12 @@ func finalizeRenderResult(record promptRecord, content string, req ai.Request, c InputSchema: detail.InputSchema, InputDefault: detail.InputDefault, OutputSchema: detail.OutputSchema, + Runtimes: runtimes, } switch { case req.Prompt.User == "" && len(req.Prompt.Attachments) == 0 && !req.IsVerifyOnly(): result.ValidationError = "prompt text or attachment required" - case cfg.Model.Name == "": + case cfg.Model.Name == "" && len(result.Runtimes) == 0: result.ValidationError = "no model: set prompt frontmatter, pass a model override, or run 'captain configure'" default: if err := req.Validate(); err != nil { @@ -156,6 +175,31 @@ func finalizeRenderResult(record promptRecord, content string, req ai.Request, c return result, nil } +func resolvePromptRuntimes(runtimes []api.Model, base api.Model) ([]api.Model, error) { + if len(runtimes) == 0 { + return nil, nil + } + resolved := make([]api.Model, len(runtimes)) + for i, runtime := range runtimes { + if runtime.Temperature == nil { + runtime.Temperature = base.Temperature + } + if runtime.Effort == api.EffortNone { + runtime.Effort = base.Effort + } + runtime.NoCache = runtime.NoCache || base.NoCache + var err error + resolved[i], err = ai.ResolveModelSelectors(runtime) + if err != nil { + return nil, fmt.Errorf("runtime %d: %w", i+1, err) + } + } + if err := validatePromptRuntimes(resolved); err != nil { + return nil, err + } + return resolved, nil +} + func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { if spec.Name != "" { req.Name = spec.Name @@ -217,6 +261,7 @@ func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { if spec.Prompt.Source != "" { req.Prompt.Source = spec.Prompt.Source } + req.Prompt.Attachments = append(req.Prompt.Attachments, spec.Prompt.Attachments...) req.Prompt.Metadata = mergeStringMaps(req.Prompt.Metadata, spec.Prompt.Metadata) if len(spec.Prompt.SchemaJSON) > 0 { req.Prompt.SchemaJSON = spec.Prompt.SchemaJSON diff --git a/pkg/cli/prompt_run.go b/pkg/cli/prompt_run.go index dd01a75..85e6499 100644 --- a/pkg/cli/prompt_run.go +++ b/pkg/cli/prompt_run.go @@ -10,180 +10,12 @@ import ( "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/database" - clickyapi "github.com/flanksource/clicky/api" clickyrpc "github.com/flanksource/clicky/rpc" "github.com/flanksource/clicky/task" flanksourceContext "github.com/flanksource/commons/context" "github.com/google/uuid" ) -// PromptRunResult is the unified result of the "run" action. Over HTTP (serve) -// it carries the async handle (RunID + Status "running") — the web UI then -// streams from /api/captain/prompt/runs/{runId}/stream. On the CLI it carries the -// synchronous result (Text + tokens/cost). One type serves both transports. -type PromptRunResult struct { - RunID string `json:"runId,omitempty"` - BatchID string `json:"batchId,omitempty"` - Status string `json:"status,omitempty" pretty:"label=Status"` - Model string `json:"model,omitempty" pretty:"label=Model"` - Backend string `json:"backend,omitempty" pretty:"label=Backend"` - Chat bool `json:"chat,omitempty"` - Capabilities ChatCapabilities `json:"capabilities,omitempty"` - - Text string `json:"text,omitempty" pretty:"label=Response"` - SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` - Dir string `json:"dir,omitempty" pretty:"label=Dir"` - HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` - InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` - OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` - CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` - Duration string `json:"duration,omitempty" pretty:"label=Duration"` - - Total int `json:"total,omitempty" pretty:"label=Total"` - Succeeded int `json:"succeeded,omitempty" pretty:"label=Succeeded"` - Failed int `json:"failed,omitempty" pretty:"label=Failed"` - Runs []PromptRunItem `json:"runs,omitempty" pretty:"label=Runs"` -} - -type PromptRunItem struct { - RunID string `json:"runId,omitempty" pretty:"label=Run"` - Selector string `json:"selector,omitempty" pretty:"label=Selector"` - Status string `json:"status,omitempty" pretty:"label=Status"` - Model string `json:"model,omitempty" pretty:"label=Model"` - Backend string `json:"backend,omitempty" pretty:"label=Backend"` - Effort string `json:"effort,omitempty" pretty:"label=Effort"` - Chat bool `json:"chat,omitempty"` - Capabilities ChatCapabilities `json:"capabilities,omitempty"` - Text string `json:"text,omitempty" pretty:"label=Response"` - SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` - Dir string `json:"dir,omitempty" pretty:"label=Dir"` - HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` - InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` - OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` - CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` - Duration string `json:"duration,omitempty" pretty:"label=Duration"` - Error string `json:"error,omitempty" pretty:"label=Error"` -} - -func (r PromptRunResult) Pretty() clickyapi.Text { - if len(r.Runs) == 0 { - if r.Text != "" { - return clickyapi.Text{Content: r.Text} - } - return clickyapi.Text{Content: r.Status} - } - t := clickyapi.Text{}. - Append(fmt.Sprintf("Status: %s Total: %d Succeeded: %d Failed: %d Duration: %s", - r.Status, r.Total, r.Succeeded, r.Failed, r.Duration), "font-medium") - - t = t.NewLine().Add(promptRunComparisonTable(r.Runs)) - for _, run := range r.Runs { - if strings.TrimSpace(run.Text) == "" { - continue - } - t = t.NewLine().NewLine(). - Append("Response — ", "text-gray-500"). - Append(runColumnHeader(run), "font-bold"). - NewLine(). - Append(run.Text) - } - return t -} - -func promptRunComparisonTable(runs []PromptRunItem) clickyapi.TextTable { - table := clickyapi.TextTable{ - Headers: clickyapi.TextList{textCell("Metric")}, - FieldNames: []string{"metric"}, - } - for i, run := range runs { - field := runColumnField(i) - table.FieldNames = append(table.FieldNames, field) - table.Headers = append(table.Headers, textCell(runColumnHeader(run))) - } - - add := func(metric string, values func(PromptRunItem) string) { - row := clickyapi.TableRow{"metric": cell(metric)} - for i, run := range runs { - row[runColumnField(i)] = cell(values(run)) - } - table.Rows = append(table.Rows, row) - } - add("Status", func(run PromptRunItem) string { return run.Status }) - add("Backend", func(run PromptRunItem) string { return run.Backend }) - add("Model", func(run PromptRunItem) string { return run.Model }) - add("Error", func(run PromptRunItem) string { return truncateCell(run.Error, 160) }) - add("Duration", func(run PromptRunItem) string { return run.Duration }) - add("Tokens", func(run PromptRunItem) string { return tokenCell(run.InputTokens, run.OutputTokens) }) - add("Cost", func(run PromptRunItem) string { return costCell(run.CostUSD) }) - add("Session", func(run PromptRunItem) string { return shortSessionCell(run.SessionID) }) - add("History", func(run PromptRunItem) string { return truncatePathCell(run.HistoryFile, 72) }) - add("Dir", func(run PromptRunItem) string { return truncatePathCell(run.Dir, 56) }) - return table -} - -func runColumnField(index int) string { - return fmt.Sprintf("run%d", index+1) -} - -func runColumnHeader(run PromptRunItem) string { - if strings.TrimSpace(run.Selector) != "" { - return run.Selector - } - if run.Backend != "" && run.Model != "" { - return run.Backend + ":" + run.Model - } - return firstNonEmpty(run.Model, run.Backend, "run") -} - -func textCell(s string) clickyapi.Textable { - return clickyapi.Text{Content: s} -} - -func cell(s string) clickyapi.TypedValue { - return clickyapi.TypedValue{Textable: clickyapi.Text{Content: s}} -} - -func truncateCell(s string, max int) string { - s = strings.TrimSpace(s) - if len(s) <= max { - return s - } - return s[:max] + "..." -} - -func truncatePathCell(s string, max int) string { - s = strings.TrimSpace(s) - if len(s) <= max { - return s - } - if max <= 3 { - return s[:max] - } - return "..." + s[len(s)-max+3:] -} - -func shortSessionCell(s string) string { - s = strings.TrimSpace(s) - if len(s) <= 12 { - return s - } - return s[:12] -} - -func tokenCell(input, output int) string { - if input == 0 && output == 0 { - return "" - } - return fmt.Sprintf("%d/%d", input, output) -} - -func costCell(cost float64) string { - if cost <= 0 { - return "" - } - return fmt.Sprintf("$%.4f", cost) -} - // PromptRunSummary is the terminal payload of a run: the SSE stream ends with it // and the task's typed result carries it. type PromptRunSummary struct { @@ -199,23 +31,22 @@ type PromptRunSummary struct { Error string `json:"error,omitempty"` } -// runPromptAction renders the prompt (from an id | .prompt filepath | --prompt | -// stdin), then executes it — synchronously on the CLI (returns the text + cost) -// or asynchronously over HTTP (returns a run handle to stream from). This is the -// single prompt-run implementation; `captain ai prompt` is a deprecated alias. +// runPromptAction renders the prompt (from an id | discovered name | .prompt +// filepath | --prompt | stdin), then executes it — synchronously on the CLI +// (returns the text + cost) or asynchronously over HTTP (returns a run handle to +// stream from). This is the single prompt-run implementation; `captain ai +// prompt` is a deprecated alias. func runPromptAction(ctx context.Context, id string, flags map[string]string) (PromptRunResult, error) { _, isHTTP := clickyrpc.RequestFromContext(ctx) var rendered PromptRenderResult var opts AIPromptOptions - var httpReq PromptRenderRequest var chatRequested bool if isHTTP { req, err := readRenderRequest(ctx, flags) if err != nil { return PromptRunResult{}, err } - httpReq = req if rendered, err = renderPrompt(ctx, id, req); err != nil { return PromptRunResult{}, err } @@ -242,8 +73,8 @@ func runPromptAction(ctx context.Context, id string, flags map[string]string) (P } if isHTTP { - if len(httpReq.Runtimes) > 0 { - return launchAsyncBatch(ctx, id, rendered, httpReq.Runtimes, chatRequested) + if len(rendered.Runtimes) > 0 { + return launchAsyncBatch(ctx, id, rendered, rendered.Runtimes, chatRequested) } return launchAsyncRun(id, rendered, chatRequested), nil } @@ -294,7 +125,7 @@ func workflowConfigured(workflow *api.Workflow) bool { // executeSyncRun runs the prompt in-process (CLI) — live output to stderr, final // result returned — and persists the realized prompt for the launched session. func executeSyncRun(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions) (PromptRunResult, error) { - if len(opts.MultiModels) > 0 { + if len(rendered.Runtimes) > 0 || len(opts.MultiModels) > 0 { return executeSyncBatch(ctx, rendered, opts) } return executeSyncRunSingle(ctx, rendered, opts) @@ -321,27 +152,32 @@ func executeSyncRunSingleDirect(ctx context.Context, rendered PromptRenderResult r, _ := out.(AIPromptResult) persistPromptRun(context.WithoutCancel(ctx), promptRunRecordInput{ Rendered: rendered, SessionID: r.SessionID, Model: r.Model, Backend: r.Backend, - Binding: binding, ResultText: r.Text, + Binding: binding, ResultText: r.Text, ResultJSON: r.StructuredOutput, }) return PromptRunResult{ - Status: "completed", - Model: r.Model, - Backend: r.Backend, - Text: r.Text, - SessionID: r.SessionID, - Dir: r.Dir, - HistoryFile: r.HistoryFile, - InputTokens: r.InputTokens, - OutputTokens: r.Output, - CostUSD: r.CostUSD, - Duration: r.Duration, + Status: "completed", + Model: r.Model, + Backend: r.Backend, + Text: r.Text, + StructuredOutput: r.StructuredOutput, + SessionID: r.SessionID, + Dir: r.Dir, + HistoryFile: r.HistoryFile, + InputTokens: r.InputTokens, + OutputTokens: r.Output, + CostUSD: r.CostUSD, + Duration: r.Duration, }, nil } func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIPromptOptions) (PromptRunResult, error) { - models, err := ai.ResolveRuntimeSelectors(opts.MultiModels, rendered.Config.Model) - if err != nil { - return PromptRunResult{}, err + models := rendered.Runtimes + if len(models) == 0 { + var err error + models, err = ai.ResolveRuntimeSelectors(opts.MultiModels, rendered.Config.Model) + if err != nil { + return PromptRunResult{}, err + } } if len(models) == 0 { return executeSyncRunSingle(ctx, rendered, opts) @@ -430,6 +266,7 @@ func executeSyncBatch(ctx context.Context, rendered PromptRenderResult, opts AIP item.Model = firstNonEmpty(result.Model, item.Model) item.Backend = firstNonEmpty(result.Backend, item.Backend) item.Text = result.Text + item.StructuredOutput = result.StructuredOutput providerSessionID := result.SessionID item.SessionID = binding.SessionID.String() item.Dir = firstNonEmpty(result.Dir, item.Dir) @@ -517,8 +354,8 @@ func renderVariant(rendered PromptRenderResult, model api.Model, fallbacks []api out := rendered req := rendered.Input cfg := rendered.Config - req.Model = variantModel(req.Model, model, fallbacks) - cfg.Model = variantModel(cfg.Model, model, fallbacks) + req.Model = variantModel(model, fallbacks) + cfg.Model = variantModel(model, fallbacks) out.Input = req out.Config = cfg out.Model = cfg.Model.Name diff --git a/pkg/cli/prompt_run_history.go b/pkg/cli/prompt_run_history.go index cfb7c89..9962c5d 100644 --- a/pkg/cli/prompt_run_history.go +++ b/pkg/cli/prompt_run_history.go @@ -58,12 +58,9 @@ func codexHistoryFile(sessionID string) string { return "" } -func variantModel(base api.Model, model api.Model, fallbacks []api.Model) api.Model { - out := base - out.Name = model.Name - out.ID = model.ID - out.Backend = model.Backend - out.Effort = model.Effort - out.Fallbacks = fallbacks - return out +func variantModel(model api.Model, fallbacks []api.Model) api.Model { + if len(fallbacks) > 0 { + model.Fallbacks = fallbacks + } + return model } diff --git a/pkg/cli/prompt_run_live.go b/pkg/cli/prompt_run_live.go index 387728e..0d05f26 100644 --- a/pkg/cli/prompt_run_live.go +++ b/pkg/cli/prompt_run_live.go @@ -66,9 +66,17 @@ func runPromptStream(t *task.Task, rendered PromptRenderResult, timeout time.Dur session = loop.Iterations[0].SessionID } passed := verifyPassed(runResult.Verdicts) + structuredOutput, err := structuredOutputMap(runResult.Response.StructuredData) + if err != nil { + return failRun(t, stream, err) + } + resultText, err := structuredOutputText(runResult.Response.Text, structuredOutput) + if err != nil { + return failRun(t, stream, err) + } record := promptRunRecordInput{ Rendered: rendered, RunID: runID, Binding: binding, SessionID: session, - Model: acc.model, Backend: rendered.Backend, + Model: acc.model, Backend: rendered.Backend, ResultText: resultText, ResultJSON: structuredOutput, } if !passed { record.Error = verifyReason(runResult.Verdicts) diff --git a/pkg/cli/prompt_run_persist.go b/pkg/cli/prompt_run_persist.go index 9fb556d..5e25ea4 100644 --- a/pkg/cli/prompt_run_persist.go +++ b/pkg/cli/prompt_run_persist.go @@ -21,6 +21,7 @@ type promptRunRecordInput struct { Backend string BatchID *uuid.UUID ResultText string + ResultJSON map[string]any Error string } @@ -93,6 +94,9 @@ func persistPromptRun(ctx context.Context, input promptRunRecordInput) { if input.ResultText != "" { update.ResultText = &input.ResultText } + if input.ResultJSON != nil { + update.ResultJSON = &input.ResultJSON + } if input.Error != "" { update.Error = &input.Error } diff --git a/pkg/cli/prompt_run_persist_test.go b/pkg/cli/prompt_run_persist_test.go index c9b870f..cb142ac 100644 --- a/pkg/cli/prompt_run_persist_test.go +++ b/pkg/cli/prompt_run_persist_test.go @@ -18,6 +18,7 @@ func TestPersistPromptRunRecordsNativeRun(t *testing.T) { persistPromptRun(t.Context(), promptRunRecordInput{ Rendered: rendered, RunID: "run-1", SessionID: "0195c1de-4ab8-7000-8000-00000000abcd", Model: "claude-sonnet-5", Backend: "claude-agent", BatchID: &batchID, ResultText: "done", + ResultJSON: map[string]any{"answer": "42"}, }) session, err := db.GetSessionByIdentity(t.Context(), "0195c1de-4ab8-7000-8000-00000000abcd", "claude", "", "") @@ -28,6 +29,7 @@ func TestPersistPromptRunRecordsNativeRun(t *testing.T) { assert.Equal(t, database.PromptRunStateSucceeded, runs[0].State) assert.Equal(t, database.PromptRunPhaseFinished, runs[0].Phase) assert.Equal(t, "done", runs[0].ResultText) + assert.Equal(t, map[string]any{"answer": "42"}, runs[0].ResultJSON) assert.Equal(t, "captain", runs[0].Origin) assert.Equal(t, "run-1", runs[0].AdmissionKey) assert.Equal(t, "fix the failing test", runs[0].PromptMarkdown) diff --git a/pkg/cli/prompt_run_result.go b/pkg/cli/prompt_run_result.go new file mode 100644 index 0000000..ac8b026 --- /dev/null +++ b/pkg/cli/prompt_run_result.go @@ -0,0 +1,175 @@ +package cli + +import ( + "fmt" + "strings" + + clickyapi "github.com/flanksource/clicky/api" +) + +// PromptRunResult is the unified result of the "run" action. Over HTTP (serve) +// it carries the async handle; on the CLI it carries the synchronous result. +type PromptRunResult struct { + RunID string `json:"runId,omitempty"` + BatchID string `json:"batchId,omitempty"` + Status string `json:"status,omitempty" pretty:"label=Status"` + Model string `json:"model,omitempty" pretty:"label=Model"` + Backend string `json:"backend,omitempty" pretty:"label=Backend"` + Chat bool `json:"chat,omitempty"` + Capabilities ChatCapabilities `json:"capabilities,omitempty"` + + Text string `json:"text,omitempty" pretty:"label=Response"` + StructuredOutput map[string]any `json:"structuredOutput,omitempty" pretty:"-"` + SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + Dir string `json:"dir,omitempty" pretty:"label=Dir"` + HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` + InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` + OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` + CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` + Duration string `json:"duration,omitempty" pretty:"label=Duration"` + + Total int `json:"total,omitempty" pretty:"label=Total"` + Succeeded int `json:"succeeded,omitempty" pretty:"label=Succeeded"` + Failed int `json:"failed,omitempty" pretty:"label=Failed"` + Runs []PromptRunItem `json:"runs,omitempty" pretty:"label=Runs"` +} + +type PromptRunItem struct { + RunID string `json:"runId,omitempty" pretty:"label=Run"` + Selector string `json:"selector,omitempty" pretty:"label=Selector"` + Status string `json:"status,omitempty" pretty:"label=Status"` + Model string `json:"model,omitempty" pretty:"label=Model"` + Backend string `json:"backend,omitempty" pretty:"label=Backend"` + Effort string `json:"effort,omitempty" pretty:"label=Effort"` + Chat bool `json:"chat,omitempty"` + Capabilities ChatCapabilities `json:"capabilities,omitempty"` + Text string `json:"text,omitempty" pretty:"label=Response"` + StructuredOutput map[string]any `json:"structuredOutput,omitempty" pretty:"-"` + SessionID string `json:"sessionId,omitempty" pretty:"label=Session"` + Dir string `json:"dir,omitempty" pretty:"label=Dir"` + HistoryFile string `json:"historyFile,omitempty" pretty:"label=History"` + InputTokens int `json:"inputTokens,omitempty" pretty:"label=Input Tokens"` + OutputTokens int `json:"outputTokens,omitempty" pretty:"label=Output Tokens"` + CostUSD float64 `json:"costUSD,omitempty" pretty:"label=Cost USD"` + Duration string `json:"duration,omitempty" pretty:"label=Duration"` + Error string `json:"error,omitempty" pretty:"label=Error"` +} + +func (r PromptRunResult) Pretty() clickyapi.Text { + if len(r.Runs) == 0 { + if r.Text != "" { + return clickyapi.Text{Content: r.Text} + } + return clickyapi.Text{Content: r.Status} + } + t := clickyapi.Text{}. + Append(fmt.Sprintf("Status: %s Total: %d Succeeded: %d Failed: %d Duration: %s", + r.Status, r.Total, r.Succeeded, r.Failed, r.Duration), "font-medium") + + t = t.NewLine().Add(promptRunComparisonTable(r.Runs)) + for _, run := range r.Runs { + if strings.TrimSpace(run.Text) == "" { + continue + } + t = t.NewLine().NewLine(). + Append("Response — ", "text-gray-500"). + Append(runColumnHeader(run), "font-bold"). + NewLine(). + Append(run.Text) + } + return t +} + +func promptRunComparisonTable(runs []PromptRunItem) clickyapi.TextTable { + table := clickyapi.TextTable{ + Headers: clickyapi.TextList{textCell("Metric")}, + FieldNames: []string{"metric"}, + } + for i, run := range runs { + field := runColumnField(i) + table.FieldNames = append(table.FieldNames, field) + table.Headers = append(table.Headers, textCell(runColumnHeader(run))) + } + + add := func(metric string, values func(PromptRunItem) string) { + row := clickyapi.TableRow{"metric": cell(metric)} + for i, run := range runs { + row[runColumnField(i)] = cell(values(run)) + } + table.Rows = append(table.Rows, row) + } + add("Status", func(run PromptRunItem) string { return run.Status }) + add("Backend", func(run PromptRunItem) string { return run.Backend }) + add("Model", func(run PromptRunItem) string { return run.Model }) + add("Error", func(run PromptRunItem) string { return truncateCell(run.Error, 160) }) + add("Duration", func(run PromptRunItem) string { return run.Duration }) + add("Tokens", func(run PromptRunItem) string { return tokenCell(run.InputTokens, run.OutputTokens) }) + add("Cost", func(run PromptRunItem) string { return costCell(run.CostUSD) }) + add("Session", func(run PromptRunItem) string { return shortSessionCell(run.SessionID) }) + add("History", func(run PromptRunItem) string { return truncatePathCell(run.HistoryFile, 72) }) + add("Dir", func(run PromptRunItem) string { return truncatePathCell(run.Dir, 56) }) + return table +} + +func runColumnField(index int) string { + return fmt.Sprintf("run%d", index+1) +} + +func runColumnHeader(run PromptRunItem) string { + if strings.TrimSpace(run.Selector) != "" { + return run.Selector + } + if run.Backend != "" && run.Model != "" { + return run.Backend + ":" + run.Model + } + return firstNonEmpty(run.Model, run.Backend, "run") +} + +func textCell(s string) clickyapi.Textable { + return clickyapi.Text{Content: s} +} + +func cell(s string) clickyapi.TypedValue { + return clickyapi.TypedValue{Textable: clickyapi.Text{Content: s}} +} + +func truncateCell(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[:max] + "..." +} + +func truncatePathCell(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return "..." + s[len(s)-max+3:] +} + +func shortSessionCell(s string) string { + s = strings.TrimSpace(s) + if len(s) <= 12 { + return s + } + return s[:12] +} + +func tokenCell(input, output int) string { + if input == 0 && output == 0 { + return "" + } + return fmt.Sprintf("%d/%d", input, output) +} + +func costCell(cost float64) string { + if cost <= 0 { + return "" + } + return fmt.Sprintf("$%.4f", cost) +} diff --git a/pkg/cli/prompt_run_test.go b/pkg/cli/prompt_run_test.go index 7749fe3..b004241 100644 --- a/pkg/cli/prompt_run_test.go +++ b/pkg/cli/prompt_run_test.go @@ -36,16 +36,17 @@ func TestExecuteSyncRunMultiModelsParallel(t *testing.T) { return nil, errors.New("executor was not called concurrently") } return AIPromptResult{ - Text: cfg.Model.Name, - Model: cfg.Model.Name, - Backend: string(cfg.Model.Backend), - Dir: req.Cwd(), - SessionID: "session-" + cfg.Model.Name, - HistoryFile: filepath.Join(req.Cwd(), ".history", cfg.Model.Name+".jsonl"), - InputTokens: 1, - Output: 2, - CostUSD: 0.01, - Duration: "1ms", + Text: cfg.Model.Name, + StructuredOutput: map[string]any{"model": cfg.Model.Name}, + Model: cfg.Model.Name, + Backend: string(cfg.Model.Backend), + Dir: req.Cwd(), + SessionID: "session-" + cfg.Model.Name, + HistoryFile: filepath.Join(req.Cwd(), ".history", cfg.Model.Name+".jsonl"), + InputTokens: 1, + Output: 2, + CostUSD: 0.01, + Duration: "1ms", }, nil } @@ -81,6 +82,9 @@ func TestExecuteSyncRunMultiModelsParallel(t *testing.T) { if got.Runs[0].CostUSD != 0.01 || got.Runs[0].InputTokens != 1 || got.Runs[0].OutputTokens != 2 { t.Fatalf("run usage/cost missing: %+v", got.Runs[0]) } + if got.Runs[0].StructuredOutput["model"] != got.Runs[0].Model { + t.Fatalf("run structured output missing: %+v", got.Runs[0]) + } } func TestExecuteSyncRunMultiModelsHonorsNoStream(t *testing.T) { @@ -150,9 +154,8 @@ func TestExecuteSyncRunMultiModelsRejectsResume(t *testing.T) { } func TestVariantModelUsesSelectorEffort(t *testing.T) { - base := api.Model{Name: "gpt-5.6-sol", Backend: api.BackendCodexAgent, Effort: api.EffortLow} selector := api.Model{Name: "gpt-5.6-terra", Backend: api.BackendCodexCmux, Effort: api.EffortUltra} - got := variantModel(base, selector, nil) + got := variantModel(selector, nil) if got.Name != selector.Name || got.Backend != selector.Backend || got.Effort != api.EffortUltra { t.Fatalf("variant = %+v, want selector model/backend/effort", got) } diff --git a/pkg/cli/prompt_runtimes_ginkgo_test.go b/pkg/cli/prompt_runtimes_ginkgo_test.go new file mode 100644 index 0000000..d7373d1 --- /dev/null +++ b/pkg/cli/prompt_runtimes_ginkgo_test.go @@ -0,0 +1,166 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("prompt-declared runtimes", func() { + var path string + + BeforeEach(func() { + configPath := filepath.Join(GinkgoT().TempDir(), ".captain.yaml") + captainconfig.SetPathForTesting(configPath) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + path = filepath.Join(GinkgoT().TempDir(), "compare.prompt") + Expect(os.WriteFile(path, []byte(`--- +name: Compare UI audits +model: gemini-3.5-flash +runtimes: + - api:gemini-3.5-flash:high + - model: claude-sonnet-5 + backend: anthropic + effort: medium +--- +{{role "user"}} +Review the screenshot. +`), 0o600)).To(Succeed()) + }) + + It("renders root runtimes as resolved parallel defaults", func() { + rendered, err := renderPromptCLI(context.Background(), path, AIPromptOptions{}, "", "") + + Expect(err).NotTo(HaveOccurred()) + Expect(rendered.Runtimes).To(HaveLen(2)) + Expect(rendered.Runtimes[0]).To(SatisfyAll( + HaveField("Name", "gemini-3.5-flash"), + HaveField("Backend", api.BackendGemini), + HaveField("Effort", api.EffortHigh), + )) + Expect(rendered.Runtimes[1]).To(SatisfyAll( + HaveField("Name", "claude-sonnet-5"), + HaveField("Backend", api.BackendAnthropic), + HaveField("Effort", api.EffortMedium), + )) + }) + + DescribeTable("resolves a discovered prompt by bare filename", + func(id string) { + ctx := ContextWithPromptDirs(context.Background(), []string{filepath.Dir(path)}) + expectedPath, err := filepath.EvalSymlinks(path) + Expect(err).NotTo(HaveOccurred()) + + record, err := resolvePromptRecord(ctx, id) + + Expect(err).NotTo(HaveOccurred()) + Expect(displayPromptPath(record)).To(Equal(expectedPath)) + Expect(record.Rel).To(Equal("compare.prompt")) + }, + Entry("without the extension", "compare"), + Entry("with the extension", "compare.prompt"), + ) + + It("rejects an ambiguous bare filename", func() { + otherDir := GinkgoT().TempDir() + otherPath := filepath.Join(otherDir, filepath.Base(path)) + Expect(os.WriteFile(otherPath, []byte("Review this screenshot."), 0o600)).To(Succeed()) + ctx := ContextWithPromptDirs(context.Background(), []string{filepath.Dir(path), otherDir}) + + _, err := resolvePromptRecord(ctx, "compare") + + Expect(err).To(MatchError(ContainSubstring("prompt name \"compare\" is ambiguous"))) + }) + + It("does not require a singular base model", func() { + content, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(os.WriteFile(path, []byte(strings.Replace(string(content), "model: gemini-3.5-flash\n", "", 1)), 0o600)).To(Succeed()) + + rendered, err := renderPromptCLI(context.Background(), path, AIPromptOptions{}, "", "") + + Expect(err).NotTo(HaveOccurred()) + Expect(rendered.ValidationError).To(BeEmpty()) + Expect(rendered.Runtimes).To(HaveLen(2)) + }) + + It("lets explicit CLI runtimes replace prompt defaults", func() { + rendered, err := renderPromptCLI(context.Background(), path, AIPromptOptions{ + MultiModels: []string{"api:gpt-5.5:high,agent:sol:medium"}, + }, "", "") + + Expect(err).NotTo(HaveOccurred()) + Expect(rendered.Runtimes).To(HaveLen(2)) + Expect(rendered.Runtimes[0].Name).To(Equal("gpt-5.5")) + Expect(rendered.Runtimes[1].Backend).To(Equal(api.BackendCodexAgent)) + }) + + It("lets explicit HTTP runtimes replace prompt defaults", func() { + record, err := filePromptRecord(path) + Expect(err).NotTo(HaveOccurred()) + rendered, err := renderPrompt(context.Background(), record.ID, PromptRenderRequest{Runtimes: []api.Model{ + {Name: "gpt-5.5", Backend: api.BackendOpenAI, Effort: api.EffortHigh}, + {Name: "gpt-5.6-sol", Backend: api.BackendCodexAgent, Effort: api.EffortMedium}, + }}) + + Expect(err).NotTo(HaveOccurred()) + Expect(rendered.Runtimes).To(HaveLen(2)) + Expect(rendered.Runtimes[0].Backend).To(Equal(api.BackendOpenAI)) + Expect(rendered.Runtimes[1].Backend).To(Equal(api.BackendCodexAgent)) + }) + + DescribeTable("executes name-resolved runtimes without CLI selectors", + func(id string) { + originalExecute := executePromptRequestFunc + DeferCleanup(func() { executePromptRequestFunc = originalExecute }) + var mu sync.Mutex + executed := []api.Model{} + executePromptRequestFunc = func(_ context.Context, _ ai.Request, cfg ai.Config, _ time.Duration, _ bool) (any, error) { + mu.Lock() + defer mu.Unlock() + executed = append(executed, cfg.Model) + return AIPromptResult{Text: "ok", Model: cfg.Model.Name, Backend: string(cfg.Model.Backend)}, nil + } + ctx := ContextWithPromptDirs(context.Background(), []string{filepath.Dir(path)}) + rendered, err := renderPromptCLI(ctx, id, AIPromptOptions{}, "", "") + Expect(err).NotTo(HaveOccurred()) + + result, err := executeSyncRun(ctx, rendered, AIPromptOptions{}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.Runs).To(HaveLen(2)) + Expect(executed).To(ConsistOf( + SatisfyAll(HaveField("Name", "gemini-3.5-flash"), HaveField("Backend", api.BackendGemini)), + SatisfyAll(HaveField("Name", "claude-sonnet-5"), HaveField("Backend", api.BackendAnthropic)), + )) + }, + Entry("without the extension", "compare"), + Entry("with the extension", "compare.prompt"), + ) + + It("rejects unknown fields in prompt runtimes", func() { + invalidPath := filepath.Join(GinkgoT().TempDir(), "invalid.prompt") + Expect(os.WriteFile(invalidPath, []byte(`--- +runtimes: + - model: gemini-3.5-flash + backend: gemini + typo: true + - api:sonnet-5:medium +--- +Review the screenshot. +`), 0o600)).To(Succeed()) + + _, err := renderPromptCLI(context.Background(), invalidPath, AIPromptOptions{}, "", "") + + Expect(err).To(MatchError(ContainSubstring("field typo not found"))) + }) +}) diff --git a/pkg/cli/prompt_source.go b/pkg/cli/prompt_source.go index 3b1a426..5fdcae4 100644 --- a/pkg/cli/prompt_source.go +++ b/pkg/cli/prompt_source.go @@ -32,20 +32,21 @@ func readStdinIfCLI(ctx context.Context) string { } // loadPromptContent resolves the prompt source for the unified prompt commands. -// Precedence: the positional (a .prompt filepath or a registry id) > --prompt/-p -// text > piped stdin. usedStdin reports whether stdin became the prompt body (so -// the caller does not also expose it as the {{input}} variable). +// Precedence: the positional (a discovered name, .prompt filepath, or registry +// id) > --prompt/-p text > piped stdin. usedStdin reports whether stdin became +// the prompt body (so the caller does not also expose it as the {{input}} +// variable). func loadPromptContent(ctx context.Context, id string, opts AIPromptOptions, stdin string) (content, source string, usedStdin bool, record promptRecord, err error) { switch { case strings.TrimSpace(id) != "": - record, err := resolvePromptRecord(ctx, id) // .prompt filepath or registry id + record, err := resolvePromptRecord(ctx, id) if err != nil { return "", "", false, promptRecord{}, err } if record.Source.Kind == "file" { log.Debugf("prompt source: file %s (positional %q)", record.Path, id) } else { - log.Debugf("prompt source: registry id %q → %s/%s", id, record.Source.Kind, record.Rel) + log.Debugf("prompt source: resolved %q → %s/%s", id, record.Source.Kind, record.Rel) } c, err := readPromptContent(record) if err != nil { @@ -61,7 +62,7 @@ func loadPromptContent(ctx context.Context, id string, opts AIPromptOptions, std case len(opts.Attach) > 0: return ephemeralPromptContent(), "", false, promptRecord{Rel: "attachment.prompt"}, nil default: - return "", "", false, promptRecord{}, fmt.Errorf("prompt or attachment required: pass a .prompt file/id, --prompt/-p text, --attach/-A, or pipe via stdin") + return "", "", false, promptRecord{}, fmt.Errorf("prompt or attachment required: pass a prompt name, .prompt file, id, --prompt/-p text, --attach/-A, or pipe via stdin") } } diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index e4f9812..b0019c3 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -28,11 +28,10 @@ import ( "github.com/spf13/cobra" ) -// The built webapp is committed under webapp/dist (see .gitignore) because the -// vite build depends on a local clicky-ui link: dependency that is unavailable -// in CI and in the goreleaser release job, so the binary embeds the checked-in -// dist rather than building it. all: ensures dotfiles are embedded too. When -// index.html is absent, serve.go reports it at runtime. +// The built webapp under webapp/dist is generated locally and ignored. The +// tracked .gitkeep lets the embed pattern compile before `task www:build` +// generates the Vite assets. all: ensures the placeholder is embedded too. +// When index.html is absent, serve.go reports it at runtime. // //go:embed all:webapp/dist var captainWebappFS embed.FS diff --git a/pkg/cli/session_get.go b/pkg/cli/session_get.go index e5738c2..01eee4f 100644 --- a/pkg/cli/session_get.go +++ b/pkg/cli/session_get.go @@ -2,9 +2,12 @@ package cli import ( "context" + "encoding/json" "fmt" "strings" + "time" + "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" "github.com/flanksource/clicky" clickyapi "github.com/flanksource/clicky/api" @@ -23,6 +26,7 @@ type SessionGetItem struct { RootSessionID string `json:"rootSessionId,omitempty"` ProviderSessionID string `json:"providerSessionId,omitempty"` Host string `json:"host,omitempty"` + Aggregate bool `json:"aggregate,omitempty"` DetailAvailable bool `json:"detailAvailable"` Summary SessionRecord `json:"summary"` Detail *session.Session `json:"detail,omitempty"` @@ -31,18 +35,31 @@ type SessionGetItem struct { ChatState *ChatStateFrame `json:"chatState,omitempty"` } +type sessionGetStore interface { + sessionOverviewStore + ListPromptRuns(context.Context, database.PromptRunFilter) ([]database.PromptRun, error) +} + // RunSessionGet returns every Captain session matching an exact Captain UUID // or provider-session-id prefix. Transcript-less matches remain visible via -// their overview metadata; recorded transcripts are parsed and paged. +// their overview metadata; recorded transcripts and native prompt runs are +// projected into the same detail model and paged. func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResult, error) { - id := strings.TrimSpace(opts.ID) - if id == "" { + if strings.TrimSpace(opts.ID) == "" { return SessionGetResult{}, fmt.Errorf("id is required") } db, err := captainDB(ctx) if err != nil { return SessionGetResult{}, err } + return runSessionGet(ctx, db, opts) +} + +func runSessionGet(ctx context.Context, db sessionGetStore, opts SessionGetOptions) (SessionGetResult, error) { + id := strings.TrimSpace(opts.ID) + if id == "" { + return SessionGetResult{}, fmt.Errorf("id is required") + } overviews, err := resolveOverviewsByAnyID(ctx, db, id) if err != nil { return SessionGetResult{}, err @@ -51,41 +68,9 @@ func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResul defer rpchttp.Track(ctx, "parse")() items := make([]SessionGetItem, 0, len(overviews)) for i := range overviews { - overview := overviews[i] - path := stringOr(overview.HistoryFile, stringOr(overview.Path, "")) - item := SessionGetItem{ - CaptainID: overview.ID.String(), - ProviderSessionID: stringOr(overview.ProviderSessionID, ""), - Host: overview.HostID, - DetailAvailable: path != "", - Summary: recordFromOverview(overview), - } - if overview.ParentSessionID != nil { - item.ParentSessionID = overview.ParentSessionID.String() - } - if overview.RootSessionID != nil { - item.RootSessionID = overview.RootSessionID.String() - } - capabilities := sessionChatCapabilities(item.Summary) - item.Chat = &capabilities - active, ok := promptChats.getRun(item.CaptainID) - if !ok && item.ProviderSessionID != "" { - active, ok = promptChats.getSession(item.ProviderSessionID) - } - if ok { - var activeCapabilities ChatCapabilities - item.ActiveRunID, activeCapabilities, item.ChatState = active.projection() - item.Chat = &activeCapabilities - } - if path != "" { - detail, buildErr := buildSessionModel(candidateFromOverview(overview)) - if buildErr != nil { - return SessionGetResult{}, fmt.Errorf("parse Captain session %s: %w", overview.ID, buildErr) - } - attachPromptRun(ctx, db, overview.ID, detail) - enrichSessionDetail(detail, item.Summary) - pageSessionMessages(detail, opts) - item.Detail = detail + item, itemErr := buildSessionGetItem(ctx, db, overviews[i], opts) + if itemErr != nil { + return SessionGetResult{}, itemErr } items = append(items, item) } @@ -96,6 +81,177 @@ func RunSessionGet(ctx context.Context, opts SessionGetOptions) (SessionGetResul return SessionGetResult{RootSessionID: rootID, Sessions: items, Total: len(items)}, nil } +func buildSessionGetItem(ctx context.Context, db sessionGetStore, overview database.SessionOverview, opts SessionGetOptions) (SessionGetItem, error) { + item := SessionGetItem{ + CaptainID: overview.ID.String(), ProviderSessionID: stringOr(overview.ProviderSessionID, ""), + Host: overview.HostID, Aggregate: stringOr(overview.AgentType, "") == "batch", + Summary: recordFromOverview(overview), + } + if overview.ParentSessionID != nil { + item.ParentSessionID = overview.ParentSessionID.String() + } + if overview.RootSessionID != nil { + item.RootSessionID = overview.RootSessionID.String() + } + capabilities := sessionChatCapabilities(item.Summary) + item.Chat = &capabilities + active, ok := promptChats.getRun(item.CaptainID) + if !ok && item.ProviderSessionID != "" { + active, ok = promptChats.getSession(item.ProviderSessionID) + } + if ok { + var activeCapabilities ChatCapabilities + item.ActiveRunID, activeCapabilities, item.ChatState = active.projection() + item.Chat = &activeCapabilities + } + detail, err := loadSessionDetail(ctx, db, overview) + if err != nil { + return SessionGetItem{}, err + } + if detail == nil { + return item, nil + } + enrichSessionDetail(detail, item.Summary) + item.DetailAvailable = true + item.Summary.DetailAvailable = true + item.Summary.Messages = max(item.Summary.Messages, len(detail.Messages)) + item.Summary.Provider = firstNonEmpty(item.Summary.Provider, detail.Provider) + item.Summary.Backend = firstNonEmpty(item.Summary.Backend, detail.Backend) + item.Summary.Model = firstNonEmpty(item.Summary.Model, detail.Model) + item.Summary.ReasoningEffort = firstNonEmpty(item.Summary.ReasoningEffort, detail.ReasoningEffort) + pageSessionTranscript(detail, opts) + item.Detail = detail + return item, nil +} + +func loadSessionDetail(ctx context.Context, db sessionGetStore, overview database.SessionOverview) (*session.Session, error) { + path := stringOr(overview.HistoryFile, stringOr(overview.Path, "")) + var detail *session.Session + if path != "" { + parsed, err := buildSessionModel(candidateFromOverview(overview)) + if err != nil { + return nil, fmt.Errorf("parse Captain session %s: %w", overview.ID, err) + } + detail = parsed + } + runs, err := db.ListPromptRuns(ctx, database.PromptRunFilter{SessionID: &overview.ID}) + if err != nil { + return nil, fmt.Errorf("list prompt runs for Captain session %s: %w", overview.ID, err) + } + if len(runs) == 0 { + return detail, nil + } + if detail == nil { + return sessionFromPromptRun(overview, runs[0]) + } + if err := attachPromptRunData(detail, runs[0]); err != nil { + return nil, fmt.Errorf("attach prompt run %s to Captain session %s: %w", runs[0].ID, overview.ID, err) + } + return detail, nil +} + +func sessionFromPromptRun(overview database.SessionOverview, run database.PromptRun) (*session.Session, error) { + resolved := run.Runtime.Resolved + requested := run.Runtime.Requested + detail := &session.Session{ + ID: stringOr(overview.ProviderSessionID, overview.ID.String()), + Source: overview.Source, + Project: stringOr(overview.Project, ""), + CWD: stringOr(overview.CWD, ""), + Slug: stringOr(overview.Slug, ""), + Title: stringOr(overview.Title, ""), + InitialPrompt: stringOr(overview.InitialPrompt, run.PromptMarkdown), + Version: stringOr(overview.CLIVersion, ""), + Provider: firstNonEmpty(overview.Provider, resolved.Provider, requested.Provider), + Backend: firstNonEmpty(stringOr(overview.Backend, ""), resolved.Backend, requested.Backend), + Model: firstNonEmpty(stringOr(overview.Model, ""), resolved.Model, requested.Model), + ReasoningEffort: firstNonEmpty(stringOr(overview.Effort, ""), resolved.Effort, requested.Effort), + StartedAt: firstTime(overview.StartedAt, run.StartedAt, &run.QueuedAt), + EndedAt: firstTime(overview.EndedAt, run.FinishedAt), + } + if run.PromptMarkdown != "" { + detail.Messages = append(detail.Messages, promptRunMessage(run, "user", run.PromptMarkdown)) + } + resultText, err := promptRunResultText(run) + if err != nil { + return nil, err + } + if resultText != "" { + detail.Messages = append(detail.Messages, promptRunMessage(run, "assistant", resultText)) + } + if run.Error != "" { + detail.Events = append(detail.Events, session.Event{ + Type: "error", Scope: "prompt_run", Timestamp: run.FinishedAt, UUID: run.ID.String(), + Data: map[string]any{"message": run.Error, "state": run.State}, + }) + } + if err := attachPromptRunData(detail, run); err != nil { + return nil, fmt.Errorf("encode prompt run %s: %w", run.ID, err) + } + return detail, nil +} + +func promptRunMessage(run database.PromptRun, role, text string) session.Message { + return session.Message{ + ID: run.ID.String() + "-" + role, Role: role, + Parts: []session.Part{{Type: session.PartText, Text: text}}, + } +} + +func promptRunResultText(run database.PromptRun) (string, error) { + if run.ResultText != "" { + return run.ResultText, nil + } + if len(run.ResultJSON) == 0 { + return "", nil + } + raw, err := json.Marshal(run.ResultJSON) + if err != nil { + return "", fmt.Errorf("encode prompt run %s result: %w", run.ID, err) + } + return string(raw), nil +} + +func attachPromptRunData(detail *session.Session, run database.PromptRun) error { + if len(run.RenderedSpec) > 0 { + raw, err := json.Marshal(run.RenderedSpec) + if err != nil { + return err + } + detail.Prompt = raw + } + detail.StructuredOutput = promptRunStructuredOutput(run) + return nil +} + +func promptRunStructuredOutput(run database.PromptRun) map[string]any { + if run.ResultJSON != nil { + return run.ResultJSON + } + if !promptRunDeclaresOutputSchema(run.RenderedSpec) || !json.Valid([]byte(run.ResultText)) { + return nil + } + var output map[string]any + if err := json.Unmarshal([]byte(run.ResultText), &output); err != nil { + return nil + } + return output +} + +func promptRunDeclaresOutputSchema(rendered map[string]any) bool { + schema, ok := rendered["outputSchema"].(map[string]any) + return ok && len(schema) > 0 +} + +func firstTime(values ...*time.Time) *time.Time { + for _, value := range values { + if value != nil && !value.IsZero() { + return value + } + } + return nil +} + func sessionChatCapabilities(summary SessionRecord) ChatCapabilities { capabilities := chatCapabilitiesForBackend(summary.Backend) if summary.Source == "claude" || summary.Source == "codex" { @@ -134,6 +290,10 @@ func enrichSessionDetail(detail *session.Session, summary SessionRecord) { } } +// Pretty renders the flat list form. NOTE: terminal and HTML output do not go +// through here — clicky's TryTypedValue matches TreeMixin before Pretty, so +// Tree below wins for any formatter-driven render. Both delegate to +// SessionGetItem.Pretty, which is where per-session layout changes belong. func (r SessionGetResult) Pretty() clickyapi.Text { list := clicky.List() list.Unstyled = true @@ -174,29 +334,53 @@ func (n sessionGetTreeNode) GetChildren() []clickyapi.TreeNode { func (i SessionGetItem) Pretty() clickyapi.Text { text := clickyapi.Text{}. - Append("Captain ", "text-gray-500"). + Append("Captain ", "text-muted"). Append(i.CaptainID, "font-bold text-blue-600") + if strings.TrimSpace(i.Host) != "" { + text = text.Append(" "+i.Host, "text-muted") + } + // The detail body opens with its own header and Summary rows carrying + // source, project, cwd and the provider session id, so repeating them here + // would duplicate four of the first eight lines of output. + if i.Detail != nil { + return text.NewLine().NewLine().Add(i.Detail.Pretty()).Add(i.hiddenRowsNotice()) + } if i.Summary.Source != "" { - text = text.Append(" ", "").Append(strings.ToUpper(i.Summary.Source), "text-gray-500") + text = text.Append(" ", "").Append(strings.ToUpper(i.Summary.Source), "text-muted") } for _, metadata := range []struct{ label, value string }{ {label: "Provider session", value: i.ProviderSessionID}, - {label: "Host", value: i.Host}, {label: "Project", value: i.Summary.Project}, {label: "CWD", value: i.Summary.CWD}, } { if strings.TrimSpace(metadata.value) != "" { text = text.NewLine(). - Append(" "+metadata.label+": ", "text-gray-500"). - Append(metadata.value, "text-muted") + Append(" "+metadata.label+": ", "text-muted"). + Append(metadata.value, "") } } - if i.Detail != nil { - return text.NewLine().NewLine().Add(i.Detail.Pretty()) + if i.Aggregate { + return text.NewLine().Append(" Aggregate session; child results are shown below", "text-muted") } return text.NewLine().Append(" Transcript: unavailable", "text-amber-600") } +// hiddenRowsNotice reports messages dropped by the transcript window so a +// bounded view never reads as the whole session. Summary.Messages holds the +// full count; the detail holds only the retained slice. +func (i SessionGetItem) hiddenRowsNotice() clickyapi.Text { + if i.Detail == nil { + return clickyapi.Text{} + } + hidden := i.Summary.Messages - len(i.Detail.Messages) + if hidden <= 0 { + return clickyapi.Text{} + } + return clickyapi.Text{}.NewLine().Append( + fmt.Sprintf(" … %d of %d messages hidden — use --limit 0 for the full transcript", + hidden, i.Summary.Messages), "text-amber-600") +} + type sessionGetListItem struct { text clickyapi.Text } @@ -206,25 +390,42 @@ func (i sessionGetListItem) ANSI() string { return i.text.ANSI() } func (i sessionGetListItem) HTML() string { return i.text.HTML() } func (i sessionGetListItem) Markdown() string { return i.text.Markdown() } -// pageSessionMessages windows the message stream: the last Tail messages, or -// an Offset/Limit slice from the start. -func pageSessionMessages(s *session.Session, opts SessionGetOptions) { +// pageSessionTranscript windows both transcript collections: the last Tail +// rows, or an Offset/Limit slice from the start. Events are windowed alongside +// messages because provider state rows (titles, skill listings, prompt +// checkpoints) grow with the session and would otherwise ignore the window +// entirely — a --tail 10 could still emit hundreds of event lines. +func pageSessionTranscript(s *session.Session, opts SessionGetOptions) { + full := session.TranscriptWindow{ + Messages: len(s.Messages), Events: len(s.Events), + ToolCalls: session.CountToolParts(s.Messages), + } + s.Messages = pageTranscriptRows(s.Messages, opts) + s.Events = pageTranscriptRows(s.Events, opts) + // Recorded only when rows were actually dropped, so summaries of a complete + // transcript stay free of window annotations. + if len(s.Messages) != full.Messages || len(s.Events) != full.Events { + s.Window = &full + } +} + +func pageTranscriptRows[T any](rows []T, opts SessionGetOptions) []T { if opts.Tail > 0 { - if len(s.Messages) > opts.Tail { - s.Messages = s.Messages[len(s.Messages)-opts.Tail:] + if len(rows) > opts.Tail { + return rows[len(rows)-opts.Tail:] } - return + return rows } if opts.Offset <= 0 && opts.Limit <= 0 { - return + return rows } offset := max(opts.Offset, 0) - if offset >= len(s.Messages) { - s.Messages = nil - return + if offset >= len(rows) { + return nil } - s.Messages = s.Messages[offset:] - if opts.Limit > 0 && len(s.Messages) > opts.Limit { - s.Messages = s.Messages[:opts.Limit] + rows = rows[offset:] + if opts.Limit > 0 && len(rows) > opts.Limit { + rows = rows[:opts.Limit] } + return rows } diff --git a/pkg/cli/session_get_compact_test.go b/pkg/cli/session_get_compact_test.go new file mode 100644 index 0000000..75603e4 --- /dev/null +++ b/pkg/cli/session_get_compact_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/session" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// These specs join the package's existing suite (see session_get_multi_test.go); +// Ginkgo permits only one RunSpecs per package. + +// transcriptFixture builds a session detail with the given number of messages +// and events so paging can be observed on both collections. +func transcriptFixture(messages, events int) *session.Session { + s := &session.Session{ID: "ae95d3f5-9303-45ae-8c93-a83e13c79811", Source: "claude"} + for i := 0; i < messages; i++ { + s.Messages = append(s.Messages, session.Message{ + ID: fmt.Sprintf("m%d", i), + Role: "user", + Parts: []session.Part{{Type: session.PartText, Text: fmt.Sprintf("message %d", i)}}, + }) + } + for i := 0; i < events; i++ { + s.Events = append(s.Events, session.Event{ + Type: "task_started", Scope: "session", UUID: fmt.Sprintf("e%d", i), + }) + } + return s +} + +var _ = Describe("session get transcript paging", func() { + It("bounds events alongside messages under --tail", func() { + detail := transcriptFixture(50, 40) + + pageSessionTranscript(detail, SessionGetOptions{Tail: 5}) + + Expect(detail.Messages).To(HaveLen(5)) + Expect(detail.Events).To(HaveLen(5), + "events were previously unbounded, so --tail 5 still dumped every state row") + Expect(detail.Messages[0].ID).To(Equal("m45")) + Expect(detail.Events[0].UUID).To(Equal("e35")) + }) + + It("bounds events alongside messages under --offset/--limit", func() { + detail := transcriptFixture(50, 40) + + pageSessionTranscript(detail, SessionGetOptions{Offset: 10, Limit: 5}) + + Expect(detail.Messages).To(HaveLen(5)) + Expect(detail.Events).To(HaveLen(5)) + Expect(detail.Messages[0].ID).To(Equal("m10")) + Expect(detail.Events[0].UUID).To(Equal("e10")) + }) + + It("returns everything when the limit is explicitly cleared", func() { + detail := transcriptFixture(50, 40) + + pageSessionTranscript(detail, SessionGetOptions{Limit: 0}) + + Expect(detail.Messages).To(HaveLen(50)) + Expect(detail.Events).To(HaveLen(40)) + }) + + It("leaves a collection untouched when it is shorter than the window", func() { + detail := transcriptFixture(3, 2) + + pageSessionTranscript(detail, SessionGetOptions{Tail: 10}) + + Expect(detail.Messages).To(HaveLen(3)) + Expect(detail.Events).To(HaveLen(2)) + }) +}) + +var _ = Describe("session get header", func() { + It("does not repeat detail fields in the Captain header", func() { + item := SessionGetItem{ + CaptainID: "d81f885d-3d60-47c0-8122-a8124f2fbdd1", + ProviderSessionID: "ae95d3f5-9303-45ae-8c93-a83e13c79811", + Host: "MacBook-Pro.local", + DetailAvailable: true, + Summary: SessionRecord{ + ID: "ae95d3f5-9303-45ae-8c93-a83e13c79811", Source: "claude", + Project: "captain", CWD: "/repo/captain", Messages: 4, + }, + Detail: &session.Session{ + ID: "ae95d3f5-9303-45ae-8c93-a83e13c79811", Source: "claude", + Project: "captain", CWD: "/repo/captain", + }, + } + + rendered := item.Pretty().String() + + Expect(rendered).To(ContainSubstring("d81f885d-3d60-47c0-8122-a8124f2fbdd1")) + Expect(rendered).To(ContainSubstring("MacBook-Pro.local")) + Expect(strings.Count(rendered, "/repo/captain")).To(Equal(1), + "CWD is a Summary row; the header must not repeat it") + Expect(rendered).NotTo(ContainSubstring("Provider session:"), + "the provider session id is already the detail's ID row") + }) + + It("keeps identifying metadata when no transcript is available", func() { + item := SessionGetItem{ + CaptainID: "7ca78c55-e280-50ff-a19a-9f355a6fc55e", + ProviderSessionID: "ad4c854e-cde6-4b99-99f3-667bf74112e3", + Host: "local", + Summary: SessionRecord{Source: "gavel", Project: "xero-cli", CWD: "/repo/xero"}, + } + + rendered := item.Pretty().String() + + Expect(rendered).To(ContainSubstring("Transcript: unavailable")) + Expect(rendered).To(ContainSubstring("ad4c854e-cde6-4b99-99f3-667bf74112e3")) + Expect(rendered).To(ContainSubstring("xero-cli")) + Expect(rendered).To(ContainSubstring("/repo/xero")) + }) + + It("reports how many transcript rows the window hid", func() { + detail := transcriptFixture(200, 0) + pageSessionTranscript(detail, SessionGetOptions{Tail: 5}) + item := SessionGetItem{ + CaptainID: "d81f885d-3d60-47c0-8122-a8124f2fbdd1", + Summary: SessionRecord{Messages: 200}, + Detail: detail, + } + + rendered := item.Pretty().String() + + Expect(rendered).To(ContainSubstring("195 of 200 messages hidden")) + Expect(rendered).To(ContainSubstring("--limit 0")) + }) + + It("stays silent when the whole transcript is shown", func() { + detail := transcriptFixture(4, 0) + item := SessionGetItem{ + CaptainID: "d81f885d-3d60-47c0-8122-a8124f2fbdd1", + Summary: SessionRecord{Messages: 4}, + Detail: detail, + } + + Expect(item.Pretty().String()).NotTo(ContainSubstring("hidden")) + }) +}) diff --git a/pkg/cli/session_get_multi_test.go b/pkg/cli/session_get_multi_test.go index a4ee8a8..4326ba2 100644 --- a/pkg/cli/session_get_multi_test.go +++ b/pkg/cli/session_get_multi_test.go @@ -160,6 +160,102 @@ var _ = Describe("session get multi-result output", func() { Expect(store.threadRoots).To(Equal([]uuid.UUID{rootID})) }) + It("hydrates transcript-less API sessions from persisted prompt runs", func(ctx SpecContext) { + rootID := uuid.MustParse("055781c7-360a-4eb2-80be-452b3937fcfe") + childID := uuid.MustParse("7ca78c55-e280-50ff-a19a-9f355a6fc55e") + runID := uuid.MustParse("293b06b4-f6b7-4f69-a531-7499bd5a473a") + agentType := "batch" + startedAt := time.Date(2026, time.July, 19, 9, 30, 0, 0, time.UTC) + finishedAt := startedAt.Add(8 * time.Second) + store := &sessionGetOverviewStore{ + identity: []database.SessionOverview{{ID: rootID, Source: "captain", AgentType: &agentType}}, + thread: []database.SessionOverview{ + {ID: rootID, Source: "captain", AgentType: &agentType}, + { + ID: childID, ParentSessionID: &rootID, RootSessionID: &rootID, Source: "captain", + Provider: "google", PromptRunCount: 1, + }, + }, + promptRuns: map[uuid.UUID][]database.PromptRun{ + childID: {{ + ID: runID, SessionID: childID, RootSessionID: rootID, + RenderedSpec: map[string]any{ + "name": "structured-ui-review", + "outputSchema": map[string]any{"type": "object"}, + }, + PromptMarkdown: "Review the attached form screenshot.", + ResultText: `{"summary":"Use a single-column form layout."}`, + Runtime: database.PromptRunRuntime{Resolved: database.PromptRunRuntimeSelection{ + Provider: "google", Backend: "gemini-api", Model: "gemini-2.5-pro", Effort: "high", + }}, + State: database.PromptRunStateSucceeded, Phase: database.PromptRunPhaseFinished, + StartedAt: &startedAt, FinishedAt: &finishedAt, + }}, + }, + } + + result, err := runSessionGet(ctx, store, SessionGetOptions{ID: rootID.String()}) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.Sessions).To(HaveLen(2)) + Expect(result.Sessions[0].Aggregate).To(BeTrue()) + Expect(result.Pretty().String()).To(ContainSubstring("Aggregate session; child results are shown below")) + Expect(result.Pretty().String()).NotTo(ContainSubstring("Transcript: unavailable")) + child := result.Sessions[1] + Expect(child.DetailAvailable).To(BeTrue()) + Expect(child.Summary.DetailAvailable).To(BeTrue()) + Expect(child.Summary.Messages).To(Equal(2)) + Expect(child.Summary.Model).To(Equal("gemini-2.5-pro")) + Expect(child.Summary.Backend).To(Equal("gemini-api")) + Expect(child.Summary.Provider).To(Equal("google")) + Expect(child.Detail).NotTo(BeNil()) + Expect(child.Detail.Messages).To(Equal([]session.Message{ + { + ID: runID.String() + "-user", Role: "user", + Parts: []session.Part{{Type: session.PartText, Text: "Review the attached form screenshot."}}, + }, + { + ID: runID.String() + "-assistant", Role: "assistant", + Parts: []session.Part{{Type: session.PartText, Text: `{"summary":"Use a single-column form layout."}`}}, + }, + })) + Expect(child.Detail.Model).To(Equal("gemini-2.5-pro")) + Expect(child.Detail.Backend).To(Equal("gemini-api")) + Expect(child.Detail.Provider).To(Equal("google")) + Expect(child.Detail.StartedAt).To(PointTo(Equal(startedAt))) + Expect(child.Detail.EndedAt).To(PointTo(Equal(finishedAt))) + Expect(child.Detail.Prompt).To(MatchJSON(`{ + "name":"structured-ui-review", + "outputSchema":{"type":"object"} + }`)) + Expect(child.Detail.StructuredOutput).To(Equal(map[string]any{ + "summary": "Use a single-column form layout.", + })) + }) + + It("prefers persisted structured output and ignores non-schema JSON text", func() { + runID := uuid.MustParse("293b06b4-f6b7-4f69-a531-7499bd5a473a") + overview := database.SessionOverview{ID: uuid.New(), Source: "captain"} + detail, err := sessionFromPromptRun(overview, database.PromptRun{ + ID: runID, + RenderedSpec: map[string]any{ + "outputSchema": map[string]any{"type": "object"}, + }, + ResultText: `{"source":"text"}`, + ResultJSON: map[string]any{"source": "stored"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(detail.StructuredOutput).To(Equal(map[string]any{"source": "stored"})) + Expect(detail.Messages).To(HaveLen(1)) + Expect(detail.Messages[0].Parts[0].Text).To(Equal(`{"source":"text"}`)) + + detail, err = sessionFromPromptRun(overview, database.PromptRun{ + ID: runID, ResultText: `{"source":"plain-text-prompt"}`, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(detail.StructuredOutput).To(BeNil()) + }) + It("renders every match sequentially and preserves metadata-only sessions", func() { result := SessionGetResult{ Sessions: []SessionGetItem{ @@ -237,6 +333,7 @@ type sessionGetOverviewStore struct { identities []string threadRoots []uuid.UUID listCalls int + promptRuns map[uuid.UUID][]database.PromptRun } func (s *sessionGetOverviewStore) ListSessionSummaries(_ context.Context, filter database.SessionListFilter) (database.SessionListPage, error) { @@ -262,3 +359,10 @@ func (s *sessionGetOverviewStore) ListThreadSessionOverviews(_ context.Context, s.threadRoots = append(s.threadRoots, rootID) return s.thread, nil } + +func (s *sessionGetOverviewStore) ListPromptRuns(_ context.Context, filter database.PromptRunFilter) ([]database.PromptRun, error) { + if filter.SessionID == nil { + return nil, nil + } + return s.promptRuns[*filter.SessionID], nil +} diff --git a/pkg/cli/session_list_render.go b/pkg/cli/session_list_render.go index 142bc96..dfd2b2e 100644 --- a/pkg/cli/session_list_render.go +++ b/pkg/cli/session_list_render.go @@ -8,10 +8,13 @@ import ( "github.com/flanksource/clicky/api" ) -const ( - sessionIDDisplayWidth = 12 - sessionInitialPromptWidth = 100 -) +const sessionIDDisplayWidth = 12 + +// sessionInitialPromptStyle collapses the prompt to a single line but sets no +// width cap: the column runs to whatever the terminal leaves it and the renderer +// truncates there, rather than at a width picked here that the terminal knows +// nothing about. +const sessionInitialPromptStyle = "max-lines-[1] truncate-suffix" var _ api.TableProvider = SessionRecord{} @@ -24,7 +27,7 @@ func (SessionRecord) Columns() []api.ColumnDef { api.Column("project").Label("Project").MaxWidth(20).Build(), api.Column("session").Label("Session").MaxWidth(sessionIDDisplayWidth).Build(), api.Column("model").Label("Model").MaxWidth(24).Build(), - api.Column("initial_prompt").Label("Initial Prompt").Style("max-lines-[1] truncate-suffix").MaxWidth(sessionInitialPromptWidth).Build(), + api.Column("initial_prompt").Label("Initial Prompt").Style(sessionInitialPromptStyle).Build(), api.Column("tokens").Label("Tokens").Build(), } } @@ -56,8 +59,7 @@ func sessionListID(id string) string { } func sessionInitialPromptCell(prompt string) api.Textable { - style := fmt.Sprintf("max-lines-[1] max-w-[%dch] truncate-suffix", sessionInitialPromptWidth) - return api.Text{}.Append(prompt, style) + return api.Text{}.Append(prompt, sessionInitialPromptStyle) } func sessionProjectName(r SessionRecord) string { diff --git a/pkg/cli/session_list_render_test.go b/pkg/cli/session_list_render_test.go index 5aa5104..875fcd2 100644 --- a/pkg/cli/session_list_render_test.go +++ b/pkg/cli/session_list_render_test.go @@ -33,8 +33,10 @@ func TestSessionRecordTableProviderIsCompact(t *testing.T) { t.Fatalf("column %d = %q, want %q", i, columns[i].Name, want) } } - if columns[5].MaxWidth != sessionInitialPromptWidth { - t.Fatalf("initial prompt max width = %d, want %d", columns[5].MaxWidth, sessionInitialPromptWidth) + // The prompt column declares no width: it runs to whatever the terminal + // leaves it, and the renderer truncates there. + if columns[5].MaxWidth != 0 { + t.Fatalf("initial prompt max width = %d, want no cap", columns[5].MaxWidth) } if !strings.Contains(columns[5].Style, "max-lines-[1]") { t.Fatalf("initial prompt style = %q", columns[5].Style) @@ -63,19 +65,24 @@ func TestSessionRecordTableProviderIsCompact(t *testing.T) { if strings.Contains(prompt, "\n") || strings.Contains(prompt, secondLine) { t.Fatalf("initial prompt is not one line: %q", prompt) } - if got := len([]rune(prompt)); got != sessionInitialPromptWidth { - t.Fatalf("initial prompt width = %d, want %d: %q", got, sessionInitialPromptWidth, prompt) + // The cell keeps its full width -- only the extra line is dropped. Capping + // it here would truncate at a width the terminal knows nothing about. + if !strings.Contains(prompt, longTail) { + t.Fatalf("initial prompt was width-truncated before rendering: %q", prompt) } if !strings.HasPrefix(prompt, "Improve the sessions table") || !strings.HasSuffix(prompt, "…") { t.Fatalf("initial prompt = %q", prompt) } - for name, rendered := range map[string]string{"ansi": table.ANSI(), "markdown": table.Markdown()} { - if !strings.Contains(rendered, "6522fe00-9a7") || !strings.Contains(rendered, "$1.25") { - t.Fatalf("%s output missing compact identity/usage: %q", name, rendered) - } - if strings.Contains(rendered, longTail) || strings.Contains(rendered, secondLine) { - t.Fatalf("%s output did not truncate the initial prompt: %q", name, rendered) - } + + rendered := table.ANSI() + if !strings.Contains(rendered, "6522fe00-9a7") || !strings.Contains(rendered, "$1.25") { + t.Fatalf("ansi output missing compact identity/usage: %q", rendered) + } + if strings.Contains(rendered, longTail) || strings.Contains(rendered, secondLine) { + t.Fatalf("ansi output did not truncate the initial prompt: %q", rendered) + } + if lines := strings.Count(strings.Trim(rendered, "\n"), "\n") + 1; lines != 5 { + t.Fatalf("ansi output should be border/header/separator/row/border, got %d lines: %q", lines, rendered) } } diff --git a/pkg/cli/session_record_db.go b/pkg/cli/session_record_db.go index 4a3ccd6..d5ec9e2 100644 --- a/pkg/cli/session_record_db.go +++ b/pkg/cli/session_record_db.go @@ -213,13 +213,13 @@ func recordFromOverview(overview database.SessionOverview) SessionRecord { ReasoningEffort: stringOr(overview.Effort, ""), Version: stringOr(overview.CLIVersion, ""), GitBranch: overviewGitBranch(overview), - Provider: metadata.Provider, + Provider: firstNonEmpty(overview.Provider, metadata.Provider), Backend: stringOr(overview.Backend, ""), LifecycleStatus: string(overview.LifecycleStatus), CWD: stringOr(overview.CWD, ""), ToolCalls: int(overview.ToolCallCount), Messages: int(overview.MessageCount), - DetailAvailable: path != "", + DetailAvailable: path != "" || overview.PromptRunCount > 0, CostUSD: overview.CostUSD, } if record.EndedAt == nil { diff --git a/pkg/cli/sessions.go b/pkg/cli/sessions.go index 060ddde..923c96e 100644 --- a/pkg/cli/sessions.go +++ b/pkg/cli/sessions.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "os" "path/filepath" @@ -12,9 +11,7 @@ import ( "time" "github.com/flanksource/captain/pkg/claude" - "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/session" - "github.com/google/uuid" ) type SessionListOptions struct { @@ -28,9 +25,9 @@ type SessionListOptions struct { type SessionGetOptions struct { ID string `flag:"id" args:"true" help:"Session id (full or unambiguous prefix)"` - Offset int `flag:"offset" help:"Skip this many messages from the start"` - Limit int `flag:"limit" help:"Maximum messages to return; 0 means all" short:"l"` - Tail int `flag:"tail" help:"Return only the last N messages (overrides offset/limit)"` + Offset int `flag:"offset" help:"Skip this many transcript rows from the start"` + Limit int `flag:"limit" help:"Maximum transcript rows to return; 0 means all" default:"200" short:"l"` + Tail int `flag:"tail" help:"Return only the last N transcript rows (overrides offset/limit)"` } func (SessionGetOptions) GetName() string { return "get " } @@ -319,18 +316,6 @@ func buildSessionModel(candidate sessionCandidate) (*session.Session, error) { } } -// attachPromptRun attaches the realized prompt (for captain-launched sessions) -// from the native prompt-run store to the session model. -func attachPromptRun(ctx context.Context, db *database.DB, sessionID uuid.UUID, s *session.Session) { - runs, err := db.ListPromptRuns(ctx, database.PromptRunFilter{SessionID: &sessionID}) - if err != nil || len(runs) == 0 || len(runs[0].RenderedSpec) == 0 { - return - } - if raw, err := json.Marshal(runs[0].RenderedSpec); err == nil { - s.Prompt = raw - } -} - func normalizeSessionSource(source string) (string, error) { source = strings.ToLower(strings.TrimSpace(source)) switch source { diff --git a/pkg/cli/structured_output.go b/pkg/cli/structured_output.go new file mode 100644 index 0000000..a496d6b --- /dev/null +++ b/pkg/cli/structured_output.go @@ -0,0 +1,38 @@ +package cli + +import ( + "encoding/json" + "fmt" +) + +func structuredOutputMap(value any) (map[string]any, error) { + if value == nil { + return nil, nil + } + if output, ok := value.(map[string]any); ok { + return output, nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode structured output: %w", err) + } + if string(raw) == "null" { + return nil, nil + } + var output map[string]any + if err := json.Unmarshal(raw, &output); err != nil { + return nil, fmt.Errorf("decode structured output object: %w", err) + } + return output, nil +} + +func structuredOutputText(text string, output map[string]any) (string, error) { + if text != "" || output == nil { + return text, nil + } + raw, err := json.Marshal(output) + if err != nil { + return "", fmt.Errorf("encode structured output text: %w", err) + } + return string(raw), nil +} diff --git a/pkg/cli/webapp/package.json b/pkg/cli/webapp/package.json index 5fefc28..50da037 100644 --- a/pkg/cli/webapp/package.json +++ b/pkg/cli/webapp/package.json @@ -18,11 +18,13 @@ "@tanstack/react-query": "^5.80.7", "ai": "^6.0.199", "marked": "^15.0.0", + "monaco-editor": "0.48.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-rnd": "^10.5.3", "shiki": "^1.24.0", - "streamdown": "^2.5.0" + "streamdown": "^2.5.0", + "yaml": "^2.8.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", diff --git a/pkg/cli/webapp/src/App.tsx b/pkg/cli/webapp/src/App.tsx index e3b093e..337cd3e 100644 --- a/pkg/cli/webapp/src/App.tsx +++ b/pkg/cli/webapp/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppShell, type AppShellProps } from "@flanksource/clicky-ui/components"; import { @@ -10,6 +10,11 @@ import { EntityExplorerApp } from "@flanksource/clicky-ui/rpc"; import { apiClient } from "./api"; import { AgentLauncher } from "./AgentLauncher"; import { ChatLayer } from "./ChatLayer"; +import { + CommandPalette, + SearchTrigger, + useCommandPaletteShortcut, +} from "./CommandPalette"; import { ChatRoute } from "./ChatRoute"; import { HomeDashboard } from "./HomeDashboard"; import { PromptWorkbench } from "./PromptWorkbench"; @@ -50,6 +55,12 @@ export function App() { ); + const [paletteOpen, setPaletteOpen] = useState(false); + useCommandPaletteShortcut( + useCallback(() => setPaletteOpen((prev) => !prev), []), + ); + const shellSearch = setPaletteOpen(true)} />; + return ( @@ -60,6 +71,7 @@ export function App() { onNavigate={router.navigate} navSections={captainNavSections("sessions", projectScope)} actions={shellActions} + search={shellSearch} projectScope={projectScope} /> ) : route.kind === "prompts" ? ( @@ -68,6 +80,7 @@ export function App() { onNavigate={router.navigate} navSections={captainNavSections("prompts", projectScope)} actions={shellActions} + search={shellSearch} /> ) : ( {route.kind === "dashboard" ? ( @@ -100,6 +114,11 @@ export function App() { )} + setPaletteOpen(false)} + onNavigate={router.navigate} + /> @@ -111,12 +130,14 @@ function CaptainShell({ onNavigate, projectScope, actions, + search, children, }: { active: PrimaryRoute; onNavigate: (to: string, opts?: { replace?: boolean }) => void; projectScope: ProjectScope; actions: AppShellProps["actions"]; + search: AppShellProps["search"]; children: AppShellProps["children"]; }) { return ( @@ -126,6 +147,7 @@ function CaptainShell({ navSections={captainNavSections(active, projectScope)} collapsedStorageKey={CAPTAIN_SIDEBAR_COLLAPSE_KEY} actions={actions} + search={search} contentClassName="p-0 overflow-hidden" > {children} diff --git a/pkg/cli/webapp/src/CommandPalette.test.tsx b/pkg/cli/webapp/src/CommandPalette.test.tsx new file mode 100644 index 0000000..c56165e --- /dev/null +++ b/pkg/cli/webapp/src/CommandPalette.test.tsx @@ -0,0 +1,259 @@ +import { useState } from "react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CommandPalette, + directSessionId, + useCommandPaletteShortcut, +} from "./CommandPalette"; +import type { SessionRecord } from "./sessionData"; +import type { PromptSummary } from "./promptData"; + +const fetchSessionSearch = vi.hoisted(() => vi.fn()); +const fetchPromptList = vi.hoisted(() => vi.fn()); + +vi.mock("./sessionData", async (importOriginal) => ({ + ...(await importOriginal()), + fetchSessionSearch, +})); + +vi.mock("./promptData", async (importOriginal) => ({ + ...(await importOriginal()), + fetchPromptList, + resolvePromptOps: () => ({ list: { path: "/api/v1/prompt", method: "GET" } }), +})); + +vi.mock("@flanksource/clicky-ui/rpc", async (importOriginal) => ({ + ...(await importOriginal()), + useOperations: () => ({ operations: [], isLoading: false, error: null }), +})); + +// Fixtures use values distinct from anything the component derives, so an +// assertion can only pass if the component actually plumbed them through. +const SESSION_KEY = "claude-a1b2c3d4e5f60718"; +const SESSION_ID = "11111111-2222-3333-4444-555555555555"; +const PROMPT_ID = "local:review-diff"; + +function session(overrides: Partial = {}): SessionRecord { + return { + key: SESSION_KEY, + id: SESSION_ID, + source: "claude", + title: "Refactor the billing importer", + project: "/home/dev/acme/billing", + toolCalls: 0, + messages: 0, + ...overrides, + } as SessionRecord; +} + +function prompt(overrides: Partial = {}): PromptSummary { + return { + id: PROMPT_ID, + name: "review-diff", + sourceKind: "local", + sourceId: "local", + source: "local", + path: "/home/dev/prompts/review-diff.prompt", + relPath: "prompts/review-diff.prompt", + writable: true, + ...overrides, + } as PromptSummary; +} + +function renderPalette(onNavigate = vi.fn(), onClose = vi.fn()) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + render( + + + , + ); + return { onNavigate, onClose }; +} + +async function type(value: string) { + const input = screen.getByLabelText("Search sessions and prompts"); + fireEvent.change(input, { target: { value } }); + return input; +} + +beforeEach(() => { + fetchSessionSearch.mockReset(); + fetchPromptList.mockReset(); + fetchSessionSearch.mockResolvedValue({ sessions: [], total: 0 }); + fetchPromptList.mockResolvedValue([]); +}); + +afterEach(cleanup); + +describe("directSessionId", () => { + it("accepts a token long enough to be an unambiguous id prefix", () => { + // resolveOverviewsByAnyID takes UUIDs, provider-id prefixes and record + // keys, so this must not be gated on a UUID shape. + expect(directSessionId(SESSION_ID)).toBe(SESSION_ID); + expect(directSessionId("a1b2c3d4")).toBe("a1b2c3d4"); + expect(directSessionId(` ${SESSION_KEY} `)).toBe(SESSION_KEY); + }); + + it("rejects prose and tokens too short to disambiguate", () => { + expect(directSessionId("billing importer")).toBeNull(); + expect(directSessionId("a1b2c3")).toBeNull(); + expect(directSessionId("")).toBeNull(); + }); +}); + +describe("useCommandPaletteShortcut", () => { + function Harness() { + const [count, setCount] = useState(0); + useCommandPaletteShortcut(() => setCount((c) => c + 1)); + return {count}; + } + + it("fires on Cmd+K and Ctrl+K but not on a bare k or Alt+Cmd+K", () => { + render(); + const count = () => screen.getByTestId("count").textContent; + + fireEvent.keyDown(window, { key: "k", metaKey: true }); + expect(count()).toBe("1"); + + fireEvent.keyDown(window, { key: "k", ctrlKey: true }); + expect(count()).toBe("2"); + + fireEvent.keyDown(window, { key: "k" }); + fireEvent.keyDown(window, { key: "k", metaKey: true, altKey: true }); + expect(count()).toBe("2"); + }); +}); + +describe("CommandPalette", () => { + it("prompts for input before any query is typed", () => { + renderPalette(); + expect( + screen.getByText(/Type to search sessions and prompts/), + ).toBeInTheDocument(); + expect(fetchSessionSearch).not.toHaveBeenCalled(); + }); + + it("searches all projects rather than the active project scope", async () => { + renderPalette(); + await type("billing"); + await waitFor(() => + expect(fetchSessionSearch).toHaveBeenCalledWith({ + query: "billing", + limit: 20, + }), + ); + }); + + it("opens the highlighted session on Enter using its record key", async () => { + fetchSessionSearch.mockResolvedValue({ sessions: [session()], total: 1 }); + const { onNavigate, onClose } = renderPalette(); + const input = await type("billing"); + + await screen.findByText("Refactor the billing importer"); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onNavigate).toHaveBeenCalledWith( + `/sessions/${encodeURIComponent(SESSION_KEY)}`, + ); + expect(onClose).toHaveBeenCalled(); + }); + + it("opens a prompt result at its prompt route", async () => { + fetchPromptList.mockResolvedValue([prompt()]); + const { onNavigate } = renderPalette(); + const input = await type("review"); + + await screen.findByText("review-diff"); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onNavigate).toHaveBeenCalledWith( + `/prompts/${encodeURIComponent(PROMPT_ID)}`, + ); + }); + + it("wraps arrow navigation across the session and prompt groups", async () => { + fetchSessionSearch.mockResolvedValue({ sessions: [session()], total: 1 }); + fetchPromptList.mockResolvedValue([prompt()]); + const { onNavigate } = renderPalette(); + const input = await type("review"); + + await screen.findByText("review-diff"); + + // Two rows total, starting on the session. ArrowDown crosses into the + // prompt group... + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onNavigate).toHaveBeenCalledWith( + `/prompts/${encodeURIComponent(PROMPT_ID)}`, + ); + + // ...and a further ArrowDown wraps past the end back to the session. + onNavigate.mockClear(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onNavigate).toHaveBeenCalledWith( + `/sessions/${encodeURIComponent(SESSION_KEY)}`, + ); + + // ArrowUp wraps in the opposite direction, off the first row to the last. + onNavigate.mockClear(); + fireEvent.keyDown(input, { key: "ArrowUp" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onNavigate).toHaveBeenCalledWith( + `/prompts/${encodeURIComponent(PROMPT_ID)}`, + ); + }); + + it("offers a direct-open row that defers resolution to the session route", async () => { + const { onNavigate } = renderPalette(); + const input = await type(SESSION_ID); + + await screen.findByText("Open session by id"); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onNavigate).toHaveBeenCalledWith( + `/sessions/${encodeURIComponent(SESSION_ID)}`, + ); + }); + + it("does not offer a direct-open row for a multi-word query", async () => { + renderPalette(); + await type("billing importer"); + await waitFor(() => expect(fetchSessionSearch).toHaveBeenCalled()); + expect(screen.queryByText("Open session by id")).not.toBeInTheDocument(); + }); + + it("caps each group and reports the remainder", async () => { + const many = Array.from({ length: 11 }, (_, i) => + session({ key: `key-${i}`, id: `id-${i}`, title: `Session ${i}` }), + ); + fetchSessionSearch.mockResolvedValue({ sessions: many, total: many.length }); + renderPalette(); + await type("session"); + + await screen.findByText("Session 0"); + expect(screen.getByText("Session 7")).toBeInTheDocument(); + expect(screen.queryByText("Session 8")).not.toBeInTheDocument(); + expect(screen.getByText("+3 more")).toBeInTheDocument(); + }); + + it("reports when nothing matches", async () => { + renderPalette(); + // Multi-word so no direct-open row is offered; otherwise the palette always + // has at least one row and the empty state is unreachable. + await type("no such thing"); + expect( + await screen.findByText(/No sessions or prompts match/), + ).toBeInTheDocument(); + }); +}); diff --git a/pkg/cli/webapp/src/CommandPalette.tsx b/pkg/cli/webapp/src/CommandPalette.tsx new file mode 100644 index 0000000..d16f488 --- /dev/null +++ b/pkg/cli/webapp/src/CommandPalette.tsx @@ -0,0 +1,412 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Modal } from "@flanksource/clicky-ui/components"; +import { + UiCode2, + UiSearch, + UiTerminal, + type IconComponent, +} from "@flanksource/clicky-ui/icons"; +import { useOperations } from "@flanksource/clicky-ui/rpc"; +import { apiClient } from "./api"; +import { + fetchSessionSearch, + projectLabel, + sessionTitle, + shortID, + type SessionRecord, +} from "./sessionData"; +import { + fetchPromptList, + resolvePromptOps, + type PromptSummary, +} from "./promptData"; + +// The ⌘K palette is captain's single global search: it spans sessions and +// prompts regardless of the active route and jumps to the chosen item rather +// than filtering a list in place. The per-page SearchInput on /sessions still +// owns scoped, in-place filtering; this owns "find that one thing". +// +// Session results deliberately ignore the app-bar project scope so a session is +// findable from anywhere, and the direct-open row hands the raw text to +// /sessions/{id}, whose RunSessionGet → resolveOverviewsByAnyID resolution is +// the same code path as `captain sessions get`. + +const isMac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ""); +export const paletteShortcutLabel = isMac ? "⌘K" : "Ctrl K"; + +// GROUP_CAP keeps each section short so the list stays scannable; overflow is +// surfaced as a "+N more" hint rather than silently dropped. +const GROUP_CAP = 8; +const DEBOUNCE_MS = 200; + +// resolveOverviewsByAnyID accepts a full Captain UUID, a provider-session-id +// prefix, or a path-derived record key — so the direct-open row cannot gate on +// a UUID regex. Anything token-shaped and long enough to be unambiguous counts. +const MIN_DIRECT_ID_LENGTH = 8; + +/** + * Binds ⌘K / Ctrl+K to `toggle` for the lifetime of the caller. Works even while + * a field is focused — it isn't a text-editing shortcut. `SearchInput`'s own + * `onShortcut` cannot serve here: that listener lives in `InputField` and only + * runs while the field is mounted, which a closed palette is not. + */ +export function useCommandPaletteShortcut(toggle: () => void) { + useEffect(() => { + const onKey = (event: globalThis.KeyboardEvent) => { + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + event.key.toLowerCase() === "k" + ) { + event.preventDefault(); + toggle(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [toggle]); +} + +export function directSessionId(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length < MIN_DIRECT_ID_LENGTH) return null; + if (/\s/.test(trimmed)) return null; + return trimmed; +} + +interface Row { + key: string; + icon: IconComponent; + title: string; + subtitle: string; + meta: string; + onSelect: () => void; +} + +/** + * Top-bar affordance that stands in for an inline search box: a click (or the + * ⌘K/Ctrl+K shortcut) opens the palette. Rendered on every route so global + * search is always one key away. + */ +export function SearchTrigger({ onOpen }: { onOpen: () => void }) { + return ( + + ); +} + +export function CommandPalette({ + open, + onClose, + onNavigate, +}: { + open: boolean; + onClose: () => void; + onNavigate: (to: string, opts?: { replace?: boolean }) => void; +}) { + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [active, setActive] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Reset to a clean slate every time the palette opens, then move focus to the + // input so the user can type immediately. The Modal focuses its own panel in a + // passive effect whose flush time varies (opening cascades state updates), so a + // single deferred focus keeps losing the race. Instead retry briefly until the + // input holds focus, then stop — there is no focus trap, so once it lands it + // stays. This self-corrects regardless of when the Modal grabs focus. + useEffect(() => { + if (!open) return; + setQuery(""); + setDebounced(""); + setActive(0); + let tries = 0; + const timer = setInterval(() => { + const el = inputRef.current; + if (el && document.activeElement !== el) el.focus(); + if (++tries >= 10 || (el && document.activeElement === el)) + clearInterval(timer); + }, 30); + return () => clearInterval(timer); + }, [open]); + + // Results are server-side, so hold off a beat rather than firing a request per + // keystroke. + useEffect(() => { + const timer = setTimeout(() => setDebounced(query.trim()), DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query]); + + const enabled = open && debounced.length > 0; + + const sessionsQuery = useQuery({ + queryKey: ["palette-sessions", debounced], + queryFn: () => fetchSessionSearch({ query: debounced, limit: 20 }), + enabled, + }); + + const { operations } = useOperations(apiClient); + const promptOps = useMemo(() => resolvePromptOps(operations), [operations]); + const promptsQuery = useQuery({ + queryKey: ["palette-prompts", promptOps.list?.path, debounced], + queryFn: () => + fetchPromptList(promptOps.list!, { source: "all", query: debounced }), + enabled: enabled && Boolean(promptOps.list), + }); + + const sessions = sessionsQuery.data?.sessions ?? EMPTY_SESSIONS; + const prompts = promptsQuery.data ?? EMPTY_PROMPTS; + const directId = directSessionId(query); + + // One flat, ordered list (direct id, sessions, then prompts) backs keyboard + // navigation; the rendered groups index into it so the highlight and Enter + // stay in sync. + const rows = useMemo(() => { + const out: Row[] = []; + if (directId) { + out.push({ + key: `id:${directId}`, + icon: UiSearch, + title: "Open session by id", + subtitle: directId, + meta: "id", + onSelect: () => { + onClose(); + onNavigate(`/sessions/${encodeURIComponent(directId)}`); + }, + }); + } + for (const session of sessions.slice(0, GROUP_CAP)) { + out.push({ + key: `session:${session.key}`, + icon: UiTerminal, + title: sessionTitle(session), + subtitle: shortID(session.id) || session.key, + meta: session.project ? projectLabel(session.project) : session.source, + onSelect: () => { + onClose(); + onNavigate(`/sessions/${encodeURIComponent(session.key)}`); + }, + }); + } + for (const prompt of prompts.slice(0, GROUP_CAP)) { + out.push({ + key: `prompt:${prompt.id}`, + icon: UiCode2, + title: prompt.name, + subtitle: prompt.relPath || prompt.path, + meta: prompt.sourceKind, + onSelect: () => { + onClose(); + onNavigate(`/prompts/${encodeURIComponent(prompt.id)}`); + }, + }); + } + return out; + }, [directId, sessions, prompts, onClose, onNavigate]); + + // Keep the active index in range as results change while typing. + useEffect(() => { + setActive((a) => (rows.length === 0 ? 0 : Math.min(a, rows.length - 1))); + }, [rows.length]); + + // Scroll the highlighted row into view as the selection moves. + useEffect(() => { + listRef.current + ?.querySelector(`[data-row="${active}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [active]); + + function onKeyDown(event: KeyboardEvent) { + if (rows.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setActive((a) => (a + 1) % rows.length); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActive((a) => (a - 1 + rows.length) % rows.length); + } else if (event.key === "Enter") { + event.preventDefault(); + rows[active]?.onSelect(); + } + } + + const directBase = directId ? 1 : 0; + const promptBase = directBase + Math.min(sessions.length, GROUP_CAP); + const loading = sessionsQuery.isFetching || promptsQuery.isFetching; + + return ( + +
    + + { + setQuery(event.target.value); + setActive(0); + }} + onKeyDown={onKeyDown} + placeholder="Search sessions, prompts, or paste a session id…" + aria-label="Search sessions and prompts" + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + + esc + +
    + +
    + {!query.trim() ? ( +
    + Type to search sessions and prompts, or paste a session id. +
    + ) : rows.length === 0 && !loading ? ( +
    + No sessions or prompts match “{query.trim()}”. +
    + ) : ( + <> + {directId && ( + + + + )} + {sessions.length > 0 && ( + + {sessions.slice(0, GROUP_CAP).map((_, i) => ( + + ))} + + )} + {prompts.length > 0 && ( + + {prompts.slice(0, GROUP_CAP).map((_, i) => ( + + ))} + + )} + {loading && ( +
    + Searching… +
    + )} + + )} +
    +
    + ); +} + +function Group({ + label, + overflow, + children, +}: { + label: string; + overflow: number; + children: ReactNode; +}) { + return ( +
    +
    + {label} + {overflow > 0 && ( + +{overflow} more + )} +
    + {children} +
    + ); +} + +function PaletteRow({ + row, + index, + active, + onHover, +}: { + row: Row; + index: number; + active: number; + onHover: (index: number) => void; +}) { + const isActive = index === active; + const Icon = row.icon; + return ( + + ); +} + +const EMPTY_SESSIONS: SessionRecord[] = []; +const EMPTY_PROMPTS: PromptSummary[] = []; diff --git a/pkg/cli/webapp/src/PromptSchemaEditor.test.tsx b/pkg/cli/webapp/src/PromptSchemaEditor.test.tsx new file mode 100644 index 0000000..9de1f64 --- /dev/null +++ b/pkg/cli/webapp/src/PromptSchemaEditor.test.tsx @@ -0,0 +1,161 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PromptSchemaEditor } from "./PromptSchemaEditor"; + +const monacoModels = vi.hoisted( + () => new Map void }>(), +); + +vi.mock("@flanksource/clicky-ui/monaco", () => ({ + MonacoEditor: ({ + value, + onChange, + path, + onMount, + }: { + value: string; + onChange: (value: string) => void; + path: string; + onMount?: (editor: { + getModel: () => { value: string; dispose: () => void }; + }) => void; + }) => { + const model = monacoModels.get(path) ?? { + value, + dispose: () => monacoModels.delete(path), + }; + monacoModels.set(path, model); + onMount?.({ getModel: () => model }); + return ( +