Compare commits

..
Author SHA1 Message Date
Kevin CodexandClaude Fable 5 adb3350ea5 fix(agent): branch on a dedicated-summarizer flag instead of comparing providers
Review follow-ups for #532:

- summarizeProvider now reports whether the dedicated summarizer was
  selected and summarizeClosure branches on that flag; the previous
  `chosen == provider` compared Provider interface values, which panics
  when the dynamic type is not comparable. Regression test uses a provider
  with a slice field for both the summarizer and the main provider.
- CompactionModelID's curated-default test covers the official OpenAI
  profile alongside Anthropic and Google.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq
2026-08-25 20:49:40 +08:00
Kevin Codex 865bd5c22b perf(agent): cheap compaction — dedicated summarizer model, input caps, free reactive prune, window re-derive
Compaction summarization was the single most expensive recurring event
in a long run: the full elided middle — verbatim tool bodies included —
went to the session's main model at full price (a ~100k-token call near
a 200k window, recursively multiplied on overflow).

Four changes, each independently safe:

- Dedicated summarizer model. Compaction summaries route to a cheap
  model resolved as ZERO_COMPACTION_MODEL > preferences.compactionModel
  > a curated default on official endpoints (Haiku 4.5 / Gemini
  Flash-Lite / gpt-4.1-mini; custom-endpoint catalogs are unknowable so
  they get none). Built lazily on the first paid compaction; ANY
  failure permanently falls back to the main provider for the run, so a
  misconfigured summarizer can never break compaction. "main" at any
  level forces the old behavior.

- Summarizer input caps. renderTranscript clamps tool-result bodies to
  2,000 chars and tool-call arguments to 500 (head+tail, rune-safe) —
  the summary needs what was done and decided, not raw payloads. Cuts
  summarizer input 5-10x on tool-heavy sessions.

- Free reactive prune. recover() now runs the same prune-first stage as
  the proactive path: a context-limit error is first answered by
  pruning stale tool bodies at zero cost, without consuming the
  one-shot reactive budget; the paid summarizer only runs when a later
  error finds nothing left to prune.

- Window re-derivation on model switch. Options.ContextWindowFor
  (wired to the model registry in exec and the TUI) lets the loop
  re-derive the compaction threshold after escalate_model, closing the
  documented KNOWN LIMITATION where a switch to a smaller-window model
  could overflow and a larger one over-compacted.

## Rebase notes (2026-08-25, onto 2749447c)

Rebased across 214 commits. main now summarizes through the context planner
(#879) and switches models via turn sessions, so the agent side was reworked
rather than merged textually:

- Summarizer selection lives on compactionState and is resolved lazily per
  main model through summarizeWithPlanner (planner + trace preserved); a
  failing dedicated summarizer is marked broken and the same slice is
  retried once on the main provider.
- Options.Summarizer now receives the main model in force:
  `func(ctx, mainModelID string) (Provider, error)`, and may return
  (nil, nil) when no dedicated model applies. After a successful
  escalate_model switch the loop calls compactor.switchModel, which
  re-derives the window and re-resolves the summarizer — a run that started
  on the cheap model and escalated regains cheap summaries (review P2).
- recover()'s free prune stage runs before the one-shot reactive guard
  (pruning is free and cannot loop) and mirrors maybeCompact's two prune
  passes; the free retry is taken only when the pruned history fits the
  window the provider enforces (with a 5% estimate margin), otherwise the
  same call falls through to the paid summarizer. Gating on the 70%
  proactive threshold would have sent histories in the 70-95% band to the
  summarizer unnecessarily (review P1 and the follow-up question).
- ContextWindowFor is wired through the turn-session switch path; the
  planner's window is updated alongside the compactor's.

Tests: the PR's suite is ported to the new seams, plus insufficient-prune
fall-through, free prune after the budget is spent, window-vs-threshold
gating, and summarizer re-resolution across a model switch.
2026-08-25 20:37:14 +08:00
Kevin CodexandClaude Fable 5 2749447cdc fix(tools): make apply_patch tolerant and re-expose edit_file (#956)
* fix(tools): make apply_patch tolerant and re-expose edit_file

Head-to-head benchmarking against Grok Build on grok-4.6 (3 tasks × 3 runs)
showed Zero solving every task but spending 2.7× the tokens and 2.3× the
time on the multi-file feature task. Every one of the 20 apply_patch calls
in those runs failed, after which the model degraded to whole-file
write_file rewrites and read→write verification loops.

Root causes and fixes:

- The sandbox and the tool both rejected an absolute path that lies inside
  the workspace ("must stay inside the workspace"). Models routinely echo
  the absolute path read_file showed them. Absolute paths now flow through
  the normal workspace-scope validation (inside → allowed, outside → still
  denied); unified-diff headers are rewritten to a/ b/ relative form before
  git apply, and platform symlink prefixes (/var → /private/var) are
  normalised the same way the other file tools do.
- The structured parser demanded a byte-exact "*** Begin Patch" /
  "*** End Patch"; grok-4.6 writes "*** Begin Patch ***". Decorated marker
  spellings are accepted, and a unified range after "@@" ("-12,4 +12,6",
  optionally with a heading) no longer becomes a literal context search.
- Parse errors now carry a one-line format reminder so a failed call is
  recoverable in one retry instead of a format-guessing loop.
- edit_file (exact search/replace with uniqueness guard, CRLF and fuzzy
  fallbacks) is advertised to the model again alongside apply_patch, and
  the apply_patch description shows the structured format.
- Writers (edit_file, write_file, apply_patch) recorded a file's line total
  with lineCount, which is one higher than read_file's count for a file
  ending in a newline. RecordSeenRange resets every observation when the
  total changes, so a partial read_file after a successful edit silently
  discarded whole-file knowledge and the next edit was refused with
  "content that has not been read exactly in this session". All writers
  now record the same total read_file reports (trackedLineTotal).
- read_file's padded "  N | " line prefix was ~19% of every read (about
  1.5k tokens per 800-line file) and was re-sent on every later call. It is
  now the compact "N→" prefix models already know; nothing parsed the old
  form.
- The editing-discipline prompt steers: edit_file for a targeted change,
  apply_patch for multi-hunk/multi-file changes, write_file only for new
  files or full rewrites; a successful edit result is confirmation, so the
  file is not re-read to verify it.

Tests: regression tests for decorated markers, writer/reader line-total
agreement (edit → partial read → edit far away still succeeds), range hunk headers,
absolute in-workspace paths (structured + unified), outside-workspace
rejection, header-line-only relativisation, and tool visibility; the
prompt and tool-budget tests updated to the new contract.

Benchmark (grok-4.6, 3 tasks x 3 runs, harness in demo/bench-zero-vs-grok):
Zero 0.8.0 617 s / 1,527k tokens -> with this change 346 s / 493k tokens;
Grok Build 407 s / 713k. Every run of every configuration solved its task.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): keep CRLF line endings when relativizing patch headers

relativizeUnifiedPatchPaths split on a normalized "\n" and rejoined with
"\n", so any CRLF patch that needed a header rewrite lost its line endings
everywhere; git apply could then fail to match a CRLF file. Split on "\n"
only and carry each line's trailing "\r" through, including on rewritten
headers.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(sandbox): share the structured-patch classifier with apply_patch

Review follow-ups for #956:

- The tool accepted decorated structured markers ("***Begin Patch") that
  the sandbox's applyPatchPaths did not recognise, so for that spelling the
  boundary scanned the patch as a unified diff, extracted no targets and
  validated nothing. The marker classifier now lives in the sandbox package
  (StructuredPatchMarker / IsStructuredPatch) and the tool delegates to it,
  so both sides accept exactly the same set; tests cover inside, traversal
  and outside targets for the no-space spelling at the boundary.
- Document the containment model at the apply site: structured patches are
  applied through an opened os.Root (descriptor-relative, no-follow);
  unified diffs keep the pre-existing git-apply pathname flow, which the
  absolute-path rewrite does not widen (they become the same root-relative
  form relative paths always used).
- OpenAI addendum: the one-file-per-call rule names read_file, edit_file and
  write_file explicitly and allows a single multi-file apply_patch.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(sandbox): compare slash-normalised patch paths so the check passes on Windows

structuredPatchHeaderPaths normalises separators to "/", so the absolute
outside-workspace path in the boundary test must be compared in its slash
form; on Windows the backslash original never matched and the Smoke job
failed.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(sandbox): validate the parsed patch path at the scope boundary

For each accepted marker spelling, pass the exact path applyPatchRequestPaths
produced to scope.validate: an outside path must be denied and an inside
path accepted, so a separator-handling regression between the parser and the
scope cannot slip past the test.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): apply unified diffs in-process through the workspace root

Review follow-up for #956. Unified diffs were validated by pathname and then
handed to git apply, which reopened the targets by name — a check-to-use
window a symlink swap could exploit — and the absolute-path rewrite added in
this PR normalised `diff --git` headers with strings.Fields, which splits a
git C-quoted path containing whitespace.

Both go away by not shelling out at all: parseUnifiedPatch translates a
unified diff into the same operations a structured patch produces, and both
formats are applied by applyPatchOperations through os.OpenRoot — every
stat, read, create and write is descriptor-relative and refuses to follow a
link out of the root. git is no longer required by apply_patch.

Unified-diff support: modifications, creations (--- /dev/null), deletions
(+++ /dev/null), multiple hunks per file, git "rename from/to" (a move,
hunks optional) and "copy from/to" (a new copy operation whose destination
inherits only the tracker credit the source had), "\ No newline at end of
file" on either side, a/ b/ prefixes, C-quoted paths, CRLF patches, and
git's diff/index/mode lines. Hunks are placed at their "@@ -a,b" range when
the expected lines match there and located by context otherwise (so a
stale range still applies and duplicate blocks are disambiguated by the
range). Binary patches are rejected.

Removed: the git apply flow, relativizeUnifiedPatchPaths,
recheckPatchWriteTargets, missingPatchTargets, completeCreatedPatchTargets
and recordCreatedPatchTargets. validatePatchPaths/changedFilesFromPatch stay
for mutation-target listing.

Tests: the existing unified-diff suite now runs through the in-process
applier unchanged; new coverage for quoted whitespace paths (inside applies,
outside is refused untouched), a workspace path swapped for a symlink that
escapes the root (both formats refused, outside file unmodified), create +
delete + no-newline markers, range hint vs context fallback, rename with a
hunk, and binary/empty rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): harden unified-diff parsing and cover the copy operation

Review follow-ups for #956 (in-process unified applier):

- A hunk that ends before its declared line counts is reported as
  malformed instead of silently absorbing the next file's ---/+++ header
  pair (which then failed later against the wrong file). Both shapes are
  caught: leftover counts at a header or at end of input, and a --- line
  directly followed by +++ inside a hunk. The element after a patch's final
  newline is no longer counted as an empty context line, so a truncated
  hunk cannot look complete.
- "\ No newline at end of file" may only follow a file's final hunk; a
  marker before another hunk is rejected rather than leaking into that
  hunk's result.
- Zero-context insertions (git diff -U0) are documented as
  position-trusting by design, matching git apply without fuzz; hunks with
  context are verified at the range before it is used.

Tests: copy from/to (source kept, destination created with the hunk
applied and tracked with the source's whole-file state; existing
destination refused untouched; copy onto itself refused), over-declared and
truncated hunk counts, and the no-newline marker before vs after the final
hunk.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): require an @@ line before treating ---/+++ inside a hunk as a file boundary

A removed "-- x" line directly followed by an added "++ y" line is valid
hunk content that also matches the ---/+++ header shape; the boundary check
now additionally requires the following line to be a hunk header, and a
regression test keeps such a pair intact. End-of-input strictness for a hunk
cut short is documented as deliberate: applying a truncated hunk would make
a partial change (removals without their replacements).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* refactor(tools): share one line-count implementation between reader and writer

sourceLineCount and trackedLineTotal computed the same count; keeping two
copies invites exactly the reader/writer drift this PR fixed. The reader now
delegates to trackedLineTotal, which carries the tracker contract.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): verify a unified deletion's expected content before removing the file

A unified diff's "+++ /dev/null" hunks state the content being removed, but
the in-process applier dropped them and deleted unconditionally, so a stale
deletion removed a file whose current content the patch never described.
Unified deletions now keep their hunks, require at least one, and run them
through the same verifier as updates: the hunks must match the current file
and leave nothing behind, otherwise the deletion is refused and the file is
untouched. A structured "*** Delete File" keeps its unconditional meaning.

Hardening from the same review: every non-create change re-reads its source
through the opened root immediately before commit and refuses to proceed if
the bytes differ from what was planned.

Tests: matching deletion, stale deletion (file byte-for-byte unchanged),
partial deletion, header-only deletion, multi-hunk deletion with one stale
hunk (nothing removed) and all-good hunks, structured delete unchanged, and
a source changed between planning and commit for update and delete.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tools): a move with a missing source is refused before publishing

The pre-commit recheck now refuses a move whose source vanished after
planning instead of publishing the destination and then failing; update the
test to the safer contract (error names the recheck, nothing is created).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): byte-exact unified deletions and git's header-only empty-file forms

Review follow-ups for #956:

- A unified deletion is verified byte for byte: the removed lines (with the
  removed side's "\ No newline at end of file" honoured) must equal the
  current file exactly, with only the file's own line-ending style
  normalised. None of the whitespace tolerance used to locate update hunks
  applies, and a whitespace-only remainder no longer passes; a deletion is
  not recoverable, so it fails closed.
- git describes an empty file's deletion or creation with header lines only
  ("diff --git", "deleted file mode"/"new file mode", "index") and no
  hunks. Both forms are recognised: the deletion is applied only when the
  current file is empty, the creation writes an empty file, and they compose
  with hunk-carrying files in the same patch.
- The pre-commit recheck test also covers the copy operation and asserts no
  destination is created on refusal.

Tests: whitespace/indentation/blank-line/space-only/missing-newline
mismatches all refused with the file unchanged, matching no-newline marker
and CRLF file accepted, header-only deletion of a non-empty file refused,
multi-file patch with header-only delete + create + a normal hunk.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tools): remove the move source after planning via a deterministic hook

The refused-move test now creates source.txt, plans the move, and removes
the file through a test-only pre-commit hook (structuredPatchBeforeCommit)
so it exercises the post-planning disappearance path rather than a source
that was never there. Assertions unchanged: refused before publishing, no
destination created.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* test(tui): read_file fixtures use the compact N→ prefix

Three TUI fixtures still built fake read_file output in the legacy
"  N | text" shape; they pass either way but pinned an outdated contract.
They now mirror what appendReadFileRange emits, and the shape comment and
the expanded-card assertion are updated.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

* fix(tools): name the committed files when a patch is partially applied

Review follow-up for #956. When a later change in a multi-file patch fails
(for example the pre-commit recheck refuses a file that changed after
planning), the error now lists exactly which files were already committed
and states that the rest are unchanged, instead of a generic "partially
applied" warning. The model can then re-read only what actually changed.

