Compare commits

..
Author SHA1 Message Date
Gnanam 6174b20226 zeroline: remove the ZERO boot splash — launch straight to the empty state
The animated ZERO-wordmark boot splash showed for ~2s before the home; per the
request it's gone. Dropped the splash render branch + the m.booted flag/bootFrames
gating, and removed the now-dead RenderBoot + wordmark + newCanvasStyles (its last
users) and the --boot snapshot flag. The frame ticker stays (spinners); showSplash
stays (drives the empty state + placeholder). startup.go (default shell) untouched.
build/vet/test/windows/deadcode green.
2026-06-09 20:44:06 +05:30
Gnanam 4388807eee zeroline: home/empty state now matches the spec (chrome + centered 0)
The home was a separate full-screen Zen page (giant ZERO wordmark, no bars),
which doesn't match the spec: there the empty state lives INSIDE the chat layout
(title bar + status bar + composer) with a small 0 mark + tagline + model hint +
chips centered in the stream. Reworked to that: RenderChat renders an emptyState
in its body when there are no rows/stream; added zeroMark (small 0) + ChatData
.Chips/.ChipIndex; routed the live home + snapshot --page home through RenderChat;
removed the now-unused RenderHome + HomeData. emptyState is frame-exact (centers +
crops to h rows). Boot splash keeps the ZERO wordmark. build/vet/test/windows/
deadcode green.
2026-06-09 20:36:07 +05:30
Gnanam 28d55e3fd8 zeroline: remove the abandoned colorizeDiff/diffLine colorizer
The old unified-diff colorizer (colorizeDiff free func + method, diffLine,
diffMaxLines) was replaced by the bordered diff card (diffBody) in Phase 4 and has
no production caller (deadcode-confirmed); only its own tests referenced it.
Removed it + the tests that exclusively covered it (TestColorizeDiff*) + the
forceColor helper and its lipgloss/termenv imports. Kept looksLikeDiff (used by
the card) + TestLooksLikeDiff. Diff card (diffBody) + all snapshots unchanged;
build/vet/test/goldens green.
2026-06-09 20:12:17 +05:30
Gnanam 16554508f5 zeroline: remove the abandoned glamour markdown renderer
FIX 1 made assistant prose plain (renderSay), so the glamour markdown path is
dead: delete markdown.go (renderMarkdown/markdownRenderer/themeStyleConfig/sp/hex/
boolp + the renderer cache) and styles.renderAssistantMarkdown/renderAssistantPlain
in render.go (provably unreachable from main per deadcode). Removed the tests that
exclusively covered them (TestRenderMarkdown*, TestMarkdownRenderer*,
TestAssistantMarkdownClips*) + the now-orphaned glamour import. glamour is no
longer imported anywhere (can be go-mod-tidied separately). Kept looksLikeDiff +
its test and the plain/streaming assistant tests. build/vet/test/goldens green.
2026-06-09 20:09:11 +05:30
Gnanam 84ff761544 zeroline: remove provably-dead helpers orphaned by the redesign
staticcheck U1000-confirmed unused (no production OR test references): styles.block
(old bg-space caret, replaced by the ▌ glyph), styles.renderAssistant (dispatcher
— assistant is now plain renderSay), styles.renderCodeBlock + styles.codeLine (old
tool-output block, replaced by tool cards), resultSummary + plural + countNonEmpty
+ countBodyLines (old one-line result summaries, replaced by cards). No test
touched; U1000 clean afterward; build/vet/test/goldens green.
2026-06-09 20:03:17 +05:30
Gnanam a503b3a0ba zeroline: color the resolved-permission line by outcome (.perm-resolved)
The recorded permission line was always amber ⚠ regardless of decision; per the
spec it now reflects the outcome — allow/always green ✓, deny red ✗, pending ⚠
(parsed from the action word in the row text). +regression test.
2026-06-09 19:56:36 +05:30
Gnanam df6194544b zeroline: fix read_file card double line-numbering
The read_file tool already emits a 'File: … (N lines)' header and numbers each
line ('N | …'), but the read card re-numbered them — producing a doubled gutter
like '3   1 | import random' (seen in the live TUI). New readBody drops the
redundant File: header (the card head shows the path) and keeps the tool's own
numbering; numberedBody stays for the fallback/write_file body. Snapshot --tool
read now uses the real read_file output format. +regression test.
2026-06-09 19:51:34 +05:30
Gnanam 27b377186a zeroline: polish to match the spec — plain assistant prose, bordered chips, clean composer
FIX 1: assistant prose (.blk-say) renders as plain MUTED text (renderSay), no
panel/background — was routed through glamour (near-ink on a code block bg). Only
the final answer (.blk-final) keeps the accent rail + ink.
FIX 2: each home suggestion is its own bordered rounded box (.chip: panel bg,
accent ❯ + label); the selected chip uses a thick accent border (terminal analog
of the spec's accent hover border, visible without color). Was plain stacked rows.
FIX 3: composer shows the spec placeholder per surface (home 'describe a task for
zero…', chat 'message zero…') via a dynamic placeholder; the home composer box now
renders the accent ❯ prompt too. No %% artifact existed on this branch.
TDD: backtick-survival (plain say), chipBox bordered+selected, home separate
boxes, placeholder clean. No golden files exist (regenerate step is a no-op).
2026-06-09 19:39:09 +05:30
Gnanam 31cab0d836 zeroline: fix Phase 6-9 adversarial-review findings
- High: the sessions-drawer modal block no longer swallows Ctrl+C or a pending
  permission's keyboard (a/A/d) — both now take precedence over the drawer.
- High: ctrl+s on the home page no longer opens a drawer nothing renders (which
  froze input); it's a no-op until the chat page.
- Medium: composer clips the input + bounds input.Width (zeroline) so a long line
  can't push the run/stop button off the frame; drawer id/when row clipped to the
  panel width (long raw-timestamp fallback no longer overflows).
- Nit: stop button gets the spec panel background.
- Tests: Ctrl+C-through-drawer, no-drawer-on-home, composer-button-not-clipped.
Deferred (noted): TEXT|JSON toggle hidden at very narrow widths; drawer Enter-to-
resume action.
2026-06-09 18:41:30 +05:30
Gnanam c8bdbad5a1 zeroline: gofmt pre-existing test files in touched packages (keep gofmt -l clean) 2026-06-09 18:21:16 +05:30
Gnanam 5b685861f1 zeroline: complete --snapshot flag coverage (Phase 9)
Add --tool diff|read|bash|grep (single tool-card snapshot) and --frame N
(spinner frame); fix the --variant mapping to a direct theme index (0 ZERO …)
to match the flag help after the default change. Tests cover every snapshot
state (home/chat/json/sessions/perm/stream/tool).
2026-06-09 18:20:15 +05:30
Gnanam 9b1d02510c zeroline: sessions drawer slide-over (Phase 8)
ctrl+s opens a right-side sessions panel (Esc/ctrl+s closes, ↑/↓ navigate); it
dims the body and composites a bordered panel (header + session rows: accent id,
faint timestamp, title, model · turns; selected row gets an accent ▌ rail) on the
right, staying exactly h rows / w cells. Backed by the real sessions.Store
(store.List, most-recent first) with the sample list as a fallback. Snapshot
gains --sessions. Drawer is a top-level modal in Update (intercepts keys).
2026-06-09 18:17:15 +05:30
Gnanam d69396323d zeroline: TEXT/JSON view toggle (Phase 7)
ctrl+j toggles a jsonMode (model + ChatData.JSONMode), reflected by the title-bar
segmented toggle. In JSON mode RenderChat renders the run as a synthetic stream of
syntax-colored JSON events (.jsonwrap): a faint '$ zero run -p … --format json'
header + one event line per row (keys blue, strings ink, numbers amber, type
accent, punctuation faint), frame-exact. Snapshot gains --json. Pure view toggle;
the agent is unaffected.
2026-06-09 18:08:39 +05:30
Gnanam 30a9a060a5 zeroline: clear textinput prompt (composer owns ❯) + spec placeholder
The composer renders the ❯ prompt itself, so the textinput's own '❯ ' prompt
would double it in the live TUI; clear it for the zeroline skin and set the
spec placeholder 'describe a task for zero…'.
2026-06-09 18:03:59 +05:30
Gnanam 65139c7b04 zeroline: composer with accent prompt + run/stop affordance (Phase 6)
cmdRegion now renders an accent ❯ prompt + input with a right-aligned affordance:
lime 'run ↵' normally, red '■ stop' while the agent is working. Perm-mode key
hint updated to a/A/d. Snapshot chat input de-doubled (composer owns the prompt).
2026-06-09 18:03:26 +05:30
Gnanam 8c7c7e75ba zeroline: default the --snapshot theme to ZERO (variant 0) 2026-06-09 17:58:54 +05:30
Gnanam 1ded5ec253 zeroline: fix Phase 4.4 adversarial-review findings
- High: running-tool detection tested the dead 'toolcall' kind (rows are now
  'tool'), so a mid-run tool card stacked a spurious 'thinking…' line under it —
  now checks Kind=='tool' && Running.
- Medium: clip the tool-card head so a long (MCP) tool name/target can't wrap the
  head into extra rows.
- diffBody +N/-M stat now skips unified-diff ---/+++/@@ header lines; removed a
  dead sign variable.
- regression tests: running-tool suppresses thinking, long-name no head overflow,
  diff stat excludes headers.
Deferred (noted): real file line numbers, diff-head path/NEW tag, real bash exit
code (needs tools plumbing), Windows drive-letter grep paths, empty-id edge.
2026-06-09 17:57:26 +05:30
Gnanam f7f2c0c64a zeroline: permission card polish + clickable buttons (Phase 5)
Card: PERMISSION badge (amber fill) + risk on the top row, amber border, lime
allow button, keys a/A/d (capital A = always). Mouse: enable WithMouseCellMotion
in run.go + a tea.MouseMsg handler that hit-tests PermLayout and resolves the
permission on a left-click. Also fix grepBody to detab before clipping (a tab
counted as 1 but rendered wider, overflowing the card) — caught by the review.
Tests: PERMISSION render, mouse-click resolves allow, grep-tab no-overflow.
2026-06-09 17:54:23 +05:30
Gnanam 337bdd1a16 tools: edit_file emits a unified diff as its result (Phase 4.2)
edit_file's Output is now the focused old→new UnifiedDiff (the success summary
moves to Display.Summary), so the zeroline diff card — and the default skin —
show the actual change. apply_patch already carries a diff.
2026-06-09 17:44:32 +05:30
Gnanam 20a48e6af5 zeroline: bordered tool cards for diff/read/bash/grep (Phase 4.4)
Merge each tool call + its result into a single 'tool' card row (zerolineRows);
render a rounded ╭╮╰╯ card: head (accent icon + bold name + muted target +
spinner→✓/✗; accent border running, red border on error) + a per-tool body —
diff (+N/-M stat, numbered signed colored lines), read (numbered), bash ($ cmd +
output + exit foot), grep (path:line blue + count). Snapshot --page chat updated
to show the cards. (edit_file→UnifiedDiff result wiring is the next step.)
2026-06-09 17:41:38 +05:30
Gnanam 64f78875a9 zeroline: fix Phase 3 adversarial-review findings
- renderSay tone --muted (s.dim, was s.mute=faint); streaming + say now preserve
  line structure and re-clip long unbroken tokens (new wrapBlock helper).
- doneLine: spec glyph ■ (was ●), faint meta (s.mute), clipped to frame width
  (was a hardcoded 84 → spurious ellipsis on wide / backstop-only on narrow).
- system/deny notes render one styled line per input line (was firstLine-only,
  which silently truncated multi-line resume-session summaries); sys tone faint.
- regression tests: multi-line note, streaming newlines, long-token re-clip.
2026-06-09 17:31:07 +05:30
Gnanam 75342a4a31 tools: add UnifiedDiff + DiffStat (LCS line diff) for the zeroline diff card (Phase 4 pt 1)
Pure, dep-free LCS line diff emitting +/-/space-prefixed lines the zeroline
renderer parses; DiffStat counts +N/-M for the diff-card head. edit_file/
apply_patch wiring + the diff/read/bash/grep tool cards follow.
2026-06-09 17:25:18 +05:30
Gnanam eee8036c52 zeroline: redesign transcript blocks to the ZERO spec (Phase 3)
user → accent ❯ + ink (drops the 'you' label); assistant prose keeps markdown;
new final (accent rail + ink), done (●/red + faint meta), and sys/deny note
blocks; streaming renders muted say text + an accent ▌ caret (drops the ✦ zero
label and the bg-space caret).
2026-06-09 17:21:00 +05:30
Gnanam 0e1a5dde02 zeroline: redesign home/empty state to the ZERO spec (Phase 2)
ZERO wordmark (accent) + spec tagline + 'running zero against <model>' hint +
suggestion chips (accent ❯ + label, selected chip carries an accent ▌ rail).
Adds HomeData.Chips/ChipIndex + DefaultChips(); drops the recent-sessions list
and header stripe from home (sessions move to the drawer in Phase 8).
2026-06-09 17:15:28 +05:30
Gnanam aa3b610109 zeroline: redesign title + status bars to the ZERO spec (Phase 1 chrome)
Title bar: lime 0 brand mark + zero name + model menu label + cwd (hidden when
tight); TEXT|JSON segmented toggle + git branch on the right. Status bar: accent
dot + run mode, tok/s, tokens, ctx gauge, cost. Each bar stays exactly one row
and full width (bar() spacer). Run mode moves from the title to the status bar.
2026-06-09 17:05:50 +05:30
Gnanam 318e6937d5 zeroline: add ZERO design palette as the default theme (exact spec hex) 2026-06-09 16:56:16 +05:30
KRATOS 58e209d021 Sound / completion notifier: terminal bell + OSC-9 on turn completion & awaiting-input (#149)
* notify: dep-free terminal bell / OSC-9 completion notifier

* config: notify.mode + notify.focusMode (validated, default off)

* exec: --notify/--no-notify + completion notify on stderr

* tui: focus reporting + completion/awaiting-input notifications

* notify: fire on exec --use-spec completion; headless ignores focus; gate focus-reporting on enabled

* notify: validate --notify value, skip notify on empty ask_user, deterministic resolver tests

Addresses #149 review (CodeRabbit + @Vasanthdev2004):
- parseExecArgs rejects an invalid --notify value (only off/bell/notify/both),
  so a CLI typo errors early instead of silently running with no notification.
- TUI OnAskUser fires AwaitingInput only when len(request.Questions) > 0 — a
  request with no questions auto-resolves without prompting, so the bell/desktop
  notification was a false positive.
- notify resolver tests pass Env: map[string]string{} so Resolve does not fall
  back to os.Getenv (deterministic, not ambient-env dependent).
2026-06-09 14:34:24 +05:30
KRATOS 2ea36cf138 Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets (#148)
* Deferred (lazy) tool loading: tool_search + auto threshold for large MCP tool sets

Adds deferred tool loading so a large MCP tool set no longer sends every tool's full JSON schema each turn. When the number of visible MCP tools crosses a threshold (config tools.deferThreshold, default 10; 0 disables), their schemas are withheld and advertised compactly in a per-turn system-reminder; the model pulls a tool's full schema on demand via a new tool_search tool. Strictly additive: below the threshold behaviour is byte-identical to today. No new module dependencies.

- Eligibility: MCP tools opt in via an optional Deferred() bool interface (tools.IsDeferred); built-ins stay eager. No change to the core Tool interface.
- Formatting (internal/tools/deferred.go): compact one-liners wrapped in a <system-reminder> by BuildDeferredReminder; server label via MCPServerName().
- tool_search (internal/tools/tool_search.go): SideEffectNone/PermissionAllow/AdvertiseInAuto; select:Name1,Name2 (exact) or keyword (ranked); returns full schemas and signals the loop via Meta["load_tools"]. Operator-filter-aware (never lists tools hidden by --enabled-tools/--disabled-tools).
- Agent loop: partitionTools is the single source of truth; eligible counts only ToolVisible deferred tools; Meta["load_tools"] lifts to ToolResult.LoadedTools and unions into a per-run loaded-set advertised next turn. Reminder appended only to the per-turn request copy (never persisted), including both reactive-compaction retry paths. tool_search stays reachable when deferral is active (exempt from the EnabledTools allowlist).
- Config + wiring: tools.deferThreshold (default 10; 0 disables; negative rejected); tool_search registered and DeferThreshold threaded in exec and TUI, gated on the same visible-deferred count the loop uses.

Recreated on top of current main; resolves the SideEffectNone clash with #146 (escalate_model). Supersedes #147.

* deferred-tools: activate deferral only when tool_search is runnable; drop dead deferredTools()

Addresses #148 review:
- partitionTools gates 'active' on a registered, non-disabled, mode-advertised
  tool_search (mirrors the executeToolCall dispatch gate). When the loader is
  unavailable (explicitly disabled, missing, or not advertised e.g. spec-draft),
  fall back to the eager/inactive path instead of hiding deferred tools behind a
  loader the model can never call (the dead-end).
- Flip TestDisabledToolSearch... to assert the eager fallback; register a real
  tool_search in the active-path tests that previously relied on activation
  without a usable loader.
- Remove unused toolSearchTool.deferredTools().

* deferred-tools: disable deferral in exec when tool_search disabled; drop unused toolDefinitions shim

Further #148 review:
- exec computes one effective DeferThreshold, forced to 0 when tool_search is in
  --disabled-tools, and feeds it to both registerToolSearchIfEligible and
  agent.Options.DeferThreshold — so the registration gate and the loop partition
  gate agree, and a disabled loader never enables deferral.
- Remove the unused toolDefinitions shim (golangci-lint unused; no callers).
2026-06-09 12:20:22 +05:30
Anandan b83367e9d3 Add spec mode review flow (#145) 2026-06-08 21:07:57 +05:30
KRATOSandGnanam 69810d5ab0 Mid-run model escalation: escalate_model tool + --allow-escalation (#146)
* modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup

* modelregistry: seed escalation chains in catalog decoration

* modelregistry: verification gate for escalation upgrade map

* agent: add ModelSwitcher option and ToolResult.RequestedModel field

* agent: lift escalate_to_model meta into ToolResult.RequestedModel

* agent: perform at-most-one mid-run model switch in the turn loop

* tools: add SideEffectNone for control-only tools

* tools: add escalate_model control tool (metadata + args)

* cli: parse --allow-escalation exec flag

* cli: document --allow-escalation in exec help

* cli: register escalate_model tool under --allow-escalation

* cli: wire model switcher for exec escalation

* cli: guard byte-identical exec run without escalation flag

* cli: attribute post-switch usage to the escalated model

* tools: accept an explicit empty escalate_model reason

reason is an optional, informational-only arg, but the previous arg
parsing rejected an explicit reason:"" (non-empty-string check), failing
the whole escalation. Switch to stringArgWithEmpty so an absent OR empty
reason is accepted while a non-string value is still rejected. Add a test
covering both the empty and absent forms.

* agent: test (nil,nil) escalation switcher keeps original provider

When a ModelSwitcher returns (nil, nil) — no error, no replacement — the
loop's `else if newProvider != nil` guard already keeps the active
provider, so the run continues on the original model. Add an agent-layer
test that proves this (it panics if the nil guard is dropped), closing the
gap the adversarial review flagged.

Also note the deferred compaction limitation near the switch: the
compactor's context-window budget is fixed at run start and not updated on
a mid-run switch (needs a ModelSwitcher contract change to fix).

* cli: gate usage model attribution on the escalation flag and harden tests

FIX A (back-compat): the OnUsage closure wrote a "model" key into every
persisted EventUsage payload unconditionally, diverging a non-escalation
run from the byte-identical origin/main baseline. The model can only
change mid-run under --allow-escalation, so include the "model" key only
then. A new flag-OFF test asserts the persisted usage payload carries no
"model" key (mutation-proven).

Test hardening for the CLI escalation boundary:
- Wire test: each newProvider build returns a DISTINCT provider with its
  own turn counter; assert the source provider served exactly the
  escalation turn and the escalated provider served exactly the
  post-switch answer. Fails if `provider = newProvider` is dropped.
- Mixed usage attribution: emit usage pre- and post-switch and assert the
  first attributes to the original model, the second to the escalated
  target (the loop's ordering guarantee).
- Switch-error path: a failed rebuild is non-fatal and post-error usage
  stays attributed to the original model (currentModel unchanged).
- Top-tier decline end-to-end: a flag-on run that escalates while already
  top-tier performs no switch (newProvider built exactly once) and
  succeeds.

* modelregistry: validate UpgradeTargetID resolves at registry construction

* cli: update currentModel only when the switched provider is non-nil

* sandbox: make 'none' a first-class side effect (control-only tools)

---------

Co-authored-by: Gnanam <[email protected]>
2026-06-08 20:50:32 +05:30
Anandan 2dfd654107 Add spec mode foundation (#144) 2026-06-08 19:03:02 +05:30
68facda077 Image (multimodal) input: exec --image, stream-json images, TUI /image across all 3 providers (#143)
* zeroruntime: add ImageBlock type and NormalizeImageMediaType

* zeroruntime: add optional Images field to Message

* zeroruntime: add SeedMessagesWithImages, delegate SeedMessages with nil

* Emit Anthropic image source blocks for user image turns

* Add inlineData part type to Gemini request schema

* Serialize user image attachments as Gemini inlineData parts

* test(openai): pin text-only chat request serialization before content refactor

* feat(openai): allow structured multimodal content parts in chat request

* feat(openai): map image attachments to image_url content parts

* test(openai): assert image content parts serialize over the request body

* modelregistry: add SupportsVision capability gate

* Add shared image-file loader package

* cli: parse repeatable exec --image flag into imagePaths

* cli: resolve exec image attachments via shared loader with sniff and size cap

* agent: thread image attachments through Options into the seeded user turn

* cli: resolve and vision-gate exec image attachments into the agent run

* cli: document exec --image flag in help output

* streamjson: add InputImage type and InputEvent.Images field

* streamjson: whitelist images field on message events only

* streamjson: allow empty content on message events that carry images

* streamjson: add ResolveImages to decode, normalize and cap input images

* streamjson: cover image parse-and-resolve round-trip and text-only invariance

* Add /image command definition to TUI

* Add synchronous vision check for TUI model

* Implement /image attach, clear, and non-vision refusal in TUI

* Render pending image chips above the input in both skins

* Thread pending images into the agent turn and clear on submit

* Wire stream-json images through to the agent run

Stream-json message images were parsed and validated but never reached
agent.Options.Images: streamjson.ResolveImages had no production caller,
so images sent over stream-json input were silently dropped.

resolveExecPrompt now also returns the resolved stream-json images
(nil for text input and for image-free stream-json input). The exec
runner merges them with --image attachments before the vision gate, so
both input sources flow through the same drop+warn and the same
agent.Options.Images wiring.

Adds an exec-level end-to-end test that feeds a base64 image on a
stream-json message event via stdin on a vision model and asserts the
image reaches the agent run (the provider request's user turn).

* Re-check TUI vision support at submit, not only at /image attach

A user could attach an image on a vision model and then switch to a
non-vision model via /model before submitting; the images were threaded
unconditionally into the agent turn, reaching a model that rejects them.

The commandPrompt submit path now re-runs the vision check against the
current effective model. When the active model can't accept images, the
pending images are dropped (not threaded) and an inline notice mirroring
the headless drop+warn wording is appended. Pending image state is still
cleared after submit either way.

Adds a test that attaches on a vision model, switches the model id to a
non-vision one, submits, and asserts the run received no images, an
inline notice was appended, and pending state was cleared.

* Bound memory with an os.Stat size pre-check in imageinput.LoadFile

LoadFile read the whole file into memory before checking the size cap,
so a multi-gigabyte file allocated a huge buffer only to be discarded.

Add an os.Stat size pre-check before os.ReadFile: a file whose size
exceeds MaxImageBytes is rejected with the same 10 MiB-limit error
before any read; a missing file surfaces the same not-found error. The
existing post-read len(data) check is kept as a TOCTOU backstop in case
the file grows between Stat and ReadFile.

* OpenAI: only the user role emits image content-parts

The Anthropic and Gemini providers only attach images in their user
branches, but OpenAI funnels every role through a single mapMessage,
which would emit image content-parts for any role carrying Images.

Guard the content-parts path to the user role: a non-user message that
happens to carry Images keeps the plain string/nil content path. Pins
the invariant with TestMapMessageNonUserRolesNeverCarryImages, asserting
assistant/tool/system messages with Images set still serialize plain
content (no content-parts array).

* Deep-copy image buffers so attachments are never aliased

Add a shared CloneImageBlocks helper in zeroruntime that deep-copies a
slice of ImageBlock including each Data byte slice (returning nil for
nil/empty input). Use it in SeedMessagesWithImages so a seeded user turn
owns an independent copy of the caller's image bytes, and in the agent
loop's copyMessages so image bytes are deep-copied alongside ToolCalls
instead of staying aliased across history/request/result copies.

Tests assert that mutating the caller's original Data bytes after seeding
leaves the seeded message unchanged, and that copyMessages produces
independent image bytes.

* Accept image-only stream-json turns and bound base64 image size

Resolve stream-json images before the prompt in the exec input path and
tolerate an empty prompt when images are present, so an image-only
message event (empty content + one image) proceeds with prompt="" and the
image reaches the agent run instead of being rejected. A turn carrying
neither text nor images still errors.

In ResolveImages, reject an oversized payload from its ENCODED length
(DecodedLen) BEFORE the base64 decode so a huge blob never allocates a
decode buffer just to be capped afterward; the post-decode length check
stays as a backstop.

Tests cover the image-only exec turn reaching the agent and the
pre-decode oversize rejection (asserting the size gate fires before any
decode).

* Bound the image read in imageinput.LoadFile with a LimitReader

Replace os.ReadFile with os.Open + io.ReadAll over an
io.LimitReader(f, MaxImageBytes+1). The os.Stat pre-check is only a
fast-path hint (a non-regular file reports a misleading size and the file
can grow between stat and read); the LimitReader is the real bound, so at
most one byte past the cap is ever buffered and an oversized or unbounded
source can never allocate a multi-gigabyte buffer. The file is closed via
defer and the existing size message is reused.

* Fix RenderChat frame-height off-by-one with image chips

cmdRegion emits TWO rows when ImageChips is set (a chip row above the
input line), but the body/overlay height accounting assumed a one-row
command region, overflowing the fixed frame by one line. Compute the
fixed-row count from the actual command-region height (1 or 2 rows) so
the composed frame stays exactly Height rows in both cases.

A RenderChat test asserts the rendered frame line-count equals the
requested height both with and without ImageChips set.

* Reject an empty inline --image= value

Route the inline --image= form through requiredInlineFlagValue, the same
empty-rejection the other inline flags use, so --image= errors with
"--image requires a value" instead of appending an empty image path. A
parse test covers the empty inline value.

* imageinput: reject non-regular files (FIFO/dir) before opening

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Gnanam <[email protected]>
2026-06-08 18:49:09 +05:30
9c2e14272b CLI: doctor config validation, autonomy ceiling, changes --base, usage report (#142)
* config: add ValidateFile for structured config diagnostics

* doctor: add offsetToLineCol helper for JSON error positions

* doctor: validate config files in config.validation check

* cli: thread config paths into doctor and degrade on bad JSON

* doctor: inline read, single-parse config validation + utf8 col test

- Remove the osReadFile wrapper; call os.ReadFile directly at its one call site
- Add config.ValidateBytes([]byte) so each config file is read and JSON-parsed
  once in configValidationCheck instead of twice (once for position, once via
  ValidateFile); ValidateFile now reads then delegates to its own unmarshal for
  the path-prefixed error message, keeping its public signature unchanged
- Fix the misleading skip comment in configValidationCheck: configFilesCheck
  only checks path-string presence; the skip is a defensive guard for the
  unreachable case where DefaultResolveOptions passed a non-empty path that
  still fails to read
- Add utf8 multibyte case to TestOffsetToLineCol documenting byte-column
  semantics (column counts bytes, not runes)

* sandbox: add Policy.MaxAutonomy ceiling defaulting to high

* sandbox: normalize both operands in autonomyAllowed to fail closed

* sandbox: clamp grant-allow and unsafe escalation to policy autonomy ceiling

* sandbox: add RequiredAutonomyForBatch helper with fail-closed mapping

* config: add sandbox.maxAutonomy field to file and resolved config

* config: merge sandbox.maxAutonomy across layers, env, and overrides

* cli: thread configured autonomy ceiling into sandbox engine construction

* cli: surface max_autonomy in sandbox policy formatters

* zerocommands: carry max autonomy in sandbox policy snapshot

* sandbox: default empty policy ceiling to high + reason const

Evaluate now treats an empty Policy.MaxAutonomy (from a directly-constructed
Policy that bypasses DefaultPolicy) as High instead of letting it normalize to
Low, which would silently clamp every Medium/High decision to Prompt. The
ceiling is a no-op unless explicitly configured.

Also extracts the "above policy ceiling" clamp reason into a package const
referenced at both engine clamp sites and the related tests, and adds a
non-vacuous engine test proving the empty-ceiling trap is closed.

* zerogit: add base-ref three-dot diff to changes inspect

* zerogit: cover base-ref backward-compat and real-git diff

* zerogit: propagate base ref through change snapshot

* cli: parse changes --base ref and reject on commit

* cli: thread base ref into changes inspect output and help

* changes: redact base in cli summary + guard --base= + rename test

- redactChangeSummary now redacts the Base field alongside Root/Branch/Commit/etc.
- --base= equals-form rejects leading-dash values via flagValueLooksLikeOption, closing the option-smuggling gap
- TestParseNameStatusRenameAndCopy asserts rename/copy three-field lines use the new/destination path with correct status, and confirms two-field modify is unaffected

* Add ParseDiffStat helper for git diff --stat summaries

* Add usage report aggregation over persisted session usage events

* Add zero usage report command over persisted session usage

* Wire usage command into CLI dispatch and help

* usage: validate --since format, fix redaction comments, add tests

Fix 1 (BLOCKING): parseUsageArgs now validates --since with time.Parse
against YYYY-MM-DD; returns exitUsage + "invalid --since %q: expected
YYYY-MM-DD" for bare strings like "foo", unpadded "2026-6-1", or
wrong-separator "06/01/2026". Valid dates are stored unchanged for the
existing lexical comparison.

Fix 2: Replace false "pre-redaction stat" claims in diffstat.go and
usage.go with accurate comments: the --stat summary line carries no
secret-bearing tokens so parsing the already-redacted DiffStat is safe.

Fix 3: Add three tests to usage_test.go —
  - TestRunUsageDaysFilter: stubs deps.now, seeds events on two dates,
    asserts --days 3 includes the recent date and excludes the old one.
  - TestRunUsageInvalidSince: table-driven; verifies "foo", "2026-6-1",
    "06/01/2026" each return exitUsage + validation message, while
    "2026-06-01" returns exitSuccess.
  - TestRunUsageEmptyStore: zero usage events → exitSuccess, output
    contains header and "total" row, no panic.

* doctor: surface type-mismatch offsets and drop colliding flat line/col keys

Probe config validation against config.FileConfig instead of a bare any so
a structurally-valid document with a wrong field type (e.g. maxTurns as a
string) surfaces a *json.UnmarshalTypeError carrying the offset, instead of
losing line/col. Remove the flat top-level details["line"]/["col"] keys
that were overwritten by the last malformed file when several were present;
keep only the unambiguous per-path map entry. Tighten the existing malformed
test to assert concrete per-path line/col and add a type-mismatch test that
exercises the previously-dead UnmarshalTypeError branch.

* sandbox: fail closed on invalid configured autonomy ceiling

An invalid non-empty sandbox.maxAutonomy (e.g. "moderate") previously
survived Resolve (only trimmed, never validated), failed to normalize at the
sandbox bridge, and left the policy unchanged at the default High ceiling.
A typo therefore silently disabled the admin's intended ceiling: fail-open on
a security boundary.

Validate at resolve time (fail loud): Resolve now rejects a non-empty
maxAutonomy that does not normalize, returning a clear error. As defense in
depth, applyConfiguredAutonomyCeiling now clamps an unrecognised non-empty
value to the most restrictive ceiling (low) instead of returning the policy
unchanged, so a bad value can never widen the ceiling even via direct use.
Empty/unset stays valid and keeps the default High ceiling.

Add resolver tests for invalid-fails / valid-resolves and bridge tests for
the fail-closed clamp and valid mappings.

* sandbox: fail closed on malformed autonomy and surface policy resolve errors

Treat a genuinely-invalid request autonomy as the highest tier in the engine
so it exceeds a Medium/Low ceiling and clamps to Prompt instead of being
sanitized to Low and auto-allowing on the grant/unsafe path. Surface
resolveConfig failures in the policy command instead of silently falling back
to the permissive DefaultPolicy, which would misreport trust posture.

* usage: bucket and filter by UTC calendar day, reject empty --session

Normalize RFC3339 timestamps to their UTC calendar date before day bucketing
(report) and before the --since/--days cutoff comparison (CLI) so offset
timestamps land on the correct UTC day and the bucket and cutoff agree;
malformed timestamps fall back to the leading-10 slice. Reject empty
--session/--session-id values with a value-required error matching the --since
validation style.

* doctor: fail on unreadable config files and stop fabricating positions

Surface a present-but-unreadable config path (permissions, is-a-directory) as
a failing per-path validation detail instead of silently skipping it; a
genuinely-missing path stays a skip. Return ok=false for a JSON parse error
that carries no offset so it routes through the no-position ValidateBytes path
rather than fabricating a (1,1) line/col. Assert empty stderr on the JSON
doctor failure path.

* config: make sandbox maxAutonomy resolver tests hermetic

Pass an explicit empty Env to the maxAutonomy Resolve tests so host
environment variables cannot leak in through the nil-Env os.Getenv fallback.

* sandbox: preserve raw autonomy for ceiling check so invalid clamps under default ceiling

Defaulting invalid request.Autonomy to High only clamped under a Medium/Low
ceiling; under the default High ceiling autonomyAllowed(High, High) returned true
and still auto-allowed (fail-open). Keep the raw requested value for the ceiling
check so autonomyAllowed's unknown-tier guard fails it closed under ANY ceiling.
Adds default-High-ceiling regression tests for the unsafe and grant-allow paths.

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
2026-06-08 17:15:57 +05:30
Vasanth T 2a8dc75628 Add provider catalog foundation (#141)
* feat: add provider catalog foundation

* feat: add provider onboarding checks

* fix: address provider readiness review

* fix: clear provider catalog review threads

* fix: accept direct auth values in provider checks

* fix: harden provider catalog config

* fix: guard project provider credential binding
2026-06-08 16:37:29 +05:30
Vasanth T 32d0e5fa34 Add context budget report (#139)
* add context budget report

* fix context report review findings
2026-06-08 15:37:14 +05:30
KRATOSandKRATOS ea2acbbef2 zeroline TUI: fix slash palette, token readout, @ files, ! bash (#138)
* zeroline TUI: fix slash palette, token readout, @ files, ! bash

The zeroline home footer advertised affordances the code didn't keep. Fixes:

- '/' palette: autocomplete.go suppressed a bare '/' ('/' alone showed nothing). Now a bare '/' lists the full command palette (capped at 8). The footer's '/ commands' works.

- Token readout: the zeroline header was built without Cost/TotalTokens, so the bars always showed $0.00 and no token total. zerolineHeader() now threads m.usageTracker.Summary() (TotalCost + TotalTokens, with the unpriced-token fallback); the bottom bar shows a cumulative 'tok <N>' segment (zeroline.Header gains TotalTokens; humanTokens formats it).

- '@ files': '@' input now drives a workspace file picker in the existing overlay (trailingAtToken/fileSuggestions/replaceTrailingAtToken; bounded, skips .git/node_modules/vendor/dist/hidden). Selecting inserts the @path, preserving preceding prompt text. Previously '@…' was sent to the agent as a chat prompt.

- '! bash': '!cmd' now runs as a shell escape (commandBash) — executed async with a 30s timeout in the workspace, output shown in the transcript. Previously '!cmd' was sent to the agent as chat.

- '1-5 theme': left as-is — it works when the input is empty (the home screen's state); gating on empty input keeps digits typable in prompts.

Tests: bare-/ palette, @-token detection/replacement, file listing+filter+skip-dirs, !cmd parsing, header usage. build/vet/-race/full-suite + GOOS=windows green; no new deps.

* zeroline TUI: emit forward-slash @file paths (Windows fix)

fileSuggestions used filepath.Rel, which returns backslash paths on Windows, so the inserted @path token and the test assertions diverged (TestFileSuggestionsListsAndFiltersWorkspaceFiles failed on the Windows smoke). Normalize with filepath.ToSlash so @paths are forward-slash on every platform.

* zeroline TUI: gate !cmd behind unsafe mode + test humanTokens

CodeRabbit major: the !cmd shell escape ran bash -c directly with no permission check, bypassing the sandbox. It now runs ONLY in unsafe permission mode; in auto/ask it is not executed and the user is told to relaunch with --skip-permissions-unsafe. Adds TestBashEscapeGatedByPermissionMode (auto/ask gated, unsafe runs).

CodeRabbit minor: adds TestHumanTokens covering negative clamp, 0, sub-1k, 1k trim, fractional, and the 999999->1000k rounding edge.

* zeroline TUI: bound @file walk by entry count; humanTokens M-scale

Review fixes for #138:
- fileSuggestions now counts every WalkDir entry (directories included), not
  just files, and stops at the budget; a directory-heavy tree could otherwise
  be traversed in full each keystroke and stall the TUI. Extracted
  fileSuggestionsBounded so the per-keystroke budget is unit-tested.
- humanTokens switches to "M" notation past 1,000,000 instead of rendering
  "1000k"+ on large-context models; tests extended to the M boundaries.

* zeroline TUI: make unsafe mode reachable so the ! shell escape works

The "!cmd" shell escape is gated behind unsafe permission mode, but the
interactive TUI hardcoded Ask and shift+tab only cycles auto<->ask, so unsafe
was unreachable and the escape was dead code. Worse, the gate's own remediation
message ("Relaunch with --skip-permissions-unsafe") pointed at a flag the
interactive launch did not parse: "zero --skip-permissions-unsafe" fell through
to the unknown-command path.

Honor --skip-permissions-unsafe at interactive launch (both "zero" and
"zero zeroline"), mirroring exec, so unsafe mode -- and the ! escape -- is
reachable via an explicit opt-in. Thread the resolved mode through
runInteractiveTUI(WithSkin); the default stays Ask. Document the flag in
"zero --help" and add launch tests for both entrypoints.

* zeroline TUI: run the ! escape via the platform shell, not hardcoded bash

Now that --skip-permissions-unsafe makes the "!" escape reachable on every
platform, hardcoding "bash -c" left it broken on stock Windows (no bash on
PATH) and anywhere bash is not at a predictable path -- the advertised
"! bash" footer would just print an exec error.

Resolve the shell the same way the agent bash tool does: cmd.exe /d /s /c on
Windows, /bin/sh -c elsewhere. Add a platform test asserting the command is
wrapped correctly.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 11:57:39 +05:30
KRATOSandKRATOS f34588e725 Test: raise the npm-wrapper Windows spawn timeout to de-flake CI (#140)
TestNodeWrapperReportsMissingNativeBinary intermittently hit its 30s Windows deadline with empty output on loaded CI runners (cold node spawn), then passed in ~2.5s on the next run — a runner flake, not a wrapper bug. Raise the Windows timeout to 90s so a slow cold start has headroom; non-Windows stays at 10s.

Co-authored-by: KRATOS <[email protected]>
2026-06-08 11:39:11 +05:30
KRATOSandKRATOS df56b0dd9e Tool quality: per-tool denial categorization + secret scanning (#137)
* [LOCAL PR-Z] Agent: structured per-tool denial categorization

Adds ToolResult.DenialReason (DenialCategory: filtered / permission_denied / sandbox_violation) so a surface can tell precisely why a call was blocked instead of parsing Output. Set in the not-enabled path and deniedPermissionResult (sandbox-violation denials categorized distinctly from approval-declined). Tested.

* [LOCAL PR-Z] Secrets: scan + redact leaked secrets in bash output

New internal/secrets package: a dependency-free, precision-first scanner for high-confidence secret shapes (AWS/Google keys, GitHub/Slack tokens, private-key blocks, JWTs). bash output now runs through secrets.Redact so a command that prints a secret has it replaced with a typed placeholder + a redaction notice, instead of echoing it back into the model context (additive to the configured-key scrub at the registry boundary). A built-in regex scanner fits the CLI without the heavy gitleaks dependency. Tested: detection, no-false-positives on ordinary text, redaction, and the bash wiring.

* Tool quality: redact full private-key blocks + use DenialReason in retry classification

CodeRabbit critical: the private_key_block pattern matched only the BEGIN header, so Redact left the PEM/OpenSSH key BODY in the output — defeating the redaction. It now matches the entire BEGIN…END block (body included) so the key material is removed. Test asserts the body is gone.

CodeRabbit minor: isRetriableToolError now treats a structured ToolResult.DenialReason (filtered/permission/sandbox) as non-retriable, independent of message wording; the text checks remain a fallback for results lacking the field.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 09:08:51 +05:30
KRATOSandKRATOS 98c27f81da Model harness: per-model system prompts + summarizer fallback (#135)
* [LOCAL PR-X] Agent: per-model system-prompt specialization

Appends a family-specific guidance block (OpenAI: markdown+native-tool preference+persistence; Gemini: tool preference+conciseness; Anthropic: comment discipline) chosen by model id. Builds on the #129 system-prompt builder. Tests for classification + addendum selection.

* [LOCAL PR-X] Agent: chunked summarization fallback on context-limit

If the elided middle is itself too large to summarize in one call, summarizeWithFallback splits it in half recursively and joins the partial summaries, so compaction keeps working past the summarizer's own input window. Non-context errors and unsplittable single messages surface unchanged. Builds on #131. Tests cover split-and-join, non-context propagation, and the single-message edge.

* Summarizer: re-summarize partial summaries instead of persisting a blob

CodeRabbit major: summarizeWithFallback joined the two partial summaries and Compact stored that as one message; if that single message later exceeded the summarizer window, the guard couldn't split it (a single message) and compaction failed again for long sessions.

After summarizing the halves, the partials are now re-summarized into ONE compressible unit. If even the combined partials don't fit (extreme), it falls back to the joined text (still the already-compacted halves, better than failing). Adds TestSummarizeWithFallbackReSummarizesPartialsIntoOne. build/-race/full-suite + GOOS=windows green.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 09:08:28 +05:30
KRATOSandKRATOS 99a0bea90b Real-world hardening: graceful shutdown + crash capture (#136)
* [LOCAL PR-Y] CLI: graceful shutdown of headless exec runs (Ctrl+C)

zero exec wrapped agent.Run in context.Background(), so Ctrl+C/SIGTERM killed the process abruptly — leaking the in-flight provider request and any spawned children. runExec now wraps the run in a signal-aware context (signalContext): a signal cancels the run, the loop returns cleanly, and exec reports 'Interrupted.' with exit 130 (stream-json: an 'interrupted' run-end). Scoped to runExec — no dispatch-wide signature churn. NOTE: SIGINT runtime delivery needs a manual Ctrl+C check before PR; unit tests cover the helper + the no-regression of the exec suite.

* [LOCAL PR-Y] Observability: crash capture (panic -> saved report)

New internal/observability package: a recovered top-level panic is written to a local crash report (timestamp, label, full stack) under ~/.zero/crashes and surfaced as a brief notice instead of a raw stack trace. Wired into runWithDeps via a named-return defer. Fail-open and dependency-free; this is the hook a Sentry/OTEL adapter attaches to for remote reporting (deferred, needs a real endpoint to verify). Tested: report formatting/writing, panic capture sets the crash exit code + writes one report, and the no-panic path is a no-op.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 08:55:36 +05:30
KRATOSandKRATOS 669ce4ac3d Background: escalate process termination from SIGTERM to SIGKILL (#134)
* Background: escalate process termination from SIGTERM to SIGKILL

terminateProcess (POSIX) sent only SIGTERM, so a background process that traps or ignores it kept running after a Kill/KillRunning — a leak, e.g. on Ctrl+C shutdown. It now sends SIGTERM (letting the process clean up), polls liveness, and force-kills with SIGKILL if the process is still alive after a grace period.

- internal/background/process_posix.go: SIGTERM -> poll (signal 0) until a 3s grace deadline -> SIGKILL; treats an already-exited process as success (ESRCH / ErrProcessDone). Grace/poll are vars so tests run fast. Windows already force-kills via taskkill /F, so it is unchanged.

Tests: a SIGTERM-ignoring process (trap, with a readiness handshake to avoid signalling before the trap installs) is killed only after the grace period; a well-behaved process dies on SIGTERM well before escalation; an already-exited pid is a no-op. -race + full-suite + GOOS=windows build green; no new deps.

* Background test: assert the terminating signal (SIGTERM vs SIGKILL)

The graceful and escalation tests now check the actual signal that killed the process (via WaitStatus.Signal()), not just timing — so a regression to an immediate SIGKILL (or to never escalating) is caught. Graceful path must be SIGTERM; the SIGTERM-ignoring process must be SIGKILL.

* Background: keep user kill intent when the exit waiter reaps during terminate

Vasanthdev P1: terminateProcess now blocks until the child exits, so the background Wait-goroutine can reap the process and MarkExited the task to 'error' BEFORE Kill marked it 'killed' — leaving a user-initiated stop persisted as 'error' even though TaskStop returned killed.

Manager.Kill now records the kill intent (markKilledIfStillRunning) BEFORE terminating. MarkExited only acts on a running task, so once the task is killed the waiter's MarkExited is a no-op and the status stays killed. If terminateProcess fails, restoreRunningAfterFailedKill reverts the optimistic mark so a still-running task isn't falsely reported killed (preserves TestManagerDoesNotMarkKilledWhenKillFails).

Adds TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate (cross-platform: a KillProcess that simulates the waiter reaping mid-terminate; asserts final status stays killed). build/-race/full-suite + GOOS=windows green.

* Background: don't signal a stale pid + surface rollback failures in Kill

Two follow-ups to the kill-ordering fix (CodeRabbit):

1. markKilledIfStillRunning now returns whether it actually marked. If the task exited (or was reused) between the running check and the mark, Kill returns without calling killProcess — so it never signals a possibly-stale pid in the exit/reuse race.

2. restoreRunningAfterFailedKill returns its persistence error; on a failed terminate, Kill joins the kill error and the rollback error (errors.Join) instead of swallowing a failed rollback that would leave the task wrongly marked killed.

Adds TestMarkKilledIfStillRunningReportsWhetherItMarked. build/-race/full-suite + GOOS=windows green.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 08:37:44 +05:30
KRATOSandKRATOS 413c323bf7 Agent: preserve active plan + loaded skills across compaction (#131)
* Agent: preserve active plan + loaded skills across compaction

Compaction summarized the elided middle into prose, which silently dropped two pieces of structured state the model needs to keep working: the active plan (update_plan) and any loaded skill instructions (skill tool). Now Compact extracts them from the elided middle and appends them VERBATIM to the injected summary, so they survive a compaction exactly instead of being paraphrased away.

- internal/agent/compaction_preserve.go (new): extractLatestPlan (most recent update_plan, rendered as status-tagged bullets), extractLoadedSkills (skill tool calls matched to their result bodies by id, latest-per-name, 2KiB-capped per skill so a large skill can't defeat the compaction), and appendPreservedState which folds both into the summary under clear labels.

- internal/agent/compaction.go: Compact appends the preserved state; summaryInstructions now tell the summarizer to integrate any earlier summary block (delta-aware summarization) so re-compaction never drops older facts.

Tests: plan + skill preservation through Compact, no sections when absent, latest-plan-wins, and skill calls without a result body are skipped. build/vet/-race/full-suite + GOOS=windows build green; no new deps.

* Fix capBody to honor the byte budget (UTF-8 safe)

capBody sliced by RUNE count against maxPreservedSkillBytes (a BYTE budget), so multibyte UTF-8 could preserve far more than 2 KiB, and it could append the truncation note without actually truncating. Now it cuts to the byte budget on a UTF-8 rune boundary (never splitting a rune), reserves room for the note within the budget, and only adds the note on real truncation.

Adds tests: short-body unchanged, multibyte over-budget stays <= cap and valid UTF-8, and the rune-count<cap-but-bytes>cap case.

* Compaction: carry preserved plan/skills across repeated compactions

Vasanthdev P1: after the first compaction the preserved plan/skill sections live only as text in the injected summary message; on a SECOND compaction that summary is in the elided middle with no real update_plan/skill tool calls left, so the structured state was lost unless the summarizer happened to copy it (non-deterministic).

appendPreservedState now carries forward existing '## Active plan' / '## Loaded skills' sections parsed from the most recent prior summary in middle, with fresh tool calls overriding per name (skills merged by name; plan: fresh update_plan wins, else carried forward). Adds TestCompactCarriesPreservedStateAcrossRepeatedCompaction — a second compaction whose summarizer drops the sections still keeps the plan + skill body. build/vet/-race/full-suite + GOOS=windows build green.

* Compaction: serialize preserved state as JSON (lossless for arbitrary bodies)

CodeRabbit major: the carry-forward format parsed markdown delimiters (section ends at the next '## ', skills split on '### '), so a verbatim skill body containing headings (## Usage, ### Examples) or code fences was truncated or split into bogus extra skills on the next compaction — breaking the repeated-compaction guarantee.

The preserved plan + skills are now written as a single labelled line of JSON (formatPreservedState) and recovered with json.Unmarshal (parsePreservedState). JSON escapes everything, so any body — markdown headings, fences, quotes, multibyte — round-trips losslessly. Removed the markdown section/skill parsers; fresh tool calls still override the carried-forward copy by name.

Adds TestCompactPreservesSkillBodyWithMarkdownHeadings: a body with ## / ### / a code fence stays byte-identical across two compactions. build/vet/-race/full-suite + GOOS=windows green.

* Compaction test: guard compacted shape before indexing out[1]

The round-trip helper indexed out[1] without checking len(out)>=2 / role first; add the guard so a regression in Compact fails cleanly instead of panicking.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 08:35:45 +05:30
KRATOSandKRATOS 9c8ffab4d3 Agent: per-category context token budget (system / tools / messages) (#133)
Adds MeasureContext, which breaks a request's estimated token footprint into the categories that compete for the window — system prompt, tool definitions, and conversation messages — plus the used fraction of the model context window. This gives the agent the data to report context utilization and reason about what to compact.

- internal/agent/context_measurement.go: ContextBreakdown + MeasureContext (reuses estimateTokens for messages; estimateToolTokens for tool defs on the same ~4-chars/token scale).

- Options.OnContext: opt-in per-turn callback (mirrors OnText/OnUsage) emitted with the request's budget so a surface (TUI/CLI) can show utilization; nil is a no-op.

Tests: per-category split + total, system-dominant proportion, used-fraction math, unknown-window and no-system/no-tools edge cases. build/vet/-race/full-suite + GOOS=windows build green; no new deps.

Co-authored-by: KRATOS <[email protected]>
2026-06-08 08:14:49 +05:30
KRATOSandKRATOS d516830cc4 Providers: unified transient-failure retry (network, 429, 5xx) (#132)
* Providers: unified transient-failure retry (network, 429, 5xx) across all providers

Only the OpenAI provider retried before; Anthropic and Gemini surfaced the first failure, so a dropped connection or an intermittent gateway 5xx/429 failed the whole turn. Centralizes the policy so every provider behaves consistently.

- internal/providers/providerio/retry.go (new): SendWithRetry rebuilds the request each attempt (replay-safe) and retries network errors + retryable statuses (429 and 5xx) up to N attempts, backing off between tries and honoring a server Retry-After header and context cancellation. Only the INITIAL request is retried — a partially consumed stream is never replayed. Plus Backoff, ShouldRetryStatus, and RetryAfter helpers.

- anthropic/gemini: replace the bare http.Do (no retry) with SendWithRetry.

- openai: replace its bespoke retry loop + backoff func with the shared helper (now also covers 429 + Retry-After, which it didn't before). Net code reduction.

Tests: retry-then-succeed, no-retry on 400, exhausted-retries surface the last response, Retry-After parsing, and backoff context cancellation. build/vet/-race/full-suite + GOOS=windows build green; no new deps.

* Retry: don't replay transport-failed POSTs (only retry 429/5xx)

A network/transport error on a completion POST does not mean the server didn't receive it — the request may have arrived and be generating a billable, non-idempotent completion while only the response/connection failed. Replaying it could duplicate that work. SendWithRetry now returns transport errors immediately and retries ONLY server responses (429 and 5xx), where the request was explicitly rejected without effect.

Adds TestSendWithRetryDoesNotReplayTransportErrors (a transport error is attempted exactly once). Docs updated. -race + providers suite + GOOS=windows build green.

* Retry: narrow retryable statuses to 429 + 503 (not generic 5xx)

Retrying any 5xx replayed non-idempotent completion POSTs whose effect is unknown: a 500/502/504 (esp. a 504 gateway timeout) can follow an upstream that already produced a billable completion, so a retry risks duplicate work. Limit retries to 429 (rate-limited) and 503 (service unavailable) — the statuses where the server explicitly did NOT accept the request, so a replay is safe. Other 5xx now surface to the caller's HTTP-error path.

Also close the response in the transport-error test (bodyclose). Tests updated: ShouldRetryStatus maps 500/502/504 -> false, 503/429 -> true; the retry-then-succeed test uses 503.

* Retry test: check the response Body.Close() error (errcheck)

The transport-error test dropped resp.Body.Close()'s error, which errcheck flags and can break lint-gated CI. Now the close error is checked.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 08:14:29 +05:30
KRATOSandKRATOS 4475b67541 Agent: rich system prompt (coding-craft + workspace context) (#129)
* Agent: rich system prompt (coding-craft + workspace context)

Replaces the one-sentence system prompt — our single biggest agentic-quality gap — with a real coding-craft instruction set adapted to our Go toolset.

- internal/agent/system_prompt.md (embedded): identity, autonomy/persistence (bias for action, end-to-end), workflow (understand -> plan -> implement -> verify -> summarize), editing discipline (prefer native read/grep/glob/edit/apply_patch over shell, one tool call per file, minimal diffs, match style), a MANDATORY testing gate (run validators after edits; never claim done while failing), tool-use guidance, communication/markdown style.

- internal/agent/system_prompt.go: buildSystemPrompt(options) assembles core + dynamic workspace context (<environment>: cwd, git branch, first AGENTS.md/ZERO.md/.zero/AGENTS.md doc, 8KiB-capped) + the existing safety confirmation policy. Omitted when cwd is unset, keeping headless/test runs deterministic.

- loop.go: prompt decls moved to system_prompt.go; callsite passes options so cwd reaches the builder.

Tests: updated the two prompt assertions; added TestBuildSystemPromptInjectsWorkspaceContext. build/vet/-race/full-suite + GOOS=windows build green; no new deps.

* Resolve relative worktree gitdir against cwd in gitBranchForPrompt

In worktree mode .git is a file like 'gitdir: ../.git/worktrees/<name>' with a RELATIVE path; the code joined it directly so HEAD lookup resolved against the process working directory and failed, dropping the Git branch from the workspace context. Now non-absolute gitdir paths are resolved against cwd.

Adds TestGitBranchForPromptResolvesRelativeWorktreeGitdir (fails without the fix). build/-race/full-suite + GOOS=windows build green.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-08 00:23:06 +05:30
KRATOSandKRATOS d86e4e8bf7 Provider: prompt caching on system + tools, with cache-token usage (#130)
Caches the stable per-run prefix (system prompt + tool definitions) so it is not re-billed or re-processed every turn — a direct latency and token-cost win on multi-turn sessions, which matters now that the system prompt is substantial.

- types.go: system can be sent as []systemBlock (Anthropic accepts string or blocks); add cacheControl + cache_control on the last system block and the last tool definition (two cache breakpoints covering system + tools); add cache_read_input_tokens / cache_creation_input_tokens to the usage struct.

- provider.go: buildRequest emits the cacheable system block + tags the last tool; stream usage now captures cache read/creation tokens. Anthropic reports input_tokens (uncached), cache_read, and cache_creation separately, but the runtime models cached as a subset of input (clamps cached<=input), so we report the FULL prompt as InputTokens and the cache hits as CachedInputTokens.

- Caching is GA on anthropic-version 2023-06-01 (no beta header); non-caching providers are unaffected.

Tests: updated the request-shape assertions (system block + tool cache_control); added TestStreamCompletionReportsCacheTokens. build/vet/-race/full-suite + GOOS=windows build green; no new deps.

Co-authored-by: KRATOS <[email protected]>
2026-06-08 00:20:07 +05:30
KRATOSandKRATOS 07d8ffc524 CLI wiring: skills/zeroline commands, --mode presets, exec checkpoints, sandbox --effective, sessions rewind (#128)
* CLI wiring: skills/zeroline commands, --mode presets, exec checkpoints, sandbox --effective, sessions rewind

Final module of the runtime-core split. Wires the new runtime features into the cli, PRESERVING main's merged specialist cli (the source branch predates specialist and drops it; this keeps it via a 3-way merge from the common base, adding the reskin features as ours/theirs-only changes).

- skills command (internal/cli/skills.go) listing internal/skills; zeroline-skin TUI launcher (internal/cli/zeroline.go) via runInteractiveTUIWithSkin.

- exec: --mode presets (smart/deep/fast/large/precise) via applyExecMode; model-registry alias resolution + deprecation/effort notices; ContextWindow sizing (modelContextWindow); before-mutation checkpoint recording in OnToolCall; enhanced tool-result output (ChangedFiles/Redacted/Display); permission-mode validation fix.

- sandbox policy --effective (resolved guards); sessions rewind (executes the rewind plan); interactive TUI defaults to Ask mode (advertises write/edit/bash + gates via prompts).

- internal/streamjson: EventCheckpoint/EventRestore + CheckpointInfo for structured checkpoint output.

Specialist cli preserved (specialist.go + flags + runtime + tests all intact). build/vet/-race/full-suite + GOOS=windows build green; no new deps.

* Address #128 review: make sessions rewind reachable + honor --exclude-target, mode/notice/redaction fixes

- sessions: add "rewind" to the parser whitelist (isSessionsCommand) — it was wired in dispatch + help but parseSessionsArgs rejected it as 'unknown sessions command', so it was unreachable. Regression test TestRunSessionsRewindIsReachableAndHonorsExcludeTarget.

- sessions rewind: honor --exclude-target — ApplyRewind keeps THROUGH a sequence, so when KeepTarget is false apply through TargetSequence-1, matching rewind-plan (previously rewind always applied TO the target, disagreeing with the plan). The test asserts --exclude-target keeps fewer events than the default.

- sessions rewind: redact the session id in the success + not-found messages (consistent with every other human-readable path).

- exec_parse: reject empty inline --mode= via requiredInlineFlagValue (was a silent no-op running defaults).

- exec: notice writes (model-deprecation + reasoning-effort advisories) now treat write failures as exitCrash, consistent with the rest of the command.

build/vet/-race/full-suite + GOOS=windows build green.

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-07 23:42:10 +05:30
KRATOSandKRATOS 402d22de04 TUI shell: ask_user modal, pickers, autocomplete, zeroline skin (#127)
* TUI shell: ask_user modal, pickers, autocomplete, zeroline skin

Lands the interactive TUI shell on main. (The earlier PR for this content targeted the stacked agent-runtime branch and never reached main, which already has the agent runtime via #125; this re-targets the same reviewed content onto main.)

- ask_user: interactive questionnaire modal wired to agent.OnAskUser via an answer channel; transcript rows + session payloads; degrades gracefully without an interactive surface.

- compaction sizing: modelContextWindow() sets the agent ContextWindow from the active model's registry entry (unknown models -> 0, compaction off).

- pickers (model/theme/effort/mode) + slash-command autocomplete overlay; zeroline skin rendering (boot/home/chat) behind Options.Skin.

- checkpoint recording: before each mutating tool the run batches an EventSessionCheckpoint in order and flushes it at end-of-run and on cancel; /rewind reloads in-memory session state so dropped events don't re-reach the agent. Exposes sessions.SnapshotForCheckpoint with an orphan-blob safe-usage contract for the batch-in-order path.

build/vet/-race/full-suite + GOOS=windows build green; 116 tui tests; no new deps.

* Address #127 review (CodeRabbit): validate checkpoint session id, fix mode toggle, harden ask_user/rewind/flush

- sessions.SnapshotForCheckpoint validates the session id (ValidSessionID), matching CaptureToolCheckpoint, so an exported caller can't route blob writes through an invalid session path.

- nextPermissionMode: Unsafe now folds to Ask (the stricter landing), not Auto — toggling an Unsafe session must never make it less strict. Test added.

- ask_user: a request with zero questions resolves immediately instead of opening a prompt that stalls the run.

- cancelled-run flush: surface appendSessionEvents persist-failure rows instead of discarding them (a silently-failed checkpoint flush would degrade /rewind).

- /rewind: handle post-rewind reload failures explicitly — on ReadEvents error, clear in-memory context and report, so stale rewound-away events can't reach the next prompt.

- transcript dedupe: key ask_user rows on row.id (survives rehydration when row.askUser is nil).

- ask_user Esc test: replaced the empty branch with a real assertion (run stays pending after Esc cancels only the prompt).

build/vet/-race/full-suite + GOOS=windows build green.

* Address #127 re-review: mirror normal flow for zero-question ask_user

The zero-question fast path now records the (empty) request in the transcript and answers with an empty slice ([]string{}) instead of nil, so it matches the normal resolveAskUser flow and downstream sees a consistent Answers shape.

(Skipped the suggested 'defer Ctrl+C quit until flush failures are acknowledged': blocking quit on Ctrl+C contradicts prompt-exit expectations, is a non-minimal UX change, and the edge is rare with graceful impact — the failure rows are already appended for the common non-exit cancel, and a missed checkpoint only makes that one /rewind unavailable.)

---------

Co-authored-by: KRATOS <[email protected]>
2026-06-07 23:11:47 +05:30
73eddf758b Agent runtime: ask_user interception, context compaction, guardrails (#125)
* Agent runtime: ask_user interception, context compaction, guardrails

Ports the agent-runtime slices from the reskin branch onto main, MINUS the blueprint's own sub-agent/task interception (main already has the merged specialist Task implementation, which this preserves).

- ask_user (S6): AskUserRequest/AskUserResponse/AskUserQuestion types + Options.OnAskUser; the loop intercepts ask_user and routes to the interactive front-end, degrading gracefully (non-interactive fallback) when no handler is set. Answers are scrubbed through the redaction boundary.

- compaction (S8): Options.ContextWindow + CompactionPreserveLast; proactive compaction when the estimated history crosses a fraction of the window, and reactive recovery on context-limit errors (summarizes the old middle, keeps system + last N). ContextWindow=0 disables it, so every existing caller/test behaves identically.

- guardrails: empty-turn stop, repeated-tool-failure hint/stop, and a stale plan reminder; an aborted-placeholder is appended for unexecuted tool calls on guard stop. confirmation_policy.md is embedded into the system prompt.

Preserved from main's specialist work: the RunWithOptions tool-context fields (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd), propertyToRuntimeMap nested Items, and ToolAdvertised AdvertiseInAuto. Dropped Options.Provider (it existed only for the blueprint task; compaction uses Run's provider arg).

build/vet/-race/full-suite + GOOS=windows build green; no new deps. Unblocks the TUI shell + cli wiring PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #125 review (CodeRabbit + @Vasanthdev2004): scope failure hint, fix ask_user fallback/cancel

- Repeated-failure guard now fires only on RETRIABLE tool errors (bad args / execution failures), not policy refusals (disabled tool, permission denial, sandbox violation) — a 'match this schema' hint there misdirects the model or nudges it to retry blocked behavior. New isRetriableToolError classifier (keys off permission_action meta + the known policy-error messages). Test: TestIsRetriableToolError.

- Headless ask_user fallback now goes through registry.RunWithOptions with the full run context (ToolCallID/SessionID/Model/ReasoningEffort/Depth/Cwd, sandbox/permission) and copies the full result fields (Meta/ChangedFiles/Display), so it behaves like every other tool path instead of the bare registry.Run that dropped them.

- A canceled / timed-out ask_user prompt (OnAskUser returns context.Canceled/DeadlineExceeded) now ABORTS the run and returns that error instead of fabricating a synthetic 'no interactive user' answer and continuing to mutate the transcript. executeToolCall/executeAskUser return an abort error that the loop honors (closing out remaining advertised calls for replay validity). Test: TestRunAskUserCancellationAbortsRun.

- compaction_test: corrected the comment to match the assertion (retried text must NOT be re-streamed to OnText).

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* gofmt internal/agent/guardrails_test.go

The file carried over from the reskin branch was not gofmt-clean; format it so the gofmt/lint check is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 22:34:40 +05:30
0da2ecf582 MCP hardening: nested-schema passthrough + hung-server timeout (#124)
Module of the runtime-core split (off main). internal/mcp had no drift on main, so this is a clean extract of the zenline MCP improvements. Deps (config, tools) are on main; uses tools.PropertySchema.Properties/Required from #119. No new deps.

- schema.go: serialize nested object Properties/Required (and object-typed Items) when converting tools.PropertySchema <-> MCP JSON schema, so tools with nested args are advertised faithfully to/from MCP hosts instead of being flattened (audit H6).

- client.go/server.go: bound MCP stdio handshake/calls so a hung or non-responsive MCP server can't block the caller forever; surface a timeout instead (audit H9). hang_test.go covers it.

- registry.go: recognize the hardened client/schema paths; registry_test/schema_test added.

build/vet/-race/full-suite + GOOS=windows build green.

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 21:35:25 +05:30
363c0312d6 Skills & plugins: skill tool, skills loader, plugin manifests (#123)
* Skills & plugins: skill tool, skills loader, plugin manifests

Module of the runtime-core split (off main, after #119 tools merged). Clean extract — internal/skills is new; internal/plugins had no drift on main. No new deps.

- internal/skills: loads */SKILL.md frontmatter from the skills dir (resolves its own XDG dir); no YAML dependency.

- skill tool (internal/tools/skill.go), read-only, registered in CoreReadOnlyTools (so it's in the agent core + the MCP read-only default). Path-safe: it Load()s all skills and matches by exact name — it never builds a path from the model-supplied arg, so there is no traversal surface. knownToolNames gains "skill" for the specialist cross-package invariant; CoreReadOnlyTools count test bumped 4->5.

- internal/plugins: plugin manifest metadata enrichment (Author/License/Keywords/Interface). FORWARD code — the cli plugin-loading wiring lands later.

MCP hardening (nested-schema + hung-server fix) is a SEPARATE follow-up PR. build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #123 review (CodeRabbit): pointer metadata, skill schema alias, dir fallback, List test

- plugins: Author/Interface are now *PluginAuthor/*PluginInterface so omitempty actually omits them — a non-pointer struct is never empty to encoding/json, so the old form emitted author:{}/interface:{} and changed the serialized shape of plugins that don't set them. parseAuthor/parseInterface return nil when all fields are empty; formatAuthor takes the pointer. (Major)

- skill: schema now declares the 'skill' alias alongside 'name' (and drops strict Required) so the alias survives schema validators that reject unknown keys (AdditionalProperties:false); Run still enforces exactly-one-of via aliasedStringArg. (Major)

- skills.DefaultDir: return "" instead of a relative ".local/share/..." path when neither XDG_DATA_HOME nor a resolvable HOME exists, so skills can't bind to the process CWD (load("") already no-ops). (Minor)

- skills_test: TestListReturnsNamesAndDescriptions now asserts List strips Content so a listing can't silently leak full skill bodies. (Minor)

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #123 review (@Vasanthdev2004): confine skill SKILL.md to the skills root

skill is a permission-allow read-only core/MCP tool, so the loader must not follow a symlinked SKILL.md (or skill dir) out of the skills root and become an arbitrary-file reader. load() now resolves the skills root via EvalSymlinks and, for each entry, resolves SKILL.md through symlinks and verifies the real path stays under the root (confineSkillPath); an escaping or unreadable path is skipped rather than read. Mirrors the grep/read_file confinement. Legit skills under a symlinked root (e.g. macOS /tmp) still load because the root is resolved too.

Regression: TestLoadSkipsSymlinkedSkillFileEscapingRoot (a SKILL.md symlinked to a secret outside the root is skipped, not read).

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #123 re-review (CodeRabbit): reject non-regular SKILL.md, single-trim metadata

- skills: confineSkillPath now Lstats the resolved path and skips non-regular targets (directory/FIFO/device/socket). A FIFO/device named SKILL.md (or an in-root symlink to one) would otherwise make os.ReadFile block indefinitely — skill is a permission-allow tool over a user-controlled dir. Regression: TestLoadSkipsNonRegularSkillFile (unix-tagged; mkfifo SKILL.md, asserts Load skips it within a timeout rather than hanging). (Major)

- plugins: coerceMetaStringSlice trims each item once into a local var instead of calling strings.TrimSpace twice. (Nitpick)

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 21:35:11 +05:30
Vasanth T 781bc7d3c1 Add guarded web fetch tool (#121)
* feat: add guarded web fetch tool

* fix: harden web fetch DNS validation

* fix: tighten web fetch SSRF guardrails
2026-06-07 21:02:50 +05:30
Anandan f3463d66ce Add specialist lifecycle registry test (#122) 2026-06-07 17:31:07 +05:30
75f15da82a Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening (#119)
* Tools: arg-tolerance, ask_user, secret scrubbing, structured results, hardening

First module of the tools->agent->tui chain that leads to the TUI shell (off main). Scope: internal/tools (+ a 1-line specialist vocabulary update).

3-way merged onto main's specialist drift (RunOptions tool-context fields, Safety.AdvertiseInAuto, optionsAwareTool dispatch, PropertySchema, base structured Result) and grafted the runtime-core additions:

- Shared arg-tolerance layer (argtolerance.go: alias-aware/type-strict arg resolution + slice coercion) adopted across all tools so weak models' non-canonical arg shapes are accepted; bool/int coercion in args.go.

- ask_user tool (interactive clarifying questions) wired into CoreReadOnlyTools; nested PropertySchema (Properties/Required) for its object questions + update_plan.

- Secret scrubbing at the registry boundary (scrubResultSecrets via internal/redaction) applied to every tool-execution path, incl. the optionsAwareTool dispatch; structured Result (Display/Redacted/ChangedFiles).

- mutation_targets (paths a mutating tool will write, for session checkpointing) + hardened grep (symlink confinement), apply_patch, bash, edit/write.

EXCLUDED per the module split: the blueprint task tool (subagents — another dev's area; the merged specialist Task is separate) and the skill tool (ships with the later skills module); plus their subagent-only registry helpers (Without/isolateForChild).

ask_user is now a core tool, so internal/specialist knownToolNames adds "ask_user" to keep the TestKnownToolNamesMatchCoreRegistry cross-package invariant. No new deps; build/vet/-race/windows/full-suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 review (CodeRabbit): scrub Display, validate apply_patch mutation paths, narrow test skips

- registry.go: scrubResultSecrets now redacts Result.Display.Summary too (not just Output), so a caller preferring Display can't bypass the boundary redaction.

- mutation_targets.go: apply_patch targets now run through validatePatchPaths(applyRoot, ...) before being returned, so a traversal patch (../x) can't yield an out-of-workspace checkpoint target. Regression case added to TestMutationTargetsRejectsEscapingPaths.

- argtolerance_test/write_tools_test: the apply_patch tests no longer Skipf on ANY non-OK result (which masked real diff/apply regressions); they skip ONLY when the git binary is unavailable (gitApplyUnavailable helper checks exec's 'executable file not found') and otherwise Fatalf.

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 re-review (CodeRabbit): best-effort multiSelect + Display.Summary scrub coverage

- ask_user: an uncoercible multiSelect no longer fails the whole call — it's a UI hint, so it defaults to false (best-effort, mirroring the lenient options path). Test: TestParseAskUserQuestionsLenientOptions extended.

- registry_test: TestRunWithOptionsScrubsSecretsForAllCallers now also leaks a secret via Result.Display.Summary and asserts it is redacted, locking in the boundary scrub for the structured-result path (secretTool gained a display field).

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 review (@Vasanthdev2004): scrub ALL registry return paths + harden intArg

[blocker] RunWithOptions now scrubs every return path, not just tool-execution: a named return + a single 'defer scrubResultSecrets(result)' covers the unknown-tool, sandbox-deny, sandbox-approval-required, permission-required and permission-denied early returns too. Previously a sandbox denial that echoed a secret-bearing path/arg returned it raw with Redacted=false. Regression: TestRunWithOptionsScrubsSecretsOnDenialPaths (permission-denied reason carrying a token -> scrubbed + Redacted).

[non-blocking] intArg fails closed on NaN/Inf/out-of-range floats (and their string forms) via a floatToInt helper, before any implementation-defined cast. Tests added for NaN/Inf/NaN-string/non-integral.

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #119 review (CodeRabbit grep TOCTOU + anandh8x dependency blockers)

- grep: close the confinement TOCTOU — confineGrepFile now returns the symlink-RESOLVED path and collectGrepMatches reads THAT (not the raw candidate), so a symlink swapped in between check and read can't escape the workspace.

- ask_user: removed from CoreReadOnlyTools. It needs the agent loop's interactive intercept (OnAskUser), which is in the (unmerged) agent module; without it the tool only returns the non-interactive fallback. The tool still ships (NewAskUserTool); the agent module registers it in core + wires the intercept together. Reverted the specialist knownToolNames 'ask_user' entry + the ask_user-in-core test accordingly.

- update_plan: reverted the plan schema to a flat array (item structure documented in the description). The structured nested-object Items schema depends on the agent's PropertySchema serializer (propertyToRuntimeMap) passing nested Properties/Required through to providers, which is in the agent module; a nested schema here would be silently dropped. PropertySchema.Properties/Required fields are kept (used by ask_user.go's own schema + the later MCP module).

build/vet/-race/full-suite + GOOS=windows build green; no new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Harden #119 from adversarial self-review: sandbox alias-gate, Meta scrub, intArg 2^63

A multi-dimension adversarial review of the tools diff surfaced 3 real issues beyond the reviewer rounds:

- [major/security] write_file/edit_file path aliases file_path/filename/filepath bypassed the sandbox workspace+symlink gate: sandbox.requestPaths only inspected path/cwd/file/dir, so an aliased target skipped enforcement (same class as audit H4). Aligned requestPaths with the tools' path-alias lists (internal/sandbox/risk.go). The tools' own resolveWorkspaceTargetPath still confined, but the sandbox LAYER/audit/symlink-check was silently skipped. Regression: TestRegistrySandboxGatesPathAliasKeys.

- [minor] scrubResultSecrets skipped Result.Meta, which is forwarded to the transcript and carries model-controlled strings (glob pattern, bash cwd). Now scrubs Meta values too. Covered in TestRunWithOptionsScrubsSecretsForAllCallers.

- [minor] floatToInt used f > float64(MaxInt); that constant rounds up to 2^63 so exactly 2^63 slipped to an out-of-range cast. Changed to >=. Test added for 2^63 float+string.

build/vet/-race/full-suite + GOOS=windows build green; no new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 17:13:27 +05:30
Anandan be8100e526 Add specialist lifecycle accounting (#120)
* Add specialist lifecycle accounting

Record specialist start and stop events in parent sessions and roll child stream-json usage into one parent usage event.

Background TaskOutput performs the same roll-up idempotently for persisted completed tasks.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Harden npm wrapper smoke test on Windows

Clear inherited NODE_OPTIONS when executing the Node wrapper fixture and allow a longer Windows timeout for hosted-runner cold starts.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper ./internal/specialist

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Address specialist accounting review feedback

Normalize mixed usage event totals before roll-up, preserve started child exit codes on execution errors, and make accounting payload assertions fail loudly on missing or mistyped fields.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/specialist ./internal/npmwrapper

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check
2026-06-07 16:27:23 +05:30
Anandan 32d68b8d09 Persist background specialist tasks (#118)
* Persist background specialist tasks

* Harden background task reload
2026-06-07 15:47:48 +05:30
Anandan 82314a703c Add specialist authoring tools (#117)
* Add specialist authoring tools

* Harden specialist authoring boundaries
2026-06-07 15:21:09 +05:30
Anandan 27f0fe8f30 Harden specialist background lifecycle (#116)
* Harden specialist background lifecycle

* Preserve killed background task state
2026-06-07 14:18:43 +05:30
133731f5d2 TUI rendering (zeroline): glamour markdown, syntax highlighting, themed surfaces (#114)
* TUI rendering (zeroline): glamour markdown, syntax highlighting, themed surfaces

Module of the runtime-core split (off main). Renamed the reskin package from the working name 'zenline' to **zeroline** (dropping the zenline name; this becomes ZERO's default polished terminal surface). Scope: internal/zeroline only — pure presentation, no internal deps.

- glamour-rendered markdown (with a bounded mdCache to avoid per-frame re-render cost) and chroma syntax highlighting (transitive via glamour).

- colored diffs, full-bleed background, a Zen home page + a vim/powerline-style statusline chat page sharing 5 switchable color themes (theme.go).

- callers (the TUI model) build the data structs from live agent state; this package turns them into styled frames. It is FORWARD code — wired by the TUI module in a later PR; exercised here by render/markdown tests.

Deps: adds github.com/charmbracelet/glamour v1.0.0 (pulls chroma/v2, goldmark, bluemonday, x/net, x/term transitively; lipgloss/bubbletea/termenv already present). go.sum updated via go mod tidy; build/vet/-race/full-suite green; no behavior change to existing packages.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #114 review (CodeRabbit + @anandh8x + @Vasanthdev2004): TUI render-contract fixes

- AskUser suppresses overlays like Perm does: overlayRegion now returns early on d.AskUser != nil so suggestions/picker rows can't consume bodyH and push the focused questionnaire offscreen. Test: TestOverlaySuppressedDuringAskUser.

- Permission modal narrow-width: derived permMinBoxWidth=47 (43-cell button row + border/padding); PermLayout disables the hitboxes when the box is narrower (buttons would overflow/clip and stop matching), mirroring the vertical-clip guard. Rewrote TestPermLayoutMatchesRenderClamped: narrow widths -> inactive, wide+height-clamped -> lockstep.

- wrap() now budgets by display width (lipgloss.Width) instead of byte length, so CJK/emoji don't break the width budget. Test: TestWrapBudgetsByDisplayWidth.

- Plain/streaming path now clips each wrapped prose line: wrap doesn't break a single over-long token (long URL / unbroken CJK), so renderAssistantPlain clips wl to the budget. Test: TestPlainAssistantClipsLongUnbrokenToken.

- Streaming verbatim test strengthened to assert the literal 'partial **incomplete' survives (proves glamour is not applied while streaming).

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #114 re-review (@Vasanthdev2004): keep narrow perm modal + all chrome within the frame

- permModalLines: when bw < permMinBoxWidth (too narrow for the 43-cell button row) it no longer renders the full button row (which overflowed the modal frame); it renders a compact keyboard-only hint clipped to the content width. Render and hit-test now stay aligned: PermLayout already disables the mouse hitboxes at this width.

- RenderChat: added clampFrameWidth — a final frame-safety pass that clips every composed line to the frame width, so chrome (top/bottom bars) and the modal/body can never overflow the terminal at narrow widths (fixes the header/statusline-footer overflow too). Width-aware + ANSI-safe; a no-op at normal widths.

- Regression test TestPermModalFitsFrameAtNarrowWidth: RenderChat at Width:40 with a Perm prompt asserts every line stays within 40 cells while PermLayout(40,24).Active == false.

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix: apply CodeRabbit auto-fixes

Fixed 2 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <[email protected]>
2026-06-07 13:39:23 +05:30
Anandan 936bbea59d Add specialist background task tools (#115)
* Add specialist background task tools

* Address specialist background review feedback
2026-06-07 13:15:04 +05:30
f59654abfd Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking (#112)
* Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking

Module 4 of the runtime-core split. Scope: internal/sessions only. 3-way merged cleanly onto main's #106 Tag/Depth work (preserved); no new deps (filelock uses stdlib syscall); build/vet/-race/windows-cross-compile/full-suite green.

- CaptureToolCheckpoint snapshots a tool's target files into content-addressed (sha256) blobs before a mutating tool runs; SnapshotForCheckpoint records file mode; oversize files are skipped, identical content deduped.

- RestoreToSequence reverts to a target sequence: applies only the closest-to-target checkpoint per path, verifies each blob's sha256 before writing, preserves file mode, and confines paths with EvalSymlinks (no symlink escape). ApplyRewind runs restore+truncate+prune+marker atomically under one session lock.

- Cross-process file lock (flock) serializes session mutations; Fork copies checkpoint blobs so a fork can rewind; writeMetadata uses a unique tmp suffix.

FORWARD: this is the persistence layer; the CLI 'zero sessions rewind' and the TUI '/rewind' + pre-tool checkpoint capture wiring land in later modules. Exercised here by checkpoint_test.go / rewind_test.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #112 review: capture-side confinement, atomic-only checkpoint API, Windows lock, test hygiene

- [critical/human blocker] snapshotForCheckpoint now confines every capture target with resolveWithinWorkspace (same EvalSymlinks/no-".." guard as restore) BEFORE stat/read, so a traversal/symlink target can't read files outside the workspace into a blob. os.Stat failures are classified correctly: only os.IsNotExist -> Absent (restore deletes); permission/IO/symlink-loop -> Skipped (restore leaves alone), instead of treating every failure as Absent. Regression test added (TestCaptureRejectsTraversalTargets).

- [major] SnapshotForCheckpoint unexported -> snapshotForCheckpoint: the blob writes and the referencing EventSessionCheckpoint must be committed atomically under one session lock, which only CaptureToolCheckpoint guarantees. Removes the unsafe snapshot-only external entry point (blob/event gap).

- [major] Windows: real LockFileEx/UnlockFileEx (filelock_windows.go) replacing the no-op, so cross-process/cross-Store session serialization holds on Windows too. No new deps (x/sys already required for the unix flock).

- [minor] checkpoint_test hygiene: check os.WriteFile/ReadDir/ReadFile/ReadEvents errors via helpers, add bounds checks before p.Files[0]/events[...] so setup failures fail at the source instead of panicking or passing spuriously.

- rewind restore: documented the residual symlink-swap TOCTOU (boundary re-resolved immediately before the mutation; full closure needs openat2 RESOLVE_BENEATH / per-component O_NOFOLLOW, tracked for the CLI/TUI rewind-wiring work).

build/vet/-race/full-suite + GOOS=windows build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #112 re-review: fail rewind on undecodable checkpoint payload

[major] restoreToSequenceLocked silently continued past a checkpoint whose payload failed to decode, which let a NEWER checkpoint win for that path and restore a later state than the caller requested. Treat corruption as a hard rewind error. Regression test TestRestoreFailsOnCorruptCheckpointPayload (fails-open before, errors now).

Re-review note: the earlier capture-target-validation (critical) and test bounds (minor) were already addressed in 600337b (resolveWithinWorkspace guard + len checks); those re-anchored threads are stale. The restore-side symlink-swap TOCTOU remains a documented heavy-lift residual (needs openat2/descriptor traversal), tracked for the rewind-wiring work.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #112 re-review: dedupe rewind on resolved path, not raw path

[major] restoreToSequenceLocked deduped the per-path closest-to-target short-circuit on the RAW checkpoint path, so equivalent paths (./a.txt, dir/../a.txt, a symlink alias) keyed differently and a NEWER checkpoint could overwrite the closest-to-target snapshot for the same underlying file. Now resolve/confine FIRST and dedupe on the resolved workspace path (unresolvable targets dedupe on raw path). Regression test TestRestoreDedupesEquivalentRawPaths.

This + the prior commit (24e9ba2, fail-hard on undecodable payload) address both correctness blockers @Vasanthdev2004 raised after CodeRabbit's pass.

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 12:48:26 +05:30
Anandan a60154b70a Add specialist Task runtime (#113)
* Add specialist Task runtime

* Harden specialist resume identity
2026-06-07 12:39:29 +05:30
Anandan 2f50158bca Add specialist execution foundation (#111)
* Add specialist execution foundation

* Normalize specialist session ids
2026-06-07 12:01:09 +05:30
7a1800f32d Sandbox hardening: destructive/network/installer classification + interactive-command detection (#109)
* Sandbox hardening: destructive/network/installer classification + interactive-command detection

Module 3 of the runtime-core split (off main, after #107). Scope: internal/sandbox only — clean additive extract; no new deps; whole tree builds + full suite/-race green.

LIVE (Classify is already called by sandbox.Evaluate and agent/loop.go, so this hardens existing behavior immediately):

- rm -rf targeting / $HOME ~ * — now tolerant of surrounding quotes, ${HOME} braces, combined/reordered -r/-f flags, and an optional -- separator; chmod 777 only flagged when recursive or root-targeted (single-file chmod no longer false-positives).

- network-command + piped-installer detection (curl|sh incl |zsh and other shells); command resolved across command/cmd/script/shell aliases so an alias key can't bypass the gate.

FORWARD (compiles now, wired by the later tools/bash module): DetectInteractiveCommand flags interactive programs (vim/less/...) behind wrappers (sudo/nice/timeout, and sh -c/bash -c payloads), hardened against absolute-path/quote/escape/substitution bypasses.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #109 review (CodeRabbit): fix sandbox detection over/under-reach

- [critical] rm long-flag root: --no-preserve-root -rf -- "/" and ... -rf "/" now caught (quote + -- handling restored across short AND long flags).

- chmod 777 abs-path narrowed to root or a sensitive SYSTEM tree (/, /etc, /usr, …); a single-file abs path like chmod 777 /tmp/build.sh is no longer a false positive.

- piped_installer now requires a remote fetch (curl/wget/fetch/aria2c) before the pipe; a local 'cat x | bash' / 'printf | sh' is no longer mis-flagged (engine_test updated to assert the corrected semantics).

- nonInteractiveREPLFlags adds mongo/mongosh (--eval/-f/--file) so 'mongo --eval ...' isn't flagged interactive.

- programIndex now normalizes tokens (basename/quote-strip/lowercase) like firstProgram, so full-path invocations (/usr/bin/python script.py, /bin/bash -c 'vim …') classify correctly (no false positives / missed nested detections).

TDD for each; build/vet/-race/windows cross-compile/full-suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 11:47:02 +05:30
Anandan a6bff1159d Add specialist extends resolution (#110) 2026-06-07 11:44:21 +05:30
Anandan e561c0eb96 Add background task manager (#108)
* Add background task manager

* Fix background manager test on Windows

* Guard background task kill races
2026-06-07 11:29:23 +05:30
07ee16030c Model registry: deprecation fallback, mode presets, reasoning-effort, alias patterns (#107)
Module 2 of the runtime-core split (off main, after #105). Scope: internal/modelregistry only — fully additive (the whole tree builds unchanged against it).

- Regex MatchPatterns aliases + pattern-based Resolve.

- DeprecationRule with FallbackID; ResolveWithFallback returns the active fallback model + a user notice for deprecated ids; NewRegistry validates every Deprecation.FallbackID resolves (fail-fast on misconfig).

- ReasoningEffort support: per-model ReasoningEfforts + DefaultReasoningEffort (validated to be a member of the model's set) + EffectiveReasoningEffort().

- Mode presets (modes.go: smart/deep/fast/large/precise -> model id + reasoning effort).

These are the registry-capability layer; the CLI/agent adoption (--model deprecation-fallback, --mode, -r/--reasoning-effort, enhanced 'zero models') lands in a later module to keep PRs reviewable. Build/vet/full-suite/-race green; no new deps.

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 11:18:02 +05:30
Anandan 561fe0b7ad Add specialist lifecycle metadata (#106) 2026-06-07 11:06:30 +05:30
4d2ce6bc4d Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage (#105)
* Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage

First reviewable slice of the runtime-core work (split out of the large integration branch). Scope: internal/zeroruntime + internal/providers only — fully backward-compatible/additive (the rest of the tree builds unchanged against it).

- SSE idle-timeout watchdog (reader goroutine + idle timer + ctx-cancel) across OpenAI/Anthropic/Gemini via providerio.ScanSSEDataWithContext, so a stalled-but-open upstream no longer blocks the agent forever.

- Streamed tool-call assembly hardened: nameless/dropped tool calls reported (StreamEventToolCallDropped), start-order preserved, distinct empty-id calls no longer merge.

- Terminal finish/stop reason surfaced (CollectedStream.FinishReason + Truncated()) so length/content-filter responses aren't mistaken for normal completions; OpenAI requests stream_options.include_usage (token usage no longer 0); OpenAI delegates error/redact to providerio (503/529 classified) and joins multi-line SSE data; max_completion_tokens wired.

Build/vet/full-suite/-race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #105 review (CodeRabbit): ctx honored when idle watchdog off; cancel during retry backoff; test robustness

providerio: ScanSSEDataWithContext now runs the goroutine+select loop even when idleTimeout<=0 (idle case via a nil channel), so ctx cancellation is honored with the watchdog disabled (was a blocker: it short-circuited to the non-ctx-aware ScanSSEData). openai: a ctx cancellation during 5xx retry backoff is reported as the cancellation instead of being misclassified as the upstream 5xx. Tests: finish-reason/tool-call tests now assert the done/start event is actually emitted (not vacuously pass); new providerio regression test asserts cancel is honored with idleTimeout=0. Build/vet/-race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 11:03:01 +05:30
Anandan ae6c9595db feat(specialist): add exec metadata flags (#104)
* Add specialist exec metadata flags

* Address exec metadata review
2026-06-07 10:40:16 +05:30
Anandan af6a0ef204 feat(specialist): add profile management (#100)
* Add specialist profile management

* Address specialist profile review

* Address specialist validation review
2026-06-07 09:57:15 +05:30
279 changed files with 33262 additions and 3723 deletions

No files matched your search

-8
View File
@@ -37,11 +37,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/zero
/zero.exe
# local launcher with API key — never commit
start-zenline.sh
# user test artifact — never commit
shop.html
docs/audit-2026-06-07-deep-audit.md
docs/superpowers/
+4
View File
@@ -99,6 +99,7 @@ zero doctor [--connectivity] [--json] # health checks
zero config [--json] # inspect resolved configuration
zero sandbox policy [--json] # inspect sandbox backend support
zero serve --mcp [-C <path>] # expose Zero read-only tools over MCP stdio
zero specialist list|show|create|edit|delete # manage local specialist sub-agents
zero update --check [--json --target windows-x64] # check for a newer release
```
@@ -124,6 +125,8 @@ runtimes (Ollama, gateways, etc.) plug in the same way.
| `update_plan` | maintain a live task plan | plan |
| `write_file` · `edit_file` · `apply_patch` | create & modify files | write (gated) |
| `bash` | run shell commands | shell (gated) |
| `Task` · `TaskOutput` · `TaskStop` | delegate to specialist sub-agents | shell/read (gated by tool) |
| `GenerateSpecialist` | create project-local specialist manifests | write (gated) |
Write/shell tools route through the permission policy before any side effect.
@@ -199,6 +202,7 @@ and [`docs/UPDATE.md`](docs/UPDATE.md) for the update flow.
- [Product Requirements (PRD)](docs/PRD.md) — vision, goals, full feature spec, roadmap
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract
- [Specialists](docs/SPECIALISTS.md) — sub-agent manifests, Task tools, and background task state
- [Headless exec PRD](docs/M1_HEADLESS_EXEC_PRD.md)
- [npm wrapper smoke checklist](docs/NPM_WRAPPER_SMOKE.md)
- [Performance](docs/PERFORMANCE.md) · [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md)
+140
View File
@@ -0,0 +1,140 @@
# Zero Specialists
Specialists are named sub-agents that Zero can delegate focused work to through
the `Task` tool. A specialist is a markdown manifest with YAML-style
frontmatter plus a system prompt body.
Specialists can be built in, user-scoped, or project-scoped:
| Scope | Path | Notes |
| --- | --- | --- |
| Built-in | compiled into Zero | `worker`, `explorer`, and `code-review` ship with the binary. |
| User | `~/.config/zero/specialists/*.md` | Available across local workspaces. |
| Project | `.zero/specialists/*.md` | Shared with the current repository when committed. |
Project specialists override user and built-in specialists with the same name.
User specialists override built-ins.
## CLI Management
```bash
zero specialist list
zero specialist show worker
zero specialist path
zero specialist create api-review \
--project \
--description "Reviews API changes" \
--tools read-only,plan \
--prompt "Review API changes for compatibility and missing tests."
zero specialist edit api-review --project
zero specialist delete api-review --project
```
Use `--json` with `list`, `show`, `path`, `create`, or `delete` when scripting.
`create --force` replaces an existing manifest, but refuses symlink overwrites.
`edit` also refuses symlink manifests before opening `$VISUAL` or `$EDITOR`.
## Manifest Format
```markdown
---
name: api-review
description: Reviews API changes for compatibility and missing tests.
tools:
- read-only
- plan
---
Review API changes for behavior regressions, compatibility breaks, and missing
tests. Report concrete findings with file paths.
```
Supported frontmatter keys:
| Key | Purpose |
| --- | --- |
| `name` | Lowercase specialist id. Use letters, numbers, and dashes. |
| `description` | Short summary shown in listings and task metadata. |
| `extends` | Optional base specialist to inherit prompt/model/tools from. |
| `model` | Optional model override. Empty means inherit the parent model. |
| `reasoningEffort` | Optional reasoning effort override. |
| `tools` | Array of tool categories or tool ids. |
If the body is empty and `description` is set, Zero uses the description as the
system prompt and reports a warning in `zero specialist show`.
## Tool Selection
Known categories:
| Category | Tools |
| --- | --- |
| `read-only` | `read_file`, `list_directory`, `grep`, `glob` |
| `edit` | read-only tools plus `write_file`, `edit_file`, `apply_patch` |
| `execute` | read-only tools plus `bash` |
| `plan` | `update_plan` |
Specialist manifests cannot enable `Task`, `TaskOutput`, `TaskStop`, or
`GenerateSpecialist`, so child specialists cannot spawn more specialists or
author new ones.
## Agent Tools
Zero registers these tools for top-level agent runs:
| Tool | Purpose |
| --- | --- |
| `Task` | Launch a specialist sub-agent for a focused prompt. |
| `TaskOutput` | Read or block on a background specialist task's output. |
| `TaskStop` | Stop a running background specialist task. |
| `GenerateSpecialist` | Create a project-local specialist manifest from a description. |
`GenerateSpecialist` is project-scoped only. It writes to
`.zero/specialists`, not the user specialist directory.
Example LLM-facing `Task` payload:
```json
{
"name": "explorer",
"description": "Find session storage code",
"prompt": "Find the files that create, load, and list sessions."
}
```
Background task payload:
```json
{
"name": "worker",
"description": "Audit release docs",
"prompt": "Check the release docs for stale TypeScript references.",
"run_in_background": true
}
```
The returned `task_id` is also the child session id. Use it with
`TaskOutput`, `TaskStop`, or `Task` resume.
## Background State
Background specialist output is stored under:
```text
${XDG_DATA_HOME:-~/.local/share}/zero/background/
```
Each task has:
- `<task_id>.ndjson` for the child process stream output
- `<task_id>.json` for task metadata such as status, PID, parent session, and
timestamps
Persisted metadata lets a new background manager instance read completed task
output or stop a still-running task by id.
If Zero is restarted while a background task is still marked `running`, the new
manager marks that task `error` and clears its PID. This avoids sending
`TaskStop` to a stale PID that may now belong to an unrelated process.
+3 -1
View File
@@ -1,6 +1,8 @@
module github.com/Gitlawb/zero
go 1.25
go 1.24.2
toolchain go1.24.13
require (
github.com/charmbracelet/bubbles v1.0.0
+36
View File
@@ -137,6 +137,42 @@ func TestRunAskUserHandlerErrorDegradesGracefully(t *testing.T) {
}
}
func TestRunAskUserCancellationAbortsRun(t *testing.T) {
registry := registryWithAskUser()
args := `{"questions":[{"question":"Which framework?"}]}`
provider := providerCallingAskUserThenAnswer(args, "done")
result, err := Run(context.Background(), "clarify", provider, Options{
Registry: registry,
OnAskUser: func(_ context.Context, _ AskUserRequest) (AskUserResponse, error) {
return AskUserResponse{}, context.Canceled
},
})
// A canceled prompt must abort with that error — NOT fabricate a headless
// answer and run on to a final answer.
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected run to abort with context.Canceled, got err=%v answer=%q", err, result.FinalAnswer)
}
// The model must not be asked for a follow-up turn after cancellation.
if len(provider.requests) != 1 {
t.Fatalf("expected the run to stop after the canceled ask_user (1 turn), got %d", len(provider.requests))
}
// The recorded tool result must reflect cancellation, not a synthetic answer.
var toolMsg string
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleTool {
toolMsg = m.Content
}
}
if !strings.Contains(strings.ToLower(toolMsg), "canceled") {
t.Fatalf("expected a canceled tool result, got %q", toolMsg)
}
if strings.Contains(strings.ToLower(toolMsg), "no interactive user") {
t.Fatalf("must not fabricate a headless answer on cancellation, got %q", toolMsg)
}
}
func TestRunAskUserRedactsSecretsInAnswers(t *testing.T) {
registry := registryWithAskUser()
secret := "sk-ant-api03-ABCDEFGHIJKLMNOP1234567890"
+73 -21
View File
@@ -35,7 +35,9 @@ const summaryLabel = "[Summary of earlier conversation]"
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details."
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
"If the conversation already begins with an earlier summary block, treat its facts as established context and carry them " +
"forward into the new summary — never drop earlier information."
// CompactionOptions configure a single Compact call.
type CompactionOptions struct {
@@ -128,11 +130,15 @@ func Compact(messages []zeroruntime.Message, opts CompactionOptions) ([]zerorunt
}
summary = strings.TrimSpace(summary)
// Preserve structured state (active plan + loaded skills) from the elided
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
compacted = append(compacted, messages[:systemEnd]...)
compacted = append(compacted, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: summaryLabel + "\n" + summary,
Content: content,
})
compacted = append(compacted, messages[boundary:]...)
return compacted, nil
@@ -309,27 +315,73 @@ func (state *compactionState) recover(
// OnUsage callbacks, so compaction stays invisible on the user-facing surface.
func summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
return func(toSummarize []zeroruntime.Message) (string, error) {
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(toSummarize)},
},
// No tools: this is a plain text summarization call.
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", errors.New(collected.Error)
}
summary := strings.TrimSpace(collected.Text)
if summary == "" {
return "", errors.New("summarizer returned no text")
}
return summarizeWithFallback(ctx, provider, toSummarize)
}
}
// summarizeWithFallback summarizes messages in a single provider call. If that
// call fails with a context-limit error — the slice to summarize is itself too
// large for the model's input window — it splits the slice in half, summarizes
// each half recursively, and joins the partial summaries. This keeps compaction
// working when the elided middle is bigger than the summarizer's own context.
// Non-context-limit errors (and a single message that still won't fit) surface
// to the caller unchanged.
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message) (string, error) {
summary, err := summarizeMessagesOnce(ctx, provider, messages)
if err == nil {
return summary, nil
}
if len(messages) < 2 || !isContextLimitError(err.Error()) {
return "", err
}
mid := len(messages) / 2
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid])
if leftErr != nil {
return "", leftErr
}
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:])
if rightErr != nil {
return "", rightErr
}
// Re-summarize the two partial summaries into ONE so the persisted summary
// stays a single, further-summarizable unit — not an ever-growing concatenated
// blob that a later compaction (which can't split a single message) would fail
// on. If even the combined partials don't fit (extreme), fall back to the
// joined text: still better than failing, and each half is already compacted.
combined := strings.TrimSpace(left + "\n\n" + right)
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: combined},
})
if reduceErr != nil {
return combined, nil
}
return reduced, nil
}
// summarizeMessagesOnce performs a single tool-less summarization call.
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message) (string, error) {
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
},
// No tools: this is a plain text summarization call.
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", errors.New(collected.Error)
}
summary := strings.TrimSpace(collected.Text)
if summary == "" {
return "", errors.New("summarizer returned no text")
}
return summary, nil
}
// renderTranscript flattens messages into a plain-text transcript for the
+299
View File
@@ -0,0 +1,299 @@
package agent
import (
"encoding/json"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction preservation.
//
// A plain prose summary loses two things the model needs to keep working: the
// active plan (issued via the update_plan tool) and any skill instructions it
// loaded (via the skill tool). When those turns fall into the elided middle,
// the plan and skill bodies vanish from context. To prevent that, Compact
// appends them VERBATIM to the injected summary so structured state survives a
// compaction exactly rather than being paraphrased away.
const (
toolNameUpdatePlan = "update_plan"
toolNameSkill = "skill"
)
// preservedStateLabel heads the preserved-state block. The block body is a
// single line of JSON (see formatPreservedState): JSON escapes everything, so a
// skill body containing markdown headings (## / ###), code fences, or quotes
// round-trips losslessly across repeated compactions — unlike a markdown-
// delimited format, which would be truncated or mis-split when re-parsed.
const preservedStateLabel = "## Preserved state (active plan + loaded skills; carried across compaction)"
// maxPreservedSkillBytes caps how much of each loaded skill body is carried
// across a compaction, so a large skill can't defeat the compaction it is part
// of. The skill's name and head survive; the model can re-load it in full if it
// needs the rest.
const maxPreservedSkillBytes = 2 << 10 // 2 KiB
// extractLatestPlan returns a formatted view of the most recent update_plan tool
// call in messages, so an in-progress plan survives when its turns are elided by
// compaction. Returns "" when no plan was issued.
func extractLatestPlan(messages []zeroruntime.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
calls := messages[i].ToolCalls
for j := len(calls) - 1; j >= 0; j-- {
if calls[j].Name != toolNameUpdatePlan {
continue
}
if formatted := formatPlanArguments(calls[j].Arguments); formatted != "" {
return formatted
}
}
}
return ""
}
// formatPlanArguments renders an update_plan tool call's JSON arguments
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
// "" on malformed arguments or an empty plan.
func formatPlanArguments(arguments string) string {
var parsed struct {
Plan []struct {
Content string `json:"content"`
Status string `json:"status"`
} `json:"plan"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
return ""
}
lines := make([]string, 0, len(parsed.Plan))
for _, item := range parsed.Plan {
content := strings.TrimSpace(item.Content)
if content == "" {
continue
}
status := strings.TrimSpace(item.Status)
if status == "" {
status = "pending"
}
lines = append(lines, "- ["+status+"] "+content)
}
return strings.Join(lines, "\n")
}
// skillEntry is one loaded skill: its name and (capped) body.
type skillEntry struct {
name string
body string
}
// loadedSkills returns the skills loaded via the skill tool in messages — the
// latest body per name, in first-seen order — matching each skill tool call to
// its tool result by id.
func loadedSkills(messages []zeroruntime.Message) []skillEntry {
nameByID := map[string]string{}
for _, message := range messages {
for _, call := range message.ToolCalls {
if call.Name == toolNameSkill && call.ID != "" {
nameByID[call.ID] = skillNameFromArguments(call.Arguments)
}
}
}
if len(nameByID) == 0 {
return nil
}
bodyByName := map[string]string{}
nameOrder := make([]string, 0, len(nameByID))
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
continue
}
name, ok := nameByID[message.ToolCallID]
if !ok {
continue
}
if name == "" {
name = "(unnamed)"
}
body := strings.TrimSpace(message.Content)
if body == "" {
continue
}
if _, seen := bodyByName[name]; !seen {
nameOrder = append(nameOrder, name)
}
bodyByName[name] = capBody(body)
}
entries := make([]skillEntry, 0, len(nameOrder))
for _, name := range nameOrder {
entries = append(entries, skillEntry{name: name, body: bodyByName[name]})
}
return entries
}
// skillNameFromArguments pulls the "name" field from a skill tool call's JSON
// arguments ({"name":"..."}). Returns "" on malformed arguments.
func skillNameFromArguments(arguments string) string {
var parsed struct {
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
return ""
}
return strings.TrimSpace(parsed.Name)
}
// truncationNote is appended to a capped skill body. Its length is reserved
// within the byte budget so the result never exceeds maxPreservedSkillBytes.
const truncationNote = "\n… (truncated; re-load the skill for the full body)"
// capBody truncates an over-long skill body to maxPreservedSkillBytes BYTES,
// cutting on a UTF-8 rune boundary (never splitting a multibyte rune) and
// reserving room for the note so the result stays within the byte budget. The
// note is added only when the body is actually truncated.
func capBody(body string) string {
if len(body) <= maxPreservedSkillBytes {
return body
}
limit := maxPreservedSkillBytes - len(truncationNote)
if limit < 0 {
limit = 0
}
// Walk back to the start of a rune so a multibyte sequence is never split.
for limit > 0 && !utf8.RuneStart(body[limit]) {
limit--
}
return body[:limit] + truncationNote
}
// preservedState is the JSON shape of the carried-across-compaction block.
type preservedState struct {
Plan string `json:"plan,omitempty"`
Skills []preservedSkill `json:"skills,omitempty"`
}
type preservedSkill struct {
Name string `json:"name"`
Body string `json:"body"`
}
// appendPreservedState appends the active plan and loaded skills to a compaction
// summary as a single JSON block, so structured state survives verbatim. middle
// is the slice being summarized away.
//
// It is robust across REPEATED compactions: after the first compaction the plan
// and skills live only inside the injected summary message, which on a later
// compaction lands in middle with no real tool calls left to extract. So when
// middle has no fresh update_plan / skill tool calls, the preserved state is
// carried forward by re-parsing the prior block (JSON → lossless for arbitrary
// markdown bodies). Fresh tool calls always override the carried-forward copy.
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
priorPlan, priorSkills := parsePreservedState(latestSummaryContent(middle))
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
// plan preserved by an earlier compaction.
plan := extractLatestPlan(middle)
if plan == "" {
plan = priorPlan
}
// Skills: merge skills preserved earlier (older) with fresh loads (newer wins
// per name), so a loaded skill survives repeated compactions.
skills := mergeSkillEntries(priorSkills, loadedSkills(middle))
if block := formatPreservedState(plan, skills); block != "" {
summary += "\n\n" + block
}
return summary
}
// formatPreservedState renders the plan + skills as the labelled, single-line
// JSON block. Returns "" when there is nothing to preserve.
func formatPreservedState(plan string, skills []skillEntry) string {
if plan == "" && len(skills) == 0 {
return ""
}
state := preservedState{Plan: plan}
for _, s := range skills {
state.Skills = append(state.Skills, preservedSkill{Name: s.name, Body: s.body})
}
encoded, err := json.Marshal(state)
if err != nil {
return ""
}
return preservedStateLabel + "\n" + string(encoded)
}
// parsePreservedState recovers the plan + skills from a prior summary's preserved
// block. JSON escaping makes this lossless even when a skill body contains
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
// malformed.
func parsePreservedState(summaryContent string) (string, []skillEntry) {
idx := strings.LastIndex(summaryContent, preservedStateLabel)
if idx < 0 {
return "", nil
}
rest := strings.TrimPrefix(summaryContent[idx+len(preservedStateLabel):], "\n")
// The JSON is a single line (json.Marshal escapes newlines).
if nl := strings.IndexByte(rest, '\n'); nl >= 0 {
rest = rest[:nl]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", nil
}
var state preservedState
if err := json.Unmarshal([]byte(rest), &state); err != nil {
return "", nil
}
entries := make([]skillEntry, 0, len(state.Skills))
for _, s := range state.Skills {
if s.Name == "" {
continue
}
entries = append(entries, skillEntry{name: s.Name, body: s.Body})
}
return state.Plan, entries
}
// latestSummaryContent returns the content of the most recent injected summary
// message in messages (a user message beginning with summaryLabel), or "".
func latestSummaryContent(messages []zeroruntime.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
m := messages[i]
if m.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(strings.TrimSpace(m.Content), summaryLabel) {
return m.Content
}
}
return ""
}
// mergeSkillEntries overlays newer skill loads onto older preserved entries by
// name (newer body wins), keeping the older order and appending genuinely-new
// skills after.
func mergeSkillEntries(older, newer []skillEntry) []skillEntry {
if len(newer) == 0 {
return older
}
newBody := make(map[string]string, len(newer))
for _, e := range newer {
newBody[e.name] = e.body
}
merged := make([]skillEntry, 0, len(older)+len(newer))
seen := make(map[string]bool, len(older))
for _, e := range older {
if b, ok := newBody[e.name]; ok {
e.body = b
}
merged = append(merged, e)
seen[e.name] = true
}
for _, e := range newer {
if !seen[e.name] {
merged = append(merged, e)
}
}
return merged
}
+241
View File
@@ -0,0 +1,241 @@
package agent
import (
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stateConversation is a long enough conversation that Compact elides a middle
// containing an update_plan call and a loaded skill (call + result).
func stateConversation() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "build the thing"},
{Role: zeroruntime.MessageRoleAssistant, Content: "planning", ToolCalls: []zeroruntime.ToolCall{
{ID: "p1", Name: "update_plan", Arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`},
}},
{Role: zeroruntime.MessageRoleTool, Content: "plan updated", ToolCallID: "p1"},
{Role: zeroruntime.MessageRoleAssistant, Content: "loading skill", ToolCalls: []zeroruntime.ToolCall{
{ID: "s1", Name: "skill", Arguments: `{"name":"deploy"}`},
}},
{Role: zeroruntime.MessageRoleTool, Content: "Deploy skill: run `make deploy` then tag the release.", ToolCallID: "s1"},
{Role: zeroruntime.MessageRoleAssistant, Content: "done step 1"},
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
}
}
func compactStateConversation(t *testing.T, messages []zeroruntime.Message) string {
t.Helper()
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
})
if err != nil {
t.Fatalf("Compact returned error: %v", err)
}
// [system, summaryUserMsg, ...suffix] — the summary is the message after system.
if len(compacted) < 2 || compacted[1].Role != zeroruntime.MessageRoleUser {
t.Fatalf("unexpected compacted shape: %#v", compacted)
}
if !strings.Contains(compacted[1].Content, summaryLabel) {
t.Fatalf("summary message missing label: %q", compacted[1].Content)
}
return compacted[1].Content
}
func TestCompactPreservesActivePlan(t *testing.T) {
summary := compactStateConversation(t, stateConversation())
if !strings.Contains(summary, preservedStateLabel) {
t.Fatalf("expected preserved-state block, got %q", summary)
}
for _, want := range []string{"- [in_progress] write code", "- [pending] add tests"} {
if !strings.Contains(summary, want) {
t.Fatalf("plan item %q not preserved in %q", want, summary)
}
}
}
func TestCompactPreservesLoadedSkills(t *testing.T) {
summary := compactStateConversation(t, stateConversation())
if !strings.Contains(summary, preservedStateLabel) {
t.Fatalf("expected preserved-state block, got %q", summary)
}
if !strings.Contains(summary, `"name":"deploy"`) || !strings.Contains(summary, "make deploy") {
t.Fatalf("skill name/body not preserved in %q", summary)
}
}
func TestCompactWithoutStateHasNoPreserveSections(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
{Role: zeroruntime.MessageRoleAssistant, Content: "hi there"},
{Role: zeroruntime.MessageRoleUser, Content: "tell me more"},
{Role: zeroruntime.MessageRoleAssistant, Content: "sure"},
{Role: zeroruntime.MessageRoleUser, Content: "and more"},
{Role: zeroruntime.MessageRoleAssistant, Content: "ok"},
}
summary := compactStateConversation(t, messages)
if strings.Contains(summary, preservedStateLabel) {
t.Fatalf("did not expect a preserved-state block without plan/skill: %q", summary)
}
}
func TestCompactCarriesPreservedStateAcrossRepeatedCompaction(t *testing.T) {
// First compaction: real update_plan + skill load in the elided middle.
first, err := Compact(stateConversation(), CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST SUMMARY", nil },
})
if err != nil {
t.Fatalf("first Compact: %v", err)
}
// Grow the history so the first summary (which holds the preserved sections,
// but no real tool calls) falls into the SECOND compaction's middle.
second := append([]zeroruntime.Message{}, first...)
second = append(second,
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "what next"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
)
// The second summarizer deliberately DROPS the preserved sections.
out, err := Compact(second, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND SUMMARY with no preserved sections", nil },
})
if err != nil {
t.Fatalf("second Compact: %v", err)
}
if len(out) < 2 || out[1].Role != zeroruntime.MessageRoleUser {
t.Fatalf("unexpected compacted shape: %#v", out)
}
newSummary := out[1].Content
// Plan and skill must survive even though the summarizer didn't copy them.
if !strings.Contains(newSummary, preservedStateLabel) || !strings.Contains(newSummary, "write code") {
t.Fatalf("active plan lost on the second compaction: %q", newSummary)
}
if !strings.Contains(newSummary, `"name":"deploy"`) || !strings.Contains(newSummary, "make deploy") {
t.Fatalf("loaded skill lost on the second compaction: %q", newSummary)
}
}
// TestCompactPreservesSkillBodyWithMarkdownHeadings is CodeRabbit's regression:
// a verbatim skill body containing ## / ### headings (and a code fence) must
// round-trip across TWO compactions without truncation or bogus extra skills —
// which the old markdown-delimited format could not guarantee.
func TestCompactPreservesSkillBodyWithMarkdownHeadings(t *testing.T) {
body := "## Usage\nrun it\n### Examples\n```\nzero do\n```\n## Notes\ndone"
conv := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "load a skill"},
{Role: zeroruntime.MessageRoleAssistant, Content: "loading", ToolCalls: []zeroruntime.ToolCall{
{ID: "s1", Name: "skill", Arguments: `{"name":"guide"}`},
}},
{Role: zeroruntime.MessageRoleTool, Content: body, ToolCallID: "s1"},
{Role: zeroruntime.MessageRoleAssistant, Content: "done step 1"},
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
}
mustContainBody := func(label string, messages []zeroruntime.Message) []zeroruntime.Message {
out, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
})
if err != nil {
t.Fatalf("%s Compact: %v", label, err)
}
if len(out) < 2 || out[1].Role != zeroruntime.MessageRoleUser {
t.Fatalf("%s: unexpected compacted shape: %#v", label, out)
}
_, skills := parsePreservedState(out[1].Content)
if len(skills) != 1 || skills[0].name != "guide" || skills[0].body != body {
t.Fatalf("%s: skill body not round-tripped intact: %#v", label, skills)
}
return out
}
first := mustContainBody("first", conv)
// Second compaction with NO fresh tool calls and a summarizer that drops it.
second := append(append([]zeroruntime.Message{}, first...),
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "ok"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "fine"},
)
mustContainBody("second", second)
}
func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "a", Name: "update_plan", Arguments: `{"plan":[{"content":"old step","status":"completed"}]}`},
}},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "b", Name: "update_plan", Arguments: `{"plan":[{"content":"new step","status":"in_progress"}]}`},
}},
}
got := extractLatestPlan(messages)
if !strings.Contains(got, "new step") || strings.Contains(got, "old step") {
t.Fatalf("extractLatestPlan should return the most recent plan, got %q", got)
}
}
func TestCapBodyShortBodyUnchanged(t *testing.T) {
body := "short skill body"
if got := capBody(body); got != body {
t.Fatalf("capBody changed a short body: %q", got)
}
if strings.Contains(capBody(body), "truncated") {
t.Fatalf("note added without truncation")
}
}
func TestCapBodyRespectsByteBudgetForMultibyte(t *testing.T) {
// Each '世' is 3 bytes; build a body well over the byte budget.
body := strings.Repeat("世", maxPreservedSkillBytes)
got := capBody(body)
if len(got) > maxPreservedSkillBytes {
t.Fatalf("capBody returned %d bytes, want <= %d (byte budget)", len(got), maxPreservedSkillBytes)
}
if !strings.Contains(got, "truncated") {
t.Fatalf("expected truncation note on an over-budget body")
}
if !utf8.ValidString(got) {
t.Fatalf("capBody split a multibyte rune (invalid UTF-8)")
}
}
func TestCapBodyNoteOnlyWhenTruncated(t *testing.T) {
// A body whose RUNE count is under the cap but BYTE length is over it must
// still be byte-capped (the old rune-based check mishandled this case).
body := strings.Repeat("世", (maxPreservedSkillBytes/3)+100)
if len(body) <= maxPreservedSkillBytes {
t.Fatalf("test setup: body should exceed the byte budget, got %d", len(body))
}
got := capBody(body)
if len(got) > maxPreservedSkillBytes {
t.Fatalf("capBody returned %d bytes, want <= %d", len(got), maxPreservedSkillBytes)
}
if !strings.Contains(got, "truncated") || !utf8.ValidString(got) {
t.Fatalf("expected a valid, truncated body, got %q", got)
}
}
func TestLoadedSkillsSkipsCallsWithoutResult(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "s1", Name: "skill", Arguments: `{"name":"ghost"}`}, // no matching tool result
}},
}
if got := loadedSkills(messages); len(got) != 0 {
t.Fatalf("expected no skills without a result body, got %#v", got)
}
}
@@ -0,0 +1,145 @@
package agent
import (
"context"
"fmt"
"regexp"
"strings"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var markerPattern = regexp.MustCompile(`msg-\d+`)
// sizeLimitedSummarizer returns a context-limit error when the rendered
// transcript carries more than maxMarkers messages, and otherwise "summarizes"
// by echoing the message markers it saw — so a successful summary records exactly
// which messages it covered.
type sizeLimitedSummarizer struct {
maxMarkers int
calls int32
}
func (p *sizeLimitedSummarizer) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
atomic.AddInt32(&p.calls, 1)
text := request.Messages[len(request.Messages)-1].Content
markers := markerPattern.FindAllString(text, -1)
ch := make(chan zeroruntime.StreamEvent, 2)
if len(markers) > p.maxMarkers {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: "context length exceeded"}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: strings.Join(markers, " ")}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
type errorSummarizer struct {
message string
calls int32
}
func (p *errorSummarizer) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
atomic.AddInt32(&p.calls, 1)
ch := make(chan zeroruntime.StreamEvent, 1)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: p.message}
close(ch)
return ch, nil
}
// compressingSummarizer fails on more than maxMarkers messages but returns a
// SHORT marker-free summary, so two partial summaries combine into something that
// fits — modelling real summarization that shrinks its input.
type compressingSummarizer struct {
maxMarkers int
calls int32
}
func (p *compressingSummarizer) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
atomic.AddInt32(&p.calls, 1)
text := request.Messages[len(request.Messages)-1].Content
ch := make(chan zeroruntime.StreamEvent, 2)
if len(markerPattern.FindAllString(text, -1)) > p.maxMarkers {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: "context length exceeded"}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "S"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func TestSummarizeWithFallbackReSummarizesPartialsIntoOne(t *testing.T) {
messages := make([]zeroruntime.Message, 4)
for i := range messages {
messages[i] = zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("msg-%d body", i)}
}
provider := &compressingSummarizer{maxMarkers: 2}
summary, err := summarizeWithFallback(context.Background(), provider, messages)
if err != nil {
t.Fatalf("summarizeWithFallback failed: %v", err)
}
// The two chunk summaries ("S" / "S") are re-summarized into ONE unit, not
// returned as the joined "S\n\nS" blob — so a later compaction can shrink it.
if strings.Contains(summary, "\n\n") {
t.Fatalf("expected a single re-summarized result, got a joined blob: %q", summary)
}
if summary != "S" {
t.Fatalf("summary = %q, want the reduced %q", summary, "S")
}
}
func TestSummarizeWithFallbackChunksOnContextLimit(t *testing.T) {
const n = 8
messages := make([]zeroruntime.Message, n)
for i := range messages {
messages[i] = zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("msg-%d some content", i)}
}
// The summarizer can only handle 2 messages per call, so the 8-message slice
// must be split recursively until each chunk fits.
provider := &sizeLimitedSummarizer{maxMarkers: 2}
summary, err := summarizeWithFallback(context.Background(), provider, messages)
if err != nil {
t.Fatalf("summarizeWithFallback failed: %v", err)
}
for i := 0; i < n; i++ {
if !strings.Contains(summary, fmt.Sprintf("msg-%d", i)) {
t.Fatalf("combined summary missing msg-%d: %q", i, summary)
}
}
if got := atomic.LoadInt32(&provider.calls); got < 2 {
t.Fatalf("expected multiple calls from splitting, got %d", got)
}
}
func TestSummarizeWithFallbackPropagatesNonContextErrors(t *testing.T) {
provider := &errorSummarizer{message: "auth error: invalid key"}
_, err := summarizeWithFallback(context.Background(), provider, []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "msg-0"},
{Role: zeroruntime.MessageRoleUser, Content: "msg-1"},
})
if err == nil {
t.Fatal("expected a non-context-limit error to propagate")
}
if got := atomic.LoadInt32(&provider.calls); got != 1 {
t.Fatalf("a non-context error must not trigger splitting/retry, calls=%d", got)
}
}
func TestSummarizeWithFallbackSingleMessageContextLimitSurfaces(t *testing.T) {
// A single message that still won't fit can't be split further → error surfaces.
provider := &sizeLimitedSummarizer{maxMarkers: 0}
_, err := summarizeWithFallback(context.Background(), provider, []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "msg-0 too big"},
})
if err == nil {
t.Fatal("expected the context-limit error to surface for an unsplittable single message")
}
}
+3 -3
View File
@@ -409,9 +409,9 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
if provider.summarizeCalls == 0 {
t.Fatal("expected reactive compaction to run")
}
// The retried turn's text must be streamed to OnText at most once. Before the
// fix it was emitted on both the original (mid-stream) collect AND the retry
// collect, double-emitting the retried response.
// The retried turn's text must NOT be re-streamed to OnText. Before the fix it
// was emitted on both the original (mid-stream) collect AND the retry collect,
// double-emitting the retried response.
joined := strings.Join(deltas, "")
if got := strings.Count(joined, "recovered"); got != 0 {
t.Fatalf("retried-turn text must NOT be re-streamed to OnText, saw %d occurrences in %q", got, joined)
+66
View File
@@ -0,0 +1,66 @@
package agent
import (
"encoding/json"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Context budget.
//
// MeasureContext breaks a request's estimated token footprint into the
// categories that actually compete for the window — the system prompt, the
// advertised tool definitions, and the conversation messages — so the agent can
// report context utilization (e.g. "62% used: 4k system, 6k tools, 30k history")
// and reason about what to compact. Counts are estimates (see estimateTokens),
// not a real tokenizer: good for proportions and budget bars, not billing.
// ContextBreakdown is a per-category estimate of a request's token footprint.
type ContextBreakdown struct {
SystemTokens int // leading system messages (prompt + injected context)
ToolTokens int // advertised tool definitions
MessageTokens int // non-system conversation messages
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
ContextWindow int // model context window; 0 when unknown
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
}
// MeasureContext estimates the per-category token footprint of a request: the
// leading system messages, the advertised tool definitions, and the remaining
// conversation messages.
func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefinition, contextWindow int) ContextBreakdown {
systemEnd := 0
for systemEnd < len(messages) && messages[systemEnd].Role == zeroruntime.MessageRoleSystem {
systemEnd++
}
breakdown := ContextBreakdown{
SystemTokens: estimateTokens(messages[:systemEnd]),
MessageTokens: estimateTokens(messages[systemEnd:]),
ToolTokens: estimateToolTokens(tools),
ContextWindow: contextWindow,
}
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
if contextWindow > 0 {
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
}
return breakdown
}
// estimateToolTokens approximates the token footprint of advertised tool
// definitions (name + description + JSON schema), matching estimateTokens'
// ~4-chars/token heuristic so all categories use the same scale.
func estimateToolTokens(tools []zeroruntime.ToolDefinition) int {
total := 0
for _, tool := range tools {
total += len(tool.Name) / 4
total += len(tool.Description) / 4
if len(tool.Parameters) > 0 {
if encoded, err := json.Marshal(tool.Parameters); err == nil {
total += len(encoded) / 4
}
}
total += 4 // per-tool overhead
}
return total
}
@@ -0,0 +1,68 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMeasureContextSplitsByCategory(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: filler(2000)},
{Role: zeroruntime.MessageRoleUser, Content: filler(400)},
{Role: zeroruntime.MessageRoleAssistant, Content: filler(400)},
}
tools := []zeroruntime.ToolDefinition{
{Name: "read_file", Description: filler(200), Parameters: map[string]any{"type": "object"}},
}
breakdown := MeasureContext(messages, tools, 100_000)
if breakdown.SystemTokens <= 0 || breakdown.MessageTokens <= 0 || breakdown.ToolTokens <= 0 {
t.Fatalf("expected all categories non-zero, got %#v", breakdown)
}
if got := breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens; got != breakdown.TotalTokens {
t.Fatalf("TotalTokens %d != sum of categories %d", breakdown.TotalTokens, got)
}
// System content (2000 chars) dominates the conversation (800 chars total).
if breakdown.SystemTokens <= breakdown.MessageTokens {
t.Fatalf("expected system to dominate messages, got system=%d messages=%d", breakdown.SystemTokens, breakdown.MessageTokens)
}
wantFraction := float64(breakdown.TotalTokens) / 100_000
if breakdown.UsedFraction != wantFraction {
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
}
}
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
breakdown := MeasureContext([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
}, nil, 0)
if breakdown.UsedFraction != 0 {
t.Fatalf("UsedFraction = %v, want 0 when context window unknown", breakdown.UsedFraction)
}
if breakdown.ToolTokens != 0 {
t.Fatalf("ToolTokens = %d, want 0 with no tools", breakdown.ToolTokens)
}
}
func TestMeasureContextNoSystemMessage(t *testing.T) {
breakdown := MeasureContext([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: filler(400)},
}, nil, 1000)
if breakdown.SystemTokens != 0 {
t.Fatalf("SystemTokens = %d, want 0 when there is no system message", breakdown.SystemTokens)
}
if breakdown.MessageTokens <= 0 {
t.Fatalf("MessageTokens = %d, want > 0", breakdown.MessageTokens)
}
}
// filler returns a string of n characters for sizing assertions.
func filler(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = 'x'
}
return string(b)
}
+768
View File
@@ -0,0 +1,768 @@
package agent
import (
"context"
"errors"
"reflect"
"sort"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
options := Options{DeferThreshold: 10}
if options.DeferThreshold != 10 {
t.Fatalf("expected DeferThreshold 10, got %d", options.DeferThreshold)
}
}
func TestToolResultLoadedToolsField(t *testing.T) {
result := ToolResult{LoadedTools: []string{"Alpha", "Beta"}}
if len(result.LoadedTools) != 2 || result.LoadedTools[0] != "Alpha" || result.LoadedTools[1] != "Beta" {
t.Fatalf("expected LoadedTools [Alpha Beta], got %#v", result.LoadedTools)
}
// Default zero value is nil for an ordinary result.
if (ToolResult{}).LoadedTools != nil {
t.Fatalf("expected nil LoadedTools by default")
}
}
// loadSignalTool returns Meta["load_tools"] like tool_search does, so we can
// assert executeToolCall lifts it into ToolResult.LoadedTools.
type loadSignalTool struct{ value string }
func (t loadSignalTool) Name() string { return "load_signal" }
func (t loadSignalTool) Description() string { return "emits a load_tools signal" }
func (t loadSignalTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t loadSignalTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t loadSignalTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t loadSignalTool) RunWithOptions(_ context.Context, _ map[string]any, _ tools.RunOptions) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok", Meta: map[string]string{"load_tools": t.value}}
}
func TestExecuteToolCallLiftsLoadTools(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(loadSignalTool{value: " Alpha , Beta ,, "})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "load_signal", Arguments: ""},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
want := []string{"Alpha", "Beta"}
if len(result.LoadedTools) != len(want) {
t.Fatalf("expected LoadedTools %#v, got %#v", want, result.LoadedTools)
}
for i := range want {
if result.LoadedTools[i] != want[i] {
t.Fatalf("expected LoadedTools %#v, got %#v", want, result.LoadedTools)
}
}
}
func TestExecuteToolCallNoLoadToolsMetaLeavesNil(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(secretEmittingTool{output: "plain"})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "leak", Arguments: ""},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
if result.LoadedTools != nil {
t.Fatalf("expected nil LoadedTools for a tool with no load_tools meta, got %#v", result.LoadedTools)
}
}
// fakeDeferredTool is deferred-eligible (implements Deferred() bool) like an MCP
// tool wrapper, so partitionTools counts and (when active) hides it.
type fakeDeferredTool struct {
name string
desc string
}
func (t fakeDeferredTool) Name() string { return t.name }
func (t fakeDeferredTool) Description() string { return t.desc }
func (t fakeDeferredTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t fakeDeferredTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t fakeDeferredTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t fakeDeferredTool) Deferred() bool { return true }
// fakeToolSearchTool stands in for component D's tool_search (a non-deferred
// builtin) so the inactive path can assert it is dropped.
type fakeToolSearchTool struct{}
func (fakeToolSearchTool) Name() string { return "tool_search" }
func (fakeToolSearchTool) Description() string { return "load deferred tool schemas" }
func (fakeToolSearchTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (fakeToolSearchTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true}
}
func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
registry.Register(fakeToolSearchTool{})
options := Options{DeferThreshold: 0} // 0 => deferral disabled => inactive path.
// DeferThreshold 0 => deferral disabled => inactive path.
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
if reminder != "" {
t.Fatalf("expected empty reminder on inactive path, got %q", reminder)
}
// Strong byte-identity assertion: build the reference the LEGACY way (the old
// toolDefinitions construction — every visible/advertised tool's full schema,
// EXCEPT tool_search, alpha-sorted by name) and require an exact DeepEqual. This
// pins that the inactive partition produces the pre-deferral output verbatim,
// not merely the same set of names.
reference := legacyToolDefinitions(registry, PermissionModeAuto, options)
if !reflect.DeepEqual(exposed, reference) {
t.Fatalf("inactive partition not byte-identical to legacy toolDefinitions:\n got %#v\nwant %#v", exposed, reference)
}
// Belt-and-suspenders: tool_search is dropped, the deferred tool keeps its full
// schema, and only the expected names are present.
for _, def := range exposed {
if def.Name == "tool_search" {
t.Fatalf("tool_search must be dropped on inactive path, got %#v", exposed)
}
}
wantNames := map[string]bool{"read_file": true, "mcp__srv__a": true}
if len(exposed) != len(wantNames) {
t.Fatalf("expected %d exposed tools, got %d: %#v", len(wantNames), len(exposed), exposed)
}
for _, def := range exposed {
if !wantNames[def.Name] {
t.Fatalf("unexpected exposed tool %q", def.Name)
}
if def.Name == "mcp__srv__a" && def.Parameters["type"] != "object" {
t.Fatalf("expected full schema for deferred tool on inactive path, got %#v", def.Parameters)
}
}
}
// legacyToolDefinitions reconstructs the PRE-deferral tool-list builder: every
// tool that is visible (passes the operator filters) and advertised for the mode,
// EXCEPT tool_search, rendered with its full schema and alpha-sorted by name. It
// is the byte-identity reference for the inactive partition path.
func legacyToolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
definitions := make([]zeroruntime.ToolDefinition, 0)
for _, tool := range registry.All() {
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
continue
}
if tool.Name() == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
}
sort.Slice(definitions, func(left, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions
}
// Below-threshold-but-eligible (count < threshold) is also inactive.
func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "a"})
registry.Register(fakeDeferredTool{name: "mcp__srv__b", desc: "b"})
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 10}, map[string]bool{})
if reminder != "" {
t.Fatalf("expected empty reminder below threshold, got %q", reminder)
}
if len(exposed) != 2 {
t.Fatalf("expected both deferred tools exposed below threshold, got %#v", exposed)
}
}
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
// eligible total. With N deferred registered and threshold == N, disabling one
// drops the surviving eligible count to N-1 < threshold, so the partition takes
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
// the disabled one filtered out entirely).
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
DeferThreshold: 2,
DisabledTools: []string{"mcp__srv__beta"},
}, map[string]bool{})
// Eligible drops to 1 (< threshold 2) => inactive: empty reminder.
if reminder != "" {
t.Fatalf("expected inactive path (empty reminder) when a disable drops eligible below threshold, got %q", reminder)
}
// Only the surviving visible tool is exposed, with its FULL schema; the disabled
// tool is filtered out entirely.
if len(exposed) != 1 || exposed[0].Name != "mcp__srv__alpha" {
t.Fatalf("expected only mcp__srv__alpha exposed on inactive path, got %#v", exposed)
}
if exposed[0].Parameters["type"] != "object" {
t.Fatalf("surviving deferred tool must keep its full schema on inactive path, got %#v", exposed[0].Parameters)
}
}
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
// appear in the reminder NOR be exposed — it is filtered out before the partition
// even considers it.
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
registry.Register(fakeDeferredTool{name: "mcp__srv__gamma", desc: "gamma"})
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
DeferThreshold: 2,
DisabledTools: []string{"mcp__srv__beta"},
}, map[string]bool{})
if reminder == "" {
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
}
if strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
}
for _, def := range exposed {
if def.Name == "mcp__srv__beta" {
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
}
}
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
}
}
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
loaded := map[string]bool{"mcp__srv__alpha": true}
// 2 eligible deferred tools, threshold 2 => active.
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposedNames := map[string]bool{}
for _, def := range exposed {
exposedNames[def.Name] = true
}
if !exposedNames["read_file"] {
t.Fatalf("expected builtin read_file exposed, got %#v", exposed)
}
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
if !exposedNames["mcp__srv__alpha"] {
t.Fatalf("expected loaded deferred tool exposed, got %#v", exposed)
}
if exposedNames["mcp__srv__beta"] {
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
}
if reminder == "" {
t.Fatalf("expected a non-empty reminder for the hidden tool")
}
if !strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
}
if strings.Contains(reminder, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
}
}
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
loaded := map[string]bool{"mcp__srv__alpha": true, "mcp__srv__beta": true}
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposedNames := map[string]bool{}
for _, def := range exposed {
exposedNames[def.Name] = true
}
if !exposedNames["mcp__srv__alpha"] || !exposedNames["mcp__srv__beta"] {
t.Fatalf("expected both loaded deferred tools exposed, got %#v", exposed)
}
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
// BuildDeferredReminder returns "" for no hidden lines.
if reminder != "" {
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
}
}
func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
registry := tools.NewRegistry()
// load_signal asks the loop to load mcp__srv__alpha next turn.
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
// A real, usable tool_search must be registered for deferral to activate
// (otherwise the loop falls back to eager so it never strands the loader).
registry.Register(tools.NewToolSearchTool(registry))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: call load_signal
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: final answer
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
DeferThreshold: 2, // 2 deferred tools => active
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("expected two turns, got %d", len(provider.requests))
}
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
turn1Tools := map[string]bool{}
for _, def := range provider.requests[0].Tools {
turn1Tools[def.Name] = true
}
if turn1Tools["mcp__srv__alpha"] {
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
}
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
}
// Turn 2: alpha is now loaded and advertised with a full schema.
turn2Tools := map[string]bool{}
for _, def := range provider.requests[1].Tools {
turn2Tools[def.Name] = true
}
if !turn2Tools["mcp__srv__alpha"] {
t.Fatalf("turn 2 must advertise the loaded deferred tool, got %#v", provider.requests[1].Tools)
}
if turn2Tools["mcp__srv__beta"] {
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
}
// The reminder must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
// context-limit error that triggers reactive compaction+retry while deferral is
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
// degraded to the empty-loaded/no-reminder state: it must still advertise the
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
registry := tools.NewRegistry()
// load_signal asks the loop to load mcp__srv__alpha next turn.
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
// A real, usable tool_search must be registered for deferral to activate
// (otherwise the loop falls back to eager so it never strands the loader).
registry.Register(tools.NewToolSearchTool(registry))
// Request indices (mockProvider plays one turn per request, in order):
// 0: turn 1 — calls load_signal (loads mcp__srv__alpha for later turns)
// 1: turn 2 — emits a context-limit error MID-stream -> reactive recover
// 2: the summarize call inside Compact (must return non-empty text)
// 3: the RETRY request rebuilt after compaction — asserted below
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: call load_signal
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: mid-stream context-limit error -> reactive compaction
{Type: zeroruntime.StreamEventError, Error: "prompt is too long: 250000 tokens > 200000 maximum"},
},
{ // summarize call inside Compact
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"},
{Type: zeroruntime.StreamEventDone},
},
{ // retry of turn 2 after compaction: final answer
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
// A large user prompt so the elided middle is big enough that reactive
// compaction actually shrinks the history (recover only retries when it can
// shrink). ContextWindow is set high enough that PROACTIVE compaction (which
// fires at 0.8 * window at the top of a turn) does NOT trigger first — so the
// only summarize call is the reactive one, keeping the request sequence
// predictable: turn1, errored turn2, summarize, retry.
bigPrompt := strings.Repeat("work on this task. ", 2000)
result, err := Run(context.Background(), bigPrompt, provider, Options{
Registry: registry,
DeferThreshold: 2, // 2 deferred tools => active
ContextWindow: 1_000_000,
CompactionPreserveLast: 2,
})
if err != nil {
t.Fatalf("run returned error: %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer from the retried turn, got %q", result.FinalAnswer)
}
// 4 provider calls: turn1, errored turn2, summarize, retry.
if len(provider.requests) != 4 {
t.Fatalf("expected 4 provider requests (turn1, errored turn2, summarize, retry), got %d", len(provider.requests))
}
retry := provider.requests[3]
// The retry must advertise the loaded deferred tool with its FULL schema —
// NOT re-hide it as the empty-loaded partition would.
var alpha *zeroruntime.ToolDefinition
for i := range retry.Tools {
if retry.Tools[i].Name == "mcp__srv__alpha" {
alpha = &retry.Tools[i]
}
if retry.Tools[i].Name == "mcp__srv__beta" {
t.Fatalf("retry must keep the never-loaded deferred tool hidden, got %#v", retry.Tools)
}
}
if alpha == nil {
t.Fatalf("retry must still advertise the loaded deferred tool mcp__srv__alpha, got %#v", retry.Tools)
}
if alpha.Parameters["type"] != "object" {
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
// The retry must carry the deferred-tools reminder as its trailing user
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
}
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
}
// The reminder must NOT persist into the returned message history, even on
// the reactive-retry path.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
// connectErrorProvider returns a connect-time error (StreamCompletion itself
// returns a non-nil error) on the request at index errAtRequest, exercising the
// FIRST reactive-recovery block in the loop (loop.go:99). Every other request
// streams the corresponding turn's events. It mirrors mockProvider's recording so
// the rebuilt retry request can be asserted.
type connectErrorProvider struct {
turns [][]zeroruntime.StreamEvent
errAtRequest int
errText string
requests []zeroruntime.CompletionRequest
}
func (provider *connectErrorProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
index := len(provider.requests) - 1
if index == provider.errAtRequest {
return nil, errors.New(provider.errText)
}
events := []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
if index < len(provider.turns) {
events = provider.turns[index]
}
ch := make(chan zeroruntime.StreamEvent, len(events))
for _, event := range events {
ch <- event
}
close(ch)
return ch, nil
}
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
// context-limit error (rather than a mid-stream StreamEventError). With deferral
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
// partition.
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
// A real, usable tool_search must be registered for deferral to activate
// (otherwise the loop falls back to eager so it never strands the loader).
registry.Register(tools.NewToolSearchTool(registry))
// Request indices:
// 0: turn 1 — calls load_signal (loads mcp__srv__alpha for later turns)
// 1: turn 2 — StreamCompletion returns a connect-time context-limit error
// 2: the summarize call inside Compact (must return non-empty text)
// 3: the RETRY request rebuilt after compaction — asserted below
provider := &connectErrorProvider{
errAtRequest: 1,
errText: "prompt is too long: 250000 tokens > 200000 maximum",
turns: [][]zeroruntime.StreamEvent{
{ // turn 1: call load_signal
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
nil, // index 1: replaced by the connect-time error
{ // index 2: summarize call inside Compact
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"},
{Type: zeroruntime.StreamEventDone},
},
{ // index 3: retry of turn 2 after compaction: final answer
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
bigPrompt := strings.Repeat("work on this task. ", 2000)
result, err := Run(context.Background(), bigPrompt, provider, Options{
Registry: registry,
DeferThreshold: 2, // 2 deferred tools => active
ContextWindow: 1_000_000,
CompactionPreserveLast: 2,
})
if err != nil {
t.Fatalf("run returned error: %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer from the retried turn, got %q", result.FinalAnswer)
}
// 4 provider calls: turn1, errored-connect turn2, summarize, retry.
if len(provider.requests) != 4 {
t.Fatalf("expected 4 provider requests (turn1, errored turn2, summarize, retry), got %d", len(provider.requests))
}
retry := provider.requests[3]
// The retry must advertise the loaded deferred tool with its FULL schema and
// keep the never-loaded one hidden.
var alpha *zeroruntime.ToolDefinition
for i := range retry.Tools {
if retry.Tools[i].Name == "mcp__srv__alpha" {
alpha = &retry.Tools[i]
}
if retry.Tools[i].Name == "mcp__srv__beta" {
t.Fatalf("retry must keep the never-loaded deferred tool hidden, got %#v", retry.Tools)
}
}
if alpha == nil {
t.Fatalf("retry must still advertise the loaded deferred tool mcp__srv__alpha, got %#v", retry.Tools)
}
if alpha.Parameters["type"] != "object" {
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
// The retry must carry the deferred-tools reminder as its trailing user message.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
}
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
}
// The reminder must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
}
}
}
// TestAllowlistedDeferredToolsKeepToolSearchReachable is the FIX 1+2 dead-end
// regression: N deferred tools are registered alongside tool_search, the operator
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
// the reminder never points the model at an unreachable tool.
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
registry := tools.NewRegistry()
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
for _, name := range deferredNames {
registry.Register(fakeDeferredTool{name: name, desc: name + " tool"})
}
// The REAL tool_search (so partitionTools can look it up by name and dispatch
// can run it through registry.RunWithOptions).
registry.Register(tools.NewToolSearchTool(registry))
options := Options{
DeferThreshold: len(deferredNames), // threshold == N => active
// Allowlist the deferred tools but NOT tool_search.
EnabledTools: append([]string{}, deferredNames...),
}
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
// Deferral active => non-empty reminder advertising the hidden deferred tools.
if reminder == "" {
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
}
if !strings.Contains(reminder, "tool_search") {
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
}
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
exposedNames := map[string]bool{}
for _, def := range exposed {
exposedNames[def.Name] = true
}
if !exposedNames["tool_search"] {
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
}
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
for _, name := range deferredNames {
if exposedNames[name] {
t.Fatalf("unloaded deferred tool %q must be hidden when active, got %#v", name, exposed)
}
}
// FIX 2(b): a tool_search call must be DISPATCH-ALLOWED (not rejected by the
// allowlist that omits it). It runs through the registry and reports a load.
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "tool_search", Arguments: `{"query":"select:mcp__srv__alpha"}`},
PermissionModeAuto,
options,
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
if result.Status != tools.StatusOK {
t.Fatalf("tool_search call was rejected: status=%s output=%q", result.Status, result.Output)
}
if result.DenialReason == DenialFiltered {
t.Fatalf("tool_search must not be denied by the allowlist, got DenialFiltered: %q", result.Output)
}
if len(result.LoadedTools) != 1 || result.LoadedTools[0] != "mcp__srv__alpha" {
t.Fatalf("tool_search call must load mcp__srv__alpha, got LoadedTools=%#v", result.LoadedTools)
}
}
// TestDisabledToolSearchStaysHiddenAndRejectedWhenActive verifies an explicit
// DisabledTools entry for tool_search is STILL honored on the active path: the
// loader is not exposed and a call to it is rejected (FIX 2 exempts the allowlist
// only, never the denylist).
func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
registry.Register(tools.NewToolSearchTool(registry))
options := Options{
DeferThreshold: 2,
DisabledTools: []string{"tool_search"},
}
// tool_search is explicitly disabled, so it can never run. Deferral must NOT
// activate — otherwise the loop would hide the deferred tools behind a loader
// the dispatch gate rejects (an inescapable dead-end). Expect the eager /
// inactive fallback: every deferred tool exposed with its full schema, no
// tool_search definition, and an empty reminder.
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
if reminder != "" {
t.Fatalf("inactive fallback must emit no reminder, got %q", reminder)
}
exposedNames := make(map[string]bool, len(exposed))
for _, def := range exposed {
exposedNames[def.Name] = true
}
if exposedNames["tool_search"] {
t.Fatalf("tool_search must not be advertised when disabled, got %#v", exposed)
}
if !exposedNames["mcp__srv__alpha"] || !exposedNames["mcp__srv__beta"] {
t.Fatalf("deferred tools must be exposed eagerly when deferral is inactive, got %#v", exposed)
}
// The deferred tools themselves stay directly callable (not stranded behind a
// disabled loader).
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "mcp__srv__alpha", Arguments: `{}`},
PermissionModeAuto,
options,
)
if abortErr != nil {
t.Fatalf("unexpected abort error: %v", abortErr)
}
if result.Status != tools.StatusOK {
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
}
}
+41
View File
@@ -0,0 +1,41 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
DisabledTools: []string{"read_file"},
})
if result.DenialReason != DenialFiltered {
t.Fatalf("DenialReason = %q, want %q", result.DenialReason, DenialFiltered)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
}
func TestDeniedPermissionResultCategories(t *testing.T) {
// Plain approval-declined.
plain := deniedPermissionResult(ToolCall{ID: "c1", Name: "bash"}, "needs approval", PermissionEvent{ToolName: "bash"})
if plain.DenialReason != DenialPermissionDenied {
t.Fatalf("plain denial = %q, want %q", plain.DenialReason, DenialPermissionDenied)
}
// Sandbox-violation-driven denial.
sandboxDenied := deniedPermissionResult(ToolCall{ID: "c2", Name: "bash"}, "outside workspace", PermissionEvent{
ToolName: "bash",
Violation: &sandbox.Violation{Code: "outside_workspace"},
})
if sandboxDenied.DenialReason != DenialSandboxViolation {
t.Fatalf("sandbox denial = %q, want %q", sandboxDenied.DenialReason, DenialSandboxViolation)
}
}
+318
View File
@@ -0,0 +1,318 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
// assignable field on Options with the agreed signature, and that nil is its
// zero value (escalation disabled by default).
func TestOptionsModelSwitcherFieldExists(t *testing.T) {
var options Options
if options.ModelSwitcher != nil {
t.Fatalf("expected ModelSwitcher to default to nil, got non-nil")
}
options.ModelSwitcher = func(_ context.Context, modelID string) (Provider, error) {
return &mockProvider{}, nil
}
provider, err := options.ModelSwitcher(context.Background(), "claude-sonnet-4.5")
if err != nil {
t.Fatal(err)
}
if _, ok := provider.(*mockProvider); !ok {
t.Fatalf("expected ModelSwitcher to return a Provider, got %T", provider)
}
}
// TestToolResultRequestedModelFieldExists asserts the loop-level escalation
// signal field is present on ToolResult and empty for a normal result.
func TestToolResultRequestedModelFieldExists(t *testing.T) {
var result ToolResult
if result.RequestedModel != "" {
t.Fatalf("expected RequestedModel to default to empty, got %q", result.RequestedModel)
}
result.RequestedModel = "claude-opus-4.1"
if result.RequestedModel != "claude-opus-4.1" {
t.Fatalf("expected RequestedModel to round-trip, got %q", result.RequestedModel)
}
// Keep the zeroruntime import load-bearing so the file compiles standalone.
_ = zeroruntime.MessageRoleAssistant
}
// escalatingTool is a registered fake tool that returns the escalation signal
// in result Meta (mirroring the real escalate_model tool's contract), used to
// drive the loop-level switch in tests without depending on the tools package.
type escalatingTool struct {
target string
}
func (t escalatingTool) Name() string { return "escalate" }
func (t escalatingTool) Description() string { return "requests a model switch for testing" }
func (t escalatingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t escalatingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t escalatingTool) Run(_ context.Context, _ map[string]any) tools.Result {
meta := map[string]string{}
if t.target != "" {
meta["escalate_to_model"] = t.target
}
return tools.Result{Status: tools.StatusOK, Output: "escalating", Meta: meta}
}
// TestExecuteToolCallLiftsEscalationMeta verifies executeToolCall promotes the
// tool's Meta["escalate_to_model"] into ToolResult.RequestedModel.
func TestExecuteToolCallLiftsEscalationMeta(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "escalate"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.RequestedModel != "claude-opus-4.1" {
t.Fatalf("expected RequestedModel lifted from meta, got %q", result.RequestedModel)
}
}
// TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty verifies a normal
// tool result (no escalate_to_model meta) leaves RequestedModel empty.
func TestExecuteToolCallNoEscalationMetaLeavesRequestedModelEmpty(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: ""})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "escalate"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.RequestedModel != "" {
t.Fatalf("expected empty RequestedModel without escalation meta, got %q", result.RequestedModel)
}
}
// escalateThenAnswerTurns builds a two-turn provider script: turn 1 calls the
// escalate tool, turn 2 returns a final answer. Reused by the switch tests.
func escalateThenAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
return [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: answer},
{Type: zeroruntime.StreamEventDone},
},
}
}
// TestRunSwitchesProviderOnEscalationRequest verifies that when a tool requests
// a model and a ModelSwitcher is wired, the loop swaps the active provider (the
// NEXT turn streams from the new provider) and updates options.Model.
func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstProvider := &mockProvider{turns: escalateThenAnswerTurns("done")}
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "answered on the upgraded model"},
{Type: zeroruntime.StreamEventDone},
},
}}
var switchedTo string
switchCount := 0
result, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
switchCount++
switchedTo = modelID
return secondProvider, nil
},
})
if err != nil {
t.Fatal(err)
}
if switchCount != 1 {
t.Fatalf("expected exactly one switch, got %d", switchCount)
}
if switchedTo != "claude-opus-4.1" {
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
}
// The first provider only handled the escalation turn (1 request); the second
// provider handled the answer turn (1 request) — proving the swap took effect.
if len(firstProvider.requests) != 1 {
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
}
if result.FinalAnswer != "answered on the upgraded model" {
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
}
}
// TestRunIgnoresEscalationWhenSwitcherNil verifies that without a ModelSwitcher
// the escalation request is ignored: the original provider serves every turn.
func TestRunIgnoresEscalationWhenSwitcherNil(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
provider := &mockProvider{turns: escalateThenAnswerTurns("done on original")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
// ModelSwitcher intentionally nil.
})
if err != nil {
t.Fatal(err)
}
// Both turns (escalate + answer) must run on the single original provider.
if len(provider.requests) != 2 {
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
}
if result.FinalAnswer != "done on original" {
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
}
}
// TestRunEscalationSwitcherErrorIsNonFatal verifies a ModelSwitcher error does
// not abort the run: the loop continues on the current provider and a note is
// recorded in the transcript.
func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return nil, errors.New("provider build blew up")
},
})
if err != nil {
t.Fatalf("expected escalation error to be non-fatal, got %v", err)
}
// The run continues on the same provider through the answer turn.
if len(provider.requests) != 2 {
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
}
// A brief note about the failed switch must reach the model on the next turn.
var sawNote bool
for _, m := range provider.requests[1].Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(m.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", provider.requests[1].Messages)
}
}
// TestRunEscalationSwitcherNilProviderKeepsOriginal verifies that when a
// ModelSwitcher returns (nil, nil) — no error, but also no replacement provider —
// the loop does NOT null out the active provider. The run must continue on the
// ORIGINAL provider and return a result rather than panic on a nil provider.
func TestRunEscalationSwitcherNilProviderKeepsOriginal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
provider := &mockProvider{turns: escalateThenAnswerTurns("answered on original")}
switchCount := 0
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
switchCount++
// No error, but no provider either: the loop must NOT swap to nil.
return nil, nil
},
})
if err != nil {
t.Fatalf("expected (nil,nil) switch to be non-fatal, got %v", err)
}
if switchCount != 1 {
t.Fatalf("expected exactly one switch attempt, got %d", switchCount)
}
// Both turns (escalate + answer) must run on the single original provider; if
// the loop nil'd out the provider on a (nil,nil) return, the second turn would
// panic or never run.
if len(provider.requests) != 2 {
t.Fatalf("expected the original provider to serve both turns, got %d requests", len(provider.requests))
}
if result.FinalAnswer != "answered on original" {
t.Fatalf("expected final answer from the original provider, got %q", result.FinalAnswer)
}
}
// TestRunSwitchesAtMostOncePerTurn verifies that when a single turn carries two
// escalation requests, only the first triggers a switch.
func TestRunSwitchesAtMostOncePerTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
{Type: zeroruntime.StreamEventDone},
},
}}
secondProvider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
switchCount := 0
if _, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
switchCount++
return secondProvider, nil
},
}); err != nil {
t.Fatal(err)
}
if switchCount != 1 {
t.Fatalf("expected at most one switch per turn, got %d", switchCount)
}
}
+6 -4
View File
@@ -212,7 +212,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // single tool call
textTurn("done"), // immediately answers
textTurn("done"), // immediately answers
},
}
@@ -332,9 +332,11 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
type alwaysFailingTool struct{}
func (alwaysFailingTool) Name() string { return "flaky" }
func (alwaysFailingTool) Description() string { return "always fails for testing" }
func (alwaysFailingTool) Parameters() tools.Schema { return tools.Schema{Type: "object", AdditionalProperties: false} }
func (alwaysFailingTool) Name() string { return "flaky" }
func (alwaysFailingTool) Description() string { return "always fails for testing" }
func (alwaysFailingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysFailingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
+399 -198
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
_ "embed"
"encoding/json"
"errors"
"sort"
@@ -14,9 +13,13 @@ import (
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const defaultSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const (
toolResultMetaControl = "control"
toolResultControlSpecReview = "spec_review_required"
)
// abortedToolResultNotice is the placeholder tool result recorded for a tool
// call that was advertised by the assistant turn but never executed because the
// repeated-failure guard halted the run first. It keeps every tool_use paired
@@ -30,46 +33,20 @@ const abortedToolResultNotice = "aborted: run halted by the repeated-failure gua
const droppedToolCallNotice = "Your previous tool call was malformed (it was missing a tool name) and was not executed. " +
"Re-issue the tool call with a valid tool name and JSON arguments, or reply with your final answer."
// maxTaskDepth caps sub-agent (task) recursion. The top-level run is Depth 0, so
// with maxTaskDepth = 2 a parent (0) may spawn a child (1) which may spawn a
// grandchild (2); a task call AT Depth 2 is refused. This bounds the worst-case
// fan-out and the call stack without forbidding useful one- or two-level
// delegation.
const maxTaskDepth = 2
// escalationFailedNoticePrefix introduces the brief, user-role note recorded
// when a requested mid-run model switch could not be performed (the
// ModelSwitcher returned an error). The run continues on the current model;
// escalation is best-effort, never fatal.
const escalationFailedNoticePrefix = "Note: could not switch to the requested model"
// maxTaskDepthAnswer is returned as the task tool result when the depth guard
// trips, so the model gets a clear, actionable message instead of a silent stop.
const maxTaskDepthAnswer = "Error: max sub-agent depth reached; cannot spawn another sub-agent. Complete this work directly."
// confirmationPolicy is the de-branded safety policy appended to the system
// prompt so the model self-polices before risky actions. It mirrors the
// sandbox's enforced rules but applies model-side judgement first.
//
//go:embed confirmation_policy.md
var confirmationPolicy string
// buildSystemPrompt returns the base instruction with the confirmation policy
// appended. It is built once per run so every turn shares the same system turn.
func buildSystemPrompt() string {
policy := strings.TrimSpace(confirmationPolicy)
if policy == "" {
return defaultSystemPrompt
}
return defaultSystemPrompt + "\n\n" + policy
}
// The system prompt (core coding-craft instructions + workspace context + safety
// confirmation policy) is assembled in system_prompt.go via buildSystemPrompt.
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
if provider == nil {
return Result{}, errors.New("agent provider is required")
}
// Inject the provider so executeToolCall can spawn a sub-agent (task) child
// run with the same provider, without changing Run's signature or callers.
// A caller that explicitly set Provider (e.g. a custom child provider) wins.
if options.Provider == nil {
options.Provider = provider
}
maxTurns := options.MaxTurns
if maxTurns <= 0 {
maxTurns = 12
@@ -85,11 +62,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
permissionMode = PermissionModeAuto
}
messages := zeroruntime.SeedMessages(buildSystemPrompt(), prompt)
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
guards := newGuardState()
compactor := newCompactionState(options)
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
loaded := map[string]bool{}
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
@@ -99,9 +81,25 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// request. A no-op when ContextWindow == 0 (compaction disabled).
messages = compactor.maybeCompact(ctx, provider, messages)
exposed, reminder := partitionTools(registry, permissionMode, options, loaded)
request := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefinitions(registry, permissionMode, options),
Tools: exposed,
}
if reminder != "" {
// Append to the per-turn request copy only — NEVER to persistent
// messages — so the reminder refreshes each turn and never accumulates
// in the saved transcript.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Report the per-category context budget for this turn so a surface can
// show utilization. Opt-in: a no-op when OnContext is unset.
if options.OnContext != nil {
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
}
stream, err := provider.StreamCompletion(ctx, request)
@@ -114,9 +112,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryErr
}
// Rebuild from the compacted messages but reuse the SAME active-mode
// partition (exposed) and reminder computed for this turn: they depend
// on registry+loaded, not on the messages, so they stay valid after
// compaction. Using the bare toolDefinitions here would route through an
// empty-loaded partition, re-hiding every already-loaded deferred tool
// and dropping the reminder when deferral is active.
request = zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefinitions(registry, permissionMode, options),
Tools: exposed,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
stream, err = provider.StreamCompletion(ctx, request)
}
@@ -140,9 +152,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryErr
}
// Reuse the SAME active-mode partition (exposed) and reminder from this
// turn rather than the bare toolDefinitions: exposed/reminder depend on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool and drop the reminder when deferral is active.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefinitions(registry, permissionMode, options),
Tools: exposed,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
retryRequest.Messages = append(retryRequest.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
retryStream, retryStreamErr := provider.StreamCompletion(ctx, retryRequest)
if retryStreamErr != nil {
@@ -216,23 +241,62 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards.observeTurn(collected)
failureHint := ""
// turnRequestedModel records the FIRST mid-run escalation target requested
// during this turn's tool batch. The actual provider switch happens once,
// after the batch, so every tool_result is recorded first and at most one
// switch occurs per turn.
turnRequestedModel := ""
for index, call := range collected.ToolCalls {
if options.OnToolCall != nil {
options.OnToolCall(call)
}
toolResult := executeToolCall(ctx, registry, call, permissionMode, options)
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
// Union the deferred tools this result asked to load into the per-run
// set BEFORE any abort/stop/guard branch, so a load that coincides with
// a turn-ending result is still recorded for the next turn's partition.
for _, name := range toolResult.LoadedTools {
loaded[name] = true
}
if turnRequestedModel == "" && toolResult.RequestedModel != "" {
turnRequestedModel = toolResult.RequestedModel
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.Output,
ToolCallID: toolResult.ToolCallID,
})
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
// returns context.Canceled rather than fabricating a headless answer. Stop
// promptly with that error (or run-context cancellation), keeping messages
// valid by closing out the still-advertised calls first.
if abortErr == nil && ctx.Err() != nil {
abortErr = ctx.Err()
}
if abortErr != nil {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
result.Messages = copyMessages(messages)
return result, abortErr
}
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
result.FinalAnswer = toolResult.Output
result.StopReason = stopReason
result.Messages = copyMessages(messages)
return result, nil
}
// Repeated-failure guard: if a tool keeps failing the same way, hint
// once (with its schema) then halt — so no model loops on a bad call.
outcome := guards.observeToolResult(call.Name, toolResult.Status == tools.StatusError, toolResult.Output)
// Only RETRIABLE failures (bad arguments / execution errors) drive it:
// policy refusals (disabled tool, permission denial, sandbox violation)
// aren't fixed by reformatting the call, so a "match this schema" hint
// would misdirect the model toward JSON shape or blocked behavior.
retriableFailure := isRetriableToolError(toolResult)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
if outcome.Stop {
// The assistant message advertised EVERY collected tool call, but
// the guard halts mid-turn so the calls after this one never run.
@@ -250,6 +314,32 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Mid-run model escalation: if a tool asked to switch models this turn and
// a switcher is wired, rebuild the provider on the new model for the rest
// of the run. At most one switch per turn (first request wins). A switcher
// error is NON-FATAL — record a brief note and continue on the current
// model. nil switcher ⇒ requests are ignored entirely (escalation off).
if turnRequestedModel != "" && options.ModelSwitcher != nil {
newProvider, switchErr := options.ModelSwitcher(ctx, turnRequestedModel)
if switchErr != nil {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: escalationFailedNoticePrefix + " (" + turnRequestedModel + "): " + switchErr.Error() + ". Continuing on " + options.Model + ".",
})
} else if newProvider != nil {
// Reassign the local provider so the next turn's StreamCompletion and
// compaction use it; update options.Model so subsequent RunOptions.Model,
// context-window sizing, and usage attribution follow the new model.
provider = newProvider
options.Model = turnRequestedModel
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs a ModelSwitcher contract
// change (return the new window) — out of scope for this change.
}
}
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
// calls executed above; surface the dropped call too so it is never
// silently ignored just because the turn also did real work. This is
@@ -297,7 +387,11 @@ func toolSchemaJSON(registry *tools.Registry, name string) string {
return string(data)
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) ToolResult {
// executeToolCall runs one tool call and returns its result. The second return
// is a non-nil ABORT error only when the call demands the whole run stop (a
// canceled/timed-out ask_user prompt) rather than continuing — every ordinary
// success or tool error returns a nil abort error.
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
args := map[string]any{}
if call.Arguments != "" {
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
@@ -306,35 +400,42 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Failed to parse arguments for " + call.Name + ": " + err.Error(),
}
}, nil
}
}
if !ToolAllowedByFilters(call.Name, options.EnabledTools, options.DisabledTools) {
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
// reminder points the model at a tool the dispatch gate rejects (an inescapable
// dead-end). The allowlist is exempted; an explicit DisabledTools entry for
// tool_search is still honored (only the allowlist is exempted, not the denylist).
toolSearchAllowed := call.Name == tools.ToolSearchToolName && !containsToolName(options.DisabledTools, tools.ToolSearchToolName)
if !toolSearchAllowed && !ToolAllowedByFilters(call.Name, options.EnabledTools, options.DisabledTools) {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not enabled for this run.`,
}
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not enabled for this run.`,
DenialReason: DenialFiltered,
}, nil
}
tool, toolFound := registry.Get(call.Name)
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
DenialReason: DenialFiltered,
}, nil
}
// ask_user is intercepted in the loop (like permissions) so the question can
// be routed to an interactive front-end instead of blocking inside the tool.
// When no front-end is wired up it degrades to the tool's own graceful Run().
if call.Name == "ask_user" {
return executeAskUser(ctx, registry, call, args, options)
return executeAskUser(ctx, registry, call, args, permissionMode, options)
}
// task is intercepted here too: a normal tool's Run() has no access to the
// provider/registry needed to spawn a child agent run. When a provider is
// available and we are under the depth cap we run a synchronous sub-agent;
// otherwise it falls through to the tool's own graceful Run() (e.g. the
// "sub-agents are unavailable" fallback or the depth-limit error).
if call.Name == "task" {
return executeTask(ctx, registry, call, args, permissionMode, options)
}
tool, toolFound := registry.Get(call.Name)
permissionGranted := permissionMode == PermissionModeUnsafe
if toolFound && tool.Safety().Permission == tools.PermissionAllow {
permissionGranted = true
@@ -367,13 +468,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
grant, err := persistPermissionGrant(call.Name, decisionReason, options)
if err != nil {
emitDeniedPermission(options, call, requestEvent, "failed to persist permission grant: "+err.Error())
return deniedPermissionResult(call, "failed to persist permission grant: "+err.Error(), requestEvent)
return deniedPermissionResult(call, "failed to persist permission grant: "+err.Error(), requestEvent), nil
}
requestEvent.GrantMatched = true
requestEvent.Grant = &grant
default:
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
}
}
@@ -382,6 +483,16 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: call.ID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
// The sandbox decision (if any) is returned synchronously on the Result and
// used here for permission event building.
})
@@ -404,7 +515,42 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
// the Run turn loop performs the actual provider switch. Empty for every
// ordinary tool result.
RequestedModel: result.Meta["escalate_to_model"],
}, nil
}
// isRetriableToolError reports whether a failed tool result is one the model can
// plausibly fix by changing its next call (argument or execution failure), as
// opposed to a policy refusal (disabled tool, permission denial, sandbox
// violation) that no reformatting will satisfy. Only retriable failures should
// drive the repeated-failure schema hint / stop.
func isRetriableToolError(result ToolResult) bool {
if result.Status != tools.StatusError {
return false
}
// A categorized denial (filtered / permission / sandbox) is a policy decision,
// not a transient failure — never retry it. This is robust to message wording
// (the text checks below remain as a fallback for results lacking the field).
if result.DenialReason != DenialNone {
return false
}
if result.Meta["permission_action"] == string(PermissionActionDeny) {
return false
}
switch {
case strings.Contains(result.Output, "is not enabled for this run"),
strings.Contains(result.Output, "Permission denied for "),
strings.Contains(result.Output, "Permission required for "),
strings.Contains(result.Output, "Sandbox violation"),
strings.Contains(result.Output, "Sandbox approval required for "):
return false
}
return true
}
// scrubInterceptedOutput mirrors the registry's scrubResultSecrets boundary for
@@ -419,125 +565,14 @@ func scrubInterceptedOutput(output string) (string, bool) {
return scrubbed, scrubbed != output
}
// executeTask spawns a synchronous sub-agent run for a task tool call. It is the
// task-tool counterpart to executeAskUser: the loop intercepts the call because
// a normal tool's Run() cannot reach the provider/registry needed to start a
// child run. The child shares ctx (so cancellation propagates) and inherits the
// permission mode and sandbox, but runs with NO interactive callbacks — a
// sub-agent must never prompt the user or ask questions. Recursion is bounded by
// the depth guard and by stripping "task" from the child registry.
func executeTask(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) ToolResult {
request, err := tools.ParseTaskRequest(args)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Invalid arguments for task: " + err.Error(),
}
}
// No nil-provider fallback here: Run() rejects a nil provider and injects it
// into options before the loop runs, so options.Provider is always non-nil by
// the time executeTask is reached. (Unlike ask_user, which genuinely degrades
// when OnAskUser is nil, the task path has no headless degradation to fall
// back to.)
// Depth guard: refuse to spawn beyond the cap so a sub-agent can't recurse
// indefinitely. This is an error result (not a hard loop stop) so the parent
// model can recover by doing the work itself.
if options.Depth >= maxTaskDepth {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: maxTaskDepthAnswer,
}
}
// Build the child run's options from the parent's, then isolate it:
// - Depth+1 so the guard counts nesting.
// - Registry without "task" (no infinite nesting) and "ask_user" (a
// sub-agent has no interactive user to prompt).
// - Interactive/activity callbacks cleared so the sub-run is fully headless.
// - OnUsage IS propagated: it is non-interactive cost telemetry, not
// user-facing tool activity, so the child's real token spend must roll up
// to whoever tracks cost on the parent run.
// Permission mode + sandbox + autonomy are inherited so the child enforces
// the same safety policy.
childRegistry := options.Registry
if childRegistry == nil {
childRegistry = registry
}
if childRegistry != nil {
childRegistry = childRegistry.Without("task", "ask_user")
}
childOptions := Options{
MaxTurns: options.MaxTurns,
ContextWindow: options.ContextWindow,
CompactionPreserveLast: options.CompactionPreserveLast,
Provider: options.Provider,
Depth: options.Depth + 1,
Registry: childRegistry,
PermissionMode: permissionMode,
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
// OnUsage is intentionally propagated so a sub-agent's token spend is
// counted for cost accounting (cli/exec.go and tui/model.go record usage
// via OnUsage). It is telemetry, not interactive output.
OnUsage: options.OnUsage,
// Other interactive + observer callbacks (OnText, OnToolCall,
// OnToolResult, OnPermission*, OnAskUser) intentionally left nil: the
// sub-run must not prompt the user, ask questions, or surface its inner
// tool activity as if it were the parent's.
}
childResult, runErr := Run(ctx, request.Prompt, options.Provider, childOptions)
if runErr != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: sub-agent failed: " + runErr.Error(),
Display: tools.Display{Kind: "task", Summary: taskDisplaySummary(request, "failed")},
}
}
// Scrub the child's final answer through the same redaction boundary the
// registry applies to tool output, so a secret a sub-agent surfaced never
// lands in the parent transcript unredacted.
output, redacted := scrubInterceptedOutput(childResult.FinalAnswer)
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusOK,
Output: output,
Redacted: redacted,
Display: tools.Display{Kind: "task", Summary: taskDisplaySummary(request, "")},
}
}
// taskDisplaySummary builds a short, human-readable label for a task tool result.
func taskDisplaySummary(request tools.TaskRequest, suffix string) string {
label := request.Description
if label == "" {
label = "sub-agent"
}
summary := "ran sub-agent: " + label
if suffix != "" {
summary += " (" + suffix + ")"
}
return summary
}
// executeAskUser routes an ask_user call to the interactive front-end via
// options.OnAskUser, mirroring the async permission flow. If no handler is set,
// or the handler errors, it falls back to the tool's own graceful result so the
// loop never blocks forever waiting on a user who isn't there.
func executeAskUser(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, options Options) ToolResult {
// executeAskUser returns (result, abortErr). abortErr is non-nil ONLY when the
// prompt was canceled/timed out and the run must stop with that error; every
// other path (success, bad args, headless degradation) returns a nil abortErr.
func executeAskUser(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
questions, err := tools.ParseAskUserQuestions(args)
if err != nil {
return ToolResult{
@@ -545,11 +580,11 @@ func executeAskUser(ctx context.Context, registry *tools.Registry, call ToolCall
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Invalid arguments for ask_user: " + err.Error(),
}
}, nil
}
if options.OnAskUser == nil {
return askUserFallbackResult(ctx, registry, call, args)
return askUserFallbackResult(ctx, registry, call, args, permissionMode, options), nil
}
header, _ := args["header"].(string)
@@ -560,7 +595,21 @@ func executeAskUser(ctx context.Context, registry *tools.Registry, call ToolCall
}
response, err := options.OnAskUser(ctx, request)
if err != nil {
return askUserFallbackResult(ctx, registry, call, args)
// A canceled / timed-out prompt must ABORT the run (return the error), not
// fabricate a headless answer that keeps mutating the transcript after the UI
// asked to stop. The error result carries the reason; the abort error stops
// the loop even when the run context itself was not canceled.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: ask_user canceled: " + err.Error(),
}, err
}
// Genuine handler unavailability (no interactive surface / non-cancel error):
// degrade to the same headless path as a missing handler.
return askUserFallbackResult(ctx, registry, call, args, permissionMode, options), nil
}
// Scrub the formatted answers through the same redaction boundary the
@@ -573,20 +622,38 @@ func executeAskUser(ctx context.Context, registry *tools.Registry, call ToolCall
Status: tools.StatusOK,
Output: output,
Redacted: redacted,
}
}, nil
}
// askUserFallbackResult runs the registered ask_user tool (or returns the shared
// graceful message) so the no-interactive-user path matches the headless path.
func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any) ToolResult {
// graceful message) so the no-interactive-user path matches every OTHER tool: it
// goes through registry.RunWithOptions with the same run context (ToolCallID,
// session/model/depth/cwd, sandbox/permission) and copies the full result fields
// (Meta/ChangedFiles/Display), instead of the bare registry.Run that dropped them.
func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) ToolResult {
if _, ok := registry.Get(call.Name); ok {
result := registry.Run(ctx, call.Name, args)
result := registry.RunWithOptions(ctx, call.Name, args, tools.RunOptions{
// ask_user is read-only (PermissionAllow); no prompt/grant is required.
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: call.ID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Redacted: result.Redacted,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
}
}
return ToolResult{
@@ -704,11 +771,18 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
if requestEvent.ToolName == "" {
event.ToolName = call.Name
}
// A denial driven by a sandbox violation is categorized distinctly from a
// plain approval-declined so a surface can tell policy from user choice.
denial := DenialPermissionDenied
if requestEvent.Violation != nil {
denial = DenialSandboxViolation
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Permission denied for " + call.Name + ": " + reason,
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Permission denied for " + call.Name + ": " + reason,
DenialReason: denial,
Meta: map[string]string{
"permission_action": string(event.Action),
},
@@ -829,24 +903,102 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
}
}
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
// partitionTools builds the per-turn advertised tool list and an optional
// deferred-tools reminder. INACTIVE (DeferThreshold <= 0 or the eligible count is
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
// (dropped so it is never advertised when it cannot help), and the reminder is
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
// tool is exposed only when loaded[name]; otherwise it is hidden and its compact
// line goes into the reminder. Non-deferred tools (including tool_search) are
// always exposed. The exposed slice is alpha-sorted by name, matching the legacy
// order so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
registeredTools := registry.All()
definitions := make([]zeroruntime.ToolDefinition, 0, len(registeredTools))
visible := make([]tools.Tool, 0, len(registeredTools))
eligible := 0
for _, tool := range registeredTools {
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
continue
}
visible = append(visible, tool)
if tools.IsDeferred(tool) {
eligible++
}
}
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
var hiddenLines []string
for _, tool := range visible {
name := tool.Name()
deferred := tools.IsDeferred(tool)
if !active {
// Inactive: byte-identical to legacy, but tool_search is never advertised.
if name == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
continue
}
// Active path.
if deferred && !loaded[name] {
hiddenLines = append(hiddenLines, tools.DeferredLine(tool))
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: tool.Name(),
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
}
// On the ACTIVE path tool_search is guaranteed runnable (active implies
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
// EnabledTools allowlist omits it (the operator allowlisted the deferred
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
if active && !exposedNames[tools.ToolSearchToolName] {
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: loader.Description(),
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions
reminder := ""
if active {
reminder = tools.BuildDeferredReminder(hiddenLines)
}
return definitions, reminder
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
@@ -908,6 +1060,9 @@ func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
if property.Default != nil {
schema["default"] = property.Default
}
if property.Items != nil {
schema["items"] = propertyToRuntimeMap(*property.Items)
}
if property.Minimum != nil {
schema["minimum"] = *property.Minimum
}
@@ -934,12 +1089,55 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
if tool.Safety().Permission == tools.PermissionDeny {
return false
}
if permissionMode == PermissionModeSpecDraft {
return toolAdvertisedInSpecDraft(tool)
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
}
return true
}
func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
switch tool.Name() {
case "ask_user", "submit_spec":
return true
case "update_plan":
return false
}
safety := tool.Safety()
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}
func stopReasonFromToolResult(result ToolResult) StopReason {
if result.Meta == nil {
return ""
}
if result.Meta[toolResultMetaControl] == toolResultControlSpecReview {
return StopReasonSpecReviewRequired
}
return ""
}
// loadedToolsFromResult extracts the deferred-tool names a tool (tool_search)
// asked the loop to expose next turn from Meta["load_tools"] (comma-separated).
// It trims each name and drops empties; returns nil when the key is absent or
// yields no names, so an ordinary result keeps a nil LoadedTools. Mirrors the
// Meta-driven control signal read by stopReasonFromToolResult.
func loadedToolsFromResult(meta map[string]string) []string {
raw := meta["load_tools"]
if strings.TrimSpace(raw) == "" {
return nil
}
var names []string
for _, part := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
names = append(names, trimmed)
}
}
return names
}
// appendAbortedToolResults adds a placeholder tool-result message for each of
// the given (unexecuted) tool calls, so every advertised tool_use keeps a
// matching tool_result when the loop halts a turn before all calls have run.
@@ -961,6 +1159,9 @@ func copyMessages(messages []Message) []Message {
if message.ToolCalls != nil {
copied[index].ToolCalls = append([]ToolCall{}, message.ToolCalls...)
}
// Deep-copy image attachments (slice AND each Data byte slice) so the
// raw image bytes are never aliased across history/request/result copies.
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
}
return copied
}
+87
View File
@@ -0,0 +1,87 @@
package agent
import (
"bytes"
"context"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// imageEchoProvider records the messages of the first request it receives, then
// returns an empty final answer so the loop terminates after one turn.
type imageEchoProvider struct {
seen []zeroruntime.Message
}
func (p *imageEchoProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
if p.seen == nil {
p.seen = append([]zeroruntime.Message{}, request.Messages...)
}
events := make(chan zeroruntime.StreamEvent)
close(events)
return events, nil
}
func TestRunSeedsImagesIntoUserTurn(t *testing.T) {
provider := &imageEchoProvider{}
images := []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{0x89, 0x50}}}
if _, err := Run(context.Background(), "look at this", provider, Options{
MaxTurns: 1,
Images: images,
}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if len(provider.seen) < 2 {
t.Fatalf("provider saw %d messages, want >= 2", len(provider.seen))
}
user := provider.seen[len(provider.seen)-1]
if user.Role != zeroruntime.MessageRoleUser {
t.Fatalf("last seeded message role = %q, want user", user.Role)
}
if len(user.Images) != 1 || user.Images[0].MediaType != "image/png" {
t.Fatalf("user.Images = %#v, want one image/png block", user.Images)
}
}
// TestCopyMessagesDeepCopiesImageBytes locks the anti-aliasing guarantee for
// copyMessages: copies must carry INDEPENDENT image bytes, so mutating the
// source message's Data never bleeds into a history/request/result copy.
func TestCopyMessagesDeepCopiesImageBytes(t *testing.T) {
source := []Message{
{
Role: zeroruntime.MessageRoleUser,
Content: "look",
Images: []zeroruntime.ImageBlock{
{MediaType: "image/png", Data: []byte{0x89, 0x50, 0x4e, 0x47}},
},
},
}
copied := copyMessages(source)
if len(copied) != 1 || len(copied[0].Images) != 1 {
t.Fatalf("unexpected copy shape: %#v", copied)
}
// Mutating the source bytes must not change the copy.
source[0].Images[0].Data[0] = 0x00
if !bytes.Equal(copied[0].Images[0].Data, []byte{0x89, 0x50, 0x4e, 0x47}) {
t.Fatalf("copy image bytes aliased the source: %v", copied[0].Images[0].Data)
}
if &source[0].Images[0].Data[0] == &copied[0].Images[0].Data[0] {
t.Fatal("copy Data shares backing array with source")
}
}
func TestRunWithoutImagesSeedsNilImages(t *testing.T) {
provider := &imageEchoProvider{}
if _, err := Run(context.Background(), "hello", provider, Options{MaxTurns: 1}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
user := provider.seen[len(provider.seen)-1]
if user.Images != nil {
t.Fatalf("user.Images = %#v, want nil for text-only run", user.Images)
}
}
+262 -53
View File
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -34,6 +35,32 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
func TestIsRetriableToolError(t *testing.T) {
cases := []struct {
name string
result ToolResult
want bool
}{
{"success", ToolResult{Status: tools.StatusOK}, false},
{"bad arguments", ToolResult{Status: tools.StatusError, Output: "Error: Failed to parse arguments for x: bad json"}, true},
{"execution failure", ToolResult{Status: tools.StatusError, Output: "Error: read foo.txt: no such file"}, true},
{"disabled tool", ToolResult{Status: tools.StatusError, Output: `Error: Tool "x" is not enabled for this run.`}, false},
{"permission denied (meta)", ToolResult{Status: tools.StatusError, Output: "Error: Permission denied for x: needs approval", Meta: map[string]string{"permission_action": "deny"}}, false},
{"permission required", ToolResult{Status: tools.StatusError, Output: "Error: Permission required for x: approve first"}, false},
{"sandbox violation", ToolResult{Status: tools.StatusError, Output: "Error: Sandbox violation: outside_workspace"}, false},
{"sandbox approval", ToolResult{Status: tools.StatusError, Output: "Error: Sandbox approval required for x: network"}, false},
// Structured denial categories are authoritative regardless of message text.
{"denial: filtered", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialFiltered}, false},
{"denial: permission", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialPermissionDenied}, false},
{"denial: sandbox", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialSandboxViolation}, false},
}
for _, c := range cases {
if got := isRetriableToolError(c.result); got != c.want {
t.Errorf("%s: isRetriableToolError = %v, want %v", c.name, got, c.want)
}
}
}
func TestRunReturnsProviderText(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
@@ -162,6 +189,32 @@ func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
}
}
func TestRunAdvertisesWebFetchInAutoMode(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewWebFetchTool())
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}},
}
_, err := Run(context.Background(), "what tools exist?", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 1 {
t.Fatalf("expected one request, got %d", len(provider.requests))
}
if len(provider.requests[0].Tools) != 1 || provider.requests[0].Tools[0].Name != "web_fetch" {
t.Fatalf("expected web_fetch to be advertised in auto mode, got %#v", provider.requests[0].Tools)
}
}
func TestRunFiltersAdvertisedTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -739,8 +792,11 @@ func TestRunAppendsConfirmationPolicyToSystemPrompt(t *testing.T) {
if system.Role != zeroruntime.MessageRoleSystem {
t.Fatalf("expected first message to be system, got %s", system.Role)
}
if !strings.Contains(system.Content, defaultSystemPrompt) {
t.Fatalf("system prompt lost base instruction: %q", system.Content)
// The overhauled core prompt: identity + the mandatory testing gate.
for _, marker := range []string{"You are Zero", "Testing gate"} {
if !strings.Contains(system.Content, marker) {
t.Fatalf("system prompt missing core marker %q: %q", marker, system.Content)
}
}
// Key markers from CONFIRMATION_POLICY.md must be present so the model self-polices.
for _, marker := range []string{"Confirmation Modes", "BLOCKED", "ALWAYS CONFIRM"} {
@@ -751,13 +807,213 @@ func TestRunAppendsConfirmationPolicyToSystemPrompt(t *testing.T) {
}
func TestSystemPromptEmbedsConfirmationPolicy(t *testing.T) {
prompt := buildSystemPrompt()
if !strings.HasPrefix(prompt, defaultSystemPrompt) {
t.Fatalf("system prompt should start with the base instruction, got %q", prompt)
prompt := buildSystemPrompt(Options{})
if !strings.HasPrefix(prompt, "You are Zero") {
t.Fatalf("system prompt should start with the core instructions, got %q", prompt)
}
if !strings.Contains(prompt, "Confirmation Modes") {
t.Fatalf("embedded confirmation policy missing from system prompt")
}
// No workspace context without a cwd (keeps headless/test runs deterministic).
if strings.Contains(prompt, "<environment>") {
t.Fatalf("system prompt should omit the environment block when cwd is unset")
}
}
func TestGitBranchForPromptResolvesRelativeWorktreeGitdir(t *testing.T) {
root := t.TempDir()
// The real gitdir for the worktree, where HEAD lives.
gitdir := filepath.Join(root, "realgit", "worktrees", "wt")
if err := os.MkdirAll(gitdir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gitdir, "HEAD"), []byte("ref: refs/heads/feature-x\n"), 0o644); err != nil {
t.Fatal(err)
}
// The worktree checkout: a .git FILE pointing at the gitdir via a RELATIVE path.
worktree := filepath.Join(root, "checkout")
if err := os.MkdirAll(worktree, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: ../realgit/worktrees/wt\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := gitBranchForPrompt(worktree); got != "feature-x" {
t.Fatalf("gitBranchForPrompt = %q, want feature-x (relative worktree gitdir resolved against cwd)", got)
}
}
func TestBuildSystemPromptInjectsWorkspaceContext(t *testing.T) {
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Always run `make lint` before committing."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
if !strings.Contains(prompt, "<environment>") || !strings.Contains(prompt, "Working directory: "+cwd) {
t.Fatalf("expected environment block with cwd, got %q", prompt)
}
if !strings.Contains(prompt, "Project guidelines (AGENTS.md)") || !strings.Contains(prompt, "make lint") {
t.Fatalf("expected AGENTS.md project guidelines injected, got %q", prompt)
}
}
func TestBuildSystemPromptAllowsSpecModeOverride(t *testing.T) {
prompt := buildSystemPrompt(Options{SystemPrompt: specmode.DraftSystemPrompt})
if !strings.HasPrefix(prompt, "Specification drafting is active.") {
t.Fatalf("expected spec prompt override, got %q", prompt)
}
if !strings.Contains(prompt, "Confirmation Modes") {
t.Fatalf("expected confirmation policy to remain appended")
}
}
func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
specmode.RegisterDraftTools(registry, root, nil)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}},
}
_, err := Run(context.Background(), "draft", provider, Options{
Registry: registry,
PermissionMode: PermissionModeSpecDraft,
})
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, definition := range provider.requests[0].Tools {
names[definition.Name] = true
}
for _, want := range []string{"read_file", "list_directory", "glob", "grep", "skill", "ask_user", specmode.SubmitToolName} {
if !names[want] {
t.Fatalf("spec draft tools missing %q from %#v", want, names)
}
}
for _, denied := range []string{"write_file", "edit_file", "apply_patch", "bash", "update_plan", "web_fetch"} {
if names[denied] {
t.Fatalf("spec draft advertised denied tool %q in %#v", denied, names)
}
}
}
func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("done")
result, err := Run(context.Background(), "draft", provider, Options{
Registry: registry,
PermissionMode: PermissionModeSpecDraft,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in spec-draft mode") {
t.Fatalf("expected spec-draft denial, got %q", denied)
}
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
t.Fatalf("write_file should not have written notes.txt, stat err=%v", err)
}
}
func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"printf ran > ran.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "draft", provider, Options{
Registry: registry,
PermissionMode: PermissionModeSpecDraft,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
}
var denied string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
denied = message.Content
break
}
}
if !strings.Contains(denied, "not available in spec-draft mode") {
t.Fatalf("expected spec-draft bash denial, got %q", denied)
}
if _, err := os.Stat(filepath.Join(root, "ran.txt")); !os.IsNotExist(err) {
t.Fatalf("bash should not have written ran.txt, stat err=%v", err)
}
}
func TestRunStopsWhenSubmitSpecReturnsReviewControl(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
specmode.RegisterDraftTools(registry, root, nil)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "exit-1", ToolName: specmode.SubmitToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "exit-1", ArgumentsFragment: `{"title":"Implementation Plan","plan":"# Goal\n\nAdd implementation plan."}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "exit-1"},
{Type: zeroruntime.StreamEventDone},
}},
}
result, err := Run(context.Background(), "draft", provider, Options{
Registry: registry,
PermissionMode: PermissionModeSpecDraft,
MaxTurns: 3,
})
if err != nil {
t.Fatal(err)
}
if result.StopReason != StopReasonSpecReviewRequired {
t.Fatalf("StopReason = %q, want %q", result.StopReason, StopReasonSpecReviewRequired)
}
if len(provider.requests) != 1 {
t.Fatalf("expected run to stop after submit_spec, got %d requests", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, ".zero/specs/") {
t.Fatalf("final answer should mention saved spec, got %q", result.FinalAnswer)
}
}
func assertMessage(t *testing.T, message zeroruntime.Message, role zeroruntime.MessageRole, contentContains string) {
@@ -876,53 +1132,6 @@ func TestRunSurfacesDroppedToolCallAlongsideValidCall(t *testing.T) {
}
}
// TestRunPropagatesSubAgentUsageToParentOnUsage verifies that token usage a
// sub-agent (task) child run reports is surfaced to the PARENT run's OnUsage
// callback, so cost accounting that tracks via OnUsage does not count every
// sub-agent run as zero tokens.
func TestRunPropagatesSubAgentUsageToParentOnUsage(t *testing.T) {
const childPrompt = "do the costed sub work"
registry := tools.NewRegistry()
registry.Register(tools.NewTaskTool())
registry.Register(tools.NewReadFileTool(t.TempDir()))
provider := &routingProvider{
parentSubstr: "delegate with cost",
parentTurns: [][]zeroruntime.StreamEvent{
taskCallTurn(childPrompt), // parent turn 1: call task
textTurn("parent done"), // parent turn 2: final answer
},
childTurns: [][]zeroruntime.StreamEvent{
{
// The child run reports token usage as it produces its answer.
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 30, CompletionTokens: 9, CachedInputTokens: 4}},
{Type: zeroruntime.StreamEventText, Content: "child summary"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var usages []zeroruntime.Usage
if _, err := Run(context.Background(), "delegate with cost", provider, Options{
Registry: registry,
MaxTurns: 5,
OnUsage: func(usage zeroruntime.Usage) { usages = append(usages, usage) },
}); err != nil {
t.Fatal(err)
}
// The child's usage event must reach the parent's OnUsage callback.
var sawChildUsage bool
for _, usage := range usages {
if usage.PromptTokens == 30 && usage.CompletionTokens == 9 && usage.CachedInputTokens == 4 {
sawChildUsage = true
}
}
if !sawChildUsage {
t.Fatalf("expected sub-agent token usage to propagate to parent OnUsage, got %#v", usages)
}
}
// TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop verifies
// that when a turn carries multiple tool calls and the repeated-failure guard
// halts the run on a call that is NOT the last, every advertised tool_use still
@@ -992,7 +1201,7 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
type secretEmittingTool struct{ output string }
func (t secretEmittingTool) Name() string { return "leak" }
func (t secretEmittingTool) Description() string { return "emits text for testing" }
func (t secretEmittingTool) Description() string { return "emits text for testing" }
func (t secretEmittingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
+136
View File
@@ -0,0 +1,136 @@
package agent
import (
_ "embed"
"os"
"path/filepath"
"strings"
)
// coreSystemPrompt is the de-branded coding-craft instruction set: identity,
// autonomy, workflow, editing discipline, the testing gate, tool use, and
// communication style.
//
//go:embed system_prompt.md
var coreSystemPrompt string
// confirmationPolicy is the de-branded safety policy appended to the system
// prompt so the model self-polices before risky actions. The sandbox enforces a
// subset of these rules, but the model applies judgement first.
//
//go:embed confirmation_policy.md
var confirmationPolicy string
// fallbackSystemPrompt is used only if the embedded core prompt is somehow empty
// (it never should be) so a run always has a non-empty system turn.
const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
// projectContextFiles are workspace docs injected into the system prompt so the
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md). The
// first one found wins.
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
// maxProjectContextBytes caps how much of a project doc is injected so a large
// guidelines file can't blow the context budget.
const maxProjectContextBytes = 8 << 10 // 8 KiB
// buildSystemPrompt assembles the full system prompt for a run: the core
// coding-craft instructions, dynamic workspace context (cwd, git branch, project
// guidelines), and the safety confirmation policy. It is built once per run so
// every turn shares one (cacheable) system turn.
func buildSystemPrompt(options Options) string {
core := strings.TrimSpace(options.SystemPrompt)
if core == "" {
core = strings.TrimSpace(coreSystemPrompt)
}
if core == "" {
core = fallbackSystemPrompt
}
sections := []string{core}
if addendum := modelPromptAddendum(options.Model); addendum != "" {
sections = append(sections, addendum)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
}
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
sections = append(sections, policy)
}
return strings.Join(sections, "\n\n")
}
// workspaceContext returns an <environment> block describing the working
// directory, git branch, and any project guideline doc, so the model grounds its
// work in the actual repo. Returns "" when cwd is unset (keeps headless/test
// runs deterministic).
func workspaceContext(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
var b strings.Builder
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
b.WriteString("</environment>")
for _, name := range projectContextFiles {
data, err := os.ReadFile(filepath.Join(cwd, name))
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content == "" {
continue
}
if len(content) > maxProjectContextBytes {
content = content[:maxProjectContextBytes] + "\n… (truncated)"
}
b.WriteString("\n\n## Project guidelines (" + name + ")\n\n" + content)
break // first match wins
}
return b.String()
}
// gitBranchForPrompt reads the current branch (or short SHA when detached) for
// cwd, handling both a regular checkout (.git dir) and a worktree (.git file).
// Returns "" on any problem — the prompt simply omits the branch segment.
func gitBranchForPrompt(cwd string) string {
gitPath := filepath.Join(cwd, ".git")
info, err := os.Stat(gitPath)
if err != nil {
return ""
}
headPath := filepath.Join(gitPath, "HEAD")
if !info.IsDir() {
data, err := os.ReadFile(gitPath)
if err != nil {
return ""
}
dir := strings.TrimPrefix(strings.TrimSpace(string(data)), "gitdir: ")
if dir == "" {
return ""
}
// In worktree mode the gitdir is often RELATIVE (e.g.
// "gitdir: ../.git/worktrees/<name>") — resolve it against cwd, not the
// process working directory, or HEAD lookup fails and we drop the branch.
if !filepath.IsAbs(dir) {
dir = filepath.Join(cwd, dir)
}
headPath = filepath.Join(dir, "HEAD")
}
data, err := os.ReadFile(headPath)
if err != nil {
return ""
}
ref := strings.TrimSpace(string(data))
if strings.HasPrefix(ref, "ref: ") {
return strings.TrimPrefix(strings.TrimPrefix(ref, "ref: "), "refs/heads/")
}
if len(ref) >= 7 {
return ref[:7]
}
return ref
}
+76
View File
@@ -0,0 +1,76 @@
You are Zero, an autonomous terminal coding agent. You operate inside the user's
workspace via tools and help with real software engineering tasks: understanding
code, implementing changes, fixing bugs, running commands, and explaining your
work.
## Autonomy and persistence
- Act like a senior pair-programmer who owns the task end-to-end. Once the user
gives a direction, gather context, plan, implement, verify, and explain —
without stopping to ask for confirmation at each step.
- Be biased toward action. If a request is slightly ambiguous but the intent is
clear, proceed with the most reasonable interpretation rather than leaving the
user waiting. If the user asks "should we do X?" and the answer is yes, do X.
- Persist until the task is genuinely complete in this turn whenever feasible:
don't stop at analysis or a partial fix. Carry changes through search,
implementation, verification, and a clear summary.
- Only stop to ask the user (via the ask_user tool) when you are genuinely
blocked on a decision that is theirs to make and that you cannot resolve from
the code, the request, or sensible defaults.
## Workflow
1. **Understand.** Restate the goal to yourself. For anything non-trivial, use
grep/glob/read_file to learn the relevant code BEFORE changing it. Never edit
a file you have not read.
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
keep it current as you go. Skip the plan for trivial one-step tasks.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem; do not refactor or reformat unrelated code.
4. **Verify.** See the testing gate below — this is mandatory.
5. **Summarize.** Report what you changed and why, concisely, with `file:line`
references. State plainly what was verified and what was not.
## Editing discipline
- Prefer the native file tools — read_file, list_directory, glob, grep,
write_file, edit_file, apply_patch — over shelling out to cat/sed/awk/python
for file operations. They are safer, reviewable, and produce clean diffs.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file/apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
## Testing gate (mandatory)
- After any change to code, run the project's validators before you summarize or
commit: tests, type-checks, linters, and/or the build, as appropriate. Scope
them to the change while iterating; reserve full-suite runs for milestones.
- If you are unsure which validators apply, search the repo (Makefile, package
manifests, CI config) to find them.
- Never claim a task is done, and never commit, while validators are failing.
If they fail, fix the cause and rerun — do not paper over it. If you could not
run a validator, say so explicitly rather than implying success.
## Tool use
- Use tools to act, not to narrate. Don't announce each call; just do the work
and explain the outcome.
- Run independent, read-only lookups together when you can, rather than one at a
time, to move faster.
- bash is for commands that have no native tool (build, test, git, package
managers). It is not a substitute for the file tools.
- Treat tool output as ground truth. If a command fails, read the error, form a
hypothesis, and address the root cause — don't retry the same call blindly.
## Communication
- Default to concise, skimmable output. Lead with the answer or the result.
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
code blocks for code, and `inline code` for file paths, commands, symbols, and
short snippets. Reference code as `file:line` so it is clickable.
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
say so; when something is done and verified, state it plainly without hedging.
+77
View File
@@ -0,0 +1,77 @@
package agent
import "strings"
// Per-model prompt specialization.
//
// Different model families respond to different framing: the GPT family benefits
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
// Gemini benefits from explicit tool-preference and conciseness guidance; the
// Claude family is already well-aligned with the core prompt and only needs a
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
// block appended after the core prompt. Classification is by model id (the agent
// only has the model string — no registry dependency).
const (
familyOpenAI = "openai"
familyGemini = "gemini"
familyAnthropic = "anthropic"
)
// modelFamily classifies a model id into a prompt-tuning family, or "" when
// unknown (in which case no addendum is added).
func modelFamily(model string) string {
m := strings.ToLower(strings.TrimSpace(model))
switch {
case m == "":
return ""
case strings.HasPrefix(m, "gpt"), strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"),
strings.HasPrefix(m, "o4"), strings.Contains(m, "openai"):
return familyOpenAI
case strings.HasPrefix(m, "gemini"), strings.Contains(m, "google"):
return familyGemini
case strings.HasPrefix(m, "claude"), strings.Contains(m, "anthropic"):
return familyAnthropic
default:
return ""
}
}
// modelPromptAddendum returns the family-specific prompt block for a model id,
// or "" when the family is unknown.
func modelPromptAddendum(model string) string {
switch modelFamily(model) {
case familyOpenAI:
return openAIPromptAddendum
case familyGemini:
return geminiPromptAddendum
case familyAnthropic:
return anthropicPromptAddendum
default:
return ""
}
}
const openAIPromptAddendum = `<model_guidance>
- Output your final responses in GitHub-flavored Markdown: headings to structure
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
const anthropicPromptAddendum = `<model_guidance>
- Do not add code comments unless the user asks or the surrounding code is
comment-dense; match the file's existing comment density.
</model_guidance>`
@@ -0,0 +1,46 @@
package agent
import (
"strings"
"testing"
)
func TestModelFamilyClassification(t *testing.T) {
cases := map[string]string{
"gpt-5": familyOpenAI,
"gpt-4o": familyOpenAI,
"o3-mini": familyOpenAI,
"gemini-2.5-pro": familyGemini,
"claude-opus-4-6": familyAnthropic,
"anthropic/claude-haiku": familyAnthropic,
"some-unknown-model": "",
"": "",
}
for model, want := range cases {
if got := modelFamily(model); got != want {
t.Errorf("modelFamily(%q) = %q, want %q", model, got, want)
}
}
}
func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
// Assert on the addendum constants themselves (the core prompt shares phrases
// like "one tool call per file", so substring checks can't distinguish them).
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
if !strings.Contains(claude, anthropicPromptAddendum) {
t.Fatalf("expected the Anthropic addendum in the claude prompt")
}
if strings.Contains(claude, openAIPromptAddendum) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
}
// Unknown / unset model gets no family block.
if got := modelPromptAddendum(""); got != "" {
t.Fatalf("expected no addendum without a model, got %q", got)
}
if strings.Contains(buildSystemPrompt(Options{}), "<model_guidance>") {
t.Fatalf("expected no <model_guidance> block without a model")
}
}
+8
View File
@@ -0,0 +1,8 @@
package agent
// BuildSystemPromptPreview returns the same system prompt a run would seed for
// the supplied options. It is intended for non-runtime reporting surfaces that
// need to estimate prompt footprint without starting a provider call.
func BuildSystemPromptPreview(options Options) string {
return buildSystemPrompt(options)
}
-303
View File
@@ -1,303 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// routingProvider serves different scripted turns to the parent run vs a child
// (sub-agent) run, distinguishing them by the user prompt that seeds each run.
// The parent run carries parentPrompt; the child run carries the task prompt.
// It records every request so tests can assert what each run advertised/sent.
type routingProvider struct {
parentSubstr string
parentTurns [][]zeroruntime.StreamEvent
childTurns [][]zeroruntime.StreamEvent
requests []zeroruntime.CompletionRequest
parentCount int
childCount int
childRequests []zeroruntime.CompletionRequest
}
func (provider *routingProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
isParent := false
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, provider.parentSubstr) {
isParent = true
break
}
}
var events []zeroruntime.StreamEvent
if isParent {
if provider.parentCount < len(provider.parentTurns) {
events = provider.parentTurns[provider.parentCount]
}
provider.parentCount++
} else {
provider.childRequests = append(provider.childRequests, request)
if provider.childCount < len(provider.childTurns) {
events = provider.childTurns[provider.childCount]
}
provider.childCount++
}
if events == nil {
events = []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
}
ch := make(chan zeroruntime.StreamEvent, len(events))
for _, event := range events {
ch <- event
}
close(ch)
return ch, nil
}
// taskCallTurn scripts a turn that invokes the task tool with the given child
// prompt. It reuses toolTurn (defined in guardrails_test.go).
func taskCallTurn(prompt string) []zeroruntime.StreamEvent {
return toolTurn("task-1", "task", `{"description":"sub work","prompt":`+quoteJSONString(prompt)+`}`)
}
// (a) A top-level run where the model calls task spawns a sub-run, and the
// child's final answer comes back as the task tool result.
func TestRunSpawnsSubAgentForTaskCall(t *testing.T) {
const childPrompt = "investigate the failing test"
registry := tools.NewRegistry()
registry.Register(tools.NewTaskTool())
registry.Register(tools.NewReadFileTool(t.TempDir()))
provider := &routingProvider{
parentSubstr: "delegate please",
parentTurns: [][]zeroruntime.StreamEvent{
taskCallTurn(childPrompt), // parent turn 1: call task
textTurn("parent done"), // parent turn 2: final answer
},
childTurns: [][]zeroruntime.StreamEvent{
textTurn("child summary: the test was flaky"), // child turn 1: final answer
},
}
var toolResults []ToolResult
result, err := Run(context.Background(), "delegate please", provider, Options{
Registry: registry,
MaxTurns: 5,
OnToolResult: func(r ToolResult) { toolResults = append(toolResults, r) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "parent done" {
t.Fatalf("expected parent final answer, got %q", result.FinalAnswer)
}
if provider.childCount != 1 {
t.Fatalf("expected exactly one child turn, got %d", provider.childCount)
}
if len(toolResults) != 1 {
t.Fatalf("expected one task tool result, got %d", len(toolResults))
}
if toolResults[0].Status != tools.StatusOK {
t.Fatalf("expected ok task result, got %s: %s", toolResults[0].Status, toolResults[0].Output)
}
if toolResults[0].Output != "child summary: the test was flaky" {
t.Fatalf("expected child final answer as task output, got %q", toolResults[0].Output)
}
if !strings.Contains(toolResults[0].Display.Summary, "sub work") {
t.Fatalf("expected display summary to name the sub-task, got %q", toolResults[0].Display.Summary)
}
// The child run must have been seeded with the task prompt.
if len(provider.childRequests) == 0 {
t.Fatalf("expected the child run to issue at least one request")
}
var sawChildPrompt bool
for _, message := range provider.childRequests[0].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, childPrompt) {
sawChildPrompt = true
}
}
if !sawChildPrompt {
t.Fatalf("expected child run to be seeded with the task prompt %q", childPrompt)
}
}
// A sub-agent's final answer is scrubbed before it becomes the task tool
// result, so a secret a child surfaced never lands in the parent transcript.
func TestRunRedactsSecretsInTaskChildFinalAnswer(t *testing.T) {
const childPrompt = "find the leaked key"
secret := "ghp_ABCDEFGHIJKLMNOP1234567890"
registry := tools.NewRegistry()
registry.Register(tools.NewTaskTool())
registry.Register(tools.NewReadFileTool(t.TempDir()))
provider := &routingProvider{
parentSubstr: "delegate please",
parentTurns: [][]zeroruntime.StreamEvent{
taskCallTurn(childPrompt),
textTurn("parent done"),
},
childTurns: [][]zeroruntime.StreamEvent{
textTurn("the key is " + secret),
},
}
var captured ToolResult
_, err := Run(context.Background(), "delegate please", provider, Options{
Registry: registry,
MaxTurns: 5,
OnToolResult: func(r ToolResult) { captured = r },
})
if err != nil {
t.Fatal(err)
}
if strings.Contains(captured.Output, secret) {
t.Fatalf("secret leaked into task tool result: %q", captured.Output)
}
if !captured.Redacted {
t.Error("expected Redacted=true when a secret was scrubbed from a sub-agent final answer")
}
// The redacted answer must also not reach the parent model.
for _, request := range provider.requests {
for _, m := range request.Messages {
if strings.Contains(m.Content, secret) {
t.Fatalf("secret leaked into parent model message: %q", m.Content)
}
}
}
}
// (c) The child registry excludes task and ask_user, so the sub-run never
// advertises them and can never recurse via task.
func TestSubAgentRegistryExcludesTaskAndAskUser(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewTaskTool())
registry.Register(tools.NewAskUserTool())
registry.Register(tools.NewReadFileTool(t.TempDir()))
provider := &routingProvider{
parentSubstr: "please delegate",
parentTurns: [][]zeroruntime.StreamEvent{
taskCallTurn("do the sub work"),
textTurn("parent done"),
},
childTurns: [][]zeroruntime.StreamEvent{
textTurn("child done"),
},
}
if _, err := Run(context.Background(), "please delegate", provider, Options{
Registry: registry,
MaxTurns: 5,
// Ask mode so even the normally hidden tools would be advertised if present.
PermissionMode: PermissionModeAsk,
}); err != nil {
t.Fatal(err)
}
if len(provider.childRequests) == 0 {
t.Fatalf("expected a child run")
}
for _, toolDefinition := range provider.childRequests[0].Tools {
if toolDefinition.Name == "task" {
t.Fatalf("child run must not advertise the task tool")
}
if toolDefinition.Name == "ask_user" {
t.Fatalf("child run must not advertise the ask_user tool")
}
}
// read_file should still be available to the sub-agent.
var sawReadFile bool
for _, toolDefinition := range provider.childRequests[0].Tools {
if toolDefinition.Name == "read_file" {
sawReadFile = true
}
}
if !sawReadFile {
t.Fatalf("expected child run to keep read_file, advertised: %#v", provider.childRequests[0].Tools)
}
}
// (b) At max depth, a task call returns the limit error and does NOT recurse.
func TestTaskCallAtMaxDepthDoesNotRecurse(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewTaskTool())
provider := &routingProvider{
parentSubstr: "delegate at depth",
parentTurns: [][]zeroruntime.StreamEvent{
taskCallTurn("should not run"),
textTurn("parent done"),
},
// Any child turn here would indicate an illegal recursion.
childTurns: [][]zeroruntime.StreamEvent{
textTurn("ILLEGAL CHILD RUN"),
},
}
var toolResults []ToolResult
result, err := Run(context.Background(), "delegate at depth", provider, Options{
Registry: registry,
MaxTurns: 5,
Depth: maxTaskDepth, // already at the cap
OnToolResult: func(r ToolResult) { toolResults = append(toolResults, r) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "parent done" {
t.Fatalf("expected parent final answer, got %q", result.FinalAnswer)
}
if provider.childCount != 0 {
t.Fatalf("expected NO child run at max depth, got %d child turns", provider.childCount)
}
if len(toolResults) != 1 {
t.Fatalf("expected one task tool result, got %d", len(toolResults))
}
if toolResults[0].Status != tools.StatusError {
t.Fatalf("expected error status at max depth, got %s", toolResults[0].Status)
}
if !strings.Contains(toolResults[0].Output, "max sub-agent depth") {
t.Fatalf("expected depth-limit error, got %q", toolResults[0].Output)
}
}
// The child run must not inherit interactive callbacks: it runs headless even
// when the parent wired up OnAskUser / OnPermissionRequest.
func TestSubAgentRunsWithoutInteractiveCallbacks(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewTaskTool())
registry.Register(tools.NewReadFileTool(t.TempDir()))
provider := &routingProvider{
parentSubstr: "delegate headless",
parentTurns: [][]zeroruntime.StreamEvent{
taskCallTurn("sub work in child"),
textTurn("parent done"),
},
childTurns: [][]zeroruntime.StreamEvent{
textTurn("child done"),
},
}
askUserCalls := 0
if _, err := Run(context.Background(), "delegate headless", provider, Options{
Registry: registry,
MaxTurns: 5,
OnAskUser: func(context.Context, AskUserRequest) (AskUserResponse, error) {
askUserCalls++
return AskUserResponse{}, nil
},
}); err != nil {
t.Fatal(err)
}
if askUserCalls != 0 {
t.Fatalf("sub-agent must not invoke the parent's OnAskUser callback")
}
}
+81 -27
View File
@@ -18,9 +18,16 @@ type PermissionAction string
type PermissionDecisionAction string
const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
)
type StopReason string
const (
StopReasonSpecReviewRequired StopReason = "spec_review_required"
)
const (
@@ -44,8 +51,30 @@ type ToolResult struct {
Redacted bool
ChangedFiles []string
Display tools.Display
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
// LoadedTools carries the deferred-tool names a tool_search call asked the
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
// ordinary tool result; only tool_search populates it.
LoadedTools []string
// RequestedModel is the model id a tool asked the loop to switch to for the
// rest of the run (lifted from the tool's Meta["escalate_to_model"]). Empty
// for every normal tool result; the Run loop performs the switch when it is
// set and Options.ModelSwitcher is wired.
RequestedModel string
}
// DenialCategory classifies why a tool call was blocked before it executed.
type DenialCategory string
const (
DenialNone DenialCategory = ""
DenialFiltered DenialCategory = "filtered" // tool not enabled for this run
DenialPermissionDenied DenialCategory = "permission_denied" // approval declined
DenialSandboxViolation DenialCategory = "sandbox_violation" // blocked by the sandbox
)
type PermissionRequest struct {
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
@@ -105,40 +134,65 @@ type AskUserResponse struct {
type Options struct {
MaxTurns int
// ContextWindow is the model's maximum input token budget. When > 0 the
// agent loop compacts long conversations once the estimated size crosses a
// fraction of this window (see compactionThreshold). 0 DISABLES compaction
// entirely, which keeps every existing caller and test behaving identically.
// DeferThreshold activates deferred MCP-tool loading: when the number of
// deferred-eligible visible tools is >= this value (and it is > 0), their
// full schemas are withheld and advertised as compact lines via tool_search.
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
// the pre-deferral behavior.
DeferThreshold int
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
CallingSessionID string
CallingToolUseID string
Tag string
Depth int
SessionTitle string
Model string
ReasoningEffort string
Cwd string
SystemPrompt string
// Images are optional image attachments to seed onto the initial user turn.
// nil for text-only runs (the seeded message then carries no images, exactly
// as before).
Images []zeroruntime.ImageBlock
// ContextWindow is the model's maximum input token budget. When > 0 the agent
// loop compacts long conversations once the estimated size crosses a fraction
// of this window. 0 DISABLES compaction entirely (every existing caller/test
// behaves identically).
ContextWindow int
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
// Provider is the completion provider used to spawn sub-agent (task) runs.
// It is normally left nil by callers: Run() injects the provider it was
// given at the top of the loop, so executeToolCall can start a child run
// with the same provider without changing Run's signature.
Provider Provider
// Depth is the sub-agent nesting level. The top-level run is Depth 0; each
// task interception spawns a child at Depth+1. A task call at or above
// maxTaskDepth is refused to prevent runaway recursion.
Depth int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
EnabledTools []string
DisabledTools []string
OnText func(string)
OnToolCall func(ToolCall)
OnPermissionRequest func(context.Context, PermissionRequest) (PermissionDecision, error)
OnPermission func(PermissionEvent)
OnAskUser func(context.Context, AskUserRequest) (AskUserResponse, error)
OnToolResult func(ToolResult)
OnUsage func(Usage)
Autonomy string
Sandbox *sandbox.Engine
EnabledTools []string
DisabledTools []string
OnText func(string)
OnToolCall func(ToolCall)
OnPermissionRequest func(context.Context, PermissionRequest) (PermissionDecision, error)
OnPermission func(PermissionEvent)
OnAskUser func(context.Context, AskUserRequest) (AskUserResponse, error)
OnToolResult func(ToolResult)
OnUsage func(Usage)
// OnContext, when set, is called once per turn with the per-category context
// budget of the request about to be sent, so a surface (TUI/CLI) can show
// context utilization. Opt-in like the other callbacks; nil is a no-op.
OnContext func(ContextBreakdown)
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
// mid-run: the loop calls it with the requested model id and, on success,
// swaps the active provider and updates Options.Model for the rest of the
// run. nil DISABLES escalation entirely (the loop ignores any switch
// request), so every existing caller is unaffected. A returned error is
// non-fatal: the run continues on the current model.
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
}
type Result struct {
FinalAnswer string
Turns int
Messages []Message
StopReason StopReason
}
+592
View File
@@ -0,0 +1,592 @@
package background
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
type Status string
const (
StatusRunning Status = "running"
StatusCompleted Status = "completed"
StatusError Status = "error"
StatusKilled Status = "killed"
)
type Task struct {
ID string `json:"id"`
Type string `json:"type"`
SpecialistName string `json:"specialistName,omitempty"`
Description string `json:"description,omitempty"`
ParentID string `json:"parentId,omitempty"`
PID int `json:"pid,omitempty"`
Status Status `json:"status"`
OutputFile string `json:"outputFile"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
}
type RegisterInput struct {
TaskID string
Type string
SpecialistName string
Description string
ParentID string
PID int
OutputFile string
}
type ManagerOptions struct {
RootDir string
Env map[string]string
Now func() time.Time
KillProcess func(pid int) error
}
type Manager struct {
mu sync.Mutex
tasks map[string]Task
warnings []string
rootDir string
now func() time.Time
killProcess func(pid int) error
}
var taskIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
func NewManager(rootDir string) (*Manager, error) {
return NewManagerWithOptions(ManagerOptions{RootDir: rootDir})
}
func NewManagerWithOptions(options ManagerOptions) (*Manager, error) {
rootDir := strings.TrimSpace(options.RootDir)
if rootDir == "" {
rootDir = DefaultRoot(options.Env)
}
rootDir = filepath.Clean(rootDir)
if err := os.MkdirAll(rootDir, 0o700); err != nil {
return nil, fmt.Errorf("create background task directory: %w", err)
}
now := options.Now
if now == nil {
now = time.Now
}
killProcess := options.KillProcess
if killProcess == nil {
killProcess = terminateProcess
}
manager := &Manager{tasks: map[string]Task{}, rootDir: rootDir, now: now, killProcess: killProcess}
if err := manager.loadTasks(); err != nil {
return nil, err
}
return manager, nil
}
func DefaultRoot(env map[string]string) string {
dataHome := strings.TrimSpace(envValue(env, "XDG_DATA_HOME"))
home := strings.TrimSpace(envValue(env, "HOME"))
if home == "" {
if userHome, err := os.UserHomeDir(); err == nil {
home = userHome
}
}
base := dataHome
if base == "" {
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "zero", "background")
}
func (manager *Manager) RootDir() string {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.rootDir
}
func (manager *Manager) LoadWarnings() []string {
manager.mu.Lock()
defer manager.mu.Unlock()
return append([]string(nil), manager.warnings...)
}
func (manager *Manager) Register(input RegisterInput) (string, error) {
taskID := strings.TrimSpace(input.TaskID)
if !validTaskID(taskID) {
return "", fmt.Errorf("invalid background task id %q", input.TaskID)
}
taskType := strings.TrimSpace(input.Type)
if taskType == "" {
return "", fmt.Errorf("background task %s requires a type", taskID)
}
if input.PID < 0 {
return "", fmt.Errorf("invalid background task pid %d", input.PID)
}
outputFile, err := manager.outputFile(taskID, input.OutputFile)
if err != nil {
return "", err
}
manager.mu.Lock()
defer manager.mu.Unlock()
if _, exists := manager.tasks[taskID]; exists {
return "", fmt.Errorf("background task already registered: %s", taskID)
}
if err := os.MkdirAll(filepath.Dir(outputFile), 0o700); err != nil {
return "", fmt.Errorf("create background task output directory: %w", err)
}
file, err := os.OpenFile(outputFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
if errors.Is(err, os.ErrExist) {
return "", fmt.Errorf("background task output already exists: %s", outputFile)
}
return "", fmt.Errorf("create background task output file: %w", err)
}
if err := file.Close(); err != nil {
return "", fmt.Errorf("close background task output file: %w", err)
}
task := Task{
ID: taskID,
Type: taskType,
SpecialistName: strings.TrimSpace(input.SpecialistName),
Description: strings.TrimSpace(input.Description),
ParentID: strings.TrimSpace(input.ParentID),
PID: input.PID,
Status: StatusRunning,
OutputFile: outputFile,
StartedAt: manager.now(),
}
manager.tasks[taskID] = task
if err := manager.persistTaskLocked(task); err != nil {
delete(manager.tasks, taskID)
_ = os.Remove(outputFile)
return "", err
}
return outputFile, nil
}
func (manager *Manager) SetPID(taskID string, pid int) error {
taskID = strings.TrimSpace(taskID)
if pid <= 0 {
return fmt.Errorf("invalid background task pid %d", pid)
}
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[taskID]
if !ok {
return fmt.Errorf("background task not found: %s", taskID)
}
task.PID = pid
if err := manager.persistTaskLocked(task); err != nil {
return err
}
manager.tasks[taskID] = task
return nil
}
func (manager *Manager) UpdateStatus(taskID string, status Status, exitCode int) error {
taskID = strings.TrimSpace(taskID)
if !validStatus(status) {
return fmt.Errorf("invalid background task status %q", status)
}
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[taskID]
if !ok {
return fmt.Errorf("background task not found: %s", taskID)
}
task.Status = status
task.ExitCode = exitCode
if status == StatusRunning {
task.CompletedAt = time.Time{}
} else if task.CompletedAt.IsZero() {
task.CompletedAt = manager.now()
}
if err := manager.persistTaskLocked(task); err != nil {
return err
}
manager.tasks[taskID] = task
return nil
}
func (manager *Manager) MarkExited(taskID string, status Status, exitCode int) error {
taskID = strings.TrimSpace(taskID)
if status != StatusCompleted && status != StatusError {
return fmt.Errorf("invalid background task exit status %q", status)
}
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[taskID]
if !ok {
return fmt.Errorf("background task not found: %s", taskID)
}
if task.Status != StatusRunning {
return nil
}
task.Status = status
task.ExitCode = exitCode
if task.CompletedAt.IsZero() {
task.CompletedAt = manager.now()
}
if err := manager.persistTaskLocked(task); err != nil {
return err
}
manager.tasks[taskID] = task
return nil
}
func (manager *Manager) Get(taskID string) (Task, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[strings.TrimSpace(taskID)]
return task, ok
}
func (manager *Manager) Kill(taskID string) error {
taskID = strings.TrimSpace(taskID)
pid, err := manager.killTarget(taskID)
if err != nil {
return err
}
if !manager.isRunningPID(taskID, pid) {
return nil
}
// Record the kill intent BEFORE terminating. terminateProcess can block until
// the child exits, during which the background Wait-goroutine may reap it and
// call MarkExited; marking killed first makes that MarkExited a no-op (it only
// acts on a running task), so a user-initiated stop stays "killed" instead of
// being clobbered to "error".
marked, err := manager.markKilledIfStillRunning(taskID, pid)
if err != nil {
return err
}
if !marked {
// The task exited (or was reused) between the running check and now — the
// pid may be stale, so do NOT signal it.
return nil
}
if err := manager.killProcess(pid); err != nil {
// Couldn't terminate — undo the optimistic kill mark so the still-running
// task is not falsely reported as killed.
killErr := fmt.Errorf("kill background task %s: %w", taskID, err)
if restoreErr := manager.restoreRunningAfterFailedKill(taskID, pid); restoreErr != nil {
return errors.Join(killErr, fmt.Errorf("restore running state for %s: %w", taskID, restoreErr))
}
return killErr
}
return nil
}
func (manager *Manager) KillRunning() error {
var errs []error
for _, task := range manager.List() {
if task.Status != StatusRunning {
continue
}
if err := manager.Kill(task.ID); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (manager *Manager) killTarget(taskID string) (int, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[taskID]
if !ok {
return 0, fmt.Errorf("background task not found: %s", taskID)
}
if task.Status != StatusRunning {
return 0, fmt.Errorf("background task %s is %s", taskID, task.Status)
}
if task.PID <= 0 {
return 0, fmt.Errorf("background task %s has no pid", taskID)
}
return task.PID, nil
}
func (manager *Manager) isRunningPID(taskID string, pid int) bool {
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[taskID]
return ok && task.Status == StatusRunning && task.PID == pid
}
// markKilledIfStillRunning marks the task killed if it is still running. The
// bool reports whether it actually marked: false means the task already exited
// (or was reused), in which case the caller must NOT signal the pid — it may be
// stale.
func (manager *Manager) markKilledIfStillRunning(taskID string, pid int) (bool, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[taskID]
if !ok {
return false, fmt.Errorf("background task not found: %s", taskID)
}
if task.Status != StatusRunning {
return false, nil
}
if task.PID != pid {
return false, fmt.Errorf("background task %s pid changed before kill completed", taskID)
}
task.Status = StatusKilled
task.ExitCode = -1
if task.CompletedAt.IsZero() {
task.CompletedAt = manager.now()
}
if err := manager.persistTaskLocked(task); err != nil {
return false, err
}
manager.tasks[taskID] = task
return true, nil
}
// restoreRunningAfterFailedKill reverts a task that was optimistically marked
// killed back to running when the terminate failed (the process is presumed
// still alive). It only touches a task still killed with the same pid, so a real
// exit recorded meanwhile is left untouched. It returns the persistence error so
// the caller can surface a failed rollback rather than leaving the task wrongly
// marked killed.
func (manager *Manager) restoreRunningAfterFailedKill(taskID string, pid int) error {
manager.mu.Lock()
defer manager.mu.Unlock()
task, ok := manager.tasks[taskID]
if !ok || task.Status != StatusKilled || task.PID != pid {
return nil
}
task.Status = StatusRunning
task.ExitCode = 0
task.CompletedAt = time.Time{}
if err := manager.persistTaskLocked(task); err != nil {
return err
}
manager.tasks[taskID] = task
return nil
}
func (manager *Manager) loadTasks() error {
entries, err := os.ReadDir(manager.rootDir)
if err != nil {
return fmt.Errorf("read background task directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
if entry.Type()&os.ModeSymlink != 0 {
manager.warnf("skipped symlink background task metadata: %s", filepath.Join(manager.rootDir, entry.Name()))
continue
}
taskID := strings.TrimSuffix(entry.Name(), ".json")
if !validTaskID(taskID) {
manager.warnf("skipped invalid background task metadata file %q", entry.Name())
continue
}
path := manager.metadataFile(taskID)
data, err := os.ReadFile(path)
if err != nil {
manager.warnf("skipped unreadable background task metadata %s: %s", path, err)
continue
}
var task Task
if err := json.Unmarshal(data, &task); err != nil {
manager.warnf("skipped invalid background task metadata %s: %s", path, err)
continue
}
task, changed, err := manager.normalizeLoadedTask(taskID, task)
if err != nil {
manager.warnf("skipped invalid background task metadata %s: %s", path, err)
continue
}
if changed {
if err := manager.persistTaskLocked(task); err != nil {
manager.warnf("failed to repair background task metadata %s: %s", path, err)
}
}
manager.tasks[task.ID] = task
}
return nil
}
func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task, bool, error) {
changed := false
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fileTaskID
changed = true
}
if task.ID != fileTaskID {
return Task{}, false, fmt.Errorf("metadata id %q does not match file id %q", task.ID, fileTaskID)
}
if !validTaskID(task.ID) {
return Task{}, false, fmt.Errorf("invalid task id %q", task.ID)
}
if trimmed := strings.TrimSpace(task.Type); trimmed != task.Type {
task.Type = trimmed
changed = true
}
if task.Type == "" {
return Task{}, false, fmt.Errorf("background task %s requires a type", task.ID)
}
if !validStatus(task.Status) {
return Task{}, false, fmt.Errorf("invalid background task status %q", task.Status)
}
if task.PID < 0 {
return Task{}, false, fmt.Errorf("invalid background task pid %d", task.PID)
}
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
if err != nil {
return Task{}, false, err
}
if outputFile != task.OutputFile {
changed = true
}
task.OutputFile = outputFile
if task.Status == StatusRunning {
task.Status = StatusError
task.PID = 0
task.ExitCode = -1
if task.CompletedAt.IsZero() {
task.CompletedAt = manager.now()
}
changed = true
manager.warnf("marked reloaded running background task %s as error; original process ownership was lost", task.ID)
}
return task, changed, nil
}
func (manager *Manager) persistTaskLocked(task Task) error {
data, err := json.MarshalIndent(task, "", " ")
if err != nil {
return fmt.Errorf("encode background task metadata: %w", err)
}
path := manager.metadataFile(task.ID)
file, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
if err != nil {
return fmt.Errorf("create background task metadata temp file: %w", err)
}
tmp := file.Name()
if _, err := file.Write(append(data, '\n')); err != nil {
_ = file.Close()
_ = os.Remove(tmp)
return fmt.Errorf("write background task metadata: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("close background task metadata: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace background task metadata: %w", err)
}
return nil
}
func (manager *Manager) metadataFile(taskID string) string {
return filepath.Join(manager.rootDir, taskID+".json")
}
func (manager *Manager) warnf(format string, args ...any) {
message := fmt.Sprintf(format, args...)
manager.warnings = append(manager.warnings, message)
log.Printf("zero background: %s", message)
}
func (manager *Manager) OutputPath(taskID string) string {
task, ok := manager.Get(taskID)
if !ok {
return ""
}
return task.OutputFile
}
func (manager *Manager) ListByParent(parentID string) []Task {
parentID = strings.TrimSpace(parentID)
manager.mu.Lock()
defer manager.mu.Unlock()
tasks := []Task{}
for _, task := range manager.tasks {
if task.ParentID == parentID {
tasks = append(tasks, task)
}
}
sortTasks(tasks)
return tasks
}
func (manager *Manager) List() []Task {
manager.mu.Lock()
defer manager.mu.Unlock()
tasks := make([]Task, 0, len(manager.tasks))
for _, task := range manager.tasks {
tasks = append(tasks, task)
}
sortTasks(tasks)
return tasks
}
func (manager *Manager) outputFile(taskID string, requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested == "" {
return filepath.Join(manager.rootDir, taskID+".ndjson"), nil
}
path := requested
if !filepath.IsAbs(path) {
path = filepath.Join(manager.rootDir, path)
}
path = filepath.Clean(path)
rel, err := filepath.Rel(manager.rootDir, path)
if err != nil {
return "", fmt.Errorf("resolve background task output file: %w", err)
}
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return "", fmt.Errorf("background task output file must be inside %s", manager.rootDir)
}
return path, nil
}
func validTaskID(taskID string) bool {
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
}
func validStatus(status Status) bool {
switch status {
case StatusRunning, StatusCompleted, StatusError, StatusKilled:
return true
default:
return false
}
}
func sortTasks(tasks []Task) {
sort.SliceStable(tasks, func(left int, right int) bool {
if tasks[left].StartedAt.Equal(tasks[right].StartedAt) {
return tasks[left].ID < tasks[right].ID
}
return tasks[left].StartedAt.After(tasks[right].StartedAt)
})
}
func envValue(env map[string]string, key string) string {
if env != nil {
return env[key]
}
return os.Getenv(key)
}
+487
View File
@@ -0,0 +1,487 @@
package background
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
func TestManagerRegistersListsAndKillsTask(t *testing.T) {
now := sequenceClock(
time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC),
time.Date(2026, 6, 7, 9, 0, 1, 0, time.UTC),
time.Date(2026, 6, 7, 9, 0, 2, 0, time.UTC),
)
killed := []int{}
manager, err := NewManagerWithOptions(ManagerOptions{
RootDir: t.TempDir(),
Now: now,
KillProcess: func(pid int) error {
killed = append(killed, pid)
return nil
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
outputFile, err := manager.Register(RegisterInput{
TaskID: "task_1",
Type: "specialist",
SpecialistName: "worker",
Description: "Read the release notes",
ParentID: "parent",
})
if err != nil {
t.Fatalf("Register returned error: %v", err)
}
if outputFile != filepath.Join(manager.RootDir(), "task_1.ndjson") {
t.Fatalf("output file = %q", outputFile)
}
info, err := os.Stat(outputFile)
if err != nil {
t.Fatalf("expected output file to exist: %v", err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("output file permissions = %v, want 0600", info.Mode().Perm())
}
if err := manager.SetPID("task_1", 1234); err != nil {
t.Fatalf("SetPID returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task_2", Type: "specialist", SpecialistName: "explorer", ParentID: "parent", PID: 5678}); err != nil {
t.Fatalf("Register second task returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "other", Type: "specialist", ParentID: "different-parent"}); err != nil {
t.Fatalf("Register other task returned error: %v", err)
}
task, ok := manager.Get("task_1")
if !ok {
t.Fatalf("Get did not find task")
}
if task.ID != "task_1" || task.PID != 1234 || task.Status != StatusRunning || task.SpecialistName != "worker" {
t.Fatalf("unexpected task: %#v", task)
}
parentTasks := manager.ListByParent("parent")
gotIDs := []string{}
for _, task := range parentTasks {
gotIDs = append(gotIDs, task.ID)
}
if !reflect.DeepEqual(gotIDs, []string{"task_2", "task_1"}) {
t.Fatalf("ListByParent ids = %#v, want newest first", gotIDs)
}
if err := manager.Kill("task_1"); err != nil {
t.Fatalf("Kill returned error: %v", err)
}
if !reflect.DeepEqual(killed, []int{1234}) {
t.Fatalf("killed pids = %#v", killed)
}
task, ok = manager.Get("task_1")
if !ok || task.Status != StatusKilled || task.ExitCode != -1 || task.CompletedAt.IsZero() {
t.Fatalf("killed task status was not recorded: %#v", task)
}
}
func TestManagerPersistsAndLoadsTasks(t *testing.T) {
root := t.TempDir()
now := sequenceClock(
time.Date(2026, 6, 7, 10, 0, 0, 0, time.UTC),
time.Date(2026, 6, 7, 10, 0, 1, 0, time.UTC),
)
manager, err := NewManagerWithOptions(ManagerOptions{RootDir: root, Now: now})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
outputFile, err := manager.Register(RegisterInput{
TaskID: "task_1",
Type: "specialist",
SpecialistName: "worker",
Description: "Persist metadata",
ParentID: "parent",
})
if err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := manager.SetPID("task_1", 4321); err != nil {
t.Fatalf("SetPID returned error: %v", err)
}
if err := manager.UpdateStatus("task_1", StatusCompleted, 0); err != nil {
t.Fatalf("UpdateStatus returned error: %v", err)
}
reloaded, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager reload returned error: %v", err)
}
task, ok := reloaded.Get("task_1")
if !ok {
t.Fatal("reloaded manager did not find task")
}
if task.ID != "task_1" ||
task.Type != "specialist" ||
task.SpecialistName != "worker" ||
task.Description != "Persist metadata" ||
task.ParentID != "parent" ||
task.PID != 4321 ||
task.Status != StatusCompleted ||
task.ExitCode != 0 ||
task.OutputFile != outputFile ||
task.StartedAt.IsZero() ||
task.CompletedAt.IsZero() {
t.Fatalf("reloaded task = %#v", task)
}
parentTasks := reloaded.ListByParent("parent")
if len(parentTasks) != 1 || parentTasks[0].ID != "task_1" {
t.Fatalf("reloaded parent tasks = %#v", parentTasks)
}
}
func TestManagerPersistsKilledStatus(t *testing.T) {
root := t.TempDir()
manager, err := NewManagerWithOptions(ManagerOptions{
RootDir: root,
KillProcess: func(pid int) error {
return nil
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := manager.Kill("task"); err != nil {
t.Fatalf("Kill returned error: %v", err)
}
reloaded, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager reload returned error: %v", err)
}
task, ok := reloaded.Get("task")
if !ok || task.Status != StatusKilled || task.ExitCode != -1 || task.CompletedAt.IsZero() {
t.Fatalf("reloaded killed task = %#v", task)
}
}
func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "task.json"), []byte(`{"id":"other","type":"specialist","status":"running","outputFile":"task.ndjson"}`+"\n"), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error for invalid metadata: %v", err)
}
if _, ok := manager.Get("task"); ok {
t.Fatal("manager loaded invalid task metadata")
}
warnings := strings.Join(manager.LoadWarnings(), "\n")
if !strings.Contains(warnings, "does not match file id") {
t.Fatalf("load warnings = %q, want id mismatch warning", warnings)
}
}
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
root := t.TempDir()
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
killed := []int{}
reloaded, err := NewManagerWithOptions(ManagerOptions{
RootDir: root,
KillProcess: func(pid int) error {
killed = append(killed, pid)
return nil
},
})
if err != nil {
t.Fatalf("NewManager reload returned error: %v", err)
}
task, ok := reloaded.Get("task")
if !ok {
t.Fatal("reloaded manager did not find task")
}
if task.Status != StatusError || task.PID != 0 || task.ExitCode != -1 || task.CompletedAt.IsZero() {
t.Fatalf("reloaded running task was not orphaned: %#v", task)
}
if err := reloaded.Kill("task"); err == nil || !strings.Contains(err.Error(), "is error") {
t.Fatalf("Kill reloaded running task error = %v", err)
}
if len(killed) != 0 {
t.Fatalf("Kill signaled stale pids: %#v", killed)
}
reloadedAgain, err := NewManager(root)
if err != nil {
t.Fatalf("second reload returned error: %v", err)
}
task, ok = reloadedAgain.Get("task")
if !ok || task.Status != StatusError || task.PID != 0 {
t.Fatalf("repaired task metadata did not persist: %#v", task)
}
}
func TestManagerRejectsUnsafeTaskIDsAndOutputPaths(t *testing.T) {
manager, err := NewManager(t.TempDir())
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
for _, taskID := range []string{"", "../escape", "bad/name", `bad\name`} {
if _, err := manager.Register(RegisterInput{TaskID: taskID, Type: "specialist"}); err == nil {
t.Fatalf("Register accepted unsafe task id %q", taskID)
}
}
if _, err := manager.Register(RegisterInput{TaskID: "safe", Type: "specialist", OutputFile: "../escape.ndjson"}); err == nil || !strings.Contains(err.Error(), "inside") {
t.Fatalf("Register unsafe output file error = %v", err)
}
}
func TestManagerRejectsDuplicateAndMissingTasks(t *testing.T) {
manager, err := NewManager(t.TempDir())
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist"}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist"}); err == nil || !strings.Contains(err.Error(), "already registered") {
t.Fatalf("duplicate Register error = %v", err)
}
if err := manager.SetPID("missing", 1); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("missing SetPID error = %v", err)
}
if err := manager.UpdateStatus("missing", StatusCompleted, 0); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("missing UpdateStatus error = %v", err)
}
if err := manager.Kill("missing"); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("missing Kill error = %v", err)
}
}
func TestManagerDoesNotMarkKilledWhenKillFails(t *testing.T) {
manager, err := NewManagerWithOptions(ManagerOptions{
RootDir: t.TempDir(),
KillProcess: func(pid int) error {
return errors.New("denied")
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := manager.Kill("task"); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("Kill error = %v", err)
}
task, ok := manager.Get("task")
if !ok || task.Status != StatusRunning || !task.CompletedAt.IsZero() {
t.Fatalf("failed kill mutated task: %#v", task)
}
}
func TestManagerKillDoesNotClobberCompletedTask(t *testing.T) {
now := sequenceClock(
time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC),
time.Date(2026, 6, 7, 9, 0, 1, 0, time.UTC),
)
var manager *Manager
manager, err := NewManagerWithOptions(ManagerOptions{
RootDir: t.TempDir(),
Now: now,
KillProcess: func(pid int) error {
return manager.UpdateStatus("task", StatusCompleted, 0)
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := manager.Kill("task"); err != nil {
t.Fatalf("Kill returned error: %v", err)
}
task, ok := manager.Get("task")
if !ok || task.Status != StatusCompleted || task.ExitCode != 0 {
t.Fatalf("Kill clobbered completed task: %#v", task)
}
}
func TestManagerMarkExitedDoesNotClobberKilledTask(t *testing.T) {
manager, err := NewManagerWithOptions(ManagerOptions{
RootDir: t.TempDir(),
KillProcess: func(pid int) error {
return nil
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := manager.Kill("task"); err != nil {
t.Fatalf("Kill returned error: %v", err)
}
if err := manager.MarkExited("task", StatusError, 1); err != nil {
t.Fatalf("MarkExited returned error: %v", err)
}
task, ok := manager.Get("task")
if !ok || task.Status != StatusKilled || task.ExitCode != -1 {
t.Fatalf("MarkExited clobbered killed task: %#v", task)
}
}
func TestManagerKillRunningStopsOnlyRunningTasks(t *testing.T) {
now := sequenceClock(
time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC),
time.Date(2026, 6, 7, 9, 0, 1, 0, time.UTC),
time.Date(2026, 6, 7, 9, 0, 2, 0, time.UTC),
)
killed := []int{}
manager, err := NewManagerWithOptions(ManagerOptions{
RootDir: t.TempDir(),
Now: now,
KillProcess: func(pid int) error {
killed = append(killed, pid)
return nil
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "running_1", Type: "specialist", PID: 11}); err != nil {
t.Fatalf("Register running_1 returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "done", Type: "specialist", PID: 22}); err != nil {
t.Fatalf("Register done returned error: %v", err)
}
if err := manager.UpdateStatus("done", StatusCompleted, 0); err != nil {
t.Fatalf("UpdateStatus returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "running_2", Type: "specialist", PID: 33}); err != nil {
t.Fatalf("Register running_2 returned error: %v", err)
}
if err := manager.KillRunning(); err != nil {
t.Fatalf("KillRunning returned error: %v", err)
}
if !reflect.DeepEqual(killed, []int{33, 11}) {
t.Fatalf("killed pids = %#v", killed)
}
for _, id := range []string{"running_1", "running_2"} {
task, ok := manager.Get(id)
if !ok || task.Status != StatusKilled {
t.Fatalf("%s status = %#v", id, task)
}
}
done, ok := manager.Get("done")
if !ok || done.Status != StatusCompleted {
t.Fatalf("completed task was changed: %#v", done)
}
}
func TestDefaultRootHonorsXDGDataHome(t *testing.T) {
got := DefaultRoot(map[string]string{
"XDG_DATA_HOME": "/tmp/zero-data",
"HOME": "/home/example",
})
want := filepath.Join("/tmp/zero-data", "zero", "background")
if got != want {
t.Fatalf("DefaultRoot = %q, want %q", got, want)
}
}
// TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate reproduces the
// lifecycle race created by a blocking terminateProcess: while Kill waits for the
// child to exit, the background Wait-goroutine reaps it and calls MarkExited. The
// task must stay "killed" (the user's intent), not be clobbered to "error".
func TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate(t *testing.T) {
now := sequenceClock(
time.Date(2026, 6, 8, 9, 0, 0, 0, time.UTC),
time.Date(2026, 6, 8, 9, 0, 1, 0, time.UTC),
time.Date(2026, 6, 8, 9, 0, 2, 0, time.UTC),
)
var manager *Manager
var err error
manager, err = NewManagerWithOptions(ManagerOptions{
RootDir: t.TempDir(),
Now: now,
KillProcess: func(int) error {
// terminateProcess blocks until exit; simulate the Wait-goroutine
// reaping the child and marking it exited mid-kill.
_ = manager.MarkExited("task_1", StatusError, -1)
return nil
},
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "task_1", Type: "specialist", SpecialistName: "worker", ParentID: "p", PID: 4242}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
if err := manager.Kill("task_1"); err != nil {
t.Fatalf("Kill returned error: %v", err)
}
task, ok := manager.Get("task_1")
if !ok {
t.Fatal("task_1 not found after Kill")
}
if task.Status != StatusKilled {
t.Fatalf("status = %q, want %q — the exit waiter must not clobber a user-initiated kill", task.Status, StatusKilled)
}
}
func TestMarkKilledIfStillRunningReportsWhetherItMarked(t *testing.T) {
manager, err := NewManagerWithOptions(ManagerOptions{
RootDir: t.TempDir(),
Now: sequenceClock(time.Date(2026, 6, 8, 9, 0, 0, 0, time.UTC)),
})
if err != nil {
t.Fatalf("NewManagerWithOptions returned error: %v", err)
}
if _, err := manager.Register(RegisterInput{TaskID: "t", Type: "specialist", PID: 7}); err != nil {
t.Fatalf("Register returned error: %v", err)
}
marked, err := manager.markKilledIfStillRunning("t", 7)
if err != nil || !marked {
t.Fatalf("first call: marked=%v err=%v, want true/nil", marked, err)
}
// Already killed (not running) → must report not-marked so Kill skips signaling
// a possibly-stale pid.
marked, err = manager.markKilledIfStillRunning("t", 7)
if err != nil || marked {
t.Fatalf("second call: marked=%v err=%v, want false/nil", marked, err)
}
}
func sequenceClock(times ...time.Time) func() time.Time {
index := 0
return func() time.Time {
if index >= len(times) {
return times[len(times)-1]
}
next := times[index]
index++
return next
}
}
+67
View File
@@ -0,0 +1,67 @@
//go:build !windows
package background
import (
"errors"
"os"
"syscall"
"time"
)
// terminationGracePeriod is how long a process has to exit after SIGTERM before
// it is force-killed with SIGKILL. Vars (not consts) so tests can shorten them.
var (
terminationGracePeriod = 3 * time.Second
terminationPollInterval = 50 * time.Millisecond
)
// terminateProcess stops a background process. It first asks politely with
// SIGTERM (so the process can flush/clean up), then escalates to SIGKILL if the
// process is still alive after terminationGracePeriod — so a process that traps
// or ignores SIGTERM cannot leak. It returns nil once the process is gone.
func terminateProcess(pid int) error {
process, err := os.FindProcess(pid)
if err != nil {
return err
}
if err := process.Signal(syscall.SIGTERM); err != nil {
if processGoneError(err) {
return nil
}
return err
}
// Poll liveness so we return promptly once the process exits, rather than
// always waiting out the full grace period.
deadline := time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !processAlive(process) {
return nil
}
time.Sleep(terminationPollInterval)
}
if !processAlive(process) {
return nil
}
// Still alive after the grace period: force-kill.
if err := process.Kill(); err != nil && !processGoneError(err) {
return err
}
return nil
}
// processAlive reports whether the process can still be signalled (signal 0 is
// the standard liveness probe — it performs error checking without sending a
// signal).
func processAlive(process *os.Process) bool {
return process.Signal(syscall.Signal(0)) == nil
}
// processGoneError reports whether an error means the process has already exited
// (so termination is effectively done).
func processGoneError(err error) bool {
return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH)
}
+112
View File
@@ -0,0 +1,112 @@
//go:build !windows
package background
import (
"bufio"
"errors"
"os/exec"
"syscall"
"testing"
"time"
)
// terminatingSignal returns the signal that killed a process from its cmd.Wait()
// error, or 0 if it exited normally / for another reason.
func terminatingSignal(err error) syscall.Signal {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() {
return status.Signal()
}
}
return 0
}
func TestTerminateProcessEscalatesToSIGKILL(t *testing.T) {
grace, poll := terminationGracePeriod, terminationPollInterval
terminationGracePeriod, terminationPollInterval = 300*time.Millisecond, 20*time.Millisecond
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
// A process that traps and ignores SIGTERM — only SIGKILL can stop it. The
// while-loop keeps the trap-holding shell as the process (a trailing single
// command would be exec-optimized, dropping the trap); "ready" is printed
// AFTER the trap is installed so we don't signal before it takes effect.
cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while true; do sleep 0.2; done")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if _, err := bufio.NewReader(stdout).ReadString('\n'); err != nil {
t.Fatalf("waiting for trap to install: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
start := time.Now()
if err := terminateProcess(cmd.Process.Pid); err != nil {
t.Fatalf("terminateProcess: %v", err)
}
select {
case waitErr := <-done:
if sig := terminatingSignal(waitErr); sig != syscall.SIGKILL {
t.Fatalf("process terminated by %v, want SIGKILL (it ignores SIGTERM)", sig)
}
case <-time.After(2 * time.Second):
_ = cmd.Process.Kill()
t.Fatal("process not killed — SIGKILL escalation failed")
}
if elapsed := time.Since(start); elapsed < terminationGracePeriod {
t.Fatalf("process died in %v, before the grace period — SIGTERM should be tried first", elapsed)
}
}
func TestTerminateProcessGracefulSIGTERM(t *testing.T) {
// Longer grace so we can prove a well-behaved process dies on SIGTERM,
// well before any SIGKILL escalation would fire.
grace, poll := terminationGracePeriod, terminationPollInterval
terminationGracePeriod, terminationPollInterval = 5*time.Second, 20*time.Millisecond
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
cmd := exec.Command("sh", "-c", "sleep 30") // default disposition: SIGTERM kills it
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
start := time.Now()
if err := terminateProcess(cmd.Process.Pid); err != nil {
t.Fatalf("terminateProcess: %v", err)
}
select {
case waitErr := <-done:
// Must die from SIGTERM, not SIGKILL — proves we ask politely first and
// don't regress to an immediate force-kill.
if sig := terminatingSignal(waitErr); sig != syscall.SIGTERM {
t.Fatalf("process terminated by %v, want SIGTERM", sig)
}
case <-time.After(3 * time.Second):
_ = cmd.Process.Kill()
t.Fatal("process not killed on SIGTERM")
}
if elapsed := time.Since(start); elapsed >= terminationGracePeriod {
t.Fatalf("took %v — should have died on SIGTERM, not waited for SIGKILL", elapsed)
}
}
func TestTerminateProcessAlreadyExited(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
_ = cmd.Wait() // reap; the pid is now gone
if err := terminateProcess(cmd.Process.Pid); err != nil {
t.Fatalf("terminateProcess on an exited process should be a no-op, got %v", err)
}
}
+20
View File
@@ -0,0 +1,20 @@
//go:build windows
package background
import (
"os"
"os/exec"
"strconv"
)
func terminateProcess(pid int) error {
if err := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
+121 -21
View File
@@ -5,18 +5,21 @@ import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/selfverify"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
@@ -31,6 +34,7 @@ var version = "dev"
type appDeps struct {
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
@@ -49,6 +53,7 @@ type appDeps struct {
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
}
@@ -71,8 +76,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
func defaultAppDeps() appDeps {
return appDeps{
getwd: os.Getwd,
stdin: os.Stdin,
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
@@ -116,6 +122,7 @@ func defaultAppDeps() appDeps {
inspectChanges: zerogit.Inspect,
commitChanges: zerogit.Commit,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
now: time.Now,
}
@@ -125,14 +132,23 @@ func userAgent() string {
return "zero/" + version
}
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
// Convert an unexpected panic anywhere in the CLI into a saved crash report and
// a brief notice, rather than a raw stack trace dumped at the user.
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
deps = fillAppDeps(deps)
if len(args) == 0 {
return runInteractiveTUI(stderr, deps)
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk)
}
switch args[0] {
case "--skip-permissions-unsafe":
// Launch the interactive TUI directly in unsafe mode. Without this, the
// flag fell through to the unknown-command path, so a user could never
// reach unsafe mode in the shell — and the "!" shell escape (which is
// gated behind unsafe) was therefore unreachable.
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe)
case "-h", "--help", "help":
if err := writeHelp(stdout); err != nil {
return 1
@@ -159,10 +175,16 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runProviders(args[1:], stdout, stderr, deps)
case "doctor":
return runDoctor(args[1:], stdout, stderr, deps)
case "context":
return runContext(args[1:], stdout, stderr, deps)
case "search", "find":
return runSearch(args[1:], stdout, stderr, deps)
case "sessions", "session":
return runSessions(args[1:], stdout, stderr, deps)
case "spec":
return runSpec(args[1:], stdout, stderr, deps)
case "specialists", "specialist":
return runSpecialists(args[1:], stdout, stderr, deps)
case "plugins", "plugin":
return runPlugins(args[1:], stdout, stderr, deps)
case "skills", "skill":
@@ -181,10 +203,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runVerifyCommand(args[1:], stdout, stderr, deps)
case "changes", "change":
return runChanges(args[1:], stdout, stderr, deps)
case "usage":
return runUsage(args[1:], stdout, stderr, deps)
case "serve":
return runServe(args[1:], stdout, stderr, deps)
case "zenline":
return runZenline(args[1:], stdout, stderr, deps)
case "zeroline":
return runZeroline(args[1:], stdout, stderr, deps)
default:
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
return 1
@@ -204,6 +228,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.stdin == nil {
deps.stdin = defaults.stdin
}
if deps.userConfigPath == nil {
deps.userConfigPath = defaults.userConfigPath
}
if deps.resolveConfig == nil {
deps.resolveConfig = defaults.resolveConfig
}
@@ -258,6 +285,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
if deps.runEditor == nil {
deps.runEditor = defaults.runEditor
}
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
@@ -267,11 +297,11 @@ func fillAppDeps(deps appDeps) appDeps {
return deps
}
func runInteractiveTUI(stderr io.Writer, deps appDeps) int {
return runInteractiveTUIWithSkin(stderr, deps, "")
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode) int {
return runInteractiveTUIWithSkin(stderr, deps, "", permissionMode)
}
func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string) int {
func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, permissionMode agent.PermissionMode) int {
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
@@ -288,27 +318,47 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string) int
}
registry := newCoreRegistry(workspaceRoot)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, mcp.AutonomyLow)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
// files nor run shell. Ask advertises them and routes each through the existing
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
//
// Resolve the effective mode BEFORE the deferral gate below so the registration
// count uses the SAME permission mode the agent loop's partition will use; an
// empty mode here would mis-gate prompt-advertised deferred tools.
if permissionMode == "" {
permissionMode = agent.PermissionModeAsk
}
// Activate deferred MCP-tool loading for the interactive run only when the
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
// The registry is complete (core + specialist + MCP) here, so the count is
// accurate; below threshold this is a no-op and the surface is unchanged. The
// interactive surface applies no operator tool filters, so enabled/disabled are
// nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: sandbox.DefaultPolicy(),
Policy: applyConfiguredAutonomyCeiling(sandbox.DefaultPolicy(), resolved.Sandbox.MaxAutonomy),
Store: sandboxStore,
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
})
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
// files nor run shell. Ask advertises them and routes each through the existing
// OnPermissionRequest flow; shift+tab lets the user switch modes live.
permissionMode := agent.PermissionModeAsk
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
ProviderName: resolved.Provider.Name,
@@ -325,15 +375,17 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string) int
PermissionMode: permissionMode,
Autonomy: string(sandbox.AutonomyLow),
Sandbox: sandboxEngine,
DeferThreshold: resolved.Tools.DeferThreshold,
},
PermissionMode: permissionMode,
Skin: skin,
ThemeDark: true,
Notify: resolved.Notify,
})
}
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if resolved.Provider == (config.ProviderProfile{}) {
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
return deps.newProvider(resolved.Provider)
@@ -347,6 +399,24 @@ func newCoreRegistry(workspaceRoot string) *tools.Registry {
return registry
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*specialist.Runtime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
return specialist.RegisterTools(registry, specialist.Executor{Paths: paths})
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
if options.useSpec {
return false
}
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
return false
}
return options.skipPermissionsUnsafe || strings.EqualFold(strings.TrimSpace(options.autonomy), "high")
}
func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
if runtime == nil {
return
@@ -356,6 +426,15 @@ func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
}
}
func closeSpecialistRuntime(stderr io.Writer, runtime *specialist.Runtime) {
if runtime == nil {
return
}
if err := runtime.Close(); err != nil {
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
}
}
func writeAppError(stderr io.Writer, message string, exitCode int) int {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return 1
@@ -379,9 +458,12 @@ Commands:
models List Zero model registry entries
providers Inspect resolved provider profiles
doctor Run backend health checks for config and provider setup
context Report workspace context budget usage
search Search persisted local Zero session events
find Alias for search
sessions Inspect local Zero session lineage
spec Review and approve saved spec-mode drafts
specialist Manage local Zero specialist profiles
plugins Inspect local Zero plugin manifests
skills Inspect local Zero skills
hooks Inspect Zero hook configuration
@@ -391,15 +473,17 @@ Commands:
worktrees Prepare isolated git worktrees
verify Detect and run local verification checks
changes Inspect and commit local git changes
usage Summarize token usage and estimated cost
serve Run Zero protocol servers
zenline Launch the interactive TUI with the Zenline reskin
zeroline Launch the interactive TUI with the Zeroline reskin
help Show this help
version Print version
Flags:
-h, --help Show this help
-v, --version Print version
-p, --prompt Run a one-shot prompt
-h, --help Show this help
-v, --version Print version
-p, --prompt Run a one-shot prompt
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
`)
return err
}
@@ -419,8 +503,13 @@ Runs a one-shot prompt through the Go agent runtime.
Flags:
-f, --file <path> Read prompt text from a file
--image <path> Attach a local image (repeatable; vision models only)
--mode <name> Apply a preset (smart, deep, fast, large, precise); explicit flags override it
-m, --model <model> Select the model for provider setup
--use-spec Draft a spec first and stop for review
--spec-model <model> Override the draft model when --use-spec is set
--spec-reasoning-effort <effort>
Override draft reasoning effort when --use-spec is set
--max-turns <number> Override the maximum agent loop turns
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
--enabled-tools <tools> Only expose these comma or space separated tools
@@ -435,10 +524,21 @@ Flags:
Select prompt input format
-o, --output-format text|json|stream-json
Select text, JSON, or schema-versioned JSONL output
("debug" is accepted as a stream-json alias)
--prompt <prompt> Provide prompt text as a flag
--resume [id] Resume a session; omit id to use the latest
--fork <id> Fork an existing session into a new session
--calling-session-id <id> Parent session id for specialist child runs
--calling-tool-use-id <id> Parent tool-call id for specialist child runs
--tag <tag> Attach runtime tag metadata to the exec run
--depth <number> Set specialist nesting depth metadata
--session-title <text> Set the created session title
--init-session-id <id> Create a new exec session with this id
--skip-permissions-unsafe Allow prompt-gated tools without approval
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--notify <off|bell|notify|both>
Override notification mode for this run
--no-notify Disable notifications for this run
`)
return err
}
+78 -1
View File
@@ -200,6 +200,79 @@ func TestRunNoArgsLaunchesTUIInAskPermissionMode(t *testing.T) {
}
}
func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
launched := false
var launchedOptions tui.Options
// "zero --skip-permissions-unsafe" must launch the interactive TUI in unsafe
// mode (not fall through to "unknown command"). This is the only way to reach
// unsafe mode in the shell, which the "!" escape is gated behind.
exitCode := runWithDeps([]string{"--skip-permissions-unsafe"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 3}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
}
if !launched {
t.Fatal("expected the interactive TUI to launch, but runTUI was never called")
}
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
}
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeUnsafe)
}
}
func TestRunZerolineSkipPermissionsUnsafeLaunchesInUnsafeMode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
launched := false
var launchedOptions tui.Options
exitCode := runWithDeps([]string{"zeroline", "--skip-permissions-unsafe"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 3}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
}
if !launched {
t.Fatal("expected the zeroline TUI to launch, but runTUI was never called")
}
if launchedOptions.Skin != "zeroline" {
t.Fatalf("Skin = %q, want zeroline", launchedOptions.Skin)
}
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
}
}
func TestRunNoArgsReportsConfigErrorsWithoutLaunchingTUI(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -562,6 +635,7 @@ func assertHelpOutput(t *testing.T, args []string) {
"models",
"providers",
"doctor",
"context",
"search",
"plugins",
"hooks",
@@ -571,7 +645,8 @@ func assertHelpOutput(t *testing.T, args []string) {
"worktrees",
"verify",
"serve",
"zenline",
"usage",
"zeroline",
"--version",
} {
if !strings.Contains(output, want) {
@@ -657,6 +732,7 @@ func assertCoreRegistry(t *testing.T, registry *tools.Registry) {
}
for _, name := range []string{
"Task",
"read_file",
"list_directory",
"glob",
@@ -666,6 +742,7 @@ func assertCoreRegistry(t *testing.T, registry *tools.Registry) {
"apply_patch",
"update_plan",
"bash",
"web_fetch",
} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("expected registry to include core tool %q", name)
+121 -55
View File
@@ -4,8 +4,9 @@ import (
"fmt"
"io"
"sort"
"strconv"
"strings"
"text/tabwriter"
"unicode"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -15,15 +16,17 @@ import (
type commandCenterOptions struct {
json bool
provider string
transport string
includeDeprecated bool
}
type configSummary = zerocommands.ConfigSnapshot
type providerSummary = zerocommands.ProviderSnapshot
type modelSummary = zerocommands.ModelSnapshot
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot
func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseCommandCenterArgs(args, false)
options, help, err := parseCommandCenterArgs(args, false, false)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
@@ -63,10 +66,16 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
}
return exitSuccess
}
if command != "list" && command != "current" {
if command == "add" {
return runProvidersAdd(args, stdout, stderr, deps)
}
if command == "check" {
return runProvidersCheck(args, stdout, stderr, deps)
}
if command != "list" && command != "current" && command != "catalog" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
}
options, help, err := parseCommandCenterArgs(args, false)
options, help, err := parseCommandCenterArgs(args, false, command == "catalog")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
@@ -76,6 +85,22 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
}
return exitSuccess
}
if command == "catalog" {
catalog, err := listProviderCatalogSummaries(options)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{"providers": catalog}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, formatProviderCatalogSummaries(catalog)); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
@@ -118,7 +143,7 @@ func runModels(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) > 0 && (args[0] == "list" || args[0] == "ls") {
args = args[1:]
}
options, help, err := parseCommandCenterArgs(args, true)
options, help, err := parseCommandCenterArgs(args, true, false)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
@@ -161,7 +186,7 @@ func resolveCommandCenterConfig(stderr io.Writer, deps appDeps) (config.Resolved
return resolved, exitSuccess
}
func parseCommandCenterArgs(args []string, allowModelFilters bool) (commandCenterOptions, bool, error) {
func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProviderCatalogFilters bool) (commandCenterOptions, bool, error) {
options := commandCenterOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
@@ -180,7 +205,24 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool) (commandCente
options.provider = value
index = next
case allowModelFilters && strings.HasPrefix(arg, "--provider="):
options.provider = strings.TrimSpace(strings.TrimPrefix(arg, "--provider="))
value, err := requiredInlineFlagValue(arg, "--provider")
if err != nil {
return options, false, err
}
options.provider = value
case allowProviderCatalogFilters && arg == "--transport":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.transport = value
index = next
case allowProviderCatalogFilters && strings.HasPrefix(arg, "--transport="):
value, err := requiredInlineFlagValue(arg, "--transport")
if err != nil {
return options, false, err
}
options.transport = value
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
@@ -218,6 +260,19 @@ func listModelSummaries(registry modelregistry.Registry, options commandCenterOp
return summaries, nil
}
func listProviderCatalogSummaries(options commandCenterOptions) ([]providerCatalogSummary, error) {
summaries, err := zerocommands.ProviderCatalogSnapshots(zerocommands.ProviderCatalogSnapshotOptions{
Transport: options.transport,
})
if err != nil {
return nil, err
}
sort.SliceStable(summaries, func(i int, j int) bool {
return summaries[i].ID < summaries[j].ID
})
return summaries, nil
}
func formatConfigSummary(summary configSummary) string {
lines := []string{
"Config",
@@ -277,56 +332,42 @@ func formatProviderLine(provider providerSummary) string {
return line
}
func formatProviderCatalogSummaries(providers []providerCatalogSummary) string {
lines := []string{"Provider Catalog"}
if len(providers) == 0 {
lines = append(lines, " (none)")
return strings.Join(lines, "\n")
}
for _, provider := range providers {
lines = append(lines, " "+formatProviderCatalogLine(provider))
}
return strings.Join(lines, "\n")
}
func formatProviderCatalogLine(provider providerCatalogSummary) string {
return fmt.Sprintf("id=%s name=%s transport=%s defaultModel=%s defaultBaseURL=%s authEnvVars=%s requiresAuth=%t local=%t runtimeSupported=%t",
formatProviderCatalogValue(provider.ID, "unknown"),
formatProviderCatalogValue(provider.Name, "unknown"),
formatProviderCatalogValue(provider.Transport, "unknown"),
formatProviderCatalogValue(provider.DefaultModel, "none"),
formatProviderCatalogValue(provider.DefaultBaseURL, "none"),
formatProviderCatalogValue(strings.Join(provider.AuthEnvVars, ","), "none"),
provider.RequiresAuth,
provider.Local,
provider.RuntimeSupported,
)
}
func formatModelSummaries(models []modelSummary) string {
lines := []string{"Models"}
if len(models) == 0 {
return "Models\n (none)"
lines = append(lines, " (none)")
return strings.Join(lines, "\n")
}
var builder strings.Builder
builder.WriteString("Models\n")
writer := tabwriter.NewWriter(&builder, 0, 0, 2, ' ', 0)
fmt.Fprintln(writer, "ID\tPROVIDER\tSTATUS\tCONTEXT\tREASONING\t$IN/1M\t$OUT/1M")
for _, model := range models {
fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
model.ID,
model.Provider,
model.Status,
formatTokenCount(model.ContextWindow),
formatReasoningEfforts(model.ReasoningEfforts),
formatRate(model.InputPerMillion),
formatRate(model.OutputPerMillion),
)
lines = append(lines, fmt.Sprintf(" %s [%s] ctx=%d out=%d - %s", model.ID, model.Provider, model.ContextWindow, model.MaxOutputTokens, model.DisplayName))
}
writer.Flush()
return strings.TrimRight(builder.String(), "\n")
}
func formatReasoningEfforts(efforts []string) string {
if len(efforts) == 0 {
return "-"
}
return strings.Join(efforts, "/")
}
func formatRate(rate float64) string {
if rate <= 0 {
return "-"
}
return fmt.Sprintf("%.2f", rate)
}
func formatTokenCount(value int) string {
if value <= 0 {
return "-"
}
digits := fmt.Sprintf("%d", value)
var grouped strings.Builder
for index, char := range digits {
if index > 0 && (len(digits)-index)%3 == 0 {
grouped.WriteByte(',')
}
grouped.WriteRune(char)
}
return grouped.String()
return strings.Join(lines, "\n")
}
func apiKeyState(set bool) string {
@@ -343,6 +384,16 @@ func displayCLIValue(value string, fallback string) string {
return value
}
func formatProviderCatalogValue(value string, fallback string) string {
value = displayCLIValue(value, fallback)
for _, r := range value {
if unicode.IsSpace(r) || unicode.IsControl(r) || r == '"' {
return strconv.Quote(value)
}
}
return value
}
func writeConfigHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero config [flags]
@@ -375,12 +426,27 @@ func writeProvidersHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero providers current [flags]
zero providers list [flags]
zero providers catalog [flags]
zero providers add <catalog-id> [flags]
zero providers check [name] [flags]
Inspects resolved provider profiles without printing secrets.
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Flags:
--json Print JSON summary
-h, --help Show this help
--json Print JSON summary
--transport <transport> Filter catalog descriptors by transport
Add flags:
--name <name> Saved provider profile name
--model <model> Override catalog default model
--base-url <url> Override catalog default base URL
--api-key-env <name> Environment variable that contains the API key
--auth-header <header> Custom API-key header name
--auth-scheme <scheme> Auth scheme prefix, for example Bearer or Token
--auth-header-value <v> Exact auth header value; stored in config
--header <key=value> Custom provider header; repeatable
--set-active Make the added provider active
-h, --help Show this help
`)
return err
}
+377 -41
View File
@@ -3,10 +3,15 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zerocommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -127,46 +132,6 @@ func TestRunModelsListsRegistryModels(t *testing.T) {
}
}
func TestRunModelsRendersAlignedTable(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"models", "list", "--provider", "anthropic"}, &stdout, &stderr, commandCenterDeps(t))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
for _, want := range []string{"ID", "PROVIDER", "STATUS", "CONTEXT", "REASONING", "$IN/1M", "$OUT/1M"} {
if !strings.Contains(output, want) {
t.Fatalf("expected models table header %q, got:\n%s", want, output)
}
}
// Row content: canonical id, provider, formatted context window, and rates.
for _, want := range []string{"claude-sonnet-4.5", "anthropic", "200,000", "3.00", "15.00"} {
if !strings.Contains(output, want) {
t.Fatalf("expected models table to contain %q, got:\n%s", want, output)
}
}
}
func TestRunModelsJSONUnchangedIncludesRates(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"models", "list", "--provider", "anthropic", "--json"}, &stdout, &stderr, commandCenterDeps(t))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
for _, want := range []string{`"models"`, `"id": "claude-sonnet-4.5"`, `"inputPerMillion": 3`, `"outputPerMillion": 15`} {
if !strings.Contains(output, want) {
t.Fatalf("expected models JSON to contain %q, got:\n%s", want, output)
}
}
}
func TestRunModelsRejectsUnknownProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -224,6 +189,328 @@ func TestRunProvidersCurrentJSONIncludesRuntimeMetadata(t *testing.T) {
}
}
func TestRunProvidersCatalogListsDescriptors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "catalog"}, &stdout, &stderr, providerCatalogDeps(t))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
for _, want := range []string{
"Provider Catalog",
"id=openai",
"name=OpenAI",
"transport=openai",
"defaultModel=gpt-4.1",
"defaultBaseURL=https://api.openai.com/v1",
"authEnvVars=OPENAI_API_KEY",
"requiresAuth=true",
"local=false",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected providers catalog output to contain %q, got %q", want, output)
}
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestFormatProviderCatalogValueQuotesControlCharacters(t *testing.T) {
value := formatProviderCatalogValue("bad\rvalue", "none")
if value != strconv.Quote("bad\rvalue") {
t.Fatalf("formatProviderCatalogValue() = %q, want quoted control character value", value)
}
}
func TestRunProvidersCatalogJSONIncludesDescriptors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "catalog", "--json"}, &stdout, &stderr, providerCatalogDeps(t))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload struct {
Providers []zerocommands.ProviderCatalogSnapshot `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode providers catalog JSON: %v\n%s", err, stdout.String())
}
openai := findProviderCatalogSnapshot(t, payload.Providers, "openai")
if openai.Name != "OpenAI" ||
openai.Transport != "openai" ||
openai.DefaultBaseURL != "https://api.openai.com/v1" ||
openai.DefaultModel != "gpt-4.1" ||
!openai.RequiresAuth ||
openai.Local {
t.Fatalf("unexpected OpenAI catalog descriptor: %#v", openai)
}
if len(openai.AuthEnvVars) != 1 || openai.AuthEnvVars[0] != "OPENAI_API_KEY" {
t.Fatalf("unexpected OpenAI auth env vars: %#v", openai.AuthEnvVars)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunProvidersCatalogFiltersByTransport(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "catalog", "--transport", "openai"}, &stdout, &stderr, providerCatalogDeps(t))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "id=openai") || !strings.Contains(output, "transport=openai") {
t.Fatalf("expected OpenAI transport providers in filtered catalog output, got %q", output)
}
if strings.Contains(output, "(none)") {
t.Fatalf("expected non-empty HTTP catalog output, got %q", output)
}
}
func TestRunProvidersCatalogRejectsUnknownTransport(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "catalog", "--transport", "space-link"}, &stdout, &stderr, providerCatalogDeps(t))
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), `unknown provider transport "space-link"`) {
t.Fatalf("expected unknown transport error, got %q", stderr.String())
}
}
func TestRunProvidersCatalogRejectsUnknownFlags(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "catalog", "--include-deprecated"}, &stdout, &stderr, providerCatalogDeps(t))
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), `unknown flag "--include-deprecated"`) {
t.Fatalf("expected unknown flag error, got %q", stderr.String())
}
}
func TestRunProvidersAddWritesCatalogProfile(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
exitCode := runWithDeps([]string{"providers", "add", "groq", "--name", "fast", "--set-active"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
cfg := readFileConfig(t, configPath)
if cfg.ActiveProvider != "fast" {
t.Fatalf("ActiveProvider = %q, want fast", cfg.ActiveProvider)
}
if len(cfg.Providers) != 1 {
t.Fatalf("providers = %#v, want one provider", cfg.Providers)
}
profile := cfg.Providers[0]
if profile.Name != "fast" ||
profile.CatalogID != "groq" ||
profile.ProviderKind != config.ProviderKindOpenAICompatible ||
profile.BaseURL != "https://api.groq.com/openai/v1" ||
profile.Model != "llama-3.3-70b-versatile" ||
profile.APIKeyEnv != "GROQ_API_KEY" {
t.Fatalf("unexpected provider profile: %#v", profile)
}
if profile.APIKey != "" {
t.Fatalf("providers add must not persist raw API keys: %#v", profile)
}
output := stdout.String()
if !strings.Contains(output, "Added provider fast") || !strings.Contains(output, configPath) {
t.Fatalf("unexpected add output: %q", output)
}
}
func TestRunProvidersAddWritesCustomHeaders(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
exitCode := runWithDeps([]string{
"providers", "add", "custom-openai-compatible",
"--name", "gateway",
"--base-url", "https://gateway.example/v1",
"--model", "gateway-model",
"--api-key-env", "GATEWAY_API_KEY",
"--auth-header", "X-API-Key",
"--auth-scheme", "Token",
"--header", "HTTP-Referer=https://zero.dev",
"--header", "X-Title=Zero",
}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
profile := readFileConfig(t, configPath).Providers[0]
if profile.AuthHeader != "X-API-Key" || profile.AuthScheme != "Token" {
t.Fatalf("unexpected auth override: %#v", profile)
}
if profile.CustomHeaders["HTTP-Referer"] != "https://zero.dev" || profile.CustomHeaders["X-Title"] != "Zero" {
t.Fatalf("unexpected custom headers: %#v", profile.CustomHeaders)
}
}
func TestRunProvidersAddRejectsCatalogOnlyTransports(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "add", "bedrock"}, &stdout, &stderr, providerSetupDeps(filepath.Join(t.TempDir(), "config.json")))
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "native adapter") {
t.Fatalf("expected native adapter warning, got %q", stderr.String())
}
}
func TestRunProvidersCheckConstructsProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var checked config.ProviderProfile
deps := commandCenterDeps(t)
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
checked = profile
return commandCenterProvider{}, nil
}
exitCode := runWithDeps([]string{"providers", "check", "work"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if checked.Name != "work" || checked.Model != "gpt-4.1" {
t.Fatalf("checked profile = %#v, want work provider", checked)
}
output := stdout.String()
if !strings.Contains(output, "Provider check") || !strings.Contains(output, "status: ok") {
t.Fatalf("unexpected check output: %q", output)
}
}
func TestRunProvidersCheckAcceptsAuthHeaderValueCredential(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var checked config.ProviderProfile
deps := commandCenterDeps(t)
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
profile := config.ProviderProfile{
Name: "groq",
ProviderKind: config.ProviderKindOpenAICompatible,
CatalogID: "groq",
BaseURL: "https://api.groq.com/openai/v1",
APIKeyEnv: "GROQ_API_KEY",
AuthHeader: "X-API-Key",
AuthHeaderValue: "direct-secret",
Model: "llama-3.3-70b-versatile",
}
return config.ResolvedConfig{ActiveProvider: "groq", Provider: profile, Providers: []config.ProviderProfile{profile}, MaxTurns: 7}, nil
}
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
checked = profile
return commandCenterProvider{}, nil
}
exitCode := runWithDeps([]string{"providers", "check", "groq"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if checked.AuthHeaderValue != "direct-secret" || checked.APIKey != "" {
t.Fatalf("checked profile = %#v, want direct auth header value without API key", checked)
}
if !strings.Contains(stdout.String(), "status: ok") {
t.Fatalf("expected successful check output, got %q", stdout.String())
}
}
func TestRunProvidersCheckAcceptsOfficialAuthHeaderValueCredential(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var checked config.ProviderProfile
deps := commandCenterDeps(t)
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
profile := config.ProviderProfile{
Name: "manual-openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
AuthHeaderValue: "Bearer direct-secret",
Model: "gpt-4.1",
}
return config.ResolvedConfig{ActiveProvider: "manual-openai", Provider: profile, Providers: []config.ProviderProfile{profile}, MaxTurns: 7}, nil
}
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
checked = profile
return commandCenterProvider{}, nil
}
exitCode := runWithDeps([]string{"providers", "check", "manual-openai"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if checked.AuthHeaderValue != "Bearer direct-secret" || checked.APIKey != "" {
t.Fatalf("checked profile = %#v, want direct auth header value without API key", checked)
}
}
func TestRunProvidersCheckFailsWhenCatalogAuthEnvIsMissing(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
profile := config.ProviderProfile{
Name: "groq",
ProviderKind: config.ProviderKindOpenAICompatible,
CatalogID: "groq",
BaseURL: "https://api.groq.com/openai/v1",
APIKeyEnv: "GROQ_API_KEY",
Model: "llama-3.3-70b-versatile",
}
return config.ResolvedConfig{ActiveProvider: "groq", Provider: profile, Providers: []config.ProviderProfile{profile}, MaxTurns: 7}, nil
}
exitCode := runWithDeps([]string{"providers", "check", "groq"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("expected exit code %d, got %d", exitProvider, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "GROQ_API_KEY") || strings.Contains(stderr.String(), "sk-") {
t.Fatalf("expected missing env error without secret leak, got %q", stderr.String())
}
}
func TestRunProvidersPositionalHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -234,7 +521,7 @@ func TestRunProvidersPositionalHelp(t *testing.T) {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
for _, want := range []string{"Usage:", "zero providers", "list", "current"} {
for _, want := range []string{"Usage:", "zero providers", "list", "current", "catalog"} {
if !strings.Contains(output, want) {
t.Fatalf("expected providers help to contain %q, got %q", want, output)
}
@@ -322,6 +609,55 @@ func commandCenterSecretBaseURLDeps(t *testing.T) appDeps {
return deps
}
func providerCatalogDeps(t *testing.T) appDeps {
t.Helper()
return appDeps{
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
t.Fatalf("providers catalog should not resolve runtime config")
return config.ResolvedConfig{}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatalf("providers catalog should not construct runtime providers")
return nil, nil
},
}
}
func providerSetupDeps(configPath string) appDeps {
return appDeps{
userConfigPath: func() (string, error) {
return configPath, nil
},
}
}
func readFileConfig(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config %s: %v", path, err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config %s: %v\n%s", path, err, string(data))
}
return cfg
}
func findProviderCatalogSnapshot(t *testing.T, snapshots []zerocommands.ProviderCatalogSnapshot, id string) zerocommands.ProviderCatalogSnapshot {
t.Helper()
for _, snapshot := range snapshots {
if snapshot.ID == id {
return snapshot
}
}
t.Fatalf("catalog descriptor %q not found in %#v", id, snapshots)
return zerocommands.ProviderCatalogSnapshot{}
}
type commandCenterProvider struct{}
func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
+91
View File
@@ -0,0 +1,91 @@
package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/contextreport"
"github.com/Gitlawb/zero/internal/modelregistry"
)
type contextOptions struct {
json bool
}
func runContext(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseContextArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeContextHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
modelRegistry, err := modelregistry.DefaultRegistry()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
report, err := contextreport.Build(contextreport.Options{
WorkspaceRoot: workspaceRoot,
Provider: resolved.Provider,
Registry: newCoreRegistry(workspaceRoot),
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, contextreport.Format(report)); err != nil {
return exitCrash
}
return exitSuccess
}
func parseContextArgs(args []string) (contextOptions, bool, error) {
options := contextOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown context flag %q", arg)}
default:
return options, false, execUsageError{fmt.Sprintf("unexpected context argument %q", arg)}
}
}
return options, false, nil
}
func writeContextHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero context [flags]
Reports the current workspace context budget.
Flags:
--json Print JSON report
-h, --help Show this help
`)
return err
}
+131
View File
@@ -0,0 +1,131 @@
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestRunContextHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"context", "--help"}, &stdout, &stderr, appDeps{})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
for _, want := range []string{"Usage:", "zero context [flags]", "--json", "-h, --help"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("expected context help to contain %q, got %q", want, stdout.String())
}
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunContextTextReport(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
exitCode := runWithDeps([]string{"context"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "gpt-4.1",
},
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
for _, want := range []string{"Zero context report", "root: " + cwd, "model: gpt-4.1"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("expected context output to contain %q, got %q", want, stdout.String())
}
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunContextJSONReport(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
exitCode := runWithDeps([]string{"context", "--json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "gpt-4.1",
},
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var report struct {
Root string `json:"root"`
ModelID string `json:"modelId"`
ContextWindow int `json:"contextWindow"`
ToolCount int `json:"toolCount"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("context JSON did not decode: %v\n%s", err, stdout.String())
}
if report.Root != cwd {
t.Fatalf("Root = %q, want %q", report.Root, cwd)
}
if report.ModelID != "gpt-4.1" {
t.Fatalf("ModelID = %q, want gpt-4.1", report.ModelID)
}
if report.ContextWindow <= 0 {
t.Fatalf("ContextWindow = %d, want > 0", report.ContextWindow)
}
if report.ToolCount <= 0 {
t.Fatalf("ToolCount = %d, want > 0", report.ToolCount)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunContextRejectsUnknownFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"context", "--wat"}, &stdout, &stderr, appDeps{})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), `unknown context flag "--wat"`) {
t.Fatalf("expected unknown flag error, got %q", stderr.String())
}
}
+221
View File
@@ -0,0 +1,221 @@
package cli
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
)
// cliFakeDeferredTool is deferred-eligible (implements Deferred() bool), mirroring
// an MCP registry tool, so it counts toward the deferral threshold.
type cliFakeDeferredTool struct {
name string
}
func (t cliFakeDeferredTool) Name() string { return t.name }
func (t cliFakeDeferredTool) Description() string { return "fake deferred tool" }
func (t cliFakeDeferredTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (t cliFakeDeferredTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
}
func (t cliFakeDeferredTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t cliFakeDeferredTool) Deferred() bool { return true }
func registryHasToolSearch(registry *tools.Registry) bool {
_, ok := registry.Get("tool_search")
return ok
}
func TestRegisterToolSearchIfEligibleRegistersAtThreshold(t *testing.T) {
registry := tools.NewRegistry()
for i := 0; i < 3; i++ {
registry.Register(cliFakeDeferredTool{name: "mcp_srv_t" + string(rune('a'+i))})
}
registerToolSearchIfEligible(registry, 3, agent.PermissionModeAuto, nil, nil)
if !registryHasToolSearch(registry) {
t.Fatal("expected tool_search registered when eligible count == threshold")
}
}
func TestRegisterToolSearchIfEligibleSkipsBelowThreshold(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(cliFakeDeferredTool{name: "mcp_srv_ta"})
registry.Register(cliFakeDeferredTool{name: "mcp_srv_tb"})
// A plain (non-deferred) MCP-named tool must NOT count toward eligibility.
registry.Register(cliFakeMCPRegistryTool{})
registerToolSearchIfEligible(registry, 3, agent.PermissionModeAuto, nil, nil)
if registryHasToolSearch(registry) {
t.Fatal("expected no tool_search when eligible count (2) < threshold (3)")
}
}
func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
registry := tools.NewRegistry()
for i := 0; i < 5; i++ {
registry.Register(cliFakeDeferredTool{name: "mcp_srv_t" + string(rune('a'+i))})
}
registerToolSearchIfEligible(registry, 0, agent.PermissionModeAuto, nil, nil)
if registryHasToolSearch(registry) {
t.Fatal("expected no tool_search when threshold is 0 (disabled)")
}
}
func TestDeferredEligibleCountIgnoresCoreTools(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
// newCoreRegistry holds only built-ins; none implement Deferred().
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
}
}
// FIX 1: a deferred tool the operator hid via --disabled-tools must NOT count
// toward the visible-deferred total, so registration agrees with the loop's
// activation gate. Two deferred + threshold 2 normally registers tool_search;
// disabling one drops the visible count to 1 (< 2) so it must NOT register.
func TestRegisterToolSearchSkipsWhenDisabledDropsVisibleBelowThreshold(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(cliFakeDeferredTool{name: "mcp_srv_ta"})
registry.Register(cliFakeDeferredTool{name: "mcp_srv_tb"})
registerToolSearchIfEligible(registry, 2, agent.PermissionModeAuto, nil, []string{"mcp_srv_tb"})
if registryHasToolSearch(registry) {
t.Fatal("expected no tool_search: a disabled deferred tool must not count toward the visible-deferred total")
}
}
// FIX 3: validateExecToolFilters must treat tool_search as always-valid even
// though it is not registered yet at validation time (it is registered later only
// when deferral activates). Listing it in --enabled-tools/--disabled-tools must
// not raise "Unknown tool".
func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
// tool_search is NOT registered in this registry — it would be added later.
if _, present := registry.Get(tools.ToolSearchToolName); present {
t.Fatalf("precondition: tool_search must not be registered yet")
}
if err := validateExecToolFilters(execOptions{enabledTools: []string{tools.ToolSearchToolName}}, registry); err != nil {
t.Fatalf("--enabled-tools tool_search must validate, got error: %v", err)
}
if err := validateExecToolFilters(execOptions{disabledTools: []string{tools.ToolSearchToolName}}, registry); err != nil {
t.Fatalf("--disabled-tools tool_search must validate, got error: %v", err)
}
// A genuinely unknown tool still errors.
if err := validateExecToolFilters(execOptions{enabledTools: []string{"definitely_not_a_tool"}}, registry); err == nil {
t.Fatal("expected an Unknown tool error for an unregistered, non-tool_search name")
}
}
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
// independent of the deferral threshold: --list-tools short-circuits and returns
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
// so tool_search is never registered on this path. The threshold gate itself is
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry.Register(cliFakeMCPRegistryTool{})
return closeFunc(func() error { return nil }), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "mcp_docs_lookup") {
t.Fatalf("expected MCP tool advertised by --list-tools, got %q", out)
}
// --list-tools never registers tool_search (it returns before the threshold
// gate runs); the threshold logic is verified by the unit/E2E tests named above.
if strings.Contains(out, "tool_search") {
t.Fatalf("expected NO tool_search from --list-tools, got %q", out)
}
}
func TestTUIRunThreadsDeferThresholdAndRegistersToolSearch(t *testing.T) {
cwd := t.TempDir()
var captured agent.Options
var capturedRegistry *tools.Registry
// Empty args route runWithDeps to the interactive TUI (runInteractiveTUIWithSkin).
// There is no "--tui" flag; that arg would hit the unknown-command path.
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "p",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "m",
},
Tools: config.ToolsConfig{DeferThreshold: 2},
}, nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
registry.Register(cliFakeDeferredTool{name: "mcp_docs_ta"})
registry.Register(cliFakeDeferredTool{name: "mcp_docs_tb"})
return closeFunc(func() error { return nil }), nil
},
runTUI: func(_ context.Context, options tui.Options) int {
captured = options.AgentOptions
capturedRegistry = options.Registry
return exitSuccess
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d", exitSuccess, exitCode)
}
if captured.DeferThreshold != 2 {
t.Fatalf("AgentOptions.DeferThreshold = %d, want 2", captured.DeferThreshold)
}
if capturedRegistry == nil {
t.Fatal("expected registry passed to TUI")
}
if _, ok := capturedRegistry.Get("tool_search"); !ok {
t.Fatal("expected tool_search registered for TUI run at/above threshold")
}
}
+367 -47
View File
@@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -11,12 +12,18 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
@@ -24,6 +31,9 @@ const (
exitCrash = 1
exitUsage = 2
exitProvider = 3
// exitInterrupted is the conventional shell exit code for a process stopped by
// SIGINT (128 + signal number 2).
exitInterrupted = 130
)
type execOutputFormat string
@@ -38,10 +48,11 @@ const (
)
type execOptions struct {
promptParts []string
file string
mode string
model string
promptParts []string
file string
imagePaths []string
mode string
model string
// modelProfile captures the legacy --profile flag. It is accepted for
// backward compatibility (so old invocations do not error) but is
// intentionally inert: nothing consumes it. Model selection is driven by
@@ -49,6 +60,9 @@ type execOptions struct {
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
modelProfile string
reasoningEffort string
useSpec bool
specModel string
specReasoningEffort string
maxTurns int
cwd string
inputFormat execInputFormat
@@ -60,10 +74,26 @@ type execOptions struct {
resume string
resumeLatest bool
fork string
callingSessionID string
callingToolUseID string
tag string
depth int
sessionTitle string
initSessionID string
worktree bool
worktreeName string
worktreeDir string
skipPermissionsUnsafe bool
// allowEscalation opts the run into mid-run model escalation: it registers
// the escalate_model tool and wires agent.Options.ModelSwitcher. Off by
// default — a run without the flag is byte-identical to before (no tool, nil
// switcher).
allowEscalation bool
// notifyMode overrides config.Notify.Mode for this run. Mutually exclusive
// with noNotify.
notifyMode string
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
noNotify bool
}
type execUsageError struct {
@@ -114,15 +144,38 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
registry := newCoreRegistry(workspaceRoot)
// Register the escalate_model tool only when the run opted into mid-run
// escalation. It is registered before --list-tools formatting and
// validateExecToolFilters so the tool is both listable and filter-validatable.
// The no-arg constructor builds its own default registry internally (and
// degrades to an inert tool if the catalog fails), so no registry is threaded.
if options.allowEscalation {
registry.Register(tools.NewEscalateModelTool())
}
var specialistRuntime *specialist.Runtime
if shouldRegisterExecSpecialistTools(options) {
var err error
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error())
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
}
permissionMode, err := resolveExecPermissionMode(options)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
@@ -138,15 +191,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err := preflightExecSession(options); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, deps)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
prompt, err := resolveExecPrompt(options, workspaceRoot, deps.stdin)
prompt, streamImages, err := resolveExecPrompt(options, workspaceRoot, deps.stdin)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
sessionTitle := execSessionTitle(options, prompt)
// Build the model registry once and reuse it across every model-aware
// lookup in this exec run (model resolution, reasoning-effort advisory, and
@@ -157,11 +207,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
modelRegistry, _ := modelregistry.DefaultRegistry()
overrides := config.Overrides{}
if options.model != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, options.model)
modelOverride := options.model
if options.useSpec && options.specModel != "" {
modelOverride = options.specModel
}
if modelOverride != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
overrides.Provider.Model = resolvedModel
if notice != "" {
fmt.Fprintln(stderr, notice)
if _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
}
}
if options.maxTurns > 0 {
@@ -171,16 +227,61 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
if resolved.Provider == (config.ProviderProfile{}) {
if !config.HasProviderProfile(resolved.Provider) {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Set OPENAI_MODEL/OPENAI_API_KEY or add .zero/config.json.")
}
// Activate deferred MCP-tool loading for this run only when the VISIBLE
// deferred-eligible count meets the resolved threshold; below threshold this
// is a no-op and tool advertising stays byte-identical. The registry is
// already complete (core + MCP) at this point, so the count is accurate. The
// permission mode and operator tool filters MUST match the values passed to
// agent.Run below so this registration gate counts the same population the
// loop's partition gate counts.
// tool_search is the only way to reach a hidden deferred tool, so if the
// operator explicitly disables it, deferral must not activate at all —
// otherwise a positive threshold would hide tools behind a loader the run
// rejects (a dead-end). Force the effective threshold to 0 so this
// registration gate and agent.Run's partition gate agree the run is inactive.
effectiveDeferThreshold := resolved.Tools.DeferThreshold
if toolListContains(options.disabledTools, tools.ToolSearchToolName) {
effectiveDeferThreshold = 0
}
registerToolSearchIfEligible(registry, effectiveDeferThreshold, permissionMode, options.enabledTools, options.disabledTools)
images, err := resolveExecImages(options.imagePaths, workspaceRoot)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
// Merge stream-json message images with --image attachments so BOTH input
// sources flow through the same vision gate (drop+warn) and the same
// agent.Options.Images wiring below. Without this merge, images sent over
// stream-json are parsed and validated but never reach the agent.
images = append(images, streamImages...)
// Gate against the EFFECTIVE resolved model (not the --model override). An
// unknown/custom id can't be confirmed vision-capable, so drop+warn rather
// than error: image input is best-effort, never fatal to the run.
if len(images) > 0 && !modelregistry.SupportsVision(modelRegistry, resolved.Provider.Model) {
if _, err := fmt.Fprintf(stderr, "Model %s does not support image input; ignoring %d image(s).\n", resolved.Provider.Model, len(images)); err != nil {
return exitCrash
}
images = nil
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
runReasoningEffort := options.reasoningEffort
if options.useSpec && options.specReasoningEffort != "" {
runReasoningEffort = options.specReasoningEffort
}
// Evaluate the --reasoning-effort advisory against the EFFECTIVE resolved
// model (resolved.Provider.Model), not the override. Without an explicit
// --model the override model is empty, so checking it here would silently
// skip the advisory even though the run uses a concrete effective model.
if options.reasoningEffort != "" {
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, options.reasoningEffort); notice != "" {
fmt.Fprintln(stderr, notice)
if runReasoningEffort != "" {
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, runReasoningEffort); notice != "" {
if _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
}
}
@@ -188,22 +289,82 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
// currentModel tracks the model in force for usage attribution. It starts at
// the resolved model and is reassigned by the model switcher on a mid-run
// escalation so post-switch turns are attributed to the escalated model.
currentModel := resolved.Provider.Model
var modelSwitcher func(context.Context, string) (agent.Provider, error)
if options.allowEscalation {
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
if err != nil {
return nil, err
}
// Mirror the agent loop's switch guard (it only reassigns the provider
// when newProvider != nil). Updating currentModel only on a non-nil
// provider keeps usage attribution consistent with whether the loop
// actually switched — a (nil, nil) return leaves both untouched.
if switchedProvider != nil {
currentModel = modelID
}
return switchedProvider, nil
}
}
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
// Notify on completion via stderr (never stdout, which may carry stream-json).
// Headless has no terminal-focus signal, so focus gating does not apply here:
// always emit when a mode is configured (focusMode is a TUI-only concept).
notifier := notify.New(stderr, notify.Config{
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
FocusMode: notify.FocusAlways,
})
if options.useSpec {
return runExecSpecDraft(execSpecDraftRun{
options: options,
stdout: stdout,
stderr: stderr,
deps: deps,
workspaceRoot: workspaceRoot,
registry: registry,
modelRegistry: modelRegistry,
resolved: resolved,
runMetadata: runMetadata,
provider: provider,
sandboxEngine: sandboxEngine,
prompt: prompt,
sessionTitle: sessionTitle,
images: images,
reasoningEffort: runReasoningEffort,
specPermissionMode: permissionMode,
notifier: notifier,
})
}
preparedSession := sessions.PreparedExec{}
agentPrompt := prompt
if shouldUseExecSession(options) {
preparedSession, err = sessions.PrepareExec(sessions.PrepareExecOptions{
Title: createSessionTitle(prompt),
Cwd: workspaceRoot,
ModelID: resolved.Provider.Model,
Provider: runMetadata.Provider,
Resume: options.resume,
ResumeLatest: options.resumeLatest,
Fork: options.fork,
SessionID: options.initSessionID,
Title: sessionTitle,
Cwd: workspaceRoot,
ModelID: resolved.Provider.Model,
Provider: runMetadata.Provider,
Tag: options.tag,
Depth: options.depth,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
AgentName: specialistAgentName(options.sessionTitle),
TaskID: options.initSessionID,
Resume: options.resume,
ResumeLatest: options.resumeLatest,
Fork: options.fork,
})
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
@@ -251,16 +412,32 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// user, so ask_user degrades to a "proceed with your best assumption" result
// rather than blocking. (Future enhancement: collect answers over stream-json
// input when a controlling client is attached.)
result, err := agent.Run(context.Background(), agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
Sandbox: sandboxEngine,
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
// Cancel the agent run on Ctrl+C / SIGTERM so a long headless run shuts down
// cleanly (the loop honors context cancellation) instead of being killed.
runCtx, stopSignals := signalContext()
defer stopSignals()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
DeferThreshold: effectiveDeferThreshold,
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
Tag: options.tag,
Depth: options.depth,
SessionTitle: sessionTitle,
Model: resolved.Provider.Model,
ModelSwitcher: modelSwitcher,
ReasoningEffort: runReasoningEffort,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
Sandbox: sandboxEngine,
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
@@ -298,17 +475,41 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
},
OnUsage: func(usage agent.Usage) {
writer.usage(usage)
sessionRecorder.append(sessions.EventUsage, map[string]any{
payload := map[string]any{
"promptTokens": usage.EffectiveInputTokens(),
"completionTokens": usage.EffectiveOutputTokens(),
"totalTokens": usage.TotalTokens(),
})
}
// Attribute usage to a specific model ONLY when escalation is enabled:
// the model in force can change mid-run only under --allow-escalation, so
// the "model" key is meaningful exclusively then. Omitting it otherwise
// keeps a non-escalation run's persisted usage payload byte-identical to
// before this feature (the origin/main guarantee).
if options.allowEscalation {
payload["model"] = currentModel
}
sessionRecorder.append(sessions.EventUsage, payload)
},
})
notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if writer.err != nil {
return exitCrash
}
if err != nil {
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
if options.outputFormat == execOutputStreamJSON {
writer.errorEvent("interrupted", "run cancelled by signal", false)
writer.runEnd("interrupted", exitInterrupted)
if writer.err != nil {
return exitCrash
}
} else {
fmt.Fprintln(stderr, "Interrupted.")
}
return exitInterrupted
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
if options.outputFormat == execOutputStreamJSON {
writer.errorEvent("provider_error", err.Error(), false)
@@ -333,12 +534,52 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitSuccess
}
func buildExecSandboxEngine(workspaceRoot string, deps appDeps) (*sandbox.Engine, error) {
// deferredEligibleCount returns the number of registered tools that are
// deferred-eligible (MCP tools) AND visible to the model for THIS run — i.e. they
// pass the same agent.ToolVisible gate (permission-mode advertising + operator
// allow/deny filters) that the agent loop's partitionTools applies when it
// decides whether deferral activates. Counting the SAME visible-deferred
// population here keeps registration and activation in agreement: tool_search is
// registered iff the partition will actually go active. Built-ins never implement
// the Deferred interface, so they never count.
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
count := 0
for _, tool := range registry.All() {
if !tools.IsDeferred(tool) {
continue
}
if !agent.ToolVisible(tool, permissionMode, enabledTools, disabledTools) {
continue
}
count++
}
return count
}
// registerToolSearchIfEligible registers the tool_search tool only when deferral
// is active for this run: the visible-deferred count (the same population the
// agent loop's partition counts) meets the (positive) threshold. Below threshold
// or with a zero/negative threshold, tool_search is never registered, so the
// agent loop's partition stays inactive and tool advertising is byte-identical to
// today. The permissionMode + enabled/disabled filters MUST match the values the
// run passes to agent.Run so the registration gate and the activation gate count
// the same tools.
func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) {
if deferThreshold <= 0 {
return
}
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
return
}
registry.Register(tools.NewToolSearchTool(registry))
}
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps) (*sandbox.Engine, error) {
store, err := deps.newSandboxStore()
if err != nil {
return nil, err
}
policy := sandbox.DefaultPolicy()
policy := applyConfiguredAutonomyCeiling(sandbox.DefaultPolicy(), resolved.Sandbox.MaxAutonomy)
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
@@ -348,6 +589,29 @@ func buildExecSandboxEngine(workspaceRoot string, deps appDeps) (*sandbox.Engine
}), nil
}
// applyConfiguredAutonomyCeiling overlays a config-sourced autonomy ceiling onto
// a policy. An empty ceiling is a no-op so the default High ceiling is preserved
// (backward compatible).
//
// A non-empty but unrecognised value FAILS CLOSED: it clamps to the most
// restrictive ceiling (AutonomyLow) rather than leaving the policy unchanged.
// config.Resolve already rejects invalid values, so this branch is defense in
// depth — it guarantees that even a direct/programmatic caller passing a typo
// can never WIDEN the ceiling back to the High default.
func applyConfiguredAutonomyCeiling(policy sandbox.Policy, maxAutonomy string) sandbox.Policy {
trimmed := strings.TrimSpace(maxAutonomy)
if trimmed == "" {
return policy
}
normalized, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(trimmed))
if err != nil {
policy.MaxAutonomy = sandbox.AutonomyLow
return policy
}
policy.MaxAutonomy = normalized
return policy
}
func resolveWorkspaceRoot(cwd string, deps appDeps) (string, error) {
current, err := deps.getwd()
if err != nil {
@@ -369,7 +633,12 @@ func resolveWorkspaceRoot(cwd string, deps appDeps) (string, error) {
return workspaceRoot, nil
}
func resolveExecPrompt(options execOptions, workspaceRoot string, stdin io.Reader) (string, error) {
// resolveExecPrompt resolves the run's prompt text and, for stream-json input,
// the images carried on its message events. The returned image slice is nil for
// text input and for stream-json input that carries no images; it is merged with
// any --image attachments by the caller before the shared vision gate, so both
// sources flow through the same drop+warn and agent.Options.Images wiring.
func resolveExecPrompt(options execOptions, workspaceRoot string, stdin io.Reader) (string, []zeroruntime.ImageBlock, error) {
if options.inputFormat == execInputStreamJSON {
input := ""
if options.file != "" {
@@ -379,21 +648,36 @@ func resolveExecPrompt(options execOptions, workspaceRoot string, stdin io.Reade
}
data, err := os.ReadFile(promptPath)
if err != nil {
return "", execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
return "", nil, execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
}
input = string(data)
} else {
data, err := io.ReadAll(stdin)
if err != nil {
return "", execUsageError{fmt.Sprintf("failed to read stream-json input: %v", err)}
return "", nil, execUsageError{fmt.Sprintf("failed to read stream-json input: %v", err)}
}
input = string(data)
}
prompt, err := streamjson.ParsePrompt(input)
events, err := streamjson.ParseInput(input)
if err != nil {
return "", execUsageError{err.Error()}
return "", nil, execUsageError{err.Error()}
}
return prompt, nil
streamImages, err := streamjson.ResolveImages(events)
if err != nil {
return "", nil, execUsageError{err.Error()}
}
prompt, perr := streamjson.ResolvePrompt(events)
if perr != nil {
// An image-only turn (a message event with empty content but at least
// one image) is valid: ResolvePrompt rejects empty content, but with
// images present the run proceeds with an empty prompt. Only a turn
// with neither text nor images is a real error.
if len(streamImages) == 0 {
return "", nil, execUsageError{perr.Error()}
}
prompt = ""
}
return prompt, streamImages, nil
}
parts := []string{}
@@ -409,20 +693,43 @@ func resolveExecPrompt(options execOptions, workspaceRoot string, stdin io.Reade
}
data, err := os.ReadFile(promptPath)
if err != nil {
return "", execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
return "", nil, execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
}
filePrompt := strings.TrimSpace(string(data))
if filePrompt == "" {
return "", execUsageError{fmt.Sprintf("prompt file is empty: %s", promptPath)}
return "", nil, execUsageError{fmt.Sprintf("prompt file is empty: %s", promptPath)}
}
parts = append(parts, filePrompt)
}
prompt := strings.TrimSpace(strings.Join(parts, "\n\n"))
if prompt == "" {
return "", execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
return "", nil, execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
}
return prompt, nil
return prompt, nil, nil
}
// resolveExecImages loads each --image attachment through the shared
// imageinput.LoadFile loader (read, sniff, normalize, 10-MiB cap), resolving
// relative paths against workspaceRoot. It is a thin per-path loop: the actual
// read/sniff/cap logic lives in internal/imageinput so the CLI and TUI surfaces
// never duplicate it. Any loader error (missing file, unsupported type,
// oversized) is wrapped into an execUsageError so the run reports it as a usage
// problem rather than reaching a provider with an invalid image. Returns nil for
// an empty path list (text-only behavior unchanged).
func resolveExecImages(paths []string, workspaceRoot string) ([]zeroruntime.ImageBlock, error) {
if len(paths) == 0 {
return nil, nil
}
images := make([]zeroruntime.ImageBlock, 0, len(paths))
for _, path := range paths {
image, err := imageinput.LoadFile(path, workspaceRoot)
if err != nil {
return nil, execUsageError{err.Error()}
}
images = append(images, image)
}
return images, nil
}
func writeExecUsageError(stderr io.Writer, message string) int {
@@ -610,3 +917,16 @@ func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType
}
return sessions.EventPermission
}
// execNotifyMode resolves the effective notification mode for a run:
// --no-notify forces "off", --notify <mode> overrides config, otherwise the
// config value is used.
func execNotifyMode(options execOptions, resolved config.ResolvedConfig) string {
if options.noNotify {
return string(notify.ModeOff)
}
if options.notifyMode != "" {
return options.notifyMode
}
return resolved.Notify.Mode
}
+55
View File
@@ -0,0 +1,55 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestRunExecDropsImagesOnNonVisionModelWithWarning drives a full exec run with
// an --image attachment against a custom (catalog-unknown) model id. The vision
// gate cannot confirm vision support for an unknown id, so it must warn on
// stderr and proceed text-only (exit 0), never erroring the run.
func TestRunExecDropsImagesOnNonVisionModelWithWarning(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "shot.png"), pngBytes, 0o600); err != nil {
t.Fatal(err)
}
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--cwd", root,
"--model", "my-custom-vision-less-model",
"--image", "shot.png",
"describe the screenshot",
})
if exitCode != 0 {
t.Fatalf("expected exit code 0 (drop+warn, never error), got %d: %s", exitCode, stderr)
}
if !strings.Contains(stderr, "does not support image input") {
t.Fatalf("expected non-vision warning on stderr, got %q", stderr)
}
}
// TestRunExecRejectsUnsupportedImageType confirms the usage-error path is wired
// into the run (a .txt sniffs as text -> unsupported, exit 2).
func TestRunExecRejectsUnsupportedImageType(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("not an image at all"), 0o600); err != nil {
t.Fatal(err)
}
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--cwd", root,
"--image", "notes.txt",
"look",
})
if exitCode != 2 {
t.Fatalf("expected usage exit code 2, got %d: %s", exitCode, stderr)
}
if !strings.Contains(stderr, "unsupported image type") {
t.Fatalf("expected unsupported-image-type error on stderr, got %q", stderr)
}
}
+121
View File
@@ -0,0 +1,121 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// pngBytes is a minimal valid PNG (8-byte signature is enough for
// http.DetectContentType to sniff "image/png").
var pngBytes = []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
}
func TestResolveExecImagesValidSingle(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "shot.png"), pngBytes, 0o600); err != nil {
t.Fatal(err)
}
images, err := resolveExecImages([]string{"shot.png"}, root)
if err != nil {
t.Fatalf("resolveExecImages error: %v", err)
}
if len(images) != 1 {
t.Fatalf("len(images) = %d, want 1", len(images))
}
if images[0].MediaType != "image/png" {
t.Fatalf("MediaType = %q, want image/png", images[0].MediaType)
}
if !bytes.Equal(images[0].Data, pngBytes) {
t.Fatalf("Data = %v, want raw png bytes", images[0].Data)
}
}
func TestResolveExecImagesRepeatedAndRelativeToRoot(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "media")
if err := os.MkdirAll(sub, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "a.png"), pngBytes, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "b.png"), pngBytes, 0o600); err != nil {
t.Fatal(err)
}
images, err := resolveExecImages([]string{"a.png", "media/b.png"}, root)
if err != nil {
t.Fatalf("resolveExecImages error: %v", err)
}
if len(images) != 2 {
t.Fatalf("len(images) = %d, want 2", len(images))
}
}
func TestResolveExecImagesMissingFileIsUsageError(t *testing.T) {
root := t.TempDir()
_, err := resolveExecImages([]string{"nope.png"}, root)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := err.(execUsageError); !ok {
t.Fatalf("error type = %T, want execUsageError", err)
}
if !strings.Contains(err.Error(), "image file not found") {
t.Fatalf("error = %q, want image-file-not-found", err.Error())
}
}
func TestResolveExecImagesUnsupportedTypeIsUsageError(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("just some text, not an image"), 0o600); err != nil {
t.Fatal(err)
}
_, err := resolveExecImages([]string{"notes.txt"}, root)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := err.(execUsageError); !ok {
t.Fatalf("error type = %T, want execUsageError", err)
}
if !strings.Contains(err.Error(), "unsupported image type") {
t.Fatalf("error = %q, want unsupported-image-type", err.Error())
}
}
func TestResolveExecImagesOversizedIsUsageError(t *testing.T) {
root := t.TempDir()
big := make([]byte, (10<<20)+1)
copy(big, pngBytes) // keep a valid png sniff at the head
if err := os.WriteFile(filepath.Join(root, "huge.png"), big, 0o600); err != nil {
t.Fatal(err)
}
_, err := resolveExecImages([]string{"huge.png"}, root)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := err.(execUsageError); !ok {
t.Fatalf("error type = %T, want execUsageError", err)
}
if !strings.Contains(err.Error(), "10 MiB") {
t.Fatalf("error = %q, want size-cap message", err.Error())
}
}
func TestResolveExecImagesEmptyReturnsNil(t *testing.T) {
images, err := resolveExecImages(nil, t.TempDir())
if err != nil {
t.Fatalf("resolveExecImages error: %v", err)
}
if images != nil {
t.Fatalf("images = %#v, want nil", images)
}
}
+198 -3
View File
@@ -4,6 +4,9 @@ import (
"fmt"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specialist"
)
func parseExecArgs(args []string) (execOptions, bool, error) {
@@ -21,6 +24,23 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
options.skipPermissionsUnsafe = true
case arg == "--list-tools":
options.listTools = true
case arg == "--allow-escalation":
options.allowEscalation = true
case arg == "--no-notify":
options.noNotify = true
case arg == "--notify":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.notifyMode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--notify="):
value, err := requiredInlineFlagValue(arg, "--notify")
if err != nil {
return options, false, err
}
options.notifyMode = value
case arg == "--auto":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -57,6 +77,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--file="):
options.file = strings.TrimSpace(strings.TrimPrefix(arg, "--file="))
case arg == "--image":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.imagePaths = append(options.imagePaths, value)
index = next
case strings.HasPrefix(arg, "--image="):
value, err := requiredInlineFlagValue(arg, "--image")
if err != nil {
return options, false, err
}
options.imagePaths = append(options.imagePaths, value)
case arg == "--mode":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -65,7 +98,11 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
options.mode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--mode="):
options.mode = strings.TrimSpace(strings.TrimPrefix(arg, "--mode="))
value, err := requiredInlineFlagValue(arg, "--mode")
if err != nil {
return options, false, err
}
options.mode = value
case arg == "-m" || arg == "--model":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -93,6 +130,34 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--reasoning-effort="):
options.reasoningEffort = strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
case arg == "--use-spec":
options.useSpec = true
case arg == "--spec-model":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.specModel = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--spec-model="):
value, err := requiredInlineFlagValue(arg, "--spec-model")
if err != nil {
return options, false, err
}
options.specModel = value
case arg == "--spec-reasoning-effort":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.specReasoningEffort = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--spec-reasoning-effort="):
value, err := requiredInlineFlagValue(arg, "--spec-reasoning-effort")
if err != nil {
return options, false, err
}
options.specReasoningEffort = value
case arg == "--max-turns":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -185,6 +250,88 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--fork="):
options.fork = strings.TrimSpace(strings.TrimPrefix(arg, "--fork="))
case arg == "--calling-session-id":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.callingSessionID = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--calling-session-id="):
value, err := requiredInlineFlagValue(arg, "--calling-session-id")
if err != nil {
return options, false, err
}
options.callingSessionID = value
case arg == "--calling-tool-use-id":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.callingToolUseID = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--calling-tool-use-id="):
value, err := requiredInlineFlagValue(arg, "--calling-tool-use-id")
if err != nil {
return options, false, err
}
options.callingToolUseID = value
case arg == "--tag":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.tag = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--tag="):
value, err := requiredInlineFlagValue(arg, "--tag")
if err != nil {
return options, false, err
}
options.tag = value
case arg == "--depth":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
depth, err := parseExecDepth(value)
if err != nil {
return options, false, err
}
options.depth = depth
index = next
case strings.HasPrefix(arg, "--depth="):
depth, err := parseExecDepth(strings.TrimSpace(strings.TrimPrefix(arg, "--depth=")))
if err != nil {
return options, false, err
}
options.depth = depth
case arg == "--session-title":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.sessionTitle = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--session-title="):
value, err := requiredInlineFlagValue(arg, "--session-title")
if err != nil {
return options, false, err
}
options.sessionTitle = value
case arg == "--init-session-id":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.initSessionID = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--init-session-id="):
value, err := requiredInlineFlagValue(arg, "--init-session-id")
if err != nil {
return options, false, err
}
options.initSessionID = value
case arg == "-w" || arg == "--worktree":
options.worktree = true
if index+1 < len(args) && !flagValueLooksLikeOption(strings.TrimSpace(args[index+1])) && strings.TrimSpace(args[index+1]) != "" {
@@ -213,15 +360,43 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
}
}
if options.noNotify && options.notifyMode != "" {
return options, false, execUsageError{"Use either --notify or --no-notify, not both."}
}
if options.notifyMode != "" {
switch options.notifyMode {
case "off", "bell", "notify", "both":
default:
return options, false, execUsageError{fmt.Sprintf("invalid --notify %q. Expected off, bell, notify, or both.", options.notifyMode)}
}
}
if (options.resume != "" || options.resumeLatest) && options.fork != "" {
return options, false, execUsageError{"Use either --resume or --fork, not both."}
}
if options.useSpec && (options.resume != "" || options.resumeLatest || options.fork != "") {
return options, false, execUsageError{"--use-spec cannot be combined with --resume or --fork."}
}
if options.useSpec && strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
return options, false, execUsageError{"--use-spec cannot be used inside a specialist child session."}
}
if !options.useSpec && options.specModel != "" {
return options, false, execUsageError{"--spec-model requires --use-spec."}
}
if !options.useSpec && options.specReasoningEffort != "" {
return options, false, execUsageError{"--spec-reasoning-effort requires --use-spec."}
}
if options.initSessionID != "" && (options.resume != "" || options.resumeLatest) {
return options, false, execUsageError{"Use --init-session-id only when creating or forking a session."}
}
if options.worktree && options.fork != "" {
return options, false, execUsageError{"--fork cannot be used with --worktree. Forked sessions must continue in the source session workspace."}
}
if options.worktreeDir != "" && !options.worktree {
return options, false, execUsageError{"--worktree-dir requires --worktree."}
}
if options.initSessionID != "" && !sessions.ValidSessionID(options.initSessionID) {
return options, false, execUsageError{fmt.Sprintf("invalid --init-session-id %q", options.initSessionID)}
}
if options.inputFormat == execInputStreamJSON && strings.TrimSpace(strings.Join(options.promptParts, " ")) != "" {
return options, false, execUsageError{"Stream-json input does not accept positional prompt text. Pipe JSONL or use --file."}
}
@@ -231,6 +406,18 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
return options, false, nil
}
func parseExecDepth(value string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, execUsageError{"--depth requires a value"}
}
depth, err := strconv.Atoi(trimmed)
if err != nil || depth < 0 {
return 0, execUsageError{fmt.Sprintf("invalid --depth %q. Expected a non-negative integer.", value)}
}
return depth, nil
}
func parseExecMaxTurns(value string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
@@ -254,6 +441,14 @@ func nextFlagValue(args []string, index int, flag string) (string, int, error) {
return next, index + 1, nil
}
func requiredInlineFlagValue(arg string, flag string) (string, error) {
value := strings.TrimSpace(strings.TrimPrefix(arg, flag+"="))
if value == "" {
return "", execUsageError{fmt.Sprintf("%s requires a value", flag)}
}
return value, nil
}
func flagValueLooksLikeOption(value string) bool {
if !strings.HasPrefix(value, "-") {
return false
@@ -270,10 +465,10 @@ func parseExecOutputFormat(value string) (execOutputFormat, error) {
return execOutputText, nil
case string(execOutputJSON):
return execOutputJSON, nil
case string(execOutputStreamJSON):
case string(execOutputStreamJSON), "debug":
return execOutputStreamJSON, nil
default:
return "", execUsageError{fmt.Sprintf("invalid output format %q. Expected text, json, or stream-json.", value)}
return "", execUsageError{fmt.Sprintf("invalid output format %q. Expected text, json, stream-json, or debug.", value)}
}
}
+49
View File
@@ -0,0 +1,49 @@
package cli
import (
"strings"
"testing"
)
func TestParseExecImageFlagRepeatable(t *testing.T) {
options, help, err := parseExecArgs([]string{
"--image", "one.png",
"--image=two.jpg",
"describe these",
})
if err != nil {
t.Fatalf("parseExecArgs returned error: %v", err)
}
if help {
t.Fatal("help = true, want false")
}
want := []string{"one.png", "two.jpg"}
if len(options.imagePaths) != len(want) {
t.Fatalf("imagePaths = %#v, want %#v", options.imagePaths, want)
}
for i := range want {
if options.imagePaths[i] != want[i] {
t.Fatalf("imagePaths[%d] = %q, want %q", i, options.imagePaths[i], want[i])
}
}
if strings.Join(options.promptParts, " ") != "describe these" {
t.Fatalf("promptParts = %#v", options.promptParts)
}
}
func TestParseExecImageFlagRequiresValue(t *testing.T) {
if _, _, err := parseExecArgs([]string{"--image"}); err == nil ||
!strings.Contains(err.Error(), "--image requires a value") {
t.Fatalf("expected --image requires a value error, got %v", err)
}
}
// TestParseExecImageInlineFlagRejectsEmpty locks that the inline `--image=` form
// rejects an empty value (the same empty-rejection the other inline flags use)
// instead of appending an empty image path.
func TestParseExecImageInlineFlagRejectsEmpty(t *testing.T) {
if _, _, err := parseExecArgs([]string{"--image=", "prompt"}); err == nil ||
!strings.Contains(err.Error(), "--image requires a value") {
t.Fatalf("expected --image requires a value error for empty inline value, got %v", err)
}
}
+20 -1
View File
@@ -40,7 +40,8 @@ func shouldUseExecSession(options execOptions) bool {
return options.outputFormat == execOutputStreamJSON ||
options.resume != "" ||
options.resumeLatest ||
options.fork != ""
options.fork != "" ||
options.initSessionID != ""
}
func preflightExecSession(options execOptions) error {
@@ -92,6 +93,24 @@ func createSessionTitle(prompt string) string {
return title
}
func execSessionTitle(options execOptions, prompt string) string {
if title := strings.TrimSpace(options.sessionTitle); title != "" {
return title
}
return createSessionTitle(prompt)
}
func specialistAgentName(sessionTitle string) string {
title := strings.TrimSpace(sessionTitle)
if title == "" {
return ""
}
if name, _, ok := strings.Cut(title, ":"); ok {
return strings.TrimSpace(name)
}
return title
}
func (recorder *execSessionRecorder) append(eventType sessions.EventType, payload any) {
if recorder.err != nil || recorder.prepared.Store == nil || recorder.prepared.Session.SessionID == "" {
return
+295
View File
@@ -0,0 +1,295 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type execSpecDraftRun struct {
options execOptions
stdout io.Writer
stderr io.Writer
deps appDeps
workspaceRoot string
registry *tools.Registry
modelRegistry modelregistry.Registry
resolved config.ResolvedConfig
runMetadata execRunMetadata
provider zeroruntime.Provider
sandboxEngine *sandbox.Engine
prompt string
sessionTitle string
images []zeroruntime.ImageBlock
reasoningEffort string
specPermissionMode agent.PermissionMode
notifier *notify.Notifier
}
type execSpecDraftInfo struct {
SpecID string
SpecTitle string
SpecFilePath string
RelativePath string
DraftSessionID string
}
func runExecSpecDraft(run execSpecDraftRun) int {
store := run.deps.newSessionStore()
draftSession, err := store.Create(sessions.CreateInput{
SessionID: run.options.initSessionID,
SessionKind: sessions.SessionKindSpecDraft,
Title: run.sessionTitle,
Cwd: run.workspaceRoot,
ModelID: run.resolved.Provider.Model,
Provider: run.runMetadata.Provider,
SpecDraftModelID: run.resolved.Provider.Model,
SpecDraftReasoning: run.reasoningEffort,
})
if err != nil {
return writeExecFormatUsageError(run.stdout, run.stderr, run.options.outputFormat, err.Error())
}
runID, err := streamjson.CreateRunID(run.deps.now())
if err != nil {
return writeAppError(run.stderr, "failed to create run id: "+err.Error(), exitCrash)
}
writer := execEventWriter{
stdout: run.stdout,
stderr: run.stderr,
format: run.options.outputFormat,
runID: runID,
sessionID: draftSession.SessionID,
streamedText: &strings.Builder{},
}
writer.runStart(run.workspaceRoot, run.runMetadata, run.specPermissionMode)
if writer.err != nil {
return exitCrash
}
preparedSession := sessions.PreparedExec{
Mode: sessions.ModeNew,
Session: draftSession,
Store: store,
}
sessionRecorder := execSessionRecorder{prepared: preparedSession}
sessionRecorder.append(sessions.EventMessage, map[string]any{
"role": "user",
"content": run.prompt,
})
var draftInfo execSpecDraftInfo
runCtx, stopSignals := signalContext()
defer stopSignals()
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
Model: run.resolved.Provider.Model,
ReasoningEffort: run.reasoningEffort,
Cwd: run.workspaceRoot,
SystemPrompt: specmode.DraftSystemPrompt,
Images: run.images,
Registry: run.registry,
PermissionMode: agent.PermissionModeSpecDraft,
Autonomy: "low",
Sandbox: run.sandboxEngine,
EnabledTools: run.options.enabledTools,
DisabledTools: run.options.disabledTools,
OnText: writer.text,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, run.registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
"id": call.ID,
"name": call.Name,
"arguments": call.Arguments,
})
if checkpoint, ok := sessionRecorder.captureCheckpoint(run.workspaceRoot, call); ok {
writer.checkpoint(checkpoint)
}
},
OnPermission: func(event agent.PermissionEvent) {
writer.permission(event)
sessionRecorder.append(sessionPermissionEventType(event), event)
},
OnToolResult: func(result agent.ToolResult) {
writer.toolResult(result)
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
draftInfo = info
}
payload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if result.Redacted {
payload["redacted"] = true
}
if len(result.ChangedFiles) > 0 {
payload["changedFiles"] = result.ChangedFiles
}
sessionRecorder.append(sessions.EventToolResult, payload)
},
OnUsage: func(usage agent.Usage) {
writer.usage(usage)
sessionRecorder.append(sessions.EventUsage, map[string]any{
"promptTokens": usage.EffectiveInputTokens(),
"completionTokens": usage.EffectiveOutputTokens(),
"totalTokens": usage.TotalTokens(),
})
},
})
run.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if writer.err != nil {
return exitCrash
}
if err != nil {
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
if run.options.outputFormat == execOutputStreamJSON {
writer.errorEvent("interrupted", "run cancelled by signal", false)
writer.runEnd("interrupted", exitInterrupted)
if writer.err != nil {
return exitCrash
}
} else {
fmt.Fprintln(run.stderr, "Interrupted.")
}
return exitInterrupted
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": err.Error()})
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, err.Error())
}
if result.StopReason != agent.StopReasonSpecReviewRequired || draftInfo.SpecID == "" || draftInfo.SpecFilePath == "" {
message := "spec draft ended without submit_spec"
sessionRecorder.append(sessions.EventError, map[string]any{"message": message})
return writeExecSpecDraftProviderError(&writer, run.options.outputFormat, message)
}
draftInfo.DraftSessionID = draftSession.SessionID
if _, _, err := store.RecordSpec(draftSession.SessionID, sessions.RecordSpecInput{
SpecID: draftInfo.SpecID,
SpecFilePath: draftInfo.SpecFilePath,
SpecStatus: sessions.SpecStatusDraft,
SpecDraftModelID: run.resolved.Provider.Model,
SpecDraftReasoning: run.reasoningEffort,
}); err != nil {
return writeAppError(run.stderr, err.Error(), exitCrash)
}
if emittedTerminal := writer.specReviewRequired(draftInfo); !emittedTerminal {
writer.runEnd(string(agent.StopReasonSpecReviewRequired), exitSuccess)
}
if writer.err != nil {
return exitCrash
}
return exitSuccess
}
func execSpecDraftInfoFromToolResult(result agent.ToolResult) (execSpecDraftInfo, bool) {
if result.Name != specmode.SubmitToolName || result.Meta["control"] != specmode.ControlSpecReviewRequired {
return execSpecDraftInfo{}, false
}
return execSpecDraftInfo{
SpecID: strings.TrimSpace(result.Meta["specId"]),
SpecTitle: strings.TrimSpace(result.Meta["specTitle"]),
SpecFilePath: strings.TrimSpace(result.Meta["specFilePath"]),
RelativePath: strings.TrimSpace(result.Meta["relativePath"]),
}, true
}
func writeExecSpecDraftProviderError(writer *execEventWriter, format execOutputFormat, message string) int {
if format == execOutputStreamJSON {
writer.errorEvent("provider_error", message, false)
writer.runEnd("error", exitProvider)
if writer.err != nil {
return exitCrash
}
return exitProvider
}
if format == execOutputJSON {
writer.errorEvent("provider_error", message, false)
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitProvider})
if writer.err != nil {
return exitCrash
}
return exitProvider
}
writer.errorEvent("provider_error", message, false)
if writer.err != nil {
return exitCrash
}
return exitProvider
}
func (writer *execEventWriter) specReviewRequired(info execSpecDraftInfo) bool {
summary := formatExecSpecDraftSummary(info)
switch writer.format {
case execOutputJSON:
writer.writeJSON(map[string]any{
"type": string(agent.StopReasonSpecReviewRequired),
"status": string(sessions.SpecStatusDraft),
"stop_reason": string(agent.StopReasonSpecReviewRequired),
"spec_id": info.SpecID,
"spec_title": info.SpecTitle,
"spec_file_path": info.SpecFilePath,
"relative_path": info.RelativePath,
"draft_session_id": info.DraftSessionID,
})
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
return true
case execOutputStreamJSON:
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventFinal,
RunID: writer.runID,
Text: summary,
Meta: map[string]string{
"status": string(sessions.SpecStatusDraft),
"stopReason": string(agent.StopReasonSpecReviewRequired),
"specId": info.SpecID,
"specTitle": info.SpecTitle,
"specFilePath": info.SpecFilePath,
"relativePath": info.RelativePath,
"draftSessionId": info.DraftSessionID,
},
})
return false
default:
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
writer.writeStdout("\n")
}
writer.writeStdout(summary + "\n")
return true
}
}
func formatExecSpecDraftSummary(info execSpecDraftInfo) string {
path := strings.TrimSpace(info.RelativePath)
if path == "" {
path = info.SpecFilePath
}
lines := []string{
"Spec draft saved for review.",
" spec: " + redact(info.SpecID),
" path: " + redact(path),
" draft session: " + redact(info.DraftSessionID),
"Next: zero spec show " + redact(info.SpecID) + "; zero spec approve " + redact(info.SpecID),
}
return strings.Join(lines, "\n")
}
+204
View File
@@ -0,0 +1,204 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestRunExecUseSpecCreatesDraftSession(t *testing.T) {
workspaceRoot := t.TempDir()
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLISpecTime})
provider := &submitSpecExecProvider{}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--cwd", workspaceRoot,
"--use-spec",
"--spec-model", "draft-model",
"--spec-reasoning-effort", "high",
"-o", "json",
"plan the review flow",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return workspaceRoot, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "default-model"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
return config.ResolvedConfig{
ActiveProvider: "test",
Provider: config.ProviderProfile{
Name: "test",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: model,
},
MaxTurns: 3,
}, nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
newSessionStore: func() *sessions.Store {
return store
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
},
now: fixedCLISpecTime,
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
}
events := decodeJSONLines(t, stdout.String())
if !slicesContains(jsonEventTypes(events), string(agent.StopReasonSpecReviewRequired)) {
t.Fatalf("missing spec_review_required event in %v", jsonEventTypes(events))
}
items, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("session count = %d, want 1: %#v", len(items), items)
}
draft := items[0]
if draft.SessionKind != sessions.SessionKindSpecDraft || draft.SpecStatus != sessions.SpecStatusDraft {
t.Fatalf("unexpected draft session: %#v", draft)
}
if draft.SpecID == "" || !strings.Contains(filepath.ToSlash(draft.SpecFilePath), ".zero/specs/") {
t.Fatalf("draft spec metadata missing: %#v", draft)
}
if draft.SpecDraftModelID != "draft-model" || draft.ModelID != "draft-model" || draft.SpecDraftReasoning != "high" {
t.Fatalf("draft model metadata = %#v", draft)
}
if len(provider.requests) != 1 {
t.Fatalf("provider request count = %d, want 1", len(provider.requests))
}
if !provider.requestIncludesTool(specmode.SubmitToolName) {
t.Fatalf("provider tools missing submit_spec: %#v", provider.requests[0].Tools)
}
}
func TestParseExecUseSpecRejectsResume(t *testing.T) {
_, _, err := parseExecArgs([]string{"--use-spec", "--resume", "zero_1", "plan"})
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
t.Fatalf("expected --use-spec/--resume validation, got %v", err)
}
}
func TestParseExecSpecOverridesRequireUseSpec(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "spec model",
args: []string{"--spec-model", "gpt-5", "plan"},
want: "--spec-model requires --use-spec",
},
{
name: "spec reasoning effort",
args: []string{"--spec-reasoning-effort", "high", "plan"},
want: "--spec-reasoning-effort requires --use-spec",
},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, err := parseExecArgs(tc.args)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q validation, got %v", tc.want, err)
}
})
}
}
func TestRunExecUseSpecRejectsSpecialistTag(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, []string{"exec", "--use-spec", "--tag", "specialist", "plan"})
if exitCode != exitUsage {
t.Fatalf("exit = %d, want %d stderr=%s", exitCode, exitUsage, stderr)
}
if !strings.Contains(stderr, "specialist child session") {
t.Fatalf("expected --use-spec specialist tag validation, got %q", stderr)
}
}
func TestRunExecUseSpecRejectsFiltersThatHideSubmitSpec(t *testing.T) {
for _, args := range [][]string{
{"exec", "--use-spec", "--disabled-tools", specmode.SubmitToolName, "plan"},
{"exec", "--use-spec", "--enabled-tools", "read_file", "plan"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, args)
if exitCode != exitUsage {
t.Fatalf("exit = %d, want %d stderr=%s", exitCode, exitUsage, stderr)
}
if !strings.Contains(stderr, "submit_spec") {
t.Fatalf("expected submit_spec validation, got %q", stderr)
}
})
}
}
type submitSpecExecProvider struct {
requests []zeroruntime.CompletionRequest
}
func (provider *submitSpecExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
arguments, _ := json.Marshal(map[string]string{
"title": "Review Flow",
"plan": "# Goal\n\nAdd the review flow.",
})
ch := make(chan zeroruntime.StreamEvent, 4)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: specmode.SubmitToolName}:
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: string(arguments)}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func (provider *submitSpecExecProvider) requestIncludesTool(name string) bool {
if len(provider.requests) == 0 {
return false
}
for _, tool := range provider.requests[0].Tools {
if tool.Name == name {
return true
}
}
return false
}
func slicesContains(values []string, value string) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
+172
View File
@@ -0,0 +1,172 @@
package cli
import (
"bytes"
"context"
"encoding/base64"
"strings"
"sync"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// capturingImageProvider records the images carried on the last user turn of the
// request it receives, so a test can assert what reached agent.Options.Images
// end-to-end (the agent seeds Options.Images onto the initial user message).
type capturingImageProvider struct {
mu sync.Mutex
images []zeroruntime.ImageBlock
}
func (provider *capturingImageProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.mu.Lock()
for index := len(request.Messages) - 1; index >= 0; index-- {
if request.Messages[index].Role == zeroruntime.MessageRoleUser {
provider.images = append([]zeroruntime.ImageBlock(nil), request.Messages[index].Images...)
break
}
}
provider.mu.Unlock()
ch := make(chan zeroruntime.StreamEvent, 2)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "ok"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// TestRunExecStreamJSONImageReachesAgent locks the end-to-end wiring: a base64
// image carried on a stream-json `message` event, fed via stdin on a vision
// model, must reach agent.Options.Images (and therefore the provider request's
// user turn). Before the fix, stream-json images were parsed and validated but
// silently dropped — never threaded into the agent run.
func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
cwd := t.TempDir()
// A minimal PNG, base64-encoded for the stream-json image payload.
png := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
}
encoded := base64.StdEncoding.EncodeToString(png)
input := `{"schemaVersion":1,"type":"message","role":"user","content":"describe this","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n"
provider := &capturingImageProvider{}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--cwd", cwd,
"--input-format", "stream-json",
// gpt-4.1 is a registry-known vision model, so the gate keeps the image.
"--model", "gpt-4.1",
}, &stdout, &stderr, appDeps{
stdin: strings.NewReader(input),
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "gpt-4.1"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
return config.ResolvedConfig{
ActiveProvider: "echo",
Provider: config.ProviderProfile{
Name: "echo",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: model,
},
MaxTurns: 3,
}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
provider.mu.Lock()
defer provider.mu.Unlock()
if len(provider.images) != 1 {
t.Fatalf("expected 1 image to reach the agent run, got %d (stderr=%q)", len(provider.images), stderr.String())
}
if provider.images[0].MediaType != "image/png" {
t.Fatalf("image media type = %q, want image/png", provider.images[0].MediaType)
}
if !bytes.Equal(provider.images[0].Data, png) {
t.Fatalf("image bytes = %v, want decoded png", provider.images[0].Data)
}
}
// TestRunExecStreamJSONImageOnlyMessageProceeds locks that an IMAGE-ONLY turn
// (a message event with EMPTY content but at least one image) is accepted: the
// run proceeds with an empty prompt and the image still reaches the agent. Before
// the fix, ResolvePrompt's "must include at least one prompt or user message
// event" error was returned before images were considered, rejecting the turn.
func TestRunExecStreamJSONImageOnlyMessageProceeds(t *testing.T) {
cwd := t.TempDir()
png := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
}
encoded := base64.StdEncoding.EncodeToString(png)
// Empty content, one image: the image-only turn.
input := `{"schemaVersion":1,"type":"message","role":"user","content":"","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n"
provider := &capturingImageProvider{}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--cwd", cwd,
"--input-format", "stream-json",
"--model", "gpt-4.1",
}, &stdout, &stderr, appDeps{
stdin: strings.NewReader(input),
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "gpt-4.1"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
return config.ResolvedConfig{
ActiveProvider: "echo",
Provider: config.ProviderProfile{
Name: "echo",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: model,
},
MaxTurns: 3,
}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("image-only turn rejected: exit code %d (want %d): %s", exitCode, exitSuccess, stderr.String())
}
provider.mu.Lock()
defer provider.mu.Unlock()
if len(provider.images) != 1 {
t.Fatalf("expected 1 image to reach the agent run, got %d (stderr=%q)", len(provider.images), stderr.String())
}
if provider.images[0].MediaType != "image/png" || !bytes.Equal(provider.images[0].Data, png) {
t.Fatalf("image not threaded: %#v", provider.images[0])
}
}
+938 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -36,6 +37,7 @@ func TestRunExecHelpDocumentsM1Flags(t *testing.T) {
}
for _, want := range []string{
"-f, --file",
"--image <path>",
"--mode <name>",
"-m, --model",
"--max-turns",
@@ -44,6 +46,12 @@ func TestRunExecHelpDocumentsM1Flags(t *testing.T) {
"-C, --cwd",
"-o, --output-format text|json",
"--prompt",
"--calling-session-id",
"--calling-tool-use-id",
"--tag <tag>",
"--depth <number>",
"--session-title",
"--init-session-id",
"--skip-permissions-unsafe",
} {
if !strings.Contains(stdout.String(), want) {
@@ -126,6 +134,193 @@ func TestRunExecMaxTurnsReachesConfigOverrides(t *testing.T) {
}
}
func TestParseExecSpecialistMetadataFlags(t *testing.T) {
options, help, err := parseExecArgs([]string{
"--calling-session-id", "parent_session",
"--calling-tool-use-id=toolu_123",
"--tag", "specialist",
"--depth=2",
"--session-title", "Explorer child",
"--init-session-id", "child_session",
"--output-format", "debug",
"inspect the parser",
})
if err != nil {
t.Fatalf("parseExecArgs returned error: %v", err)
}
if help {
t.Fatal("help = true, want false")
}
if options.callingSessionID != "parent_session" ||
options.callingToolUseID != "toolu_123" ||
options.tag != "specialist" ||
options.depth != 2 ||
options.sessionTitle != "Explorer child" ||
options.initSessionID != "child_session" {
t.Fatalf("metadata flags did not parse correctly: %#v", options)
}
if options.outputFormat != execOutputStreamJSON {
t.Fatalf("outputFormat = %q, want stream-json debug alias", options.outputFormat)
}
if strings.Join(options.promptParts, " ") != "inspect the parser" {
t.Fatalf("promptParts = %#v", options.promptParts)
}
}
func TestParseExecSpecialistMetadataRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "negative depth", args: []string{"--depth=-1", "hello"}, want: "invalid --depth"},
{name: "non numeric depth", args: []string{"--depth", "many", "hello"}, want: "invalid --depth"},
{name: "empty tag", args: []string{"--tag=", "hello"}, want: "--tag requires a value"},
{name: "bad init session", args: []string{"--init-session-id", "../escape", "hello"}, want: "invalid --init-session-id"},
{name: "init with resume", args: []string{"--init-session-id", "child", "--resume", "parent", "hello"}, want: "Use --init-session-id only"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, _, err := parseExecArgs(tc.args)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q error, got %v", tc.want, err)
}
})
}
}
func TestRunExecRegistersTaskOnlyForUnsafeTopLevelRuns(t *testing.T) {
tests := []struct {
name string
args []string
wantTask bool
}{
{name: "default headless", args: []string{"exec", "--list-tools"}, wantTask: false},
{name: "unsafe headless", args: []string{"exec", "--auto", "high", "--list-tools"}, wantTask: true},
{name: "specialist child", args: []string{"exec", "--auto", "high", "--tag", "specialist", "--list-tools"}, wantTask: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
for _, toolName := range []string{"Task", "TaskOutput", "TaskStop", "GenerateSpecialist"} {
hasTool := strings.Contains(stdout.String(), " "+toolName+" ")
if hasTool != tc.wantTask {
t.Fatalf("%s visibility = %v, want %v; output:\n%s", toolName, hasTool, tc.wantTask, stdout.String())
}
}
})
}
}
func TestRunExecUsesInitSessionIDAndSessionTitle(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--init-session-id", "specialist_child",
"--session-title", "Explorer child",
"--tag", "specialist",
"--depth", "1",
"hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
session, err := store.Get("specialist_child")
if err != nil {
t.Fatalf("Get session returned error: %v", err)
}
if session == nil {
t.Fatal("expected initialized session specialist_child")
}
if session.Title != "Explorer child" || session.Tag != "specialist" || session.Depth != 1 {
t.Fatalf("session metadata = %#v, want title/tag/depth", session)
}
if session.Cwd != cwd {
t.Fatalf("session cwd = %q, want %q", session.Cwd, cwd)
}
}
func TestRunExecPersistsCallingSessionChildMetadata(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session", Title: "Parent", Cwd: "/repo", ModelID: "gpt-parent", Provider: "openai"})
if err != nil {
t.Fatalf("Create parent returned error: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--output-format", "stream-json",
"--init-session-id", "child_session",
"--session-title", "worker: Auth check",
"--tag", "specialist",
"--depth", "1",
"--calling-session-id", parent.SessionID,
"--calling-tool-use-id", "toolu_123",
"hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
child, err := store.Get("child_session")
if err != nil {
t.Fatalf("Get child returned error: %v", err)
}
if child == nil {
t.Fatal("expected child session metadata")
}
if child.SessionKind != sessions.SessionKindChild ||
child.ParentSessionID != parent.SessionID ||
child.RootSessionID != parent.SessionID ||
child.AgentName != "worker" ||
child.TaskID != "child_session" ||
child.SpawnedFromEventID != "toolu_123" ||
child.Tag != "specialist" ||
child.Depth != 1 {
t.Fatalf("unexpected child metadata: %#v", child)
}
}
func TestRunExecModeSeedsModelAndTurnOverrides(t *testing.T) {
cwd := t.TempDir()
var gotModel string
@@ -526,7 +721,7 @@ func TestRunExecRejectsInvalidOutputFormatBeforeRuntime(t *testing.T) {
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, `invalid output format "yaml"`) {
if got := stderr.String(); !strings.Contains(got, `invalid output format "yaml"`) || !strings.Contains(got, "debug") {
t.Fatalf("expected output format validation error, got %q", got)
}
if strings.Contains(stdout.String()+stderr.String(), "Go agent runtime ready") {
@@ -940,3 +1135,745 @@ func TestRunExecInvalidAutoValidatedWithSkipPermissions(t *testing.T) {
t.Fatalf("expected autonomy validation error, got %q", got)
}
}
func TestParseExecAllowEscalationFlag(t *testing.T) {
t.Run("absent defaults to false", func(t *testing.T) {
options, help, err := parseExecArgs([]string{"hello"})
if err != nil {
t.Fatalf("parseExecArgs returned error: %v", err)
}
if help {
t.Fatal("help = true, want false")
}
if options.allowEscalation {
t.Fatal("allowEscalation = true, want false by default")
}
})
t.Run("flag sets true", func(t *testing.T) {
options, _, err := parseExecArgs([]string{"--allow-escalation", "hello"})
if err != nil {
t.Fatalf("parseExecArgs returned error: %v", err)
}
if !options.allowEscalation {
t.Fatal("allowEscalation = false, want true")
}
if strings.Join(options.promptParts, " ") != "hello" {
t.Fatalf("promptParts = %#v, want [hello]", options.promptParts)
}
})
}
func TestRunExecHelpDocumentsAllowEscalation(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"exec", "--help"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(stdout.String(), "--allow-escalation") {
t.Fatalf("expected exec help to document --allow-escalation, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunExecRegistersEscalateModelOnlyWithFlag(t *testing.T) {
for _, tc := range []struct {
name string
args []string
wantTool bool
}{
{name: "absent", args: []string{"exec", "--list-tools"}, wantTool: false},
{name: "present", args: []string{"exec", "--allow-escalation", "--list-tools"}, wantTool: true},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
hasTool := strings.Contains(stdout.String(), " escalate_model ")
if hasTool != tc.wantTool {
t.Fatalf("escalate_model visibility = %v, want %v; output:\n%s", hasTool, tc.wantTool, stdout.String())
}
})
}
}
// escalatingExecProvider drives an exec run through a mid-run switch without a
// live model. Each instance tracks how many turns IT served (turns) so a test
// can assert exactly which provider handled which turn across a switch.
//
// escalateOnce controls behavior: when true, the FIRST turn this instance serves
// emits an escalate_model tool call (later turns answer); when false, every turn
// emits a final text answer. Pointing escalateOnce at distinct instances lets a
// test prove the post-switch turn lands on the SECOND (escalated) provider.
type escalatingExecProvider struct {
// turns counts how many StreamCompletion calls THIS instance handled.
turns int
// escalateOnce makes this instance's first served turn request an escalation.
escalateOnce bool
}
func (provider *escalatingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
turn := provider.turns
provider.turns++
ch := make(chan zeroruntime.StreamEvent, 4)
if provider.escalateOnce && turn == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func TestRunExecWiresModelSwitcherUnderFlag(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
var providerModels []string
// Each newProvider build returns a DISTINCT provider instance with its OWN
// turn counter, mirroring the agent-layer firstProvider/secondProvider split.
// The first build (escalation source) must handle exactly the escalation turn;
// the second build (escalation target) must handle exactly the post-switch
// answer turn. Sharing a single counter would hide a dropped `provider =
// newProvider` in loop.go — here it is mutation-proven by the per-instance
// turn assertions below.
var builtProviders []*escalatingExecProvider
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--allow-escalation", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
// The escalation chain (haiku -> sonnet) is anthropic; declare the
// provider kind to match so provider-metadata resolution accepts it.
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerModels = append(providerModels, profile.Model)
// First build escalates; every later build answers. Each instance owns
// its turn counter so we can assert exactly-one-turn per provider.
provider := &escalatingExecProvider{escalateOnce: len(builtProviders) == 0}
builtProviders = append(builtProviders, provider)
return provider, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
// Initial provider build + one rebuild for the escalation target.
if len(providerModels) != 2 {
t.Fatalf("newProvider called for models %#v, want exactly 2 builds (initial + escalated)", providerModels)
}
if providerModels[0] != "claude-haiku-4.5" {
t.Fatalf("initial provider model = %q, want claude-haiku-4.5", providerModels[0])
}
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
if !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
if providerModels[1] != target {
t.Fatalf("escalated provider model = %q, want %q", providerModels[1], target)
}
if len(builtProviders) != 2 {
t.Fatalf("built %d providers, want 2", len(builtProviders))
}
// The original (escalation-source) provider handled ONLY the escalation turn;
// the escalated (target) provider handled ONLY the post-switch answer turn.
// This FAILS if loop.go drops `provider = newProvider` (the second provider
// would then handle zero turns and the first would handle both).
if builtProviders[0].turns != 1 {
t.Fatalf("first (source) provider handled %d turns, want exactly 1 (the escalation turn)", builtProviders[0].turns)
}
if builtProviders[1].turns != 1 {
t.Fatalf("second (escalated) provider handled %d turns, want exactly 1 (the post-switch answer turn)", builtProviders[1].turns)
}
}
func mustUpgradeTarget(t *testing.T, id string) (string, bool) {
t.Helper()
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
entry, ok := registry.UpgradeTarget(id)
return entry.ID, ok
}
func TestRunExecNoSwitcherWithoutFlag(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
var providerModels []string
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
// The escalation chain (haiku -> sonnet) is anthropic; declare the
// provider kind to match so provider-metadata resolution accepts it.
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerModels = append(providerModels, profile.Model)
return &escalatingExecProvider{escalateOnce: true}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if len(providerModels) != 1 {
t.Fatalf("newProvider called for %#v, want exactly 1 build (no switcher wired)", providerModels)
}
if providerModels[0] != "claude-haiku-4.5" {
t.Fatalf("provider model = %q, want claude-haiku-4.5", providerModels[0])
}
}
func TestRunExecAttributesUsageToEscalatedModel(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
if !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
// The escalation turn (turn 0, pre-switch) emits usage with distinct tokens so
// it can be told apart from the post-switch usage; the first build escalates,
// the second answers.
builds := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "escalation_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
// The escalation chain (haiku -> sonnet) is anthropic; declare the
// provider kind to match so provider-metadata resolution accepts it.
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
// First build = escalation source (escalate + usage 3/4 pre-switch);
// second build = escalation target (answer + usage 5/7 post-switch).
escalate := builds == 0
builds++
return &usageEmittingEscalatingProvider{escalate: escalate}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("escalation_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
// Collect every usage model attribution IN ORDER. The pre-switch usage must
// attribute to the ORIGINAL model and the post-switch usage to the escalated
// target — proving the loop's ordering guarantee (usage is attributed to the
// model that produced it, not retroactively reassigned after the switch).
var usageModels []string
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
model, _ := payload["model"].(string)
usageModels = append(usageModels, model)
}
if len(usageModels) != 2 {
t.Fatalf("recorded usage model attributions = %#v, want exactly 2 (pre- and post-switch)", usageModels)
}
if usageModels[0] != "claude-haiku-4.5" {
t.Fatalf("first (pre-switch) usage model = %q, want claude-haiku-4.5", usageModels[0])
}
if usageModels[1] != target {
t.Fatalf("second (post-switch) usage model = %q, want escalated target %q", usageModels[1], target)
}
}
func TestRunExecNilSwitchProviderKeepsOriginalAttribution(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
// The escalation rebuild returns (nil, nil): the loop must NOT swap the
// provider, and the CLI switcher must leave currentModel unchanged so usage
// after the declined switch stays attributed to the ORIGINAL model. Without
// the switchedProvider != nil guard, currentModel would advance to the target
// even though no switch happened, misattributing the post-turn usage.
builds := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "nil_switch_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builds++
// First build = the original (haiku). The escalation rebuild returns
// (nil, nil), so the loop keeps the original provider for every turn.
if builds == 1 {
return &escalateThenAnswerProvider{}, nil
}
return nil, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("nil_switch_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
sawUsage := false
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
sawUsage = true
if model, _ := payload["model"].(string); model != "claude-haiku-4.5" {
t.Fatalf("usage attributed to %q after a (nil,nil) switch, want original claude-haiku-4.5", model)
}
}
if !sawUsage {
t.Fatal("expected at least one usage event")
}
}
// escalateThenAnswerProvider escalates on its first turn and answers afterward,
// so a single instance can serve an entire run when no provider swap occurs
// (e.g. a (nil,nil) switcher). It emits usage on every turn for attribution.
type escalateThenAnswerProvider struct {
turns int
}
func (provider *escalateThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
turn := provider.turns
provider.turns++
ch := make(chan zeroruntime.StreamEvent, 6)
if turn == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// usageEmittingEscalatingProvider emits a usage event on every turn it serves so
// usage attribution can be traced across a mid-run switch. When escalate is set,
// its turn requests an escalation (and emits pre-switch usage tokens 3/4);
// otherwise it answers (post-switch usage tokens 5/7).
type usageEmittingEscalatingProvider struct {
escalate bool
}
func (provider *usageEmittingEscalatingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 6)
if provider.escalate {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
// Pre-switch usage: still attributed to the ORIGINAL model.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
// Post-switch usage: attributed to the escalated model.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// usageEmittingEchoProvider answers in a single turn and emits a usage event. It
// has no escalation behavior, so it exercises a plain (flag-OFF) run that still
// records usage.
type usageEmittingEchoProvider struct{}
func (usageEmittingEchoProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 3)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// TestRunExecUsageOmitsModelKeyWithoutEscalationFlag verifies the back-compat
// guarantee: a run WITHOUT --allow-escalation persists EventUsage payloads that
// carry NO "model" key (byte-identical to before the escalation feature), since
// the model can never change mid-run when escalation is off. The flag-ON,
// post-escalation case is covered by TestRunExecAttributesUsageToEscalatedModel.
func TestRunExecUsageOmitsModelKeyWithoutEscalationFlag(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--model", "claude-haiku-4.5",
"--init-session-id", "no_escalation_run",
"hello",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return usageEmittingEchoProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("no_escalation_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
var sawUsage bool
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
sawUsage = true
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
if _, ok := payload["model"]; ok {
t.Fatalf("flag-OFF usage payload must NOT carry a model key, got %#v", payload)
}
}
if !sawUsage {
t.Fatal("expected at least one usage event to be recorded")
}
}
// usageThenAnswerProvider serves a two-turn run on ONE instance: turn 0 requests
// an escalation and emits usage, turn 1 answers and emits usage. It is used by
// the switch-error test where no second provider is ever built, so a single
// instance must serve both turns on the original model.
type usageThenAnswerProvider struct {
turns int
}
func (provider *usageThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
turn := provider.turns
provider.turns++
ch := make(chan zeroruntime.StreamEvent, 6)
if turn == 0 {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// TestRunExecSwitcherErrorKeepsOriginalModelAttribution verifies that when the
// ModelSwitcher fails on the rebuild (deps.newProvider errors for the escalation
// target), the run is NON-FATAL and stays on the original provider — and every
// usage event, including the one AFTER the failed switch, stays attributed to the
// ORIGINAL model (currentModel is never reassigned on a failed switch).
func TestRunExecSwitcherErrorKeepsOriginalModelAttribution(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
builds := 0
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "switch_error_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builds++
// The first build (original model) succeeds; the rebuild on escalation
// FAILS, so the switcher returns an error and the run continues on the
// original provider.
if builds >= 2 {
return nil, errors.New("provider rebuild failed")
}
return &usageThenAnswerProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected non-fatal switch error, exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if builds != 2 {
t.Fatalf("newProvider builds = %d, want 2 (initial + one failed rebuild attempt)", builds)
}
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
events, err := store.ReadEvents("switch_error_run")
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
var usageModels []string
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
var payload map[string]any
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("unmarshal usage payload: %v", err)
}
model, _ := payload["model"].(string)
usageModels = append(usageModels, model)
}
if len(usageModels) != 2 {
t.Fatalf("recorded usage attributions = %#v, want 2", usageModels)
}
// Both usages (pre- AND post-failed-switch) stay on the ORIGINAL model.
for i, model := range usageModels {
if model != "claude-haiku-4.5" {
t.Fatalf("usage[%d] model = %q, want claude-haiku-4.5 (currentModel must not change on a failed switch)", i, model)
}
}
}
// TestRunExecTopTierDeclineNoSwitch verifies an end-to-end flag-ON run where the
// agent calls escalate_model while ALREADY on a top-tier model (claude-opus-4.1):
// the tool returns the informational no-meta result, NO switch happens
// (newProvider is called exactly once), and the run succeeds.
func TestParseExecNotifyFlag(t *testing.T) {
opts, _, err := parseExecArgs([]string{"--notify", "both", "hello"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if opts.notifyMode != "both" || opts.noNotify {
t.Fatalf("got mode=%q noNotify=%v", opts.notifyMode, opts.noNotify)
}
}
func TestParseExecNoNotifyFlag(t *testing.T) {
opts, _, err := parseExecArgs([]string{"--no-notify", "hello"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if !opts.noNotify {
t.Fatal("expected noNotify=true")
}
}
func TestParseExecNotifyConflict(t *testing.T) {
if _, _, err := parseExecArgs([]string{"--notify", "bell", "--no-notify", "hi"}); err == nil {
t.Fatal("expected error for --notify with --no-notify")
}
}
func TestParseExecNotifyInvalidValue(t *testing.T) {
for _, arg := range [][]string{{"--notify", "buzz", "hi"}, {"--notify=loud", "hi"}} {
if _, _, err := parseExecArgs(arg); err == nil {
t.Fatalf("expected error for invalid notify value in %v", arg)
}
}
// Valid values still parse.
for _, mode := range []string{"off", "bell", "notify", "both"} {
if _, _, err := parseExecArgs([]string{"--notify", mode, "hi"}); err != nil {
t.Fatalf("valid --notify %s rejected: %v", mode, err)
}
}
}
func TestExecNotifyModeResolution(t *testing.T) {
resolved := config.ResolvedConfig{Notify: config.NotifyConfig{Mode: "bell"}}
if got := execNotifyMode(execOptions{}, resolved); got != "bell" {
t.Fatalf("config passthrough got %q", got)
}
if got := execNotifyMode(execOptions{notifyMode: "both"}, resolved); got != "both" {
t.Fatalf("flag override got %q", got)
}
if got := execNotifyMode(execOptions{noNotify: true}, resolved); got != "off" {
t.Fatalf("--no-notify got %q", got)
}
}
func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
// Sanity: claude-opus-4.1 must genuinely be top-tier (no upgrade target), or
// the scenario is meaningless.
if _, ok := mustUpgradeTarget(t, "claude-opus-4.1"); ok {
t.Skip("registry unexpectedly has an upgrade target for claude-opus-4.1")
}
var providerModels []string
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-opus-4.1",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-opus-4.1"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerModels = append(providerModels, profile.Model)
return &escalatingExecProvider{escalateOnce: true}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
// The escalate_model call resolved to a no-target informational result, so the
// loop never requested a switch and newProvider was built exactly once.
if len(providerModels) != 1 {
t.Fatalf("newProvider built for %#v, want exactly 1 (top-tier decline performs no switch)", providerModels)
}
if providerModels[0] != "claude-opus-4.1" {
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
}
}
+23 -1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -34,7 +35,11 @@ func validateExecToolFilters(options execOptions, registry *tools.Registry) erro
if !toolNamePattern.MatchString(name) {
return execUsageError{fmt.Sprintf("Invalid tool name %q.", name)}
}
if _, ok := registry.Get(name); !ok {
// tool_search is registered later (only when deferral activates), so it is
// not in the registry yet at validation time. Treat it as always-valid so an
// operator can list it in --enabled-tools / --disabled-tools without a
// spurious "Unknown tool" error.
if _, ok := registry.Get(name); !ok && name != tools.ToolSearchToolName {
return execUsageError{fmt.Sprintf("Unknown tool: %s", name)}
}
}
@@ -47,9 +52,26 @@ func validateExecToolFilters(options execOptions, registry *tools.Registry) erro
return execUsageError{fmt.Sprintf("Tool cannot be both enabled and disabled: %s", name)}
}
}
if options.useSpec {
if disabled[specmode.SubmitToolName] {
return execUsageError{"--use-spec requires submit_spec; remove it from --disabled-tools."}
}
if len(options.enabledTools) > 0 && !toolListContains(options.enabledTools, specmode.SubmitToolName) {
return execUsageError{"--use-spec requires submit_spec; include it in --enabled-tools."}
}
}
return nil
}
func toolListContains(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error) {
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
// invalid autonomy value is always rejected. (Previously the unsafe path
+2
View File
@@ -390,6 +390,8 @@ func streamJSONSideEffect(name string, registry *tools.Registry) string {
return "shell"
case tools.SideEffectNetwork:
return "network"
case tools.SideEffectNone:
return "none"
default:
return "unknown"
}
+18
View File
@@ -0,0 +1,18 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// escalate_model is a control-only tool (SideEffectNone). The stream-json tool
// call must report sideEffect "none", not "unknown", so automation sees the
// promised value.
func TestStreamJSONSideEffectReportsNoneForControlTool(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewEscalateModelTool())
if got := streamJSONSideEffect("escalate_model", registry); got != "none" {
t.Fatalf("streamJSONSideEffect(escalate_model) = %q, want none", got)
}
}
+19 -7
View File
@@ -34,16 +34,28 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
// Resolve config paths for the validation check even if resolution itself
// fails on malformed JSON — that failure is exactly what config.validation
// is meant to report, so it must not abort the run.
var userConfig, projectConfig string
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
userConfig = resolveOptions.UserConfigPath
projectConfig = resolveOptions.ProjectConfigPath
}
var provider config.ProviderProfile
if resolved, resolveErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); resolveErr == nil {
provider = resolved.Provider
}
report := doctor.Run(doctor.Options{
Now: deps.now,
Runtime: "go",
Provider: resolved.Provider,
Connectivity: options.connectivity,
Now: deps.now,
Runtime: "go",
UserConfig: userConfig,
ProjectConfig: projectConfig,
Provider: provider,
Connectivity: options.connectivity,
})
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
+56
View File
@@ -3,6 +3,9 @@ package cli
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -240,6 +243,59 @@ func TestRunSearchJSONRedactsQueryAndSessionMetadata(t *testing.T) {
}
}
func TestRunDoctorReportsConfigValidationForMalformedFile(t *testing.T) {
cwd := t.TempDir()
zeroDir := filepath.Join(cwd, ".zero")
if err := os.MkdirAll(zeroDir, 0o755); err != nil {
t.Fatalf("mkdir .zero: %v", err)
}
if err := os.WriteFile(filepath.Join(zeroDir, "config.json"), []byte("{\n \"activeProvider\": \"openai\",\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"doctor", "--json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON %s: unexpected end of JSON input", filepath.Join(zeroDir, "config.json"))
},
now: fixedCLITime("2026-06-08T11:00:00Z"),
})
if exitCode != exitProvider {
t.Fatalf("expected provider exit %d, got %d: %s", exitProvider, exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
var report struct {
OK bool `json:"ok"`
Checks []struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("doctor JSON did not decode: %v\n%s", err, stdout.String())
}
if report.OK {
t.Fatalf("expected ok=false for malformed config, got %#v", report)
}
found := false
for _, check := range report.Checks {
if check.ID == "config.validation" {
found = true
if check.Status != "fail" {
t.Fatalf("expected config.validation fail, got %q", check.Status)
}
}
}
if !found {
t.Fatalf("expected config.validation check in report: %#v", report)
}
}
func fixedCLITime(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
+414
View File
@@ -0,0 +1,414 @@
package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/zerocommands"
)
type providerAddOptions struct {
catalogID string
name string
model string
baseURL string
apiKeyEnv string
authHeader string
authScheme string
authHeaderValue string
customHeaders map[string]string
setActive bool
json bool
}
type providerCheckOptions struct {
name string
json bool
}
func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderAddArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
profile, err := providerProfileForAdd(options)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
cfg, err := config.UpsertProvider(configPath, profile, options.setActive)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"configPath": configPath,
"activeProvider": cfg.ActiveProvider,
"provider": zerocommands.ProviderSnapshotFromProfile(profile, cfg.ActiveProvider == profile.Name),
}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Added provider %s to %s\n", profile.Name, configPath); err != nil {
return exitCrash
}
return exitSuccess
}
func runProvidersCheck(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderCheckArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
profile, err := selectProviderForCheck(resolved, options.name)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if err := validateProviderRuntimeReady(profile); err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
if _, err := deps.newProvider(profile); err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
snapshot := zerocommands.ProviderSnapshotFromProfile(profile, profile.Name == resolved.ActiveProvider)
if options.json {
if err := writePrettyJSON(stdout, map[string]any{"provider": snapshot, "status": "ok"}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Provider check\nname: %s\nstatus: ok\nkind: %s\nmodel: %s\napi model: %s\n", snapshot.Name, snapshot.ProviderKind, snapshot.Model, snapshot.APIModel); err != nil {
return exitCrash
}
return exitSuccess
}
func parseProviderAddArgs(args []string) (providerAddOptions, bool, error) {
options := providerAddOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case arg == "--set-active":
options.setActive = true
case arg == "--name":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.name = value
index = next
case strings.HasPrefix(arg, "--name="):
value, err := requiredInlineFlagValue(arg, "--name")
if err != nil {
return options, false, err
}
options.name = value
case arg == "--model":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.model = value
index = next
case strings.HasPrefix(arg, "--model="):
value, err := requiredInlineFlagValue(arg, "--model")
if err != nil {
return options, false, err
}
options.model = value
case arg == "--base-url":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.baseURL = value
index = next
case strings.HasPrefix(arg, "--base-url="):
value, err := requiredInlineFlagValue(arg, "--base-url")
if err != nil {
return options, false, err
}
options.baseURL = value
case arg == "--api-key-env":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.apiKeyEnv = value
index = next
case strings.HasPrefix(arg, "--api-key-env="):
value, err := requiredInlineFlagValue(arg, "--api-key-env")
if err != nil {
return options, false, err
}
options.apiKeyEnv = value
case arg == "--auth-header":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.authHeader = value
index = next
case strings.HasPrefix(arg, "--auth-header="):
value, err := requiredInlineFlagValue(arg, "--auth-header")
if err != nil {
return options, false, err
}
options.authHeader = value
case arg == "--auth-scheme":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.authScheme = value
index = next
case strings.HasPrefix(arg, "--auth-scheme="):
options.authScheme = strings.TrimSpace(strings.TrimPrefix(arg, "--auth-scheme="))
case arg == "--auth-header-value":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.authHeaderValue = value
index = next
case strings.HasPrefix(arg, "--auth-header-value="):
value, err := requiredInlineFlagValue(arg, "--auth-header-value")
if err != nil {
return options, false, err
}
options.authHeaderValue = value
case arg == "--header":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
if err := addCustomHeader(&options, value); err != nil {
return options, false, err
}
index = next
case strings.HasPrefix(arg, "--header="):
value, err := requiredInlineFlagValue(arg, "--header")
if err != nil {
return options, false, err
}
if err := addCustomHeader(&options, value); err != nil {
return options, false, err
}
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
if options.catalogID != "" {
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
options.catalogID = arg
}
}
if strings.TrimSpace(options.catalogID) == "" {
return options, false, execUsageError{"provider catalog id is required"}
}
return options, false, nil
}
func parseProviderCheckArgs(args []string) (providerCheckOptions, bool, error) {
options := providerCheckOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
if options.name != "" {
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
options.name = arg
}
}
return options, false, nil
}
func addCustomHeader(options *providerAddOptions, value string) error {
key, headerValue, ok := strings.Cut(value, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return execUsageError{fmt.Sprintf("invalid header %q; want key=value", value)}
}
if options.customHeaders == nil {
options.customHeaders = map[string]string{}
}
options.customHeaders[key] = strings.TrimSpace(headerValue)
return nil
}
func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile, error) {
descriptor, err := providercatalog.Require(options.catalogID)
if err != nil {
return config.ProviderProfile{}, err
}
if !providercatalog.RuntimeSupported(descriptor) {
return config.ProviderProfile{}, fmt.Errorf("provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
}
name := strings.TrimSpace(options.name)
if name == "" {
name = descriptor.ID
}
apiKeyEnv := strings.TrimSpace(options.apiKeyEnv)
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
apiKeyEnv = descriptor.AuthEnvVars[0]
}
profile := config.ProviderProfile{
Name: name,
ProviderKind: providerKindForDescriptor(descriptor),
CatalogID: descriptor.ID,
BaseURL: firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL),
APIKeyEnv: apiKeyEnv,
APIFormat: firstAPIFormat(descriptor),
AuthHeader: strings.TrimSpace(options.authHeader),
AuthScheme: normalizeAuthScheme(options.authScheme),
AuthHeaderValue: strings.TrimSpace(options.authHeaderValue),
CustomHeaders: copyProviderHeaders(options.customHeaders),
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
}
return profile, nil
}
func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config.ProviderProfile, error) {
name = strings.TrimSpace(name)
if name == "" {
if config.HasProviderProfile(resolved.Provider) {
return resolved.Provider, nil
}
return config.ProviderProfile{}, fmt.Errorf("no active provider configured")
}
for _, profile := range resolved.Providers {
if profile.Name == name {
return profile, nil
}
}
return config.ProviderProfile{}, fmt.Errorf("provider %q not found", name)
}
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
hasCredential := providerProfileHasCredential(profile)
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
return err
}
if !providercatalog.RuntimeSupported(descriptor) {
return fmt.Errorf("provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
}
if descriptor.RequiresAuth && !hasCredential {
apiKeyEnv := strings.TrimSpace(profile.APIKeyEnv)
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
apiKeyEnv = descriptor.AuthEnvVars[0]
}
if apiKeyEnv != "" {
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, apiKeyEnv)
}
return fmt.Errorf("provider %s requires API key", profile.Name)
}
return nil
}
switch profile.ProviderKind {
case config.ProviderKindOpenAI, config.ProviderKindAnthropic, config.ProviderKindGoogle:
if !hasCredential {
return fmt.Errorf("provider %s requires API key", profile.Name)
}
}
return nil
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
switch descriptor.Transport {
case providercatalog.TransportOpenAI:
return config.ProviderKindOpenAI
case providercatalog.TransportAnthropic:
return config.ProviderKindAnthropic
case providercatalog.TransportAnthropicCompatible:
return config.ProviderKindAnthropicCompat
case providercatalog.TransportGoogle:
return config.ProviderKindGoogle
case providercatalog.TransportOpenAICompatible:
return config.ProviderKindOpenAICompatible
default:
return config.ProviderKind(strings.ToLower(string(descriptor.Transport)))
}
}
func firstAPIFormat(descriptor providercatalog.Descriptor) string {
if descriptor.Transport == providercatalog.TransportOpenAI || descriptor.Transport == providercatalog.TransportOpenAICompatible {
return string(providercatalog.APIFormatOpenAIChatCompletions)
}
if len(descriptor.SupportedAPIFormats) == 0 {
return ""
}
return string(descriptor.SupportedAPIFormats[0])
}
func firstNonEmptyCLI(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func normalizeAuthScheme(value string) string {
value = strings.TrimSpace(value)
if strings.EqualFold(value, "none") || strings.EqualFold(value, "raw") {
return ""
}
return value
}
func copyProviderHeaders(headers map[string]string) map[string]string {
if headers == nil {
return nil
}
copied := make(map[string]string, len(headers))
for key, value := range headers {
copied[key] = value
}
return copied
}
+12 -1
View File
@@ -5,6 +5,7 @@ import (
"io"
"strings"
"github.com/Gitlawb/zero/internal/config"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
@@ -54,7 +55,15 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
policy := zeroSandbox.DefaultPolicy()
// Surface config resolution failures instead of silently falling back to
// DefaultPolicy() (High): an unresolvable config (e.g. an invalid
// sandbox.maxAutonomy that now errors at resolve time) would otherwise
// misreport the trust posture as the permissive default.
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
policy := applyConfiguredAutonomyCeiling(zeroSandbox.DefaultPolicy(), resolved.Sandbox.MaxAutonomy)
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
plan := backend.BuildPlan(workspaceRoot, policy)
if options.effective {
@@ -143,6 +152,7 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
"enforce_workspace: " + fmt.Sprintf("%t", policy.EnforceWorkspace),
"deny_destructive_shell: " + fmt.Sprintf("%t", policy.DenyDestructiveShell),
"allow_policy_only_runner: " + fmt.Sprintf("%t", policy.AllowPolicyOnlyRunner),
"max_autonomy: " + string(policy.MaxAutonomy),
"backend: " + string(backend.Name),
"support_level: " + string(plan.SupportLevel),
"interactive_command_guard: " + enabledLabel(guards.InteractiveCommand),
@@ -412,6 +422,7 @@ func formatSandboxPolicy(workspaceRoot string, policy zeroSandbox.Policy, backen
"root: " + workspaceRoot,
"mode: " + string(policy.Mode),
"network: " + string(policy.Network),
"max_autonomy: " + string(policy.MaxAutonomy),
"backend: " + string(backend.Name),
"support_level: " + string(plan.SupportLevel),
}
+115
View File
@@ -3,10 +3,12 @@ package cli
import (
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
@@ -104,6 +106,9 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendPolicyOnly,
@@ -187,6 +192,9 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendPolicyOnly,
@@ -300,6 +308,113 @@ func TestRunSandboxHelpDoesNotOpenStore(t *testing.T) {
}
}
func TestRunSandboxPolicyAppliesConfiguredCeiling(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Sandbox: config.SandboxConfig{MaxAutonomy: "medium"}}, nil
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
}
var payload struct {
Policy sandbox.Policy `json:"policy"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode policy JSON: %v\n%s", err, stdout.String())
}
if payload.Policy.MaxAutonomy != sandbox.AutonomyMedium {
t.Fatalf("policy.MaxAutonomy = %q, want medium", payload.Policy.MaxAutonomy)
}
}
func TestRunSandboxPolicyTextShowsMaxAutonomy(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Sandbox: config.SandboxConfig{MaxAutonomy: "medium"}}, nil
},
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"sandbox", "policy"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("policy exit = %d, stderr %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "max_autonomy: medium") {
t.Fatalf("policy text missing max_autonomy line:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("effective policy exit = %d, stderr %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "max_autonomy: medium") {
t.Fatalf("effective policy text missing max_autonomy line:\n%s", stdout.String())
}
}
func TestRunSandboxPolicySurfacesResolveConfigError(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("invalid sandbox.maxAutonomy %q", "moderate")
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("policy exit = %d, want provider exit %d (resolve error surfaced, not silent DefaultPolicy fallback)", exitCode, exitProvider)
}
if !strings.Contains(stderr.String(), "invalid sandbox.maxAutonomy") {
t.Fatalf("expected surfaced resolve error in stderr, got %q", stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout on resolve error, got %q", stdout.String())
}
}
func TestApplyConfiguredAutonomyCeiling(t *testing.T) {
cases := []struct {
name string
maxAutonomy string
want sandbox.Autonomy
}{
// Empty is a no-op: the default High ceiling is preserved.
{name: "empty keeps default high", maxAutonomy: "", want: sandbox.AutonomyHigh},
{name: "whitespace keeps default high", maxAutonomy: " ", want: sandbox.AutonomyHigh},
{name: "valid low", maxAutonomy: "low", want: sandbox.AutonomyLow},
{name: "valid medium", maxAutonomy: "medium", want: sandbox.AutonomyMedium},
{name: "valid high", maxAutonomy: "high", want: sandbox.AutonomyHigh},
{name: "case-insensitive medium", maxAutonomy: "MEDIUM", want: sandbox.AutonomyMedium},
// Fail-closed: an invalid non-empty value clamps to the most restrictive
// ceiling instead of leaving the High default in place.
{name: "invalid banana clamps to low", maxAutonomy: "banana", want: sandbox.AutonomyLow},
{name: "invalid moderate clamps to low", maxAutonomy: "moderate", want: sandbox.AutonomyLow},
{name: "invalid med clamps to low", maxAutonomy: "med", want: sandbox.AutonomyLow},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if base := sandbox.DefaultPolicy(); base.MaxAutonomy != sandbox.AutonomyHigh {
t.Fatalf("precondition: DefaultPolicy().MaxAutonomy = %q, want high", base.MaxAutonomy)
}
policy := applyConfiguredAutonomyCeiling(sandbox.DefaultPolicy(), tc.maxAutonomy)
if policy.MaxAutonomy != tc.want {
t.Fatalf("applyConfiguredAutonomyCeiling(_, %q).MaxAutonomy = %q, want %q", tc.maxAutonomy, policy.MaxAutonomy, tc.want)
}
})
}
}
func newSandboxTestStore(t *testing.T) *sandbox.GrantStore {
t.Helper()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{
+2 -2
View File
@@ -31,7 +31,7 @@ func TestRunServeMCPListsReadOnlyToolsByDefault(t *testing.T) {
t.Fatalf("expected MCP output to contain %q, got %q", want, output)
}
}
for _, unwanted := range []string{"write_file", "apply_patch", "bash"} {
for _, unwanted := range []string{"write_file", "apply_patch", "bash", "web_fetch"} {
if strings.Contains(output, unwanted) {
t.Fatalf("did not expect default MCP output to contain %q: %q", unwanted, output)
}
@@ -53,7 +53,7 @@ func TestRunServeMCPAllowsUnsafeToolsWithExplicitFlag(t *testing.T) {
t.Fatalf("expected exit code 0, got %d; stderr=%q", exitCode, stderr.String())
}
output := stdout.String()
for _, want := range []string{"read_file", "write_file", "apply_patch", "bash"} {
for _, want := range []string{"read_file", "write_file", "apply_patch", "bash", "web_fetch"} {
if !strings.Contains(output, want) {
t.Fatalf("expected unsafe MCP output to contain %q, got %q", want, output)
}
+70 -4
View File
@@ -12,6 +12,7 @@ import (
type sessionCommandOptions struct {
json bool
kind sessions.SessionKind
sequence int
eventID string
excludeTarget bool
@@ -90,8 +91,26 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
options.json = true
case "--exclude-target":
options.excludeTarget = true
case "--kind":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, remaining, options, false, err
}
kind, err := parseSessionKindFlag(value)
if err != nil {
return command, remaining, options, false, err
}
options.kind = kind
index = next
default:
switch {
case strings.HasPrefix(arg, "--kind="):
kind, err := parseSessionKindFlag(strings.TrimPrefix(arg, "--kind="))
if err != nil {
return command, remaining, options, false, err
}
options.kind = kind
continue
case arg == "--sequence":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -194,9 +213,19 @@ func parseNonEmptySessionsFlag(flag string, value string) (string, error) {
return trimmed, nil
}
func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
kind := sessions.SessionKind(strings.ToLower(strings.TrimSpace(value)))
switch kind {
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
return kind, nil
default:
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, spec-draft, or spec-impl.", value)}
}
}
func isSessionsCommand(command string) bool {
switch command {
case "list", "children", "lineage", "tree", "rewind-plan", "compact-plan":
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan":
return true
default:
return false
@@ -204,6 +233,9 @@ func isSessionsCommand(command string) bool {
}
func validateSessionCommandFlags(command string, options sessionCommandOptions) error {
if options.kind != "" && command != "list" {
return execUsageError{"--kind is only valid for sessions list"}
}
hasRewindFlag := options.sequence > 0 || strings.TrimSpace(options.eventID) != "" || options.excludeTarget
if hasRewindFlag && command != "rewind-plan" && command != "rewind" {
return execUsageError{"--sequence, --event, and --exclude-target are only valid for sessions rewind-plan and rewind"}
@@ -220,6 +252,7 @@ func runSessionsList(store *sessions.Store, options sessionCommandOptions, stdou
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
items = filterSessionsByKind(items, options.kind)
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(zerocommands.SessionSnapshots(items), redaction.Options{})); err != nil {
return exitCrash
@@ -232,6 +265,19 @@ func runSessionsList(store *sessions.Store, options sessionCommandOptions, stdou
return exitSuccess
}
func filterSessionsByKind(items []sessions.Metadata, kind sessions.SessionKind) []sessions.Metadata {
if kind == "" {
return items
}
filtered := make([]sessions.Metadata, 0, len(items))
for _, item := range items {
if item.SessionKind == kind {
filtered = append(filtered, item)
}
}
return filtered
}
func runSessionsChildren(store *sessions.Store, sessionID string, options sessionCommandOptions, stdout io.Writer, stderr io.Writer) int {
items, err := store.ListChildren(sessionID)
if err != nil {
@@ -322,13 +368,20 @@ func runSessionsRewind(store *sessions.Store, sessionID string, options sessionC
return writeSessionCommandError(stderr, err)
}
if session == nil {
return writeExecUsageError(stderr, "Zero session not found: "+sessionID)
return writeExecUsageError(stderr, "Zero session not found: "+redact(sessionID))
}
workspaceRoot := strings.TrimSpace(session.Cwd)
if workspaceRoot == "" {
return writeExecUsageError(stderr, "session has no recorded workspace (cwd); cannot restore files")
}
report, err := store.ApplyRewind(sessionID, workspaceRoot, plan.TargetSequence)
// Honor --exclude-target: ApplyRewind keeps events THROUGH the given sequence,
// so when the target event itself must be dropped (KeepTarget=false), apply
// through the sequence BEFORE it — matching what rewind-plan reports.
keepThrough := plan.TargetSequence
if !plan.KeepTarget {
keepThrough = plan.TargetSequence - 1
}
report, err := store.ApplyRewind(sessionID, workspaceRoot, keepThrough)
if err != nil {
return writeSessionCommandError(stderr, err)
}
@@ -339,7 +392,7 @@ func runSessionsRewind(store *sessions.Store, sessionID string, options sessionC
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Rewound %s to sequence %d: %d file(s) restored, %d deleted, %d skipped.\n",
sessionID, plan.TargetSequence, report.FilesRestored, report.FilesDeleted, len(report.Skipped)); err != nil {
redact(sessionID), keepThrough, report.FilesRestored, report.FilesDeleted, len(report.Skipped)); err != nil {
return exitCrash
}
return exitSuccess
@@ -439,6 +492,18 @@ func formatSessionSnapshotLine(session zerocommands.SessionSnapshot) string {
if session.TaskID != "" {
details = append(details, "task="+redact(session.TaskID))
}
if session.SpecStatus != "" {
details = append(details, "spec="+redact(session.SpecStatus))
}
if session.SpecID != "" {
details = append(details, "spec_id="+redact(session.SpecID))
}
if session.Tag != "" {
details = append(details, "tag="+redact(session.Tag))
}
if session.Depth > 0 {
details = append(details, fmt.Sprintf("depth=%d", session.Depth))
}
if session.ParentSessionID != "" {
details = append(details, "parent="+redact(session.ParentSessionID))
}
@@ -470,6 +535,7 @@ Commands:
Flags:
--json Print JSON output
--kind <kind> Filter list by fork, child, spec-draft, or spec-impl
--sequence <n> Rewind target sequence (rewind-plan, rewind)
--event <id> Rewind target event id (rewind-plan, rewind)
--exclude-target Drop the target event (rewind-plan, rewind)
+87 -1
View File
@@ -25,6 +25,8 @@ func TestRunSessionsListsLineageAndTree(t *testing.T) {
child, err := store.CreateChild(root.SessionID, sessions.ChildInput{
SessionID: "child",
Title: "Review child",
Tag: "specialist",
Depth: 1,
AgentName: "code-review",
TaskID: "task-7",
})
@@ -43,7 +45,7 @@ func TestRunSessionsListsLineageAndTree(t *testing.T) {
t.Fatalf("sessions list exit = %d, stderr = %q", exitCode, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "Zero sessions") || !strings.Contains(output, root.SessionID) || !strings.Contains(output, child.SessionID) || !strings.Contains(output, "code-review") {
if !strings.Contains(output, "Zero sessions") || !strings.Contains(output, root.SessionID) || !strings.Contains(output, child.SessionID) || !strings.Contains(output, "code-review") || !strings.Contains(output, "tag=specialist") || !strings.Contains(output, "depth=1") {
t.Fatalf("sessions list output = %q, want root, child, and agent", output)
}
@@ -129,6 +131,45 @@ func TestRunSessionsPlansRewindAndCompaction(t *testing.T) {
}
}
// Regression: `sessions rewind` must be reachable (the parser whitelist), and the
// destructive path must honor --exclude-target (apply BEFORE the target event).
func TestRunSessionsRewindIsReachableAndHonorsExcludeTarget(t *testing.T) {
makeStore := func() (*sessions.Store, string) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T19:30:00Z")})
session, err := store.Create(sessions.CreateInput{SessionID: "rw", Title: "Rewind", Cwd: t.TempDir()})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
for _, content := range []string{"alpha", "beta", "gamma", "delta"} {
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": content}}); err != nil {
t.Fatal(err)
}
}
return store, session.SessionID
}
remainingAfterRewind := func(extra ...string) int {
store, id := makeStore()
var stdout, stderr bytes.Buffer
args := append([]string{"sessions", "rewind", id, "--sequence", "2"}, extra...)
if ec := runWithDeps(args, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
}); ec != exitSuccess {
t.Fatalf("sessions rewind %v exit = %d, stderr = %q", extra, ec, stderr.String())
}
events, err := store.ReadEvents(id)
if err != nil {
t.Fatal(err)
}
return len(events)
}
keepTarget := remainingAfterRewind() // keep through seq 2
excludeTarget := remainingAfterRewind("--exclude-target") // keep through seq 1
if excludeTarget >= keepTarget {
t.Fatalf("--exclude-target should keep fewer events than keep-target: keep=%d exclude=%d", keepTarget, excludeTarget)
}
}
func TestRunSessionsValidatesArgs(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T19:30:00Z")})
@@ -175,6 +216,51 @@ func TestRunSessionsValidatesArgs(t *testing.T) {
}
}
func TestRunSessionsListFiltersByKind(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: sequenceClockCLI([]time.Time{
time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC),
time.Date(2026, 6, 8, 12, 0, 1, 0, time.UTC),
})})
if _, err := store.Create(sessions.CreateInput{SessionID: "regular", Title: "Regular"}); err != nil {
t.Fatal(err)
}
if _, err := store.Create(sessions.CreateInput{
SessionID: "draft",
SessionKind: sessions.SessionKindSpecDraft,
Title: "Spec draft",
SpecID: "2026-06-08-spec-draft",
SpecStatus: sessions.SpecStatusDraft,
}); err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"sessions", "list", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return store
},
})
if exitCode != exitSuccess {
t.Fatalf("sessions list --kind exit = %d, stderr = %q", exitCode, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "draft") || strings.Contains(output, "regular") || !strings.Contains(output, "spec=draft") {
t.Fatalf("filtered sessions output = %q", output)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sessions", "tree", "draft", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return store
},
})
if exitCode != exitUsage || !strings.Contains(stderr.String(), "--kind is only valid for sessions list") {
t.Fatalf("expected --kind validation error, exit=%d stderr=%q", exitCode, stderr.String())
}
}
func sequenceClockCLI(values []time.Time) func() time.Time {
index := 0
return func() time.Time {
+19
View File
@@ -0,0 +1,19 @@
package cli
import (
"context"
"os"
"os/signal"
"syscall"
)
// signalContext returns a context that is cancelled when the process receives an
// interrupt (Ctrl+C / SIGINT) or SIGTERM, plus a stop function that releases the
// signal handler (call it when the operation completes). The agent loop honors
// context cancellation, so wrapping a long run in this context turns a Ctrl+C
// into a clean shutdown — the in-flight provider call is cancelled and the run
// returns context.Canceled — instead of an abrupt process kill that leaks the
// HTTP request and any spawned children.
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
}
+25
View File
@@ -0,0 +1,25 @@
package cli
import (
"testing"
"time"
)
func TestSignalContextNotCancelledInitially(t *testing.T) {
ctx, stop := signalContext()
defer stop()
if ctx.Err() != nil {
t.Fatalf("signalContext should not be cancelled before a signal, got %v", ctx.Err())
}
}
func TestSignalContextStopCancels(t *testing.T) {
ctx, stop := signalContext()
stop()
select {
case <-ctx.Done():
// stop() releases the handler and cancels the context — expected.
case <-time.After(time.Second):
t.Fatal("stop() should cancel the signal context")
}
}
+366
View File
@@ -0,0 +1,366 @@
package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
)
type specCommandOptions struct {
json bool
comment string
commentProvided bool
reason string
reasonProvided bool
}
type specCommandResult struct {
Status string `json:"status"`
SpecID string `json:"specId,omitempty"`
SpecFilePath string `json:"specFilePath,omitempty"`
DraftSessionID string `json:"draftSessionId,omitempty"`
ImplementationSessionID string `json:"implementationSessionId,omitempty"`
Message string `json:"message,omitempty"`
Next string `json:"next,omitempty"`
}
func runSpec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
command, target, options, help, err := parseSpecArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeSpecHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
store := deps.newSessionStore()
draft, err := resolveSpecReviewTarget(store, target)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
switch command {
case "show":
return runSpecShow(store, draft, options, stdout, stderr)
case "approve":
return runSpecApprove(store, draft, options, stdout, stderr)
case "reject":
return runSpecReject(store, draft, options, stdout, stderr)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown spec command %q", command))
}
}
func parseSpecArgs(args []string) (string, string, specCommandOptions, bool, error) {
options := specCommandOptions{}
if len(args) == 0 {
return "", "", options, false, execUsageError{"spec command required. Use `zero spec show <spec>`."}
}
command := ""
target := ""
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return command, target, options, true, nil
case arg == "--json":
options.json = true
case arg == "--comment":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, target, options, false, err
}
options.comment = value
options.commentProvided = true
index = next
case strings.HasPrefix(arg, "--comment="):
value, err := requiredInlineFlagValue(arg, "--comment")
if err != nil {
return command, target, options, false, err
}
options.comment = value
options.commentProvided = true
case arg == "--reason":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, target, options, false, err
}
options.reason = value
options.reasonProvided = true
index = next
case strings.HasPrefix(arg, "--reason="):
value, err := requiredInlineFlagValue(arg, "--reason")
if err != nil {
return command, target, options, false, err
}
options.reason = value
options.reasonProvided = true
case strings.HasPrefix(arg, "-"):
return command, target, options, false, execUsageError{fmt.Sprintf("unknown spec flag %q", arg)}
default:
if command == "" {
command = arg
continue
}
if target == "" {
target = arg
continue
}
return command, target, options, false, execUsageError{fmt.Sprintf("unexpected spec argument %q", arg)}
}
}
command = strings.TrimSpace(command)
switch command {
case "show", "approve", "reject":
default:
return command, target, options, false, execUsageError{fmt.Sprintf("unknown spec command %q", command)}
}
if strings.TrimSpace(target) == "" {
return command, target, options, false, execUsageError{fmt.Sprintf("zero spec %s requires a spec id or draft session id", command)}
}
if options.commentProvided && command != "approve" {
return command, target, options, false, execUsageError{"--comment is only valid for zero spec approve"}
}
if options.reasonProvided && command != "reject" {
return command, target, options, false, execUsageError{"--reason is only valid for zero spec reject"}
}
return command, strings.TrimSpace(target), options, false, nil
}
func resolveSpecReviewTarget(store *sessions.Store, target string) (sessions.Metadata, error) {
target = strings.TrimSpace(target)
if target == "" {
return sessions.Metadata{}, fmt.Errorf("spec id or draft session id is required")
}
if sessions.ValidSessionID(target) {
session, err := store.Get(target)
if err != nil {
return sessions.Metadata{}, err
}
if session != nil {
if session.SpecID == "" || session.SpecFilePath == "" {
return sessions.Metadata{}, fmt.Errorf("zero session has no recorded spec: %s", redact(target))
}
return *session, nil
}
}
items, err := store.List()
if err != nil {
return sessions.Metadata{}, err
}
matches := []sessions.Metadata{}
draftMatches := []sessions.Metadata{}
for _, item := range items {
if item.SpecID == target && item.SpecFilePath != "" {
matches = append(matches, item)
if item.SessionKind == sessions.SessionKindSpecDraft {
draftMatches = append(draftMatches, item)
}
}
}
if len(draftMatches) == 1 {
return draftMatches[0], nil
}
if len(draftMatches) > 1 {
return sessions.Metadata{}, fmt.Errorf("zero spec id is ambiguous: %s; use the draft session id", redact(target))
}
if len(matches) == 0 {
return sessions.Metadata{}, fmt.Errorf("zero spec not found: %s", redact(target))
}
if len(matches) > 1 {
return sessions.Metadata{}, fmt.Errorf("zero spec id is ambiguous: %s; use the draft session id", redact(target))
}
return matches[0], nil
}
func runSpecShow(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
body, path, err := specmode.LoadSpecFile(draft.Cwd, draft.SpecFilePath)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
payload := map[string]any{
"specId": draft.SpecID,
"specFilePath": path,
"draftSessionId": draft.SessionID,
"specStatus": draft.SpecStatus,
"content": body,
}
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, body); err != nil {
return exitCrash
}
return exitSuccess
}
func runSpecApprove(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
if draft.SessionKind != sessions.SessionKindSpecDraft {
return writeExecUsageError(stderr, "zero spec approve requires a spec-draft session")
}
if draft.SpecStatus == sessions.SpecStatusRejected {
return writeExecUsageError(stderr, "zero spec approve cannot approve a rejected spec")
}
if draft.SpecStatus == sessions.SpecStatusApproved && draft.SpecImplSessionID != "" {
return writeSpecResult(stdout, options, specCommandResult{
Status: string(sessions.SpecStatusApproved),
SpecID: draft.SpecID,
SpecFilePath: draft.SpecFilePath,
DraftSessionID: draft.SessionID,
ImplementationSessionID: draft.SpecImplSessionID,
Message: "Spec already approved.",
Next: "zero exec --resume " + draft.SpecImplSessionID + ` "Start implementation"`,
})
}
body, path, err := specmode.LoadSpecFile(draft.Cwd, draft.SpecFilePath)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
prompt := specmode.ImplementationPrompt(body, path, draft.SessionID, options.comment)
impl, _, err := store.EnsureSpecImplementation(sessions.EnsureSpecImplementationInput{
Title: specImplementationTitle(draft),
Cwd: draft.Cwd,
ModelID: draft.ModelID,
Provider: draft.Provider,
RootSessionID: firstNonEmptyString(draft.RootSessionID, draft.SessionID),
SpecID: draft.SpecID,
SpecFilePath: path,
SpecDraftModelID: draft.SpecDraftModelID,
SpecDraftReasoning: draft.SpecDraftReasoning,
SpecUserComment: options.comment,
SpecSourceSessionID: draft.SessionID,
Prompt: prompt,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
updated, _, err := store.RecordSpec(draft.SessionID, sessions.RecordSpecInput{
SpecID: draft.SpecID,
SpecFilePath: path,
SpecStatus: sessions.SpecStatusApproved,
SpecDraftModelID: draft.SpecDraftModelID,
SpecDraftReasoning: draft.SpecDraftReasoning,
SpecUserComment: options.comment,
SpecImplSessionID: impl.SessionID,
SpecSourceSessionID: draft.SessionID,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return writeSpecResult(stdout, options, specCommandResult{
Status: string(updated.SpecStatus),
SpecID: updated.SpecID,
SpecFilePath: updated.SpecFilePath,
DraftSessionID: updated.SessionID,
ImplementationSessionID: impl.SessionID,
Message: "Spec approved.",
Next: "zero exec --resume " + impl.SessionID + ` "Start implementation"`,
})
}
func runSpecReject(store *sessions.Store, draft sessions.Metadata, options specCommandOptions, stdout io.Writer, stderr io.Writer) int {
if draft.SessionKind != sessions.SessionKindSpecDraft {
return writeExecUsageError(stderr, "zero spec reject requires a spec-draft session")
}
if draft.SpecStatus == sessions.SpecStatusApproved && draft.SpecImplSessionID != "" {
return writeExecUsageError(stderr, "zero spec reject cannot reject an approved spec with an implementation session")
}
updated, _, err := store.RecordSpec(draft.SessionID, sessions.RecordSpecInput{
SpecID: draft.SpecID,
SpecFilePath: draft.SpecFilePath,
SpecStatus: sessions.SpecStatusRejected,
SpecDraftModelID: draft.SpecDraftModelID,
SpecDraftReasoning: draft.SpecDraftReasoning,
SpecRejectReason: options.reason,
SpecSourceSessionID: draft.SessionID,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return writeSpecResult(stdout, options, specCommandResult{
Status: string(updated.SpecStatus),
SpecID: updated.SpecID,
SpecFilePath: updated.SpecFilePath,
DraftSessionID: updated.SessionID,
Message: "Spec rejected.",
Next: "Run zero exec --use-spec again with a revised task.",
})
}
func writeSpecResult(stdout io.Writer, options specCommandOptions, result specCommandResult) int {
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(result, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
lines := []string{result.Message}
if result.SpecID != "" {
lines = append(lines, " spec: "+redact(result.SpecID))
}
if result.SpecFilePath != "" {
lines = append(lines, " path: "+redact(result.SpecFilePath))
}
if result.DraftSessionID != "" {
lines = append(lines, " draft session: "+redact(result.DraftSessionID))
}
if result.ImplementationSessionID != "" {
lines = append(lines, " implementation session: "+redact(result.ImplementationSessionID))
}
if result.Next != "" {
lines = append(lines, "Next: "+redact(result.Next))
}
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
return exitCrash
}
return exitSuccess
}
func specImplementationTitle(draft sessions.Metadata) string {
title := strings.TrimSpace(draft.Title)
if title == "" {
title = strings.TrimSpace(draft.SpecID)
}
if title == "" {
return "Spec implementation"
}
return title + " implementation"
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func writeSpecHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero spec show <spec-id|draft-session-id> [--json]
zero spec approve <spec-id|draft-session-id> [--comment <text>] [--json]
zero spec reject <spec-id|draft-session-id> [--reason <text>] [--json]
Commands:
show Print the saved draft spec
approve Mark a draft approved and create a spec implementation session
reject Mark a draft rejected
Flags:
--json Print JSON output
--comment <text> Approval note to include in the implementation prompt
--reason <text> Rejection reason
-h, --help Show this help
`)
return err
}
+201
View File
@@ -0,0 +1,201 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specmode"
)
func TestRunSpecApproveCreatesImplementationSession(t *testing.T) {
store, deps, draft := seedSpecDraft(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"spec", "approve", draft.SpecID, "--comment", "Keep changes focused.", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
}
var payload specCommandResult
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode approve json: %v\n%s", err, stdout.String())
}
if payload.Status != string(sessions.SpecStatusApproved) || payload.ImplementationSessionID == "" {
t.Fatalf("unexpected approve payload: %#v", payload)
}
updatedDraft, err := store.Get(draft.SessionID)
if err != nil {
t.Fatal(err)
}
if updatedDraft.SpecStatus != sessions.SpecStatusApproved || updatedDraft.SpecImplSessionID != payload.ImplementationSessionID {
t.Fatalf("draft metadata not updated: %#v", updatedDraft)
}
impl, err := store.Get(payload.ImplementationSessionID)
if err != nil {
t.Fatal(err)
}
if impl == nil || impl.SessionKind != sessions.SessionKindSpecImpl || impl.SpecSourceSessionID != draft.SessionID {
t.Fatalf("unexpected impl session: %#v", impl)
}
events, err := store.ReadEvents(payload.ImplementationSessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Type != sessions.EventMessage {
t.Fatalf("implementation events = %#v", events)
}
if !strings.Contains(string(events[0].Payload), "Keep changes focused.") || !strings.Contains(string(events[0].Payload), "# Goal") {
t.Fatalf("implementation prompt missing approved spec context: %s", string(events[0].Payload))
}
}
func TestRunSpecApproveIsIdempotent(t *testing.T) {
store, deps, draft := seedSpecDraft(t)
var first bytes.Buffer
if exitCode := runWithDeps([]string{"spec", "approve", draft.SessionID, "--json"}, &first, &bytes.Buffer{}, deps); exitCode != exitSuccess {
t.Fatalf("first approve exit = %d", exitCode)
}
var firstPayload specCommandResult
if err := json.Unmarshal(first.Bytes(), &firstPayload); err != nil {
t.Fatal(err)
}
var second bytes.Buffer
if exitCode := runWithDeps([]string{"spec", "approve", draft.SpecID, "--json"}, &second, &bytes.Buffer{}, deps); exitCode != exitSuccess {
t.Fatalf("second approve exit = %d", exitCode)
}
var secondPayload specCommandResult
if err := json.Unmarshal(second.Bytes(), &secondPayload); err != nil {
t.Fatal(err)
}
if secondPayload.ImplementationSessionID != firstPayload.ImplementationSessionID {
t.Fatalf("approve created a second implementation session: first=%q second=%q", firstPayload.ImplementationSessionID, secondPayload.ImplementationSessionID)
}
items, err := store.List()
if err != nil {
t.Fatal(err)
}
implCount := 0
for _, item := range items {
if item.SessionKind == sessions.SessionKindSpecImpl {
implCount++
}
}
if implCount != 1 {
t.Fatalf("implementation session count = %d, want 1", implCount)
}
}
func TestRunSpecRejectMarksDraftRejected(t *testing.T) {
store, deps, draft := seedSpecDraft(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"spec", "reject", draft.SpecID, "--reason=Needs narrower scope"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
}
updated, err := store.Get(draft.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.SpecStatus != sessions.SpecStatusRejected || updated.SpecRejectReason != "Needs narrower scope" {
t.Fatalf("draft metadata not rejected: %#v", updated)
}
if !strings.Contains(stdout.String(), "Spec rejected.") {
t.Fatalf("stdout = %q", stdout.String())
}
}
func TestParseSpecRejectsCommandSpecificFlags(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "comment on show",
args: []string{"show", "draft", "--comment", "ship it"},
want: "--comment is only valid for zero spec approve",
},
{
name: "reason on approve",
args: []string{"approve", "draft", "--reason", "too broad"},
want: "--reason is only valid for zero spec reject",
},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, _, _, err := parseSpecArgs(tc.args)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %q validation, got %v", tc.want, err)
}
})
}
}
func TestRunSpecShowPrintsSavedDraft(t *testing.T) {
_, deps, draft := seedSpecDraft(t)
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"spec", "show", draft.SessionID}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "# Goal") || !strings.Contains(stdout.String(), "Add review flow.") {
t.Fatalf("stdout = %q", stdout.String())
}
}
func seedSpecDraft(t *testing.T) (*sessions.Store, appDeps, sessions.Metadata) {
t.Helper()
workspaceRoot := t.TempDir()
saved, err := specmode.SaveDraft(specmode.SaveOptions{
WorkspaceRoot: workspaceRoot,
Title: "Review Flow",
Plan: "# Goal\n\nAdd review flow.",
Now: fixedCLISpecTime,
})
if err != nil {
t.Fatal(err)
}
storeRoot := t.TempDir()
store := sessions.NewStore(sessions.StoreOptions{RootDir: storeRoot, Now: fixedCLISpecTime})
draft, err := store.Create(sessions.CreateInput{
SessionKind: sessions.SessionKindSpecDraft,
Title: "Review flow",
Cwd: workspaceRoot,
ModelID: "test-model",
Provider: "test",
SpecID: saved.ID,
SpecFilePath: saved.Path,
SpecStatus: sessions.SpecStatusDraft,
SpecDraftModelID: "test-model",
SpecDraftReasoning: "high",
})
if err != nil {
t.Fatal(err)
}
deps := appDeps{
newSessionStore: func() *sessions.Store {
return store
},
}
t.Cleanup(func() {
_ = os.RemoveAll(storeRoot)
})
return store, deps, draft
}
func fixedCLISpecTime() time.Time {
return time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
}
+411
View File
@@ -0,0 +1,411 @@
package cli
import (
"fmt"
"io"
"os"
osexec "os/exec"
"strings"
"github.com/Gitlawb/zero/internal/specialist"
)
type specialistOptions struct {
json bool
location specialist.Location
description string
prompt string
extends string
model string
reasoningEffort string
tools []string
force bool
}
func runSpecialists(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
command, remaining, options, help, err := parseSpecialistArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeSpecialistHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if err := validateSpecialistCommand(command, remaining); err != nil {
return writeExecUsageError(stderr, err.Error())
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
switch command {
case "list":
return runSpecialistList(paths, options, stdout, stderr)
case "show":
return runSpecialistShow(paths, remaining[0], options, stdout, stderr)
case "create":
return runSpecialistCreate(paths, remaining[0], options, stdout, stderr)
case "delete", "rm":
return runSpecialistDelete(paths, remaining[0], options, stdout, stderr)
case "edit":
return runSpecialistEdit(paths, remaining[0], options, stdout, stderr, deps)
case "path":
return runSpecialistPath(paths, options, stdout)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown specialist command %q", command))
}
}
func parseSpecialistArgs(args []string) (string, []string, specialistOptions, bool, error) {
command := "list"
commandExplicit := false
remaining := []string{}
options := specialistOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch arg {
case "-h", "--help", "help":
return command, remaining, options, true, nil
case "--json":
options.json = true
case "--user":
options.location = specialist.LocationUser
case "--project":
options.location = specialist.LocationProject
case "--force":
options.force = true
case "--location", "--description", "--prompt", "--system-prompt", "--tools", "--extends", "--model", "--reasoning-effort":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, remaining, options, false, err
}
index = next
if err := applySpecialistOption(&options, arg, value); err != nil {
return command, remaining, options, false, err
}
default:
if strings.HasPrefix(arg, "--location=") {
value, err := requiredInlineFlagValue(arg, "--location")
if err != nil {
return command, remaining, options, false, err
}
if err := applySpecialistOption(&options, "--location", value); err != nil {
return command, remaining, options, false, err
}
continue
}
if strings.HasPrefix(arg, "--description=") {
value, err := requiredInlineFlagValue(arg, "--description")
if err != nil {
return command, remaining, options, false, err
}
if err := applySpecialistOption(&options, "--description", value); err != nil {
return command, remaining, options, false, err
}
continue
}
if strings.HasPrefix(arg, "--prompt=") || strings.HasPrefix(arg, "--system-prompt=") {
flag := "--prompt"
if strings.HasPrefix(arg, "--system-prompt=") {
flag = "--system-prompt"
}
value, err := requiredInlineFlagValue(arg, flag)
if err != nil {
return command, remaining, options, false, err
}
if err := applySpecialistOption(&options, flag, value); err != nil {
return command, remaining, options, false, err
}
continue
}
if strings.HasPrefix(arg, "--tools=") {
value, err := requiredInlineFlagValue(arg, "--tools")
if err != nil {
return command, remaining, options, false, err
}
if err := applySpecialistOption(&options, "--tools", value); err != nil {
return command, remaining, options, false, err
}
continue
}
if strings.HasPrefix(arg, "--extends=") || strings.HasPrefix(arg, "--model=") || strings.HasPrefix(arg, "--reasoning-effort=") {
flag, _, _ := strings.Cut(arg, "=")
value, err := requiredInlineFlagValue(arg, flag)
if err != nil {
return command, remaining, options, false, err
}
if err := applySpecialistOption(&options, flag, value); err != nil {
return command, remaining, options, false, err
}
continue
}
if strings.HasPrefix(arg, "-") {
return command, remaining, options, false, fmt.Errorf("unknown specialist flag %q", arg)
}
if !commandExplicit {
command = arg
commandExplicit = true
} else {
remaining = append(remaining, arg)
}
}
}
return command, remaining, options, false, nil
}
func applySpecialistOption(options *specialistOptions, flag string, value string) error {
value = strings.TrimSpace(value)
switch flag {
case "--location":
switch strings.ToLower(value) {
case "user":
options.location = specialist.LocationUser
case "project":
options.location = specialist.LocationProject
default:
return fmt.Errorf("--location must be user or project")
}
case "--description":
options.description = value
case "--prompt", "--system-prompt":
options.prompt = value
case "--tools":
options.tools = specialist.SplitToolList(value)
case "--extends":
options.extends = value
case "--model":
options.model = value
case "--reasoning-effort":
options.reasoningEffort = value
default:
return fmt.Errorf("unknown specialist flag %q", flag)
}
return nil
}
func validateSpecialistCommand(command string, remaining []string) error {
switch command {
case "list":
if len(remaining) != 0 {
return fmt.Errorf("specialist list does not accept positional arguments")
}
case "show":
if len(remaining) != 1 {
return fmt.Errorf("specialist show requires a specialist name")
}
case "create":
if len(remaining) != 1 {
return fmt.Errorf("specialist create requires a specialist name")
}
case "delete", "rm":
if len(remaining) != 1 {
return fmt.Errorf("specialist delete requires a specialist name")
}
case "edit":
if len(remaining) != 1 {
return fmt.Errorf("specialist edit requires a specialist name")
}
case "path":
if len(remaining) != 0 {
return fmt.Errorf("specialist path does not accept positional arguments")
}
default:
return fmt.Errorf("unknown specialist command %q", command)
}
return nil
}
func runSpecialistList(paths specialist.Paths, options specialistOptions, stdout io.Writer, stderr io.Writer) int {
result, err := specialist.Load(specialist.LoadOptions{Paths: paths})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, struct {
Paths specialist.Paths `json:"paths"`
Specialists []specialist.Summary `json:"specialists"`
Warnings []string `json:"warnings,omitempty"`
}{
Paths: result.Paths,
Specialists: specialist.Summaries(result.Specialists),
Warnings: result.Warnings,
}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, specialist.FormatList(result)); err != nil {
return exitCrash
}
return exitSuccess
}
func runSpecialistShow(paths specialist.Paths, name string, options specialistOptions, stdout io.Writer, stderr io.Writer) int {
result, err := specialist.Load(specialist.LoadOptions{Paths: paths})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
manifest, ok := specialist.Find(result, name)
if !ok {
return writeExecUsageError(stderr, "Zero specialist not found: "+name)
}
if options.json {
if err := writePrettyJSON(stdout, manifest); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, specialist.FormatShow(manifest)); err != nil {
return exitCrash
}
return exitSuccess
}
func runSpecialistCreate(paths specialist.Paths, name string, options specialistOptions, stdout io.Writer, stderr io.Writer) int {
storage := specialist.NewStorage(paths)
manifest, err := storage.Create(specialist.CreateInput{
Name: name,
Description: options.description,
SystemPrompt: options.prompt,
Extends: options.extends,
Model: options.model,
ReasoningEffort: options.reasoningEffort,
Tools: options.tools,
Location: options.location,
Overwrite: options.force,
})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
if err := writePrettyJSON(stdout, manifest); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Created specialist %s at %s\n", manifest.Metadata.Name, manifest.FilePath); err != nil {
return exitCrash
}
return exitSuccess
}
func runSpecialistDelete(paths specialist.Paths, name string, options specialistOptions, stdout io.Writer, stderr io.Writer) int {
storage := specialist.NewStorage(paths)
path, err := storage.Delete(specialist.DeleteInput{Name: name, Location: options.location})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{"name": name, "path": path, "deleted": true}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Deleted specialist %s at %s\n", name, path); err != nil {
return exitCrash
}
return exitSuccess
}
func runSpecialistEdit(paths specialist.Paths, name string, options specialistOptions, stdout io.Writer, stderr io.Writer, deps appDeps) int {
storage := specialist.NewStorage(paths)
path, err := storage.Path(name, options.location)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
info, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return writeExecUsageError(stderr, "specialist not found: "+name)
}
return writeAppError(stderr, err.Error(), exitCrash)
}
if info.Mode()&os.ModeSymlink != 0 {
return writeExecUsageError(stderr, "refusing to edit symlink specialist file: "+path)
}
runEditor := deps.runEditor
if runEditor == nil {
runEditor = openEditor
}
if err := runEditor(path); err != nil {
return writeExecUsageError(stderr, err.Error())
}
if _, err := fmt.Fprintf(stdout, "Edited specialist %s at %s\n", name, path); err != nil {
return exitCrash
}
return exitSuccess
}
func runSpecialistPath(paths specialist.Paths, options specialistOptions, stdout io.Writer) int {
if options.json {
if err := writePrettyJSON(stdout, paths); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, specialist.FormatPaths(paths)); err != nil {
return exitCrash
}
return exitSuccess
}
func writeSpecialistHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero specialist [command] [flags]
Commands:
list List built-in, user, and project specialists
show NAME Show one specialist profile
create NAME
Create a user specialist profile
delete NAME
Delete a user specialist profile
edit NAME Open a user specialist profile in $VISUAL or $EDITOR
path Print specialist directories
help Show this help
Flags:
--json Print JSON output
--user Use the user specialist directory (default)
--project Use the project .zero/specialists directory
--description <text> Description for create
--prompt <text> System prompt for create
--tools <list> Tool ids/categories for create
--extends <name> Base specialist for create
--model <model> Model override for create
--reasoning-effort <value> Reasoning effort override for create
--force Replace an existing specialist during create
`)
return err
}
func openEditor(path string) error {
editor := strings.TrimSpace(os.Getenv("VISUAL"))
if editor == "" {
editor = strings.TrimSpace(os.Getenv("EDITOR"))
}
if editor == "" {
return fmt.Errorf("VISUAL or EDITOR must be set to edit specialists")
}
parts := strings.Fields(editor)
if len(parts) == 0 {
return fmt.Errorf("VISUAL or EDITOR must be set to edit specialists")
}
command := osexec.Command(parts[0], append(parts[1:], path)...)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {
return fmt.Errorf("editor failed: %w", err)
}
return nil
}
+344
View File
@@ -0,0 +1,344 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestRunSpecialistListShowAndPath(t *testing.T) {
cwd := t.TempDir()
configRoot := setSpecialistConfigRoot(t)
userDir := filepath.Join(configRoot, "zero", "specialists")
writeSpecialistManifest(t, filepath.Join(userDir, "triage.md"), `---
name: triage
description: Triage failing tests
tools: [read-only]
---
Find the likely failure area.`)
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"specialist", "list"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
for _, want := range []string{"Zero Specialists", "worker [builtin]", "triage [user]", "code-review"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("list output missing %q: %s", want, stdout.String())
}
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"specialist", "show", "triage"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "Zero Specialist: triage") || !strings.Contains(stdout.String(), "Find the likely failure area.") {
t.Fatalf("unexpected show output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"specialist", "path"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), userDir) || !strings.Contains(stdout.String(), filepath.Join(cwd, ".zero", "specialists")) {
t.Fatalf("unexpected path output: %s", stdout.String())
}
}
func TestRunSpecialistShowAndPathJSON(t *testing.T) {
cwd := t.TempDir()
setSpecialistConfigRoot(t)
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"specialist", "show", "worker", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("show --json exitCode = %d stderr=%s", exitCode, stderr.String())
}
var showPayload struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
Location string `json:"location"`
}
if err := json.Unmarshal(stdout.Bytes(), &showPayload); err != nil {
t.Fatalf("failed to decode show JSON: %v\n%s", err, stdout.String())
}
if showPayload.Metadata.Name != "worker" || showPayload.Location == "" {
t.Fatalf("unexpected show JSON: %#v", showPayload)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"specialist", "path", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("path --json exitCode = %d stderr=%s", exitCode, stderr.String())
}
var pathPayload struct {
UserDir string `json:"userDir"`
ProjectDir string `json:"projectDir"`
}
if err := json.Unmarshal(stdout.Bytes(), &pathPayload); err != nil {
t.Fatalf("failed to decode path JSON: %v\n%s", err, stdout.String())
}
if pathPayload.UserDir == "" || pathPayload.ProjectDir != filepath.Join(cwd, ".zero", "specialists") {
t.Fatalf("unexpected path JSON: %#v", pathPayload)
}
}
func TestRunSpecialistListJSON(t *testing.T) {
cwd := t.TempDir()
setSpecialistConfigRoot(t)
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"specialists", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
var payload struct {
Specialists []struct {
Name string `json:"name"`
Location string `json:"location"`
} `json:"specialists"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode JSON: %v\n%s", err, stdout.String())
}
if len(payload.Specialists) == 0 {
t.Fatalf("unexpected JSON payload: %#v", payload)
}
for _, item := range payload.Specialists {
if item.Name == "" || item.Location == "" {
t.Fatalf("specialist JSON item missing name or location: %#v", item)
}
}
}
func TestRunSpecialistCreateDeleteAndEdit(t *testing.T) {
cwd := t.TempDir()
configRoot := setSpecialistConfigRoot(t)
userDir := filepath.Join(configRoot, "zero", "specialists")
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{
"specialist", "create", "triage",
"--description", "Triage failures",
"--prompt", "Find the most likely failure.",
"--tools", "read-only,plan",
}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("create exitCode = %d stderr=%s", exitCode, stderr.String())
}
path := filepath.Join(userDir, "triage.md")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected specialist file: %v", err)
}
content := string(data)
for _, want := range []string{`name: "triage"`, `description: "Triage failures"`, `"read-only"`, `"plan"`, "Find the most likely failure."} {
if !strings.Contains(content, want) {
t.Fatalf("created file missing %q:\n%s", want, content)
}
}
var editedPath string
deps.runEditor = func(path string) error {
editedPath = path
return nil
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"specialist", "edit", "triage"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("edit exitCode = %d stderr=%s", exitCode, stderr.String())
}
if editedPath != path {
t.Fatalf("edited path = %q, want %q", editedPath, path)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"specialist", "delete", "triage", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("delete exitCode = %d stderr=%s", exitCode, stderr.String())
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected specialist file deleted, stat err=%v", err)
}
if !strings.Contains(stdout.String(), `"deleted": true`) {
t.Fatalf("delete JSON missing deleted=true: %s", stdout.String())
}
}
func TestRunSpecialistEditRejectsSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows")
}
cwd := t.TempDir()
configRoot := setSpecialistConfigRoot(t)
userDir := filepath.Join(configRoot, "zero", "specialists")
if err := os.MkdirAll(userDir, 0o700); err != nil {
t.Fatalf("create specialist dir: %v", err)
}
target := filepath.Join(t.TempDir(), "target.md")
if err := os.WriteFile(target, []byte("do not edit"), 0o600); err != nil {
t.Fatalf("write symlink target: %v", err)
}
link := filepath.Join(userDir, "linked.md")
if err := os.Symlink(target, link); err != nil {
t.Skipf("create symlink: %v", err)
}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
runEditor: func(path string) error {
t.Fatalf("editor should not be invoked for symlink path %s", path)
return nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"specialist", "edit", "linked"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exitCode = %d, want usage; stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "refusing to edit symlink") {
t.Fatalf("expected symlink refusal, got %q", stderr.String())
}
}
func TestRunSpecialistCreateProjectAndRejectDuplicate(t *testing.T) {
cwd := t.TempDir()
setSpecialistConfigRoot(t)
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
args := []string{"specialist", "create", "project-helper", "--project", "--description=Project helper"}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("create project exitCode = %d stderr=%s", exitCode, stderr.String())
}
if _, err := os.Stat(filepath.Join(cwd, ".zero", "specialists", "project-helper.md")); err != nil {
t.Fatalf("expected project specialist file: %v", err)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("duplicate exitCode = %d, want usage", exitCode)
}
if !strings.Contains(stderr.String(), "already exists") {
t.Fatalf("duplicate error = %q", stderr.String())
}
}
func TestRunSpecialistShowMissingReturnsUsage(t *testing.T) {
setSpecialistConfigRoot(t)
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"specialist", "show", "missing"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exitCode = %d, want usage", exitCode)
}
if !strings.Contains(stderr.String(), "not found") {
t.Fatalf("expected not found error, got %q", stderr.String())
}
}
func TestRunSpecialistUnknownCommandDoesNotResolveWorkspace(t *testing.T) {
deps := appDeps{getwd: func() (string, error) {
t.Fatal("getwd should not be called for an unknown specialist command")
return "", nil
}}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"specialist", "missing"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exitCode = %d, want usage", exitCode)
}
if !strings.Contains(stderr.String(), `unknown specialist command "missing"`) {
t.Fatalf("expected unknown command error, got %q", stderr.String())
}
}
func TestRunSpecialistArgCountErrors(t *testing.T) {
setSpecialistConfigRoot(t)
deps := appDeps{getwd: func() (string, error) {
t.Fatal("getwd should not be called for invalid specialist arguments")
return "", nil
}}
tests := []struct {
args []string
want string
}{
{args: []string{"specialist", "list", "extra"}, want: "does not accept positional"},
{args: []string{"specialist", "show"}, want: "show requires a specialist name"},
{args: []string{"specialist", "create"}, want: "create requires a specialist name"},
{args: []string{"specialist", "delete"}, want: "delete requires a specialist name"},
{args: []string{"specialist", "edit"}, want: "edit requires a specialist name"},
{args: []string{"specialist", "path", "extra"}, want: "path does not accept positional"},
}
for _, tc := range tests {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exitCode = %d, want usage", exitCode)
}
if !strings.Contains(stderr.String(), tc.want) {
t.Fatalf("expected %q error, got %q", tc.want, stderr.String())
}
})
}
}
func setSpecialistConfigRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("APPDATA", root)
case "darwin":
t.Setenv("HOME", root)
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
return configRoot
}
func writeSpecialistManifest(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("create manifest dir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
}
+315
View File
@@ -0,0 +1,315 @@
package cli
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zerogit"
)
type usageOptions struct {
json bool
days int
since string
sessionID string
}
// usageEventSet keeps the persisted events paired with the session metadata they
// were read alongside, so cost reconstruction can resolve each event's owning
// model id during aggregation.
type usageEventSet struct {
events []sessions.Event
meta []sessions.Metadata
}
// collectUsageData reads every persisted session's events (optionally limited to
// a single session id) and returns them flattened alongside the matching session
// metadata. It mirrors runSearch's persisted-event traversal over the injected
// session store.
func collectUsageData(store *sessions.Store, sessionFilter string) (usageEventSet, error) {
metadata, err := store.List()
if err != nil {
return usageEventSet{}, err
}
set := usageEventSet{}
for _, meta := range metadata {
if sessionFilter != "" && meta.SessionID != sessionFilter {
continue
}
set.meta = append(set.meta, meta)
sessionEvents, err := store.ReadEvents(meta.SessionID)
if err != nil {
return usageEventSet{}, err
}
set.events = append(set.events, sessionEvents...)
}
return set, nil
}
// filterEventsSince drops events whose UTC calendar date precedes the inclusive
// lower bound. An empty since returns the events unchanged.
func filterEventsSince(events []sessions.Event, since string) []sessions.Event {
if since == "" {
return events
}
filtered := make([]sessions.Event, 0, len(events))
for _, event := range events {
if eventUTCDate(event.CreatedAt) >= since {
filtered = append(filtered, event)
}
}
return filtered
}
// eventUTCDate maps an RFC3339 timestamp to its UTC calendar date (YYYY-MM-DD)
// so the --since/--days cutoff compares against the same UTC day the report
// buckets under. On a parse failure it falls back to the leading-10 slice.
func eventUTCDate(createdAt string) string {
if parsed, err := time.Parse(time.RFC3339, createdAt); err == nil {
return parsed.UTC().Format("2006-01-02")
}
if len(createdAt) >= 10 {
return createdAt[:10]
}
return createdAt
}
func runUsage(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
subcommand := "report"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
subcommand = strings.ToLower(strings.TrimSpace(args[0]))
args = args[1:]
}
if subcommand == "help" {
if err := writeUsageHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if subcommand != "report" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown usage command %q", subcommand))
}
options, help, err := parseUsageArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeUsageHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
set, err := collectUsageData(deps.newSessionStore(), options.sessionID)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
since := usageSinceCutoff(options, deps)
events := filterEventsSince(set.events, since)
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{Cwd: workspaceRoot})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
// The --stat summary line ("N files changed, A insertions(+), B deletions(-)")
// carries no secret-bearing tokens, so parsing the already-redacted DiffStat
// returned by zerogit.Inspect is safe.
diff := zerogit.ParseDiffStat(summary.DiffStat)
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
report, err := usage.BuildReport(events, set.meta, &registry, diff.NetLOC())
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, FormatReport(report, diff.Insertions, diff.Deletions)); err != nil {
return exitCrash
}
return exitSuccess
}
// usageSinceCutoff resolves the inclusive lower-bound date (YYYY-MM-DD). An
// explicit --since wins; otherwise --days N derives a cutoff relative to the
// injected clock. An empty result means "no lower bound".
func usageSinceCutoff(options usageOptions, deps appDeps) string {
if strings.TrimSpace(options.since) != "" {
return options.since
}
if options.days > 0 {
cutoff := deps.now().UTC().AddDate(0, 0, -(options.days - 1))
return cutoff.Format("2006-01-02")
}
return ""
}
func parseUsageArgs(args []string) (usageOptions, bool, error) {
options := usageOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case arg == "--days":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
days, err := parsePositiveOrZeroInt(value, "--days")
if err != nil {
return options, false, err
}
options.days = days
index = next
case strings.HasPrefix(arg, "--days="):
days, err := parsePositiveOrZeroInt(strings.TrimSpace(strings.TrimPrefix(arg, "--days=")), "--days")
if err != nil {
return options, false, err
}
options.days = days
case arg == "--since":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
if _, parseErr := time.Parse("2006-01-02", value); parseErr != nil {
return options, false, execUsageError{fmt.Sprintf("invalid --since %q: expected YYYY-MM-DD", value)}
}
options.since = value
index = next
case strings.HasPrefix(arg, "--since="):
value := strings.TrimSpace(strings.TrimPrefix(arg, "--since="))
if _, parseErr := time.Parse("2006-01-02", value); parseErr != nil {
return options, false, execUsageError{fmt.Sprintf("invalid --since %q: expected YYYY-MM-DD", value)}
}
options.since = value
case arg == "--session" || arg == "--session-id":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
if strings.TrimSpace(value) == "" {
return options, false, execUsageError{fmt.Sprintf("%s requires a value", arg)}
}
options.sessionID = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--session="):
value := strings.TrimSpace(strings.TrimPrefix(arg, "--session="))
if value == "" {
return options, false, execUsageError{"--session requires a value"}
}
options.sessionID = value
case strings.HasPrefix(arg, "--session-id="):
value := strings.TrimSpace(strings.TrimPrefix(arg, "--session-id="))
if value == "" {
return options, false, execUsageError{"--session-id requires a value"}
}
options.sessionID = value
default:
return options, false, execUsageError{fmt.Sprintf("unknown usage flag %q", arg)}
}
}
return options, false, nil
}
// FormatReport renders a usage Report as a per-day table plus totals and net-LOC
// efficiency ratios. Cost and net-LOC are labeled as estimates: persisted usage
// events carry no model id or cost (cost is reconstructed from session
// metadata), and net-LOC is a working-tree diff proxy. The insertions/deletions
// counts come straight from the parsed working-tree --stat.
func FormatReport(report usage.Report, insertions int, deletions int) string {
var builder strings.Builder
builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n")
builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost"))
for _, bucket := range report.Buckets {
builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n",
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)))
}
builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n",
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)))
builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n",
insertions, deletions, report.NetLOC))
if report.NetLOCPositive {
builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC))
builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)))
} else {
builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n")
builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n")
}
return strings.TrimRight(builder.String(), "\n")
}
func formatUSD(value float64) string {
formatted, err := modelregistry.FormatCostUSD(value)
if err != nil {
return "$0.0000"
}
return formatted
}
// groupThousands renders an integer with comma thousands separators, preserving
// a leading minus sign.
func groupThousands(value int) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
digits := fmt.Sprintf("%d", value)
if len(digits) <= 3 {
return sign + digits
}
var out []byte
prefix := len(digits) % 3
if prefix == 0 {
prefix = 3
}
out = append(out, digits[:prefix]...)
for index := prefix; index < len(digits); index += 3 {
out = append(out, ',')
out = append(out, digits[index:index+3]...)
}
return sign + string(out)
}
func writeUsageHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero usage report [flags]
Summarizes token usage and reconstructed (estimated) cost from persisted local
Zero session usage events, plus a working-tree net-LOC efficiency estimate.
Flags:
--json Print JSON report
--days <number> Only include the most recent N days
--since <YYYY-MM-DD> Only include events on or after this date
--session <id> Limit the report to one session
-h, --help Show this help
Cost is reconstructed from session model metadata and is an estimate; net LOC
is a working-tree diff proxy and is labeled as an estimate.
`)
return err
}
+339
View File
@@ -0,0 +1,339 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zerogit"
)
func seedUsageStore(t *testing.T) *sessions.Store {
t.Helper()
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-01T09:00:00Z")})
session, err := store.Create(sessions.CreateInput{SessionID: "usage_s1", Title: "Usage", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
for _, payload := range []map[string]any{
{"promptTokens": 1000, "completionTokens": 200, "totalTokens": 1200},
{"promptTokens": 500, "completionTokens": 100, "totalTokens": 600},
} {
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventUsage, Payload: payload}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
}
return store
}
func stubInspectChanges(stat string) func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return zerogit.ChangeSummary{Root: "/repo", DiffStat: stat}, nil
}
}
func TestRunUsageTextReport(t *testing.T) {
store := seedUsageStore(t)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
inspectChanges: stubInspectChanges(" 1 file changed, 100 insertions(+), 30 deletions(-)"),
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
for _, want := range []string{"2026-06-01", "1,800", "estimate", "net LOC", "+100", "-30"} {
if !strings.Contains(output, want) {
t.Fatalf("usage report missing %q in:\n%s", want, output)
}
}
}
func TestRunUsageDefaultsToReport(t *testing.T) {
store := seedUsageStore(t)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
inspectChanges: stubInspectChanges(""),
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "2026-06-01") {
t.Fatalf("expected default report output, got %q", stdout.String())
}
}
func TestRunUsageJSONReport(t *testing.T) {
store := seedUsageStore(t)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report", "--json"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
inspectChanges: stubInspectChanges(" 1 file changed, 100 insertions(+), 30 deletions(-)"),
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var report struct {
NetLOC int `json:"netLOC"`
Total struct {
Requests int `json:"requests"`
TotalTokens int `json:"totalTokens"`
} `json:"total"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("usage JSON did not decode: %v\n%s", err, stdout.String())
}
if report.NetLOC != 70 || report.Total.Requests != 2 || report.Total.TotalTokens != 1800 {
t.Fatalf("unexpected usage JSON: %+v", report)
}
}
func TestRunUsageSinceFilter(t *testing.T) {
store := seedUsageStore(t)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report", "--since", "2026-07-01"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
inspectChanges: stubInspectChanges(""),
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if strings.Contains(stdout.String(), "2026-06-01") {
t.Fatalf("expected --since to filter out June events, got %q", stdout.String())
}
}
func TestRunUsageSessionFilter(t *testing.T) {
store := seedUsageStore(t)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report", "--json", "--session", "missing_session"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
inspectChanges: stubInspectChanges(""),
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var report struct {
Total struct {
Requests int `json:"requests"`
} `json:"total"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("usage JSON did not decode: %v\n%s", err, stdout.String())
}
if report.Total.Requests != 0 {
t.Fatalf("expected unknown session to yield 0 requests, got %d", report.Total.Requests)
}
}
func TestRunUsageHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "--help"}, &stdout, &stderr, appDeps{})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
for _, want := range []string{"zero usage report", "--json", "--days", "--since", "--session"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("usage help missing %q in:\n%s", want, stdout.String())
}
}
}
func TestRunUsageUnknownFlag(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report", "--bogus"}, &stdout, &stderr, appDeps{})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr.String(), "unknown usage flag") {
t.Fatalf("expected unknown-flag error, got %q", stderr.String())
}
}
// TestRunUsageEmptySessionRejected verifies that an empty --session/--session-id
// value is rejected with exitUsage and the value-required error, matching the
// --since validation style, rather than silently filtering to no session.
func TestRunUsageEmptySessionRejected(t *testing.T) {
cases := []struct {
name string
args []string
flag string
}{
{name: "session equals empty", args: []string{"usage", "report", "--session="}, flag: "--session"},
{name: "session-id equals empty", args: []string{"usage", "report", "--session-id="}, flag: "--session-id"},
{name: "session spaced empty", args: []string{"usage", "report", "--session", " "}, flag: "--session"},
{name: "session-id spaced empty", args: []string{"usage", "report", "--session-id", ""}, flag: "--session-id"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
},
inspectChanges: stubInspectChanges(""),
})
if exitCode != exitUsage {
t.Fatalf("%s: expected exitUsage (%d), got %d", tc.flag, exitUsage, exitCode)
}
msg := stderr.String()
if !strings.Contains(msg, tc.flag+" requires a value") {
t.Fatalf("%s: expected value-required error in stderr, got %q", tc.flag, msg)
}
})
}
}
// seedUsageStoreDates creates a store with usage events spread across two
// different calendar dates. The "recent" session's events fall on recentDate
// and the "old" session's events fall on oldDate. Both dates must be
// YYYY-MM-DD strings that parse as time.RFC3339 with a T00:00:00Z suffix.
func seedUsageStoreDates(t *testing.T, recentDate, oldDate string) (*sessions.Store, func() time.Time) {
t.Helper()
// The store's Now function is used to timestamp every appended event.
// We swap it via this pointer so we can stamp the two sessions differently.
currentTime := mustParseTime(t, recentDate+"T09:00:00Z")
nowFunc := func() time.Time { return currentTime }
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: nowFunc})
// Session whose events fall on recentDate.
sess, err := store.Create(sessions.CreateInput{SessionID: "days_recent", Title: "Recent", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create recent session: %v", err)
}
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
Type: sessions.EventUsage,
Payload: map[string]any{"promptTokens": 100, "completionTokens": 20, "totalTokens": 120},
}); err != nil {
t.Fatalf("AppendEvent recent: %v", err)
}
// Swap the clock to oldDate before creating the old session's events.
currentTime = mustParseTime(t, oldDate+"T09:00:00Z")
sessOld, err := store.Create(sessions.CreateInput{SessionID: "days_old", Title: "Old", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create old session: %v", err)
}
if _, err := store.AppendEvent(sessOld.SessionID, sessions.AppendEventInput{
Type: sessions.EventUsage,
Payload: map[string]any{"promptTokens": 200, "completionTokens": 40, "totalTokens": 240},
}); err != nil {
t.Fatalf("AppendEvent old: %v", err)
}
// Return the store and a fixed-time now func anchored at recentDate.
return store, fixedCLITime(recentDate + "T12:00:00Z")
}
func mustParseTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("mustParseTime %q: %v", value, err)
}
return parsed
}
// TestRunUsageDaysFilter verifies that --days N excludes events outside the
// rolling window and includes events inside it.
func TestRunUsageDaysFilter(t *testing.T) {
// recentDate is within the last 3 days; oldDate is 10 days ago.
recentDate := "2026-06-06"
oldDate := "2026-05-29"
store, nowFunc := seedUsageStoreDates(t, recentDate, oldDate)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report", "--days", "3"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
inspectChanges: stubInspectChanges(""),
now: nowFunc, // anchored at 2026-06-06T12:00:00Z
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
// The recent event (recentDate) must appear.
if !strings.Contains(output, recentDate) {
t.Fatalf("--days 3 should include %s, got:\n%s", recentDate, output)
}
// The old event (oldDate) must be excluded.
if strings.Contains(output, oldDate) {
t.Fatalf("--days 3 should exclude %s, got:\n%s", oldDate, output)
}
}
// TestRunUsageInvalidSince verifies that malformed --since values are rejected
// with exitUsage and the expected validation message, while a valid YYYY-MM-DD
// date is accepted.
func TestRunUsageInvalidSince(t *testing.T) {
cases := []struct {
since string
expectError bool
}{
{"foo", true},
{"2026-6-1", true}, // unpadded month/day
{"06/01/2026", true}, // wrong separator
{"2026-06-01", false}, // valid
}
for _, tc := range cases {
tc := tc
t.Run("since="+tc.since, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report", "--since", tc.since}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
},
inspectChanges: stubInspectChanges(""),
})
if tc.expectError {
if exitCode != exitUsage {
t.Fatalf("--since %q: expected exitUsage (%d), got %d", tc.since, exitUsage, exitCode)
}
msg := stderr.String()
if !strings.Contains(msg, "invalid --since") {
t.Fatalf("--since %q: expected validation error in stderr, got %q", tc.since, msg)
}
if !strings.Contains(msg, "YYYY-MM-DD") {
t.Fatalf("--since %q: expected YYYY-MM-DD hint in stderr, got %q", tc.since, msg)
}
} else {
if exitCode != exitSuccess {
t.Fatalf("--since %q: expected exitSuccess (%d), got %d: %s", tc.since, exitSuccess, exitCode, stderr.String())
}
}
})
}
}
// TestRunUsageEmptyStore verifies that running `usage report` against a store
// with no usage events exits successfully, prints the header, shows a zero
// total, and does not panic.
func TestRunUsageEmptyStore(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"usage", "report"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store { return store },
inspectChanges: stubInspectChanges(""),
})
if exitCode != exitSuccess {
t.Fatalf("empty store: expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "Usage report") {
t.Fatalf("empty store: expected header in output, got:\n%s", output)
}
// Total row must show zero requests / tokens.
if !strings.Contains(output, "total") {
t.Fatalf("empty store: expected 'total' row in output, got:\n%s", output)
}
}
+89
View File
@@ -497,6 +497,95 @@ func TestRunChangesInspectAndCommit(t *testing.T) {
})
}
func TestRunChangesInspectThreadsBaseRef(t *testing.T) {
cwd := t.TempDir()
summary := zerogit.ChangeSummary{
Root: cwd,
Branch: "feature",
Base: "main",
Files: []zerogit.FileChange{{Path: "feature.md", Status: "added"}},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "inspect", "--base", "main"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
if options.BaseRef != "main" {
t.Fatalf("InspectOptions.BaseRef = %q, want main", options.BaseRef)
}
return summary, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "base: main") {
t.Fatalf("expected base line in output, got %q", stdout.String())
}
}
func TestRunChangesCommitRejectsBaseRef(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"changes", "commit", "--base", "main"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
commitChanges: func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error) {
t.Fatal("commitChanges should not be called when --base is rejected")
return zerogit.CommitResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr.String(), "--base") {
t.Fatalf("expected --base rejection error, got %q", stderr.String())
}
}
func TestWriteChangesHelpMentionsBase(t *testing.T) {
var out bytes.Buffer
if err := writeChangesHelp(&out); err != nil {
t.Fatalf("writeChangesHelp error: %v", err)
}
if !strings.Contains(out.String(), "--base") {
t.Fatalf("expected --base in changes help, got %q", out.String())
}
}
func TestParseChangesArgsBaseRef(t *testing.T) {
for _, args := range [][]string{
{"--base", "main"},
{"--base=main"},
} {
options, help, err := parseChangesArgs(args, "inspect")
if err != nil {
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
}
if help {
t.Fatalf("parseChangesArgs(%v) returned help", args)
}
if options.baseRef != "main" {
t.Fatalf("baseRef = %q, want main (args %v)", options.baseRef, args)
}
}
}
func TestParseChangesArgsRejectsBaseOnCommit(t *testing.T) {
_, _, err := parseChangesArgs([]string{"--base", "main"}, "commit")
if err == nil || !strings.Contains(err.Error(), "--base") {
t.Fatalf("expected --base rejection on commit, got %v", err)
}
}
func TestParseChangesArgsRequiresBaseValue(t *testing.T) {
if _, _, err := parseChangesArgs([]string{"--base"}, "inspect"); err == nil {
t.Fatalf("expected error when --base has no value")
}
}
func TestRunExecWorktreeUsesPreparedWorkspace(t *testing.T) {
root := t.TempDir()
worktreeDir := t.TempDir()
+23
View File
@@ -33,6 +33,7 @@ type verifyCommandOptions struct {
type changesCommandOptions struct {
json bool
cwd string
baseRef string
message string
dryRun bool
maxDiffBytes int
@@ -179,6 +180,7 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
case "inspect", "status":
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
Cwd: workspaceRoot,
BaseRef: options.baseRef,
MaxDiffBytes: options.maxDiffBytes,
})
if err != nil {
@@ -374,6 +376,19 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
index = next
case strings.HasPrefix(arg, "--cwd="):
options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
case arg == "--base":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.baseRef = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--base="):
v := strings.TrimSpace(strings.TrimPrefix(arg, "--base="))
if v == "" || flagValueLooksLikeOption(v) {
return options, false, execUsageError{"--base requires a value"}
}
options.baseRef = v
case arg == "-m" || arg == "--message":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -409,6 +424,9 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
if command != "commit" && (options.message != "" || options.dryRun) {
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
}
if command == "commit" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
}
return options, false, nil
}
@@ -481,6 +499,7 @@ func redactVerifyLoopReport(report selfverify.Report) selfverify.Report {
func redactChangeSummary(summary zerogit.ChangeSummary) zerogit.ChangeSummary {
summary.Root = redactCLIString(summary.Root)
summary.Base = redactCLIString(summary.Base)
summary.Branch = redactCLIString(summary.Branch)
summary.Commit = redactCLIString(summary.Commit)
summary.DiffStat = redactCLIString(summary.DiffStat)
@@ -617,6 +636,9 @@ func formatChangeSummary(summary zerogit.ChangeSummary) string {
if summary.Branch != "" {
lines = append(lines, "branch: "+summary.Branch)
}
if summary.Base != "" {
lines = append(lines, "base: "+summary.Base)
}
if summary.Commit != "" {
lines = append(lines, "commit: "+summary.Commit)
}
@@ -687,6 +709,7 @@ Inspects local git changes and optionally creates a commit.
Flags:
-C, --cwd <path> Workspace directory
--base <ref> Diff against <ref>...HEAD instead of the working tree
--diff-bytes <n> Maximum diff bytes to include
-m, --message <text> Commit message for `+"`zero changes commit`"+`
--dry-run Preview commit metadata without mutating git state
-85
View File
@@ -1,85 +0,0 @@
package cli
import (
"flag"
"fmt"
"io"
"github.com/Gitlawb/zero/internal/zenline"
)
// runZenline launches the interactive Zero TUI with the "zenline" skin: a Zen
// home page and a Statusline chat page with 5 switchable color themes. It reuses
// the exact same runtime wiring as the default `zero` shell (provider, tools,
// sandbox, permissions, sessions) — only the rendering differs.
//
// With --snapshot it renders a single static frame (home page) to stdout for
// local verification without a TTY.
func runZenline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
fs := flag.NewFlagSet("zenline", flag.ContinueOnError)
fs.SetOutput(stderr)
snapshot := fs.Bool("snapshot", false, "render a single frame to stdout and exit (no TTY)")
page := fs.String("page", "home", "snapshot page: home|chat")
variant := fs.Int("variant", 1, "color theme 1-5 (1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
light := fs.Bool("light", false, "use the light variant for the snapshot")
perm := fs.Bool("perm", false, "show the centered permission modal in the chat snapshot")
boot := fs.Int("boot", -1, "render the boot splash at the given animation frame")
stream := fs.Bool("stream", false, "show a streaming assistant response in the chat snapshot")
width := fs.Int("width", 100, "snapshot width")
height := fs.Int("height", 30, "snapshot height")
if err := fs.Parse(args); err != nil {
return 2
}
if *snapshot {
v := *variant - 1
if v < 0 || v >= len(zenline.Themes) {
v = 0
}
if *boot >= 0 {
if _, err := fmt.Fprintln(stdout, zenline.RenderBoot(v, !*light, *boot, *width, *height)); err != nil {
return 1
}
return 0
}
hdr := zenline.Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"}
var frame string
if *page == "chat" {
cd := zenline.ChatData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Rows: []zenline.Row{
{Kind: "user", Text: "refactor internal/agent/loop.go to extract tool execution"},
{Kind: "toolcall", Tool: "list_directory", Detail: "internal/agent"},
{Kind: "toolresult", Tool: "list_directory", Status: "ok", Detail: "Contents of internal/agent:\n\nloop.go\ntypes.go\nloop_test.go"},
{Kind: "toolcall", Tool: "read_file", Detail: "internal/agent/loop.go"},
{Kind: "toolresult", Tool: "read_file", Status: "ok", Detail: "File: internal/agent/loop.go (164 lines)\n\n118 | func (l *Loop) run(ctx context.Context) error {"},
{Kind: "toolcall", Tool: "edit_file", Detail: "exec.go (new) · loop.go"},
{Kind: "toolresult", Tool: "edit_file", Status: "ok", Detail: "--- a/internal/agent/loop.go\n+++ b/internal/agent/exec.go\n@@ -141,6 +141,3 @@\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
{Kind: "assistant", Text: "Done. Extracted a `ToolExecutor`:\n\n```go\nfunc (e *ToolExecutor) Dispatch(c Call) (Out, error) {\n\treturn e.route(c)\n}\n```\n\nThe switch in loop.go now delegates to one call. Tests pass."},
},
Input: "❯ ",
}
if *perm {
cd.Perm = &zenline.Perm{Tool: "edit_file", Risk: "medium", Reason: "writes internal/agent/exec.go and loop.go", Summary: "write"}
}
if *stream {
cd.Rows = cd.Rows[:len(cd.Rows)-1] // drop the final assistant row
cd.Working = true
cd.Stream = "Done. I extracted a `ToolExecutor` and collapsed the dispatch switch in loop.go to a single delegated call — the"
cd.TokS = 84
}
frame = zenline.RenderChat(cd)
} else {
frame = zenline.RenderHome(zenline.HomeData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Input: "❯ message zero — / commands · @ files · ! bash",
})
}
if _, err := fmt.Fprintln(stdout, frame); err != nil {
return 1
}
return 0
}
return runInteractiveTUIWithSkin(stderr, deps, "zenline")
}
+124
View File
@@ -0,0 +1,124 @@
package cli
import (
"flag"
"fmt"
"io"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/zeroline"
)
// runZeroline launches the interactive Zero TUI with the "zeroline" skin: a Zen
// home page and a Statusline chat page with 5 switchable color themes. It reuses
// the exact same runtime wiring as the default `zero` shell (provider, tools,
// sandbox, permissions, sessions) — only the rendering differs.
//
// With --snapshot it renders a single static frame (home page) to stdout for
// local verification without a TTY.
func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
fs := flag.NewFlagSet("zeroline", flag.ContinueOnError)
fs.SetOutput(stderr)
snapshot := fs.Bool("snapshot", false, "render a single frame to stdout and exit (no TTY)")
page := fs.String("page", "home", "snapshot page: home|chat")
variant := fs.Int("variant", 0, "color theme 0-5 (0 ZERO, 1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
light := fs.Bool("light", false, "use the light variant for the snapshot")
perm := fs.Bool("perm", false, "show the centered permission modal in the chat snapshot")
stream := fs.Bool("stream", false, "show a streaming assistant response in the chat snapshot")
jsonMode := fs.Bool("json", false, "render the chat snapshot in JSON mode (TEXT/JSON toggle)")
sessionsDrawer := fs.Bool("sessions", false, "show the sessions drawer in the chat snapshot")
tool := fs.String("tool", "", "show a single tool card in the chat snapshot: diff|read|bash|grep")
frameN := fs.Int("frame", 0, "animation frame for the chat snapshot (spinner)")
width := fs.Int("width", 100, "snapshot width")
height := fs.Int("height", 30, "snapshot height")
skipUnsafe := fs.Bool("skip-permissions-unsafe", false, "launch in unsafe permission mode (enables the ! shell escape)")
if err := fs.Parse(args); err != nil {
return 2
}
if *snapshot {
v := *variant // theme index directly (0 ZERO, 1 Phosphor, …)
if v < 0 || v >= len(zeroline.Themes) {
v = 0
}
hdr := zeroline.Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"}
var frame string
if *page == "chat" {
cd := zeroline.ChatData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Rows: []zeroline.Row{
{Kind: "user", Text: "refactor internal/agent/loop.go to extract tool execution"},
{Kind: "tool", Tool: "grep", Text: "internal/agent", Status: "ok", Detail: "internal/agent/loop.go:88:\tfor i, call := range reply.ToolCalls {\ninternal/agent/loop.go:90:\t\tgo func() {"},
{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok", Detail: "func (l *Loop) run(ctx context.Context) error {\n\tfor step := 0; step < l.MaxSteps; step++ {\n\t\treply, _, err := l.model.Complete(ctx, msgs, l.tools)"},
{Kind: "tool", Tool: "edit_file", Text: "internal/agent/exec.go", Status: "ok", Detail: " func (l *Loop) run(ctx context.Context) error {\n-\tswitch t := call.Tool.(type) {\n-\tcase ReadFileTool: out, err = l.readFile(ctx, t)\n+\tout, err := l.exec.Dispatch(call)"},
{Kind: "tool", Tool: "bash", Text: "go test ./...", Status: "ok", Detail: "ok github.com/zero-dev/zero/internal/agent\t0.061s\nok github.com/zero-dev/zero/internal/tools\t0.140s"},
{Kind: "assistant", Text: "Done. Extracted a `ToolExecutor`:\n\n```go\nfunc (e *ToolExecutor) Dispatch(c Call) (Out, error) {\n\treturn e.route(c)\n}\n```\n\nThe switch in loop.go now delegates to one call. Tests pass."},
{Kind: "final", Text: "Extracted tool dispatch into a ToolExecutor; loop.go now delegates. go test ./... is green."},
{Kind: "done", Text: "4 tools · 1,284 tok · $0.04", Status: "ok"},
},
Input: "add a test for the new ToolExecutor",
Spin: *frameN,
}
if *tool != "" {
cd.Rows = toolSnapshotRows(*tool)
}
if *perm {
cd.Perm = &zeroline.Perm{Tool: "edit_file", Risk: "medium", Reason: "writes internal/agent/exec.go and loop.go", Summary: "write"}
}
if *jsonMode {
cd.JSONMode = true
}
if *sessionsDrawer {
cd.Drawer = &zeroline.Drawer{Sessions: zeroline.DefaultSessions()}
}
if *stream {
cd.Rows = cd.Rows[:len(cd.Rows)-1] // drop the final assistant row
cd.Working = true
cd.Stream = "Done. I extracted a `ToolExecutor` and collapsed the dispatch switch in loop.go to a single delegated call — the"
cd.TokS = 84
}
frame = zeroline.RenderChat(cd)
} else {
frame = zeroline.RenderChat(zeroline.ChatData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Input: "describe a task for zero…",
Chips: zeroline.DefaultChips(),
ChipIndex: 0,
})
}
if _, err := fmt.Fprintln(stdout, frame); err != nil {
return 1
}
return 0
}
permissionMode := agent.PermissionModeAsk
if *skipUnsafe {
permissionMode = agent.PermissionModeUnsafe
}
return runInteractiveTUIWithSkin(stderr, deps, "zeroline", permissionMode)
}
// toolSnapshotRows builds a chat focused on a single tool card type, for
// `zeroline --snapshot --tool <kind>`.
func toolSnapshotRows(kind string) []zeroline.Row {
user := zeroline.Row{Kind: "user", Text: "show me the " + kind + " tool card"}
var tool zeroline.Row
switch kind {
case "diff":
tool = zeroline.Row{Kind: "tool", Tool: "edit_file", Text: "internal/cli/root.go", Status: "ok",
Detail: " \tfs.IntVar(&o.MaxSteps, \"max-steps\", 50, \"agent step cap\")\n+\tfs.BoolVar(&o.ShowVersion, \"version\", false, \"print version and exit\")\n \n+\tif o.ShowVersion {\n+\t\tfmt.Fprintln(o.Stdout, \"zero \"+buildinfo.Version)\n+\t}"}
case "read":
tool = zeroline.Row{Kind: "tool", Tool: "read_file", Text: "internal/agent/loop.go", Status: "ok",
Detail: "File: internal/agent/loop.go (164 lines)\n\n 1 | func (l *Loop) Run(ctx context.Context, task string) error {\n 2 | \tl.emit(Event{Type: EventStart})\n 3 | \tmsgs := l.seed(task)\n 4 | \tfor step := 0; step < l.MaxSteps; step++ {\n 5 | \t\treply, usage, err := l.model.Complete(ctx, msgs, l.tools)"}
case "bash":
tool = zeroline.Row{Kind: "tool", Tool: "bash", Text: "go test ./internal/cli/...", Status: "ok",
Detail: "ok \tgithub.com/zero-dev/zero/internal/cli\t0.214s"}
case "grep":
tool = zeroline.Row{Kind: "tool", Tool: "grep", Text: "internal/cli", Status: "ok",
Detail: "internal/cli/root.go:41:fs := flag.NewFlagSet(\"zero\", flag.ContinueOnError)\ninternal/cli/root.go:47:fs.StringVar(&o.Model, \"model\", env(\"ZERO_MODEL\"), \"model id\")"}
default:
tool = zeroline.Row{Kind: "tool", Tool: kind, Text: "(unknown tool kind: " + kind + ")", Status: "error", Detail: "supported: diff|read|bash|grep"}
}
return []zeroline.Row{user, tool, {Kind: "done", Text: "1 tool · 214 tok", Status: "ok"}}
}
+48
View File
@@ -0,0 +1,48 @@
package cli
import (
"bytes"
"strings"
"testing"
)
func TestZerolineSnapshotToolFlag(t *testing.T) {
cases := map[string][]string{
"diff": {"edit_file", "ShowVersion"},
"read": {"read_file", "Loop"},
"bash": {"bash", "exit 0"},
"grep": {"grep", "matches"},
}
for kind, wants := range cases {
var out, errb bytes.Buffer
code := runZeroline([]string{"--snapshot", "--page", "chat", "--tool", kind, "--width", "100", "--height", "20"}, &out, &errb, appDeps{})
if code != 0 {
t.Fatalf("--tool %s: exit %d, stderr=%s", kind, code, errb.String())
}
for _, w := range wants {
if !strings.Contains(out.String(), w) {
t.Errorf("--tool %s snapshot missing %q", kind, w)
}
}
}
}
func TestZerolineSnapshotModes(t *testing.T) {
for _, args := range [][]string{
{"--snapshot", "--page", "home"},
{"--snapshot", "--page", "chat"},
{"--snapshot", "--page", "chat", "--json"},
{"--snapshot", "--page", "chat", "--sessions"},
{"--snapshot", "--page", "chat", "--perm"},
{"--snapshot", "--page", "chat", "--stream", "--frame", "3"},
} {
var out, errb bytes.Buffer
full := append(args, "--width", "100", "--height", "24")
if code := runZeroline(full, &out, &errb, appDeps{}); code != 0 {
t.Fatalf("%v: exit %d, stderr=%s", args, code, errb.String())
}
if strings.TrimSpace(out.String()) == "" {
t.Errorf("%v produced empty output", args)
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ func LoadProviderCommand(command string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider command returned no providers")
}
providers, _, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider)
providers, _, err := normalizeProvidersWithoutModelDefaults(cfg.Providers, cfg.ActiveProvider, map[string]string{})
if err != nil {
return FileConfig{}, err
}
+20
View File
@@ -28,6 +28,26 @@ func TestLoadProviderCommandSuccess(t *testing.T) {
}
}
func TestLoadProviderCommandDoesNotResolveAPIKeyEnvFromProcess(t *testing.T) {
t.Setenv("ZERO_CMD_API_KEY", "sk-process")
command := writeCommand(t, commandScript{
Stdout: `{"name":"cmd","provider":"openai","apiKeyEnv":"ZERO_CMD_API_KEY","model":"gpt-command"}`,
})
cfg, err := LoadProviderCommand(command)
if err != nil {
t.Fatalf("LoadProviderCommand() error = %v", err)
}
provider := cfg.Providers[0]
if provider.APIKey != "" {
t.Fatalf("APIKey = %q, want unresolved provider-command apiKeyEnv", provider.APIKey)
}
if provider.APIKeyEnv != "ZERO_CMD_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want command apiKeyEnv preserved", provider.APIKeyEnv)
}
}
func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
command := writeCommand(t, commandScript{
Stderr: "failed with sk-command-secret",
+11 -3
View File
@@ -10,12 +10,12 @@ import (
// DefaultResolveOptions builds config resolution inputs from the local process
// environment and workspace.
func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
userConfigDir, err := os.UserConfigDir()
userConfigPath, err := DefaultUserConfigPath()
if err != nil {
return ResolveOptions{}, fmt.Errorf("resolve user config directory: %w", err)
return ResolveOptions{}, err
}
userConfigPath, err := existingConfigFile(filepath.Join(userConfigDir, "zero", "config.json"))
userConfigPath, err = existingConfigFile(userConfigPath)
if err != nil {
return ResolveOptions{}, err
}
@@ -32,6 +32,14 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
}, nil
}
func DefaultUserConfigPath() (string, error) {
userConfigDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config directory: %w", err)
}
return filepath.Join(userConfigDir, "zero", "config.json"), nil
}
func existingConfigFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
+398 -30
View File
@@ -3,29 +3,41 @@ package config
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/sandbox"
)
const defaultMaxTurns = 12
const defaultDeferThreshold = 10
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
cfg := FileConfig{
MaxTurns: defaultMaxTurns,
}
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
if path == "" {
continue
}
fileConfig, err := loadConfigFile(path)
if options.UserConfigPath != "" {
fileConfig, err := loadConfigFile(options.UserConfigPath)
if err != nil {
return ResolvedConfig{}, err
}
mergeConfig(&cfg, fileConfig)
}
if options.ProjectConfigPath != "" {
fileConfig, err := loadConfigFile(options.ProjectConfigPath)
if err != nil {
return ResolvedConfig{}, err
}
if err := mergeProjectConfig(&cfg, fileConfig); err != nil {
return ResolvedConfig{}, err
}
}
applyEnv(&cfg, options.Env)
@@ -39,7 +51,38 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
applyOverrides(&cfg, options.Overrides)
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider)
if !cfg.Tools.deferThresholdSet && cfg.Tools.DeferThreshold == 0 {
cfg.Tools.DeferThreshold = defaultDeferThreshold
}
if cfg.Tools.DeferThreshold < 0 {
return ResolvedConfig{}, fmt.Errorf("invalid tools.deferThreshold %d: must be >= 0", cfg.Tools.DeferThreshold)
}
if maxAutonomy := strings.TrimSpace(cfg.Sandbox.MaxAutonomy); maxAutonomy != "" {
// Fail loud on an invalid ceiling. An unvalidated typo (e.g. "moderate")
// would otherwise survive Resolve untouched, reach the sandbox bridge,
// fail to normalize there, and silently leave the default High ceiling in
// place — fail-open on a security boundary. Reject it here instead.
if _, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(maxAutonomy)); err != nil {
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.maxAutonomy %q: expected low, medium, or high", maxAutonomy)
}
}
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
switch notify.Mode(mode) {
case notify.ModeOff, notify.ModeBell, notify.ModeNotify, notify.ModeBoth:
default:
return ResolvedConfig{}, fmt.Errorf("invalid notify.mode %q: expected off, bell, notify, or both", mode)
}
}
if focusMode := strings.TrimSpace(cfg.Notify.FocusMode); focusMode != "" {
switch notify.FocusMode(focusMode) {
case notify.FocusUnfocused, notify.FocusAlways, notify.FocusFocused:
default:
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
}
}
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
return ResolvedConfig{}, err
}
@@ -50,6 +93,9 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Provider: active,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
}, nil
}
@@ -94,16 +140,54 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP)
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
dst.Sandbox.MaxAutonomy = maxAutonomy
}
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
dst.Notify.Mode = mode
}
if focusMode := strings.TrimSpace(src.Notify.FocusMode); focusMode != "" {
dst.Notify.FocusMode = focusMode
}
if src.Tools.deferThresholdSet {
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
dst.Tools.deferThresholdSet = true
}
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
dst.ActiveProvider = activeProvider
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
}
for _, provider := range src.Providers {
candidate := providerMergeCandidate(*dst, provider)
if err := validateProjectProviderMerge(provider, candidate); err != nil {
return err
}
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP)
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
dst.Sandbox.MaxAutonomy = maxAutonomy
}
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
dst.Notify.Mode = mode
}
if focusMode := strings.TrimSpace(src.Notify.FocusMode); focusMode != "" {
dst.Notify.FocusMode = focusMode
}
if src.Tools.deferThresholdSet {
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
dst.Tools.deferThresholdSet = true
}
return nil
}
func mergeProvider(cfg *FileConfig, provider ProviderProfile) {
provider.Name = strings.TrimSpace(provider.Name)
if provider.Name == "" {
provider.Name = strings.TrimSpace(cfg.ActiveProvider)
}
if provider.Name == "" {
provider.Name = string(ProviderKindOpenAI)
}
provider.Name = providerMergeName(*cfg, provider)
for index := range cfg.Providers {
if cfg.Providers[index].Name == provider.Name {
@@ -114,6 +198,138 @@ func mergeProvider(cfg *FileConfig, provider ProviderProfile) {
cfg.Providers = append(cfg.Providers, provider)
}
func providerMergeCandidate(cfg FileConfig, provider ProviderProfile) ProviderProfile {
provider.Name = providerMergeName(cfg, provider)
for _, existing := range cfg.Providers {
if existing.Name == provider.Name {
return mergeProfile(existing, provider)
}
}
return provider
}
func providerMergeName(cfg FileConfig, provider ProviderProfile) string {
name := strings.TrimSpace(provider.Name)
if name == "" {
name = strings.TrimSpace(cfg.ActiveProvider)
}
if name == "" {
name = string(ProviderKindOpenAI)
}
return name
}
func validateProjectProviderMerge(project ProviderProfile, candidate ProviderProfile) error {
if strings.TrimSpace(project.APIKeyEnv) != "" &&
projectEndpointNeedsCredentialGuard(candidate) &&
!projectAPIKeyEnvAllowed(candidate, project.APIKeyEnv) {
return providerError(candidate, "project provider %s cannot bind apiKeyEnv to custom provider endpoint", candidate.Name)
}
if strings.TrimSpace(project.BaseURL) != "" &&
projectEndpointNeedsCredentialGuard(candidate) &&
hasInheritedProviderCredentialMaterial(project, candidate) &&
!projectBaseURLAllowed(candidate) {
return providerError(candidate, "project provider %s cannot override baseURL for a credentialed custom provider endpoint", candidate.Name)
}
return nil
}
func projectEndpointNeedsCredentialGuard(profile ProviderProfile) bool {
kind := effectiveProviderKind(profile)
switch kind {
case ProviderKindOpenAICompatible, ProviderKindAnthropicCompat:
return strings.TrimSpace(profile.BaseURL) != "" || strings.TrimSpace(profile.CatalogID) != ""
case ProviderKindOpenAI:
return profile.BaseURL != "" && !isOfficialOpenAIBaseURL(profile.BaseURL)
case ProviderKindAnthropic:
return profile.BaseURL != "" && !isOfficialAnthropicBaseURL(profile.BaseURL)
case ProviderKindGoogle:
return profile.BaseURL != "" && !isOfficialGoogleBaseURL(profile.BaseURL)
default:
return strings.TrimSpace(profile.BaseURL) != ""
}
}
func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
descriptor, ok := catalogDescriptorForProfile(profile)
if !ok {
return false
}
baseURL := strings.TrimSpace(profile.BaseURL)
if baseURL == "" {
baseURL = descriptor.DefaultBaseURL
}
if !sameBaseURL(baseURL, descriptor.DefaultBaseURL) {
return false
}
return containsStringFold(descriptor.AuthEnvVars, envName)
}
func projectBaseURLAllowed(profile ProviderProfile) bool {
descriptor, ok := catalogDescriptorForProfile(profile)
return ok && sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
}
func hasInheritedProviderCredentialMaterial(project ProviderProfile, candidate ProviderProfile) bool {
if strings.TrimSpace(project.APIKey) == "" && strings.TrimSpace(candidate.APIKey) != "" {
return true
}
if strings.TrimSpace(project.APIKeyEnv) == "" && strings.TrimSpace(candidate.APIKeyEnv) != "" {
return true
}
if strings.TrimSpace(project.AuthHeaderValue) == "" && strings.TrimSpace(candidate.AuthHeaderValue) != "" {
return true
}
if project.CustomHeaders == nil && hasCustomHeaderMaterial(candidate.CustomHeaders) {
return true
}
return false
}
func hasCustomHeaderMaterial(headers map[string]string) bool {
for _, value := range headers {
if strings.TrimSpace(value) != "" {
return true
}
}
return false
}
func effectiveProviderKind(profile ProviderProfile) ProviderKind {
if kind := ProviderKind(strings.TrimSpace(strings.ToLower(string(profile.ProviderKind)))); kind != "" {
return kind
}
if provider := strings.TrimSpace(strings.ToLower(profile.Provider)); provider != "" {
return ProviderKind(provider)
}
if descriptor, ok := catalogDescriptorForProfile(profile); ok {
return providerKindForCatalogTransport(descriptor.Transport)
}
return ""
}
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
catalogID := providercatalog.NormalizeID(profile.CatalogID)
if catalogID == "" {
return providercatalog.Descriptor{}, false
}
descriptor, err := providercatalog.Require(catalogID)
if err != nil {
return providercatalog.Descriptor{}, false
}
return descriptor, true
}
func containsStringFold(values []string, target string) bool {
target = strings.TrimSpace(target)
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), target) {
return true
}
}
return false
}
func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
if next.Name != "" {
base.Name = next.Name
@@ -124,12 +340,33 @@ func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
if next.ProviderKind != "" {
base.ProviderKind = next.ProviderKind
}
if next.CatalogID != "" {
base.CatalogID = next.CatalogID
}
if next.BaseURL != "" {
base.BaseURL = next.BaseURL
}
if next.APIKey != "" {
base.APIKey = next.APIKey
}
if next.APIKeyEnv != "" {
base.APIKeyEnv = next.APIKeyEnv
}
if next.APIFormat != "" {
base.APIFormat = next.APIFormat
}
if next.AuthHeader != "" {
base.AuthHeader = next.AuthHeader
}
if next.AuthScheme != "" {
base.AuthScheme = next.AuthScheme
}
if next.AuthHeaderValue != "" {
base.AuthHeaderValue = next.AuthHeaderValue
}
if next.CustomHeaders != nil {
base.CustomHeaders = copyStringMap(next.CustomHeaders)
}
if next.Model != "" {
base.Model = next.Model
}
@@ -145,6 +382,10 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
cfg.ActiveProvider = activeProvider
}
if maxAutonomy := strings.TrimSpace(envValue(env, "ZERO_SANDBOX_MAX_AUTONOMY")); maxAutonomy != "" {
cfg.Sandbox.MaxAutonomy = maxAutonomy
}
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
Name: string(ProviderKindOpenAI),
APIKey: envValue(env, "OPENAI_API_KEY"),
@@ -176,9 +417,6 @@ func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile
apiKey := strings.TrimSpace(env.APIKey)
baseURL := strings.TrimSpace(env.BaseURL)
model := strings.TrimSpace(env.Model)
if providerKind == ProviderKindOpenAI && apiKey != "" && model == "" && isOfficialOpenAIBaseURL(baseURL) {
model = modelregistry.DefaultModelID
}
if apiKey == "" && baseURL == "" && model == "" {
return
}
@@ -246,6 +484,19 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
}
if maxAutonomy := strings.TrimSpace(overrides.Sandbox.MaxAutonomy); maxAutonomy != "" {
cfg.Sandbox.MaxAutonomy = maxAutonomy
}
if mode := strings.TrimSpace(overrides.Notify.Mode); mode != "" {
cfg.Notify.Mode = mode
}
if focusMode := strings.TrimSpace(overrides.Notify.FocusMode); focusMode != "" {
cfg.Notify.FocusMode = focusMode
}
if overrides.Tools.deferThresholdSet || overrides.Tools.DeferThreshold != 0 {
cfg.Tools.DeferThreshold = overrides.Tools.DeferThreshold
cfg.Tools.deferThresholdSet = true
}
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -304,17 +555,27 @@ func copyMCPStringMap(values map[string]string) map[string]string {
}
func hasProviderFields(profile ProviderProfile) bool {
return profile.Name != "" ||
profile.Provider != "" ||
profile.ProviderKind != "" ||
profile.BaseURL != "" ||
profile.APIKey != "" ||
profile.Model != "" ||
profile.Description != ""
return HasProviderProfile(profile)
}
func normalizeProviders(providers []ProviderProfile, activeName string) ([]ProviderProfile, ProviderProfile, error) {
type normalizeOptions struct {
defaultOpenAIModel bool
}
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
}
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{}, envMaps...)
}
func normalizeProvidersWithOptions(providers []ProviderProfile, activeName string, options normalizeOptions, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
activeName = strings.TrimSpace(activeName)
var env map[string]string
if len(envMaps) > 0 {
env = envMaps[0]
}
if len(providers) == 0 {
if activeName != "" {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
@@ -330,7 +591,7 @@ func normalizeProviders(providers []ProviderProfile, activeName string) ([]Provi
var active ProviderProfile
activeFound := false
for _, provider := range providers {
next, err := normalizeProvider(provider)
next, err := normalizeProvider(provider, env, options)
if err != nil {
return nil, ProviderProfile{}, err
}
@@ -351,27 +612,50 @@ func normalizeProviders(providers []ProviderProfile, activeName string) ([]Provi
return normalized, active, nil
}
func normalizeProvider(profile ProviderProfile) (ProviderProfile, error) {
func normalizeProvider(profile ProviderProfile, env map[string]string, options normalizeOptions) (ProviderProfile, error) {
profile.Name = strings.TrimSpace(profile.Name)
profile.Provider = strings.TrimSpace(profile.Provider)
profile.ProviderKind = ProviderKind(strings.TrimSpace(strings.ToLower(string(profile.ProviderKind))))
profile.CatalogID = providercatalog.NormalizeID(profile.CatalogID)
profile.BaseURL = strings.TrimSpace(profile.BaseURL)
explicitBaseURL := profile.BaseURL != ""
profile.APIKeyEnv = strings.TrimSpace(profile.APIKeyEnv)
profile.APIFormat = strings.TrimSpace(profile.APIFormat)
profile.AuthHeader = strings.TrimSpace(profile.AuthHeader)
profile.AuthScheme = strings.TrimSpace(profile.AuthScheme)
profile.AuthHeaderValue = strings.TrimSpace(profile.AuthHeaderValue)
profile.Model = strings.TrimSpace(profile.Model)
if profile.Name == "" {
profile.Name = string(ProviderKindOpenAI)
}
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
return ProviderProfile{}, providerError(profile, "%s", err.Error())
}
if !providercatalog.RuntimeSupported(descriptor) {
return ProviderProfile{}, providerError(profile, "provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
}
applyCatalogDescriptor(&profile, descriptor, explicitBaseURL)
}
if profile.ProviderKind == "" && profile.Provider != "" {
profile.ProviderKind = ProviderKind(strings.ToLower(profile.Provider))
}
if profile.ProviderKind == "" {
profile.ProviderKind = ProviderKindOpenAI
}
if strings.TrimSpace(profile.APIKey) == "" && profile.APIKeyEnv != "" {
profile.APIKey = strings.TrimSpace(envValue(env, profile.APIKeyEnv))
}
switch profile.ProviderKind {
case ProviderKindOpenAI:
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
profile.BaseURL = OpenAIBaseURL
if options.defaultOpenAIModel && profile.Model == "" {
profile.Model = modelregistry.DefaultModelID
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "openai provider %s requires official baseURL %s", profile.Name, OpenAIBaseURL)
@@ -384,20 +668,104 @@ func normalizeProvider(profile ProviderProfile) (ProviderProfile, error) {
}
return profile, nil
case ProviderKindAnthropic:
if profile.BaseURL == "" {
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
profile.BaseURL = AnthropicBaseURL
return profile, nil
}
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
case ProviderKindAnthropicCompat:
if profile.BaseURL == "" {
return ProviderProfile{}, providerError(profile, "anthropic-compatible provider %s requires baseURL", profile.Name)
}
if isOfficialAnthropicBaseURL(profile.BaseURL) {
return ProviderProfile{}, providerError(profile, "anthropic-compatible provider %s requires custom baseURL", profile.Name)
}
return profile, nil
case ProviderKindGoogle:
if profile.BaseURL == "" {
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
profile.BaseURL = GoogleBaseURL
return profile, nil
}
return profile, nil
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
default:
return ProviderProfile{}, providerError(profile, "unknown provider kind %q for provider %s", profile.ProviderKind, profile.Name)
}
}
func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog.Descriptor, explicitBaseURL bool) {
if descriptor.ID != "" {
profile.CatalogID = descriptor.ID
}
if profile.ProviderKind == "" && strings.TrimSpace(profile.Provider) == "" {
profile.ProviderKind = providerKindForCatalogTransport(descriptor.Transport)
}
if profile.BaseURL == "" {
profile.BaseURL = descriptor.DefaultBaseURL
}
if profile.Model == "" {
profile.Model = descriptor.DefaultModel
}
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
}
}
func sameBaseURL(left string, right string) bool {
return strings.EqualFold(
strings.TrimRight(strings.TrimSpace(left), "/"),
strings.TrimRight(strings.TrimSpace(right), "/"),
)
}
func isOfficialAnthropicBaseURL(baseURL string) bool {
normalized := strings.TrimSpace(baseURL)
if strings.TrimRight(normalized, "/") == strings.TrimRight(AnthropicBaseURL, "/") {
return true
}
parsed, err := url.Parse(normalized)
if err != nil || parsed.Host == "" {
return false
}
official, err := url.Parse(AnthropicBaseURL)
if err != nil {
return false
}
return strings.EqualFold(parsed.Host, official.Host)
}
func isOfficialGoogleBaseURL(baseURL string) bool {
normalized := strings.TrimSpace(baseURL)
if strings.TrimRight(normalized, "/") == strings.TrimRight(GoogleBaseURL, "/") {
return true
}
parsed, err := url.Parse(normalized)
if err != nil || parsed.Host == "" {
return false
}
official, err := url.Parse(GoogleBaseURL)
if err != nil {
return false
}
return strings.EqualFold(parsed.Host, official.Host)
}
func providerKindForCatalogTransport(transport providercatalog.Transport) ProviderKind {
switch transport {
case providercatalog.TransportOpenAI:
return ProviderKindOpenAI
case providercatalog.TransportAnthropic:
return ProviderKindAnthropic
case providercatalog.TransportAnthropicCompatible:
return ProviderKindAnthropicCompat
case providercatalog.TransportGoogle:
return ProviderKindGoogle
case providercatalog.TransportOpenAICompatible:
return ProviderKindOpenAICompatible
default:
return ProviderKind(strings.ToLower(string(transport)))
}
}
func isOfficialOpenAIBaseURL(baseURL string) bool {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
return baseURL == "" ||
@@ -410,5 +778,5 @@ func providerError(profile ProviderProfile, format string, args ...any) error {
if profile.APIKey != "" {
message += " (apiKey=[REDACTED])"
}
return fmt.Errorf("%s", redactSecrets(message, profile.APIKey))
return fmt.Errorf("%s", redactSecrets(message, profile.APIKey, profile.AuthHeaderValue))
}
+866 -25
View File
@@ -91,6 +91,308 @@ func TestResolveSelectsActiveProviderProfile(t *testing.T) {
}
}
func TestResolveLoadsProviderCatalogSnakeAndCamelJSONFields(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "snake",
"providers": [{
"name": "snake",
"provider_kind": "openai-compatible",
"base_url": "https://snake.example/v1",
"model": "snake-model",
"catalog_id": "custom-openai-compatible",
"api_key_env": "ZERO_SNAKE_API_KEY",
"api_format": "responses",
"auth_header": "X-API-Key",
"auth_scheme": "Token",
"auth_header_value": "env:ZERO_SNAKE_HEADER"
}, {
"name": "camel",
"provider": "anthropic",
"model": "camel-model",
"catalogID": "anthropic",
"apiKeyEnv": "ZERO_CAMEL_API_KEY",
"apiFormat": "messages",
"authHeader": "Authorization",
"authScheme": "Bearer",
"authHeaderValue": "env:ZERO_CAMEL_HEADER"
}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "custom-openai-compatible" {
t.Fatalf("CatalogID = %q, want snake alias value", resolved.Provider.CatalogID)
}
if resolved.Provider.APIKeyEnv != "ZERO_SNAKE_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want snake alias value", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIFormat != "responses" {
t.Fatalf("APIFormat = %q, want snake alias value", resolved.Provider.APIFormat)
}
if resolved.Provider.AuthHeader != "X-API-Key" {
t.Fatalf("AuthHeader = %q, want snake alias value", resolved.Provider.AuthHeader)
}
if resolved.Provider.AuthScheme != "Token" {
t.Fatalf("AuthScheme = %q, want snake alias value", resolved.Provider.AuthScheme)
}
if resolved.Provider.AuthHeaderValue != "env:ZERO_SNAKE_HEADER" {
t.Fatalf("AuthHeaderValue = %q, want snake alias value", resolved.Provider.AuthHeaderValue)
}
if resolved.Provider.APIKey != "" {
t.Fatalf("APIKey = %q, want apiKeyEnv reference not resolved without env value", resolved.Provider.APIKey)
}
camel := providerByName(t, resolved.Providers, "camel")
if camel.CatalogID != "anthropic" {
t.Fatalf("camel CatalogID = %q, want camel alias value", camel.CatalogID)
}
if camel.APIKeyEnv != "ZERO_CAMEL_API_KEY" {
t.Fatalf("camel APIKeyEnv = %q, want camel alias value", camel.APIKeyEnv)
}
if camel.APIFormat != "messages" {
t.Fatalf("camel APIFormat = %q, want camel alias value", camel.APIFormat)
}
if camel.AuthHeader != "Authorization" {
t.Fatalf("camel AuthHeader = %q, want camel alias value", camel.AuthHeader)
}
if camel.AuthScheme != "Bearer" {
t.Fatalf("camel AuthScheme = %q, want camel alias value", camel.AuthScheme)
}
if camel.AuthHeaderValue != "env:ZERO_CAMEL_HEADER" {
t.Fatalf("camel AuthHeaderValue = %q, want camel alias value", camel.AuthHeaderValue)
}
}
func TestResolveMergesProviderCatalogFieldsByLayerPrecedence(t *testing.T) {
userPath := writeConfig(t, `{
"activeProvider": "catalog",
"providers": [{
"name": "catalog",
"provider_kind": "openai-compatible",
"base_url": "https://catalog.example/v1",
"model": "user-model",
"catalog_id": "openai",
"api_key_env": "ZERO_USER_API_KEY",
"api_format": "user-format",
"auth_header": "X-User-Key",
"auth_scheme": "UserScheme",
"auth_header_value": "env:ZERO_USER_HEADER"
}]
}`)
projectPath := writeConfig(t, `{
"providers": [{
"name": "catalog",
"catalogID": "anthropic",
"apiFormat": "project-format",
"authHeader": "X-Project-Key",
"authScheme": "ProjectScheme",
"authHeaderValue": "env:ZERO_PROJECT_HEADER"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
Overrides: Overrides{
Provider: ProviderProfile{
Name: "catalog",
CatalogID: "custom-openai-compatible",
APIFormat: "cli-format",
AuthScheme: "CliScheme",
},
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "custom-openai-compatible" {
t.Fatalf("CatalogID = %q, want CLI override", resolved.Provider.CatalogID)
}
if resolved.Provider.APIKeyEnv != "ZERO_USER_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want inherited user value", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIFormat != "cli-format" {
t.Fatalf("APIFormat = %q, want CLI override", resolved.Provider.APIFormat)
}
if resolved.Provider.AuthHeader != "X-Project-Key" {
t.Fatalf("AuthHeader = %q, want project override", resolved.Provider.AuthHeader)
}
if resolved.Provider.AuthScheme != "CliScheme" {
t.Fatalf("AuthScheme = %q, want CLI override", resolved.Provider.AuthScheme)
}
if resolved.Provider.AuthHeaderValue != "env:ZERO_PROJECT_HEADER" {
t.Fatalf("AuthHeaderValue = %q, want project override", resolved.Provider.AuthHeaderValue)
}
if resolved.Provider.Model != "user-model" {
t.Fatalf("Model = %q, want inherited user model", resolved.Provider.Model)
}
}
func TestResolveAPIKeyEnvLooksUpEnvOnlyWhenAPIKeyMissing(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "from-env",
"providers": [{
"name": "from-env",
"provider": "openai",
"apiKeyEnv": "ZERO_FROM_ENV_API_KEY",
"model": "gpt-from-env"
}, {
"name": "direct",
"provider": "openai",
"apiKey": "sk-direct",
"apiKeyEnv": "ZERO_DIRECT_API_KEY",
"model": "gpt-direct"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZERO_FROM_ENV_API_KEY": "sk-from-env",
"ZERO_DIRECT_API_KEY": "sk-should-not-win",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.APIKey != "sk-from-env" {
t.Fatalf("APIKey = %q, want value from apiKeyEnv", resolved.Provider.APIKey)
}
if resolved.Provider.APIKeyEnv != "ZERO_FROM_ENV_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want env reference preserved", resolved.Provider.APIKeyEnv)
}
direct := providerByName(t, resolved.Providers, "direct")
if direct.APIKey != "sk-direct" {
t.Fatalf("direct APIKey = %q, want direct apiKey to win over apiKeyEnv", direct.APIKey)
}
}
func TestResolveAPIKeyEnvRedactsResolvedSecretOnErrors(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
"providers": [{
"name": "custom",
"provider_kind": "openai-compatible",
"apiKeyEnv": "ZERO_CUSTOM_API_KEY",
"model": "custom-model"
}]
}`)
_, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZERO_CUSTOM_API_KEY": "sk-env-secret-value",
},
})
if err == nil {
t.Fatal("Resolve() error = nil, want validation error")
}
if strings.Contains(err.Error(), "sk-env-secret-value") {
t.Fatalf("error leaked apiKeyEnv secret: %q", err.Error())
}
if !strings.Contains(err.Error(), "[REDACTED]") {
t.Fatalf("error = %q, want redaction marker", err.Error())
}
}
func TestResolveRejectsProjectCompatibleProviderArbitraryAPIKeyEnv(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "evil",
"providers": [{
"name": "evil",
"provider": "openai-compatible",
"baseURL": "https://attacker.example/v1",
"apiKeyEnv": "ANTHROPIC_API_KEY",
"model": "attacker-model"
}]
}`)
_, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ANTHROPIC_API_KEY": "sk-ant-victim-secret",
},
})
if err == nil {
t.Fatal("Resolve() error = nil, want unsafe project credential binding rejection")
}
if strings.Contains(err.Error(), "sk-ant-victim-secret") {
t.Fatalf("error leaked apiKeyEnv secret: %q", err.Error())
}
if !strings.Contains(err.Error(), "project provider evil cannot bind apiKeyEnv") {
t.Fatalf("error = %q, want project apiKeyEnv rejection", err.Error())
}
}
func TestResolveRejectsProjectBaseURLOverrideWithInheritedCompatibleCredentials(t *testing.T) {
userPath := writeConfig(t, `{
"activeProvider": "shared",
"providers": [{
"name": "shared",
"provider_kind": "openai-compatible",
"base_url": "https://api.openrouter.ai/api/v1",
"api_key_env": "OPENROUTER_API_KEY",
"model": "openai/gpt-4.1"
}]
}`)
projectPath := writeConfig(t, `{
"providers": [{
"name": "shared",
"base_url": "https://attacker.example/v1"
}]
}`)
_, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{
"OPENROUTER_API_KEY": "sk-openrouter-victim-secret",
},
})
if err == nil {
t.Fatal("Resolve() error = nil, want unsafe project baseURL override rejection")
}
if strings.Contains(err.Error(), "sk-openrouter-victim-secret") {
t.Fatalf("error leaked apiKeyEnv secret: %q", err.Error())
}
if !strings.Contains(err.Error(), "project provider shared cannot override baseURL") {
t.Fatalf("error = %q, want project baseURL override rejection", err.Error())
}
}
func TestResolveAllowsProjectCatalogAPIKeyEnvOnCatalogEndpoint(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "openrouter",
"providers": [{
"name": "openrouter",
"catalogID": "openrouter",
"apiKeyEnv": "OPENROUTER_API_KEY"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"OPENROUTER_API_KEY": "sk-openrouter",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.APIKey != "sk-openrouter" {
t.Fatalf("APIKey = %q, want catalog env-resolved key", resolved.Provider.APIKey)
}
if resolved.Provider.BaseURL != "https://openrouter.ai/api/v1" {
t.Fatalf("BaseURL = %q, want catalog default", resolved.Provider.BaseURL)
}
}
func TestResolveReplacesMCPServerOverlayCollections(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
@@ -300,6 +602,34 @@ func TestResolveUsesOpenAIAPIKeyOnlyWithDefaultModel(t *testing.T) {
}
}
func TestResolvePreservesOpenAIConfigModelWhenAPIKeyEnvIsSet(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"provider": "openai",
"model": "gpt-4.1-mini"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"OPENAI_API_KEY": "sk-env",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.Model != "gpt-4.1-mini" {
t.Fatalf("Model = %q, want configured model preserved", resolved.Provider.Model)
}
if resolved.Provider.APIKey != "sk-env" {
t.Fatalf("APIKey = %q, want env key attached", resolved.Provider.APIKey)
}
}
func TestResolveDoesNotDefaultOpenAICustomBaseURLModel(t *testing.T) {
_, err := Resolve(ResolveOptions{
Env: map[string]string{
@@ -322,15 +652,13 @@ func TestResolveDoesNotDefaultOpenAICustomBaseURLModel(t *testing.T) {
func TestResolveUsesAnthropicEnvFallback(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"ZERO_PROVIDER": "anthropic",
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_BASE_URL": "https://anthropic.example",
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
"OPENAI_API_KEY": "sk-openai-env",
"OPENAI_MODEL": "gpt-4.1",
"GEMINI_API_KEY": "sk-google-env",
"GEMINI_MODEL": "gemini-2.5-flash",
"GEMINI_BASE_URL": "https://google.example",
"ZERO_PROVIDER": "anthropic",
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
"OPENAI_API_KEY": "sk-openai-env",
"OPENAI_MODEL": "gpt-4.1",
"GEMINI_API_KEY": "sk-google-env",
"GEMINI_MODEL": "gemini-2.5-flash",
},
})
if err != nil {
@@ -346,14 +674,42 @@ func TestResolveUsesAnthropicEnvFallback(t *testing.T) {
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-sonnet-4.5" {
t.Fatalf("Provider = %#v, want Anthropic env credentials/model", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://anthropic.example" {
t.Fatalf("BaseURL = %q, want Anthropic env URL", resolved.Provider.BaseURL)
if resolved.Provider.BaseURL != AnthropicBaseURL {
t.Fatalf("BaseURL = %q, want official Anthropic URL", resolved.Provider.BaseURL)
}
if len(resolved.Providers) != 3 {
t.Fatalf("Providers = %#v, want openai, anthropic, and google env profiles", resolved.Providers)
}
}
func TestResolveRejectsAnthropicOfficialProviderCustomBaseURL(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "anthropic",
"providers": [{
"name": "anthropic",
"provider": "anthropic",
"baseURL": "https://attacker.example/anthropic",
"model": "claude-sonnet-4.5"
}]
}`)
_, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ANTHROPIC_API_KEY": "sk-ant-secret",
},
})
if err == nil {
t.Fatal("Resolve() error = nil, want custom Anthropic baseURL rejection")
}
if !strings.Contains(err.Error(), "anthropic provider anthropic requires official baseURL") {
t.Fatalf("error = %q, want official Anthropic baseURL message", err.Error())
}
if strings.Contains(err.Error(), "sk-ant-secret") {
t.Fatalf("error leaked Anthropic API key: %q", err.Error())
}
}
func TestResolveUsesAnthropicEnvFallbackWithCustomProfile(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "claude-prod",
@@ -367,10 +723,9 @@ func TestResolveUsesAnthropicEnvFallbackWithCustomProfile(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZERO_PROVIDER": "claude-prod",
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_BASE_URL": "https://anthropic.example",
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
"ZERO_PROVIDER": "claude-prod",
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
},
})
if err != nil {
@@ -386,8 +741,8 @@ func TestResolveUsesAnthropicEnvFallbackWithCustomProfile(t *testing.T) {
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-sonnet-4.5" {
t.Fatalf("Provider = %#v, want Anthropic env credentials/model on custom profile", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://anthropic.example" {
t.Fatalf("BaseURL = %q, want Anthropic env URL", resolved.Provider.BaseURL)
if resolved.Provider.BaseURL != AnthropicBaseURL {
t.Fatalf("BaseURL = %q, want official Anthropic URL", resolved.Provider.BaseURL)
}
if resolved.Provider.Description != "production Claude" {
t.Fatalf("Description = %q, want existing custom profile metadata preserved", resolved.Provider.Description)
@@ -400,10 +755,9 @@ func TestResolveUsesAnthropicEnvFallbackWithCustomProfile(t *testing.T) {
func TestResolveUsesGoogleEnvFallbackAliases(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"ZERO_PROVIDER": "google",
"GOOGLE_API_KEY": "sk-google-env",
"GOOGLE_BASE_URL": "https://google.example",
"GOOGLE_MODEL": "gemini-2.5-pro",
"ZERO_PROVIDER": "google",
"GOOGLE_API_KEY": "sk-google-env",
"GOOGLE_MODEL": "gemini-2.5-pro",
},
})
if err != nil {
@@ -419,8 +773,36 @@ func TestResolveUsesGoogleEnvFallbackAliases(t *testing.T) {
if resolved.Provider.APIKey != "sk-google-env" || resolved.Provider.Model != "gemini-2.5-pro" {
t.Fatalf("Provider = %#v, want Google env credentials/model", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://google.example" {
t.Fatalf("BaseURL = %q, want Google env URL", resolved.Provider.BaseURL)
if resolved.Provider.BaseURL != GoogleBaseURL {
t.Fatalf("BaseURL = %q, want official Google URL", resolved.Provider.BaseURL)
}
}
func TestResolveRejectsGoogleOfficialProviderCustomBaseURL(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "google",
"providers": [{
"name": "google",
"provider": "google",
"baseURL": "https://attacker.example/google",
"model": "gemini-2.5-pro"
}]
}`)
_, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"GOOGLE_API_KEY": "sk-google-secret",
},
})
if err == nil {
t.Fatal("Resolve() error = nil, want custom Google baseURL rejection")
}
if !strings.Contains(err.Error(), "google provider google requires official baseURL") {
t.Fatalf("error = %q, want official Google baseURL message", err.Error())
}
if strings.Contains(err.Error(), "sk-google-secret") {
t.Fatalf("error leaked Google API key: %q", err.Error())
}
}
@@ -463,7 +845,7 @@ func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
if len(resolved.Providers) != 0 {
t.Fatalf("Providers = %#v, want empty", resolved.Providers)
}
if resolved.Provider != (ProviderProfile{}) {
if HasProviderProfile(resolved.Provider) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != defaultMaxTurns {
@@ -487,7 +869,7 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
if len(resolved.Providers) != 0 {
t.Fatalf("Providers = %#v, want empty", resolved.Providers)
}
if resolved.Provider != (ProviderProfile{}) {
if HasProviderProfile(resolved.Provider) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != 0 {
@@ -578,6 +960,172 @@ func TestResolveAcceptsOfficialAnthropicAndGoogleProfiles(t *testing.T) {
}
}
func TestResolveRejectsAnthropicCompatibleOfficialHostWithPath(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "claude-compatible",
"providers": [{
"name": "claude-compatible",
"provider_kind": "anthropic-compatible",
"baseURL": "https://api.anthropic.com/v1",
"apiKey": "sk-ant",
"model": "custom-claude"
}]
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want official Anthropic host rejection")
}
if !strings.Contains(err.Error(), "requires custom baseURL") {
t.Fatalf("error = %q, want custom baseURL message", err.Error())
}
}
func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "mini",
"providers": [{
"name": "mini",
"catalog_id": "minimax"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"MINIMAX_API_KEY": "sk-mini",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "minimax" {
t.Fatalf("CatalogID = %q, want minimax", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindAnthropicCompat)
}
if resolved.Provider.BaseURL != "https://api.minimax.io/anthropic" {
t.Fatalf("BaseURL = %q, want MiniMax default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "MiniMax-M3" {
t.Fatalf("Model = %q, want MiniMax-M3", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "MINIMAX_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want MINIMAX_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-mini" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "local",
"providers": [{
"name": "local",
"catalogID": "lm-studio"
}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "lmstudio" {
t.Fatalf("CatalogID = %q, want lmstudio", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want openai-compatible", resolved.Provider.ProviderKind)
}
if resolved.Provider.APIKey != "" {
t.Fatalf("APIKey = %q, want empty for local provider", resolved.Provider.APIKey)
}
}
func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
"providers": [{
"name": "custom",
"providerKind": "openai-compatible",
"catalogID": "custom-openai-compatible",
"base_url": "https://custom.example/v1",
"api_key_env": "CUSTOM_KEY",
"api_format": "responses",
"auth_header": "X-API-Key",
"auth_scheme": "raw",
"auth_header_value": "header-secret",
"custom_headers": {"X-Zero": "1"},
"model_id": "custom-model"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: path,
Env: map[string]string{"CUSTOM_KEY": "sk-custom-env"},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
profile := resolved.Provider
if profile.CatalogID != "custom-openai-compatible" || profile.APIKeyEnv != "CUSTOM_KEY" {
t.Fatalf("profile aliases not loaded: %#v", profile)
}
if profile.APIKey != "sk-custom-env" {
t.Fatalf("APIKey = %q, want env-resolved key", profile.APIKey)
}
if profile.APIFormat != "responses" || profile.AuthHeader != "X-API-Key" || profile.AuthScheme != "raw" || profile.AuthHeaderValue != "header-secret" {
t.Fatalf("extended provider fields not loaded: %#v", profile)
}
if profile.CustomHeaders["X-Zero"] != "1" {
t.Fatalf("CustomHeaders = %#v, want X-Zero header", profile.CustomHeaders)
}
}
func TestResolveRejectsUnknownProviderCatalogID(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "bad",
"providers": [{
"name": "bad",
"catalog_id": "missing",
"apiKey": "sk-secret-value"
}]
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want unknown catalog ID error")
}
if !strings.Contains(err.Error(), `unknown provider "missing"`) {
t.Fatalf("error = %q, want unknown catalog ID", err.Error())
}
if strings.Contains(err.Error(), "sk-secret-value") {
t.Fatalf("error leaked secret: %q", err.Error())
}
}
func TestResolveRejectsCatalogOnlyProviderRuntime(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "bedrock",
"providers": [{
"name": "bedrock",
"catalog_id": "bedrock"
}]
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want catalog-only runtime rejection")
}
if !strings.Contains(err.Error(), "native adapter not implemented yet") {
t.Fatalf("error = %q, want native adapter unsupported message", err.Error())
}
}
func TestResolveRejectsOpenAICompatibleWithoutBaseURL(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
@@ -641,6 +1189,137 @@ func TestResolveRedactsSecretsFromErrors(t *testing.T) {
}
}
func TestResolveSandboxMaxAutonomyFromFile(t *testing.T) {
path := writeConfig(t, `{
"sandbox": {"maxAutonomy": "medium"},
"providers": [{
"name": "openai",
"provider": "openai",
"api_key": "sk-test",
"model": "gpt-test"
}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.MaxAutonomy != "medium" {
t.Fatalf("resolved.Sandbox.MaxAutonomy = %q, want medium", resolved.Sandbox.MaxAutonomy)
}
}
func TestResolveSandboxMaxAutonomyPrecedence(t *testing.T) {
userPath := writeConfig(t, `{
"sandbox": {"maxAutonomy": "low"},
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk-user", "model": "gpt-user"}]
}`)
projectPath := writeConfig(t, `{
"sandbox": {"maxAutonomy": "medium"},
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk-proj", "model": "gpt-proj"}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{"ZERO_SANDBOX_MAX_AUTONOMY": "high"},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.MaxAutonomy != "high" {
t.Fatalf("resolved.Sandbox.MaxAutonomy = %q, want high (env overrides project overrides user)", resolved.Sandbox.MaxAutonomy)
}
}
func TestResolveSandboxMaxAutonomyOverride(t *testing.T) {
path := writeConfig(t, `{
"sandbox": {"maxAutonomy": "low"},
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk", "model": "gpt"}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Overrides: Overrides{Sandbox: SandboxConfig{MaxAutonomy: "high"}},
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.MaxAutonomy != "high" {
t.Fatalf("resolved.Sandbox.MaxAutonomy = %q, want high (override wins)", resolved.Sandbox.MaxAutonomy)
}
}
func TestResolveSandboxMaxAutonomyInvalidFailsLoud(t *testing.T) {
// A typo'd ceiling must be rejected at resolve time rather than silently
// surviving (which would fail-open and keep the default High ceiling).
for _, value := range []string{"moderate", "med", "HIGHEST"} {
path := writeConfig(t, `{
"sandbox": {"maxAutonomy": "`+value+`"},
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk", "model": "gpt"}]
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatalf("Resolve() error = nil for invalid maxAutonomy %q, want failure", value)
}
if !strings.Contains(err.Error(), "invalid sandbox.maxAutonomy") {
t.Fatalf("Resolve() error = %v, want invalid sandbox.maxAutonomy for %q", err, value)
}
}
}
func TestResolveSandboxMaxAutonomyValidResolves(t *testing.T) {
for _, value := range []string{"low", "medium", "high", "HIGH", " medium "} {
path := writeConfig(t, `{
"sandbox": {"maxAutonomy": "`+value+`"},
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk", "model": "gpt"}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v for valid maxAutonomy %q", err, value)
}
if strings.TrimSpace(resolved.Sandbox.MaxAutonomy) == "" {
t.Fatalf("resolved.Sandbox.MaxAutonomy empty for valid input %q", value)
}
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Notify.Mode != "both" || resolved.Notify.FocusMode != "always" {
t.Fatalf("got %+v", resolved.Notify)
}
}
func TestResolveNotifyInvalidMode(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"buzz"}}`)
if _, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}); err == nil {
t.Fatal("expected error for invalid notify.mode")
}
}
func TestResolveNotifyInvalidFocusMode(t *testing.T) {
path := writeConfig(t, `{"notify":{"focusMode":"sideways"}}`)
if _, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}); err == nil {
t.Fatal("expected error for invalid notify.focusMode")
}
}
func TestResolveNotifyDefaultEmpty(t *testing.T) {
path := writeConfig(t, `{}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Notify.Mode != "" || resolved.Notify.FocusMode != "" {
t.Fatalf("unset notify should be empty, got %+v", resolved.Notify)
}
}
func writeConfig(t *testing.T, body string) string {
t.Helper()
@@ -650,3 +1329,165 @@ func writeConfig(t *testing.T, body string) string {
}
return path
}
func providerByName(t *testing.T, providers []ProviderProfile, name string) ProviderProfile {
t.Helper()
for _, provider := range providers {
if provider.Name == name {
return provider
}
}
t.Fatalf("provider %q not found in %#v", name, providers)
return ProviderProfile{}
}
func TestResolveDefaultsDeferThresholdWhenUnset(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != defaultDeferThreshold {
t.Fatalf("Tools.DeferThreshold = %d, want %d", resolved.Tools.DeferThreshold, defaultDeferThreshold)
}
}
func TestResolveDeferThresholdFromFile(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 4 {
t.Fatalf("Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
}
}
func TestResolveDeferThresholdZeroDisablesViaFile(t *testing.T) {
// 0 is a valid, meaningful value (disabled). It must survive Resolve and NOT
// be re-defaulted to 10, so a user can explicitly disable deferral.
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 0}
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 0 {
t.Fatalf("Tools.DeferThreshold = %d, want 0 (explicit disable preserved)", resolved.Tools.DeferThreshold)
}
}
func TestResolveDeferThresholdOverrideWins(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{},
Overrides: Overrides{Tools: ToolsConfig{DeferThreshold: 20}},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 20 {
t.Fatalf("Tools.DeferThreshold = %d, want 20 (override wins)", resolved.Tools.DeferThreshold)
}
}
func TestResolveToolsOverrideDisablesDeferralOverNonZeroBase(t *testing.T) {
// A programmatic Override built via ToolsOverride(0) carries the presence flag,
// so it must override an explicit non-zero base threshold down to 0 (disabled).
// A bare ToolsConfig{DeferThreshold: 0} could not do this — it is indistinguishable
// from "unset" — which is exactly the trap ToolsOverride exists to avoid.
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{},
Overrides: Overrides{Tools: ToolsOverride(0)},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 0 {
t.Fatalf("Tools.DeferThreshold = %d, want 0 (ToolsOverride(0) disables deferral)", resolved.Tools.DeferThreshold)
}
}
func TestResolveToolsOverrideSetsNonZeroOverNonZeroBase(t *testing.T) {
// ToolsOverride(7) over an explicit non-zero base must win with the new value.
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": 4}
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{},
Overrides: Overrides{Tools: ToolsOverride(7)},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Tools.DeferThreshold != 7 {
t.Fatalf("Tools.DeferThreshold = %d, want 7 (ToolsOverride(7) wins)", resolved.Tools.DeferThreshold)
}
}
func TestApplyOverridesToolsOverrideZeroOverridesNonZero(t *testing.T) {
// Unit-level check on applyOverrides directly: ToolsOverride(0) must flip an
// explicit non-zero base to 0 (deferThresholdSet honored), while a bare
// non-zero ToolsConfig still overrides via the != 0 branch.
cfg := FileConfig{Tools: ToolsOverride(4)}
applyOverrides(&cfg, Overrides{Tools: ToolsOverride(0)})
if cfg.Tools.DeferThreshold != 0 {
t.Fatalf("applyOverrides ToolsOverride(0): DeferThreshold = %d, want 0", cfg.Tools.DeferThreshold)
}
if !cfg.Tools.deferThresholdSet {
t.Fatal("applyOverrides ToolsOverride(0): deferThresholdSet = false, want true")
}
cfg = FileConfig{Tools: ToolsOverride(4)}
applyOverrides(&cfg, Overrides{Tools: ToolsConfig{DeferThreshold: 5}})
if cfg.Tools.DeferThreshold != 5 {
t.Fatalf("applyOverrides bare non-zero: DeferThreshold = %d, want 5", cfg.Tools.DeferThreshold)
}
}
func TestResolveRejectsNegativeDeferThreshold(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
"tools": {"deferThreshold": -1}
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil for negative deferThreshold, want failure")
}
if !strings.Contains(err.Error(), "tools.deferThreshold") {
t.Fatalf("Resolve() error = %v, want tools.deferThreshold rejection", err)
}
}
+139 -18
View File
@@ -15,18 +15,82 @@ type ProviderKind string
const (
ProviderKindOpenAI ProviderKind = "openai"
ProviderKindAnthropic ProviderKind = "anthropic"
ProviderKindAnthropicCompat ProviderKind = "anthropic-compatible"
ProviderKindGoogle ProviderKind = "google"
ProviderKindOpenAICompatible ProviderKind = "openai-compatible"
)
type ProviderProfile struct {
Name string `json:"name"`
Provider string `json:"provider,omitempty"`
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
APIKey string `json:"apiKey,omitempty"`
Model string `json:"model,omitempty"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
Provider string `json:"provider,omitempty"`
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
CatalogID string `json:"catalogID,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
APIKey string `json:"apiKey,omitempty"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
APIFormat string `json:"apiFormat,omitempty"`
AuthHeader string `json:"authHeader,omitempty"`
AuthScheme string `json:"authScheme,omitempty"`
AuthHeaderValue string `json:"authHeaderValue,omitempty"`
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
Model string `json:"model,omitempty"`
Description string `json:"description,omitempty"`
}
func HasProviderProfile(profile ProviderProfile) bool {
return strings.TrimSpace(profile.Name) != "" ||
strings.TrimSpace(profile.Provider) != "" ||
strings.TrimSpace(string(profile.ProviderKind)) != "" ||
strings.TrimSpace(profile.CatalogID) != "" ||
strings.TrimSpace(profile.BaseURL) != "" ||
strings.TrimSpace(profile.APIKey) != "" ||
strings.TrimSpace(profile.APIKeyEnv) != "" ||
strings.TrimSpace(profile.APIFormat) != "" ||
strings.TrimSpace(profile.AuthHeader) != "" ||
strings.TrimSpace(profile.AuthScheme) != "" ||
strings.TrimSpace(profile.AuthHeaderValue) != "" ||
profile.CustomHeaders != nil ||
strings.TrimSpace(profile.Model) != "" ||
strings.TrimSpace(profile.Description) != ""
}
type SandboxConfig struct {
MaxAutonomy string `json:"maxAutonomy,omitempty"`
}
type NotifyConfig struct {
Mode string `json:"mode,omitempty"`
FocusMode string `json:"focusMode,omitempty"`
}
type ToolsConfig struct {
DeferThreshold int `json:"deferThreshold,omitempty"`
deferThresholdSet bool
}
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
// threshold (including to 0, which disables deferral). Use this for programmatic
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
// "unset" and will not override.
func ToolsOverride(deferThreshold int) ToolsConfig {
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
}
func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
type rawTools struct {
DeferThreshold *int `json:"deferThreshold"`
}
var raw rawTools
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
cfg.DeferThreshold = 0
cfg.deferThresholdSet = false
if raw.DeferThreshold != nil {
cfg.DeferThreshold = *raw.DeferThreshold
cfg.deferThresholdSet = true
}
return nil
}
type FileConfig struct {
@@ -34,6 +98,9 @@ type FileConfig struct {
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
}
type ResolveOptions struct {
@@ -50,6 +117,9 @@ type Overrides struct {
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
}
type ResolvedConfig struct {
@@ -58,6 +128,9 @@ type ResolvedConfig struct {
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
}
type MCPConfig struct {
@@ -81,6 +154,9 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Providers []ProviderProfile `json:"providers"`
MaxTurns int `json:"maxTurns"`
MCP MCPConfig `json:"mcp"`
Sandbox SandboxConfig `json:"sandbox"`
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
@@ -93,6 +169,9 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Providers = raw.Providers
cfg.MaxTurns = raw.MaxTurns
cfg.MCP = raw.MCP
cfg.Sandbox = raw.Sandbox
cfg.Notify = raw.Notify
cfg.Tools = raw.Tools
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
}
@@ -140,16 +219,31 @@ func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
type rawProfile struct {
Name string `json:"name"`
Provider string `json:"provider"`
ProviderKind string `json:"provider_kind"`
BaseURL string `json:"baseURL"`
BaseURLSnake string `json:"base_url"`
APIKey string `json:"apiKey"`
APIKeySnake string `json:"api_key"`
Model string `json:"model"`
ModelID string `json:"model_id"`
Description string `json:"description"`
Name string `json:"name"`
Provider string `json:"provider"`
ProviderKind string `json:"provider_kind"`
ProviderKindCamel string `json:"providerKind"`
CatalogID string `json:"catalogID"`
CatalogIDSnake string `json:"catalog_id"`
BaseURL string `json:"baseURL"`
BaseURLSnake string `json:"base_url"`
APIKey string `json:"apiKey"`
APIKeySnake string `json:"api_key"`
APIKeyEnv string `json:"apiKeyEnv"`
APIKeyEnvSnake string `json:"api_key_env"`
APIFormat string `json:"apiFormat"`
APIFormatSnake string `json:"api_format"`
AuthHeader string `json:"authHeader"`
AuthHeaderSnake string `json:"auth_header"`
AuthScheme string `json:"authScheme"`
AuthSchemeSnake string `json:"auth_scheme"`
AuthHeaderValue string `json:"authHeaderValue"`
AuthHeaderValueSnake string `json:"auth_header_value"`
CustomHeaders map[string]string `json:"customHeaders"`
CustomHeadersSnake map[string]string `json:"custom_headers"`
Model string `json:"model"`
ModelID string `json:"model_id"`
Description string `json:"description"`
}
var raw rawProfile
@@ -159,9 +253,16 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
profile.Name = strings.TrimSpace(raw.Name)
profile.Provider = strings.TrimSpace(raw.Provider)
profile.ProviderKind = ProviderKind(firstNonEmpty(raw.ProviderKind, raw.Provider))
profile.ProviderKind = ProviderKind(firstNonEmpty(raw.ProviderKind, raw.ProviderKindCamel, raw.Provider))
profile.CatalogID = strings.TrimSpace(firstNonEmpty(raw.CatalogID, raw.CatalogIDSnake))
profile.BaseURL = strings.TrimSpace(firstNonEmpty(raw.BaseURL, raw.BaseURLSnake))
profile.APIKey = firstNonEmpty(raw.APIKey, raw.APIKeySnake)
profile.APIKeyEnv = strings.TrimSpace(firstNonEmpty(raw.APIKeyEnv, raw.APIKeyEnvSnake))
profile.APIFormat = strings.TrimSpace(firstNonEmpty(raw.APIFormat, raw.APIFormatSnake))
profile.AuthHeader = strings.TrimSpace(firstNonEmpty(raw.AuthHeader, raw.AuthHeaderSnake))
profile.AuthScheme = strings.TrimSpace(firstNonEmpty(raw.AuthScheme, raw.AuthSchemeSnake))
profile.AuthHeaderValue = firstNonEmpty(raw.AuthHeaderValue, raw.AuthHeaderValueSnake)
profile.CustomHeaders = firstNonNilMap(raw.CustomHeaders, raw.CustomHeadersSnake)
profile.Model = strings.TrimSpace(firstNonEmpty(raw.Model, raw.ModelID))
profile.Description = strings.TrimSpace(raw.Description)
return nil
@@ -176,3 +277,23 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
func firstNonNilMap(values ...map[string]string) map[string]string {
for _, value := range values {
if value != nil {
return copyStringMap(value)
}
}
return nil
}
func copyStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
copied[key] = value
}
return copied
}
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"encoding/json"
"testing"
)
func TestToolsConfigJSONRoundTrip(t *testing.T) {
var cfg FileConfig
if err := json.Unmarshal([]byte(`{"tools":{"deferThreshold":25}}`), &cfg); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if cfg.Tools.DeferThreshold != 25 {
t.Fatalf("Tools.DeferThreshold = %d, want 25", cfg.Tools.DeferThreshold)
}
encoded, err := json.Marshal(ToolsConfig{DeferThreshold: 7})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if string(encoded) != `{"deferThreshold":7}` {
t.Fatalf("Marshal() = %s, want {\"deferThreshold\":7}", encoded)
}
// omitempty: a zero value must not emit the field.
emptyEncoded, err := json.Marshal(ToolsConfig{})
if err != nil {
t.Fatalf("Marshal(empty) error = %v", err)
}
if string(emptyEncoded) != `{}` {
t.Fatalf("Marshal(empty) = %s, want {}", emptyEncoded)
}
}
func TestToolsConfigPresentOnOverridesAndResolved(t *testing.T) {
// Compile-time guard that Overrides and ResolvedConfig carry the field too.
overrides := Overrides{Tools: ToolsConfig{DeferThreshold: 3}}
resolved := ResolvedConfig{Tools: ToolsConfig{DeferThreshold: 4}}
if overrides.Tools.DeferThreshold != 3 {
t.Fatalf("Overrides.Tools.DeferThreshold = %d, want 3", overrides.Tools.DeferThreshold)
}
if resolved.Tools.DeferThreshold != 4 {
t.Fatalf("ResolvedConfig.Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
}
}
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"encoding/json"
"fmt"
"os"
)
// Issue is a single structured problem found while validating a config file.
// Message is already routed through the package secret redaction.
type Issue struct {
FieldPath string `json:"fieldPath,omitempty"`
Message string `json:"message"`
}
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
// semantic provider/model rules used during resolution. It returns the parsed
// config (zero value on parse failure) plus any structured issues. A parse
// failure yields a single issue whose Message wraps the underlying JSON error
// so callers can extract *json.SyntaxError / *json.UnmarshalTypeError offsets
// via errors.As.
func ValidateFile(path string) (FileConfig, []Issue) {
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON %s: %w", path, err).Error()}}
}
issues := validateSemantics(cfg)
return cfg, issues
}
// ValidateBytes parses data as a Zero FileConfig and runs the same semantic
// provider/model rules as ValidateFile. It returns the parsed config (zero
// value on parse failure) plus any structured issues. A parse failure yields a
// single issue whose Message wraps the underlying JSON error (path-less form:
// "invalid config JSON: <err>") so callers can extract *json.SyntaxError /
// *json.UnmarshalTypeError offsets via errors.As.
func ValidateBytes(data []byte) (FileConfig, []Issue) {
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON: %w", err).Error()}}
}
issues := validateSemantics(cfg)
return cfg, issues
}
func validateSemantics(cfg FileConfig) []Issue {
if _, _, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider); err != nil {
// normalizeProviders already redacts secrets via providerError.
return []Issue{{FieldPath: "providers", Message: err.Error()}}
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeValidateFixture(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func TestValidateFileReturnsParseIssueForMalformedJSON(t *testing.T) {
path := writeValidateFixture(t, `{"activeProvider": "openai",`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected parse issue, got none")
}
if !strings.Contains(issues[0].Message, "invalid config JSON") {
t.Fatalf("expected parse issue message, got %#v", issues)
}
}
func TestValidateFileSurfacesSemanticIssue(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "baseURL": "https://example.test/v1", "model": "gpt-4.1"}
]
}`)
cfg, issues := ValidateFile(path)
if cfg.ActiveProvider != "main" {
t.Fatalf("expected parsed config, got %#v", cfg)
}
if len(issues) == 0 {
t.Fatalf("expected semantic issue for openai custom baseURL, got none")
}
}
func TestValidateFileRedactsSecretInIssue(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "baseURL": "https://example.test/v1", "apiKey": "sk-proj-secret1234567890", "model": "gpt-4.1"}
]
}`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected semantic issue, got none")
}
for _, issue := range issues {
if strings.Contains(issue.Message, "sk-proj-secret") {
t.Fatalf("issue leaked apiKey: %q", issue.Message)
}
}
}
func TestValidateFileMissingModelWarns(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "anthropic"}
]
}`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected requires-model issue, got none")
}
if !strings.Contains(issues[0].Message, "requires model") {
t.Fatalf("expected requires-model issue, got %#v", issues)
}
}
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
]
}`)
_, issues := ValidateFile(path)
if len(issues) != 0 {
t.Fatalf("expected no issues for valid config, got %#v", issues)
}
}
+60
View File
@@ -0,0 +1,60 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
profile.Name = strings.TrimSpace(profile.Name)
if profile.Name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
mergeProvider(&cfg, profile)
if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" {
cfg.ActiveProvider = profile.Name
}
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func writeConfigFile(path string, cfg FileConfig) error {
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("secure config permissions %s: %w", path, err)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestUpsertProviderTightensExistingConfigFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose POSIX mode bits reliably")
}
path := filepath.Join(t.TempDir(), "zero.json")
if err := os.WriteFile(path, []byte(`{"providers":[]}`), 0o644); err != nil {
t.Fatalf("write existing config: %v", err)
}
_, err := UpsertProvider(path, ProviderProfile{
Name: "openai",
ProviderKind: ProviderKindOpenAI,
APIKey: "sk-test",
Model: "gpt-4.1",
}, true)
if err != nil {
t.Fatalf("UpsertProvider() error = %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat config: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("config mode = %o, want 0600", got)
}
}
+274
View File
@@ -0,0 +1,274 @@
package contextreport
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/tools"
)
const ContractV1 = "zero.context.report.v1"
const RuntimeGo = "go"
const (
CategorySystemPrompt = "system_prompt"
CategoryProjectGuidelines = "project_guidelines"
CategoryTools = "tools"
CategoryFree = "free"
)
var defaultProjectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
const maxProjectContextBytes = 8 << 10
// toolDefinitionOverheadTokens approximates per-tool JSON/message framing.
const toolDefinitionOverheadTokens = 4
type Options struct {
WorkspaceRoot string
Provider config.ProviderProfile
Registry *tools.Registry
ContextWindow int
ProjectContextFiles []string
}
type Report struct {
Contract string `json:"contract"`
Runtime string `json:"runtime"`
Root string `json:"root"`
ProviderName string `json:"providerName,omitempty"`
ProviderKind string `json:"providerKind,omitempty"`
ModelID string `json:"modelId,omitempty"`
APIModel string `json:"apiModel,omitempty"`
ContextWindow int `json:"contextWindow"`
UsedTokens int `json:"usedTokens"`
FreeTokens int `json:"freeTokens"`
UsedFraction float64 `json:"usedFraction"`
ToolCount int `json:"toolCount"`
ProjectGuidelineFile string `json:"projectGuidelineFile,omitempty"`
Categories []Category `json:"categories"`
}
type Category struct {
Key string `json:"key"`
Name string `json:"name"`
Tokens int `json:"tokens"`
Percent float64 `json:"percent"`
}
func Build(options Options) (Report, error) {
root := strings.TrimSpace(options.WorkspaceRoot)
if root == "" {
root = "."
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
provider := options.Provider
report := Report{
Contract: ContractV1,
Runtime: RuntimeGo,
Root: root,
ProviderName: strings.TrimSpace(provider.Name),
ModelID: strings.TrimSpace(provider.Model),
}
if metadata, err := providers.ResolveRuntimeMetadata(provider, providers.Options{}); err == nil {
report.ProviderKind = string(metadata.ProviderKind)
report.APIModel = metadata.APIModel
if report.ModelID == "" {
report.ModelID = metadata.APIModel
}
}
report.ContextWindow = options.ContextWindow
if report.ContextWindow <= 0 {
report.ContextWindow = contextWindowForModel(report.ModelID, report.APIModel)
}
categories := []Category{}
basePrompt := systemPromptFootprint(root, report.ModelID)
categories = append(categories, category(CategorySystemPrompt, "System prompt", estimateTextTokens(basePrompt), report.ContextWindow))
projectGuidelines, guidelineFile := readProjectGuidelines(root, options.ProjectContextFiles)
if guidelineFile != "" {
report.ProjectGuidelineFile = guidelineFile
categories = append(categories, category(CategoryProjectGuidelines, "Project guidelines", estimateTextTokens(projectGuidelines), report.ContextWindow))
}
toolCount, toolTokens := estimateRegistryTools(options.Registry)
report.ToolCount = toolCount
if toolTokens > 0 {
categories = append(categories, category(CategoryTools, "Tools", toolTokens, report.ContextWindow))
}
used := 0
for _, cat := range categories {
used += cat.Tokens
}
report.UsedTokens = used
if report.ContextWindow > 0 {
report.UsedFraction = float64(report.UsedTokens) / float64(report.ContextWindow)
report.FreeTokens = report.ContextWindow - report.UsedTokens
if report.FreeTokens < 0 {
report.FreeTokens = 0
}
}
categories = append(categories, category(CategoryFree, "Free", report.FreeTokens, report.ContextWindow))
report.Categories = categories
return report, nil
}
func systemPromptFootprint(root string, modelID string) string {
parts := []string{agent.BuildSystemPromptPreview(agent.Options{Model: modelID}), "runtime: go"}
if root = strings.TrimSpace(root); root != "" {
parts = append(parts, "workspace: "+root)
}
if modelID = strings.TrimSpace(modelID); modelID != "" {
parts = append(parts, "model: "+modelID)
}
return strings.Join(parts, "\n")
}
func Format(report Report) string {
lines := []string{
"Zero context report",
"root: " + report.Root,
}
if report.ProviderName != "" {
lines = append(lines, "provider: "+report.ProviderName)
}
if report.ModelID != "" {
lines = append(lines, "model: "+report.ModelID)
}
if report.APIModel != "" {
lines = append(lines, "api_model: "+report.APIModel)
}
if report.ContextWindow > 0 {
lines = append(lines, fmt.Sprintf("usage: %s/%s tokens (%.1f%%)", compactNumber(report.UsedTokens), compactNumber(report.ContextWindow), report.UsedFraction*100))
} else {
lines = append(lines, fmt.Sprintf("usage: %s tokens (context window unknown)", compactNumber(report.UsedTokens)))
}
if report.ToolCount > 0 {
lines = append(lines, fmt.Sprintf("tools: %d", report.ToolCount))
}
if report.ProjectGuidelineFile != "" {
lines = append(lines, "project_guidelines: "+report.ProjectGuidelineFile)
}
lines = append(lines, "", "Categories:")
for _, cat := range report.Categories {
if report.ContextWindow > 0 {
lines = append(lines, fmt.Sprintf(" %s: %s tokens (%.1f%%)", cat.Name, compactNumber(cat.Tokens), cat.Percent))
} else {
lines = append(lines, fmt.Sprintf(" %s: %s tokens", cat.Name, compactNumber(cat.Tokens)))
}
}
return strings.Join(lines, "\n")
}
func contextWindowForModel(modelID string, apiModel string) int {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return 0
}
for _, candidate := range []string{modelID, apiModel} {
if model, ok := registry.Get(candidate); ok {
return model.ContextLimits.ContextWindow
}
}
return 0
}
func category(key string, name string, tokens int, contextWindow int) Category {
cat := Category{Key: key, Name: name, Tokens: maxInt(tokens, 0)}
if contextWindow > 0 {
cat.Percent = float64(cat.Tokens) / float64(contextWindow) * 100
}
return cat
}
func estimateRegistryTools(registry *tools.Registry) (int, int) {
if registry == nil {
return 0, 0
}
all := registry.All()
sort.Slice(all, func(left int, right int) bool {
return all[left].Name() < all[right].Name()
})
total := 0
for _, tool := range all {
total += estimateTextTokens(tool.Name())
total += estimateTextTokens(tool.Description())
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
total += estimateTextTokens(string(encoded))
}
total += toolDefinitionOverheadTokens
}
return len(all), total
}
func readProjectGuidelines(root string, names []string) (string, string) {
if len(names) == 0 {
names = defaultProjectContextFiles
}
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
data, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content == "" {
continue
}
if len(content) > maxProjectContextBytes {
content = content[:maxProjectContextBytes]
}
return content, filepath.ToSlash(name)
}
return "", ""
}
func estimateTextTokens(value string) int {
if value == "" {
return 0
}
tokens := len(value) / 4
if tokens == 0 {
return 1
}
return tokens
}
func compactNumber(value int) string {
if value < 1000 {
return fmt.Sprintf("%d", value)
}
if value < 1_000_000 {
return trimFloat(float64(value)/1000) + "k"
}
return trimFloat(float64(value)/1_000_000) + "m"
}
func trimFloat(value float64) string {
text := fmt.Sprintf("%.1f", value)
return strings.TrimSuffix(text, ".0")
}
func maxInt(left int, right int) int {
if left > right {
return left
}
return right
}
@@ -0,0 +1,279 @@
package contextreport
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
)
func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "AGENTS.md", strings.Repeat("project rules\n", 200))
registry := tools.NewRegistry()
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
report, err := Build(Options{
WorkspaceRoot: root,
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "gpt-4.1",
},
Registry: registry,
ContextWindow: 10_000,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if report.Contract != ContractV1 {
t.Fatalf("Contract = %q, want %q", report.Contract, ContractV1)
}
if report.Runtime != RuntimeGo {
t.Fatalf("Runtime = %q, want %q", report.Runtime, RuntimeGo)
}
if report.Root != root {
t.Fatalf("Root = %q, want %q", report.Root, root)
}
if report.ProviderName != "openai" || report.ModelID != "gpt-4.1" || report.APIModel != "gpt-4.1" {
t.Fatalf("provider metadata = %#v", report)
}
if report.ContextWindow != 10_000 {
t.Fatalf("ContextWindow = %d, want 10000", report.ContextWindow)
}
if report.ProjectGuidelineFile != "AGENTS.md" {
t.Fatalf("ProjectGuidelineFile = %q, want AGENTS.md", report.ProjectGuidelineFile)
}
if report.ToolCount == 0 {
t.Fatal("ToolCount = 0, want core tools counted")
}
if report.UsedTokens <= 0 {
t.Fatalf("UsedTokens = %d, want > 0", report.UsedTokens)
}
if report.FreeTokens <= 0 {
t.Fatalf("FreeTokens = %d, want > 0", report.FreeTokens)
}
if report.FreeTokens+report.UsedTokens != report.ContextWindow {
t.Fatalf("free + used = %d, want %d", report.FreeTokens+report.UsedTokens, report.ContextWindow)
}
if !hasCategory(report, CategoryProjectGuidelines) {
t.Fatalf("missing project guideline category: %#v", report.Categories)
}
if !hasCategory(report, CategoryTools) {
t.Fatalf("missing tools category: %#v", report.Categories)
}
if !hasCategory(report, CategoryFree) {
t.Fatalf("missing free category: %#v", report.Categories)
}
}
func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "ZERO.md", strings.Repeat("zero rules\n", 16))
registry := tools.NewRegistry()
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
report, err := Build(Options{
WorkspaceRoot: root,
Registry: registry,
ContextWindow: 50_000,
ProjectContextFiles: []string{"AGENTS.md", "ZERO.md"},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
keys := categoryKeys(report)
for _, want := range []string{CategorySystemPrompt, CategoryProjectGuidelines, CategoryTools, CategoryFree} {
if !contains(keys, want) {
t.Fatalf("missing category %q in %v", want, keys)
}
}
if report.ProjectGuidelineFile != "ZERO.md" {
t.Fatalf("ProjectGuidelineFile = %q, want ZERO.md", report.ProjectGuidelineFile)
}
if report.FreeTokens+report.UsedTokens != report.ContextWindow {
t.Fatalf("free + used = %d, want %d", report.FreeTokens+report.UsedTokens, report.ContextWindow)
}
if report.UsedFraction != float64(report.UsedTokens)/float64(report.ContextWindow) {
t.Fatalf("UsedFraction = %v, want used/context ratio", report.UsedFraction)
}
var decoded struct {
Contract string `json:"contract"`
Runtime string `json:"runtime"`
Categories []Category `json:"categories"`
}
data, err := json.Marshal(report)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if decoded.Contract != ContractV1 || decoded.Runtime != RuntimeGo {
t.Fatalf("decoded identity = %q/%q", decoded.Contract, decoded.Runtime)
}
if len(decoded.Categories) != len(report.Categories) {
t.Fatalf("decoded %d categories, want %d", len(decoded.Categories), len(report.Categories))
}
}
func TestBuildClampsFreeBudgetWhenOverContextWindow(t *testing.T) {
root := t.TempDir()
writeTestFile(t, root, "AGENTS.md", strings.Repeat("very large project rule\n", 1000))
report, err := Build(Options{
WorkspaceRoot: root,
ContextWindow: 10,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if report.UsedTokens <= report.ContextWindow {
t.Fatalf("UsedTokens = %d, want over tiny context window %d", report.UsedTokens, report.ContextWindow)
}
if report.FreeTokens != 0 {
t.Fatalf("FreeTokens = %d, want clamp to 0", report.FreeTokens)
}
free := categoryByKey(report, CategoryFree)
if free == nil {
t.Fatalf("missing free category: %#v", report.Categories)
}
if free.Tokens != 0 {
t.Fatalf("free category tokens = %d, want 0", free.Tokens)
}
}
func TestBuildWithoutProviderStillReturnsReport(t *testing.T) {
root := t.TempDir()
report, err := Build(Options{WorkspaceRoot: root})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if report.Root != root {
t.Fatalf("Root = %q, want %q", report.Root, root)
}
if report.ProviderName != "" || report.ModelID != "" || report.APIModel != "" {
t.Fatalf("provider fields = %q/%q/%q, want empty", report.ProviderName, report.ModelID, report.APIModel)
}
if report.ContextWindow != 0 || report.UsedFraction != 0 || report.FreeTokens != 0 {
t.Fatalf("unknown budget fields should be zero, got %#v", report)
}
if !hasCategory(report, CategorySystemPrompt) {
t.Fatalf("missing system prompt category: %#v", report.Categories)
}
if !hasCategory(report, CategoryFree) {
t.Fatalf("missing free category: %#v", report.Categories)
}
}
func TestFormatIncludesRootModelAndCategories(t *testing.T) {
report := Report{
Contract: ContractV1,
Runtime: RuntimeGo,
Root: "D:/repo",
ProviderName: "openai",
ModelID: "gpt-4.1",
APIModel: "gpt-4.1",
ContextWindow: 1000,
UsedTokens: 250,
FreeTokens: 750,
UsedFraction: 0.25,
ToolCount: 2,
Categories: []Category{{Key: CategorySystemPrompt, Name: "System prompt", Tokens: 100, Percent: 10}, {Key: CategoryFree, Name: "Free", Tokens: 750, Percent: 75}},
}
formatted := Format(report)
for _, want := range []string{"Zero context report", "root: D:/repo", "model: gpt-4.1", "api_model: gpt-4.1", "System prompt", "Free"} {
if !strings.Contains(formatted, want) {
t.Fatalf("Format missing %q:\n%s", want, formatted)
}
}
}
func TestFormatHandlesUnknownContextWindow(t *testing.T) {
report := Report{
Contract: ContractV1,
Runtime: RuntimeGo,
Root: "D:/repo",
ProviderName: "openai",
ModelID: "gpt-4.1",
APIModel: "gpt-4.1",
UsedTokens: 250,
ToolCount: 2,
Categories: []Category{{Key: CategorySystemPrompt, Name: "System prompt", Tokens: 100}, {Key: CategoryFree, Name: "Free"}},
}
formatted := Format(report)
for _, want := range []string{
"Zero context report",
"root: D:/repo",
"model: gpt-4.1",
"api_model: gpt-4.1",
"usage: 250 tokens (context window unknown)",
"System prompt: 100 tokens",
"Free: 0 tokens",
} {
if !strings.Contains(formatted, want) {
t.Fatalf("Format missing %q:\n%s", want, formatted)
}
}
}
func hasCategory(report Report, key string) bool {
return categoryByKey(report, key) != nil
}
func categoryByKey(report Report, key string) *Category {
for _, category := range report.Categories {
if category.Key == key {
return &category
}
}
return nil
}
func categoryKeys(report Report) []string {
keys := make([]string, 0, len(report.Categories))
for _, category := range report.Categories {
keys = append(keys, category.Key)
}
return keys
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func writeTestFile(t *testing.T, root string, name string, content string) {
t.Helper()
path := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
}
+128 -6
View File
@@ -1,7 +1,10 @@
package doctor
import (
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
"time"
@@ -50,6 +53,7 @@ func Run(options Options) Report {
checks := []Check{
runtimeCheck(options.Runtime),
configFilesCheck(options.UserConfig, options.ProjectConfig),
configValidationCheck(options.UserConfig, options.ProjectConfig),
}
providerCheck := providerConfigCheck(options.Provider)
checks = append(checks, providerCheck)
@@ -117,7 +121,7 @@ func configFilesCheck(userPath string, projectPath string) Check {
}
func providerConfigCheck(profile config.ProviderProfile) Check {
if profile == (config.ProviderProfile{}) {
if emptyProviderProfile(profile) {
return check("provider.config", "Provider config", StatusFail, "No LLM provider is configured.", map[string]any{"help": "Set a provider in config or environment."})
}
return check("provider.config", "Provider config", StatusPass, fmt.Sprintf("Provider config loaded for %s.", providerName(profile)), map[string]any{
@@ -130,7 +134,7 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
}
func providerModelCheck(profile config.ProviderProfile) Check {
if profile == (config.ProviderProfile{}) {
if emptyProviderProfile(profile) {
return check("provider.model", "Provider model", StatusWarn, "Model validity was skipped because provider config is unavailable.", nil)
}
registry, err := modelregistry.DefaultRegistry()
@@ -139,8 +143,8 @@ func providerModelCheck(profile config.ProviderProfile) Check {
}
model, err := registry.Require(profile.Model)
if err != nil {
if profile.ProviderKind == config.ProviderKindOpenAICompatible {
return check("provider.model", "Provider model", StatusWarn, "Custom OpenAI-compatible model was not found in the Zero registry; runtime will pass it through to the configured provider.", map[string]any{"model": profile.Model, "provider": providerName(profile)})
if profile.ProviderKind == config.ProviderKindOpenAICompatible || profile.ProviderKind == config.ProviderKindAnthropicCompat {
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
}
return check("provider.model", "Provider model", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
}
@@ -156,7 +160,7 @@ func providerModelCheck(profile config.ProviderProfile) Check {
}
func connectivityCheck(profile config.ProviderProfile, enabled bool, modelStatus Status) Check {
if profile == (config.ProviderProfile{}) || modelStatus == StatusFail {
if emptyProviderProfile(profile) || modelStatus == StatusFail {
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity check was skipped because provider runtime did not resolve.", nil)
}
if !enabled {
@@ -165,6 +169,10 @@ func connectivityCheck(profile config.ProviderProfile, enabled bool, modelStatus
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity probing is not wired in the Go doctor backend yet.", map[string]any{"baseURL": profile.BaseURL})
}
func emptyProviderProfile(profile config.ProviderProfile) bool {
return !config.HasProviderProfile(profile)
}
func check(id string, label string, status Status, message string, details map[string]any) Check {
redacted := redaction.RedactValue(map[string]any{
"id": id,
@@ -197,7 +205,7 @@ func providerName(profile config.ProviderProfile) string {
func toModelProvider(profile config.ProviderProfile) modelregistry.ProviderKind {
switch profile.ProviderKind {
case config.ProviderKindAnthropic:
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return modelregistry.ProviderAnthropic
case config.ProviderKindGoogle:
return modelregistry.ProviderGoogle
@@ -225,9 +233,123 @@ func formatDetails(details map[string]any) string {
return strings.Join(parts, " | ")
}
func configValidationCheck(userPath string, projectPath string) Check {
paths := make([]string, 0, 2)
for _, path := range []string{userPath, projectPath} {
if strings.TrimSpace(path) != "" {
paths = append(paths, path)
}
}
if len(paths) == 0 {
return check("config.validation", "Config validation", StatusWarn, "No Zero config files were available to validate.", nil)
}
status := StatusPass
issueCount := 0
details := map[string]any{}
for _, path := range paths {
data, readErr := os.ReadFile(path)
if readErr != nil {
// A genuinely-missing path is configFilesCheck's job to report, and a
// "missing" path must stay a skip — so a not-exist read error is ignored
// here. Any OTHER read error (permissions, is-a-directory) means a
// present-but-unreadable config that would otherwise silently pass
// validation, so surface it as a failing per-path detail.
if os.IsNotExist(readErr) {
continue
}
details[path] = map[string]any{"error": "unreadable: " + readErr.Error()}
status = StatusFail
issueCount++
continue
}
if line, col, ok := jsonParsePosition(data); ok {
_, issues := config.ValidateBytes(data)
errMsg := ""
if len(issues) > 0 {
errMsg = issues[0].Message
}
details[path] = map[string]any{"line": line, "col": col, "error": errMsg}
status = StatusFail
issueCount++
continue
}
_, issues := config.ValidateBytes(data)
if len(issues) == 0 {
continue
}
messages := make([]string, 0, len(issues))
for _, issue := range issues {
messages = append(messages, issue.Message)
}
details[path] = map[string]any{"issues": messages}
status = StatusFail
issueCount += len(issues)
}
if status == StatusPass {
return check("config.validation", "Config validation", StatusPass, "Zero config files parsed and validated successfully.", nil)
}
return check("config.validation", "Config validation", StatusFail, fmt.Sprintf("Zero config validation found %d issue(s).", issueCount), details)
}
// jsonParsePosition reports whether data fails to parse as JSON and, if so, the
// 1-based line/col of the failure using the concrete json error offset.
//
// The probe is a config.FileConfig (not `any`) so that a structurally-valid
// document with a wrong field type (e.g. {"maxTurns":"twelve"}) surfaces a
// *json.UnmarshalTypeError carrying the offset. FileConfig.UnmarshalJSON returns
// the underlying json error unchanged, preserving that offset. Documents that
// are structurally valid AND type-correct unmarshal cleanly (ok=false) and fall
// through to the semantic ValidateBytes branch.
func jsonParsePosition(data []byte) (int, int, bool) {
var probe config.FileConfig
err := json.Unmarshal(data, &probe)
if err == nil {
return 0, 0, false
}
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
line, col := offsetToLineCol(data, syntaxErr.Offset)
return line, col, true
}
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
line, col := offsetToLineCol(data, typeErr.Offset)
return line, col, true
}
// Non-positional JSON error (e.g. unexpected EOF without offset): do not
// fabricate a (1,1) position. Returning ok=false routes the error through the
// no-position ValidateBytes path so it is reported without a fake line/col.
return 0, 0, false
}
func passFail(ok bool) string {
if ok {
return "pass"
}
return "fail"
}
// offsetToLineCol converts a byte offset (as reported by *json.SyntaxError /
// *json.UnmarshalTypeError) into a 1-based line and column. Offsets out of range
// are clamped into [0, len(data)].
func offsetToLineCol(data []byte, offset int64) (int, int) {
if offset < 0 {
offset = 0
}
if offset > int64(len(data)) {
offset = int64(len(data))
}
line := 1
col := 1
for index := int64(0); index < offset; index++ {
if data[index] == '\n' {
line++
col = 1
continue
}
col++
}
return line, col
}
+185
View File
@@ -1,6 +1,8 @@
package doctor
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -84,6 +86,189 @@ func TestRunReportWarnsForUnknownOpenAICompatibleModel(t *testing.T) {
}
}
func writeDoctorConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestConfigValidationCheckPassesForValidConfig(t *testing.T) {
path := writeDoctorConfig(t, `{
"activeProvider": "main",
"providers": [{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}]
}`)
report := Run(Options{Now: fixedDoctorClock("2026-06-08T10:00:00Z"), Runtime: "go", ProjectConfig: path})
check := report.Check("config.validation")
if check == nil || check.Status != StatusPass {
t.Fatalf("expected config.validation pass, got %#v", report.Checks)
}
}
func TestConfigValidationCheckFailsMalformedJSONWithLineCol(t *testing.T) {
// Unterminated object: the trailing comma + EOF yields a *json.SyntaxError
// whose offset is the end of the 32-byte document (line 3, col 1).
path := writeDoctorConfig(t, "{\n \"activeProvider\": \"openai\",\n")
report := Run(Options{Now: fixedDoctorClock("2026-06-08T10:05:00Z"), Runtime: "go", ProjectConfig: path})
if report.OK {
t.Fatalf("malformed config should fail report: %#v", report)
}
check := report.Check("config.validation")
if check == nil || check.Status != StatusFail {
t.Fatalf("expected config.validation fail, got %#v", report.Checks)
}
entry, ok := check.Details[path].(map[string]any)
if !ok {
t.Fatalf("expected per-path map entry for %q, got %#v", path, check.Details)
}
// Redaction widens ints to int64 as it round-trips the details map.
if entry["line"] != int64(3) || entry["col"] != int64(1) {
t.Fatalf("line/col = (%v,%v), want (3,1): %#v", entry["line"], entry["col"], entry)
}
// The colliding flat top-level keys must be gone (they were ambiguous across
// multiple malformed files).
if _, exists := check.Details["line"]; exists {
t.Fatalf("flat details[\"line\"] should be removed, got %#v", check.Details)
}
if _, exists := check.Details["col"]; exists {
t.Fatalf("flat details[\"col\"] should be removed, got %#v", check.Details)
}
}
func TestConfigValidationCheckFailsTypeMismatchWithLineCol(t *testing.T) {
// {"maxTurns":"twelve"} is structurally valid JSON but the wrong type for
// FileConfig.MaxTurns. The probe (var probe config.FileConfig) surfaces a
// *json.UnmarshalTypeError carrying the offset, exercising the branch that a
// `var probe any` probe could never reach.
path := writeDoctorConfig(t, `{"maxTurns":"twelve"}`)
report := Run(Options{Now: fixedDoctorClock("2026-06-08T10:07:00Z"), Runtime: "go", ProjectConfig: path})
if report.OK {
t.Fatalf("type-mismatch config should fail report: %#v", report)
}
check := report.Check("config.validation")
if check == nil || check.Status != StatusFail {
t.Fatalf("expected config.validation fail, got %#v", report.Checks)
}
entry, ok := check.Details[path].(map[string]any)
if !ok {
t.Fatalf("expected per-path map entry for %q, got %#v", path, check.Details)
}
if entry["line"] == nil || entry["col"] == nil {
t.Fatalf("expected non-nil line/col for type mismatch, got %#v", entry)
}
// offset=20 in a single-line document -> line 1, col 21. Redaction widens
// ints to int64 as it round-trips the details map.
if entry["line"] != int64(1) || entry["col"] != int64(21) {
t.Fatalf("line/col = (%v,%v), want (1,21): %#v", entry["line"], entry["col"], entry)
}
}
func TestConfigValidationCheckFailsSemanticIssue(t *testing.T) {
path := writeDoctorConfig(t, `{
"activeProvider": "main",
"providers": [{"name": "main", "provider_kind": "openai", "baseURL": "https://example.test/v1", "model": "gpt-4.1"}]
}`)
report := Run(Options{Now: fixedDoctorClock("2026-06-08T10:10:00Z"), Runtime: "go", ProjectConfig: path})
check := report.Check("config.validation")
if check == nil || check.Status != StatusFail {
t.Fatalf("expected semantic fail, got %#v", report.Checks)
}
}
func TestConfigValidationCheckFailsUnreadableConfig(t *testing.T) {
// A present-but-unreadable path (here: a directory, which os.ReadFile rejects
// with a non-not-exist error) must surface as a failing detail rather than
// silently passing validation. A missing path stays a skip (covered by
// TestRunReportRedactsProviderSecretsAndWarnsWithoutConnectivity), so this
// guards only the non-not-exist branch.
dir := t.TempDir()
configDir := filepath.Join(dir, "config.json")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
report := Run(Options{Now: fixedDoctorClock("2026-06-08T10:13:00Z"), Runtime: "go", ProjectConfig: configDir})
if report.OK {
t.Fatalf("unreadable config should fail report: %#v", report)
}
check := report.Check("config.validation")
if check == nil || check.Status != StatusFail {
t.Fatalf("expected config.validation fail for unreadable path, got %#v", report.Checks)
}
entry, ok := check.Details[configDir].(map[string]any)
if !ok {
t.Fatalf("expected per-path map entry for %q, got %#v", configDir, check.Details)
}
message, _ := entry["error"].(string)
if !strings.Contains(message, "unreadable:") {
t.Fatalf("expected unreadable error detail, got %#v", entry)
}
}
func TestConfigValidationCheckSkippedWhenNoPaths(t *testing.T) {
report := Run(Options{Now: fixedDoctorClock("2026-06-08T10:15:00Z"), Runtime: "go"})
check := report.Check("config.validation")
if check == nil || check.Status != StatusWarn {
t.Fatalf("expected config.validation warn-skip, got %#v", report.Checks)
}
}
func TestConfigValidationCheckDoesNotLeakSecret(t *testing.T) {
path := writeDoctorConfig(t, `{
"activeProvider": "main",
"providers": [{"name": "main", "provider_kind": "openai", "baseURL": "https://example.test/v1", "apiKey": "sk-proj-secret1234567890", "model": "gpt-4.1"}]
}`)
report := Run(Options{Now: fixedDoctorClock("2026-06-08T10:20:00Z"), Runtime: "go", ProjectConfig: path})
if strings.Contains(Format(report), "sk-proj-secret") {
t.Fatalf("config.validation leaked apiKey: %q", Format(report))
}
}
func TestOffsetToLineCol(t *testing.T) {
data := []byte("{\n \"a\": 1,\n bad\n}")
cases := []struct {
name string
data []byte
offset int64
wantLine int
wantCol int
}{
{name: "start", data: data, offset: 0, wantLine: 1, wantCol: 1},
{name: "after first newline", data: data, offset: 2, wantLine: 2, wantCol: 1},
{name: "mid second line", data: data, offset: 7, wantLine: 2, wantCol: 6},
{name: "negative clamps to start", data: data, offset: -5, wantLine: 1, wantCol: 1},
{name: "past end clamps to last", data: data, offset: 9999, wantLine: 4, wantCol: 2},
// Multi-byte UTF-8: the rune '£' is 2 bytes (0xC2 0xA3). The JSON parser
// reports byte offsets, so the column after '£' is byte-column 3, not
// rune-column 2. This documents that offsetToLineCol counts BYTES, not
// runes.
{name: "utf8 multibyte byte columns", data: []byte("{\"£\": bad}"), offset: 6, wantLine: 1, wantCol: 7},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
line, col := offsetToLineCol(tc.data, tc.offset)
if line != tc.wantLine || col != tc.wantCol {
t.Fatalf("offsetToLineCol(%d) = (%d,%d), want (%d,%d)", tc.offset, line, col, tc.wantLine, tc.wantCol)
}
})
}
}
func fixedDoctorClock(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
+13 -7
View File
@@ -19,10 +19,12 @@ type ConfigSource string
type AuditStatus string
const (
EventBeforeTool Event = "beforeTool"
EventAfterTool Event = "afterTool"
EventSessionStart Event = "sessionStart"
EventSessionEnd Event = "sessionEnd"
EventBeforeTool Event = "beforeTool"
EventAfterTool Event = "afterTool"
EventSessionStart Event = "sessionStart"
EventSessionEnd Event = "sessionEnd"
EventSpecialistStart Event = "specialistStart"
EventSpecialistStop Event = "specialistStop"
)
const (
@@ -689,7 +691,7 @@ func normalizeDefinition(raw any, field string) (Definition, error) {
if err != nil {
return Definition{}, err
}
if matcher != "" && (event == EventSessionStart || event == EventSessionEnd) {
if matcher != "" && !eventSupportsMatcher(event) {
return Definition{}, manifestError{fieldPath: field + ".matcher", message: "matcher is only supported for beforeTool and afterTool hooks."}
}
command, err := requiredString(obj, field+".command")
@@ -753,6 +755,10 @@ func matchesHookMatcher(matcher string, toolName string) bool {
return cursor <= searchEnd
}
func eventSupportsMatcher(event Event) bool {
return event == EventBeforeTool || event == EventAfterTool
}
func toDiagnostic(err error, source ConfigSource, path string) Diagnostic {
var manifestErr manifestError
if errors.As(err, &manifestErr) {
@@ -803,10 +809,10 @@ func parseEvent(raw any, field string) (Event, error) {
}
event := Event(strings.TrimSpace(text))
switch event {
case EventBeforeTool, EventAfterTool, EventSessionStart, EventSessionEnd:
case EventBeforeTool, EventAfterTool, EventSessionStart, EventSessionEnd, EventSpecialistStart, EventSpecialistStop:
return event, nil
default:
return "", manifestError{fieldPath: field, message: "Expected beforeTool, afterTool, sessionStart, or sessionEnd."}
return "", manifestError{fieldPath: field, message: "Expected beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, or specialistStop."}
}
}
+63 -23
View File
@@ -127,30 +127,34 @@ func TestLoadConfigPreservesUserDisabledStateWhenProjectOmitsEnabled(t *testing.
}
}
func TestLoadConfigRejectsMatchersOnSessionHooks(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
writeHookJSON(t, projectConfigPath, map[string]any{
"hooks": []any{map[string]any{
"id": "zero.session",
"event": "sessionStart",
"matcher": "bash",
"command": "node",
}},
})
func TestLoadConfigRejectsMatchersOnLifecycleHooks(t *testing.T) {
for _, event := range []string{"sessionStart", "specialistStart"} {
t.Run(event, func(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
writeHookJSON(t, projectConfigPath, map[string]any{
"hooks": []any{map[string]any{
"id": "zero.lifecycle",
"event": event,
"matcher": "bash",
"command": "node",
}},
})
result, err := LoadConfig(LoadOptions{
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
ProjectConfigPath: projectConfigPath,
})
if err != nil {
t.Fatalf("LoadConfig returned error: %v", err)
}
if len(result.Config.Hooks) != 0 {
t.Fatalf("expected invalid hooks to be skipped: %#v", result.Config.Hooks)
}
if !hasHookDiagnostic(result.Diagnostics, DiagnosticSchema, "", "hooks.0.matcher") {
t.Fatalf("missing matcher diagnostic: %#v", result.Diagnostics)
result, err := LoadConfig(LoadOptions{
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
ProjectConfigPath: projectConfigPath,
})
if err != nil {
t.Fatalf("LoadConfig returned error: %v", err)
}
if len(result.Config.Hooks) != 0 {
t.Fatalf("expected invalid hooks to be skipped: %#v", result.Config.Hooks)
}
if !hasHookDiagnostic(result.Diagnostics, DiagnosticSchema, "", "hooks.0.matcher") {
t.Fatalf("missing matcher diagnostic: %#v", result.Diagnostics)
}
})
}
}
@@ -267,6 +271,42 @@ func TestSelectMatchesEnabledHooksByEventAndWildcard(t *testing.T) {
}
}
func TestSpecialistHookEventsLoadAndSelect(t *testing.T) {
dir := t.TempDir()
projectConfigPath := filepath.Join(dir, "hooks.json")
writeHookJSON(t, projectConfigPath, map[string]any{
"hooks": []any{
map[string]any{
"id": "zero.specialist-start",
"event": "specialistStart",
"command": "node",
},
map[string]any{
"id": "zero.specialist-stop",
"event": "specialistStop",
"command": "node",
},
},
})
result, err := LoadConfig(LoadOptions{
UserConfigPath: filepath.Join(dir, "missing-user-hooks.json"),
ProjectConfigPath: projectConfigPath,
})
if err != nil {
t.Fatalf("LoadConfig returned error: %v", err)
}
if got := hookIDs(result.Config.Hooks); !reflect.DeepEqual(got, []string{"zero.specialist-start", "zero.specialist-stop"}) {
t.Fatalf("hook ids = %#v", got)
}
if got := hookIDs(Select(result.Config, SelectInput{Event: EventSpecialistStart})); !reflect.DeepEqual(got, []string{"zero.specialist-start"}) {
t.Fatalf("specialistStart selection = %#v", got)
}
if got := hookIDs(Select(result.Config, SelectInput{Event: EventSpecialistStop})); !reflect.DeepEqual(got, []string{"zero.specialist-stop"}) {
t.Fatalf("specialistStop selection = %#v", got)
}
}
func TestAuditStoreAppendsAndSkipsMalformedLines(t *testing.T) {
auditPath := filepath.Join(t.TempDir(), "audit.jsonl")
if err := os.WriteFile(auditPath, []byte(strings.Join([]string{
+83
View File
@@ -0,0 +1,83 @@
// Package imageinput is the single shared loader for local image files used by
// every input surface (CLI exec --image, TUI /image). It reads a file, sniffs +
// normalizes its media type against the allow-list, enforces the per-image size
// cap, and returns a raw-bytes ImageBlock. Keeping it here means the CLI and TUI
// never duplicate the read/sniff/normalize/cap logic.
package imageinput
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// MaxImageBytes is the per-image decoded-size cap (10 MiB). Bytes above this are
// rejected at every input boundary so an unbounded request body never reaches a
// provider.
const MaxImageBytes = 10 << 20
// LoadFile reads the image at path (resolved against workspaceRoot when
// relative), validates its type and size, and returns a raw-bytes ImageBlock.
// Errors are plain (callers wrap them into surface-specific usage/notice text).
func LoadFile(path string, workspaceRoot string) (zeroruntime.ImageBlock, error) {
resolved := path
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(workspaceRoot, resolved)
}
// Reject oversized files via Stat BEFORE reading them into memory, so a huge
// file never allocates a multi-gigabyte buffer just to be discarded by the
// post-read cap. A missing file surfaces the same "not found" notice as a
// failed read.
info, err := os.Stat(resolved)
if err != nil {
return zeroruntime.ImageBlock{}, fmt.Errorf("image file not found: %s", path)
}
// Reject non-regular files (directories, FIFOs, devices) up front. os.Stat
// follows symlinks, so a symlink to a regular file still passes. This guards
// against os.Open blocking forever on a writerless FIFO (an --image/ /image
// path pointing at a named pipe would otherwise hang the process/UI).
if !info.Mode().IsRegular() {
return zeroruntime.ImageBlock{}, fmt.Errorf("image file must be a regular file: %s", path)
}
if info.Size() > MaxImageBytes {
return zeroruntime.ImageBlock{}, fmt.Errorf("image %s is larger than the 10 MiB limit", path)
}
// Bounded read: the os.Stat above is only a fast-path hint (a non-regular
// file reports a misleading size, and the file can grow between Stat and the
// read). A LimitReader of MaxImageBytes+1 is the real bound — at most one byte
// past the cap is ever buffered, so an oversized or unbounded source (e.g. a
// FIFO) can never allocate a multi-gigabyte buffer just to be discarded.
file, err := os.Open(resolved)
if err != nil {
return zeroruntime.ImageBlock{}, fmt.Errorf("image file not found: %s", path)
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, MaxImageBytes+1))
if err != nil {
return zeroruntime.ImageBlock{}, fmt.Errorf("image file not found: %s", path)
}
// The LimitReader yields at most MaxImageBytes+1 bytes; more than the cap means
// the source was oversized (caught here regardless of any stat/read race).
if len(data) > MaxImageBytes {
return zeroruntime.ImageBlock{}, fmt.Errorf("image %s is larger than the 10 MiB limit", path)
}
sniffLen := len(data)
if sniffLen > 512 {
sniffLen = 512
}
mediaType := zeroruntime.NormalizeImageMediaType(http.DetectContentType(data[:sniffLen]))
if mediaType == "" {
return zeroruntime.ImageBlock{}, fmt.Errorf("unsupported image type for %s (allowed: png, jpeg, gif, webp)", path)
}
return zeroruntime.ImageBlock{MediaType: mediaType, Data: data}, nil
}
+102
View File
@@ -0,0 +1,102 @@
package imageinput
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadFileReadsAndNormalizes(t *testing.T) {
root := t.TempDir()
// 1x1 PNG (real PNG signature so http.DetectContentType returns image/png).
png := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
0x42, 0x60, 0x82,
}
if err := os.WriteFile(filepath.Join(root, "pic.png"), png, 0o644); err != nil {
t.Fatalf("write png: %v", err)
}
// Relative path resolves against workspaceRoot.
block, err := LoadFile("pic.png", root)
if err != nil {
t.Fatalf("LoadFile relative: %v", err)
}
if block.MediaType != "image/png" {
t.Fatalf("MediaType = %q, want image/png", block.MediaType)
}
if len(block.Data) != len(png) {
t.Fatalf("Data length = %d, want %d", len(block.Data), len(png))
}
// Absolute path is used as-is.
if _, err := LoadFile(filepath.Join(root, "pic.png"), root); err != nil {
t.Fatalf("LoadFile absolute: %v", err)
}
}
func TestLoadFileMissing(t *testing.T) {
root := t.TempDir()
_, err := LoadFile("nope.png", root)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "nope.png") {
t.Fatalf("error %q should name the path", err.Error())
}
}
func TestLoadFileUnsupportedType(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("just some plain text, not an image at all"), 0o644); err != nil {
t.Fatalf("write txt: %v", err)
}
_, err := LoadFile("notes.txt", root)
if err == nil {
t.Fatal("expected error for non-image content")
}
if !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("error %q should mention unsupported", err.Error())
}
}
func TestLoadFileRejectsNonRegular(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "adir"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// A directory is non-regular like a FIFO/device; the guard must reject it
// before os.Open (a writerless FIFO would otherwise block the read forever).
_, err := LoadFile("adir", root)
if err == nil {
t.Fatal("expected error for a non-regular file")
}
if !strings.Contains(err.Error(), "regular file") {
t.Fatalf("error %q should mention regular file", err.Error())
}
}
func TestLoadFileOversizeRejected(t *testing.T) {
root := t.TempDir()
big := make([]byte, (10<<20)+1)
// Make it sniff as a GIF so size is the only failing condition.
copy(big, []byte("GIF89a"))
if err := os.WriteFile(filepath.Join(root, "big.gif"), big, 0o644); err != nil {
t.Fatalf("write big: %v", err)
}
_, err := LoadFile("big.gif", root)
if err == nil {
t.Fatal("expected error for oversize image")
}
if !strings.Contains(err.Error(), "10") {
t.Fatalf("error %q should mention the size limit", err.Error())
}
}
+16
View File
@@ -156,6 +156,22 @@ func (tool registryTool) Safety() tools.Safety {
return tool.safety
}
// Deferred marks every MCP tool as deferred-eligible: when many MCP tools are
// registered the agent loop may withhold their full schema and advertise them
// via tool_search. Built-in tools do not implement this interface and stay
// eager.
func (tool registryTool) Deferred() bool {
return true
}
// MCPServerName reports the tool's originating MCP server name so the deferred-
// tools reminder labels it correctly, even when the sanitized server token in the
// synthesized tool name contains an underscore (which the name-only parser would
// truncate). It returns the true configured server name, not the sanitized token.
func (tool registryTool) MCPServerName() string {
return tool.server.Name
}
func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Result {
result, err := tool.client.CallTool(ctx, tool.remote.Name, args)
if err != nil {
+66 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
@@ -171,11 +172,75 @@ func TestRegisterToolsPreservesPriorRegistryStateOnFailure(t *testing.T) {
}
}
func TestRegistryToolIsDeferredEligible(t *testing.T) {
client := &fakeToolClient{}
tool := newRegistryTool(
Server{Name: "docs", Type: "stdio"},
RemoteTool{Name: "lookup", Description: "Lookup documentation"},
client,
RegisterOptions{},
)
// Sanity: the wrapper synthesizes the expected sanitized name so the test
// exercises the real production path, not a hand-built struct.
if tool.Name() != "mcp_docs_lookup" {
t.Fatalf("registryTool.Name() = %q, want mcp_docs_lookup", tool.Name())
}
if !tool.Deferred() {
t.Fatal("registryTool.Deferred() = false, want true (all MCP tools are deferred-eligible)")
}
// The exported helper in the tools package must agree via the optional
// interface, since the agent loop partitions tools through tools.IsDeferred.
if !tools.IsDeferred(tool) {
t.Fatal("tools.IsDeferred(registryTool) = false, want true")
}
}
// TestRegistryToolReportsMCPServerName verifies the registryTool reports its true
// configured server name (not the sanitized tool-name token) so the deferred-tools
// reminder labels a multi-token server correctly via tools.DeferredLine.
func TestRegistryToolReportsMCPServerName(t *testing.T) {
client := &fakeToolClient{}
// A server name that sanitizes to a token containing an underscore ("git_hub"):
// the name-only parser would truncate the label to "git".
tool := newRegistryTool(
Server{Name: "git hub", Type: "stdio"},
RemoteTool{Name: "create_issue", Description: "Create a GitHub issue."},
client,
RegisterOptions{},
)
if tool.MCPServerName() != "git hub" {
t.Fatalf("MCPServerName() = %q, want %q", tool.MCPServerName(), "git hub")
}
// DeferredLine must prefer the reported server name over the name-derived token.
line := tools.DeferredLine(tool)
if !strings.Contains(line, "server: git hub") {
t.Fatalf("DeferredLine = %q, want it to label server as %q via MCPServerName()", line, "git hub")
}
// The truncated token-only label ("git") must NOT be the server segment.
if strings.Contains(line, "server: git |") {
t.Fatalf("DeferredLine = %q, mislabeled multi-token server with the truncated token", line)
}
}
// TestRegistryToolNameRoundTripsToServerToken pins the synthesized name format
// for a single-token server so the fallback label parser (mcpServerFromToolName,
// exercised in the tools package) recovers the right server token.
func TestRegistryToolNameRoundTripsToServerToken(t *testing.T) {
if name := registryToolName("docs", "lookup"); name != "mcp_docs_lookup" {
t.Fatalf("registryToolName(docs, lookup) = %q, want mcp_docs_lookup", name)
}
}
type fakePreexistingTool struct {
name string
}
func (t *fakePreexistingTool) Name() string { return t.name }
func (t *fakePreexistingTool) Name() string { return t.name }
func (t *fakePreexistingTool) Description() string { return "preexisting tool" }
func (t *fakePreexistingTool) Parameters() tools.Schema { return tools.Schema{} }
func (t *fakePreexistingTool) Safety() tools.Safety { return tools.Safety{} }
+19
View File
@@ -51,10 +51,12 @@ func decorateModelDepth(entries []ModelEntry) {
defaultEffort ReasoningEffort
patterns []string
deprecation *DeprecationRule
upgradeTarget string
}{
"claude-sonnet-4.5": {
defaultEffort: ReasoningEffortMedium,
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
upgradeTarget: "claude-opus-4.1",
},
"claude-opus-4.1": {
defaultEffort: ReasoningEffortHigh,
@@ -63,6 +65,7 @@ func decorateModelDepth(entries []ModelEntry) {
"claude-haiku-4.5": {
defaultEffort: ReasoningEffortLow,
patterns: []string{`(?i)^haiku[^a-z0-9]*4[.\s]?5$`},
upgradeTarget: "claude-sonnet-4.5",
},
"gemini-2.5-pro": {
defaultEffort: ReasoningEffortMedium,
@@ -70,6 +73,19 @@ func decorateModelDepth(entries []ModelEntry) {
},
"gemini-2.5-flash": {
defaultEffort: ReasoningEffortLow,
upgradeTarget: "gemini-2.5-pro",
},
"gemini-2.5-flash-lite": {
upgradeTarget: "gemini-2.5-flash",
},
"gpt-4.1-mini": {
upgradeTarget: "gpt-4.1",
},
"gpt-4.1-nano": {
upgradeTarget: "gpt-4.1-mini",
},
"gpt-4o-mini": {
upgradeTarget: "gpt-4o",
},
"gpt-4-turbo": {
deprecation: &DeprecationRule{
@@ -100,6 +116,9 @@ func decorateModelDepth(entries []ModelEntry) {
if extra.deprecation != nil {
entries[index].Deprecation = extra.deprecation.Clone()
}
if extra.upgradeTarget != "" {
entries[index].UpgradeTargetID = extra.upgradeTarget
}
}
}
+43
View File
@@ -216,6 +216,49 @@ func reasoningEffortAllowedIn(efforts []ReasoningEffort, want ReasoningEffort) b
return false
}
func TestDefaultRegistryUpgradeTargets(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
// Seeded escalation chains across the concrete catalog ids.
chains := map[string]string{
"claude-haiku-4.5": "claude-sonnet-4.5",
"claude-sonnet-4.5": "claude-opus-4.1",
"gpt-4.1-mini": "gpt-4.1",
"gpt-4.1-nano": "gpt-4.1-mini",
"gpt-4o-mini": "gpt-4o",
"gemini-2.5-flash-lite": "gemini-2.5-flash",
"gemini-2.5-flash": "gemini-2.5-pro",
}
for source, want := range chains {
target, ok := registry.UpgradeTarget(source)
if !ok {
t.Fatalf("UpgradeTarget(%q) returned no target, want %q", source, want)
}
if target.ID != want {
t.Fatalf("UpgradeTarget(%q) = %q, want %q", source, target.ID, want)
}
}
// Top-tier models declare no escalation target.
for _, topTier := range []string{"claude-opus-4.1", "gpt-4.1", "gemini-2.5-pro"} {
if target, ok := registry.UpgradeTarget(topTier); ok {
t.Fatalf("top-tier model %q should have no upgrade target, got %q", topTier, target.ID)
}
}
// The seeded field is present on the decorated entry, not only via the method.
haiku, err := registry.Require("claude-haiku-4.5")
if err != nil {
t.Fatalf("Require(claude-haiku-4.5) returned error: %v", err)
}
if haiku.UpgradeTargetID != "claude-sonnet-4.5" {
t.Fatalf("claude-haiku-4.5 UpgradeTargetID = %q, want claude-sonnet-4.5", haiku.UpgradeTargetID)
}
}
func containsModelID(models []ModelEntry, id string) bool {
for _, model := range models {
if model.ID == id {
+17 -1
View File
@@ -96,7 +96,11 @@ type ModelEntry struct {
MatchPatterns []string
// Deprecation, when set, redirects this model to a replacement.
Deprecation *DeprecationRule
Description string
// UpgradeTargetID, when set, names the stronger model this one escalates to
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
// models). Resolved and availability-checked by Registry.UpgradeTarget.
UpgradeTargetID string
Description string
}
// DeprecationRule describes how a deprecated model is phased out and what to use
@@ -342,6 +346,18 @@ func NewRegistry(entries []ModelEntry) (Registry, error) {
return Registry{}, fmt.Errorf("model %q deprecation fallback %q does not resolve to a known model", entry.ID, fallbackID)
}
}
// A non-empty UpgradeTargetID must resolve to a known model. Otherwise
// UpgradeTarget would silently disable escalation at runtime (a catalog typo)
// instead of failing loudly here — mirroring the deprecation fallback check.
for _, entry := range registry.models {
targetID := strings.TrimSpace(entry.UpgradeTargetID)
if targetID == "" {
continue
}
if _, ok := registry.Get(targetID); !ok {
return Registry{}, fmt.Errorf("model %q upgrade target %q does not resolve to a known model", entry.ID, targetID)
}
}
return registry, nil
}
+28
View File
@@ -0,0 +1,28 @@
package modelregistry
import "strings"
// UpgradeTarget resolves the stronger model that id escalates to during mid-run
// escalation. It resolves id to a concrete entry, reads its UpgradeTargetID,
// resolves THAT to a concrete entry, and returns it only when the target is
// available and not deprecated. It returns (_, false) when id is unknown, has no
// configured target, or the target resolves to an unavailable/deprecated model.
// Pure and registry-only: applies no deprecation fallback redirection.
func (registry Registry) UpgradeTarget(id string) (ModelEntry, bool) {
source, ok := registry.Resolve(id)
if !ok {
return ModelEntry{}, false
}
targetID := strings.TrimSpace(source.UpgradeTargetID)
if targetID == "" {
return ModelEntry{}, false
}
target, ok := registry.Resolve(targetID)
if !ok {
return ModelEntry{}, false
}
if target.Status == ModelStatusDeprecated {
return ModelEntry{}, false
}
return target, true
}
+99
View File
@@ -0,0 +1,99 @@
package modelregistry
import (
"strings"
"testing"
)
// upgradeEntry builds a minimal valid active entry with an upgrade target set.
func upgradeEntry(id, alias, upgradeTargetID string) ModelEntry {
entry := mkEntry(id, alias)
entry.UpgradeTargetID = upgradeTargetID
return entry
}
func upgradeTestRegistry(t *testing.T) Registry {
t.Helper()
haiku := upgradeEntry("claude-haiku-4-5", "haiku-4.5", "claude-sonnet-4-5")
sonnet := upgradeEntry("claude-sonnet-4-5", "sonnet-4.5", "claude-opus-4-1")
opus := mkEntry("claude-opus-4-1", "opus-4.1") // top-tier: no UpgradeTargetID
// A model whose target points at a deprecated entry must not escalate.
legacy := upgradeEntry("legacy-mini", "legacy-m", "legacy-retired")
retired := mkEntry("legacy-retired", "legacy-r")
retired.Status = ModelStatusDeprecated
retired.Deprecation = &DeprecationRule{FallbackID: "claude-opus-4-1", WarningMsg: "retired"}
reg, err := NewRegistry([]ModelEntry{haiku, sonnet, opus, legacy, retired})
if err != nil {
t.Fatalf("NewRegistry returned error: %v", err)
}
return reg
}
func TestUpgradeTargetResolvesConcreteEntry(t *testing.T) {
reg := upgradeTestRegistry(t)
target, ok := reg.UpgradeTarget("claude-haiku-4-5")
if !ok {
t.Fatal("haiku should escalate to its upgrade target")
}
if target.ID != "claude-sonnet-4-5" {
t.Fatalf("UpgradeTarget(haiku) = %q, want claude-sonnet-4-5", target.ID)
}
// Resolution should also work through an alias for the source model.
if target, ok := reg.UpgradeTarget("sonnet-4.5"); !ok || target.ID != "claude-opus-4-1" {
t.Fatalf("UpgradeTarget(sonnet alias) = %q/%v, want claude-opus-4-1/true", target.ID, ok)
}
}
func TestUpgradeTargetTopTierHasNone(t *testing.T) {
reg := upgradeTestRegistry(t)
if target, ok := reg.UpgradeTarget("claude-opus-4-1"); ok {
t.Fatalf("top-tier model should have no upgrade target, got %q", target.ID)
}
}
func TestUpgradeTargetUnknownSource(t *testing.T) {
reg := upgradeTestRegistry(t)
if _, ok := reg.UpgradeTarget("not-a-real-model"); ok {
t.Fatal("unknown source model should not yield an upgrade target")
}
}
func TestUpgradeTargetDeprecatedTargetRejected(t *testing.T) {
reg := upgradeTestRegistry(t)
if target, ok := reg.UpgradeTarget("legacy-mini"); ok {
t.Fatalf("deprecated target should be rejected, got %q", target.ID)
}
}
func TestNewRegistryRejectsDanglingUpgradeTarget(t *testing.T) {
// A non-empty UpgradeTargetID that does not resolve to a known model is a
// catalog typo; NewRegistry must fail loudly rather than silently disabling
// escalation at runtime (mirrors the deprecation-fallback validation).
dangling := upgradeEntry("dangling-mini", "dangling-m", "does-not-exist")
_, err := NewRegistry([]ModelEntry{dangling})
if err == nil {
t.Fatal("NewRegistry should reject an unresolvable upgrade target")
}
if !strings.Contains(err.Error(), "upgrade target") {
t.Fatalf("error %q should mention the upgrade target", err.Error())
}
}
func TestUpgradeTargetReturnsIndependentCopy(t *testing.T) {
reg := upgradeTestRegistry(t)
target, ok := reg.UpgradeTarget("claude-haiku-4-5")
if !ok {
t.Fatal("haiku should escalate")
}
target.Aliases = append(target.Aliases, "mutated")
again, _ := reg.UpgradeTarget("claude-haiku-4-5")
for _, alias := range again.Aliases {
if alias == "mutated" {
t.Fatal("UpgradeTarget must return a defensive copy, not a shared entry")
}
}
}
+15
View File
@@ -0,0 +1,15 @@
package modelregistry
// SupportsVision reports whether the model identified by modelID is known to the
// registry and advertises the vision capability. It returns false when the model
// cannot be resolved (e.g. a custom or openai-compatible id absent from the
// catalog): an unknown model is treated as "cannot confirm", so callers drop
// images rather than send them to a model that may reject them.
//
// modelID is resolved through the registry's normal alias/pattern matching, so
// any spelling that Get accepts works here too. This helper is the single
// capability check shared by the headless (exec) and interactive (TUI) input
// surfaces; the warn-and-drop behavior lives at those call sites.
func SupportsVision(registry Registry, modelID string) bool {
return registry.SupportsCapability(modelID, ModelCapabilityVision)
}
+46
View File
@@ -0,0 +1,46 @@
package modelregistry
import "testing"
func TestSupportsVision(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
// A registry containing exactly one non-vision model so the false case is
// covered even though every entry in the default catalog has vision.
nonVision := validModelEntry()
nonVision.ID = "no-vision-model"
nonVision.APIModel = "no-vision-model"
nonVision.Aliases = []string{"openai:no-vision-model"}
nonVision.Capabilities = ModelCapabilities{ModelCapabilityChat}
if nonVision.Supports(ModelCapabilityVision) {
t.Fatal("test fixture should not advertise vision")
}
nonVisionRegistry, err := NewRegistry([]ModelEntry{nonVision})
if err != nil {
t.Fatalf("NewRegistry returned error: %v", err)
}
cases := []struct {
name string
registry Registry
modelID string
want bool
}{
{name: "vision model is supported", registry: registry, modelID: "gemini-2.5-pro", want: true},
{name: "vision model resolves via alias", registry: registry, modelID: "gemini-pro", want: true},
{name: "non-vision model is not supported", registry: nonVisionRegistry, modelID: "no-vision-model", want: false},
{name: "unknown model is not supported", registry: registry, modelID: "totally-made-up-model", want: false},
{name: "empty model id is not supported", registry: registry, modelID: "", want: false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := SupportsVision(testCase.registry, testCase.modelID); got != testCase.want {
t.Fatalf("SupportsVision(%q) = %v, want %v", testCase.modelID, got, testCase.want)
}
})
}
}
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more