Compare commits

..
Author SHA1 Message Date
Gnanam 27e250c7b1 cli: gate tool_search registration on the same visible-deferred count as the loop
- deferredEligibleCount/registerToolSearchIfEligible now count only deferred tools
  that pass agent.ToolVisible (permission-mode advertising + operator filters), so
  registration and the loop's partition activation agree. Both exec and TUI call
  sites pass the run's permission mode and enabled/disabled filters; the TUI now
  resolves its effective Ask mode BEFORE the gate.
- validateExecToolFilters treats tool_search as always-valid (it is registered
  later, only when deferral activates) so --enabled-tools/--disabled-tools
  tool_search no longer errors 'Unknown tool'.
- Tests: a disabled deferred tool drops the visible count below threshold and
  suppresses registration; tool_search passes filter validation while a genuinely
  unknown name still errors.
2026-06-08 21:47:32 +05:30
Gnanam 3c82c20d20 agent: keep tool_search reachable when deferral active, forward operator filters
- partitionTools always exposes tool_search on the ACTIVE path (looked up by
  ToolSearchToolName) even when a non-empty EnabledTools allowlist omits it; the
  inactive path stays byte-identical (tool_search still dropped below threshold).
- The tool-call dispatch gate exempts tool_search from the allowlist so a call to
  the loader is never rejected as 'not enabled', while an explicit DisabledTools
  entry for tool_search is still honored. Together this closes the dead-end where
  the reminder pointed the model at an absent/rejected loader.
- Forward EnabledTools/DisabledTools into the tool RunOptions so tool_search is
  filter-aware about operator-hidden deferred tools.
- Tests: FIX 1+2 dead-end regression (allowlisted deferred + tool_search reachable
  and dispatch-allowed), disabled-tool_search still hidden/rejected, connect-time
  reactive-retry keeps the loaded tool + reminder, filter x deferral interactions,
  and a byte-identical DeepEqual reference for the inactive partition.
2026-06-08 21:47:20 +05:30
Gnanam f7c74ef784 mcp: report MCPServerName() so deferred reminder labels multi-token servers
registryTool now implements the optional tools.mcpServerNamed interface, returning
the true configured server name. This fixes the reminder 'server:' label for a
server whose sanitized name token contains an underscore (e.g. 'git hub'), which
the name-only parser would truncate. Label-only; resolution is unaffected.
2026-06-08 21:47:11 +05:30
Gnanam ae4ce72815 tools: add ToolSearchToolName, make tool_search filter-aware, label MCP server by name
- Export ToolSearchToolName and use it for the tool's Name() so every gate that
  must treat the loader specially references one constant.
- Add EnabledTools/DisabledTools to RunOptions and honor them in tool_search:
  an operator-hidden deferred tool is now invisible to select:, keyword ranking,
  and the no-match listing (no disclosure, no wasted load).
- DeferredLine prefers an optional MCPServerName() over the name-derived token so
  a multi-token server name is labeled correctly in the reminder.
2026-06-08 21:47:06 +05:30
Gnanam 99cb28d1a3 tools, mcp: gofmt deferred-tools files 2026-06-08 21:25:35 +05:30
Gnanam 410173c701 cli: rename misleading list-tools test to reflect what it checks
The old name implied the absence of the deferred-tool search on the
--list-tools path was caused by the threshold gate, but --list-tools
short-circuits and returns before resolveConfig and the eligibility
check ever run. Rename to ...AdvertisesMCPToolsWithoutToolSearch and
add a comment pointing at the unit/E2E tests that actually exercise the
threshold gate. Also apply a pre-existing gofmt alignment fix in the
same block so the package is gofmt-clean.
2026-06-08 21:24:12 +05:30
Gnanam 71da864a74 config: add ToolsOverride constructor so programmatic Overrides can disable deferral
A bare ToolsConfig{DeferThreshold: 0} leaves the unexported presence flag
unset, so applyOverrides treats it as unset and silently drops an
override-to-disable. ToolsOverride(n) carries the presence flag, so
ToolsOverride(0) now correctly overrides a non-zero base down to 0
(disabled) while ToolsOverride(7) overrides to 7. applyOverrides is
unchanged; the file-layer explicit-0 behavior is untouched.
2026-06-08 21:24:07 +05:30
Gnanam c8fa54c675 cli/tui: thread DeferThreshold and register tool_search for interactive runs 2026-06-08 21:14:00 +05:30
Gnanam c3ece4b198 cli/exec: guard byte-identical tool listing below deferral threshold 2026-06-08 21:12:33 +05:30
Gnanam 4960e40f1b cli/exec: register tool_search and thread DeferThreshold when deferral is active 2026-06-08 21:12:07 +05:30
Gnanam 12add88691 config: default tools.deferThreshold to 10, merge layers, reject negative 2026-06-08 21:11:04 +05:30
Gnanam 25ae21952d config: add ToolsConfig.DeferThreshold to file/override/resolved config 2026-06-08 21:09:51 +05:30
Gnanam 0d35d568f2 fix(agent): reactive-compaction retry preserves active deferral partition + reminder
Both reactive-compaction retry sites in the agent loop rebuilt the retry
request via the bare toolDefinitions(), which routes through partitionTools
with an empty loaded set. When deferral was ACTIVE and a context-limit error
triggered a mid-run compaction+retry, that retried turn re-hid every
already-loaded deferred tool and sent no <system-reminder>.

Reuse the exposed/reminder already computed for the turn (they depend on
registry+loaded, not the messages, so they survive compaction) and re-append
the reminder to the per-turn retry copy only, matching the main path and
keeping the reminder-not-persisted invariant. The inactive path is unchanged.