Test: a three-file structured patch whose second file is altered after
planning fails with "already committed: first.txt", first.txt holds the
patched content, and the other two files are untouched.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012JqvAf7zsP1qYFVZayKXHq

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-25 20:23:48 +08:00
github-actions[bot] ad34dc8d81 chore(main): release 0.8.0 (#896)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-22 00:08:57 +08:00
beardthelion 6edf9a8b78 fix(sandbox): normalize launcher names before the command-prefix denylist (#934)
* fix(sandbox): retire recorded approvals through a policy bump

Ahead of tightening which command prefixes may be approved, make the
upgrade path safe. A grant already on disk that the narrower rules
reject fails validation on read, and that path rejects the entire grants
file rather than the one entry. Every caller treats a read error as "no
grant" (engine.go LookupCommandPrefix, ApprovedCommandPrefixes, and the
Lookup in Evaluate), so one stale prefix would silently disable the
user's persisted deny grants too, with the error text never reaching
them.

Bump the approval policy version instead and let the store's existing
migration handle it: it backs the file up, keeps deny grants,
invalidates prior approvals, and reports the counts once through the
startup notice that both frontends already print. That is what the
mechanism was built for.

The v1-migration test pinned the policy version as a literal, so it now
derives the expected value from the constant and will not need editing
on the next bump.

* fix(sandbox): normalize launcher names before the command-prefix denylist

unsafeCommandPrefixLauncher matched the program name as written, so
python3.11, python2.7, python.exe, node.exe and git.exe all validated as
ordinary commands and could be persisted as command-prefix grants.
Lookup is a string-prefix match, so a single approval then auto-allowed
every later invocation: granting [python3.11 -c] covers
`python3.11 -c "import os; os.system(...)"` with no further prompt.

internal/agent's commandName already normalizes case and Windows
executable extensions for the allow side of the same decision; the deny
side did not, and the two disagreeing is the bug. Reduce the program to
the launcher it actually runs and match on that: executable extension,
Windows' trailing dots and spaces (python. starts python.exe), CPython
ABI flags, then a trailing version, testing the digit-stripped form as
well so python3.11 and python3 both arrive as python. A name is only
ever narrowed toward an existing entry, so the failure direction is an
extra permission prompt, never a silent grant. Digits without a
separator stay part of the name, which keeps base64, 7z and sha256sum
grantable.

Two Windows spellings cannot be resolved by name at all, so the shape is
refused alongside a path: 8.3 short names truncate the stem, which makes
POWERS~1.EXE unrecognizable, and python.exe::$DATA reaches the same
executable through its default stream. Normalizing the first token
before the banned-suggestion comparison keeps git.exe at parity with
git.

The list also gains launchers of the same class it already covers and
that normalization cannot reach: cmd, wsl, the remaining POSIX shells,
busybox, uv and uvx. Entries normalization now covers (/bin/bash,
/bin/zsh, powershell.exe, python3, pypy3) are gone; the first two were
already unreachable behind the path check above them.

Covers the persisted store, the session-scoped grants the agent takes
during a turn, the legacy-schema migration that prunes such a grant
per entry, and the Windows spellings hermetically.

* fix(sandbox): treat free-threaded CPython as a launcher too

The ABI flags came from the pre-3.13 set, so "python3.13t" and
"python3.13t.exe" normalized to themselves and stayed grantable as
command prefixes while the GIL build "python3.13" was refused. A
free-threaded interpreter runs arbitrary code just the same.

Add "t" to the flags trimmed under the existing digit guard, which is
what keeps ordinary names intact: "zstd" and "cat" have no digit beneath
the trimmed letters, so they keep their own names and stay grantable.

Reported by CodeRabbit on #934.

* fix(sandbox): refuse the remaining spellings of listed launchers

Same defect as the free-threaded build, found by sweeping the families
already on the list for names it cannot reach:

- nodejs is node on Debian and Ubuntu, and was grantable.
- python3-dbg, python3.11-dbg and python3.13t-dbg are interpreters that
  run arbitrary code; so are bash-static, pwsh-preview and
  node-nightly. A closed set of build-channel suffixes now normalizes
  them. The set stays closed deliberately: a general "-word" strip would
  also swallow node-gyp, python3-config and ruby-lsp, which are ordinary
  tools a user should still be able to approve, and those are pinned as
  must-stay-grantable.
- pnpm, yarn and bunx run package scripts exactly as npm and npx do, and
  npm and npx are already refused.
- sudoedit escalates like sudo.

The launcher-wrapper family (winpty, chroot, unshare, nsenter, parallel,
script, unbuffer, taskset, flock) has the same inconsistency against the
env/nohup/timeout/setsid entries already on the list, and is left for a
separate decision rather than widened here.

* test(sandbox): cover the -debug and -beta launcher suffixes

Four of the six build-channel suffixes had regression cases; -debug and
-beta did not, so removing either from the set left every test green.
Both now fail RED when their entry is dropped.

Reported by CodeRabbit on #934.
2026-08-21 12:50:11 +08:00
Indra Gunawan 90dcfd127e fix(modelregistry): expose reasoning effort for DeepSeek V4 models (#931)
deepseek-v4-flash / deepseek-v4-pro expose thinking-mode effort control
through the OpenAI-compatible reasoning_effort parameter (low/high/max;
medium/xhigh map onto high). reasoningEffortsForModelName did not recognize
the deepseek family, so /effort only offered 'auto' for these models.

Advertise {low, high, max} so the /effort picker and Ctrl+T ring work, and
add fallback coverage for deepseek-v4-flash/pro/reasoner/chat.
2026-08-21 12:48:12 +08:00
Vasanth T 01c3498205 feat(update): recognise Homebrew installs and add mise, go install docs (#910)
* feat(update): recognise Homebrew installs and add mise, go install docs

* fix(update): match the keg shape, correct the docs, cover Apply and cross-target

* test(update): build the release fixture from the running platform

* fix(update): require a Homebrew receipt before refusing self-update

Review found the keg check was shape-only, and it was right: a directory
tree a user built themselves can have exactly a keg's shape. Classifying
one as Homebrew refuses a `zero upgrade` that would have worked
perfectly, on a machine Homebrew has never touched.

Two changes, cheap one first. The shape rule now requires the full
<prefix>/Cellar/<formula>/<version>/bin/<binary> depth rather than one
segment fewer, which already excludes the exact path the review raised.
Then, only for a path that still looks like a keg, one Stat for
INSTALL_RECEIPT.json — the file Homebrew writes into every keg and
nothing else does.

The receipt is what the old comment argued against, on the grounds that
it would put filesystem I/O on a path that runs at every version check.
That is no longer true of it: the Stat happens only after the shape test
has already matched, so an ordinary install still reaches no filesystem
here at all.

A directory named INSTALL_RECEIPT.json is not a receipt, which a bare
Stat would have accepted silently.

NOT taken: the same review asked for the Homebrew branch to be moved
ahead of the cross-target one in upgradeGuidance. That ordering is
deliberate and already pinned by
TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew, whose comment
predicted exactly this reading — "it reads like an ordering bug until you
see which question is being answered". Homebrew is a property of the
binary on THIS machine; when the check was asked about a different
target the answer belongs to that other machine, and `brew upgrade zero`
would change this one instead. The reasoning is now recorded at the
branch as well as in the test, so the next reader finds it in both
places.

The install docs claimed detection was the Cellar path alone, which
undersold it, and stated the self-update rule absolutely in one paragraph
before excepting mise in the next. Both say the same thing now.

* test(update): give the Homebrew fixtures a receipt, and stop gating the receipt rule on the host

The previous commit made a receipt the thing that distinguishes a keg from any
directory sitting under a path containing "Cellar". Two fixtures still built a
keg without one, so detection correctly answered standalone and both failed:

  TestDetectInstallMethodRecognisesAHomebrewKeg  keg path: got "standalone", want "homebrew"
  TestApplyRefusesToUpdateAHomebrewKeg           refusal does not name the command that works:
                                                 download release archive: dial tcp: lookup example.test

The second is worth reading twice: with detection returning standalone, Apply
stopped refusing and went to the network, so the failure surfaced as a DNS error
rather than as a missing refusal. Both fixtures write a receipt now.

The reason this reached CI at all is the second half. Every test of the receipt
rule passed runtime.GOOS to isHomebrewPath, which already takes the target as a
parameter, so all of them skipped on Windows and the rule could only ever break
somewhere else. They pass an explicit target now and run everywhere. The two
that exercise DetectInstallMethod stay host-gated, because those are about the
wired entry point rather than the rule, but the rule underneath them is no
longer invisible here.

Verified the receipt path arithmetic on both non-Windows targets rather than
trusting the host-gated tests: isHomebrewPath is false before the receipt is
written and true after, with the receipt at the keg directory hasHomebrewReceipt
actually reads.
2026-08-21 12:33:21 +08:00
1ec7219aee feat: use Exa as default search provider (#926)
* feat: use Exa as default search provider

Amp-Thread-ID: https://ampcode.com/threads/T-01a01522-a5f1-7139-8a55-5e417cba2751
Co-authored-by: Amp <[email protected]>

* fix(config): carry a legacy default's disable across the Exa migration

A user who ran `zero mcp disable firecrawl` chose not to open that outbound
connection. Renaming the built-in default to exa re-opened it under the new
name on upgrade, and because the replacement looked like an untouched default
the startup warning stayed quiet too.

Carry an explicit user-level disable of a retired default onto its successor,
but only when the user never declared the successor themselves — an explicit
exa entry wins either way. The carried-over disable is recorded as a user-level
decision, so the lower-trust project layer cannot lift it while
`zero mcp enable exa` still can.

Regression coverage: the legacy-disabled upgrade path, the explicit-exa
override, a configured-but-enabled legacy entry, the CLI re-enable, the
project-layer re-enable attempt, and the resulting startup behavior
(NormalizeConfig must not hand back an exa server to connect to).

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

* fix(config): keep a retired default's transport for partial user entries

A user who added an API key to the firecrawl default wrote only the header —
the seeded default supplied the http type and URL. Once the default stops being
seeded that entry resolves with no type, url, or command, and NormalizeConfig
rejects it, taking down startup for every other server too rather than just
that one.

Record what Zero used to ship for a retired default and re-seed it under the
user's own fields. Only for an entry that named no transport of its own: an
entry with a self-hosted url or a stdio command is a complete definition the
user owns, and grafting the retired http default onto a command produces a
server that is rejected outright.

Regression coverage at both ResolveMCP and NormalizeConfig for the header-only
entry and its failure path, the self-hosted stdio entry that must be left
alone, and a disabled retired entry keeping a usable transport so a later
re-enable resolves.

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

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 20:26:24 +08:00
Eli Lap 1a81292d3f Fix LM studio (local) model discovery. (#918)
* Fix LM studio (local) model discovery.

* fix(openai): handle typed-nil properties in normalizeToolParameters

The `properties == nil` interface comparison misses typed-nil
`map[string]any` values, which JSON-serialize as `null` instead of
`{}`. Strict OpenAI-compatible servers (LM Studio) reject the request.

Type-assert first, then compare the underlying map, so a typed-nil
properties field is rewritten to an empty object. Adds a regression
test that exercises the typed-nil path.

* test(openai): cover non-map properties in normalizeToolParameters

CodeRabbit follow-up on the typed-nil fix: lock in the behavior
when `properties` is a non-map value (e.g., empty slice) so the
type-assert branch normalizes it to an empty object on the wire.
2026-08-20 20:26:03 +08:00
Vasanth T a78c6c357e fix(acp): three defects a desktop ACP client hits (#915)
* fix(acp): four defects a desktop ACP client hits

Found by driving `zero acp` from ZeroApp and comparing both sides of the
wire. None of them fail loudly — they produce a blank transcript, a
silent denial, a spurious crash, or two buttons nobody can tell apart.

session/load restored the conversation into the agent's own memory and
sent nothing. The turns were read from the store and used to build every
later prompt, but not one notification went out, so a client that resumed
opened an empty transcript against a model that remembered everything.
Ask a follow-up and ZERO answers correctly, about a question no longer on
screen. It now replays the loaded turns as user/agent message chunks
before returning, so a client rendering on the reply already has them.
Whole messages rather than per-token deltas: this is a transcript, not a
stream.

A permission option we offered could not be accepted. The fallback list
for a request with no AvailableDecisions lived inside
buildPermissionOptions, while requestPermission validated the client's
answer against the raw field — so whenever ZERO did not enumerate, the
client was sent Allow and Reject and its reply was checked against an
empty slice. Every button it could possibly show failed closed to deny
with "permission option was not offered". The user clicked Allow and ZERO
recorded a denial, and nothing surfaced it, because a denial is a
legitimate answer. One resolver now feeds both, so they agree by
construction. The narrowing this validation exists for is unchanged: an
unoffered broader grant is still refused.

Cancelling a permission prompt came back as an internal error. Only
context.Canceled was recognised, so errPermissionApprovalCanceled fell
through to -32603 carrying the sentinel text, and clients render that as
a failed turn — declining a tool looked like ZERO falling over. For
apply_patch, dismissing the dialog is the only refusal a client is
offered. It maps to StopCancelled now. The sentinel is exported so the
surfaces can recognise it; a genuine failure is still an error.

request_permissions labelled two different decisions "Allow". The label
is the only thing an ACP client shows, and plain allow and strict-review
allow are offered together, so the panel had two identical buttons where
one silently enabled strict auto-review of what was granted. The
distinction cannot live in the option id, which is opaque to the client,
so it is in the name.

Eleven tests, each confirmed to fail without its fix.

* refactor(acp): drop the session/load replay, superseded by #914

gnanam's #914 lands the same fix and does more with it: the replayed
messages carry stable ids derived from the store's event ids, session/load
replays while session/resume deliberately does not, and both are
capability-gated. Keeping a second, weaker replay here would have meant a
conflict in handleSessionLoad and translate.go for no gain.

translate.go is back to its state on main. What remains are the three
defects #914 does not touch: the permission option that could not be
accepted, the cancel that arrived as an internal error, and the two
options labelled the same.

* fix(acp): validate a permission reply against the options that were sent

CodeRabbit caught a hole on the same seam this PR already fixes, in the
other direction.

buildPermissionOptions drops PermissionDecisionCancel, because ACP
expresses cancellation through the outcome rather than an option. But
ZERO enumerates cancel in AvailableDecisions for shell commands and
apply_patch — the two commonest prompts — so validating against the
decisions accepted {"outcome":"selected","optionId":"cancel"}: an
identifier no client was ever shown, aborting the whole turn.

Rather than reject cancel by name, decisionFromOutcome now takes the
[]PermissionOption that were actually sent, and requestPermission builds
that list once and uses it for both halves. "What we offered" and "what we
accept" become the same slice instead of two things kept in step, so
anything optionKindFor declines to render is excluded structurally — the
next such action is covered the day it is added.

The regression test is written over every option-less decision rather
than over cancel specifically, for the same reason. Cancelling through
the outcome still works, which is asserted separately: closing the
selected-id route must not close the real one.

Also adds the cancellation coverage CodeRabbit asked for on the two
sandbox-retry paths. Both wrap ErrPermissionApprovalCanceled and each is
reached by a different denial, and the ACP mapping in this PR depends on
them producing something recognisable. They also assert the escalated
retry does not run: a cancelled prompt that still ran it would run the
command the user had just declined.
2026-08-20 20:24:02 +08:00
euxaristiaandeuxaristia 8dd33ec39e docs(agents): Record four recurring review blockers (#899)
* docs(agents): Record four recurring review blockers

These classes come up across unrelated pull requests, are not caught by CI,
and nothing in AGENTS.md currently prevents them, so each is rediscovered by a
reviewer at the cost of a round trip.

Host tool version floors is the clearest case. Zero shells out to git and the
project declares no minimum version anywhere, so adding a modern flag raises
that floor silently and an older host fails with a usage error instead of a
clear message. Prove-the-test-fails addresses a recurring shape where the
problem is not a missing test but one that passes either way, because an
earlier guard rejects the input before the behavior under test runs. Hermetic
tests covers isolation helpers applied to some tests but not others reaching
the same storage. Platform error codes extends the existing platform-truth
rule, which covers path canonicalization but not errno and NTSTATUS values.

Advisory lint findings are deliberately not included: deadcode and lint-static
both run with continue-on-error in ci.yml, and section 3 already requires
fixing findings the change introduces.

Refs #898

* docs(agents): Tighten version-floor, isolation, and errno guidance

Three corrections from review, two of them to claims that were too strong.

Git version floors are per subcommand, not per flag. --end-of-options reached
the shared parser in 2.24 but git rev-parse only accepted it from 2.30, so
recording the flag's general debut would still let a caller ship something
that fails on 2.24 through 2.29.

The hermetic-tests bullet named config, cache, and state but gave a procedure
covering only config. It now covers each root, including %LocalAppData% for
the cache root, which os.UserCacheDir resolves without consulting XDG.

The errno bullet asserted ENOTDIR on Linux and Darwin. Only Linux was actually
measured; Darwin was assumed, in a bullet whose whole point is not to assume.
It now says the call may report either code and both should be accepted, which
is what the guidance was trying to teach.

Refs #898

* docs(agents): Clarify macOS and Windows roots for hermetic test isolation.

os.UserConfigDir and os.UserCacheDir derive from HOME on macOS and AppData/LocalAppData on Windows rather than reading XDG variables.

Refs #899

---------

Co-authored-by: euxaristia <[email protected]>
2026-08-20 20:23:31 +08:00
Anandan d7ac85cbf9 Polish Zero's TUI and harden patch, model, and background workflows (#916)
* Checkpoint UI polish

* Fix structured apply patch handling

* Restore tool previews when resuming sessions

* Improve theme-aware code rendering

* Replace persistent sidebar with run details

* Remove inert transcript interactions

* Cover resumed edit previews

* Smooth active run status feedback

* Preserve semantic colors behind overlays

* Normalize ChatGPT model aliases

* Render staged image attachments in terminal

* Polish transcript and tool feedback

* Resolve rebased diff renderer conflict

* Address UI polish review findings

* Fix smoke test regressions

* Harden patch and transcript edge cases

* Bind structured patches to workspace root

* Use unique structured patch temp files

* Retry structured patch temp allocation

* Reuse rooted patch temp allocator

* Fix Windows CI process checks

* Fix structured patch safety boundaries

Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.

* Resolve plan test seam after rebase

Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.

* Address final review correctness gaps

Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.

* Harden patch transactions and review edges

Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.

* Bind patch rollback to committed versions

Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.

* Align structured patch failure semantics

Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.

* Preserve patch context bytes

Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
2026-08-19 22:18:47 +05:30
160 changed files with 9607 additions and 2613 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.7.0"}
{".":"0.8.0"}
+39
View File
@@ -111,3 +111,42 @@ These classes drive multi-round reviews. Fix them before requesting review:
shipped. Wire advertised entry points or shrink the claim. Do not bundle
unrelated fixes. Do not widen security allowlists by name alone; check
classification and side effects too.
- **Host tool version floors:** Zero shells out to `git` and other host tools,
and the project does not declare a minimum version for any of them. A flag,
subcommand, or config key that is not long-established therefore raises that
floor silently, and the failure on an older host is an unhelpful usage error
rather than a clear message. Before adding one, check when it landed and say
so where a caller would look. Record the floor for the exact subcommand you
call, not for the flag in general: `--end-of-options` reached Git's shared
parser in 2.24, but `git rev-parse` only accepted it from 2.30, and
`git checkout` and `git reset` later still. Likewise `--show-current` is
`git branch` 2.22, and `branch.autoSetupMerge=inherit` is 2.35. Then either
keep a fallback for older versions or gate the call. A test that depends on a
newer feature must skip below its floor instead of failing, and say which
version it needs.
- **Prove the test fails without the change:** A regression test earns its
place only if it fails on the unfixed code, for the reason it claims. Run it
both ways and quote the failure in the PR. The recurring miss is not a
missing test but a test that never reaches the behavior it names, because an
earlier guard rejects the input first, so it passes with and without the fix.
When a test targets a specific layer, call that layer directly rather than
relying on a public entry point that may refuse earlier.
- **Hermetic tests:** Tests must not read or write the developer's real config,
cache, or state directories. Redirect every root the code under test
resolves, not just config. `os.UserConfigDir` and `os.UserCacheDir` read
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` only on Linux and the BSDs. On macOS
they derive from `HOME`, and on Windows from `%AppData%` and `%LocalAppData%`.
Setting the XDG variables alone still leaves a macOS run writing to the real
home, so set `HOME` as well, or take the directory as a parameter. Apply it to
every test that reaches the same storage, not only the one where it was first
needed; a test that passes on a clean machine and pollutes a real one is a
blocker.
- **Error codes vary by platform and by flag combination:** A check written
against the code one target returns can be silently inert on another while
still looking correct. `openat(O_DIRECTORY|O_NOFOLLOW)` on a symlink may
report `ENOTDIR` or `ELOOP` depending on the platform, because the kernel can
reject the directory mismatch before it ever resolves the link; accept both
rather than assuming the one POSIX names. On Windows, `\\?\` and `\\.\`
prefixes are not UNC paths, and an `NtCreateFile` information class must match
the struct actually passed. Confirm the code each supported target returns
instead of reasoning from the man page.
+21
View File
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
### Features
* **security:** path-jail primitive, credential-store locking, worktree git hardening ([#891](https://github.com/Gitlawb/zero/issues/891)) ([060d38c](https://github.com/Gitlawb/zero/commit/060d38ce716feb7c5b6657b082cd35cb8dda3d75))
* **update:** recognise Homebrew installs and add mise, go install docs ([#910](https://github.com/Gitlawb/zero/issues/910)) ([01c3498](https://github.com/Gitlawb/zero/commit/01c3498205e69d26e5f929225f9455b276444f39))
* use Exa as default search provider ([#926](https://github.com/Gitlawb/zero/issues/926)) ([1ec7219](https://github.com/Gitlawb/zero/commit/1ec7219aee7b2840481ab0905f131644e27d2a4e))
* **zerogit:** auto-create a conventional branch before push/pr on default branch ([#671](https://github.com/Gitlawb/zero/issues/671)) ([d065467](https://github.com/Gitlawb/zero/commit/d065467cd3ec3ed91c5dc60554c36fc4b4d6de4c))
### Bug Fixes
* **acp:** three defects a desktop ACP client hits ([#915](https://github.com/Gitlawb/zero/issues/915)) ([a78c6c3](https://github.com/Gitlawb/zero/commit/a78c6c357e1d21146d7cd2e8030a754e06f7f1f5))
* bump Go to 1.26.6 for stdlib vulnerability fixes ([#903](https://github.com/Gitlawb/zero/issues/903)) ([dc15e82](https://github.com/Gitlawb/zero/commit/dc15e822987d8d7de467c62c375518f435b74d8c))
* **daemon:** clean up child after startup timeout ([#774](https://github.com/Gitlawb/zero/issues/774)) ([9c29209](https://github.com/Gitlawb/zero/commit/9c292092a26c9092fd873d072b0af1b0d9d39ef0))
* **dictation:** redact API keys from streaming transcriber errors ([#852](https://github.com/Gitlawb/zero/issues/852)) ([64a783b](https://github.com/Gitlawb/zero/commit/64a783b08caa2c7b21f188033b9ed84a14e00e84))
* **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6))
* **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d))
* **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a))
## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
+1 -1
View File
@@ -33,4 +33,4 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to the documented Firecrawl endpoint — see the README).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
+16 -2
View File
@@ -296,6 +296,16 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
if errors.Is(err, context.Canceled) {
return StopCancelled, nil
}
// A CLIENT THAT CANCELLED A PERMISSION PROMPT DID NOT FAIL. Only
// context.Canceled was matched here, so dismissing a permission dialog —
// a deliberate action, and for apply_patch the only refusal a client is
// offered — came back as JSON-RPC -32603 carrying the internal sentinel
// text. Editors and the desktop app both render that as a crashed turn,
// so declining a tool looked like ZERO falling over. It is a
// cancellation, and StopCancelled is what ACP has for saying so.
if errors.Is(err, agent.ErrPermissionApprovalCanceled) {
return StopCancelled, nil
}
return "", err
}
if result.FinishReason == "length" {
@@ -311,10 +321,14 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
// session/request_permission request and maps the outcome back. Failure to reach
// the client fails closed to deny.
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
// Built ONCE and used for both halves. Sending one list and validating the
// reply against another is the defect this whole path had; see
// decisionFromOutcome.
options := buildPermissionOptions(req)
params := RequestPermissionParams{
SessionID: sessionID,
ToolCall: permissionToolCall(req),
Options: buildPermissionOptions(req),
Options: options,
}
var result RequestPermissionResult
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
@@ -323,7 +337,7 @@ func (a *Agent) requestPermission(ctx context.Context, sessionID string, req age
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission request failed: " + err.Error()}, nil
}
return decisionFromOutcome(result.Outcome, req.AvailableDecisions), nil
return decisionFromOutcome(result.Outcome, options), nil
}
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
+217
View File
@@ -0,0 +1,217 @@
package acp
// desktopinterop_test.go covers three defects found by driving the real `zero
// acp` binary from a desktop ACP client. Each is a case where ZERO and a
// conforming client disagreed about the wire, and none of them failed loudly —
// they produced a silent denial, a spurious crash, or two buttons the user
// could not tell apart.
//
// A fourth, session/load restoring history without replaying it, is fixed by
// #914 rather than here: that one also gives the replayed messages stable ids
// and keeps session/resume replay-free, which this did not.
import (
"errors"
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
// The list the options were BUILT from and the list the answer was CHECKED
// against were different whenever ZERO did not enumerate: the client was sent
// Allow and Reject, and its reply was validated against an empty slice, so
// every button it could possibly show failed closed to deny. The user clicked
// Allow and ZERO recorded a denial, with nothing on screen to say so.
func TestAnOfferedOptionIsAccepted(t *testing.T) {
// No AvailableDecisions: the fallback path, which is every permission event
// that is not a prompt.
req := agent.PermissionRequest{ToolName: "bash"}
options := buildPermissionOptions(req)
if len(options) == 0 {
t.Fatal("no options were offered")
}
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
buildPermissionOptions(req),
)
if string(decision.Action) != option.OptionID {
t.Fatalf("option %q was answered with %q (%s) — an offered option must be accepted",
option.OptionID, decision.Action, decision.Reason)
}
}
}
// The narrowing this validation exists for still holds: a client must not be
// able to return a broader grant than it was shown.
func TestAnOptionThatWasNotOfferedIsStillRefused(t *testing.T) {
req := agent.PermissionRequest{ToolName: "bash"}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAlwaysAllow)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("an unoffered broader grant was accepted as %q", decision.Action)
}
}
// The resolver is the single source of truth for both sides, which is what
// makes the two agree by construction rather than by being kept in step.
func TestOfferedDecisionsMatchWhatWasSent(t *testing.T) {
for _, req := range []agent.PermissionRequest{
{ToolName: "bash"},
{ToolName: "bash", AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
}},
} {
offered := buildPermissionOptions(req)
for _, option := range buildPermissionOptions(req) {
if !actionOffered(option.OptionID, offered) {
t.Fatalf("option %q was sent but is not in the offered set %v", option.OptionID, offered)
}
}
}
}
// ---- two options the user can tell apart ----
// request_permissions offers plain allow and strict-review allow together, and
// both were labelled "Allow". The label is the only thing an ACP client shows,
// so the panel presented two identical buttons where one silently enabled
// strict auto-review of what was granted.
func TestEveryOfferedOptionHasADistinctLabel(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
},
}
seen := map[string]string{}
for _, option := range buildPermissionOptions(req) {
if previous, clash := seen[option.Name]; clash {
t.Fatalf("options %q and %q share the label %q — a user cannot tell them apart",
previous, option.OptionID, option.Name)
}
seen[option.Name] = option.OptionID
}
}
// The two remain distinct decisions: the label changed, the round trip did not.
func TestStrictAllowStillRoundTrips(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
},
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAllowStrict)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionAllowStrict {
t.Fatalf("strict allow round-tripped as %q", decision.Action)
}
}
// ---- cancelling a permission is a cancellation, not a crash ----
// Only context.Canceled was recognised, so dismissing a permission dialog came
// back as JSON-RPC -32603 carrying the internal sentinel text. Clients render
// that as a failed turn, so declining a tool looked like ZERO falling over —
// and for apply_patch, dismissing is the ONLY refusal a client is offered.
func TestCancellingAPermissionEndsTheTurnAsCancelled(t *testing.T) {
canceled := fmt.Errorf("%w for apply_patch", agent.ErrPermissionApprovalCanceled)
reason, err := stopReasonFor(agent.Result{}, canceled)
if err != nil {
t.Fatalf("stopReasonFor returned an error for a cancellation: %v", err)
}
if reason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", reason, StopCancelled)
}
}
// A genuine failure must still be an error. Mapping too much to cancelled would
// hide real faults behind a clean ending, which is the opposite mistake.
func TestARealFailureIsStillAnError(t *testing.T) {
failure := errors.New("provider exploded")
if _, err := stopReasonFor(agent.Result{}, failure); !errors.Is(err, failure) {
t.Fatalf("stopReasonFor(%v) = %v, want the failure preserved", failure, err)
}
}
// The sentinel is matched through errors.Is, so it survives the wrapping every
// return site applies — each one adds the tool name.
func TestPermissionCancellationSurvivesWrapping(t *testing.T) {
if !errors.Is(fmt.Errorf("%w for bash", agent.ErrPermissionApprovalCanceled), agent.ErrPermissionApprovalCanceled) {
t.Fatal("a wrapped permission cancellation was not recognised")
}
if errors.Is(errors.New("something else"), agent.ErrPermissionApprovalCanceled) {
t.Fatal("an unrelated error was reported as a permission cancellation")
}
}
// A decision that never became an option cannot be selected.
//
// PermissionDecisionCancel is the case that exists today: optionKindFor drops
// it because ACP expresses cancellation through the outcome, yet ZERO
// enumerates it in AvailableDecisions for shell commands and apply_patch. While
// the reply was validated against the DECISIONS rather than the sent options, a
// client could return {"outcome":"selected","optionId":"cancel"} — an id it was
// never shown — and abort the whole turn.
//
// Written over every option-less decision rather than over "cancel", so a
// future action that optionKindFor declines to render is covered the day it is
// added rather than the day someone remembers this test.
func TestADecisionThatIsNotAnOptionCannotBeSelected(t *testing.T) {
for _, toolName := range []string{"bash", "apply_patch"} {
req := agent.PermissionRequest{
ToolName: toolName,
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
agent.PermissionDecisionCancel,
},
}
options := buildPermissionOptions(req)
for _, action := range req.AvailableDecisions {
if actionOffered(string(action), options) {
continue // it was sent; selecting it is legitimate
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(action)},
options,
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("%s: %q was never sent as an option but was accepted as %q",
toolName, action, decision.Action)
}
}
}
}
// Cancelling is still reachable — through the outcome, which is where ACP puts
// it. Closing the selected-id route must not close the real one.
func TestCancellingThroughTheOutcomeStillWorks(t *testing.T) {
options := buildPermissionOptions(agent.PermissionRequest{ToolName: "apply_patch"})
decision := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, options)
if decision.Action != agent.PermissionDecisionCancel {
t.Fatalf("outcome=cancelled gave %q, want cancel", decision.Action)
}
}
+57 -18
View File
@@ -15,14 +15,7 @@ import (
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
actions := req.AvailableDecisions
if len(actions) == 0 {
// Sensible default if ZERO didn't enumerate: allow once / reject.
actions = []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
actions := offeredDecisions(req)
options := make([]PermissionOption, 0, len(actions))
for _, action := range actions {
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
@@ -38,6 +31,32 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
return options
}
// offeredDecisions resolves what this request actually offers the client.
//
// ONE RESOLVER, USED BY BOTH SIDES, and that is the entire point of it existing.
// The fallback below used to live inside buildPermissionOptions while
// requestPermission validated the client's answer against the raw
// req.AvailableDecisions. When ZERO did not enumerate — which is every
// permission event that is not a prompt, and includes a shell call whose sandbox
// decision came back deny — the two disagreed completely: the client was sent
// "Allow" and "Reject", and validation was performed against an EMPTY list, so
// every option the client could possibly click failed closed to deny with
// "permission option was not offered".
//
// The user clicked Allow and ZERO recorded a denial. Nothing surfaced it,
// because a denial is a legitimate answer — the tool was simply refused and the
// turn carried on as though the user had rejected it.
func offeredDecisions(req agent.PermissionRequest) []agent.PermissionDecisionAction {
if len(req.AvailableDecisions) > 0 {
return req.AvailableDecisions
}
// Sensible default if ZERO didn't enumerate: allow once / reject.
return []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
// human label. Returns an empty kind for actions that ACP expresses through the
// outcome rather than an option (cancel).
@@ -49,8 +68,16 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
// disclosure cannot be TUI-only.
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
switch action {
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
case agent.PermissionDecisionAllow:
return PermAllowOnce, "Allow"
case agent.PermissionDecisionAllowStrict:
// SPLIT FROM PLAIN ALLOW because the label is the only thing an ACP
// client shows. Both were labelled "Allow", and request_permissions
// offers them together — so the panel presented two identical buttons,
// and whichever the user picked looked the same while one of them
// silently turned on strict auto-review of what was granted. The option
// id carries no room for the distinction, so it has to be in the name.
return PermAllowOnce, "Allow with strict review"
case agent.PermissionDecisionAllowForSession:
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
@@ -70,19 +97,31 @@ func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind,
// decisionFromOutcome maps the client's permission outcome back to a ZERO
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
// action verbatim (validated against what was offered); anything unrecognized
// fails closed to deny.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
// action verbatim (validated against what was actually sent); anything
// unrecognized fails closed to deny.
//
// IT TAKES THE OPTIONS THAT WERE SENT, not the decisions the request offered,
// and the two are not the same list. Some actions never become options —
// PermissionDecisionCancel is dropped by optionKindFor because ACP expresses
// cancellation through the outcome instead — yet it IS enumerated in
// AvailableDecisions for shell commands and apply_patch, the two commonest
// prompts. Validating against the decisions therefore accepted
// {"outcome":"selected","optionId":"cancel"}: an identifier no client was ever
// shown, aborting the whole turn.
//
// Passing the sent options makes "what we offered" and "what we accept" the
// same slice rather than two things kept in step, so anything optionKindFor
// declines to render is excluded structurally instead of by name.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionOption) agent.PermissionDecision {
switch outcome.Outcome {
case OutcomeCancelled:
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
case OutcomeSelected:
action := agent.PermissionDecisionAction(outcome.OptionID)
// Bind to what was actually offered for THIS call: a client must not be able
// to return a broader grant (always_allow / allow_for_session) that wasn't
// presented. Anything not offered fails closed to deny.
if actionOffered(action, offered) {
return agent.PermissionDecision{Action: action}
if actionOffered(outcome.OptionID, offered) {
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
default:
@@ -90,9 +129,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.Permi
}
}
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
for _, a := range offered {
if a == action {
func actionOffered(optionID string, offered []PermissionOption) bool {
for _, option := range offered {
if option.OptionID == optionID {
return true
}
}
+3 -2
View File
@@ -56,10 +56,11 @@ func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
// what was offered, so decorating it would fail every prefix approval closed.
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
request := escalationRequest(true)
for _, option := range buildPermissionOptions(request) {
options := buildPermissionOptions(request)
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
request.AvailableDecisions,
options,
)
if string(decision.Action) != option.OptionID {
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
+8 -5
View File
@@ -39,11 +39,14 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
}
func TestDecisionFromOutcome(t *testing.T) {
offered := []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
}
offered := buildPermissionOptions(agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
},
})
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
t.Errorf("cancelled -> %q, want cancel", d.Action)
}
+37
View File
@@ -99,6 +99,43 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
}
}
// The launcher denylist is only worth anything if it holds on the path a user
// actually sees. A model naming its own prefix_rule is that path: before
// launcher names were normalized, "python3.11" was offered as a one-token
// grant, and hasStringPrefix then matched every later python3.11 invocation.
func TestProposedCommandPrefixRejectsLauncherSpellingsInRequestedPrefix(t *testing.T) {
for _, testCase := range []struct {
command string
rule []any
}{
{"python3.11 script.py", []any{"python3.11"}},
{"python.exe script.py", []any{"python.exe"}},
{"node.exe app.js", []any{"node.exe"}},
{"cmd /c whoami", []any{"cmd"}},
{"busybox sh -c id", []any{"busybox"}},
{"uv run main.py", []any{"uv", "run"}},
{"python3.11 -c import_os", []any{"python3.11", "-c"}},
} {
got := proposedCommandPrefix("bash", map[string]any{
"command": testCase.command,
"prefix_rule": testCase.rule,
})
if got != nil {
t.Errorf("prefix_rule %v for %q was offered as %#v, want rejected", testCase.rule, testCase.command, got)
}
}
}
func TestProposedCommandPrefixStillOffersOrdinaryCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "cargo build --release",
"prefix_rule": []any{"cargo", "build"},
})
if len(got) != 2 || got[0] != "cargo" || got[1] != "build" {
t.Fatalf("ordinary prefix rule = %#v, want [cargo build]", got)
}
}
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
cases := []string{
"cat < in > out",
+172 -11
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -390,6 +392,27 @@ type compactionState struct {
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
// (Options.Summarizer) for the current main model. summarizerProvider
// memoizes the built provider and summarizerResolved records that the
// factory ran (it may legitimately return no provider: the main model is
// already the cheap one). summarizerBroken is the sticky fallback: once a
// build or call fails, the run stays on the main provider until the next
// model switch — a misconfigured cheap model must never break (or
// repeatedly delay) compaction.
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
summarizerProvider Provider
summarizerResolved bool
summarizerBroken bool
// window is the context window the threshold was derived from. The
// reactive free-prune stage compares against it (the limit the provider
// enforces), not against the 70% proactive threshold — a pruned history
// between the two would still succeed on retry.
window int
// mainModel is the model the run currently streams on; the summarizer
// choice is made against it and remade after a mid-run switch.
mainModel string
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
@@ -424,17 +447,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
window: options.ContextWindow,
mainModel: options.Model,
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
summarizerFactory: options.Summarizer,
}
return state
}
// updateWindow re-derives the compaction threshold after a mid-run model
// switch. The low-water mark is reset: it was measured against the old
// threshold, and keeping it could suppress a compaction the smaller window
// now requires. <= 0 (unknown model) leaves everything unchanged.
func (state *compactionState) updateWindow(window int) {
if window <= 0 {
return
}
state.enabled = true
state.window = window
state.threshold = compactionThreshold(window)
state.lowWaterMark = 0
if state.planner != nil {
state.planner.config.contextWindow = window
}
}
// switchModel records a mid-run model switch: the window is re-derived
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
// the next compaction re-resolves it against the new main model. A run that
// started on the cheap model (no dedicated summarizer) and escalated to an
// expensive one therefore regains cheap summaries — the exact workflow a
// dedicated summarizer exists for.
func (state *compactionState) switchModel(modelID string, window int) {
state.mainModel = modelID
state.updateWindow(window)
state.summarizerProvider = nil
state.summarizerResolved = false
state.summarizerBroken = false
}
// summarizeProvider returns the provider summarization calls should use and
// whether it is the dedicated summarizer: that one when it applies to the
// current main model and is healthy, else the main provider. The factory runs
// at most once per main model; a build error marks the summarizer broken for
// that model. Callers branch on the boolean rather than comparing Provider
// values — interface comparison panics on a non-comparable dynamic type.
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
if state.summarizerFactory == nil || state.summarizerBroken {
return main, false
}
if !state.summarizerResolved {
state.summarizerResolved = true
built, err := state.summarizerFactory(ctx, state.mainModel)
if err != nil {
state.summarizerBroken = true
return main, false
}
state.summarizerProvider = built
}
if state.summarizerProvider == nil {
return main, false
}
return state.summarizerProvider, true
}
// fitsWindow reports whether a calibrated size is safely under the limit the
// provider enforces. The proactive threshold (70% of the window) is a trigger
// for the paid summarizer, not the hard limit; the reactive path compares
// against the window itself, with a small margin for estimate error, so a
// history pruned to, say, 85% of the window takes the free retry.
func (state *compactionState) fitsWindow(size int) bool {
if state.window <= 0 {
return size <= state.threshold
}
return size <= state.window-state.window/20
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
// when the history is under threshold, or when the history has not grown past
@@ -528,10 +622,34 @@ func (state *compactionState) recover(
// context-limit error never triggers an unexpected summarization call.
return messages, false, nil
}
if state.reactiveAttempted {
if !isContextLimitError(errorMessage) {
return messages, false, nil
}
if !isContextLimitError(errorMessage) {
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
// often be fixed for free by dropping superseded reads and pruning old
// tool-result bodies. It runs regardless of the one-shot reactive budget
// (it costs nothing and cannot loop: a second pass finds nothing left to
// prune). The free retry is taken only when the pruned history fits the
// window the provider enforces — otherwise a retry would fail the same
// way, so fall through to the paid summarizer in this same call.
toolTokens := estimateToolDefTokens(tools)
prunedAny := false
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if prunedAny {
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if state.fitsWindow(size) {
state.lowWaterMark = size
return messages, true, nil
}
}
if state.reactiveAttempted {
return messages, false, nil
}
@@ -571,11 +689,21 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
// The call goes to the dedicated (cheap) summarizer when one applies; if that
// call fails the summarizer is marked broken for the current model and the
// same slice is retried once on the main provider, so the caller sees at most
// one (main-provider-quality) error.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
return func(toSummarize []zeroruntime.Message) (string, error) {
chosen, dedicated := state.summarizeProvider(ctx, provider)
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
if err == nil || !dedicated {
return summary, err
}
state.summarizerBroken = true
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -659,8 +787,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
return summary, nil
}
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
// search results), yet the summary instructions only need their gist — the
// model that ACTED on the full output already turned it into decisions the
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
// head+tail of a result while cutting the summarizer input by 5-10x on
// tool-heavy sessions; the summarizer's own cost is the price being cut.
const summaryToolResultClamp = 2000
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
// a write_file call carries the whole file body in its arguments.
const summaryToolArgsClamp = 500
// clampForSummary head+tail-truncates text for the summarizer transcript,
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
func clampForSummary(text string, max int) string {
if max <= 0 || len(text) <= max {
return text
}
head := max * 3 / 4
tail := max - head
for head > 0 && !utf8.RuneStart(text[head]) {
head--
}
start := len(text) - tail
for start < len(text) && !utf8.RuneStart(text[start]) {
start++
}
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
}
// renderTranscript flattens messages into a plain-text transcript for the
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
// Tool-result bodies and tool-call arguments are clamped (see the constants
// above): the summary needs what was done and decided, not the raw payloads.
func renderTranscript(messages []zeroruntime.Message) string {
lines := make([]string, 0, len(messages))
for _, message := range messages {
@@ -670,13 +831,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
if len(message.ToolCalls) > 0 {
calls := make([]string, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
calls = append(calls, call.Name+"("+call.Arguments+")")
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
}
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
}
lines = append(lines, line)
case zeroruntime.MessageRoleTool:
lines = append(lines, "tool result: "+message.Content)
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
default:
lines = append(lines, string(message.Role)+": "+message.Content)
}
+469
View File
@@ -0,0 +1,469 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
// the proactive threshold for a 1000-token window.
func compactableHistory() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
}
}
// Summarization must run on the dedicated (cheap) summarizer when one is
// configured — the main provider sees no summarize traffic at all.
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
main := &mockProvider{}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return cheap, nil
},
}, nil)
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
if len(cheap.requests) == 0 {
t.Fatal("dedicated summarizer never received the summarize call")
}
if len(main.requests) != 0 {
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
}
if factoryCalls != 1 {
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
}
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
t.Fatal("compaction did not shrink the history")
}
found := false
for _, message := range compacted {
if strings.Contains(message.Content, "CHEAP SUMMARY") {
found = true
}
}
if !found {
t.Fatal("cheap summarizer's summary missing from the compacted history")
}
}
// A failing summarizer falls back to the main provider for the SAME slice and
// stays broken for the rest of the run — no repeated failed cheap calls.
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
summary, err := summarize(input)
if err != nil {
t.Fatalf("fallback must succeed via the main provider: %v", err)
}
if summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, want the main provider's", summary)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
}
if !state.summarizerBroken {
t.Fatal("a failed summarizer must be marked broken for the run")
}
// Second summarization: straight to main, no cheap retry.
if _, err := summarize(input); err != nil {
t.Fatal(err)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
}
if len(main.requests) != 2 {
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
}
}
// A factory that errors (misconfigured model) is equivalent to no summarizer:
// main provider used, factory not retried.
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return nil, errors.New("unknown model")
},
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
}
if factoryCalls != 1 {
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("after a factory failure the run must stay on the main provider")
}
if factoryCalls != 1 {
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
}
}
// Tool payloads are clamped in the summarizer transcript: results to
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
bigResult := strings.Repeat("r", 50_000)
bigArgs := strings.Repeat("w", 20_000)
transcript := renderTranscript([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "c1", Name: "write_file", Arguments: bigArgs},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
})
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
}
if !strings.Contains(transcript, "chars omitted for summarization") {
t.Fatal("clamped transcript must carry the omission marker")
}
if !strings.Contains(transcript, "keep going") {
t.Fatal("non-tool content must survive unclamped")
}
// Head AND tail of the clamped payloads survive.
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
}
}
// bigToolHistory returns a history with n large tool results (~40k estimated
// tokens each): the trailing protection window keeps the newest verbatim,
// the older ones are prunable.
func bigToolHistory(n int) []zeroruntime.Message {
bigBody := strings.Repeat("x", 160_000)
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
for i := 0; i < n; i++ {
id := string(rune('a' + i))
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
)
}
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
}
func summaryProvider(text string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
}}
}
// A context-limit error is first answered with the free prune stage; when the
// pruned history fits the window the retry is free (no summarizer call) and
// the one-shot reactive budget is untouched. The paid summarizer only runs
// when a later error finds nothing left to prune.
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
provider := summaryProvider("SUMMARY")
// The protected trailing tool output (~40k tokens) must fit the window.
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("prunable history that fits must produce a free retry")
}
if len(provider.requests) != 0 {
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
}
if state.reactiveAttempted {
t.Fatal("a free prune retry must not consume the reactive budget")
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// Second context-limit error on the pruned history: nothing meaningfully
// prunable remains, so the paid summarizer fires and consumes the budget.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("second recover must compact and retry")
}
if len(provider.requests) == 0 {
t.Fatal("second recover must reach the summarizer")
}
if !state.reactiveAttempted {
t.Fatal("a paid recover must consume the reactive budget")
}
}
// When pruning reclaims something but the pruned history still exceeds the
// window, recover falls through to the paid summarizer in the SAME call
// instead of spending a retry that would fail the same way.
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
provider := summaryProvider("SUMMARY")
// A 1,000-token window: even the protected trailing output overflows it.
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("insufficient prune must still end in a retry via summarization")
}
if len(provider.requests) == 0 {
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
}
if !state.reactiveAttempted {
t.Fatal("the paid summarizer consumes the reactive budget")
}
if estimateTokens(compacted) >= estimateTokens(messages) {
t.Fatal("recover did not shrink the history")
}
}
// The free prune stage is not gated behind the one-shot budget: after a paid
// reactive compaction, a later context-limit error still gets a free retry
// when pruning makes the history fit.
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
provider := summaryProvider("SUMMARY")
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
state.reactiveAttempted = true
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// With nothing left to prune and the budget spent, the caller keeps its
// original error — recover neither loops nor summarizes.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil || retried || len(provider.requests) != 0 {
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
}
}
// The free-retry gate compares against the window the provider enforces, not
// the 70% proactive threshold: a history pruned to ~80% of the window still
// takes the free retry.
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
provider := summaryProvider("SUMMARY")
messages := bigToolHistory(5)
pruned, _ := pruneStaleToolOutput(messages, 2)
prunedTokens := estimateTokens(pruned)
// Choose a window so the pruned size sits between 70% and 95% of it.
window := prunedTokens * 100 / 80
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
if prunedTokens <= state.threshold {
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
}
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
}
}
// After a mid-run model switch the summarizer is re-resolved against the new
// main model: a run that started on the cheap model (no dedicated summarizer)
// and escalated gets a cheap summarizer, and a previously broken one is
// retried for the new model.
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
cheap := summaryProvider("CHEAP SUMMARY")
main := summaryProvider("MAIN SUMMARY")
var factoryModels []string
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Model: "cheap-model",
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
factoryModels = append(factoryModels, mainModelID)
if mainModelID == "cheap-model" {
return nil, nil // main already is the cheap model
}
return cheap, nil
},
}, nil)
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("on the cheap model the main provider must summarize")
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
}
state.switchModel("big-model", 400_000)
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
}
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
}
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
}
// A broken summarizer is retried after the next switch.
state.summarizerBroken = true
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("a broken summarizer must fall back to main")
}
state.switchModel("other-model", 0)
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
}
}
func TestCompactionUpdateWindow(t *testing.T) {
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
state.lowWaterMark = 50_000
before := state.threshold
state.updateWindow(0) // unknown model: unchanged
if state.threshold != before || state.lowWaterMark != 50_000 {
t.Fatal("updateWindow(0) must be a no-op")
}
state.updateWindow(10_000)
if state.threshold >= before {
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
}
if state.lowWaterMark != 0 {
t.Fatal("low-water mark must reset on a window change")
}
if !state.enabled {
t.Fatal("a positive window must keep compaction enabled")
}
}
// End-to-end: after escalate_model switches providers, the loop re-derives the
// context window via ContextWindowFor, visible in the OnContext report.
func TestRunEscalationRederivesContextWindow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "big-model"})
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}}}
var windows []int
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
ContextWindow: 100_000,
Model: "small-model",
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
ContextWindowFor: func(modelID string) int {
if modelID == "big-model" {
return 400_000
}
return 0
},
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(windows) < 2 {
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
}
if windows[0] != 100_000 {
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
}
if windows[len(windows)-1] != 400_000 {
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
}
}
// nonComparableProvider has a slice field, so comparing two Provider interface
// values holding it would panic; the summarizer path must never compare them.
type nonComparableProvider struct {
inner *mockProvider
tags []string
}
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return p.inner.StreamCompletion(ctx, request)
}
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
}, nil)
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
}
// And with no dedicated summarizer at all the closure still works.
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
t.Fatal(err)
}
}
+34 -12
View File
@@ -40,7 +40,15 @@ const (
toolResultControlSpecReview = "spec_review_required"
)
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
// ErrPermissionApprovalCanceled ends a run because the caller cancelled a
// permission prompt, rather than because anything failed.
//
// EXPORTED FOR THE SURFACES. A cancel is a normal ending and each surface says
// so its own way — the TUI unwinds, and ACP has a "cancelled" stop reason for
// exactly this. While it was unexported ACP could not distinguish it from a
// fault and reported it as an internal error, so declining a tool looked like
// the agent crashing.
var ErrPermissionApprovalCanceled = errors.New("permission approval cancelled")
// isImageRejectionError reports whether err is a provider 400 that rejects
// image/multimodal content. This is checked BEFORE the compaction-retry path
@@ -836,11 +844,21 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs the switcher to also
// report the new window — out of scope for this change.
// Re-derive the context window for the new model so compaction and
// the OnContext budget report track the model actually in force —
// without this a switch to a smaller-window model can overflow the
// target, and a switch to a larger one over-compacts. An unknown
// model (<= 0) keeps the original window. The compactor also
// re-resolves its dedicated summarizer against the new model.
window := 0
if options.ContextWindowFor != nil {
window = options.ContextWindowFor(turnRequestedModel)
}
if window > 0 {
options.ContextWindow = window
planner.config.contextWindow = window
}
compactor.switchModel(turnRequestedModel, window)
}
}
}
@@ -1364,7 +1382,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, decisionReason)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
@@ -1548,7 +1566,7 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
@@ -1596,7 +1614,7 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
@@ -3087,13 +3105,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// registered, model-visible, not in DisabledTools, and advertised in the
// current permission mode (e.g. not spec-draft, where tool_search is not
// advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
tools.ModelVisible(loader) &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
@@ -3208,7 +3228,9 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
return tools.ModelVisible(tool) &&
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
ToolAdvertised(tool, permissionMode)
}
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
+93 -1
View File
@@ -1853,7 +1853,7 @@ func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
},
})
if !errors.Is(err, errPermissionApprovalCanceled) {
if !errors.Is(err, ErrPermissionApprovalCanceled) {
t.Fatalf("expected permission approval cancel error, got %v", err)
}
if result.FinalAnswer != "" {
@@ -4340,3 +4340,95 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
t.Fatalf("expected tool result to mention the beforeTool veto, got %q", combined)
}
}
// A cancelled permission on either sandbox-retry path must abort the run with a
// recognisable cancellation, and must not run the escalated retry.
//
// BOTH PATHS, because both wrap ErrPermissionApprovalCanceled and each is
// reached by a different kind of denial: one by an unrestricted platform denial
// asking for approval, the other by an external-network denial. Surfaces map
// this sentinel onto their own idea of a cancel — ACP returns stopReason
// "cancelled" for it — so a path that produced a bare error instead would show
// the user a crash where they had simply declined.
func TestCancellingASandboxRetryAbortsWithoutRetrying(t *testing.T) {
denial := func(capability execution.CapabilityKind, scope string) tools.Result {
return tools.Result{
Status: tools.StatusError,
ExecutionOutcome: &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: capability, Scope: scope},
Source: execution.DenialSourcePlatformSandbox,
Reason: "denied by the sandbox",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
},
},
}
}
for _, testCase := range []struct {
name string
result tools.Result
run func(context.Context, *tools.Registry, ToolCall, tools.Tool, map[string]any, tools.Result, Options) error
}{
{
name: "unsandboxed retry",
result: denial(execution.CapabilityUnrestricted, "host"),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, _, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
{
name: "network retry",
result: denial(execution.CapabilityExternalNetwork, ""),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, abortErr := maybeRetryWithNetworkAfterSandboxDenial(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
retryTool := &sandboxNamespaceLimitedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
asked := 0
options := Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: sandbox.DefaultPolicy(),
}),
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
asked++
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "client cancelled"}, nil
},
}
call := ToolCall{ID: "call-1", Name: "bash", Arguments: `{"command":"curl example.com"}`}
args := map[string]any{"command": "curl example.com"}
abortErr := testCase.run(context.Background(), registry, call, retryTool, args, testCase.result, options)
if asked != 1 {
t.Fatalf("permission was requested %d times, want exactly 1", asked)
}
if !errors.Is(abortErr, ErrPermissionApprovalCanceled) {
t.Fatalf("abort error = %v, want it to match ErrPermissionApprovalCanceled", abortErr)
}
// The escalated retry is the thing the permission was gating. A
// cancelled prompt that still ran it would have run the command the
// user just declined.
if len(retryTool.calls) != 0 {
t.Fatalf("the escalated retry ran %d times after a cancel: %+v", len(retryTool.calls), retryTool.calls)
}
})
}
}
+18
View File
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
}
func TestAgentAdvertisesBothEditTools(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
names := make(map[string]bool, len(exposed))
for _, definition := range exposed {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for multi-hunk changes")
}
if !names["edit_file"] {
t.Fatal("agent must receive edit_file for targeted exact replacements")
}
}
+14 -6
View File
@@ -52,7 +52,7 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
edit_file, apply_patch, write_file - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
@@ -61,10 +61,18 @@ work.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- For edits to existing files, use edit_file for a targeted change (old_string
must match the file exactly and be unique, so include a few surrounding
lines) and apply_patch when one coherent change spans several hunks or
files. Use write_file only to create a file or when most of it changes; do
not rewrite a whole file to change a few lines. Match the existing
indentation, imports, and idioms. Match the file's comment density: do not
add explanatory comments unless the user asks or the code is already
comment-dense.
- A successful edit result already confirms the change; do not re-read a file
just to verify an edit that succeeded. If an edit fails, read the error, fix
the old_string or hunk, and retry the same tool rather than switching to a
full rewrite.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
@@ -104,7 +112,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/edit_file/apply_patch for file changes.
with exec_command, or use edit_file/apply_patch/write_file for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+6 -2
View File
@@ -60,8 +60,12 @@ const openAIPromptAddendum = `<model_guidance>
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.
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python
for file work. read_file, edit_file and write_file take one file per call;
a single apply_patch may span several files when they form one coherent
change. Do not batch file writes into a script. Independent tool calls
(several reads, several edit_file calls to different files) belong in the
same turn.
- 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>`
+1 -1
View File
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"prefer edit_file or apply_patch",
"for edits to existing files, use edit_file",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
+20 -4
View File
@@ -348,10 +348,26 @@ type Options struct {
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// Summarizer, when set, lazily builds the provider used for compaction
// summarization calls — typically a cheap/fast model, since summaries at
// main-model prices are the single most expensive recurring event in a
// long run. It receives the main model currently in force and may return
// (nil, nil) when no dedicated summarizer applies to it (the main model is
// already the cheap one). Built on the first paid compaction and again
// after a mid-run model switch. Any failure (build or call) falls back to
// the run's main provider until the next switch, so a misconfigured
// summarizer can never break compaction. nil keeps today's behavior.
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
// ContextWindowFor, when set, resolves a model ID to its context window so
// the compactor can re-derive its threshold after a mid-run model switch
// (escalate_model). Without it a switch keeps compacting against the
// original model's window — overflowing a smaller target or over-compacting
// a larger one. Return <= 0 when the model is unknown (window unchanged).
ContextWindowFor func(modelID string) int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// FileTracker records per-session file read/write versions so the write tools
// can detect a file changed on disk outside Zero since it was last read. nil
// disables the check. Created once per session and threaded into every tool run.
+32 -1
View File
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
}
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
if err != nil {
return Task{}, false, err
var migrated bool
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
if !migrated {
return Task{}, false, err
}
changed = true
}
if outputFile != task.OutputFile {
changed = true
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
return path, nil
}
// migrateLegacyOutputFile recovers task metadata written under a previous home
// or XDG data root. It accepts only the canonical per-task output
// name in the historic zero/background layout, and only when the corresponding
// regular file already exists inside the active manager root.
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
requested = strings.TrimSpace(requested)
if !filepath.IsAbs(requested) {
return "", false
}
requested = filepath.Clean(requested)
expectedName := taskID + ".ndjson"
if filepath.Base(requested) != expectedName {
return "", false
}
legacyRoot := filepath.Dir(requested)
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
return "", false
}
canonical := filepath.Join(manager.rootDir, expectedName)
info, err := os.Lstat(canonical)
if err != nil || !info.Mode().IsRegular() {
return "", false
}
return canonical, true
}
func validTaskID(taskID string) bool {
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
}
+83
View File
@@ -1,6 +1,7 @@
package background
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
}
}
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
canonicalOutput := filepath.Join(root, taskID+".ndjson")
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: legacyOutput,
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal legacy metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write legacy metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
task, ok := manager.Get(taskID)
if !ok {
t.Fatal("manager did not load migrated task")
}
if task.OutputFile != canonicalOutput {
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
}
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
t.Fatalf("migration produced load warnings: %#v", warnings)
}
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
if err != nil {
t.Fatalf("read migrated metadata: %v", err)
}
var saved Task
if err := json.Unmarshal(persisted, &saved); err != nil {
t.Fatalf("decode migrated metadata: %v", err)
}
if saved.OutputFile != canonicalOutput {
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
}
}
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, ok := manager.Get(taskID); ok {
t.Fatal("manager loaded task with an unrelated absolute output path")
}
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
t.Fatalf("load warnings = %q, want containment rejection", warnings)
}
}
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
root := t.TempDir()
manager, err := NewManager(root)
+2 -1
View File
@@ -856,7 +856,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent. A built-in default the user never
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
// configured (e.g. keyless Exa with no credentials) is the exception: it
// was never asked for, so its failure is not worth a startup warning — only
// servers the user actually configured warn on failure (issue #552).
for _, skipped := range mcpRuntime.Skipped() {
@@ -948,6 +948,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
+3 -3
View File
@@ -211,12 +211,12 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"exa": config.DefaultMCPServers()["exa"],
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
{Name: "exa", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
@@ -227,7 +227,7 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stderr.String(), "firecrawl") {
if strings.Contains(stderr.String(), "exa") {
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
}
}
+14 -10
View File
@@ -682,16 +682,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
Summarizer: summarizerFactory(resolved, deps.newProvider),
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
+8 -8
View File
@@ -9,15 +9,15 @@ import (
"github.com/Gitlawb/zero/internal/config"
)
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
// `zero mcp disable exa` must work even though exa is a built-in
// default that is not written to the user's config file until overridden.
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
func TestRunMCPDisableSeededExaDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
// A config with no firecrawl entry — the default lives in code, not the file.
// A config with no Exa entry — the default lives in code, not the file.
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
var out, errBuf bytes.Buffer
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
code := runWithDeps([]string{"mcp", "disable", "exa", "--json"}, &out, &errBuf, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
if code != exitSuccess {
@@ -31,8 +31,8 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
}
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
if payload.ServerName != "exa" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want exa disabled+changed", payload)
}
// End-to-end: resolving that config now turns the default off.
@@ -40,7 +40,7 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
if !cfg.Servers["exa"].Disabled {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
}
}
+17
View File
@@ -0,0 +1,17 @@
package cli
import (
"context"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// summarizerFactory adapts the resolved profile and the authenticated provider
// builder into agent.Options.Summarizer; the selection rules live in
// providers.CompactionSummarizerFactory, shared with the TUI.
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
}
+15 -5
View File
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const providerCommandTestBudget = 60 * time.Second
const (
providerCommandTestBudget = 90 * time.Second
providerCommandReadyWaitAttempts = 45
)
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
if providerCommandTestBudget < 2*readyWaitCeiling {
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
}
}
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
// runner while leaving the other half of providerCommandTestBudget for
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
+98 -13
View File
@@ -5,22 +5,20 @@ import (
"strings"
)
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
// search and scraping work out of the box with no setup and no API key. They are
// seeded before user/project config is merged (see ResolveMCP), so a user can
// override any field — for example point firecrawl at a self-hosted instance, or
// add an API-key header to lift the free-tier limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
// use. Zero only calls it over the network, so Firecrawl's license never reaches
// into Zero's own code.
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"firecrawl": {
"exa": {
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
URL: "https://mcp.exa.ai/mcp",
},
}
}
@@ -35,13 +33,13 @@ func IsDefaultMCPServer(name string) bool {
// IsUnconfiguredDefault reports whether server is one of Zero's built-in
// defaults that the user never wrote an entry for in their config — i.e. it is
// running with whatever Zero ships (e.g. keyless Firecrawl, no credentials).
// running with whatever Zero ships (e.g. keyless Exa, no credentials).
//
// Both conditions below must hold:
// - !server.configured: the user's JSON never declared an object for this
// server key at all (set by MCPServerConfig.UnmarshalJSON only when it
// actually ran for this key). Any explicit action — including a
// disable/enable toggle like `zero mcp enable firecrawl` that leaves the
// disable/enable toggle like `zero mcp enable exa` that leaves the
// resolved value unchanged — sets configured, so it always counts as
// user-configured, even though the value comparison below could not tell
// the difference on its own.
@@ -58,3 +56,90 @@ func IsUnconfiguredDefault(name string, server MCPServerConfig) bool {
def, ok := DefaultMCPServers()[strings.TrimSpace(name)]
return ok && !server.configured && reflect.DeepEqual(def, server)
}
// retiredDefaultMCPServer describes a built-in default Zero no longer ships.
type retiredDefaultMCPServer struct {
// successor is the default that replaced it.
successor string
// shipped is the value Zero used to seed for this name. A user entry
// written while the default was still shipped may name only an override
// (a header, say) and rely on these fields for the rest, so they have to
// outlive the default itself.
shipped MCPServerConfig
}
// retiredDefaultMCPServers maps a retired built-in default to what replaced it
// and to the value Zero used to ship for it.
var retiredDefaultMCPServers = map[string]retiredDefaultMCPServer{
"firecrawl": {
successor: "exa",
shipped: MCPServerConfig{
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
},
},
}
// migrateRetiredDefaultMCPServers keeps an upgrade across a default-provider
// swap from changing what the user's config means. It handles the two ways a
// rename can break a config written against the old default:
//
// - A user who ran `zero mcp disable <old default>` made an explicit choice
// not to open that outbound connection. Without carrying that decision to
// the successor, the upgrade re-opens it under the new name — and because
// the replacement looks like an untouched default, even the startup
// warning stays quiet (see IsUnconfiguredDefault and issue #552).
// - A user who customized the old default could name only the field they
// were changing, because the seeded default supplied the transport. Once
// the default stops being seeded that entry resolves to a server with no
// type, url, or command, which fails NormalizeConfig and takes the whole
// startup down rather than just that one server.
//
// It runs immediately after the user layer merges (see ResolveMCP), which
// scopes it to user-level decisions — the only scope whose disable is sticky.
// The carried-over disable applies only when the user never declared the
// successor themselves: an explicit `exa` entry wins whether it enables or
// disables. Because the disable is recorded as a user-level decision, the
// lower-trust project layer cannot lift it, while `zero mcp enable exa` still
// can (the CLI override scope merges with canReenable=true).
func migrateRetiredDefaultMCPServers(cfg *MCPConfig) {
for name, retired := range retiredDefaultMCPServers {
entry, ok := cfg.Servers[name]
if !ok {
continue
}
if inheritsRetiredTransport(entry) {
// Re-seed what Zero used to ship underneath the user's fields, so a
// partial entry still resolves to the complete server it named when
// it was written. Their own values still win — this only fills the
// gap the retired default used to cover.
entry = mergeMCPServer(retired.shipped, entry, true)
cfg.Servers[name] = entry
}
if !entry.disabledSet || !entry.Disabled {
continue
}
replacement, ok := cfg.Servers[retired.successor]
if !ok || replacement.configured {
continue
}
replacement.Disabled = true
replacement.disabledSet = true
cfg.Servers[retired.successor] = replacement
}
}
// inheritsRetiredTransport reports whether entry named no transport of its own
// — no type, url, command, or args — and so relied entirely on the retired
// default to supply one.
//
// Anything else is a complete definition the user owns (a self-hosted url, a
// stdio command) and must be left alone: grafting the retired default's http
// type onto an entry that names a command produces a server that is rejected
// outright, turning a working config into a startup failure.
func inheritsRetiredTransport(entry MCPServerConfig) bool {
return strings.TrimSpace(entry.Type) == "" &&
strings.TrimSpace(entry.URL) == "" &&
strings.TrimSpace(entry.Command) == "" &&
len(entry.Args) == 0
}
+197 -36
View File
@@ -7,10 +7,10 @@ import (
)
func TestIsDefaultMCPServer(t *testing.T) {
if !IsDefaultMCPServer("firecrawl") {
t.Fatal("firecrawl should be a built-in default")
if !IsDefaultMCPServer("exa") {
t.Fatal("exa should be a built-in default")
}
if IsDefaultMCPServer(" firecrawl ") == false {
if IsDefaultMCPServer(" exa ") == false {
t.Fatal("IsDefaultMCPServer should trim whitespace")
}
if IsDefaultMCPServer("not-a-default") {
@@ -18,45 +18,45 @@ func TestIsDefaultMCPServer(t *testing.T) {
}
}
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
func TestResolveMCPSeedsEnabledExaDefault(t *testing.T) {
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl, ok := cfg.Servers["firecrawl"]
exa, ok := cfg.Servers["exa"]
if !ok {
t.Fatal("expected the firecrawl default to be seeded with no user config")
t.Fatal("expected the exa default to be seeded with no user config")
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
if exa.Type != "http" || exa.URL != "https://mcp.exa.ai/mcp" {
t.Fatalf("unexpected exa default: %#v", exa)
}
if firecrawl.Disabled {
t.Fatal("the firecrawl default must be enabled out of the box")
if exa.Disabled {
t.Fatal("the exa default must be enabled out of the box")
}
}
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
if !cfg.Servers["exa"].Disabled {
t.Fatal("a user must be able to disable the default by writing over it")
}
}
func TestIsUnconfiguredDefault(t *testing.T) {
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
if !IsUnconfiguredDefault("exa", DefaultMCPServers()["exa"]) {
t.Fatal("an untouched exa default should be reported as unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://example.com/mcp"}) {
t.Fatal("a server overriding the default URL is no longer unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://mcp.exa.ai/mcp", Auth: "bearer"}) {
t.Fatal("a server with credentials added is no longer unconfigured")
}
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
@@ -65,57 +65,173 @@ func TestIsUnconfiguredDefault(t *testing.T) {
}
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
// `zero mcp enable exa` after a prior disable writes {"disabled":false}
// explicitly. The resolved value is identical to the untouched default (both
// enabled, no credentials), but the user DID take an explicit action here, so
// IsUnconfiguredDefault must not treat it as untouched (issue #563 review).
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", firecrawl)
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", exa)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("an explicit enable/disable toggle must count as user-configured, even though the resolved value matches the default")
}
}
func TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault(t *testing.T) {
// A user who copies firecrawl's exact default type/url into their config
// A user who copies Exa's exact default type/url into their config
// (e.g. from an example file, planning to add credentials later) produces a
// resolved value byte-identical to DefaultMCPServers()["firecrawl"] — the
// resolved value byte-identical to DefaultMCPServers()["exa"] — the
// same trap TestResolveMCPExplicitReenableIsNotUnconfiguredDefault covers for
// the disabled toggle. IsUnconfiguredDefault must still treat this as
// user-configured because the user's JSON declared an entry for it, even
// though a plain resolved-value comparison could not tell the difference.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"type":"http","url":"https://mcp.exa.ai/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
want := DefaultMCPServers()["firecrawl"]
if firecrawl.Type != want.Type || firecrawl.URL != want.URL || firecrawl.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", firecrawl)
exa := cfg.Servers["exa"]
want := DefaultMCPServers()["exa"]
if exa.Type != want.Type || exa.URL != want.URL || exa.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", exa)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("redeclaring the default's exact values is still an explicit user configuration, not an untouched default")
}
}
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// Point firecrawl at a self-hosted instance; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
// Point Exa at a proxy; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"url":"https://example.com/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.URL != "https://example.com/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", exa)
}
if exa.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", exa)
}
}
func TestResolveMCPCarriesLegacyDefaultDisableToSuccessor(t *testing.T) {
// Upgrade path: the user disabled the firecrawl default Zero used to ship.
// Swapping the default to exa must not re-open an outbound connection they
// explicitly switched off.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatalf("a prior disable of the retired default must carry to its replacement: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPExplicitSuccessorBeatsLegacyDefaultDisable(t *testing.T) {
// The user disabled the old default but has since explicitly configured exa.
// Their newer, explicit choice wins — the migration must not override it.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true},"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("an explicit exa entry must survive the legacy-disable migration: %#v", exa)
}
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("an explicitly configured exa is not an untouched default")
}
}
func TestResolveMCPLegacyDefaultLeftEnabledDoesNotDisableSuccessor(t *testing.T) {
// A user who configured firecrawl without disabling it (e.g. added a header)
// never opted out of the default search server, so exa stays enabled.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatal("only an explicit disable of the retired default should carry over")
}
}
func TestResolveMCPLegacyDefaultDisableIsLiftableByOverride(t *testing.T) {
// The carried-over disable is a user-level decision, not a permanent one:
// `zero mcp enable exa` merges through the CLI override scope, which is the
// one layer allowed to re-enable.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
enabled := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err := ResolveMCP(ResolveOptions{
UserConfigPath: path,
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"exa": enabled}}},
})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatalf("an explicit enable must lift the carried-over disable: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPLegacyDisableSurvivesProjectReenable(t *testing.T) {
// The project layer is lower-trust and must not lift the carried-over
// user-level disable — the same guard a direct `zero mcp disable exa` gets.
dir := t.TempDir()
userPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(userPath, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "project.json")
if err := os.WriteFile(projectPath, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("project config must not re-enable a server the user disabled")
}
}
func TestResolveMCPKeepsRetiredDefaultTransportForPartialEntry(t *testing.T) {
// A user who added an API key to the firecrawl default wrote only the header
// — the seeded default supplied the http type and URL. Retiring the default
// must not strip those out from under them and leave an unusable server.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
@@ -123,10 +239,55 @@ func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.URL != "http://localhost:3002/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a partial entry must keep the retired default's transport: %#v", firecrawl)
}
if firecrawl.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("the user's own field must still win: %#v", firecrawl)
}
}
func TestResolveMCPDoesNotGraftRetiredDefaultOntoOwnTransport(t *testing.T) {
// A self-hosted firecrawl over stdio is a complete definition the user owns.
// Re-seeding the retired http default under it would produce an http server
// carrying a command, which NormalizeConfig rejects outright.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Type != "" || firecrawl.URL != "" {
t.Fatalf("an entry naming its own transport must be left alone: %#v", firecrawl)
}
if firecrawl.Command != "firecrawl-mcp" {
t.Fatalf("the user's command must survive: %#v", firecrawl)
}
}
func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
// The disable carries to exa, and the retired entry itself keeps a usable
// transport so a later `zero mcp enable firecrawl` does not resolve to a
// server with no type, url, or command.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if !firecrawl.Disabled {
t.Fatalf("the retired entry must stay disabled: %#v", firecrawl)
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a disabled retired entry should still carry a usable transport: %#v", firecrawl)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("the disable must still carry to the successor")
}
}
@@ -34,7 +34,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must always be present")
}
if _, ok := cfg.Servers["firecrawl"]; !ok {
if _, ok := cfg.Servers["exa"]; !ok {
t.Fatal("the built-in default must always be present")
}
})
@@ -54,7 +54,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must survive when the project layer is dropped")
}
if _, ok := cfg.Servers["firecrawl"]; !ok {
if _, ok := cfg.Servers["exa"]; !ok {
t.Fatal("the built-in default must survive when the project layer is dropped")
}
})
+5 -1
View File
@@ -180,7 +180,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
}
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
// Seed Zero's built-in default MCP servers (e.g. keyless Exa for free,
// no-setup web search/scrape) BEFORE merging user/project config, so the user
// can override any field or disable a default by writing over it.
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
@@ -194,6 +194,10 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// server the user disabled (a user-level disable is sticky, but the user
// scope itself may lift it).
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
// Reconcile config written against a default Zero has since retired,
// here while the user layer is the only one merged — so a carried-over
// disable counts as a user-level decision the project layer cannot lift.
migrateRetiredDefaultMCPServers(&cfg.MCP)
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
+9 -2
View File
@@ -119,6 +119,12 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
// curated cheap model on official endpoints, the main model elsewhere.
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
CompactionModel string `json:"compactionModel,omitempty"`
}
// RecentModelEntry is one provider-qualified model selection recorded in
@@ -151,7 +157,8 @@ type KeyBindingsConfig struct {
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
// TogglePlan is retained for configuration compatibility. Plan updates render
// in the transcript and no longer have a persistent panel to toggle.
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
@@ -464,7 +471,7 @@ type MCPServerConfig struct {
// fields it set or what values they hold. A built-in default seeded by
// DefaultMCPServers() is never unmarshaled from JSON, so it starts false;
// any explicit entry in the user/project file — even one that happens to
// repeat a default's exact field values (e.g. re-declaring firecrawl's
// repeat a default's exact field values (e.g. re-declaring Exa's
// default URL) — sets it true. IsUnconfiguredDefault checks this alongside
// a resolved-value comparison, so redeclaring default values verbatim still
// counts as user-configured.
+10 -1
View File
@@ -9,7 +9,16 @@ import (
"strings"
)
const PolicyVersion = 1
// PolicyVersion is the version of the approval policy that recorded grants were
// evaluated under. Bump it whenever a change narrows what may be approved: the
// grant store then backs the file up, keeps deny grants, invalidates prior
// approvals, and tells the user once, instead of silently honoring approvals
// that the current rules would have refused.
//
// v2 tightened command-prefix validation so a launcher spelled with a version
// or an executable extension ("python3.11", "python.exe") can no longer be
// approved as an ordinary command.
const PolicyVersion = 2
type Origin string
+1 -1
View File
@@ -35,7 +35,7 @@ type Server struct {
// credential lookup uses it to avoid reusing legacy user tokens by name.
ProjectConfigured bool
// UnconfiguredDefault is true when this server is one of Zero's built-in
// defaults (e.g. keyless Firecrawl) that the user never touched in their
// defaults (e.g. keyless Exa) that the user never touched in their
// config — no credentials, no overrides. Callers use it to avoid warning
// loudly when a server nobody configured fails to connect.
UnconfiguredDefault bool
+118 -3
View File
@@ -1,6 +1,8 @@
package mcp
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -90,7 +92,7 @@ func TestNormalizeConfigValidatesTransportBoundaries(t *testing.T) {
func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"exa": config.DefaultMCPServers()["exa"],
"web": {
Type: "http",
URL: "https://example.com/mcp",
@@ -107,8 +109,8 @@ func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
byName[server.Name] = server
}
if !byName["firecrawl"].UnconfiguredDefault {
t.Fatal("an untouched firecrawl default should be flagged UnconfiguredDefault")
if !byName["exa"].UnconfiguredDefault {
t.Fatal("an untouched exa default should be flagged UnconfiguredDefault")
}
if byName["web"].UnconfiguredDefault {
t.Fatal("a server the user configured must not be flagged UnconfiguredDefault")
@@ -240,3 +242,116 @@ func TestCopyStringMapTrimsKeysAndPreservesValues(t *testing.T) {
t.Fatalf("copied[TOKEN] = %q, want value preserved verbatim", copied["TOKEN"])
}
}
// Startup behavior for the firecrawl -> exa default migration: a user who
// disabled the retired default must end up with no Exa server to connect to,
// not merely a disabled entry in the resolved config.
func TestNormalizeConfigSkipsExaAfterLegacyFirecrawlDisable(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
t.Fatalf("exa must not be started after the user disabled the default it replaced: %#v", server)
}
}
}
// The same path with no legacy disable still starts Exa, so the migration
// cannot quietly switch off the default for everyone else.
func TestNormalizeConfigStartsExaWithoutLegacyDisable(t *testing.T) {
cfg, err := config.ResolveMCP(config.ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
if !server.UnconfiguredDefault {
t.Fatalf("an untouched exa default should be flagged unconfigured: %#v", server)
}
return
}
}
t.Fatal("expected the exa default to be started out of the box")
}
// The failure path CodeRabbit flagged: a firecrawl entry that named only a
// header relied on the retired default for its transport. Resolving it must
// still produce a complete server — an incomplete one fails NormalizeConfig,
// which takes down every other server's startup with it, not just this one.
func TestNormalizeConfigResolvesPartialRetiredDefaultEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a header-only legacy entry must not break startup: %v", err)
}
var firecrawl *Server
var sawExa bool
for i := range servers {
switch servers[i].Name {
case "firecrawl":
firecrawl = &servers[i]
case "exa":
sawExa = true
}
}
if firecrawl == nil {
t.Fatalf("expected the customized firecrawl server to survive: %#v", servers)
}
if firecrawl.Type != ServerTypeHTTP || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("firecrawl lost the transport its entry relied on: %#v", *firecrawl)
}
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("firecrawl lost the user's header: %#v", *firecrawl)
}
if !sawExa {
t.Fatal("the new exa default should still start alongside it")
}
}
// The mirror case: an entry that names its own stdio transport must not have
// the retired http default grafted onto it, which would be rejected outright.
func TestNormalizeConfigLeavesRetiredEntryWithOwnTransportAlone(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a self-hosted stdio entry must keep working: %v", err)
}
for _, server := range servers {
if server.Name != "firecrawl" {
continue
}
if server.Type != ServerTypeStdio || server.Command != "firecrawl-mcp" {
t.Fatalf("the user's own stdio transport was corrupted: %#v", server)
}
return
}
t.Fatalf("expected the self-hosted firecrawl server to survive: %#v", servers)
}
+9 -9
View File
@@ -153,16 +153,16 @@ func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
}
func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
// firecrawl is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure (e.g. the real HTTP 401 from
// issue #552) must be recorded as UnconfiguredDefault so callers can avoid
// Exa is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure must be recorded as
// UnconfiguredDefault so callers can avoid
// warning about a server the user never asked for. "custom" is configured by
// the user and must NOT be flagged even though it also fails to connect.
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"custom": {Type: "stdio", Command: "custom-mcp"},
"exa": config.DefaultMCPServers()["exa"],
"custom": {Type: "stdio", Command: "custom-mcp"},
}}, RegisterOptions{
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
return nil, errors.New(server.Name + " connect failed")
@@ -177,12 +177,12 @@ func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
for _, skipped := range runtime.Skipped() {
byName[skipped.Name] = skipped
}
firecrawl, ok := byName["firecrawl"]
exa, ok := byName["exa"]
if !ok {
t.Fatalf("Skipped() = %#v, want an entry for firecrawl", runtime.Skipped())
t.Fatalf("Skipped() = %#v, want an entry for exa", runtime.Skipped())
}
if !firecrawl.UnconfiguredDefault {
t.Fatalf("Skipped()[firecrawl] = %#v, want UnconfiguredDefault", firecrawl)
if !exa.UnconfiguredDefault {
t.Fatalf("Skipped()[exa] = %#v, want UnconfiguredDefault", exa)
}
custom, ok := byName["custom"]
if !ok {
+6
View File
@@ -231,6 +231,12 @@ func reasoningEffortsForModelName(name string) []ReasoningEffort {
// the gateway's translation concern — unknown fields are ignored, so the
// worst case is a silent no-op rather than a 400.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
case strings.Contains(n, "deepseek"):
// DeepSeek V4 (deepseek-v4-flash / deepseek-v4-pro) exposes thinking-mode
// effort control through the OpenAI-compatible reasoning_effort parameter.
// The API accepts low/high/max and maps medium/xhigh onto high, so only
// the three distinct tiers are advertised.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}
default:
return nil
}
@@ -20,6 +20,10 @@ func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
{"hy3", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"hunyuan-t1", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"openai/o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}}, // github-style vendor/ id
{"deepseek-v4-flash", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-v4-pro", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-reasoner", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-chat", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
{"gpt-4o-mini", nil},
{"ollama/llama3.1", nil},
+1 -1
View File
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
}
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("Windows access token has no default owner")
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
+4
View File
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
switch strings.TrimSpace(provider.ID) {
case "chatgpt":
// ChatGPT authenticates against the Codex backend, while its public
// model metadata is published under the OpenAI provider on models.dev.
return "openai"
case "dashscope":
return "alibaba"
case "github":
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
tests := map[string]string{
"chatgpt": "openai",
"github": "github-models",
"moonshot": "moonshotai",
"nvidia-nim": "nvidia",
+2 -1
View File
@@ -539,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
hasCatalog := len(byID) > 0
// Aggregators publish the live list as the source of truth. Keep live-only
// ids even when a remote catalog also loaded, instead of intersecting.
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
preferLive := provider.Local ||
providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
@@ -139,6 +139,68 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
}
}
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{
"openai": {"models": {
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"description": "Frontier coding model",
"tool_call": true,
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"limit": {"context": 1050000},
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
}
}}
}`))
case "/backend-api/codex/models":
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
return
}
}))
defer server.Close()
provider, ok := providercatalog.Get("chatgpt")
if !ok {
t.Fatal("ChatGPT provider missing from catalog")
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
ModelsDevURL: server.URL + "/api.json",
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer test", true, nil
},
})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if len(models) != 1 {
t.Fatalf("models = %#v, want one ChatGPT live model", models)
}
model := models[0]
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
}
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
t.Fatalf("input modalities = %q, want text,image,pdf", got)
}
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
@@ -164,6 +226,29 @@ func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
}
}
func TestMergeLocalModelsKeepsLiveOnlyEntries(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "lmstudio", Local: true},
[]Model{
{ID: "qwen3-coder-30b", ContextWindow: 32768},
{ID: "text-embedding-local"},
{ID: "qwen2.5-coder-32b-instruct", Description: "live description"},
},
[]Model{{ID: "qwen2.5-coder-32b-instruct", Description: "catalog description", ToolCall: true}},
)
if got, want := strings.Join(modelIDs(models), ","), "qwen3-coder-30b,qwen2.5-coder-32b-instruct"; got != want {
t.Fatalf("models = %q, want %q", got, want)
}
for _, model := range models {
if model.ID == "qwen2.5-coder-32b-instruct" {
if model.Description != "catalog description" || !model.ToolCall {
t.Fatalf("merged catalog metadata = %#v", model)
}
}
}
}
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
// Catalog and live endpoints return distinct payloads so the merge must keep
// live-only ids that are absent from the remote catalog response.
+108
View File
@@ -0,0 +1,108 @@
package providers
import (
"context"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction summarization model selection.
//
// Compaction summaries used to run on the session's main model — near a 200k
// window one summarization is a ~100k-token input call at full price, the
// single most expensive recurring event in a long run. A cheap model handles
// the "dense factual summary" task fine, so summarization is routed to one
// whenever the target is knowable.
//
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
// main model). The literal value "main" at any level forces the main model.
// A wrong choice is never fatal: the agent falls back to the main provider on
// the first summarizer failure for the rest of the run.
// CompactionModelEnv overrides the compaction summarization model.
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
// compactionMainSentinel disables the dedicated summarizer explicitly.
const compactionMainSentinel = "main"
// Curated cheap summarizer defaults, applied only for provider kinds whose
// model catalog is guaranteed (official endpoints). Custom / *-compatible
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
// failed call per run is worse than main-model prices.
const (
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
defaultOpenAICompactionModel = "gpt-4.1-mini"
)
// CompactionModelID returns the model that compaction summarization calls
// should use for the given profile, or "" to use the session's main model.
// preference is the resolved config value (preferences.compactionModel).
func CompactionModelID(profile config.ProviderProfile, preference string) string {
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
if value == "" {
value = strings.TrimSpace(preference)
}
if value != "" {
if strings.EqualFold(value, compactionMainSentinel) {
return ""
}
return value
}
return defaultCompactionModel(profile)
}
func defaultCompactionModel(profile config.ProviderProfile) string {
metadata, err := ResolveRuntimeMetadata(profile, Options{})
if err != nil {
return ""
}
var candidate string
switch metadata.ProviderKind {
case config.ProviderKindAnthropic:
candidate = defaultAnthropicCompactionModel
case config.ProviderKindGoogle:
candidate = defaultGoogleCompactionModel
case config.ProviderKindOpenAI:
candidate = defaultOpenAICompactionModel
default:
return ""
}
// Session already runs on the cheap model: a dedicated summarizer buys
// nothing and would only add a second provider instance.
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
return ""
}
return candidate
}
// CompactionSummarizerFactory returns the lazy constructor the agent loop
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
// The compactor passes the main model in force at that moment — on the first
// paid compaction and again after a mid-run model switch — so the choice
// tracks escalations: a run that starts on the cheap model gets no dedicated
// summarizer, and gains one once it escalates to a pricier main model.
// Returning (nil, nil) means "summarize on the main provider". The factory
// itself is nil only when no provider builder exists. Shared by the exec and
// TUI front ends so the selection rules cannot drift between them.
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
if newProvider == nil {
return nil
}
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
profile := base
if strings.TrimSpace(mainModelID) != "" {
profile.Model = mainModelID
}
modelID := CompactionModelID(profile, preference)
if modelID == "" {
return nil, nil
}
profile.Model = modelID
return newProvider(profile)
}
}
+113
View File
@@ -0,0 +1,113 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactionModelIDResolutionOrder(t *testing.T) {
anthropic := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
}
t.Setenv(CompactionModelEnv, "my-env-model")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
t.Fatalf("env must win, got %q", got)
}
t.Setenv(CompactionModelEnv, "main")
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
t.Fatalf("env 'main' must force the main model, got %q", got)
}
t.Setenv(CompactionModelEnv, "")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
t.Fatalf("config preference must apply when env unset, got %q", got)
}
if got := CompactionModelID(anthropic, "main"); got != "" {
t.Fatalf("config 'main' must force the main model, got %q", got)
}
}
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
}
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
}
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
}
// Custom/compatible endpoints: catalog unknowable, no default.
compatible := config.ProviderProfile{
Name: "opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opengateway.gitlawb.com/v1",
Model: "some-model",
}
if got := CompactionModelID(compatible, ""); got != "" {
t.Fatalf("openai-compatible must have no default, got %q", got)
}
}
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
haiku := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: defaultAnthropicCompactionModel,
}
if got := CompactionModelID(haiku, ""); got != "" {
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
}
}
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
var built []string
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = append(built, profile.Model)
return nil, errors.New("not needed")
}
factory := CompactionSummarizerFactory(base, "", newProvider)
if factory == nil {
t.Fatal("factory must exist when a provider builder exists")
}
// Main model is the cheap default: no dedicated summarizer, nothing built.
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
}
// After escalating to an expensive model the cheap default is built.
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
}
// An empty main model falls back to the base profile's model — the cheap
// default here, so again nothing is built.
built = nil
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
}
// The explicit "main" preference disables the dedicated summarizer.
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
}
if CompactionSummarizerFactory(base, "", nil) != nil {
t.Fatal("no provider builder means no factory")
}
}
+16 -1
View File
@@ -486,7 +486,7 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
Function: toolFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Parameters: normalizeToolParameters(tool.Parameters),
},
})
}
@@ -494,6 +494,21 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
return mapped
}
// normalizeToolParameters keeps schemas accepted by strict OpenAI-compatible
// servers such as LM Studio. No-argument tools still need an object-valued
// parameters.properties field instead of an omitted field.
func normalizeToolParameters(parameters map[string]any) map[string]any {
normalized := make(map[string]any, len(parameters)+1)
for key, value := range parameters {
normalized[key] = value
}
properties, ok := normalized["properties"].(map[string]any)
if !ok || properties == nil {
normalized["properties"] = map[string]any{}
}
return normalized
}
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
// kill switch is set to a truthy value. "0" and "false" (any case) are
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
+130
View File
@@ -3,6 +3,7 @@ package openai
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
@@ -105,6 +106,72 @@ func TestStreamCompletionPostsChatCompletionRequest(t *testing.T) {
if tool["type"] != "function" {
t.Fatalf("unexpected tool wrapper: %#v", tool)
}
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
// TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject locks in the
// normalizeToolParameters fix: a tool whose parameters carry a typed-nil
// properties map must serialize "properties":{} on the wire, never
// "properties":null, because strict OpenAI-compatible servers (LM Studio)
// reject the null form.
func TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
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"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": map[string]any(nil)},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
func TestNewRequiresModelButNotAPIKey(t *testing.T) {
@@ -1479,3 +1546,66 @@ func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
}
}
// TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject locks in the
// normalizeToolParameters type-assert branch: when a tool's parameters carry a
// `properties` field whose value is a non-map (e.g., an empty slice), it must
// be normalized to "properties":{} on the wire, never "properties":[] or
// "properties":null.
func TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
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"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": []any{}},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":[]`) {
t.Fatalf("request body must not serialize properties as an empty array, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
@@ -0,0 +1,96 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) {
root := t.TempDir()
inside := filepath.Join(root, "main.js")
structured := func(path string) string {
return strings.Join([]string{"*** Begin Patch", "*** Update File: " + path, "@@", "-a", "+b", "*** End Patch"}, "\n")
}
for name, patch := range map[string]string{
"absolute inside workspace": structured(inside),
"relative": structured("main.js"),
"decorated markers": "*** Begin Patch ***\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch ***",
"no-space marker": "***Begin Patch\n*** Update File: main.js\n@@\n-a\n+b\n***End Patch",
} {
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
if block := applyPatchPathBlock(request); block != nil {
t.Fatalf("%s: unexpected block %+v", name, block)
}
}
for _, path := range []string{"../escape.js", ".."} {
for name, patch := range map[string]string{"canonical": structured(path), "no-space": "***Begin Patch\n*** Update File: " + path + "\n@@\n-a\n+b\n***End Patch"} {
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
block := applyPatchPathBlock(request)
if block == nil || block.Code != BlockOutsideWorkspace {
t.Fatalf("%s %q must be blocked as traversal, got %+v", name, path, block)
}
}
}
}
// Every marker spelling the tool applies must be classified as structured at
// the sandbox boundary too; otherwise the boundary scans the patch as a unified
// diff, extracts no targets, and validates nothing (fail-open).
func TestStructuredPatchClassifierMatchesToolSpellings(t *testing.T) {
for _, header := range []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "\ufeff*** Begin Patch"} {
patch := header + "\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch"
if !IsStructuredPatch(patch) {
t.Fatalf("%q must classify as a structured patch", header)
}
if paths := applyPatchPaths(patch); len(paths) != 1 || paths[0] != "main.js" {
t.Fatalf("%q: sandbox must extract the structured target, got %v", header, paths)
}
}
for _, header := range []string{"--- a/x", "Begin Patch", "*** Begin Patchwork", "*** Update File: x"} {
if IsStructuredPatch(header + "\n-a\n+b") {
t.Fatalf("%q must not classify as a structured patch", header)
}
}
if StructuredPatchMarker("*** End Patch ***") != "end" || StructuredPatchMarker("***End Patch") != "end" {
t.Fatal("decorated end markers must classify as end")
}
}
func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) {
root := t.TempDir()
// NewScope also grants the system temp dir, so a sibling t.TempDir() is
// legitimately in scope; pick a path under the filesystem root instead.
outside, err := filepath.Abs(filepath.Join(string(filepath.Separator), "zero-outside-workspace-test", "escape.js"))
if err != nil {
t.Fatal(err)
}
scope, err := NewScope(root, nil)
if err != nil {
t.Fatal(err)
}
inside := filepath.Join(root, "main.js")
structured := func(header, footer, path string) string {
return strings.Join([]string{header, "*** Update File: " + path, "@@", "-a", "+b", footer}, "\n")
}
for name, spelling := range map[string][2]string{"canonical": {"*** Begin Patch", "*** End Patch"}, "no-space": {"***Begin Patch", "***End Patch"}} {
// Failure path: the exact path the boundary parsed must be denied by the
// scope. structuredPatchHeaderPaths normalises separators to "/", so the
// parsed form is compared in slash form and then validated as-is.
paths := applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], outside)})
if len(paths) != 1 || paths[0] != filepath.ToSlash(outside) {
t.Fatalf("%s: absolute patch path must reach scope validation unchanged, got %v", name, paths)
}
if block := scope.validate(paths[0]); block == nil || block.Code != BlockOutsideWorkspace {
t.Fatalf("%s: scope must deny the parsed outside path %q, got %+v", name, paths[0], block)
}
// Success path: the parsed inside path must be accepted by the scope.
paths = applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], inside)})
if len(paths) != 1 || paths[0] != filepath.ToSlash(inside) {
t.Fatalf("%s: inside patch path must reach scope validation unchanged, got %v", name, paths)
}
if block := scope.validate(paths[0]); block != nil {
t.Fatalf("%s: scope must accept the parsed inside path %q, got %+v", name, paths[0], block)
}
}
}
+105 -10
View File
@@ -148,8 +148,10 @@ func unsafeCommandPrefix(prefix []string) bool {
return true
}
}
normalized := append([]string(nil), prefix...)
normalized[0] = normalizeLauncherName(normalized[0])
for _, banned := range bannedCommandPrefixSuggestions {
if sameStringSlice(prefix, banned) {
if sameStringSlice(prefix, banned) || sameStringSlice(normalized, banned) {
return true
}
}
@@ -165,25 +167,118 @@ func unsafeCommandPrefixPart(part string) bool {
}
func unsafeCommandPrefixLauncher(program string) bool {
program = strings.ToLower(strings.TrimSpace(program))
if strings.ContainsAny(program, `/\`) {
// A path is already refused. ":" and "~" are refused for the same reason:
// on Windows they introduce names that resolve to a different file than the
// one matched here, and no list can keep up with them. "PYTHON~1.EXE" is
// python.exe under an 8.3 short name, and 8.3 truncates the stem, so
// "POWERS~1.EXE" cannot be recognized by name at all; "python.exe::$DATA"
// names the same executable through its default stream. Refusing the shape
// costs a permission prompt on names no real command uses.
if strings.ContainsAny(program, `/\:~`) {
return true
}
switch program {
case "bash", "sh", "zsh", "/bin/bash", "/bin/zsh",
"pwsh", "powershell", "powershell.exe",
"env", "sudo", "doas", "su", "run0", "osascript",
name := normalizeLauncherName(program)
return bannedLauncherName(name) || bannedLauncherName(strings.TrimRight(name, "0123456789"))
}
func bannedLauncherName(name string) bool {
switch name {
case "bash", "sh", "zsh", "dash", "ash", "ksh", "csh", "tcsh", "fish", "busybox",
"pwsh", "powershell", "cmd", "wsl",
"env", "sudo", "sudoedit", "doas", "su", "run0", "osascript",
"command", "eval", "exec", "time",
"find", "xargs", "timeout", "nice", "nohup", "watch", "setsid", "stdbuf", "ionice",
"ssh", "make", "npm", "npx",
"python", "python3", "py", "pythonw", "pyw", "pypy", "pypy3",
"node", "perl", "ruby", "php", "lua", "deno", "bun":
"ssh", "make", "npm", "npx", "pnpm", "yarn", "bunx",
"python", "py", "pythonw", "pyw", "pypy", "uv", "uvx",
"node", "nodejs", "perl", "ruby", "php", "lua", "deno", "bun":
return true
default:
return false
}
}
// normalizeLauncherName reduces a program name to the launcher it actually runs
// so the list above cannot be stepped around by spelling the same interpreter
// differently: "python3.11", "python.exe" and "PYTHON3" all reach it as a name
// the list matches. internal/agent's commandName already normalizes case and
// Windows executable extensions on the allow side; this side matched raw, so
// every versioned or .exe-suffixed launcher validated as an ordinary command
// and persisted as a prefix grant that auto-allows every later invocation.
//
// A name is only ever narrowed toward an existing entry, so the failure
// direction is an extra permission prompt, never a silent grant.
func normalizeLauncherName(program string) string {
name := strings.ToLower(strings.TrimSpace(program))
// Windows discards trailing dots and spaces when resolving a filename, so
// "python." starts python.exe. Drop them before anything matches on the
// result, on every platform: the deny side must not depend on which OS is
// reading the grant, since the grants file travels with a synced home
// directory.
name = strings.TrimRight(name, ". ")
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com", ".ps1"} {
if strings.HasSuffix(name, suffix) {
name = strings.TrimSuffix(name, suffix)
name = strings.TrimRight(name, ". ")
break
}
}
return trimLauncherVersion(trimLauncherABISuffix(trimLauncherBuildSuffix(name)))
}
// trimLauncherBuildSuffix drops the build-channel suffixes distributions and
// upstreams append to an otherwise unchanged interpreter, so "python3.11-dbg",
// "bash-static" and "pwsh-preview" are the launchers they say they are. The set
// is closed on purpose: a general "-word" strip would also swallow
// "node-gyp", "python3-config" and "ruby-lsp", which are ordinary tools a user
// should still be able to approve.
func trimLauncherBuildSuffix(name string) string {
for _, suffix := range []string{"-dbg", "-debug", "-static", "-nightly", "-preview", "-beta"} {
if strings.HasSuffix(name, suffix) {
return strings.TrimSuffix(name, suffix)
}
}
return name
}
// trimLauncherABISuffix drops CPython's ABI flags from a versioned interpreter
// name, so "python3.7m" and "python3.6dm" reduce to their version, and the
// free-threaded builds "python3.13t" and "python3.13td" reduce to theirs. The
// flags are only removed when a digit sits underneath them, which leaves
// ordinary names that happen to end in those letters alone: "sha256sum" keeps
// its "sum", "zstd" its "td", "cat" its "t".
func trimLauncherABISuffix(name string) string {
trimmed := strings.TrimRight(name, "dmut")
if trimmed == name || trimmed == "" {
return name
}
if last := trimmed[len(trimmed)-1]; last < '0' || last > '9' {
return name
}
return trimmed
}
// trimLauncherVersion drops a trailing "<separator><digits>" version from a
// program name, so "python3.11" becomes "python3" and "python2.7" becomes
// "python2". Digits without a separator are part of the name, which keeps
// "base64", "7z" and "sha256sum" intact; the caller handles the "python3" shape
// by also testing the digit-stripped form.
func trimLauncherVersion(name string) string {
for {
trimmed := strings.TrimRight(name, "0123456789")
if trimmed == name || trimmed == "" {
return name
}
if !strings.HasSuffix(trimmed, ".") && !strings.HasSuffix(trimmed, "-") {
return name
}
next := trimmed[:len(trimmed)-1]
if next == "" {
return name
}
name = next
}
}
func sameStringSlice(left []string, right []string) bool {
if len(left) != len(right) {
return false
+115
View File
@@ -0,0 +1,115 @@
package sandbox
import "testing"
func TestUnsafeCommandPrefixLauncherIgnoresVersionAndExtension(t *testing.T) {
for _, program := range []string{
"python3", "python", "node", "git", "sh", "bash", "sudo",
"python3.11", "python3.12", "python2.7", "python3.13.1",
"python.exe", "python3.exe", "node.exe", "git.exe", "bash.exe",
"PYTHON3.11", "Node.EXE",
"cmd", "cmd.exe", "wsl", "wsl.exe",
// Windows resolves these to the same executable.
"python.", "python3.", "python3.11.", "python.exe.", "PYTHON.",
"python.cmd", "python.bat", "python.com", "python.ps1",
"PYTHON~1.EXE", "POWERS~1.EXE", "python.exe::$DATA",
// Versioned and ABI-suffixed interpreters.
"python3.7m", "python3.6dm", "pythonw3.11", "python-3.11",
// Free-threaded CPython (PEP 703) ships beside the GIL build.
"python3.13t", "python3.13t.exe", "python3.14t", "python3.13td",
// Distribution and build-channel spellings of the same interpreter.
"nodejs", "nodejs.exe", "node-nightly", "python3-dbg", "python3.11-dbg",
"python3.13t-dbg", "python3-debug", "python3.11-debug", "node-beta",
"pwsh-preview", "bash-static", "sudoedit",
// Twins of npm and npx, which the list already refuses.
"pnpm", "yarn", "bunx", "pnpm.cmd", "yarn.exe",
"perl5.36", "ruby3.2", "php8", "lua5.4", "node22.exe", "bash5",
} {
if !unsafeCommandPrefix([]string{program}) {
t.Errorf("unsafeCommandPrefix([%q]) = false, want true", program)
}
}
}
func TestUnsafeCommandPrefixKeepsOrdinaryCommandsGrantable(t *testing.T) {
for _, prefix := range [][]string{
{"cargo", "build"},
{"go", "test"},
{"ls", "-la"},
{"rg", "--json"},
{"7z", "l"},
{"base64", "-d"},
{"kubectl", "get", "pods"},
{"docker", "ps"},
{"sha256sum", "file"},
{"gcc-13", "-c"},
{"node-gyp", "build"},
{"python3-config", "--includes"},
{"s3cmd", "ls"},
{"cat", "file"},
{"zstd", "-d"},
{"sqlite3", "db"},
{"yt-dlp", "url"},
{"perl-doc", "-h"},
{"php-fpm", "-t"},
{"ruby-lsp", "--version"},
{"lua-language-server", "--check"},
{"nodemon", "app.js"},
} {
if unsafeCommandPrefix(prefix) {
t.Errorf("unsafeCommandPrefix(%q) = true, want false", prefix)
}
}
}
func TestNormalizeLauncherName(t *testing.T) {
for _, testCase := range []struct{ in, want string }{
{"python3.11", "python3"},
{"python2.7", "python2"},
{"pypy3", "pypy3"},
{"node.exe", "node"},
{"POWERSHELL.EXE", "powershell"},
{"7z", "7z"},
{"base64", "base64"},
{"sha256sum", "sha256sum"},
{"python.", "python"},
{"python3.11.exe.", "python3"},
{"python3.7m", "python3"},
{"python3.6dm", "python3"},
{"python3.13t", "python3"},
{"python3.13td", "python3"},
{"python3.11-dbg", "python3"},
{"node-nightly", "node"},
{"node-gyp", "node-gyp"},
{"python3-config", "python3-config"},
{"zstd", "zstd"},
{"cat", "cat"},
{"cargo", "cargo"},
{"", ""},
} {
if got := normalizeLauncherName(testCase.in); got != testCase.want {
t.Errorf("normalizeLauncherName(%q) = %q, want %q", testCase.in, got, testCase.want)
}
}
}
// Session-scoped grants are the agent's other entry point (loop.go grants them
// for a single turn without touching the store), so they must refuse the same
// launcher spellings the persisted path does.
func TestGrantCommandPrefixForSessionRefusesLauncherVariants(t *testing.T) {
engine := &Engine{commandPrefixes: newCommandPrefixGrantSet()}
engine.GrantCommandPrefixForSession("bash", []string{"cargo", "build"})
if _, ok := engine.LookupCommandPrefixForSession("bash", []string{"cargo", "build", "--release"}); !ok {
t.Fatal("CONTROL BROKEN: an ordinary session prefix grant did not match")
}
for _, prefix := range [][]string{
{"python3.11", "-c"},
{"python.exe", "-c"},
{"cmd", "/c"},
} {
engine.GrantCommandPrefixForSession("bash", prefix)
command := append(append([]string(nil), prefix...), "whatever")
if _, ok := engine.LookupCommandPrefixForSession("bash", command); ok {
t.Errorf("session grant %q matched %q, want refused", prefix, command)
}
}
}
+22
View File
@@ -242,6 +242,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
} {
t.Run(tc.name, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), Request{
@@ -315,6 +316,27 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
}
}
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
for _, patch := range []string{
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "apply_patch",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"patch": patch},
})
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
}
}
}
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
+66 -1
View File
@@ -2,12 +2,15 @@ package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
@@ -343,7 +346,8 @@ func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
if err != nil {
t.Fatalf("read rewritten grant file: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
policyLine := fmt.Sprintf(`"policyVersion": %d`, execution.PolicyVersion)
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), policyLine) || !strings.Contains(string(raw), `"write_file": [`) {
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
}
backup, err := os.ReadFile(path + ".v1.backup")
@@ -406,6 +410,62 @@ func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing
}
}
// A prefix grant recorded before launcher-name normalization existed (a
// versioned or .exe-suffixed interpreter) no longer validates. It must reach
// the user through the policy migration — backup, denies preserved, one notice
// — and never make the whole grants file unreadable, which would silently drop
// their persisted deny grants too.
func TestGrantStoreMigratesPreNormalizationPrefixInsteadOfRejectingTheFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":3,"policyVersion":1,"grants":{"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["cargo","build"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
t.Fatalf("grants after migration = %#v err=%v, want the deny preserved", grants, err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil || len(prefixes) != 0 {
t.Fatalf("prefixes after migration = %#v err=%v, want all re-approved", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
t.Fatalf("notice = %q err=%v", notice, err)
}
if backup, err := os.ReadFile(path + ".policy-v1.backup"); err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v", backup, err)
}
}
// The legacy-schema path is a separate migration from the policy bump above and
// prunes per grant rather than wholesale, so a pre-normalization prefix must be
// dropped there while the valid prefix beside it survives.
func TestGrantStoreLegacyMigrationDropsOnlyThePreNormalizationPrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil {
t.Fatalf("ListCommandPrefixes after legacy migration returned error: %v", err)
}
if len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("prefixes = %#v, want only [git status]", prefixes)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
t.Fatalf("notice = %q err=%v", notice, err)
}
}
func TestGrantStorePersistsCommandPrefixes(t *testing.T) {
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
@@ -481,6 +541,11 @@ func TestGrantStoreRejectsUnsafeCommandPrefixes(t *testing.T) {
{"python", "script.py"},
{"./script.sh"},
{"git"},
{"python3.11", "-c"},
{"python.exe", "-c"},
{"node.exe", "-e"},
{"git.exe"},
{"cmd", "/c"},
} {
if _, err := store.GrantCommandPrefix(CommandPrefixInput{ToolName: "bash", Prefix: prefix}); err == nil {
t.Fatalf("GrantCommandPrefix(%#v) succeeded, want validation error", prefix)
+62 -3
View File
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
}
cwd := firstArgString(args, "cwd")
var paths []string
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -281,11 +281,15 @@ func applyPatchPathBlock(request Request) *pathBlock {
if patch == "" {
return nil
}
for _, path := range patchHeaderPaths(patch) {
// Only relative traversal is rejected up front. Absolute paths flow through
// the regular workspace-scope validation below (requestPaths), which accepts
// one inside the workspace and denies one outside — a model that echoes the
// absolute path read_file showed it must not be blocked for that alone.
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
if path == ".." || strings.HasPrefix(path, "../") {
return &pathBlock{
Code: BlockOutsideWorkspace,
Path: path,
@@ -296,6 +300,61 @@ func applyPatchPathBlock(request Request) *pathBlock {
return nil
}
// structuredPatchMarkerPattern is the single classifier for structured-patch
// markers, shared by the sandbox boundary and the apply_patch tool (which
// imports this package). It accepts the canonical "*** Begin Patch" /
// "*** End Patch" and the decorated spellings models emit ("*** Begin Patch ***",
// "***Begin Patch", trailing whitespace). Both sides must agree: a spelling the
// tool would apply but the sandbox did not recognise would make the sandbox
// scan the patch as a unified diff, extract no targets, and validate nothing.
var structuredPatchMarkerPattern = regexp.MustCompile(`^\*{3}\s*(Begin|End) Patch\s*\**\s*$`)
// StructuredPatchMarker classifies a line as the "begin" or "end" marker of a
// structured patch, or "" when it is neither.
func StructuredPatchMarker(line string) string {
match := structuredPatchMarkerPattern.FindStringSubmatch(strings.TrimSpace(line))
if match == nil {
return ""
}
return strings.ToLower(match[1])
}
// IsStructuredPatch reports whether patch opens with a structured begin marker.
// The tool applies exactly the patches this returns true for, so the sandbox
// extracts structured header paths for exactly the same set.
func IsStructuredPatch(patch string) bool {
first, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "\n")
return StructuredPatchMarker(first) == "begin"
}
func applyPatchPaths(patch string) []string {
if IsStructuredPatch(patch) {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
}
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
// the sandbox boundary. The executor validates the complete hunk grammar before
// writing; the sandbox only needs every possible target path so it can reject an
// unsafe request before the tool runs.
func structuredPatchHeaderPaths(patch string) []string {
var paths []string
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
if path != "" {
paths = append(paths, path)
}
break
}
}
}
return paths
}
func patchHeaderPaths(patch string) []string {
var paths []string
oldRemaining, newRemaining := 0, 0
+34 -186
View File
@@ -3,11 +3,11 @@ package tools
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
)
type applyPatchTool struct {
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
description: "Apply a multi-hunk or multi-file patch inside the workspace (or a granted extra write root). Structured format:\n*** Begin Patch\n*** Update File: src/app.js\n@@\n unchanged context line\n-removed line\n+added line\n*** End Patch\nUse \"*** Add File: path\" with \"+\" lines to create a file and \"*** Delete File: path\" to remove one; several sections may follow each other. A unified diff (---/+++ headers with a/ b/ prefixes, @@ hunks) is also accepted and applied in-process. Paths are workspace-relative; absolute paths inside the workspace are fine. For a single targeted change, edit_file is simpler.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "Unified diff patch to apply."},
"patch": {Type: "string", Description: "The structured (*** Begin Patch) or unified-diff patch text."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
@@ -56,177 +56,17 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
if isStructuredPatch(patch) {
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
}
tempFile, err := os.CreateTemp("", "zero-patch-*.patch")
// Unified diffs are translated into the same operations and applied by
// the same os.Root engine, so neither format opens a target by pathname
// after validation (no check-to-use window) and git is not needed.
operations, err := parseUnifiedPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
patchPath := tempFile.Name()
defer func() {
_ = os.Remove(patchPath)
}()
if _, err := tempFile.WriteString(patch); err != nil {
_ = tempFile.Close()
return errorResult("Error applying patch: " + err.Error())
}
if err := tempFile.Close(); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if err := recheckPatchWriteTargets(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
var createdTargets []string
var fullySuppliedTargets []string
wholeBefore := map[string]bool{}
if options.FileTracker != nil {
createdTargets = missingPatchTargets(applyRoot, patch)
fullySuppliedTargets = completeCreatedPatchTargets(applyRoot, patch)
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if absolute, _, rerr := resolveWorkspaceTargetPath(applyRoot, path); rerr == nil {
wholeBefore[absolute] = options.FileTracker.SeenWhole(absolute)
}
}
}
command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath)
command.Dir = applyRoot
output, err := command.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return errorResult("Error applying patch: " + message)
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromPatch(relativeRoot, patch)
result.Display = Display{Summary: summary, Kind: "diff", Preview: capPreviewDiff(patch)}
fullySupplied := make(map[string]bool, len(fullySuppliedTargets))
for _, absolute := range fullySuppliedTargets {
fullySupplied[absolute] = true
}
// Re-baseline files changed by this tool. When the model had already seen the
// whole input (or supplied a complete new file), the exact patch plus that
// input determines the whole output, so a follow-up edit needs no wasted read.
// Partial observations remain conservative and are cleared by Record.
for _, changed := range result.ChangedFiles {
if absolute, _, rerr := resolveScopedPath(tool.workspaceRoot, tool.scope, changed); rerr == nil {
content, readErr := os.ReadFile(absolute)
if readErr != nil {
options.FileTracker.Forget(absolute)
continue
}
info, _ := os.Stat(absolute)
wasWhole := wholeBefore[absolute] || fullySupplied[absolute]
options.FileTracker.Record(absolute, content, info)
if wasWhole {
lines := lineCount(string(content))
options.FileTracker.RecordSeenRange(absolute, 1, lines, lines)
}
}
}
recordCreatedPatchTargets(options.FileTracker, createdTargets)
return result
}
func missingPatchTargets(root string, patch string) []string {
seen := map[string]bool{}
var missing []string
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
absolute, _, err := resolveWorkspaceTargetPath(root, path)
if err != nil || seen[absolute] {
continue
}
seen[absolute] = true
if _, err := os.Stat(absolute); os.IsNotExist(err) {
missing = append(missing, absolute)
}
}
return missing
}
// completeCreatedPatchTargets returns only files whose full contents are
// supplied by a /dev/null creation patch. A missing rename/copy destination is
// created by git too, but its bytes come from an unread source and must not gain
// whole-file observation credit.
func completeCreatedPatchTargets(root string, patch string) []string {
seen := map[string]bool{}
var created []string
oldRemaining, newRemaining := 0, 0
inHunk := false
fromDevNull := false
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
switch {
case strings.HasPrefix(line, "-"):
oldRemaining--
case strings.HasPrefix(line, "+"):
newRemaining--
case strings.HasPrefix(line, "\\"):
default:
oldRemaining--
newRemaining--
}
continue
}
inHunk = false
switch {
case strings.HasPrefix(line, "diff --git "):
fromDevNull = false
case strings.HasPrefix(line, "@@"):
oldRemaining, newRemaining = parseHunkCounts(line)
inHunk = oldRemaining > 0 || newRemaining > 0
case strings.HasPrefix(line, "--- "):
fromDevNull = patchFileHeaderPath(line) == "/dev/null"
case strings.HasPrefix(line, "+++ "):
path := patchFileHeaderPath(line)
if !fromDevNull || path == "" || path == "/dev/null" {
fromDevNull = false
continue
}
fromDevNull = false
absolute, _, err := resolveWorkspaceTargetPath(root, stripPatchPrefix(path))
if err != nil || seen[absolute] {
continue
}
if _, err := os.Stat(absolute); !os.IsNotExist(err) {
continue
}
seen[absolute] = true
created = append(created, absolute)
}
}
return created
}
func recordCreatedPatchTargets(tracker *FileTracker, missingBefore []string) {
if tracker == nil {
return
}
for _, absolute := range missingBefore {
if _, err := os.Stat(absolute); err != nil {
continue
}
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
absolute = resolved
}
tracker.RecordCreated(absolute)
}
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
}
// changedFilesFromPatch extracts the unique, WORKSPACE-relative paths a patch
@@ -257,27 +97,35 @@ func changedFilesFromPatch(relativeRoot string, patch string) []string {
return paths
}
// normalizePatchPathForRoot resolves platform-level symlinks (macOS /var ->
// /private/var) in the prefix of an absolute patch path that lies outside the
// apply root, so a path the model copied from read_file compares equal to the
// symlink-resolved root. Relative paths are returned unchanged.
func normalizePatchPathForRoot(root string, path string) string {
if !filepath.IsAbs(path) {
return path
}
resolvedRoot, err := filepath.Abs(root)
if err != nil {
return path
}
if evaluated, err := filepath.EvalSymlinks(resolvedRoot); err == nil {
resolvedRoot = evaluated
}
return sandbox.NormalizePrefixForRoot(path, resolvedRoot)
}
func validatePatchPaths(root string, patch string) error {
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
// Relative traversal is rejected here; an absolute path is checked by
// resolveWorkspaceTargetPath, which only accepts one inside the root.
if path == ".." || strings.HasPrefix(path, "../") {
return fmt.Errorf("patch path %q must stay inside the workspace", path)
}
if _, _, err := resolveWorkspaceTargetPath(root, path); err != nil {
return err
}
}
return nil
}
func recheckPatchWriteTargets(root string, patch string) error {
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
if err := recheckWorkspaceWriteTarget(root, path); err != nil {
if _, _, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path)); err != nil {
return err
}
}
@@ -348,7 +196,7 @@ func patchFileHeaderPath(line string) string {
// tokens. Scanning the whole line would let a crafted heading like
// "@@ -1,1 +1,1 @@ +1,999999" overwrite the real count, keep the parser stuck in
// hunk mode, and swallow later "--- "/"+++ " file headers so they escape
// validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass.
// validatePatchPaths — a workspace-confinement bypass.
func parseHunkCounts(line string) (int, int) {
_, rest, ok := strings.Cut(line, "@@")
if !ok {
@@ -0,0 +1,712 @@
package tools
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Models routinely decorate the structured markers ("*** Begin Patch ***") and
// write unified-diff ranges after "@@"; both used to fail on the first line and
// pushed the model into whole-file rewrites.
func TestApplyPatchToleratesDecoratedMarkersAndRangeHeaders(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\nbye\n")
patch := strings.Join([]string{
"*** Begin Patch ***",
"*** Update File: hello.txt",
"@@ -1,3 +1,3 @@",
" hello",
"-old",
"+new",
"*** End Patch ***",
"",
}, "\n")
if !isStructuredPatch(patch) {
t.Fatal("decorated begin marker must still be recognised as a structured patch")
}
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("decorated structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\nbye\n" {
t.Fatalf("patched content = %q", got)
}
}
func TestStructuredHunkAnchorKeepsHeadingAndPlainContext(t *testing.T) {
cases := map[string]string{
"-12,4 +12,6": "",
"-12 +12": "",
"-12,4 +12,6 @@": "",
"-12,4 +12,6 @@ func main() {": "func main() {",
"func main() {": "func main() {",
"": "",
}
for input, want := range cases {
if got := structuredHunkAnchor(input); got != want {
t.Fatalf("structuredHunkAnchor(%q) = %q, want %q", input, got, want)
}
}
}
func TestStructuredPatchMarkerSpellings(t *testing.T) {
begin := []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "*** Begin Patch **"}
for _, line := range begin {
if structuredPatchMarker(line) != "begin" {
t.Fatalf("%q must read as the begin marker", line)
}
}
if structuredPatchMarker("*** End Patch ***") != "end" {
t.Fatal("decorated end marker must read as the end marker")
}
for _, line := range []string{"*** Update File: x", "Begin Patch", "*** Begin Patchwork"} {
if structuredPatchMarker(line) != "" {
t.Fatalf("%q must not read as a marker", line)
}
}
}
func TestStructuredPatchHeaderErrorCarriesFormatHint(t *testing.T) {
_, err := parseStructuredPatch("--- a/x\n+++ b/x\n")
if err == nil || !strings.Contains(err.Error(), "*** Update File: path") {
t.Fatalf("header error should teach the format, got %v", err)
}
}
func TestApplyPatchAcceptsAbsoluteInWorkspacePaths(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
absolute := filepath.Join(root, "hello.txt")
structured := strings.Join([]string{
"*** Begin Patch",
"*** Update File: " + absolute,
"@@",
" hello",
"-old",
"+structured",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": structured})
if result.Status != StatusOK {
t.Fatalf("absolute in-workspace structured path should apply, got %s: %s", result.Status, result.Output)
}
content, _ := os.ReadFile(absolute)
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nstructured\n" {
t.Fatalf("structured content = %q", got)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "hello.txt" {
t.Fatalf("changed files should be workspace-relative, got %v", result.ChangedFiles)
}
unified := strings.Join([]string{
"--- " + absolute,
"+++ " + absolute,
"@@ -1,2 +1,2 @@",
" hello",
"-structured",
"+unified",
"",
}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": unified})
if result.Status != StatusOK {
t.Fatalf("absolute in-workspace unified path should apply, got %s: %s", result.Status, result.Output)
}
content, _ = os.ReadFile(absolute)
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nunified\n" {
t.Fatalf("unified content = %q", got)
}
}
func TestApplyPatchStillRejectsAbsolutePathsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
writeTestFile(t, outside, "old\n")
for name, patch := range map[string]string{
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: " + outside, "@@", "-old", "+new", "*** End Patch", ""}, "\n"),
"unified": strings.Join([]string{"--- " + outside, "+++ " + outside, "@@ -1 +1 @@", "-old", "+new", ""}, "\n"),
} {
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK {
t.Fatalf("%s patch outside the workspace must be rejected", name)
}
if content, _ := os.ReadFile(outside); string(content) != "old\n" {
t.Fatalf("%s patch must not touch a file outside the workspace", name)
}
}
}
// Unified diffs are applied in-process through the same os.Root engine as
// structured patches: git C-quoted paths with whitespace resolve to one file,
// and the same header outside the workspace is refused before anything is
// written.
func TestUnifiedPatchQuotedWhitespacePaths(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "work tree")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "file.txt")
writeTestFile(t, target, "hello\nold\n")
quoted := func(path string) string { return strconv.Quote(path) }
inside := strings.Join([]string{
"diff --git " + quoted("a/"+filepath.ToSlash(target)) + " " + quoted("b/"+filepath.ToSlash(target)),
"--- " + quoted(target),
"+++ " + quoted(target),
"@@ -1,2 +1,2 @@",
" hello",
"-old",
"+new",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": inside})
if result.Status != StatusOK {
t.Fatalf("quoted whitespace path inside the workspace should apply, got %s: %s", result.Status, result.Output)
}
content, _ := os.ReadFile(target)
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("content = %q", got)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "work tree/file.txt" {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
outsideDir := filepath.Join(t.TempDir(), "other tree")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(outsideDir, "file.txt")
writeTestFile(t, outside, "hello\nold\n")
escape := strings.Join([]string{"--- " + quoted(outside), "+++ " + quoted(outside), "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": escape})
if result.Status == StatusOK {
t.Fatal("quoted whitespace path outside the workspace must be rejected")
}
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
t.Fatal("outside file must be untouched")
}
}
// A workspace path that is swapped for a symlink escaping the root — the
// classic check-to-use attack — is refused by the os.Root engine for both
// formats, and the outside file is never modified.
func TestApplyPatchRefusesSymlinkEscapingWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "secret.txt")
writeTestFile(t, outside, "hello\nold\n")
link := filepath.Join(root, "target.txt")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for name, patch := range map[string]string{
"unified": strings.Join([]string{"--- a/target.txt", "+++ b/target.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n"),
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: target.txt", "@@", " hello", "-old", "+new", "*** End Patch", ""}, "\n"),
} {
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK {
t.Fatalf("%s patch through an escaping symlink must be refused", name)
}
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
t.Fatalf("%s patch must not modify the file outside the workspace", name)
}
}
}
func TestUnifiedPatchCreatesDeletesAndHonoursNoNewlineMarker(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "bye\n")
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\nb\n")
patch := strings.Join([]string{
"diff --git a/new.txt b/new.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1,2 @@",
"+first",
"+second",
"\\ No newline at end of file",
"diff --git a/gone.txt b/gone.txt",
"deleted file mode 100644",
"--- a/gone.txt",
"+++ /dev/null",
"@@ -1 +0,0 @@",
"-bye",
"--- a/keep.txt",
"+++ b/keep.txt",
"@@ -1,2 +1,2 @@",
" a",
"-b",
"+c",
"\\ No newline at end of file",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("multi-file unified patch should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "new.txt")); string(content) != "first\nsecond" {
t.Fatalf("created file = %q", string(content))
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("deleted file must be gone")
}
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "a\nc" {
t.Fatalf("updated file = %q", string(content))
}
if len(result.ChangedFiles) != 3 {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
}
func TestUnifiedPatchUsesRangeHintThenFallsBackToContext(t *testing.T) {
root := t.TempDir()
// Two identical blocks: the hunk's range picks the second one, which
// content search alone would call ambiguous.
writeTestFile(t, filepath.Join(root, "dup.txt"), "x\ny\nx\ny\n")
patch := strings.Join([]string{"--- a/dup.txt", "+++ b/dup.txt", "@@ -3,2 +3,2 @@", " x", "-y", "+z", ""}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("range-hinted hunk should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "dup.txt")); string(content) != "x\ny\nx\nz\n" {
t.Fatalf("content = %q", string(content))
}
// A stale range (file grew above the hunk) still applies by unique context.
writeTestFile(t, filepath.Join(root, "moved.txt"), "header\nhello\nold\n")
patch = strings.Join([]string{"--- a/moved.txt", "+++ b/moved.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("offset hunk should apply by context, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "moved.txt")); string(content) != "header\nhello\nnew\n" {
t.Fatalf("content = %q", string(content))
}
}
func TestUnifiedPatchRenameWithHunkAndRejectsBinary(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "x.txt"), "hello\nold\n")
rename := strings.Join([]string{
"diff --git a/x.txt b/y.txt",
"similarity index 90%",
"rename from x.txt",
"rename to y.txt",
"--- a/x.txt",
"+++ b/y.txt",
"@@ -1,2 +1,2 @@",
" hello",
"-old",
"+new",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": rename})
if result.Status != StatusOK {
t.Fatalf("rename with hunk should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "x.txt")); !os.IsNotExist(err) {
t.Fatal("renamed source must be gone")
}
if content, _ := os.ReadFile(filepath.Join(root, "y.txt")); string(content) != "hello\nnew\n" {
t.Fatalf("renamed content = %q", string(content))
}
for name, patch := range map[string]string{
"binary": "diff --git a/x b/x\nBinary files a/x and b/x differ\n",
"empty": "diff --git a/x b/x\n",
} {
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status == StatusOK {
t.Fatalf("%s patch must be rejected", name)
}
}
}
func TestUnifiedPatchCopyOperation(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "src.txt"), "hello\nold\n")
copyPatch := func(from, to string, hunk bool) string {
lines := []string{"diff --git a/" + from + " b/" + to, "similarity index 90%", "copy from " + from, "copy to " + to}
if hunk {
lines = append(lines, "--- a/"+from, "+++ b/"+to, "@@ -1,2 +1,2 @@", " hello", "-old", "+new")
}
return strings.Join(append(lines, ""), "\n")
}
// Success: source kept, destination created with the hunk applied, and the
// destination inherits the source's tracker state (read whole -> whole).
tracker := NewFileTracker()
registry := NewRegistry()
registry.Register(NewScopedReadFileTool(root, nil))
registry.Register(NewScopedApplyPatchTool(root, nil))
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "src.txt"}, opts); r.Status != StatusOK {
t.Fatalf("read: %s", r.Output)
}
result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": copyPatch("src.txt", "dst.txt", true)}, opts)
if result.Status != StatusOK {
t.Fatalf("copy with hunk should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "src.txt")); string(content) != "hello\nold\n" {
t.Fatalf("copy must keep the source, got %q", string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "dst.txt")); string(content) != "hello\nnew\n" {
t.Fatalf("copy destination = %q", string(content))
}
if len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "src.txt" || result.ChangedFiles[1] != "dst.txt" {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
destination, err := filepath.EvalSymlinks(filepath.Join(root, "dst.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(destination) {
t.Fatal("destination copied from a fully read source must be tracked as seen whole")
}
unreadSource, _ := filepath.EvalSymlinks(filepath.Join(root, "src.txt"))
if _, tracked := tracker.Version(unreadSource); !tracked {
t.Fatal("source must stay tracked after a copy")
}
// Failure: destination already exists — nothing is written.
writeTestFile(t, filepath.Join(root, "taken.txt"), "keep\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "taken.txt", false)})
if result.Status == StatusOK || !strings.Contains(result.Output, "already exists") {
t.Fatalf("copy onto an existing destination must be refused, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "taken.txt")); string(content) != "keep\n" {
t.Fatal("existing destination must be untouched")
}
// Failure: copy onto itself.
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "src.txt", false)})
if result.Status == StatusOK || !strings.Contains(result.Output, "onto itself") {
t.Fatalf("copy onto itself must be refused, got %s: %s", result.Status, result.Output)
}
}
func TestUnifiedPatchRejectsOverDeclaredHunkCounts(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "a.txt"), "hello\nold\n")
writeTestFile(t, filepath.Join(root, "b.txt"), "x\n")
// "-1,3 +1,3" over-declares a two-line hunk, so without a guard the next
// file's ---/+++ headers would be swallowed as "-"/"+" content.
patch := strings.Join([]string{
"--- a/a.txt", "+++ b/a.txt", "@@ -1,3 +1,3 @@", " hello", "-old", "+new",
"--- a/b.txt", "+++ b/b.txt", "@@ -1 +1 @@", "-x", "+y", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
t.Fatalf("over-declared hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
}
for name, want := range map[string]string{"a.txt": "hello\nold\n", "b.txt": "x\n"} {
if content, _ := os.ReadFile(filepath.Join(root, name)); string(content) != want {
t.Fatalf("%s must be untouched after a malformed patch, got %q", name, string(content))
}
}
// Truncated at end of input is reported the same way.
truncated := "--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n hello\n-old\n"
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": truncated}); result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
t.Fatalf("truncated hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
}
}
func TestUnifiedPatchNoNewlineMarkerOnlyAfterFinalHunk(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "two.txt"), "a\nb\nc\nd\ne\nf\n")
// A marker after the first of two hunks describes nothing real and must
// not leak into the second hunk's result: the patch is rejected untouched.
early := strings.Join([]string{
"--- a/two.txt", "+++ b/two.txt",
"@@ -1,2 +1,2 @@", " a", "-b", "+B", "\\ No newline at end of file",
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": early})
if result.Status == StatusOK || !strings.Contains(result.Output, "must follow the last hunk") {
t.Fatalf("marker before a later hunk must be rejected, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nb\nc\nd\ne\nf\n" {
t.Fatal("rejected patch must not modify the file")
}
// The same marker after the final hunk applies and strips the newline.
final := strings.Join([]string{
"--- a/two.txt", "+++ b/two.txt",
"@@ -1,2 +1,2 @@", " a", "-b", "+B",
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "\\ No newline at end of file", "",
}, "\n")
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": final})
if result.Status != StatusOK {
t.Fatalf("two-hunk patch with a final marker should apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nB\nc\nd\ne\nF" {
t.Fatalf("content = %q", string(content))
}
}
// A removed "-- …" line directly followed by an added "++ …" line looks like a
// ---/+++ header pair; the boundary check must not mistake it for one.
func TestUnifiedPatchKeepsAdjacentDashPlusContentLines(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.sql"), "select 1;\n-- old comment\nselect 2;\n")
patch := strings.Join([]string{
"--- a/notes.sql", "+++ b/notes.sql",
"@@ -1,3 +1,3 @@", " select 1;", "--- old comment", "+++ new comment", " select 2;", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("adjacent -- / ++ content lines must apply, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "notes.sql")); string(content) != "select 1;\n++ new comment\nselect 2;\n" {
t.Fatalf("content = %q", string(content))
}
}
// A unified deletion states the content it removes; it must be verified
// against the current file before anything is deleted.
func TestUnifiedPatchDeletionVerifiesExpectedContent(t *testing.T) {
deletion := func(path string, hunks ...string) string {
lines := []string{"diff --git a/" + path + " b/" + path, "deleted file mode 100644", "--- a/" + path, "+++ /dev/null"}
return strings.Join(append(append(lines, hunks...), ""), "\n")
}
t.Run("matching deletion succeeds", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-current")})
if result.Status != StatusOK {
t.Fatalf("matching deletion should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("file must be deleted")
}
})
t.Run("stale deletion is refused untouched", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-stale")})
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
t.Fatalf("stale deletion must be refused, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "current\n" {
t.Fatalf("file must be byte-for-byte unchanged, got %q", string(content))
}
})
t.Run("partial deletion is refused", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "one\ntwo\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-one")})
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
t.Fatalf("deletion that does not cover the file must be refused, got %s: %s", result.Status, result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "one\ntwo\n" {
t.Fatal("file must be unchanged")
}
})
t.Run("header-only deletion is refused", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt")})
if result.Status == StatusOK || !strings.Contains(result.Output, "must include the hunk") {
t.Fatalf("header-only deletion must be refused, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); err != nil {
t.Fatal("file must still exist")
}
})
t.Run("multi-hunk deletion verifies everything before removing", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\nb\nc\nd\n")
good := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-d")
bad := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-stale")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": bad})
if result.Status == StatusOK {
t.Fatalf("deletion with one stale hunk must be refused: %s", result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "a\nb\nc\nd\n" {
t.Fatal("no hunk may be applied when any hunk is stale")
}
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": good})
if result.Status != StatusOK {
t.Fatalf("multi-hunk deletion should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("file must be deleted")
}
})
t.Run("structured delete stays unconditional", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "anything\n")
patch := "*** Begin Patch\n*** Delete File: gone.txt\n*** End Patch\n"
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
t.Fatalf("structured delete should apply, got %s: %s", result.Status, result.Output)
}
})
}
// The source is re-read through the root immediately before commit, so a
// change made after planning is refused instead of overwritten or removed.
func TestApplyStructuredPatchChangeRefusesSourceChangedAfterPlanning(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "file.txt")
writeTestFile(t, path, "planned\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
target := structuredPatchTarget{requested: "file.txt", absolute: path, relative: "file.txt"}
destination := structuredPatchTarget{requested: "copy.txt", absolute: filepath.Join(root, "copy.txt"), relative: "copy.txt"}
for name, change := range map[string]structuredPatchChange{
"update": {kind: structuredPatchUpdate, from: target, to: target, before: "planned\n", after: "new\n", mode: 0o644},
"delete": {kind: structuredPatchDelete, from: target, to: target, before: "planned\n", mode: 0o644},
"copy": {kind: structuredPatchCopy, from: target, to: destination, before: "planned\n", after: "planned\n", mode: 0o644},
} {
writeTestFile(t, path, "changed after planning\n")
committed, err := applyStructuredPatchChange(workspace, change)
if err == nil || committed || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
t.Fatalf("%s: expected a refusal, got committed=%v err=%v", name, committed, err)
}
if content, _ := os.ReadFile(path); string(content) != "changed after planning\n" {
t.Fatalf("%s: file must be untouched, got %q", name, string(content))
}
if _, statErr := os.Stat(filepath.Join(root, "copy.txt")); !os.IsNotExist(statErr) {
t.Fatalf("%s: refused change must not create copy.txt", name)
}
}
}
func TestUnifiedPatchDeletionIsByteExact(t *testing.T) {
deletion := func(hunks ...string) string {
return strings.Join(append(append([]string{"--- a/gone.txt", "+++ /dev/null"}, hunks...), ""), "\n")
}
for name, tc := range map[string]struct{ file, patch string }{
"trailing whitespace differs": {"current \n", deletion("@@ -1 +0,0 @@", "-current")},
"indentation differs": {" current\n", deletion("@@ -1 +0,0 @@", "-current")},
"blank line not covered": {"current\n\n", deletion("@@ -1 +0,0 @@", "-current")},
"space-only line not covered": {"current\n \n", deletion("@@ -1 +0,0 @@", "-current")},
"missing final newline": {"current", deletion("@@ -1 +0,0 @@", "-current")},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), tc.file)
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": tc.patch})
if result.Status == StatusOK {
t.Fatalf("deletion must be byte-exact; %q was deleted by a non-matching hunk", tc.file)
}
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != tc.file {
t.Fatalf("file must be unchanged, got %q", string(content))
}
})
}
t.Run("no-newline marker on the removed side matches exactly", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "current")
patch := deletion("@@ -1 +0,0 @@", "-current", "\\ No newline at end of file")
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
t.Fatalf("deletion with a matching no-newline marker should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
t.Fatal("file must be deleted")
}
})
t.Run("CRLF file matches an LF patch of the same content", func(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\r\nb\r\n")
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("@@ -1,2 +0,0 @@", "-a", "-b")}); result.Status != StatusOK {
t.Fatalf("CRLF deletion should apply, got %s: %s", result.Status, result.Output)
}
})
}
// git describes an empty file's creation or deletion with header lines only.
func TestUnifiedPatchHeaderOnlyEmptyFileForms(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "empty.txt"), "")
writeTestFile(t, filepath.Join(root, "full.txt"), "content\n")
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\n")
deleteHeader := func(path string) string {
return "diff --git a/" + path + " b/" + path + "\ndeleted file mode 100644\nindex e69de29..0000000\n"
}
// A non-empty file is not deleted by the header-only form.
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deleteHeader("full.txt")})
if result.Status == StatusOK || !strings.Contains(result.Output, "expects an empty file") {
t.Fatalf("header-only deletion of a non-empty file must be refused, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "full.txt")); err != nil {
t.Fatal("non-empty file must still exist")
}
// In a multi-file patch the empty-file deletion and creation are applied
// alongside a normal hunk.
patch := deleteHeader("empty.txt") +
"diff --git a/blank.txt b/blank.txt\nnew file mode 100644\nindex 0000000..e69de29\n" +
"diff --git a/keep.txt b/keep.txt\n--- a/keep.txt\n+++ b/keep.txt\n@@ -1 +1 @@\n-a\n+b\n"
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("multi-file patch with header-only forms should apply, got %s: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(root, "empty.txt")); !os.IsNotExist(err) {
t.Fatal("empty file must be deleted")
}
if content, err := os.ReadFile(filepath.Join(root, "blank.txt")); err != nil || len(content) != 0 {
t.Fatalf("empty file must be created, got err=%v content=%q", err, string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "b\n" {
t.Fatalf("hunk file = %q", string(content))
}
if len(result.ChangedFiles) != 3 {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
}
// When a later change fails, the error names exactly which files were already
// committed so the caller knows what changed and what did not.
func TestApplyPatchOperationsReportsCommittedPrefixOnFailure(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "first.txt"), "one\n")
writeTestFile(t, filepath.Join(root, "second.txt"), "two\n")
writeTestFile(t, filepath.Join(root, "third.txt"), "three\n")
// Make second.txt change after planning so its commit is refused.
structuredPatchBeforeCommit = func(change structuredPatchChange) {
if change.to.relative == "second.txt" {
writeTestFile(t, filepath.Join(root, "second.txt"), "changed\n")
}
}
defer func() { structuredPatchBeforeCommit = nil }()
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: first.txt", "@@", "-one", "+ONE",
"*** Update File: second.txt", "@@", "-two", "+TWO",
"*** Update File: third.txt", "@@", "-three", "+THREE",
"*** End Patch", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status == StatusOK {
t.Fatal("patch must fail when a later change is refused")
}
for _, want := range []string{"already committed: first.txt", "remaining files are unchanged"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("error must report the committed prefix, got: %s", result.Output)
}
}
if strings.Contains(result.Output, "committed: first.txt, second.txt") || strings.Contains(result.Output, "third.txt") {
t.Fatalf("error must not list uncommitted files as committed: %s", result.Output)
}
if content, _ := os.ReadFile(filepath.Join(root, "first.txt")); string(content) != "ONE\n" {
t.Fatalf("first.txt must hold the committed change, got %q", string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "second.txt")); string(content) != "changed\n" {
t.Fatalf("second.txt must be untouched by the patch, got %q", string(content))
}
if content, _ := os.ReadFile(filepath.Join(root, "third.txt")); string(content) != "three\n" {
t.Fatalf("third.txt must be untouched, got %q", string(content))
}
}
+19
View File
@@ -231,6 +231,25 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
}
}
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, key := range keys {
if !want[key] {
t.Fatalf("unexpected key %q in %v", key, keys)
}
delete(want, key)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
+1 -1
View File
@@ -168,7 +168,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
if updated == modelKnownContent {
if previouslySeenWhole {
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(updated), lineCount(updated))
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(updated), trackedLineTotal(updated))
} else {
for _, span := range editedSpans {
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
+106
View File
@@ -221,6 +221,112 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
}
}
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-beta",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if tracker.SeenWhole(trackedPath) {
t.Fatal("a partial structured patch credited the unread file as fully seen")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
}
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
t.Fatalf("file = %q, want %q", got, want)
}
}
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
t.Fatalf("read_file failed: %s", result.Output)
}
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-alpha",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(trackedPath) {
t.Fatal("structured patch discarded an existing whole-file observation")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusOK {
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
}
}
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: created.txt",
"+supplied",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(path) {
t.Fatal("a fully supplied structured add was not credited as fully seen")
}
}
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
+7 -7
View File
@@ -26,8 +26,8 @@ func TestReadFileToolReadsLineRanges(t *testing.T) {
}
for _, want := range []string{
"File: notes.txt (lines 2-3 of 4)",
"2 | beta",
"3 | gamma",
"2→beta",
"3→gamma",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
@@ -54,7 +54,7 @@ func TestReadFileToolReadsCanonicalLineRange(t *testing.T) {
if result.Truncated {
t.Fatalf("canonical limit defines the requested range and must not mark it truncated: %#v", result.Meta)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
t.Fatalf("canonical range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
@@ -94,7 +94,7 @@ func TestReadFileToolMixedLegacyRangesPreferLines(t *testing.T) {
if result.Status != StatusOK {
t.Fatalf("mixed legacy range should recover, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
t.Fatalf("mixed legacy range did not prefer lines: %q", result.Output)
}
}
@@ -110,7 +110,7 @@ func TestReadFileToolCombinesLegacyStartWithCanonicalLimit(t *testing.T) {
if result.Status != StatusOK || result.Truncated {
t.Fatalf("mixed compatible range should be exact, got status=%s truncated=%v: %s", result.Status, result.Truncated, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
t.Fatalf("mixed compatible range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
@@ -955,7 +955,7 @@ func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
for _, want := range []string{
"end_line 2 was before start_line 3", // the note
"only line 3 was read",
"3 | gamma",
"3→gamma",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in output, got %q", want, result.Output)
@@ -984,7 +984,7 @@ func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
// Byte-for-byte: header, blank separator, the exact selected lines, and no
// recovery note or trailing content. An altered header/separator or a stray
// out-of-range line would fail this where a substring check would not.
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
const want = "File: notes.txt (lines 2-4 of 5)\n\n2→beta\n3→gamma\n4→delta"
if result.Output != want {
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
}
+11
View File
@@ -0,0 +1,11 @@
package tools
// ModelVisible reports whether a registered tool is advertised to an agent.
// Every registered tool is visible. edit_file (exact search/replace with a
// uniqueness guard, CRLF and fuzzy fallbacks) was hidden for a while in favour
// of apply_patch; head-to-head benchmarking showed that when a model misses the
// patch grammar it degrades to whole-file rewrites, so both edit tools are
// advertised and the prompt steers between them.
func ModelVisible(tool Tool) bool {
return tool != nil
}
+29
View File
@@ -0,0 +1,29 @@
package tools
import "testing"
func TestToolSearchSurfacesBothEditTools(t *testing.T) {
registry := NewRegistry()
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
search := NewToolSearchTool(registry).(toolSearchTool)
eager := search.visibleEagerToolNames(nil, nil, "ask")
if !eager["edit_file"] {
t.Fatal("tool_search must describe edit_file as available to the model")
}
if !eager["apply_patch"] {
t.Fatal("tool_search must retain apply_patch as an available edit tool")
}
}
func TestModelVisibleAdvertisesEveryRegisteredTool(t *testing.T) {
if ModelVisible(nil) {
t.Fatal("a nil tool must not be visible")
}
for _, tool := range []Tool{NewScopedEditFileTool(t.TempDir(), nil), NewScopedApplyPatchTool(t.TempDir(), nil), NewScopedWriteFileTool(t.TempDir(), nil)} {
if !ModelVisible(tool) {
t.Fatalf("%s must be visible to the model", tool.Name())
}
}
}
+34
View File
@@ -1,5 +1,7 @@
package tools
import "path/filepath"
// MutationTargets returns the workspace-relative paths a tool call will write to,
// so the session layer can snapshot their before-state for safe rewind. It is a
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
@@ -36,6 +38,19 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
if err != nil {
return nil
}
if isStructuredPatch(patch) {
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
paths := structuredPatchOperationPaths(operations)
for _, path := range paths {
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
return nil
}
}
return prefixPatchPaths(relativeRoot, paths)
}
// Enforce the same workspace confinement apply_patch applies (against the
// resolved apply dir), so a patch with a traversal path (../x) never yields
// an out-of-workspace target.
@@ -51,3 +66,22 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
return nil
}
}
func prefixPatchPaths(relativeRoot string, paths []string) []string {
if len(paths) == 0 {
return nil
}
seen := make(map[string]bool)
var out []string
for _, path := range paths {
workspacePath := path
if relativeRoot != "" && relativeRoot != "." {
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[workspacePath] {
seen[workspacePath] = true
out = append(out, workspacePath)
}
}
return out
}
+11
View File
@@ -17,6 +17,7 @@ func TestMutationTargets(t *testing.T) {
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
{"bash", map[string]any{"command": "echo hi"}, nil},
{"read_file", map[string]any{"path": "e.txt"}, nil},
{"grep", map[string]any{"pattern": "x"}, nil},
@@ -62,6 +63,10 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
}
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
}
}
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
@@ -82,6 +87,12 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
t.Fatalf("got %v, want %v", got, want)
}
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
if !reflect.DeepEqual(got, want) {
t.Fatalf("structured cwd: got %v, want %v", got, want)
}
// cwd "." (the default) must keep paths unprefixed.
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
if !reflect.DeepEqual(got, []string{"d.txt"}) {
+11 -9
View File
@@ -9,12 +9,14 @@ import (
"io"
"os"
"strconv"
"strings"
"unicode/utf8"
)
const readFileByteChunkMax = 64 * 1024
// readFileLinePrefixSeparator follows the line number on every read_file line.
const readFileLinePrefixSeparator = "→"
type readFileTool struct {
baseTool
workspaceRoot string
@@ -31,7 +33,7 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
return readFileTool{
baseTool: baseTool{
name: "read_file",
description: "Read exact file text with line numbers. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
description: "Read exact file text, each line prefixed with its line number and →. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
@@ -221,7 +223,6 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
}
lastLine := startLine + selectedLines - 1
width := len(strconv.Itoa(lastLine))
header := fmt.Sprintf("File: %s (%d lines)", relativePath, total)
if startLine != 1 || endLine != total || maxLines > 0 {
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
@@ -244,7 +245,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
budgetedOutput.WriteString("\n")
}
budgetedOutput.WriteString("\n")
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines); err != nil {
return errorResult("Error reading file " + relativePath + ": " + err.Error())
}
if truncated {
@@ -357,7 +358,7 @@ func renderReadFileBytes(path, relativePath string, total, requestedStart, limit
return Result{Status: StatusOK, Output: output, Meta: map[string]string{"next_byte_offset": strconv.Itoa(end)}}, start, end
}
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int, width int) error {
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int) error {
file, err := os.Open(path)
if err != nil {
return err
@@ -380,10 +381,11 @@ func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int
if emitted > 0 {
output.WriteString("\n")
}
number := strconv.Itoa(lineNumber)
output.WriteString(strings.Repeat(" ", width-len(number)))
output.WriteString(number)
output.WriteString(" | ")
// Compact "N→" prefix: the padded " N | " form cost ~19% of every
// read (about 1.5k tokens per 800-line file) and was re-sent on each
// later call; no consumer parses it, and models read "N→" natively.
output.WriteString(strconv.Itoa(lineNumber))
output.WriteString(readFileLinePrefixSeparator)
output.WriteString(string(trimLineBreak(raw, ended)))
emitted++
}
+20 -8
View File
@@ -171,14 +171,9 @@ func selectSourceLines(content []byte, offset, limit int) sourceSelection {
}
func sourceLineCount(content []byte) int {
if len(content) == 0 {
return 1
}
lines := strings.Count(string(content), "\n")
if content[len(content)-1] != '\n' {
lines++
}
return lines
// One implementation for reader and writer: the tracker contract lives on
// trackedLineTotal, and this must never drift from it.
return trackedLineTotal(string(content))
}
// lineCount reports the number of newline-separated lines in s (an empty string
@@ -189,3 +184,20 @@ func lineCount(s string) int {
}
return strings.Count(s, "\n") + 1
}
// trackedLineTotal reports a file's line count the way read_file reports it
// to the FileTracker (newline-terminated lines, plus an unterminated last
// line; an empty file is one line). Writers must record this same number:
// RecordSeenRange resets every observation when the total changes, so a
// writer that recorded lineCount (one higher for a trailing newline) made the
// next partial read_file wipe whole-file knowledge and refuse the next edit.
func trackedLineTotal(s string) int {
if s == "" {
return 1
}
total := strings.Count(s, "\n")
if !strings.HasSuffix(s, "\n") {
total++
}
return total
}
+12 -1
View File
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromUnifiedDiff(patch) {
for _, p := range pathsFromApplyPatch(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
@@ -191,6 +191,17 @@ func applyPatchResourceKeys(args map[string]any) []string {
return keys
}
func pathsFromApplyPatch(patch string) []string {
if !isStructuredPatch(patch) {
return pathsFromUnifiedDiff(patch)
}
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
return structuredPatchOperationPaths(operations)
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
+959
View File
@@ -0,0 +1,959 @@
package tools
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/Gitlawb/zero/internal/pathjail"
"github.com/Gitlawb/zero/internal/sandbox"
)
const (
structuredPatchBegin = "*** Begin Patch"
structuredPatchEnd = "*** End Patch"
structuredAddFile = "*** Add File: "
structuredDeleteFile = "*** Delete File: "
structuredUpdateFile = "*** Update File: "
structuredMoveTo = "*** Move to: "
structuredEndOfFile = "*** End of File"
structuredHunkContext = "@@ "
)
type structuredPatchKind uint8
const (
structuredPatchAdd structuredPatchKind = iota
structuredPatchDelete
structuredPatchUpdate
// structuredPatchCopy creates movePath from path's content (plus any
// hunks) and keeps path; produced by a unified diff's "copy from/to".
structuredPatchCopy
)
type structuredPatchOperation struct {
kind structuredPatchKind
path string
movePath string
contents string
chunks []structuredPatchChunk
line int
// eofNewline lets a unified diff's "\ No newline at end of file" marker
// force the trailing-newline state of the result; structured patches keep
// the file's existing state.
eofNewline eofNewlineMode
// verifyDelete marks a deletion that carries the expected old content
// (a unified diff's "+++ /dev/null" hunks, or git's header-only form for
// an empty file): the removed lines must equal the current file byte for
// byte, otherwise the deletion is stale and refused. A structured
// "*** Delete File" means "delete this path" and has no chunks.
verifyDelete bool
// oldNoNewline records a unified diff's "\ No newline at end of file"
// on the removed side, i.e. the old content did not end with a newline.
oldNoNewline bool
}
type eofNewlineMode uint8
const (
eofNewlineKeep eofNewlineMode = iota
eofNewlinePresent
eofNewlineAbsent
)
type structuredPatchChunk struct {
context string
hasContext bool
old []string
new []string
newSourceOffsets []int
endOfFile bool
// hint is the 0-based line the hunk is expected at (from a unified diff's
// "@@ -a,b" range). When the expected lines match there it is used as-is;
// otherwise the hunk is located by content like a structured hunk.
hint int
hasHint bool
}
type structuredPatchTarget struct {
requested string
absolute string
relative string
}
type structuredPatchChange struct {
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
}
// unifiedHunkRangePattern recognises a unified-diff range header ("-12,4 +12,6",
// optionally followed by "@@ heading") written inside a structured hunk marker.
// The line numbers carry no information for the context-anchored grammar, so
// the range is dropped and only an explicit heading is kept as the anchor.
var unifiedHunkRangePattern = regexp.MustCompile(`^-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*(?:@@\s*(.*))?$`)
const structuredPatchFormatHint = ` (format: "*** Begin Patch", then "*** Update File: path" / "*** Add File: path" / "*** Delete File: path" sections whose hunks start with "@@" and use " " context, "-" removed and "+" added lines, then "*** End Patch")`
// structuredPatchMarker classifies a line as the "begin" or "end" marker of a
// structured patch, or "" when it is neither. The classifier is owned by the
// sandbox package so the boundary check and the tool accept exactly the same
// spellings; a strict byte-equal match here once made every patch from some
// models fail on line 1 and pushed them into whole-file rewrites.
func structuredPatchMarker(line string) string {
return sandbox.StructuredPatchMarker(line)
}
// structuredHunkAnchor normalises the text after a hunk's "@@ " marker. A
// unified-diff range is dropped (its optional heading survives as the anchor);
// anything else is used verbatim.
func structuredHunkAnchor(context string) string {
context = strings.TrimSpace(context)
if match := unifiedHunkRangePattern.FindStringSubmatch(context); match != nil {
return strings.TrimSpace(match[1])
}
return context
}
func isStructuredPatch(patch string) bool {
return sandbox.IsStructuredPatch(patch)
}
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
operations, err := parseStructuredPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
}
// applyPatchOperations applies parsed operations (from either patch format)
// through an opened workspace root: every stat, read, create and write is
// descriptor-relative and refuses to follow a link out of the root, so there
// is no pathname check-to-use window between validation and write.
func applyPatchOperations(applyRoot, relativeRoot string, operations []structuredPatchOperation, options RunOptions) Result {
workspace, err := os.OpenRoot(applyRoot)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
defer workspace.Close()
changes, err := planStructuredPatch(workspace, operations, options.FileTracker)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
wholeBefore := make(map[string]bool, len(changes))
if options.FileTracker != nil {
for _, change := range changes {
if change.kind == structuredPatchUpdate || change.kind == structuredPatchCopy {
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
}
}
}
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
for _, change := range changes {
switch change.kind {
case structuredPatchDelete:
options.FileTracker.Forget(change.from.absolute)
case structuredPatchAdd:
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true)
case structuredPatchUpdate:
wasWhole := wholeBefore[change.from.absolute]
options.FileTracker.Forget(change.from.absolute)
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
case structuredPatchCopy:
// The source is untouched; the destination inherits only what the
// model had actually seen of the source.
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, wholeBefore[change.from.absolute])
}
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
return result
}
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
if normalized == "" {
return nil, fmt.Errorf("structured patch is empty")
}
lines := strings.Split(normalized, "\n")
if structuredPatchMarker(lines[0]) != "begin" {
return nil, fmt.Errorf("the first line of a structured patch must be %q%s", structuredPatchBegin, structuredPatchFormatHint)
}
if structuredPatchMarker(lines[len(lines)-1]) != "end" {
return nil, fmt.Errorf("the last line of a structured patch must be %q%s", structuredPatchEnd, structuredPatchFormatHint)
}
var operations []structuredPatchOperation
for index := 1; index < len(lines)-1; {
header := strings.TrimSpace(lines[index])
lineNumber := index + 1
if header == "" {
index++
continue
}
switch {
case strings.HasPrefix(header, structuredAddFile):
path, err := structuredPatchPath(header, structuredAddFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchAdd, path: path, line: lineNumber}
index++
var content []string
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
if !strings.HasPrefix(lines[index], "+") {
return nil, structuredPatchLineError(index+1, "added-file contents must start with '+'")
}
content = append(content, strings.TrimPrefix(lines[index], "+"))
index++
}
if len(content) > 0 {
op.contents = strings.Join(content, "\n") + "\n"
}
operations = append(operations, op)
case strings.HasPrefix(header, structuredDeleteFile):
path, err := structuredPatchPath(header, structuredDeleteFile, lineNumber)
if err != nil {
return nil, err
}
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: path, line: lineNumber})
index++
case strings.HasPrefix(header, structuredUpdateFile):
path, err := structuredPatchPath(header, structuredUpdateFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchUpdate, path: path, line: lineNumber}
index++
if index < len(lines)-1 && strings.HasPrefix(strings.TrimSpace(lines[index]), structuredMoveTo) {
op.movePath, err = structuredPatchPath(strings.TrimSpace(lines[index]), structuredMoveTo, index+1)
if err != nil {
return nil, err
}
index++
}
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
line := lines[index]
trimmed := strings.TrimSpace(line)
if trimmed == structuredEndOfFile {
if len(op.chunks) == 0 || (!hasStructuredPatchContent(op.chunks[len(op.chunks)-1])) {
return nil, structuredPatchLineError(index+1, "*** End of File must follow an update hunk")
}
op.chunks[len(op.chunks)-1].endOfFile = true
index++
continue
}
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
chunk := structuredPatchChunk{}
rest := ""
if trimmed != "@@" {
rest = strings.TrimPrefix(trimmed, structuredHunkContext)
}
if anchor := structuredHunkAnchor(rest); anchor != "" {
chunk.context = anchor
chunk.hasContext = true
}
op.chunks = append(op.chunks, chunk)
index++
continue
}
if len(op.chunks) == 0 {
op.chunks = append(op.chunks, structuredPatchChunk{})
}
chunk := &op.chunks[len(op.chunks)-1]
switch {
case line == "":
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, "")
chunk.new = append(chunk.new, "")
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, " "):
content := strings.TrimPrefix(line, " ")
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, "+"):
chunk.new = append(chunk.new, strings.TrimPrefix(line, "+"))
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
case strings.HasPrefix(line, "-"):
chunk.old = append(chunk.old, strings.TrimPrefix(line, "-"))
default:
return nil, structuredPatchLineError(index+1, "update lines must start with ' ', '+', or '-'")
}
index++
}
if len(op.chunks) == 0 || !allStructuredPatchChunksHaveContent(op.chunks) {
return nil, structuredPatchLineError(lineNumber, "update file hunk is empty")
}
operations = append(operations, op)
default:
return nil, structuredPatchLineError(lineNumber, "expected an Add File, Delete File, or Update File header")
}
}
if len(operations) == 0 {
return nil, fmt.Errorf("structured patch contains no file operations")
}
return operations, nil
}
func structuredPatchPath(header, prefix string, line int) (string, error) {
path := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if path == "" {
return "", structuredPatchLineError(line, "file path is required")
}
return path, nil
}
func isStructuredPatchHeader(line string) bool {
return structuredPatchMarker(line) == "end" || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
}
func structuredPatchLineError(line int, message string) error {
return fmt.Errorf("invalid structured patch at line %d: %s", line, message)
}
func hasStructuredPatchContent(chunk structuredPatchChunk) bool {
return len(chunk.old) > 0 || len(chunk.new) > 0
}
func allStructuredPatchChunksHaveContent(chunks []structuredPatchChunk) bool {
for _, chunk := range chunks {
if !hasStructuredPatchContent(chunk) {
return false
}
}
return true
}
func structuredPatchOperationPaths(operations []structuredPatchOperation) []string {
seen := make(map[string]bool)
var paths []string
for _, operation := range operations {
for _, path := range []string{operation.path, operation.movePath} {
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, tracker *FileTracker) ([]structuredPatchChange, error) {
seen := make(map[string]struct{})
var changes []structuredPatchChange
for _, operation := range operations {
from, err := resolveStructuredPatchTarget(root.Name(), operation.path)
if err != nil {
return nil, err
}
to := from
if operation.movePath != "" {
to, err = resolveStructuredPatchTarget(root.Name(), operation.movePath)
if err != nil {
return nil, err
}
}
targets := []structuredPatchTarget{from}
if to.absolute != from.absolute {
targets = append(targets, to)
}
for _, target := range targets {
if _, exists := seen[target.absolute]; exists {
return nil, fmt.Errorf("structured patch changes %q more than once", target.relative)
}
seen[target.absolute] = struct{}{}
}
change := structuredPatchChange{kind: operation.kind, from: from, to: to, mode: 0o644}
switch operation.kind {
case structuredPatchAdd:
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot add %s because it already exists", to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate, structuredPatchCopy:
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
}
change.mode = info.Mode()
content, err := root.ReadFile(from.relative)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
}
if err := tracker.CheckConflict(from.absolute, content); err != nil {
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
change.before = string(content)
if operation.kind == structuredPatchDelete && operation.verifyDelete {
if err := verifyUnifiedDeletion(change.before, from.relative, operation); err != nil {
return nil, err
}
}
if operation.kind == structuredPatchUpdate || operation.kind == structuredPatchCopy {
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
if err != nil {
return nil, err
}
change.after = applyEOFNewline(updated, operation.eofNewline)
if operation.kind == structuredPatchCopy && from.absolute == to.absolute {
return nil, fmt.Errorf("cannot copy %s onto itself", from.relative)
}
if from.absolute != to.absolute {
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
}
}
}
changes = append(changes, change)
}
return changes, nil
}
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
// Relative traversal is rejected outright. An absolute path is allowed when
// it resolves inside the apply root (models routinely echo the absolute path
// they were shown by read_file); resolveWorkspaceTargetPath rejects anything
// that lands outside.
if path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
}
absolute, relative, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path))
if err != nil {
return structuredPatchTarget{}, err
}
return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil
}
func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchChunk) (string, error) {
lineEnding := structuredPatchLineEnding(content)
if lineEnding == "\r\n" {
content = strings.ReplaceAll(content, "\r\n", "\n")
}
lines := strings.Split(content, "\n")
trailingNewline := content != "" && len(lines) > 0 && lines[len(lines)-1] == ""
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
replacements := make([]structuredReplacement, 0, len(chunks))
lineIndex := 0
for _, chunk := range chunks {
if chunk.hasContext {
index, ambiguous := findStructuredPatchSequence(lines, []string{chunk.context}, lineIndex, false)
if ambiguous {
return "", fmt.Errorf("context %q is ambiguous in %s; provide a more specific hunk header", chunk.context, path)
}
if index < 0 {
return "", fmt.Errorf("could not find context %q in %s", chunk.context, path)
}
lineIndex = index + 1
}
if len(chunk.old) == 0 {
// A context anchor identifies the line immediately before a pure
// insertion. Only an explicit end-of-file hunk (or an unanchored
// insertion) belongs at the file end. A unified diff names the
// insertion point in its range instead; a zero-context hunk (as
// from `git diff -U0`) has nothing to verify the position against,
// so it is position-trusting by design — the same semantics git
// apply uses without fuzz. Hunks that carry context are verified.
start := lineIndex
if chunk.hasHint {
start = max(chunk.hint, lineIndex)
} else if chunk.endOfFile || !chunk.hasContext {
start = len(lines)
}
if start > len(lines) {
start = len(lines)
}
replacement, err := structuredPatchReplacement(chunk, lines, start)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: start, replacement: replacement})
lineIndex = start
continue
}
var index int
ambiguous := false
if chunk.hasHint && chunk.hint >= lineIndex && sequenceMatchesAt(lines, chunk.old, chunk.hint) {
index = chunk.hint
} else {
index, ambiguous = findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
}
if ambiguous {
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
}
if index < 0 {
return "", fmt.Errorf("could not find expected lines in %s:\n%s", path, strings.Join(chunk.old, "\n"))
}
replacement, err := structuredPatchReplacement(chunk, lines, index)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: index, length: len(chunk.old), replacement: replacement})
lineIndex = index + len(chunk.old)
}
for index := 1; index < len(replacements); index++ {
previous := replacements[index-1]
current := replacements[index]
if current.start < previous.start+previous.length {
return "", fmt.Errorf("overlapping or out-of-order hunks in %s; provide non-overlapping context", path)
}
}
for index := len(replacements) - 1; index >= 0; index-- {
replacement := replacements[index]
lines = append(lines[:replacement.start], append(replacement.replacement, lines[replacement.start+replacement.length:]...)...)
}
if len(lines) == 0 {
return "", nil
}
updated := strings.Join(lines, lineEnding)
if trailingNewline {
updated += lineEnding
}
return updated, nil
}
// verifyUnifiedDeletion checks, byte for byte, that the lines a unified
// deletion removes are exactly the file's current content. A deletion is not
// recoverable, so none of the whitespace tolerance used to locate update
// hunks applies here; only the file's own line-ending style is normalised.
// With no hunks (git's header-only form) the file must be empty.
func verifyUnifiedDeletion(current, path string, operation structuredPatchOperation) error {
var removed []string
for _, chunk := range operation.chunks {
if len(chunk.new) > 0 {
return fmt.Errorf("deletion of %s must not add lines", path)
}
removed = append(removed, chunk.old...)
}
expected := strings.Join(removed, "\n")
if len(removed) > 0 && !operation.oldNoNewline {
expected += "\n"
}
actual := current
if structuredPatchLineEnding(current) == "\r\n" {
actual = strings.ReplaceAll(current, "\r\n", "\n")
}
if actual != expected {
if len(removed) == 0 {
return fmt.Errorf("deletion of %s expects an empty file but it has content; include the removed lines in the patch", path)
}
return fmt.Errorf("deletion of %s does not match its current content; the removed lines must equal the whole file", path)
}
return nil
}
// sequenceMatchesAt reports whether wanted appears verbatim at lines[start:].
func sequenceMatchesAt(lines, wanted []string, start int) bool {
if start < 0 || start+len(wanted) > len(lines) {
return false
}
for offset, line := range wanted {
if lines[start+offset] != line {
return false
}
}
return true
}
// applyEOFNewline forces or strips a single trailing line ending when a
// unified diff's end-of-file marker asked for it.
func applyEOFNewline(content string, mode eofNewlineMode) string {
switch mode {
case eofNewlinePresent:
if content != "" && !strings.HasSuffix(content, "\n") {
return content + structuredPatchLineEnding(content)
}
case eofNewlineAbsent:
content = strings.TrimSuffix(content, "\n")
content = strings.TrimSuffix(content, "\r")
}
return content
}
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
if len(chunk.newSourceOffsets) != len(chunk.new) {
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
}
replacement := append([]string(nil), chunk.new...)
for outputIndex, sourceOffset := range chunk.newSourceOffsets {
if sourceOffset < 0 {
continue
}
sourceIndex := start + sourceOffset
if sourceIndex < 0 || sourceIndex >= len(source) {
return nil, fmt.Errorf("context source index %d is outside the matched file", sourceIndex)
}
replacement[outputIndex] = source[sourceIndex]
}
return replacement, nil
}
func structuredPatchLineEnding(content string) string {
if strings.Contains(content, "\r\n") && !strings.Contains(strings.ReplaceAll(content, "\r\n", ""), "\n") {
return "\r\n"
}
return "\n"
}
type structuredReplacement struct {
start int
length int
replacement []string
}
func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bool) (int, bool) {
if len(wanted) == 0 {
return start, false
}
if len(wanted) > len(lines) {
return -1, false
}
searchStart := start
if endOfFile {
searchStart = len(lines) - len(wanted)
if searchStart < start {
searchStart = start
}
}
for _, equal := range []func(string, string) bool{
func(left, right string) bool { return left == right },
func(left, right string) bool {
return strings.TrimRight(left, " \t") == strings.TrimRight(right, " \t")
},
func(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) },
} {
match := -1
for index := searchStart; index+len(wanted) <= len(lines); index++ {
matched := true
for offset, line := range wanted {
if !equal(lines[index+offset], line) {
matched = false
break
}
}
if matched {
if match >= 0 {
return -1, true
}
match = index
}
}
if match >= 0 {
return match, false
}
}
return -1, false
}
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
// committed lists, in order, the paths whose change reached disk before a
// later change failed, so the caller (and the model) knows exactly which
// files now hold the patched content and which were never touched.
var committed []string
for _, change := range changes {
done, err := applyStructuredPatchChange(root, change)
if done {
committed = append(committed, structuredPatchChangePaths(change)...)
}
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
if len(committed) > 0 {
return fmt.Errorf("%w; patch was partially applied — already committed: %s; the remaining files are unchanged; re-read the committed files before retrying", err, strings.Join(committed, ", "))
}
return err
}
}
return nil
}
// structuredPatchChangePaths names the workspace-relative paths a committed
// change touched: the destination, plus the source of a move or copy.
func structuredPatchChangePaths(change structuredPatchChange) []string {
if change.kind == structuredPatchDelete {
return []string{change.from.relative}
}
paths := []string{change.to.relative}
if change.from.absolute != change.to.absolute && change.from.relative != "" {
paths = append([]string{change.from.relative}, paths...)
}
return paths
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
if tracker == nil {
return
}
for _, change := range changes {
tracker.Forget(change.from.absolute)
if change.to.absolute != change.from.absolute {
tracker.Forget(change.to.absolute)
}
}
}
// structuredPatchBeforeCommit, when set, runs before each change's pre-commit
// recheck. Tests use it to alter the filesystem between planning and commit
// deterministically; it is nil in production.
var structuredPatchBeforeCommit func(change structuredPatchChange)
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
if structuredPatchBeforeCommit != nil {
structuredPatchBeforeCommit(change)
}
// The planner validated the source against the bytes it read; re-read
// through the same root immediately before committing so a change made
// by another process in between is refused rather than overwritten or
// removed.
if change.kind != structuredPatchAdd {
current, err := root.ReadFile(change.from.relative)
if err != nil {
return false, fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
}
if string(current) != change.before {
return false, fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
}
}
switch change.kind {
case structuredPatchDelete:
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return true, nil
case structuredPatchAdd, structuredPatchCopy:
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
if err != nil {
return committed, err
}
if moving {
if err := root.Remove(change.from.relative); err != nil {
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
}
}
return true, nil
}
return false, fmt.Errorf("unsupported structured patch operation")
}
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
parent := filepath.Dir(target.relative)
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
}
temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp")
if err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
defer func() { _ = removeStructuredPatchTemp(root, tempName) }()
if _, err := temp.WriteString(content); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Chmod(mode.Perm()); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Close(); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if createOnly {
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
}
if err := root.Rename(tempName, target.relative); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
return true, nil
}
func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
}
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
if linkErr := link(source, target); linkErr == nil {
cleanupErr := removeStructuredPatchTemp(root, source)
chmodErr := root.Chmod(target, mode.Perm())
if cleanupErr != nil || chmodErr != nil {
return true, fmt.Errorf("publishing %s: %w", target, errors.Join(cleanupErr, chmodErr))
}
return true, nil
} else if errors.Is(linkErr, os.ErrExist) {
return false, fmt.Errorf("creating %s: %w", target, linkErr)
} else {
committed, copyErr := copyStructuredPatchNoReplace(root, source, target, mode)
if copyErr != nil {
return committed, fmt.Errorf("creating %s without overwrite: hard link failed: %w; exclusive-copy fallback failed: %w", target, linkErr, copyErr)
}
return committed, nil
}
}
func copyStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return copyStructuredPatchNoReplaceWith(root, source, target, mode, io.Copy, removeStructuredPatchTemp)
}
func copyStructuredPatchNoReplaceWith(
root *os.Root,
source, target string,
mode os.FileMode,
copyFile func(io.Writer, io.Reader) (int64, error),
cleanup func(*os.Root, string) error,
) (bool, error) {
sourceFile, err := root.Open(source)
if err != nil {
return false, err
}
sourceClosed := false
defer func() {
if !sourceClosed {
_ = sourceFile.Close()
}
}()
targetFile, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false, err
}
committed := true
cleanupTarget := func(cause error) (bool, error) {
_ = targetFile.Close()
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, cause
} else {
return committed, errors.Join(cause, fmt.Errorf("cleaning partial target: %w", cleanupErr))
}
}
if _, err := copyFile(targetFile, sourceFile); err != nil {
return cleanupTarget(err)
}
if err := sourceFile.Close(); err != nil {
sourceClosed = true
return cleanupTarget(err)
}
sourceClosed = true
if err := targetFile.Chmod(mode.Perm()); err != nil {
return cleanupTarget(err)
}
if err := targetFile.Close(); err != nil {
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, err
} else {
return true, errors.Join(err, fmt.Errorf("cleaning target after close failure: %w", cleanupErr))
}
}
if err := cleanup(root, source); err != nil {
return true, fmt.Errorf("removing published source: %w", err)
}
return true, nil
}
func removeStructuredPatchTemp(root *os.Root, name string) error {
chmodErr := root.Chmod(name, 0o600)
if os.IsNotExist(chmodErr) {
return nil
}
removeErr := root.Remove(name)
if os.IsNotExist(removeErr) {
removeErr = nil
}
if removeErr == nil {
return nil
}
return errors.Join(chmodErr, removeErr)
}
func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) {
if tracker == nil {
return
}
content, err := os.ReadFile(absolute)
if err != nil {
tracker.Forget(absolute)
return
}
info, _ := os.Stat(absolute)
tracker.Record(absolute, content, info)
if seenWhole {
lines := trackedLineTotal(string(content))
tracker.RecordSeenRange(absolute, 1, lines, lines)
}
if created {
tracker.RecordCreated(absolute)
}
}
func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPatchChange) []string {
seen := make(map[string]bool)
var paths []string
for _, change := range changes {
targets := []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
for _, target := range targets {
path := target.relative
if relativeRoot != "" && relativeRoot != "." {
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
func structuredPatchPreview(changes []structuredPatchChange) string {
var preview strings.Builder
for _, change := range changes {
path := change.to.relative
if change.kind == structuredPatchDelete {
path = change.from.relative
}
before, after := change.before, change.after
if change.kind == structuredPatchDelete {
after = ""
}
diff := boundedUnifiedDiff(path, before, after)
if diff == "" {
continue
}
if preview.Len()+len(diff)+1 > maxToolPreviewBytes {
break
}
if preview.Len() > 0 {
preview.WriteByte('\n')
}
preview.WriteString(diff)
}
return capPreviewDiff(preview.String())
}
+6
View File
@@ -130,6 +130,9 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if !IsDeferred(candidate) {
continue
}
@@ -158,6 +161,9 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
return names
}
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
continue
}
+59
View File
@@ -0,0 +1,59 @@
package tools
import (
"context"
"fmt"
"path/filepath"
"testing"
)
func TestTrackedLineTotalMatchesReadFileStats(t *testing.T) {
root := t.TempDir()
for name, content := range map[string]string{"trailing": "a\nb\nc\n", "unterminated": "a\nb\nc", "single": "x\n", "empty": "", "crlf": "a\r\nb\r\n"} {
path := filepath.Join(root, name+".txt")
writeTestFile(t, path, content)
stats, err := scanReadFileStats(path)
if err != nil {
t.Fatal(err)
}
if got := trackedLineTotal(content); got != stats.lines {
t.Fatalf("%s: trackedLineTotal = %d, read_file reports %d", name, got, stats.lines)
}
}
}
// A whole-file read, an edit, then a partial re-read must keep the file "seen
// whole": the writer and reader now agree on the line total, so the partial
// read accumulates instead of resetting and a later edit far from the re-read
// window is not refused.
func TestEditThenPartialReadKeepsWholeFileObservation(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "notes.txt")
content := ""
for i := 1; i <= 40; i++ {
content += fmt.Sprintf("line %02d\n", i)
}
writeTestFile(t, path, content)
tracker := NewFileTracker()
registry := NewRegistry()
registry.Register(NewScopedReadFileTool(root, nil))
registry.Register(NewScopedEditFileTool(root, nil))
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt"}, opts); r.Status != StatusOK {
t.Fatalf("read: %s", r.Output)
}
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 01\nline 02\n", "new_string": "line 01\nline two\n"}, opts); r.Status != StatusOK {
t.Fatalf("first edit: %s", r.Output)
}
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt", "limit": 5}, opts); r.Status != StatusOK {
t.Fatalf("partial read: %s", r.Output)
}
absolute, _ := filepath.EvalSymlinks(path)
if !tracker.SeenWhole(absolute) {
t.Fatal("partial re-read of an unchanged file must not discard whole-file knowledge")
}
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 39\nline 40\n", "new_string": "line 39\nline forty\n"}, opts); r.Status != StatusOK {
t.Fatalf("edit far from the re-read window must succeed: %s", r.Output)
}
}
+393
View File
@@ -0,0 +1,393 @@
package tools
import (
"fmt"
"strings"
)
// parseUnifiedPatch converts a unified diff into the same operations a
// structured patch produces, so both formats are applied by Zero itself
// through an opened workspace root (descriptor-relative, no-follow writes)
// instead of handing pathnames to git apply after validation.
//
// Supported: file modifications, creations (--- /dev/null) and deletions
// (+++ /dev/null, verified against the content the hunks expect to remove),
// multiple hunks per file, "\ No newline at end of file"
// markers, a/ and b/ prefixes, git C-quoted paths, CRLF patches, and git's
// "rename from/to" and "copy from/to" headers (with or without hunks). Headers
// git emits around a hunk (diff --git, index, mode lines) are skipped; binary
// patches are rejected.
func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff")
lines := strings.Split(normalized, "\n")
// The element after a final "\n" is the line terminator, not an empty
// context line; counting it would let a truncated hunk look complete.
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
var operations []structuredPatchOperation
var current *structuredPatchOperation
var chunk *structuredPatchChunk
var added []string // collected "+" lines for a file creation
oldPath, newPath := "", ""
pendingFrom, pendingKind := "", structuredPatchUpdate
// git's header-only forms: "deleted file mode" / "new file mode" after a
// "diff --git" line with no ---/+++ pair describe an empty file.
diffPath, headerOnly := "", byte(0) // headerOnly is 'd' (deleted) or 'n' (new)
oldRemaining, newRemaining := 0, 0
inHunk := false
lastSide := byte(0) // '+' or '-' for the most recent content line
finish := func() error {
if current == nil {
return nil
}
switch current.kind {
case structuredPatchAdd:
if len(added) > 0 {
current.contents = strings.Join(added, "\n")
if current.eofNewline != eofNewlineAbsent {
current.contents += "\n"
}
}
case structuredPatchUpdate, structuredPatchCopy:
if !allStructuredPatchChunksHaveContent(current.chunks) {
return fmt.Errorf("unified diff for %s has an empty hunk", current.path)
}
if len(current.chunks) == 0 && current.movePath == "" {
return fmt.Errorf("unified diff for %s has no hunk lines", current.path)
}
case structuredPatchDelete:
if len(current.chunks) == 0 || !allStructuredPatchChunksHaveContent(current.chunks) {
return fmt.Errorf("unified deletion of %s must include the hunk with the content being removed", current.path)
}
}
operations = append(operations, *current)
current, chunk, added = nil, nil, nil
return nil
}
flushHeaderOnly := func(line int) error {
if headerOnly == 0 {
return nil
}
if diffPath == "" {
return fmt.Errorf("invalid unified diff at line %d: file mode header without a diff --git path", line)
}
if err := finish(); err != nil {
return err
}
switch headerOnly {
case 'd':
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: diffPath, line: line, verifyDelete: true})
case 'n':
operations = append(operations, structuredPatchOperation{kind: structuredPatchAdd, path: diffPath, line: line})
}
headerOnly, diffPath = 0, ""
return nil
}
startFile := func(line int) error {
// A ---/+++ pair means the file has hunks; it is not header-only.
headerOnly, diffPath = 0, ""
if oldPath == "" || newPath == "" {
return fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", line)
}
// A ---/+++ pair after a rename/copy header names the same files; keep
// accumulating that operation's hunks instead of starting a new one.
if current != nil && current.movePath != "" && current.path == oldPath && (current.movePath == newPath || oldPath == newPath) {
return nil
}
if err := finish(); err != nil {
return err
}
op := structuredPatchOperation{line: line}
switch {
case oldPath == "/dev/null" && newPath == "/dev/null":
return fmt.Errorf("invalid unified diff at line %d: both sides are /dev/null", line)
case oldPath == "/dev/null":
op.kind, op.path = structuredPatchAdd, newPath
case newPath == "/dev/null":
// A unified deletion states the content it expects to remove; keep
// its hunks so the planner verifies them before deleting.
op.kind, op.path, op.verifyDelete = structuredPatchDelete, oldPath, true
default:
op.kind, op.path = structuredPatchUpdate, oldPath
if newPath != oldPath {
op.movePath = newPath
}
}
current = &op
return nil
}
for index, raw := range lines {
lineNumber := index + 1
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
// A "--- " line followed by "+++ " and then a "@@" hunk header is
// the next file's header pair, not a removed "-- x" line followed by
// an added "++ y" line: an over-declared count would otherwise
// swallow both and the hunk would later fail against the wrong
// file. The "@@" requirement keeps a genuine adjacent pair intact.
if strings.HasPrefix(raw, "--- ") && index+2 < len(lines) && strings.HasPrefix(lines[index+1], "+++ ") && strings.HasPrefix(lines[index+2], "@@") {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
}
switch {
case strings.HasPrefix(raw, "\\"):
// "\ No newline at end of file" qualifies the previous line's side.
if current != nil && lastSide == '+' {
current.eofNewline = eofNewlineAbsent
} else if current != nil && lastSide == '-' {
current.oldNoNewline = true
if current.eofNewline == eofNewlineKeep {
current.eofNewline = eofNewlinePresent
}
}
continue
case strings.HasPrefix(raw, "-"):
oldRemaining--
lastSide = '-'
if chunk != nil {
chunk.old = append(chunk.old, raw[1:])
}
case strings.HasPrefix(raw, "+"):
newRemaining--
lastSide = '+'
if current != nil && current.kind == structuredPatchAdd {
added = append(added, raw[1:])
} else if chunk != nil {
chunk.new = append(chunk.new, raw[1:])
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
}
default:
content := raw
if strings.HasPrefix(raw, " ") {
content = raw[1:]
} else if raw != "" {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk lines must start with ' ', '+', '-' or '\\'", lineNumber)
}
oldRemaining--
newRemaining--
lastSide = ' '
if chunk != nil {
offset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, offset)
}
}
continue
}
inHunk = false
trimmed := strings.TrimSpace(raw)
if oldRemaining > 0 || newRemaining > 0 {
// The hunk ended before its declared counts were consumed. Report
// it here rather than absorbing the next file's ---/+++ headers as
// "-"/"+" content and failing later against the wrong file.
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
}
switch {
case trimmed == "":
continue
case strings.HasPrefix(raw, "\\"):
// "\ No newline at end of file" directly after a hunk's last line.
if current != nil && lastSide == '+' {
current.eofNewline = eofNewlineAbsent
} else if current != nil && lastSide == '-' {
current.oldNoNewline = true
if current.eofNewline == eofNewlineKeep {
current.eofNewline = eofNewlinePresent
}
}
continue
case strings.HasPrefix(raw, "diff --git "):
if err := flushHeaderOnly(lineNumber); err != nil {
return nil, err
}
diffPath = diffGitNewPath(raw)
continue
case strings.HasPrefix(raw, "deleted file mode "):
headerOnly = 'd'
continue
case strings.HasPrefix(raw, "new file mode "):
headerOnly = 'n'
continue
case strings.HasPrefix(raw, "index "), strings.HasPrefix(raw, "old mode "), strings.HasPrefix(raw, "new mode "):
continue
case strings.HasPrefix(raw, "similarity index "), strings.HasPrefix(raw, "dissimilarity index "):
continue
case strings.HasPrefix(raw, "rename from "), strings.HasPrefix(raw, "copy from "):
pendingFrom = strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename from "), "copy from ")))
pendingKind = structuredPatchUpdate
if strings.HasPrefix(raw, "copy from ") {
pendingKind = structuredPatchCopy
}
if pendingFrom == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: missing source path", lineNumber)
}
case strings.HasPrefix(raw, "rename to "), strings.HasPrefix(raw, "copy to "):
to := strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to ")))
if pendingFrom == "" || to == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: rename/copy destination without a source", lineNumber)
}
if err := finish(); err != nil {
return nil, err
}
current = &structuredPatchOperation{kind: pendingKind, path: pendingFrom, movePath: to, line: lineNumber}
oldPath, newPath, pendingFrom = pendingFrom, to, ""
case strings.HasPrefix(raw, "Binary files "), strings.HasPrefix(raw, "GIT binary patch"):
return nil, fmt.Errorf("invalid unified diff at line %d: binary patches are not supported", lineNumber)
case strings.HasPrefix(raw, "--- "):
oldPath = stripPatchPrefix(patchFileHeaderPath(raw))
newPath = ""
if oldPath == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in --- header", lineNumber)
}
case strings.HasPrefix(raw, "+++ "):
newPath = stripPatchPrefix(patchFileHeaderPath(raw))
if newPath == "" {
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in +++ header", lineNumber)
}
if err := startFile(lineNumber); err != nil {
return nil, err
}
case strings.HasPrefix(raw, "@@"):
if current == nil {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", lineNumber)
}
oldStart, oldCount, newCount, ok := parseHunkRange(raw)
if !ok {
return nil, fmt.Errorf("invalid unified diff at line %d: malformed hunk header", lineNumber)
}
// "\ No newline at end of file" describes the end of the file, so it
// may only follow a file's final hunk; a marker before another hunk
// would otherwise leak into that later hunk's result.
if current.eofNewline != eofNewlineKeep {
return nil, fmt.Errorf("invalid unified diff at line %d: \"\\ No newline at end of file\" must follow the last hunk of a file", lineNumber)
}
oldRemaining, newRemaining = oldCount, newCount
inHunk = oldRemaining > 0 || newRemaining > 0
lastSide = 0
if current.kind != structuredPatchAdd {
next := structuredPatchChunk{hasHint: true, hint: oldStart - 1}
if oldCount == 0 {
// A pure insertion's range names the line after which the
// new lines go, so the insertion index is oldStart itself.
next.hint = oldStart
}
if next.hint < 0 {
next.hint = 0
}
current.chunks = append(current.chunks, next)
chunk = &current.chunks[len(current.chunks)-1]
}
default:
return nil, fmt.Errorf("invalid unified diff at line %d: unexpected %q", lineNumber, trimmed)
}
}
// Strict at end of input on purpose: a hunk cut short (typically a model
// response that stopped mid-patch) would otherwise apply as a partial
// change — removals without their replacements — which is worse than a
// clear error and a retry. Miscounted ranges with complete content are
// still tolerated at every other boundary.
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", len(lines))
}
if err := flushHeaderOnly(len(lines)); err != nil {
return nil, err
}
if err := finish(); err != nil {
return nil, err
}
if len(operations) == 0 {
return nil, fmt.Errorf("unified diff contains no file changes")
}
return operations, nil
}
// diffGitNewPath returns the post-image path named by a "diff --git a/X b/Y"
// line, handling git's C-quoted form; "" when it cannot be determined.
func diffGitNewPath(line string) string {
rest := strings.TrimSpace(strings.TrimPrefix(line, "diff --git "))
if strings.HasPrefix(rest, "\"") {
// Two quoted tokens: skip the first, unquote the second.
end := strings.Index(rest[1:], "\"")
for end > 0 && rest[end] == '\\' {
next := strings.Index(rest[end+2:], "\"")
if next < 0 {
return ""
}
end += next + 2
}
if end < 0 || end+2 > len(rest) {
return ""
}
rest = strings.TrimSpace(rest[end+2:])
return stripPatchPrefix(unquoteGitPath(rest))
}
fields := strings.Fields(rest)
if len(fields) < 2 {
return ""
}
last := fields[len(fields)-1]
if strings.HasPrefix(last, "\"") {
return stripPatchPrefix(unquoteGitPath(last))
}
return stripPatchPrefix(last)
}
// parseHunkRange reads "@@ -a[,b] +c[,d] @@" and returns a, b and d; a missing
// count means 1 per unified-diff convention.
func parseHunkRange(line string) (oldStart, oldCount, newCount int, ok bool) {
_, rest, found := strings.Cut(line, "@@")
if !found {
return 0, 0, 0, false
}
rangeSection := rest
if before, _, found := strings.Cut(rest, "@@"); found {
rangeSection = before
}
fields := strings.Fields(rangeSection)
if len(fields) != 2 || !strings.HasPrefix(fields[0], "-") || !strings.HasPrefix(fields[1], "+") {
return 0, 0, 0, false
}
parse := func(spec string) (int, int, bool) {
startText, countText, hasCount := strings.Cut(spec, ",")
start, err := parseNonNegativeInt(startText)
if err != nil {
return 0, 0, false
}
count := 1
if hasCount {
if count, err = parseNonNegativeInt(countText); err != nil {
return 0, 0, false
}
}
return start, count, true
}
oldStart, oldCount, ok = parse(fields[0][1:])
if !ok {
return 0, 0, 0, false
}
_, newCount, ok = parse(fields[1][1:])
if !ok {
return 0, 0, 0, false
}
return oldStart, oldCount, newCount, true
}
func parseNonNegativeInt(text string) (int, error) {
if text == "" {
return 0, fmt.Errorf("empty number")
}
value := 0
for _, r := range text {
if r < '0' || r > '9' {
return 0, fmt.Errorf("not a number: %q", text)
}
value = value*10 + int(r-'0')
if value > 1<<30 {
return 0, fmt.Errorf("number too large: %q", text)
}
}
return value, nil
}
+1 -1
View File
@@ -120,7 +120,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
if content == modelKnownContent {
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(content), lineCount(content))
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content))
}
if !existed {
options.FileTracker.RecordCreated(absolutePath)
+552
View File
@@ -2,12 +2,23 @@ package tools
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func mustReadTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreToolsScoped(t.TempDir(), nil)
byName := make(map[string]Tool, len(toolset))
@@ -513,6 +524,547 @@ func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
}
}
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
" hello",
"-old",
"+new",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: nested/new.txt",
"+created",
"*** Update File: old.txt",
"*** Move to: moved.txt",
"@@",
"-old",
"+moved",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
t.Fatalf("added file = %q, err = %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
t.Fatalf("moved file = %q, err = %v", got, err)
}
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
t.Fatalf("old source should be removed, stat err = %v", err)
}
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
"-hello",
"+goodbye",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "goodbye\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
" if cond {",
" doWork()",
"-\t\tlogIt()",
"+\t\tlogIt(ctx)",
" }",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
" alpha",
" context",
"-\told",
"+\tnew",
" omega",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@ anchor",
"+inserted",
"@@ middle",
"-remove",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured insertion failed: %s", result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
t.Fatalf("structured insertion content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
"-\treturn nil",
"+\treturn errNotFound",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("ambiguous structured patch changed the file: %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-bravo",
"+BRAVO",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\nbravo")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-alpha",
"+ALPHA",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "empty.txt")
writeTestFile(t, path, "")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: empty.txt",
"@@",
"+content",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "content"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-delta",
"+DELTA",
"@@",
"-delta",
"-echo",
"+tail",
"*** End of File",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("out-of-order structured patch changed the file: %q", got)
}
}
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "created.txt")
writeTestFile(t, targetPath, "other writer\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
after: "patch content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
if strings.Contains(err.Error(), "exclusive-copy fallback") {
t.Fatalf("existing destination should be reported directly: %v", err)
}
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
t.Fatalf("raced add overwrote another writer's file: %q", got)
}
}
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
missingPath := filepath.Join(root, "missing.txt")
change := structuredPatchChange{
kind: structuredPatchDelete,
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
before: "removed by another writer\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
t.Fatalf("failed delete recreated the missing file: %v", statErr)
}
}
// A move whose source vanishes after planning is refused by the pre-commit
// recheck before the destination is published, so nothing is left half-done.
func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
writeTestFile(t, sourcePath, "removed source\n")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
// The source exists at planning time and disappears just before commit.
removed := false
structuredPatchBeforeCommit = func(structuredPatchChange) {
if err := os.Remove(sourcePath); err != nil {
t.Fatal(err)
}
removed = true
}
defer func() { structuredPatchBeforeCommit = nil }()
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if !removed {
t.Fatal("pre-commit hook did not run")
}
if err == nil || !strings.Contains(err.Error(), "before commit") || strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want a pre-commit refusal with nothing applied", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if _, statErr := os.Stat(destinationPath); !os.IsNotExist(statErr) {
t.Fatalf("refused move must not publish the destination: %v", statErr)
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
t.Fatal(err)
}
committed, err := publishStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o444,
func(string, string) error { return errors.New("hard links unsupported") },
)
if err != nil || !committed {
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
}
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
t.Fatalf("fallback target content = %q", got)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("read-only source was not cleaned up: %v", statErr)
}
}
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
copyErr := errors.New("injected copy failure")
cleanupErr := errors.New("injected target cleanup failure")
committed, err := copyStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o644,
func(destination io.Writer, _ io.Reader) (int64, error) {
written, writeErr := io.WriteString(destination, "partial")
if writeErr != nil {
return int64(written), writeErr
}
return int64(written), copyErr
},
func(root *os.Root, name string) error {
if name == "target.txt" {
return cleanupErr
}
return removeStructuredPatchTemp(root, name)
},
)
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("copy failure = committed %v, error %v", committed, err)
}
targetPath := filepath.Join(root, "target.txt")
if got := mustReadTestFile(t, targetPath); got != "partial" {
t.Fatalf("partial target content = %q", got)
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
writeTestFile(t, trackedPath, "original\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
trackedPath, err = filepath.EvalSymlinks(trackedPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(trackedPath)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
tracker.Record(trackedPath, []byte("original\n"), info)
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
blockedPath := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedPath, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
changes := []structuredPatchChange{
{
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
before: "original\n", after: "updated\n", mode: 0o644,
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
after: "cannot replace a non-empty directory\n", mode: 0o644,
},
}
err = applyStructuredPatchChanges(workspace, changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
t.Fatalf("completed change was unexpectedly altered: %q", got)
}
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
t.Fatal("partial failure retained stale file-tracker state")
}
}
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
root := t.TempDir()
tool := NewScopedApplyPatchTool(root, nil)
for name, patch := range map[string]string{
"empty update": strings.Join([]string{
"*** Begin Patch",
"*** Update File: target.txt",
"*** End Patch",
}, "\n"),
"escape": strings.Join([]string{
"*** Begin Patch",
"*** Add File: ../outside.txt",
"+nope",
"*** End Patch",
}, "\n"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "No valid patches") {
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
}
if name == "empty update" {
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
t.Fatalf("empty update must not write target file, stat err = %v", err)
}
}
})
}
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
}
}
// A hunk-body line that removes content beginning with "-- " appears in the diff
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
// apply_patch reject a valid patch as targeting an outside path).
+1 -1
View File
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
if err != nil {
return m.appendSystemNotice("add-dir: " + err.Error())
}
return m.appendSystemNotice("write access added: " + root + " (this session only)")
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
}
// appendSystemNotice appends a system transcript line and returns the updated
+2 -2
View File
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
t.Fatalf("expected one additional write root after grant, got %#v", roots)
}
notice := lastTranscriptText(next)
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
notice := next.transientNotice.text
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
}
+39 -4
View File
@@ -18,6 +18,8 @@ const (
markdownTableRuleBodyRows = 4
markdownBoldStart = "\x1b[1m"
markdownBoldEnd = "\x1b[22m"
markdownCodeStart = "\x1b[7m"
markdownCodeEnd = "\x1b[27m"
)
type markdownDisplayStyle int
@@ -25,6 +27,7 @@ type markdownDisplayStyle int
const (
markdownDisplayNormal markdownDisplayStyle = iota
markdownDisplayBold
markdownDisplayCode
markdownDisplayRule
)
@@ -262,6 +265,8 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
case markdownDisplayBold:
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
case markdownDisplayCode:
builder.WriteString(inlineCodeStyle().Render(text))
case markdownDisplayRule:
builder.WriteString(zeroTheme.lineStrong.Render(text))
default:
@@ -282,6 +287,16 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
style = markdownDisplayNormal
index += len(markdownBoldEnd)
continue
case strings.HasPrefix(line[index:], markdownCodeStart):
flush()
style = markdownDisplayCode
index += len(markdownCodeStart)
continue
case strings.HasPrefix(line[index:], markdownCodeEnd):
flush()
style = markdownDisplayNormal
index += len(markdownCodeEnd)
continue
case line[index] == '\x1b':
// Already-styled input (highlighted code, headings, tables) carries real
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
@@ -313,6 +328,13 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
return builder.String()
}
func hasMarkdownDisplayControls(line string) bool {
return strings.Contains(line, markdownBoldStart) ||
strings.Contains(line, markdownBoldEnd) ||
strings.Contains(line, markdownCodeStart) ||
strings.Contains(line, markdownCodeEnd)
}
func hasExternalANSIStyle(line string) bool {
for index := 0; index < len(line); {
if line[index] != '\x1b' {
@@ -325,7 +347,7 @@ func hasExternalANSIStyle(line string) bool {
continue
}
seq := line[index:end]
if seq != markdownBoldStart && seq != markdownBoldEnd {
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
return true
}
index = end
@@ -334,6 +356,9 @@ func hasExternalANSIStyle(line string) bool {
}
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
if current == markdownDisplayCode {
return current
}
switch r {
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
return markdownDisplayRule
@@ -980,6 +1005,7 @@ func renderMarkdownStandaloneLine(line string) string {
type markdownInlineSegment struct {
text string
bold bool
code bool
}
func wrapMarkdownInline(text string, measure int) []string {
@@ -1028,7 +1054,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
flush()
}
head, tail := splitAtWidth(word.text, available)
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
word.text = tail
prefix = continuationPrefix
available = maxInt(1, measure-lipgloss.Width(prefix))
@@ -1063,8 +1089,14 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
words := []markdownInlineSegment{}
for _, segment := range segments {
if segment.code {
if segment.text != "" {
words = append(words, segment)
}
continue
}
for _, word := range strings.Fields(segment.text) {
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
}
}
return words
@@ -1078,6 +1110,9 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
if segment.text == "" {
return ""
}
if segment.code {
return markdownCodeStart + segment.text + markdownCodeEnd
}
if segment.bold {
return renderMarkdownBoldText(segment.text)
}
@@ -1107,7 +1142,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
if builder.Len() == 0 {
return
}
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
builder.Reset()
}
+110
View File
@@ -0,0 +1,110 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
attachmentImageID = uint32(0xC0E0)
attachmentPreviewCols = 10
attachmentPreviewRows = 4
attachmentPreviewHeightPixels = 64
attachmentPreviewMaxImages = 4
)
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
// image is an enhancement to the existing attachment chips, not a reason to
// crowd a narrow or modal surface.
func (m model) attachmentThumbnailVisible(width int) bool {
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
return false
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
return true
}
}
return false
}
func (m model) attachmentThumbnailSlots(width int) int {
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
}
func (m model) attachmentThumbnailLines(width int) []string {
left := strings.Repeat(" ", attachmentPreviewCols+1)
lines := make([]string, attachmentPreviewRows)
for index := range lines {
lines[index] = left
}
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return lines
}
func (m model) attachmentThumbnailSupplementalChips() string {
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
return ""
}
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
}
func (m model) attachmentComposerPrefixRows(width int) int {
if m.attachmentThumbnailVisible(width) {
rows := attachmentPreviewRows
if m.attachmentThumbnailSupplementalChips() != "" {
rows++
}
return rows
}
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
return 1
}
return 0
}
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
// of the composer. The position comes from the same footer layout used for mouse
// input, so it follows sidebar changes and terminal resizes instead of relying on
// a brittle absolute bottom-right calculation.
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
width := m.chatColumnWidth()
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
return nil
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height < attachmentPreviewRows+2 {
return nil
}
return &terminalpet.ImageDraw{
ID: attachmentImageID + uint32(index),
Animation: m.pendingImageThumbnails[index],
State: terminalpet.Idle,
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
Y: frame.composerRect.y + 1,
Columns: attachmentPreviewCols,
Rows: attachmentPreviewRows,
HeightPixels: attachmentPreviewHeightPixels,
}
}
func attachmentPreviewImageIDs() []uint32 {
ids := make([]uint32, attachmentPreviewMaxImages)
for index := range ids {
ids[index] = attachmentImageID + uint32(index)
}
return ids
}
+168
View File
@@ -0,0 +1,168 @@
package tui
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
t.Helper()
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
t.Fatal(err)
}
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
}
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
t.Fatal("expected staged image thumbnail")
}
view := m.View()
plain := plainRender(t, view.Content)
if strings.Contains(plain, "[Image #1]") {
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
}
var output bytes.Buffer
if err := m.attachmentRenderers[0].Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
}
}
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
m.pendingImageLabels = []string{"first.png", "second.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #2]") {
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
}
_ = m.View()
for index, renderer := range m.attachmentRenderers {
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
if !strings.Contains(output.String(), id) {
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
}
}
}
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #1]") {
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
}
}
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
_ = m.View()
var first bytes.Buffer
if err := m.attachmentRenderers[0].Render(&first); err != nil {
t.Fatal(err)
}
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
_ = m.View()
var cleared bytes.Buffer
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
t.Fatal(err)
}
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
}
}
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
if err != nil {
t.Fatal(err)
}
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sink.Close() })
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
}
for _, id := range []string{"i=49374", "i=49376"} {
if !strings.Contains(got, id) {
t.Fatalf("missing image placement %s: %q", id, got)
}
}
}
+1
View File
@@ -136,6 +136,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
if question == "" {
side.pendingImages = nil
side.pendingImageLabels = nil
side.pendingImageThumbnails = nil
side.pendingDocuments = nil
}
side.loops = nil
-1
View File
@@ -107,7 +107,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+28 -7
View File
@@ -15,6 +15,7 @@ import (
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -731,14 +732,16 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
}, true
}
if provider, ok := m.activeProviderDescriptor(); ok {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, candidate := range providerModelSwitchCandidates(provider.ID, args) {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
if genericProviderCatalogID(provider.ID) && strings.TrimSpace(args) != "" {
@@ -748,6 +751,24 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
return modelSwitchTarget{}, false
}
// providerModelSwitchCandidates preserves provider-owned model IDs while
// accepting the models.dev OpenAI namespace for ChatGPT subscription models.
// The ChatGPT backend itself publishes bare model IDs (for example,
// "gpt-5.6-sol"); gateway providers may legitimately require "openai/..."
// as part of their model ID, so the alias is deliberately provider-specific.
func providerModelSwitchCandidates(providerID string, input string) []string {
requested := strings.TrimSpace(input)
candidates := []string{requested}
const openAIPrefix = "openai/"
if providercatalog.NormalizeID(providerID) != "chatgpt" || len(requested) <= len(openAIPrefix) || !strings.EqualFold(requested[:len(openAIPrefix)], openAIPrefix) {
return candidates
}
if bare := strings.TrimSpace(requested[len(openAIPrefix):]); bare != "" {
candidates = append(candidates, bare)
}
return candidates
}
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
if modelSwitchCompactionGuard == nil {
return m, "", false
+1 -4
View File
@@ -269,10 +269,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
if width < 8 {
return m.composerPositionAtVisualCell(localX, localY, width)
}
contentY := localY - 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY--
}
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
if contentY < 0 {
return 0, false
}
+4 -4
View File
@@ -201,13 +201,13 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) {
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
// its port) until the app exits. It respawns lazily on the next streaming
// recording. Skip while a recording is still in flight so we don't kill it.
next := m.appendSystemNotice("Voice mode off.")
next := m.showTransientNoticeInline("Voice mode off.", transientNoticeInfo)
if next.dictation.active() {
return next, nil
}
@@ -299,7 +299,7 @@ func (m model) cancelDictation() (model, tea.Cmd) {
}
m = m.discardDictationRegion()
m.dictation.reset()
return m.appendSystemNotice("Dictation cancelled."), nil
return m.showTransientNoticeInline("Dictation cancelled.", transientNoticeInfo), nil
}
// wantStreaming decides whether this recording uses the streaming pipeline:
@@ -438,7 +438,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
if streaming {
return m, nil // streaming already rendered live; nothing to add
}
return m.appendSystemNotice("No speech detected."), nil
return m.showTransientNoticeInline("No speech detected.", transientNoticeInfo), nil
}
if !streaming {
m = m.insertDictatedText(msg.text)
+3 -3
View File
@@ -42,7 +42,7 @@ func (d dictationController) canOfferDownload(err error) bool {
// variant, reporting progress live, then persists and applies the resolved paths.
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
if m.dictation.downloading {
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
return m.showTransientNoticeInline("Dictation setup is already in progress.", transientNoticeWarning), nil
}
m.dictation.downloading = true
sink := m.runtimeMessageSink
@@ -53,7 +53,7 @@ func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
base = context.Background()
}
m.dictation.downloadStatus = "Starting download…"
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
m = m.showTransientNoticeInline(fmt.Sprintf("Setting up local dictation (%s) — progress is in the status line.", v.Label), transientNoticeInfo)
return m, func() tea.Msg {
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
DestRoot: root,
@@ -93,7 +93,7 @@ func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea
if err != nil {
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
}
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
return m.showTransientNoticeInline("Local dictation is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
}
// applyEngineComponents persists and applies resolved engine/model paths to the
+3 -2
View File
@@ -2,6 +2,7 @@ package tui
import (
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -79,7 +80,7 @@ func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
if got.dictation.cfg.StreamingEnabled() {
t.Error("a batch variant should set streaming off")
}
if !transcriptHasText(got, "ready") {
if !strings.Contains(got.transientNotice.text, "ready") {
t.Error("expected a ready notice")
}
}
@@ -158,7 +159,7 @@ func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
if cmd == nil {
t.Error("expected a download command")
}
if !transcriptHasText(next, variant.Label) {
if !strings.Contains(next.transientNotice.text, variant.Label) {
t.Error("expected the download-starting notice to name the variant")
}
}
+5 -2
View File
@@ -33,8 +33,11 @@ func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
if got.composer.text != "" {
t.Errorf("composer should stay empty, got %q", got.composer.text)
}
if !transcriptHasText(got, "No speech detected") {
t.Error("expected a 'no speech' notice")
if got.transientNotice.text != "No speech detected." {
t.Errorf("no-speech notice = %q", got.transientNotice.text)
}
if transcriptHasText(got, "No speech detected") {
t.Error("no-speech confirmation should not be persisted in the transcript")
}
}
+5 -2
View File
@@ -36,8 +36,11 @@ func TestToggleVoiceModeFlips(t *testing.T) {
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if !transcriptHasText(next, "Voice mode on") {
t.Error("expected voice-mode-on notice")
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
}
if transcriptHasText(next, "Voice mode on") {
t.Error("voice-mode-on confirmation should not be persisted in the transcript")
}
off, _ := next.toggleVoiceMode()
if off.dictation.voiceModeEnabled {
+35 -11
View File
@@ -2,12 +2,16 @@
package tui
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tools"
)
func renderMarkdownInline(text string) string {
@@ -48,6 +52,26 @@ func formatGroupedCommandHelpLines() []string {
return lines
}
// runningPlanModel supplies plan state for tests that exercise contextual
// details without reviving the former persistent plan presentation.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func listCommandNames() []string {
names := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
@@ -88,18 +112,18 @@ func (m model) overlayMouseTop(overlayHeight int, width int) int {
return m.overlayMouseRect(overlayHeight, width).y
}
// height returns the number of terminal lines renderPlanPanel will occupy at
// the given width (0 when the panel is not visible). The step list is shown
// when the panel is expanded or still running; a collapsed, finished plan is
// just the header and progress bar.
func (s planPanelState) height(width int, now time.Time) int {
if !s.visible(now) {
return 0
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
cwd, err := os.Getwd()
if err != nil {
return 0, 0, err
}
if s.expanded || !s.isComplete() {
return 2 + len(s.steps)
}
return 2
ctx, cancel := context.WithTimeout(context.Background(), prCommandTimeout)
defer cancel()
return getLocalDiffStats(ctx, cwd, baseBranch, defaultPRCommandRunner)
}
func WatchPRState(service *PrService, onChange func(PrState)) func() {
return WatchPRStateContext(context.Background(), service, onChange)
}
func (c *staticRenderCache) retainedCharacters() int {
+33 -9
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -161,9 +162,9 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
}
}
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
// TestSidebarAgentClickIsIgnoredWithoutRail verifies invisible legacy hit
// coordinates cannot unexpectedly replace the full-width conversation view.
func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
@@ -185,14 +186,14 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if !handled {
t.Fatal("agent row click should be handled")
if handled {
t.Fatal("invisible rail coordinates must not handle clicks")
}
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
if !updated.fileView.active {
t.Fatal("an ignored rail click must leave the active file view alone")
}
if !updated.subchat.active {
t.Fatal("subchat should be active")
if updated.subchat.active {
t.Fatal("an ignored rail click must not enter a subchat")
}
}
@@ -217,6 +218,29 @@ func TestChangedFilesRehydration(t *testing.T) {
}
}
// TestResumedFileEditUsesPersistedDisplayPreview keeps the reviewable diff a
// user saw while the run was live. The provider-facing output is intentionally
// a short confirmation, so it cannot substitute for the card-only preview.
func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"edit-1","name":"edit_file","status":"ok","output":"Successfully edited calculator.go (replaced 1 occurrence).","displayPreview":` + strconv.Quote(preview) + `}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected one restored row, got %d", len(rows))
}
if rows[0].detail != preview {
t.Fatalf("resume should restore the saved diff preview, got %q", rows[0].detail)
}
card := renderToolResultCard(rows[0], 80, rowContext{}, cardRenderOptions{})
if plain := plainRender(t, card); !strings.Contains(plain, "newValue := 2") {
t.Fatalf("resumed edit should render its diff preview, got %q", plain)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
+2 -33
View File
@@ -6,10 +6,8 @@
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
// The same compact rows feed the run-details overlay; selecting a file from a
// visible transcript card opens the file drill-in (file_view.go).
package tui
import (
@@ -17,8 +15,6 @@ import (
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -312,33 +308,6 @@ func (m model) sidebarFileSelectables(width int) []fileHit {
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
+9 -31
View File
@@ -161,38 +161,16 @@ func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
}
}
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
// TestRunDetailsListsTouchedFiles keeps edited files discoverable without a
// permanent column beside the conversation.
func TestRunDetailsListsTouchedFiles(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
m.runDetailsOpen = true
plain := plainRender(t, m.runDetailsOverlay(m.width))
for _, file := range m.touchedFiles() {
if !strings.Contains(plain, file.path) {
t.Errorf("run details missing touched file %q:\n%s", file.path, plain)
}
}
}
+2 -4
View File
@@ -116,7 +116,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
}
m, _ = m.settleTranscript()
selectedView := viewString(m.View())
_ = viewString(m.View())
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
@@ -126,9 +126,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
t.Fatal("clearing a selected file should rebuild the invalidated cache")
}
if clearedView := viewString(m.View()); clearedView == selectedView {
t.Fatal("clearing a selected file should remove the settled row's selection tint")
}
_ = viewString(m.View())
}
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
+3 -14
View File
@@ -51,21 +51,10 @@ func mouseHover(msg tea.MouseMsg) bool {
return mouseEvent(msg).Button == tea.MouseNone
}
// updateHoverTarget resolves what's under the cursor for a hover motion, using
// the SAME hit-testers and priority order as the click handlers in
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
// step) takes priority since it's outside the chat column, then a clickable
// transcript line. Clears the hover when nothing clickable is under the cursor.
// updateHoverTarget resolves visible transcript targets for a hover motion.
// Context data lives in the run-details overlay; the former sidebar rail is not
// rendered and therefore cannot own a hover target.
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if hit, ok := m.sidebarLineAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
}
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
+49 -70
View File
@@ -1,12 +1,12 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func TestMouseHoverPredicate(t *testing.T) {
@@ -78,70 +78,71 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
}
}
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
// that: real conversation + a mapped swarm member session.
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
func TestDiffHeaderDoesNotHoverOrToggle(t *testing.T) {
m := mouseTestModel()
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowToolResult,
id: "diff",
tool: "write_file",
status: tools.StatusOK,
detail: "--- go.mod\n+++ go.mod\n@@ -0,0 +1,3 @@\n+module greeting\n+\n+go 1.25",
})
rowIndex := len(m.transcript) - 1
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.rowIndex == rowIndex {
target = line
break
}
}
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
if target.text == "" {
t.Fatalf("expected a selectable diff header, got %#v", selectable)
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
if m.hover.kind != hoverNone {
t.Fatalf("diff header must not hover as a control, got %#v", m.hover)
}
if m.hover.sessionID != hits[0].sessionID {
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
}
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
// not a raw line offset — resolve it back to a line offset the same way the
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
// SAME row that was actually hovered, not some other row at that raw index.
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
updated, _ = m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.transcript[rowIndex].expanded {
t.Fatal("clicking an always-visible diff header must not toggle it")
}
}
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
func TestUpdateHoverTargetIgnoresFormerSidebarAgentRail(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected a legacy sidebar row for the rail-coordinate regression")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
func TestUpdateHoverTargetIgnoresFormerSidebarPlanRail(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
// leaving the transcript at its default (welcome-only, i.e. "empty").
m.transcript = appendRow(m.transcript, rowUser, "do something")
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
}
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable plan step row")
t.Fatal("expected a legacy plan row for the rail-coordinate regression")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverPlanStep {
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
}
if m.hover.stepIndex != hits[0].stepIndex {
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
}
// The bug this guards: the render path must resolve stepIndex back to the
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
// into the rendered sidebar (which would land on the AGENTS header/body).
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
@@ -207,28 +208,6 @@ func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
}
}
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
width := m.chatColumnWidth()
height := m.height
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
}
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
if without == with {
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverClearsOnWheelScroll(t *testing.T) {
m := mouseTestModel()
for i := 0; i < 80; i++ {
+56
View File
@@ -1,7 +1,12 @@
package tui
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"runtime"
@@ -9,9 +14,13 @@ import (
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
_ "golang.org/x/image/webp"
)
const attachmentThumbnailMaxPixels = 16 << 20
// droppableImageExts are the image extensions a dragged-and-dropped file may
// carry (matched case-insensitively); PDFs are recognized separately.
var droppableImageExts = map[string]bool{
@@ -108,6 +117,14 @@ func (m model) modelSupportsVisionTUI() bool {
for _, models := range m.modelPickerLiveByProvider {
for _, dm := range models {
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
// A provider's authenticated listing may only establish which models
// are available, without repeating modalities. Treat an empty list as
// unknown rather than as an explicit image-input denial, so the
// curated registry/name capability fallback remains available while
// models.dev metadata is temporarily unavailable.
if len(dm.InputModalities) == 0 {
continue
}
for _, modality := range dm.InputModalities {
if strings.EqualFold(strings.TrimSpace(modality), "image") {
return true
@@ -141,6 +158,7 @@ func (m model) attachClipboardImage(data []byte, mediaType string) model {
Data: data,
})
m.pendingImageLabels = append(m.pendingImageLabels, "clipboard")
m.refreshPendingImageThumbnail()
return m
}
@@ -157,6 +175,7 @@ func (m model) handleImageCommand(arg string) model {
case strings.EqualFold(trimmed, "clear"):
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
m.pendingDocuments = nil
return m.appendImageNotice("Cleared pending attachments.")
}
@@ -185,6 +204,7 @@ func (m model) handleImageCommand(arg string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
m.refreshPendingImageThumbnail()
// No "attached" system message: the composer attachment chip ([Image #N]) is
// the confirmation, matching the compact attach UX.
return m
@@ -219,6 +239,7 @@ func (m model) handleDocumentAttach(path string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, label)
}
m.refreshPendingImageThumbnail()
// The composer attachment chip ([Doc #N] / [Image #N]) is the confirmation; no
// "attached" system message.
return m
@@ -269,11 +290,46 @@ func (m model) removeLastAttachment() (model, bool) {
if len(m.pendingImages) > 0 {
m.pendingImages = m.pendingImages[:len(m.pendingImages)-1]
}
m.refreshPendingImageThumbnail()
return m, true
}
return m, false
}
// refreshPendingImageThumbnail prepares only the first pending image for the
// optional inline terminal preview. Decode failures deliberately leave the
// attachment intact: the text chip remains the portable fallback and the model
// still receives the original bytes.
func (m *model) refreshPendingImageThumbnail() {
m.pendingImageThumbnails = nil
if len(m.pendingImages) == 0 {
return
}
count := min(len(m.pendingImages), attachmentPreviewMaxImages)
m.pendingImageThumbnails = make([]*terminalpet.Animation, count)
for index := 0; index < count; index++ {
preview, err := attachmentThumbnail(m.pendingImages[index])
if err == nil {
m.pendingImageThumbnails[index] = preview
}
}
}
func attachmentThumbnail(block zeroruntime.ImageBlock) (*terminalpet.Animation, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
if config.Width < 1 || config.Height < 1 || config.Width > attachmentThumbnailMaxPixels/config.Height {
return nil, fmt.Errorf("image is too large for an inline thumbnail")
}
decoded, _, err := image.Decode(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
return terminalpet.ThumbnailAnimation(decoded)
}
// renderAttachmentChips builds the pending-attachment row from both staged images
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
// nothing is staged. Numbered (not named) so a long screenshot path never shows
+16
View File
@@ -188,6 +188,22 @@ func TestSubmitThreadsImagesThenClears(t *testing.T) {
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
if len(next.transcript) == 0 || next.transcript[len(next.transcript)-1].kind != rowUser {
t.Fatalf("submit should append a user row, got %#v", next.transcript)
}
if rendered := plainRender(t, renderUserRow(next.transcript[len(next.transcript)-1], 80)); !strings.Contains(rendered, "[Image #1]") {
t.Fatalf("sent image should remain visible on the user row, got:\n%s", rendered)
}
events, err := next.sessionStore.ReadEvents(next.activeSession.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) == 0 {
t.Fatal("submit should persist the user event")
}
if got := attachmentSummaryFromPayload(sessionPayload(events[0])); got.images != 1 || got.documents != 0 {
t.Fatalf("persisted attachment summary = %#v, want one image", got)
}
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
+9 -6
View File
@@ -1,5 +1,5 @@
// keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a
// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat,
// rich set of chord bindings (Ctrl+T effort, drill-in subchat,
// Shift+Tab permission mode, …) that are otherwise invisible — only learnable
// by reading the source. A single-key `?` overlay (opened on an empty composer)
// lists them grouped, so the keymap is discoverable the way the reference TUIs
@@ -7,11 +7,15 @@
// model.go's Update switch; keep them in sync when a binding changes.
//
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
// toggleSidebar) pull their key label from m.keyBindings so a user
// who remaps them in config.json sees the actual chords, not the defaults.
package tui
import "strings"
import (
"strings"
"charm.land/lipgloss/v2"
)
// keybinding is one row in the help overlay: the key chord and what it does.
type keybinding struct {
@@ -53,7 +57,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
},
},
{
@@ -63,7 +66,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "show / hide run details"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
@@ -144,7 +147,7 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+21 -19
View File
@@ -162,22 +162,24 @@ func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
}
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
// 1. Empty composer: Ctrl+B should toggle run details, Ctrl+E should toggle mouseReleased.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.altScreen = true
m.width = 120
m.height = 40
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
initialSidebar := m.sidebarHidden
initialDetails := m.runDetailsOpen
updated, _ := m.Update(testKeyCtrl('b'))
next := updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
if next.runDetailsOpen == initialDetails {
t.Fatal("Ctrl+B on empty composer should toggle run details")
}
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
initialMouse := next.mouseReleased
updated, _ = next.Update(testKeyCtrl('e'))
@@ -198,11 +200,11 @@ func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
}
initialSidebar2 := m2.sidebarHidden
initialDetails2 := m2.runDetailsOpen
updated, _ = m2.Update(testKeyCtrl('b'))
next2 := updated.(model)
if next2.sidebarHidden != initialSidebar2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
if next2.runDetailsOpen != initialDetails2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle run details")
}
initialMouse2 := next2.mouseReleased
@@ -228,8 +230,8 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
// reaches configurable global bindings.
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -247,11 +249,11 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('y'))
next = updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
if next.runDetailsOpen == initialDetails {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still open run details with a non-empty composer")
}
if next.composerValue() != "hello" {
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
@@ -273,8 +275,8 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -289,10 +291,10 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
if next.sidebarHidden != initialSidebar {
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
if next.runDetailsOpen != initialDetails {
t.Fatal("explicit ctrl+b config should NOT toggle run details with a non-empty composer")
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
@@ -136,12 +137,12 @@ func renderLeaderHelpLines(innerWidth int) []string {
}
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
// general ? keyboard-shortcut overlay: same width helper, border style
// (zeroTheme.line), panel fill, and centered block.
// general ? keyboard-shortcut overlay. Its interior stays transparent so a
// terminal's own canvas remains visible without jagged panel-fill padding.
func (m model) renderLeaderHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := renderLeaderHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+17 -1
View File
@@ -240,7 +240,7 @@ func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
}
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
// Both overlays must use the same frame builder (titled box + panel fill).
// Both overlays must use the same transparent, titled frame builder.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
help := m.renderKeybindingHelpOverlay(100)
@@ -267,6 +267,22 @@ func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
}
}
func TestShortcutOverlaysKeepTerminalCanvasTransparent(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
panelFill := zeroTheme.panel.Render(" ")
for _, overlay := range []struct {
name string
view string
}{
{"keyboard help", m.renderKeybindingHelpOverlay(100)},
{"leader help", m.renderLeaderHelpOverlay(100)},
} {
if strings.Contains(overlay.view, panelFill) {
t.Fatalf("%s should not paint the panel background behind shortcut rows", overlay.name)
}
}
}
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.leaderHelpOverlay = true
+1 -32
View File
@@ -91,32 +91,12 @@ func TestCtrlPNMovesPermissionOptions(t *testing.T) {
}
}
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
}
if m.plan.isEmpty() {
t.Fatal("setup: plan should be non-empty")
}
initial := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('p'))
next := updated.(model)
if next.plan.expanded == initial {
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
}
}
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlPMovesPickerSelection(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
Provider: &fakeProvider{},
})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: true,
}
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
@@ -125,9 +105,6 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
}
updated, _ = m.Update(testKeyCtrl('p'))
next := updated.(model)
if !next.plan.expanded {
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
}
if next.picker == nil {
t.Fatal("picker should remain open")
}
@@ -135,19 +112,11 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlNNoOpWithoutModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: false,
}
before := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('n'))
next := updated.(model)
if next.picker != nil || next.suggestionsActive() {
t.Fatal("Ctrl+N idle must not open a modal")
}
if next.plan.expanded != before {
t.Fatal("Ctrl+N idle must not toggle the plan panel")
}
if next.composerValue() != "" {
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
}
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more