Also gofmt deferred_loop_test.go (receiver-column alignment).
2026-06-08 21:07:45 +05:30
Gnanam d967b26817 agent: thread per-run loaded set and per-turn deferred reminder through Run 2026-06-08 20:58:52 +05:30
Gnanam 980f7d7f41 agent: cover partitionTools active hide/expose and reminder 2026-06-08 20:57:30 +05:30
Gnanam 4721ce727c agent: add partitionTools with byte-identical inactive path 2026-06-08 20:57:07 +05:30
Gnanam fdf11d6a0a agent: lift load_tools meta into ToolResult.LoadedTools 2026-06-08 20:56:17 +05:30
Gnanam d8cf8d3923 agent: add DeferThreshold option and ToolResult.LoadedTools field 2026-06-08 20:55:22 +05:30
Gnanam 8774620e09 tools: use strconv.Itoa in tool_search and drop unused const 2026-06-08 20:53:07 +05:30
Gnanam 44998d4745 Add SideEffectNone and cover tool_search safety, schema, and registry dispatch 2026-06-08 20:48:19 +05:30
Gnanam 3a453baa7c Cover tool_search no-match returns informational result without load_tools 2026-06-08 20:47:58 +05:30
Gnanam ad34419ee5 Cover tool_search keyword ranking and exclusion 2026-06-08 20:47:42 +05:30
Gnanam 9afcd9651c Add tool_search tool for select: exact-name loading 2026-06-08 20:47:23 +05:30
Gnanam f9416f3283 tools: add exported DeferredLine entry point for the agent loop 2026-06-08 20:40:31 +05:30
Gnanam 624d7f798b tools: build system-reminder block advertising deferred tools 2026-06-08 20:38:55 +05:30
Gnanam adeda876b7 tools: assemble compact deferred tool advertisement line 2026-06-08 20:38:55 +05:30
Gnanam a1feeb37e2 tools: derive MCP server token from synthesized tool name 2026-06-08 20:38:55 +05:30
Gnanam b13529cb14 tools: add formatInputSchemaHint for deferred tool schemas 2026-06-08 20:38:55 +05:30
Gnanam 2c227a3395 tools: add shortenDescription formatter for deferred tool lines 2026-06-08 20:38:49 +05:30
Gnanam 4c7c3a1f48 mcp: mark registry tools deferred-eligible via Deferred() 2026-06-08 20:35:50 +05:30
Gnanam 2f525c2f5f tools: add deferredTool optional interface and IsDeferred helper 2026-06-08 20:35:26 +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
240 changed files with 26066 additions and 1790 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)
}
+735
View File
@@ -0,0 +1,735 @@
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"})
// 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"})
loaded := map[string]bool{"mcp__srv__alpha": true, "mcp__srv__beta": true}
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
if len(exposed) != 2 {
t.Fatalf("expected both loaded deferred tools exposed, 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"})
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"})
// 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"})
// 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 TestDisabledToolSearchStaysHiddenAndRejectedWhenActive(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"},
}
exposed, _ := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
for _, def := range exposed {
if def.Name == "tool_search" {
t.Fatalf("tool_search must stay hidden when explicitly disabled, got %#v", exposed)
}
}
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.StatusError || result.DenialReason != DenialFiltered {
t.Fatalf("a disabled tool_search call must be filtered out, got status=%s denial=%v output=%q", result.Status, result.DenialReason, 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)
}
}
+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}
}
+352 -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,14 @@ 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
// 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 +56,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 +75,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 +106,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 +146,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 {
@@ -220,19 +239,50 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
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
}
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.
@@ -297,7 +347,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 +360,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 +428,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 +443,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 +475,37 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
}, 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 +520,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 +535,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 +550,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 +577,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 +726,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 +858,100 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
}
}
// toolDefinitions returns the full-schema tool list for a turn. It now delegates
// to partitionTools with no loaded set; with deferral inactive (the only state
// the bare toolDefinitions is used in) the exposed slice is byte-identical to the
// pre-deferral output. Retained so existing callers/tests keep their signature.
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
exposed, _ := partitionTools(registry, permissionMode, options, map[string]bool{})
return exposed
}
// 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++
}
}
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold
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 the gateway to the allowlisted deferred
// tools, 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 unless an explicit DisabledTools entry
// turns the loader itself off. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
if active && !exposedNames[tools.ToolSearchToolName] && !containsToolName(options.DisabledTools, tools.ToolSearchToolName) {
if loader, ok := registry.Get(tools.ToolSearchToolName); ok {
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 +1013,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 +1042,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 +1112,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")
}
}
+69 -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,25 @@ 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
}
// 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 +129,58 @@ 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)
}
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()
}
+106 -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,14 @@ 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 "specialists", "specialist":
return runSpecialists(args[1:], stdout, stderr, deps)
case "plugins", "plugin":
return runPlugins(args[1:], stdout, stderr, deps)
case "skills", "skill":
@@ -181,10 +201,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 +226,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 +283,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 +295,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 +316,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,6 +373,7 @@ 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,
@@ -333,7 +382,7 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string) int
}
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 +396,21 @@ 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 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 +420,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 +452,11 @@ 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
specialist Manage local Zero specialist profiles
plugins Inspect local Zero plugin manifests
skills Inspect local Zero skills
hooks Inspect Zero hook configuration
@@ -391,15 +466,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,6 +496,7 @@ 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
--max-turns <number> Override the maximum agent loop turns
@@ -435,9 +513,16 @@ 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
`)
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")
}
}
+238 -41
View File
@@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -11,12 +12,16 @@ 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/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specialist"
"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 +29,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 +46,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
@@ -60,6 +69,12 @@ 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
@@ -114,6 +129,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
registry := newCoreRegistry(workspaceRoot)
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())
@@ -138,15 +162,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
@@ -161,7 +182,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
resolvedModel, notice := resolveSelectedModel(modelRegistry, options.model)
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 +194,48 @@ 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.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, 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())
}
// 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 _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
}
}
@@ -197,13 +252,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
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 +313,31 @@ 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: resolved.Tools.DeferThreshold,
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
Tag: options.tag,
Depth: options.depth,
SessionTitle: sessionTitle,
Model: resolved.Provider.Model,
ReasoningEffort: options.reasoningEffort,
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{
@@ -309,6 +386,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
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 +424,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 +479,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 +523,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 +538,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 +583,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 {
+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)
}
}
+130 -3
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/sessions"
)
func parseExecArgs(args []string) (execOptions, bool, error) {
@@ -57,6 +59,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 +80,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 {
@@ -185,6 +204,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]) != "" {
@@ -216,12 +317,18 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
if (options.resume != "" || options.resumeLatest) && options.fork != "" {
return options, false, execUsageError{"Use either --resume or --fork, not both."}
}
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 +338,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 +373,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 +397,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
+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])
}
}
+196 -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") {
+5 -1
View File
@@ -34,7 +34,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)}
}
}
+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")
}
}
+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
@@ -5,18 +5,19 @@ import (
"fmt"
"io"
"github.com/Gitlawb/zero/internal/zenline"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/zeroline"
)
// runZenline launches the interactive Zero TUI with the "zenline" skin: a Zen
// 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 runZenline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
fs := flag.NewFlagSet("zenline", flag.ContinueOnError)
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")
@@ -27,27 +28,28 @@ func runZenline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
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")
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 - 1
if v < 0 || v >= len(zenline.Themes) {
if v < 0 || v >= len(zeroline.Themes) {
v = 0
}
if *boot >= 0 {
if _, err := fmt.Fprintln(stdout, zenline.RenderBoot(v, !*light, *boot, *width, *height)); err != nil {
if _, err := fmt.Fprintln(stdout, zeroline.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"}
hdr := zeroline.Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"}
var frame string
if *page == "chat" {
cd := zenline.ChatData{
cd := zeroline.ChatData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Rows: []zenline.Row{
Rows: []zeroline.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"},
@@ -60,7 +62,7 @@ func runZenline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
Input: "❯ ",
}
if *perm {
cd.Perm = &zenline.Perm{Tool: "edit_file", Risk: "medium", Reason: "writes internal/agent/exec.go and loop.go", Summary: "write"}
cd.Perm = &zeroline.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
@@ -68,9 +70,9 @@ func runZenline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
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)
frame = zeroline.RenderChat(cd)
} else {
frame = zenline.RenderHome(zenline.HomeData{
frame = zeroline.RenderHome(zeroline.HomeData{
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
Input: "❯ message zero — / commands · @ files · ! bash",
})
@@ -81,5 +83,9 @@ func runZenline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return 0
}
return runInteractiveTUIWithSkin(stderr, deps, "zenline")
permissionMode := agent.PermissionModeAsk
if *skipUnsafe {
permissionMode = agent.PermissionModeUnsafe
}
return runInteractiveTUIWithSkin(stderr, deps, "zeroline", permissionMode)
}
+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 {
+364 -30
View File
@@ -3,29 +3,40 @@ package config
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
"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 +50,24 @@ 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)
}
}
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
return ResolvedConfig{}, err
}
@@ -50,6 +78,8 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Provider: active,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Tools: cfg.Tools,
}, nil
}
@@ -94,16 +124,42 @@ 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 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 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 +170,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 +312,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 +354,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 +389,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 +456,13 @@ 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 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 +521,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 +557,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 +578,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 +634,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 +744,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))
}
+830 -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,101 @@ 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 writeConfig(t *testing.T, body string) string {
t.Helper()
@@ -650,3 +1293,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)
}
}
+129 -18
View File
@@ -15,18 +15,77 @@ 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 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 +93,8 @@ type FileConfig struct {
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
}
type ResolveOptions struct {
@@ -50,6 +111,8 @@ type Overrides struct {
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Tools ToolsConfig
}
type ResolvedConfig struct {
@@ -58,6 +121,8 @@ type ResolvedConfig struct {
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Tools ToolsConfig
}
type MCPConfig struct {
@@ -81,6 +146,8 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Providers []ProviderProfile `json:"providers"`
MaxTurns int `json:"maxTurns"`
MCP MCPConfig `json:"mcp"`
Sandbox SandboxConfig `json:"sandbox"`
Tools ToolsConfig `json:"tools"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
@@ -93,6 +160,8 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Providers = raw.Providers
cfg.MaxTurns = raw.MaxTurns
cfg.MCP = raw.MCP
cfg.Sandbox = raw.Sandbox
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 +209,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 +243,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 +267,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{} }
+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)
}
})
}
}
+23 -4
View File
@@ -67,9 +67,9 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := exec.CommandContext(ctx, node, wrapperPath, "--version")
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out reporting missing native binary: %v; output: %s", ctx.Err(), output)
@@ -98,9 +98,9 @@ func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
t.Fatalf("WriteFile native fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := exec.CommandContext(ctx, node, wrapperPath, "--version")
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
@@ -151,6 +151,25 @@ func requireNode(t *testing.T) string {
return node
}
func nodeWrapperCommand(ctx context.Context, node string, wrapperPath string, args ...string) *exec.Cmd {
commandArgs := append([]string{wrapperPath}, args...)
command := exec.CommandContext(ctx, node, commandArgs...)
// Keep wrapper behavior independent from developer or runner Node settings
// such as --inspect-brk, which would block this smoke test.
command.Env = append(os.Environ(), "NODE_OPTIONS=")
return command
}
func nodeWrapperTimeout() time.Duration {
if runtime.GOOS == "windows" {
// Windows CI runners cold-start node slowly under load; 30s intermittently
// timed out with empty output (a flake, not a wrapper bug — the same run
// passes in ~2.5s on a warm runner), so give the spawn ample headroom.
return 90 * time.Second
}
return 10 * time.Second
}
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
+63
View File
@@ -0,0 +1,63 @@
// Package observability provides dependency-free crash capture for the CLI: a
// recovered panic is written to a local crash report (timestamp, label, stack)
// and surfaced to the user as a brief notice instead of a raw stack trace. It is
// the fail-open foundation for remote crash/metrics reporting — a Sentry/OTEL
// adapter can hook the same Recover/report path when configured — without
// pulling those dependencies into the base build.
package observability
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"time"
)
// crashExitCode is returned when a top-level panic is recovered.
const crashExitCode = 1
// FormatCrashReport renders a human-readable crash report.
func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time) string {
return fmt.Sprintf("zero crash report\ntime: %s\nlabel: %s\npanic: %v\n\nstack:\n%s\n",
ts.UTC().Format(time.RFC3339), label, recovered, stack)
}
// WriteCrashReport writes a crash report file into dir and returns its path.
func WriteCrashReport(dir, label string, recovered any, stack []byte, ts time.Time) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log")
if err := os.WriteFile(path, []byte(FormatCrashReport(label, recovered, stack, ts)), 0o600); err != nil {
return "", err
}
return path, nil
}
// DefaultCrashDir is where crash reports are written by default.
func DefaultCrashDir() string {
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".zero", "crashes")
}
return filepath.Join(os.TempDir(), "zero-crashes")
}
// Recover is deferred at a top-level entrypoint. On a panic it captures the
// stack, writes a crash report under dir, prints a brief notice to stderr, and
// sets *code to a crash exit code. It is fail-open: if the report can't be
// written it still reports the crash with the stack inline. No panic escapes.
func Recover(dir, label string, stderr io.Writer, code *int) {
recovered := recover()
if recovered == nil {
return
}
stack := debug.Stack()
if path, err := WriteCrashReport(dir, label, recovered, stack, time.Now()); err == nil {
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
} else {
fmt.Fprintf(stderr, "zero crashed: %v\n%s\n", recovered, stack)
}
*code = crashExitCode
}
+65
View File
@@ -0,0 +1,65 @@
package observability
import (
"bytes"
"os"
"strings"
"testing"
"time"
)
func TestWriteAndFormatCrashReport(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 6, 8, 10, 30, 0, 0, time.UTC)
path, err := WriteCrashReport(dir, "cli", "boom", []byte("goroutine 1 [running]:\nmain.x()"), ts)
if err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read report: %v", err)
}
report := string(data)
for _, want := range []string{"boom", "cli", "2026-06-08T10:30:00Z", "goroutine 1"} {
if !strings.Contains(report, want) {
t.Fatalf("report missing %q:\n%s", want, report)
}
}
}
func TestRecoverCapturesPanic(t *testing.T) {
dir := t.TempDir()
var stderr bytes.Buffer
code := 0
func() {
defer Recover(dir, "test", &stderr, &code)
panic("kaboom")
}()
if code != crashExitCode {
t.Fatalf("exit code = %d, want %d", code, crashExitCode)
}
if !strings.Contains(stderr.String(), "zero crashed") || !strings.Contains(stderr.String(), "kaboom") {
t.Fatalf("missing crash notice: %q", stderr.String())
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Fatalf("expected one crash report written, got %d", len(entries))
}
}
func TestRecoverNoPanicIsNoop(t *testing.T) {
dir := t.TempDir()
var stderr bytes.Buffer
code := 7
func() {
defer Recover(dir, "test", &stderr, &code)
}()
if code != 7 {
t.Fatalf("code changed without a panic: %d", code)
}
if stderr.Len() != 0 {
t.Fatalf("unexpected output without a panic: %q", stderr.String())
}
}
+28 -15
View File
@@ -114,11 +114,14 @@ type LoadedPlugin struct {
Skills []PathExtension `json:"skills"`
Hooks []HookExtension `json:"hooks"`
// Optional, additive metadata (omitempty so existing plugins are unchanged).
Author PluginAuthor `json:"author,omitempty"`
License string `json:"license,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Homepage string `json:"homepage,omitempty"`
Interface PluginInterface `json:"interface,omitempty"`
// Author/Interface are pointers: a non-pointer struct is never "empty" to
// encoding/json, so omitempty would still emit `author:{}` / `interface:{}`
// and change the serialized form of plugins that don't set them.
Author *PluginAuthor `json:"author,omitempty"`
License string `json:"license,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Homepage string `json:"homepage,omitempty"`
Interface *PluginInterface `json:"interface,omitempty"`
}
type LoadResult struct {
@@ -371,30 +374,38 @@ func ParseManifest(raw any, options ParseManifestOptions) (LoadedPlugin, error)
// parseAuthor reads optional authorship metadata. It is intentionally tolerant:
// an absent, non-object, or partially-typed value yields zero/empty fields so it
// never fails an otherwise-valid manifest.
func parseAuthor(raw any) PluginAuthor {
func parseAuthor(raw any) *PluginAuthor {
obj, ok := raw.(map[string]any)
if !ok {
return PluginAuthor{}
return nil
}
return PluginAuthor{
author := PluginAuthor{
Name: optionalMetaString(obj["name"]),
Email: optionalMetaString(obj["email"]),
URL: optionalMetaString(obj["url"]),
}
if author.Name == "" && author.Email == "" && author.URL == "" {
return nil
}
return &author
}
// parseInterface reads optional presentation metadata, tolerating missing keys.
func parseInterface(raw any) PluginInterface {
func parseInterface(raw any) *PluginInterface {
obj, ok := raw.(map[string]any)
if !ok {
return PluginInterface{}
return nil
}
return PluginInterface{
iface := PluginInterface{
DisplayName: optionalMetaString(obj["displayName"]),
Category: optionalMetaString(obj["category"]),
BrandColor: optionalMetaString(obj["brandColor"]),
DefaultPrompts: coerceMetaStringSlice(firstNonNil(obj["defaultPrompts"], obj["defaultPrompt"])),
}
if iface.DisplayName == "" && iface.Category == "" && iface.BrandColor == "" && len(iface.DefaultPrompts) == 0 {
return nil
}
return &iface
}
// optionalMetaString returns a trimmed string for additive metadata fields, or
@@ -416,8 +427,10 @@ func coerceMetaStringSlice(raw any) []string {
}
values := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
values = append(values, strings.TrimSpace(text))
if text, ok := item.(string); ok {
if trimmed := strings.TrimSpace(text); trimmed != "" {
values = append(values, trimmed)
}
}
}
if len(values) == 0 {
@@ -483,8 +496,8 @@ func formatPluginMetadata(plugin LoadedPlugin) []string {
return meta
}
func formatAuthor(author PluginAuthor) string {
if author.Name == "" {
func formatAuthor(author *PluginAuthor) string {
if author == nil || author.Name == "" {
return ""
}
if author.Email != "" {
+5 -5
View File
@@ -135,8 +135,8 @@ func TestParseManifestWithoutOptionalMetadataLeavesZeroValues(t *testing.T) {
if err != nil {
t.Fatalf("ParseManifest returned error: %v", err)
}
if plugin.Author != (PluginAuthor{}) {
t.Fatalf("author should be zero value, got %#v", plugin.Author)
if plugin.Author != nil {
t.Fatalf("author should be nil when absent, got %#v", plugin.Author)
}
if plugin.License != "" || plugin.Homepage != "" {
t.Fatalf("license/homepage should be empty, got %q / %q", plugin.License, plugin.Homepage)
@@ -144,8 +144,8 @@ func TestParseManifestWithoutOptionalMetadataLeavesZeroValues(t *testing.T) {
if len(plugin.Keywords) != 0 {
t.Fatalf("keywords should be empty, got %#v", plugin.Keywords)
}
if plugin.Interface.DisplayName != "" || plugin.Interface.Category != "" || plugin.Interface.BrandColor != "" || len(plugin.Interface.DefaultPrompts) != 0 {
t.Fatalf("interface should be zero value, got %#v", plugin.Interface)
if plugin.Interface != nil {
t.Fatalf("interface should be nil when absent, got %#v", plugin.Interface)
}
}
@@ -157,7 +157,7 @@ func TestFormatListSurfacesOptionalMetadata(t *testing.T) {
Version: "0.1.0",
Enabled: true,
Source: SourceUser,
Author: PluginAuthor{Name: "OpenAI"},
Author: &PluginAuthor{Name: "OpenAI"},
License: "MIT",
Keywords: []string{"automation", "macos"},
}}, nil)
+285
View File
@@ -0,0 +1,285 @@
package providercatalog
import (
"errors"
"fmt"
"strings"
"unicode"
)
type Transport string
const (
TransportOpenAI Transport = "openai"
TransportAnthropic Transport = "anthropic"
TransportGoogle Transport = "google"
TransportBedrock Transport = "bedrock"
TransportVertex Transport = "vertex"
TransportOpenAICompatible Transport = "openai-compatible"
TransportOpenAICompat Transport = TransportOpenAICompatible
TransportAnthropicCompatible Transport = "anthropic-compatible"
TransportAnthropicCompat Transport = TransportAnthropicCompatible
)
type APIFormat string
const (
APIFormatOpenAIResponses APIFormat = "responses"
APIFormatOpenAIChatCompletions APIFormat = "chat-completions"
APIFormatAnthropicMessages APIFormat = "messages"
APIFormatGoogleGenerateContent APIFormat = "generate-content"
APIFormatBedrockConverse APIFormat = "bedrock-converse"
APIFormatVertexGenerateContent APIFormat = "vertex-generate-content"
)
var ErrUnknownProvider = errors.New("unknown provider")
type Descriptor struct {
ID string
Name string
Transport Transport
DefaultBaseURL string
DefaultModel string
AuthEnvVars []string
RequiresAuth bool
UsesAmbientAuth bool
Public bool
Local bool
SupportedAPIFormats []APIFormat
Aliases []string
}
func RuntimeSupported(descriptor Descriptor) bool {
switch descriptor.Transport {
case TransportOpenAI, TransportOpenAICompatible, TransportAnthropic, TransportAnthropicCompatible, TransportGoogle:
return true
default:
return false
}
}
func RuntimeUnsupportedReason(descriptor Descriptor) string {
if RuntimeSupported(descriptor) {
return ""
}
switch descriptor.Transport {
case TransportBedrock, TransportVertex:
return "native adapter not implemented yet"
default:
return "provider transport not implemented yet"
}
}
var descriptors = []Descriptor{
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
localOpenAI("ollama", "Ollama", "http://localhost:11434/v1", "llama3.1"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}),
openAICompat("groq", "Groq", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", []string{"GROQ_API_KEY"}),
openAICompat("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat", []string{"DEEPSEEK_API_KEY"}),
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
transportDescriptor("vertex", "Vertex AI", TransportVertex, "https://aiplatform.googleapis.com", "gemini-2.5-pro", []string{"GOOGLE_APPLICATION_CREDENTIALS"}, []APIFormat{APIFormatVertexGenerateContent}, true),
openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}),
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
openAICompat("custom-openai-compatible", "Custom OpenAI-compatible", "https://example.invalid/v1", "custom-model", []string{"OPENAI_API_KEY"}, "custom openai compatible"),
anthropicCompat("custom-anthropic-compatible", "Custom Anthropic-compatible", "https://example.invalid/anthropic", "custom-model", []string{"ANTHROPIC_API_KEY"}, "custom anthropic compatible"),
}
func All() []Descriptor {
copied := make([]Descriptor, 0, len(descriptors))
for _, descriptor := range descriptors {
copied = append(copied, cloneDescriptor(descriptor))
}
return copied
}
func IDs() []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
ids = append(ids, descriptor.ID)
}
return ids
}
func Get(id string) (Descriptor, bool) {
normalized := NormalizeID(id)
for _, descriptor := range descriptors {
if descriptor.ID == normalized {
return cloneDescriptor(descriptor), true
}
for _, alias := range descriptor.Aliases {
if NormalizeID(alias) == normalized {
return cloneDescriptor(descriptor), true
}
}
}
return Descriptor{}, false
}
func Require(id string) (Descriptor, error) {
normalized := NormalizeID(id)
descriptor, ok := Get(normalized)
if !ok {
return Descriptor{}, fmt.Errorf("%w %q", ErrUnknownProvider, normalized)
}
return descriptor, nil
}
func ListByTransport(transport Transport) []Descriptor {
normalized := Transport(NormalizeID(string(transport)))
items := make([]Descriptor, 0)
for _, descriptor := range descriptors {
if descriptor.Transport == normalized {
items = append(items, cloneDescriptor(descriptor))
}
}
return items
}
func ValidTransport(transport Transport) bool {
switch Transport(NormalizeID(string(transport))) {
case TransportOpenAI, TransportAnthropic, TransportGoogle, TransportBedrock, TransportVertex, TransportOpenAICompatible, TransportAnthropicCompatible:
return true
default:
return false
}
}
func ValidAPIFormat(format APIFormat) bool {
switch format {
case APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions, APIFormatAnthropicMessages, APIFormatGoogleGenerateContent, APIFormatBedrockConverse, APIFormatVertexGenerateContent:
return true
default:
return false
}
}
func NormalizeID(id string) string {
var builder strings.Builder
lastDash := false
for _, r := range strings.ToLower(strings.TrimSpace(id)) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
builder.WriteRune(r)
lastDash = false
default:
if builder.Len() > 0 && !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}
func openAI(id string, name string, baseURL string, model string, env []string, aliases ...string) Descriptor {
return Descriptor{
ID: id,
Name: name,
Transport: TransportOpenAI,
DefaultBaseURL: baseURL,
DefaultModel: model,
AuthEnvVars: env,
RequiresAuth: true,
SupportedAPIFormats: []APIFormat{APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions},
Aliases: aliases,
}
}
func anthropic(id string, name string, baseURL string, model string, env []string, aliases ...string) Descriptor {
return Descriptor{
ID: id,
Name: name,
Transport: TransportAnthropic,
DefaultBaseURL: baseURL,
DefaultModel: model,
AuthEnvVars: env,
RequiresAuth: true,
SupportedAPIFormats: []APIFormat{APIFormatAnthropicMessages},
Aliases: aliases,
}
}
func google(id string, name string, baseURL string, model string, env []string, aliases ...string) Descriptor {
return Descriptor{
ID: id,
Name: name,
Transport: TransportGoogle,
DefaultBaseURL: baseURL,
DefaultModel: model,
AuthEnvVars: env,
RequiresAuth: true,
SupportedAPIFormats: []APIFormat{APIFormatGoogleGenerateContent},
Aliases: aliases,
}
}
func localOpenAI(id string, name string, baseURL string, model string, aliases ...string) Descriptor {
descriptor := openAICompat(id, name, baseURL, model, nil, aliases...)
descriptor.RequiresAuth = false
descriptor.Local = true
return descriptor
}
func openAICompat(id string, name string, baseURL string, model string, env []string, aliases ...string) Descriptor {
return Descriptor{
ID: id,
Name: name,
Transport: TransportOpenAICompatible,
DefaultBaseURL: baseURL,
DefaultModel: model,
AuthEnvVars: env,
RequiresAuth: len(env) > 0,
SupportedAPIFormats: []APIFormat{APIFormatOpenAIChatCompletions},
Aliases: aliases,
}
}
func anthropicCompat(id string, name string, baseURL string, model string, env []string, aliases ...string) Descriptor {
return Descriptor{
ID: id,
Name: name,
Transport: TransportAnthropicCompatible,
DefaultBaseURL: baseURL,
DefaultModel: model,
AuthEnvVars: env,
RequiresAuth: len(env) > 0,
SupportedAPIFormats: []APIFormat{APIFormatAnthropicMessages},
Aliases: aliases,
}
}
func transportDescriptor(id string, name string, transport Transport, baseURL string, model string, env []string, formats []APIFormat, ambient bool) Descriptor {
return Descriptor{
ID: id,
Name: name,
Transport: transport,
DefaultBaseURL: baseURL,
DefaultModel: model,
AuthEnvVars: env,
RequiresAuth: len(env) > 0 || ambient,
UsesAmbientAuth: ambient,
SupportedAPIFormats: append([]APIFormat{}, formats...),
}
}
func cloneDescriptor(descriptor Descriptor) Descriptor {
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
return descriptor
}
+250
View File
@@ -0,0 +1,250 @@
package providercatalog
import (
"errors"
"reflect"
"strings"
"testing"
)
var expectedCatalogIDs = []string{
"openai",
"anthropic",
"google",
"ollama",
"lmstudio",
"openrouter",
"groq",
"deepseek",
"together",
"dashscope",
"moonshot",
"nvidia-nim",
"minimax",
"mistral",
"github",
"bedrock",
"vertex",
"xai",
"venice",
"xiaomi-mimo",
"bankr",
"zai",
"gitlawb-opengateway",
"atomic-chat",
"custom-openai-compatible",
"custom-anthropic-compatible",
}
func TestAllHasStableUniqueIDs(t *testing.T) {
descriptors := All()
if len(descriptors) != len(expectedCatalogIDs) {
t.Fatalf("All() returned %d descriptors, want %d", len(descriptors), len(expectedCatalogIDs))
}
seen := map[string]bool{}
for index, descriptor := range descriptors {
if descriptor.ID != expectedCatalogIDs[index] {
t.Fatalf("All()[%d].ID = %q, want %q", index, descriptor.ID, expectedCatalogIDs[index])
}
if seen[descriptor.ID] {
t.Fatalf("duplicate descriptor ID %q", descriptor.ID)
}
seen[descriptor.ID] = true
}
if !reflect.DeepEqual(IDs(), expectedCatalogIDs) {
t.Fatalf("IDs() = %#v, want %#v", IDs(), expectedCatalogIDs)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
t.Fatal("provider ID is required")
}
if descriptor.Name == "" {
t.Fatalf("provider %q should expose a display name", descriptor.ID)
}
if descriptor.Transport == "" {
t.Fatalf("provider %q should expose a transport", descriptor.ID)
}
if !ValidTransport(descriptor.Transport) {
t.Fatalf("provider %q has unknown transport %q", descriptor.ID, descriptor.Transport)
}
if descriptor.DefaultBaseURL == "" {
t.Fatalf("provider %q should expose a default base URL", descriptor.ID)
}
if descriptor.DefaultModel == "" {
t.Fatalf("provider %q should expose a default model", descriptor.ID)
}
if len(descriptor.SupportedAPIFormats) == 0 {
t.Fatalf("provider %q should expose at least one supported API format", descriptor.ID)
}
for _, format := range descriptor.SupportedAPIFormats {
if !ValidAPIFormat(format) {
t.Fatalf("provider %q has unknown API format %q", descriptor.ID, format)
}
}
}
if ValidTransport("missing") {
t.Fatal("ValidTransport should reject unknown transports")
}
if ValidAPIFormat("missing") {
t.Fatal("ValidAPIFormat should reject unknown API formats")
}
}
func TestRemoteProvidersDeclareAuthOrExplicitPublicAccess(t *testing.T) {
for _, descriptor := range All() {
if descriptor.Local {
continue
}
if descriptor.RequiresAuth && (len(descriptor.AuthEnvVars) > 0 || descriptor.UsesAmbientAuth) {
continue
}
if descriptor.Public && !descriptor.RequiresAuth {
continue
}
t.Fatalf("%s is remote but declares neither credential env vars, ambient auth, nor public access", descriptor.ID)
}
}
func TestLocalProvidersDoNotRequireAuth(t *testing.T) {
for _, id := range []string{"ollama", "lmstudio"} {
descriptor, err := Require(id)
if err != nil {
t.Fatalf("Require(%q) error = %v", id, err)
}
if !descriptor.Local {
t.Fatalf("%s Local = false, want true", id)
}
if descriptor.RequiresAuth {
t.Fatalf("%s RequiresAuth = true, want false", id)
}
if len(descriptor.AuthEnvVars) != 0 {
t.Fatalf("%s AuthEnvVars = %#v, want empty", id, descriptor.AuthEnvVars)
}
}
}
func TestLookupNormalizesIDsAndAliases(t *testing.T) {
cases := map[string]string{
" OpenAI ": "openai",
"Gemini": "google",
"lm-studio": "lmstudio",
"mini_max": "minimax",
"Moonshot": "moonshot",
"nvidia nim": "nvidia-nim",
"xiaomi mimo": "xiaomi-mimo",
"custom_openai_compatible": "custom-openai-compatible",
"custom--anthropic compatible": "custom-anthropic-compatible",
"GitLawb OpenGateway": "gitlawb-opengateway",
}
for input, want := range cases {
descriptor, ok := Get(input)
if !ok {
t.Fatalf("Get(%q) returned false", input)
}
if descriptor.ID != want {
t.Fatalf("Get(%q).ID = %q, want %q", input, descriptor.ID, want)
}
required, err := Require(input)
if err != nil {
t.Fatalf("Require(%q) error = %v", input, err)
}
if required.ID != want {
t.Fatalf("Require(%q).ID = %q, want %q", input, required.ID, want)
}
}
if normalized := NormalizeID("custom--anthropic compatible"); normalized != "custom-anthropic-compatible" {
t.Fatalf("NormalizeID() = %q, want custom-anthropic-compatible", normalized)
}
if _, ok := Get("unknown-provider"); ok {
t.Fatal("Get should reject unknown provider IDs")
}
if _, err := Require(" Not_A_Provider "); err == nil {
t.Fatal("Require should reject unknown provider IDs")
} else {
if !errors.Is(err, ErrUnknownProvider) {
t.Fatalf("Require error = %v, want ErrUnknownProvider", err)
}
if !strings.Contains(err.Error(), `unknown provider "not-a-provider"`) {
t.Fatalf("Require error = %q, want normalized provider ID", err.Error())
}
}
}
func TestListByTransportPreservesCatalogOrder(t *testing.T) {
cases := map[Transport][]string{
TransportOpenAI: {"openai"},
TransportAnthropic: {"anthropic"},
TransportGoogle: {"google"},
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
descriptors := ListByTransport(transport)
gotIDs := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
if descriptor.Transport != Transport(NormalizeID(string(transport))) {
t.Fatalf("ListByTransport(%q) returned provider %q with transport %q", transport, descriptor.ID, descriptor.Transport)
}
gotIDs = append(gotIDs, descriptor.ID)
}
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Fatalf("ListByTransport(%q) IDs = %#v, want %#v", transport, gotIDs, wantIDs)
}
}
if descriptors := ListByTransport("missing"); len(descriptors) != 0 {
t.Fatalf("ListByTransport(missing) returned %#v, want empty", descriptors)
}
if gotIDs := descriptorIDs(ListByTransport(TransportOpenAICompatible)); !reflect.DeepEqual(gotIDs, cases[TransportOpenAICompat]) {
t.Fatalf("ListByTransport(openai-compatible alias) IDs = %#v, want %#v", gotIDs, cases[TransportOpenAICompat])
}
if gotIDs := descriptorIDs(ListByTransport(TransportAnthropicCompatible)); !reflect.DeepEqual(gotIDs, cases[TransportAnthropicCompat]) {
t.Fatalf("ListByTransport(anthropic-compatible alias) IDs = %#v, want %#v", gotIDs, cases[TransportAnthropicCompat])
}
}
func TestReturnedDescriptorsAreCopies(t *testing.T) {
descriptors := All()
descriptors[0].ID = "changed"
descriptors[0].AuthEnvVars[0] = "BROKEN"
descriptors[0].SupportedAPIFormats[0] = "broken"
descriptor, ok := Get("openai")
if !ok {
t.Fatal("Get(openai) returned false")
}
if descriptor.ID != "openai" {
t.Fatalf("catalog entry mutated through All(): %#v", descriptor)
}
if descriptor.AuthEnvVars[0] != "OPENAI_API_KEY" {
t.Fatalf("descriptor auth env vars are shared, got %q", descriptor.AuthEnvVars[0])
}
if descriptor.SupportedAPIFormats[0] != APIFormatOpenAIResponses {
t.Fatalf("descriptor API formats are shared, got %q", descriptor.SupportedAPIFormats[0])
}
descriptor.AuthEnvVars[0] = "BROKEN-AGAIN"
next, ok := Get("openai")
if !ok {
t.Fatal("Get(openai) returned false on second lookup")
}
if next.AuthEnvVars[0] != "OPENAI_API_KEY" {
t.Fatalf("descriptor slices are shared, got %q", next.AuthEnvVars[0])
}
}
func descriptorIDs(descriptors []Descriptor) []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
ids = append(ids, descriptor.ID)
}
return ids
}
@@ -44,9 +44,16 @@ func TestStreamCompletionNormalStopReasonHasNoFinishReason(t *testing.T) {
})
events := collectProviderEvents(t, provider)
var sawDone bool
for _, e := range events {
if e.Type == zeroruntime.StreamEventDone && e.FinishReason != "" {
t.Fatalf("normal stop leaked FinishReason %q", e.FinishReason)
if e.Type == zeroruntime.StreamEventDone {
sawDone = true
if e.FinishReason != "" {
t.Fatalf("normal stop leaked FinishReason %q", e.FinishReason)
}
}
}
if !sawDone {
t.Fatalf("no done event; events: %+v", events)
}
}
+140
View File
@@ -0,0 +1,140 @@
package anthropic
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// captureRequestBody runs one StreamCompletion against a stub server that
// records the decoded JSON request body, then returns it for assertions.
func captureRequestBody(t *testing.T, request zeroruntime.CompletionRequest) map[string]any {
t.Helper()
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
t.Cleanup(server.Close)
provider, err := New(Options{APIKey: "sk-ant", BaseURL: server.URL, Model: "claude-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), request)
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if gotBody == nil {
t.Fatal("server did not capture a request body")
}
return gotBody
}
// firstUserContentBlocks returns the content blocks of the first user message.
func firstUserContentBlocks(t *testing.T, body map[string]any) []any {
t.Helper()
messages, ok := body["messages"].([]any)
if !ok || len(messages) == 0 {
t.Fatalf("messages = %#v, want at least one", body["messages"])
}
user, ok := messages[0].(map[string]any)
if !ok || user["role"] != "user" {
t.Fatalf("first message = %#v, want a user message", messages[0])
}
blocks, ok := user["content"].([]any)
if !ok {
t.Fatalf("user content = %#v, want a blocks array", user["content"])
}
return blocks
}
// TestUserTextOnlyTurnUnchanged pins the text-only wire shape: a single
// user message whose content is a one-element text-block array.
func TestUserTextOnlyTurnUnchanged(t *testing.T) {
body := captureRequestBody(t, zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "Describe this."},
},
})
blocks := firstUserContentBlocks(t, body)
if len(blocks) != 1 {
t.Fatalf("text-only blocks = %#v, want exactly one", blocks)
}
text := blocks[0].(map[string]any)
if text["type"] != "text" || text["text"] != "Describe this." {
t.Fatalf("text-only block = %#v, want a text block", text)
}
}
// TestUserImagePlusTextTurn asserts a text block followed by one image
// source block carrying base64 of the RAW bytes.
func TestUserImagePlusTextTurn(t *testing.T) {
raw := []byte{0x89, 0x50, 0x4e, 0x47, 0x01, 0x02}
body := captureRequestBody(t, zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{
Role: zeroruntime.MessageRoleUser,
Content: "Describe this.",
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: raw}},
},
},
})
blocks := firstUserContentBlocks(t, body)
if len(blocks) != 2 {
t.Fatalf("image+text blocks = %#v, want text then image", blocks)
}
text := blocks[0].(map[string]any)
if text["type"] != "text" || text["text"] != "Describe this." {
t.Fatalf("first block = %#v, want a text block", text)
}
image := blocks[1].(map[string]any)
if image["type"] != "image" {
t.Fatalf("second block = %#v, want an image block", image)
}
source := image["source"].(map[string]any)
if source["type"] != "base64" || source["media_type"] != "image/png" {
t.Fatalf("image source = %#v, want base64 png", source)
}
if source["data"] != base64.StdEncoding.EncodeToString(raw) {
t.Fatalf("image data = %v, want base64 of raw bytes", source["data"])
}
}
// TestUserImageOnlyTurnEmits asserts an image-only user turn (empty Content)
// still produces a user message with a single image block.
func TestUserImageOnlyTurnEmits(t *testing.T) {
raw := []byte{0xff, 0xd8, 0xff, 0xe0}
body := captureRequestBody(t, zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{
Role: zeroruntime.MessageRoleUser,
Images: []zeroruntime.ImageBlock{{MediaType: "image/jpeg", Data: raw}},
},
},
})
messages := body["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("messages = %#v, want exactly one user turn for image-only", messages)
}
blocks := firstUserContentBlocks(t, body)
if len(blocks) != 1 {
t.Fatalf("image-only blocks = %#v, want a single image block", blocks)
}
image := blocks[0].(map[string]any)
if image["type"] != "image" {
t.Fatalf("image-only block = %#v, want an image block", image)
}
source := image["source"].(map[string]any)
if source["media_type"] != "image/jpeg" || source["data"] != base64.StdEncoding.EncodeToString(raw) {
t.Fatalf("image-only source = %#v, want base64 jpeg of raw bytes", source)
}
}
+94 -40
View File
@@ -1,8 +1,8 @@
package anthropic
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -26,14 +26,18 @@ const defaultStreamIdleTimeout = 90 * time.Second
// Options configures an Anthropic Messages API provider.
type Options struct {
APIKey string
BaseURL string
Model string
MaxTokens int
Version string
Beta string
HTTPClient *http.Client
UserAgent string
APIKey string
BaseURL string
Model string
MaxTokens int
Version string
Beta string
AuthHeader string
AuthScheme string
AuthHeaderValue string
CustomHeaders map[string]string
HTTPClient *http.Client
UserAgent string
// StreamIdleTimeout aborts the stream if no data arrives for this long.
// Zero uses defaultStreamIdleTimeout.
StreamIdleTimeout time.Duration
@@ -47,6 +51,10 @@ type Provider struct {
maxTokens int
version string
beta string
authHeader string
authScheme string
authHeaderValue string
customHeaders map[string]string
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
@@ -81,6 +89,10 @@ func New(options Options) (*Provider, error) {
maxTokens: maxTokens,
version: version,
beta: strings.TrimSpace(options.Beta),
authHeader: strings.TrimSpace(options.AuthHeader),
authScheme: strings.TrimSpace(options.AuthScheme),
authHeaderValue: strings.TrimSpace(options.AuthHeaderValue),
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
streamIdleTimeout: idleTimeout,
@@ -115,24 +127,24 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
httpRequest, err := http.NewRequestWithContext(streamCtx, http.MethodPost, provider.baseURL+"/v1/messages", bytes.NewReader(body))
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider request error: " + err.Error())})
return
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("anthropic-version", provider.version)
if provider.apiKey != "" {
httpRequest.Header.Set("x-api-key", provider.apiKey)
}
if provider.beta != "" {
httpRequest.Header.Set("anthropic-beta", provider.beta)
}
if provider.userAgent != "" {
httpRequest.Header.Set("User-Agent", provider.userAgent)
}
response, err := provider.httpClient.Do(httpRequest)
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body, func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("anthropic-version", provider.version)
if provider.beta != "" {
request.Header.Set("anthropic-beta", provider.beta)
}
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
APIKey: provider.apiKey,
DefaultAuthHeader: "x-api-key",
AuthHeader: provider.authHeader,
AuthScheme: provider.authScheme,
AuthHeaderValue: provider.authHeaderValue,
CustomHeaders: provider.customHeaders,
})
}, 0)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
@@ -254,8 +266,13 @@ func (provider *Provider) emitDone(ctx context.Context, state *streamState, even
state.closeOpen(ctx, events)
if state.hasInputUsage || state.hasOutputUsage {
usage, err := zeroruntime.NormalizeUsage(zeroruntime.TokenUsage{
InputTokens: state.inputTokens,
OutputTokens: state.outputTokens,
// Anthropic reports input_tokens (uncached), cache_read, and
// cache_creation SEPARATELY. The runtime models the cached count as a
// SUBSET of total input (it clamps cached <= input), so report the full
// prompt size as InputTokens and the cache hits as CachedInputTokens.
InputTokens: state.inputTokens + state.cacheReadTokens + state.cacheCreationTokens,
CachedInputTokens: state.cacheReadTokens,
OutputTokens: state.outputTokens,
})
if err == nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: usage})
@@ -292,9 +309,21 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
Model: provider.model,
MaxTokens: provider.maxTokens,
Messages: messages,
System: system,
Stream: true,
}
// Prompt caching: send the (stable, per-run) system prompt as a cacheable text
// block so the system instructions + tool definitions are not re-billed on
// every turn. The cache_control breakpoint on the last system block covers the
// whole system prompt; the breakpoint on the last tool covers all tool defs.
// Cache hits show up as cache_read_input_tokens in the usage. Non-caching
// providers ignore the field, and Anthropic accepts an empty/omitted system.
if strings.TrimSpace(system) != "" {
mapped.System = []systemBlock{{
Type: "text",
Text: system,
CacheControl: &cacheControl{Type: cacheEphemeral},
}}
}
if len(request.Tools) > 0 {
mapped.Tools = make([]anthropicTool, 0, len(request.Tools))
for _, tool := range request.Tools {
@@ -304,6 +333,7 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
InputSchema: tool.Parameters,
})
}
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
}
return mapped, nil
}
@@ -354,8 +384,22 @@ func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, er
}
mapped = append(mapped, anthropicMessage{Role: "assistant", Content: messageContent})
default:
blocks := []map[string]any{}
if hasContent {
appendUserBlocks(&mapped, []map[string]any{{"type": "text", "text": content}})
blocks = append(blocks, map[string]any{"type": "text", "text": content})
}
for _, image := range message.Images {
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": image.MediaType,
"data": base64.StdEncoding.EncodeToString(image.Data),
},
})
}
if len(blocks) > 0 {
appendUserBlocks(&mapped, blocks)
}
}
}
@@ -411,11 +455,11 @@ func parseErrorMessage(body []byte) string {
}
func (provider *Provider) classifiedError(statusCode int, message string) string {
return providerio.ClassifiedError(statusCode, message, provider.apiKey)
return providerio.ClassifiedError(statusCode, message, provider.apiKey, provider.authHeaderValue)
}
func (provider *Provider) redact(message string) string {
return providerio.Redact(message, provider.apiKey)
return providerio.Redact(message, provider.apiKey, provider.authHeaderValue)
}
func firstNonEmpty(values ...string) string {
@@ -434,13 +478,15 @@ type toolBlock struct {
}
type streamState struct {
tools map[int]toolBlock
inputTokens int
outputTokens int
hasInputUsage bool
hasOutputUsage bool
finishReason string // normalized terminal stop reason (empty for normal stop)
done bool
tools map[int]toolBlock
inputTokens int
outputTokens int
cacheReadTokens int // prompt-cache hits (cheap, re-billed reads)
cacheCreationTokens int // tokens written to the cache this turn
hasInputUsage bool
hasOutputUsage bool
finishReason string // normalized terminal stop reason (empty for normal stop)
done bool
}
// mapStopReason maps Anthropic's message_delta stop_reason onto the runtime's
@@ -462,6 +508,14 @@ func (state *streamState) recordUsage(usage usage) {
state.inputTokens = usage.InputTokens
state.hasInputUsage = true
}
if usage.CacheReadInputTokens != 0 {
state.cacheReadTokens = usage.CacheReadInputTokens
state.hasInputUsage = true
}
if usage.CacheCreationInputTokens != 0 {
state.cacheCreationTokens = usage.CacheCreationInputTokens
state.hasInputUsage = true
}
if usage.OutputTokens != 0 {
state.outputTokens = usage.OutputTokens
state.hasOutputUsage = true
+87 -3
View File
@@ -83,8 +83,14 @@ func TestStreamCompletionPostsMessagesRequest(t *testing.T) {
if gotBody["model"] != "claude-test" || gotBody["stream"] != true || gotBody["max_tokens"] != float64(64_000) {
t.Fatalf("unexpected model/stream/max_tokens: %#v", gotBody)
}
if gotBody["system"] != "You are Zero." {
t.Fatalf("system = %#v, want system prompt", gotBody["system"])
// Prompt caching: system is sent as a cacheable text block, not a bare string.
system := gotBody["system"].([]any)
sysBlock := system[0].(map[string]any)
if sysBlock["type"] != "text" || sysBlock["text"] != "You are Zero." {
t.Fatalf("unexpected system block: %#v", gotBody["system"])
}
if cc, _ := sysBlock["cache_control"].(map[string]any); cc["type"] != "ephemeral" {
t.Fatalf("system block must carry ephemeral cache_control, got %#v", sysBlock["cache_control"])
}
messages := gotBody["messages"].([]any)
if len(messages) != 3 {
@@ -106,9 +112,56 @@ func TestStreamCompletionPostsMessagesRequest(t *testing.T) {
t.Fatalf("unexpected tool result: %#v", toolResultBlocks[0])
}
tools := gotBody["tools"].([]any)
if tools[0].(map[string]any)["input_schema"].(map[string]any)["type"] != "object" {
lastTool := tools[len(tools)-1].(map[string]any)
if lastTool["input_schema"].(map[string]any)["type"] != "object" {
t.Fatalf("unexpected tool schema: %#v", tools)
}
// The last tool carries the cache breakpoint so the whole tool block is cached.
if cc, _ := lastTool["cache_control"].(map[string]any); cc["type"] != "ephemeral" {
t.Fatalf("last tool must carry ephemeral cache_control, got %#v", lastTool["cache_control"])
}
}
func TestStreamCompletionAppliesCustomAuthAndHeaders(t *testing.T) {
var gotDefaultAuth string
var gotCustomAuth string
var gotTenant string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotDefaultAuth = r.Header.Get("x-api-key")
gotCustomAuth = r.Header.Get("Authorization")
gotTenant = r.Header.Get("X-Tenant")
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-ant",
BaseURL: server.URL,
Model: "claude-test",
AuthHeader: "Authorization",
AuthScheme: "Bearer",
CustomHeaders: map[string]string{"X-Tenant": "zero"},
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if gotDefaultAuth != "" {
t.Fatalf("x-api-key = %q, want empty when custom auth header is used", gotDefaultAuth)
}
if gotCustomAuth != "Bearer sk-ant" {
t.Fatalf("Authorization = %q, want bearer token", gotCustomAuth)
}
if gotTenant != "zero" {
t.Fatalf("X-Tenant = %q, want custom header", gotTenant)
}
}
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
@@ -132,6 +185,37 @@ func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
}
}
func TestStreamCompletionReportsCacheTokens(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_read_input_tokens":200,"cache_creation_input_tokens":40}}}`)
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`)
writeSSEEvent(w, "message_delta", `{"type":"message_delta","usage":{"output_tokens":5}}`)
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
})
var usage *zeroruntime.Usage
for _, event := range collectProviderEvents(t, provider) {
if event.Type == zeroruntime.StreamEventUsage {
u := event.Usage
usage = &u
}
}
if usage == nil {
t.Fatal("expected a usage event")
}
// InputTokens is the full prompt (uncached + cache_read + cache_creation);
// CachedInputTokens is the cache-hit subset.
if usage.CachedInputTokens != 200 {
t.Fatalf("CachedInputTokens = %d, want 200", usage.CachedInputTokens)
}
if usage.InputTokens != 250 {
t.Fatalf("InputTokens = %d, want 250 (10 input + 200 cache_read + 40 cache_creation)", usage.InputTokens)
}
if usage.OutputTokens != 5 {
t.Fatalf("OutputTokens = %d, want 5", usage.OutputTokens)
}
}
func TestStreamCompletionEmitsToolUseBlocks(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"read_file","input":{}}}`)
+28 -5
View File
@@ -4,9 +4,12 @@ type messagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
// System is either a plain string or a []systemBlock when prompt caching is
// enabled (Anthropic accepts both). The block form lets us attach a
// cache_control breakpoint to the stable system prompt.
System any `json:"system,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
}
type anthropicMessage struct {
@@ -14,10 +17,28 @@ type anthropicMessage struct {
Content any `json:"content"`
}
// systemBlock is a single text block of the system prompt. A cache_control
// breakpoint on a block caches everything up to and including it.
type systemBlock struct {
Type string `json:"type"`
Text string `json:"text"`
CacheControl *cacheControl `json:"cache_control,omitempty"`
}
// cacheControl marks a prompt-cache breakpoint. Only "ephemeral" is supported.
type cacheControl struct {
Type string `json:"type"`
}
const cacheEphemeral = "ephemeral"
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema,omitempty"`
// CacheControl on the LAST tool caches the whole (stable) tool-definition
// block so it is not re-billed every turn.
CacheControl *cacheControl `json:"cache_control,omitempty"`
}
type streamPayload struct {
@@ -49,8 +70,10 @@ type streamDelta struct {
}
type usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
}
type apiError struct {
+35 -19
View File
@@ -30,30 +30,42 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
switch resolved.providerKind {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
return openai.New(openai.Options{
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
})
case config.ProviderKindAnthropic:
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return anthropic.New(anthropic.Options{
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
})
case config.ProviderKindGoogle:
return gemini.New(gemini.Options{
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
})
default:
return nil, fmt.Errorf("unsupported provider kind %q", resolved.providerKind)
@@ -107,6 +119,10 @@ func resolveProfile(profile config.ProviderProfile, options Options) (resolvedPr
if !entry.AllowsProvider(modelregistry.ProviderOpenAICompatible) {
return resolvedProfile{}, fmt.Errorf("zero model %s belongs to %s, not %s", entry.ID, entry.Provider, modelregistry.ProviderOpenAICompatible)
}
} else if providerKind == config.ProviderKindAnthropicCompat {
if !entry.AllowsProvider(modelregistry.ProviderAnthropic) {
return resolvedProfile{}, fmt.Errorf("zero model %s belongs to %s, not %s", entry.ID, entry.Provider, providerKind)
}
} else if providerKind != modelProvider {
return resolvedProfile{}, fmt.Errorf("zero model %s belongs to %s, not %s", entry.ID, entry.Provider, providerKind)
}
+39
View File
@@ -55,6 +55,45 @@ func TestNewCreatesOpenAIProviderWithFactoryOptions(t *testing.T) {
}
}
func TestNewThreadsCustomProviderHeaders(t *testing.T) {
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
}
client := &http.Client{Transport: transport}
provider, err := New(config.ProviderProfile{
Name: "gateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://gateway.example/v1",
APIKey: "sk-gateway",
AuthHeader: "X-API-Key",
AuthScheme: "Token",
CustomHeaders: map[string]string{"HTTP-Referer": "https://zero.dev"},
Model: "gateway-model",
}, Options{HTTPClient: client})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request.Header.Get("Authorization") != "" {
t.Fatalf("Authorization = %q, want empty when custom auth header is set", transport.request.Header.Get("Authorization"))
}
if transport.request.Header.Get("X-API-Key") != "Token sk-gateway" {
t.Fatalf("X-API-Key = %q, want custom auth header", transport.request.Header.Get("X-API-Key"))
}
if transport.request.Header.Get("HTTP-Referer") != "https://zero.dev" {
t.Fatalf("HTTP-Referer = %q, want custom provider header", transport.request.Header.Get("HTTP-Referer"))
}
}
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
provider, err := New(config.ProviderProfile{
Name: "openai",
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more