Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 524a738eb0 docs(agents): record the normalize-before-match blocker
Three separate changes hit this in a fortnight, each written by someone
being careful about exactly the thing that got them.

A transform that removes bytes without leaving a gap is also a
reassembler. Redaction that matches by shape, run before a sanitizer that
strips control bytes, lets a credential split by a NUL or an ESC pass the
patterns as two fragments and be rejoined on the way out: the MCP failure
reason in #835, and the imported-transcript chokepoint in #878. The path
form of the same mistake is comparing where a handle landed against a
value produced by the same resolver the kernel just used, so a redirect
agrees with itself: the ACL guard in #808, where junctions were caught
only by an accident of Go's mode bits and directory symlinks were not
caught at all.

The unsplit value passing is what makes it survive review, so the note
says the test needs a split case.
2026-08-13 10:40:51 +05:30
Anandan 2d2450e9a7 fix(providers): discover ChatGPT capabilities (#890)
* fix(providers): discover ChatGPT capabilities

* fix(providers): preserve discovered capabilities

* test(agent): cover service tier switching

* fix(tui): restrict fast mode to ChatGPT
2026-08-12 22:41:05 +05:30
cabfeefc4e security(update): stage binary replacement at an unpredictable, exclusive path (#751)
* security(update): stage binary replacement at an unpredictable, exclusive path

Fixes #742. installBinary staged downloaded release bytes at the fixed
<target>.new path and opened it with O_CREATE|O_WRONLY|O_TRUNC. In an
installation directory writable by a lower-privileged process, that
process could pre-create <target>.new as a hard link or reparse point
to another file the (possibly elevated) updater can write; the
truncating open then overwrote that unintended file with verified Zero
executable bytes.

stagingFilePath now generates a cryptographically random suffix so the
path can't be targeted in advance, and platform-specific
createStagingFile opens it exclusively without following a pre-existing
link: O_CREATE|O_EXCL on POSIX (which POSIX guarantees fails on a
pre-existing path, symlink or not, without resolving it), and
CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus a
post-open GetFileInformationByHandle check on Windows (not a reparse
point, not a directory, single hard link).

replace_windows.go's rename-swap sequence is unchanged: the dangerous
operation was always the truncating open of the staged path, not the
.old rename/removal that follows.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* security(update): bind the binary swap to the staged object, not its pathname

Addresses the two open findings on #751.

[P1] The staged file is no longer promoted by pathname. Randomizing the name
and creating it exclusively stops PRE-creation, but under #742's model — a
lower-privileged principal that can write in the installation directory — the
name can be observed after the fact and the entry replaced between the write and
the swap, so the updater would install the substituted file. installBinary now
keeps the creating handle open and promotes through it:

- Windows renames through the handle itself
  (SetFileInformationByHandle/FileRenameInfo, hence the added DELETE access), so
  there is no second pathname lookup to win. replaceBinary is gone; promote owns
  the aside-rename of the running binary and its restore-on-failure retry.
- POSIX has no rename-by-descriptor, so staging moves into a private directory
  created next to the target by os.MkdirTemp (mode 0700, random name, created
  exclusively). An attacker who can write in the installation directory cannot
  create, replace, or list entries inside it. promote additionally sets the
  executable bit through the handle (os.Chmod would re-resolve the path) and
  fails closed if the entry ever stops naming the object it wrote.

[P2] Staging cleanup now covers every failure path: the removal defer is
registered before the copy, and stageBinary discards the object if the copy
fails, so a mid-write error (ENOSPC, unreadable source) no longer leaks a
release-sized file that the next attempt will not reuse. CleanupStaleBinary also
sweeps leftovers from a hard crash — staging files on Windows, staging
directories on POSIX, where it used to be a no-op — skipping anything younger
than an hour so a concurrent update is never disturbed.

Tests: substitution-race regression per platform (Windows asserts the verified
bytes are installed despite a replaced staging entry; POSIX asserts the private
0700 directory and that promote refuses a substituted entry), success controls,
cleanup-on-failure, and the crash-leftover sweep. The forced-staging-failure test
for helper refresh now uses a stageBinary seam, because the staging location can
no longer be occupied from outside — which is the point of the fix.

* security(update): close remaining updater staging races

Address jatmn's review findings on PR #751:

- Windows promote() now verifies targetPath is actually reachable after a
  reported-successful handle rename before trusting it. Some Windows versions
  have been observed accepting SetFileInformationByHandle against a handle
  whose directory entry was substituted out from under it without the object
  actually moving, which let promote report success while targetPath was
  left missing entirely. renameFileByHandle is now a package var so this is
  covered by a deterministic regression test rather than relying on
  reproducing the exact trigger condition.

- When a promotion failure's restore-to-.old also fails (a writable-parent
  attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING can't
  get past), that combination is now wrapped in ErrTargetPossiblyTampered
  instead of reading like an ordinary failed update, with a best-effort
  MOVEFILE_DELAY_UNTIL_REBOOT fallback so an admin-context updater can still
  recover the original at next boot.

- POSIX promote() now binds its final rename to a directory descriptor
  opened when the staging directory is created (unix.Renameat), not a
  pathname. The 0700 staging directory protects its contents from a
  writable-parent principal, but not its own directory entry — that
  principal could rename the staging directory aside and recreate a
  look-alike with an attacker file at the same basename in the gap between
  the identity check and the rename, which a plain os.Rename would silently
  follow.

- Stale-cleanup on both platforms now matches the exact generated artifact
  shape (POSIX: prefix + os.MkdirTemp's all-digit suffix; Windows: prefix +
  32 lowercase hex chars + ".new") instead of a loose prefix/suffix, so a
  user's own similarly-named file or directory is never swept up. Both also
  re-check identity immediately before deleting to shrink the window a
  writable-parent principal could swap the checked path in.

- copyFrom now copies in chunks and refreshes the POSIX staging directory's
  mtime between them, since writing into an already-created file never
  touches the parent directory's own mtime — a large or slow copy could
  previously look abandoned to a concurrent update's cleanup sweep while
  still in progress.

Verified on real Windows and Linux (via WSL), plus cross-compiled build/vet
checks for darwin/linux-arm64.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(update): address staging review findings

* fix(update): verify promoted object identity safely

Bind POSIX staging creation and both rename endpoints to directory descriptors, clean only identity-matched empty directories, and verify Windows promotion by file identity rather than path spelling.

Preserve unverifiable crash leftovers and add adversarial cleanup, hard-link, and reparse-ancestor coverage.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-27da-72ec-8e6d-5d43f127b6a1

Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): preserve promotion recovery signals

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): keep the recovery copy a failed restore promised

Two error-path follow-ups from the #742 staging review.

When a promotion fails and restoring the original also fails, the error tells
the operator their original is preserved at <binary>.old — and then the next
Apply deleted it, because a present target read as proof the copy was stale.
What occupies the target in exactly that case is the bytes the updater could
not verify, so cleanup was erasing the one it could. A failed restore now marks
the copy, cleanup honors the mark, and a successful promotion clears it.

The POSIX staging flow could also leave an empty .zero-stage-* directory behind
when the stat immediately after mkdirat failed. Nothing knows that name yet, so
it would have stayed for good; it is now removed best-effort with rmdir
semantics, which cannot touch anything but the empty directory just created.

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

* fix(update): stop the recovery copy from being destroyed by the next attempt

The marker added in d9523ba kept cleanup from deleting <target>.old, but four
paths still undermined the promise it was meant to make.

Promotion destroyed the copy it was preserving. Skipping the pre-rename cleanup
was not enough: os.Rename uses MOVEFILE_REPLACE_EXISTING on Windows, so renaming
the running binary aside overwrote the last verified copy with the unverified
bytes the earlier failure left at the target — and a promotion that then failed
could only move those unverified bytes back. Promotion now refuses while the
tamper state is unresolved. Only the operator can say whether the file at the
target is theirs, so the error names both moves that end it: restore the copy,
or delete the marker to accept what is installed.

The marker itself was a predictable link-following truncate write. Its path is
fixed, so a writer in the install directory could pre-create it as a hard link
or reparse point and have the elevated updater write through it. It is now
created with the same CREATE_NEW + FILE_FLAG_OPEN_REPARSE_POINT + fresh-regular-
file check as a staging file, and never truncates; an existing name is treated
as already-marked rather than an object to open.

A marker that could not be written left the promise unqualified while the next
run's cleanup deleted exactly the file it named. That failure now rides in the
error, telling the operator to copy it somewhere safe now.

Helper refreshes downgraded tampering to a warning. An ordinary helper failure
still warns — a stale helper is better than a failed update — but a helper whose
path may hold unverified content fails the apply, because helpers are resolved
from the install directory and executed by the sandbox runner, and reporting
Applied: true would hand the operator a success while a sibling executable is
suspect. ErrTargetPossiblyTampered moved to apply.go so callers on every
platform can test for it without build tags.

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

* fix(update): keep the recovery copy when its marker cannot be established

Surfacing a failed marker write told the operator to hurry; it did not stop the
next run from deleting the file they were being told to save. When no marker can
be established, the copy is now moved to an unpredictable sibling name that
routine cleanup never touches — CleanupStaleBinary only ever removes the exact
"<target>.old" — and the error names where it went. Telling the operator to act
now remains the fallback for when even that move fails.

The marker check is conservative in the same direction: only a definite
"not there" allows the copy to be deleted. An Lstat that fails for any other
reason leaves the question open, and deleting is irreversible while keeping
costs one stale file.

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

* fix(update): preserve Windows recovery state across retries

Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>

* fix(update): watch every recovery path and never orphan a partial marker

A failed second-or-later update marks the randomized aside it used, but
promotion only consulted the canonical <target>.old marker, so a retry
proceeded over an unverified target while the verified copy sat marked on a
path the refusal logic did not watch. Promotion now enumerates every recovery
candidate — canonical and randomized asides — and refuses while any of them
is marked, naming each recovery path and its marker.

The missing-target refusal had the same single-path blind spot: with a stale
canonical .old from an earlier successful update plus the aside from an
interrupted attempt, it named only the canonical path, which can be the wrong
binary. It now names the single actual candidate or refuses the ambiguous
layout outright until the operator resolves it.

A failed marker write could also leave a partial .keep behind while the
recovery copy was relocated elsewhere; later refusals then named a location
that no longer held the verified bytes. markOldBinaryPreserved now removes
the entry it created before reporting a write/close failure, and
conservatively reports success when that removal (or the state check) fails
so oldPath stays authoritative. keepUnmarkedRecoveryCopy is handle-bound end
to end: it pins the recovery copy with a no-delete-sharing, no-reparse open,
verifies it is a single-link regular file, renames through the handle with
ReplaceIfExists false, and confirms identity at the destination, failing
closed with ErrTargetPossiblyTampered on any race.

Finally, the error for an unestablishable marker still promised that a later
update would otherwise remove the copy, but CleanupStaleBinary is now a
no-op; the operator is told that a manual copy is required.

* fix(update): tolerate the fail-closed outcome of a fully-unlinked substitution

TestPromoteInstallsTheStagedObjectNotTheStagedPath deletes the staging
file's only directory entry and recreates it as an "attacker" file, then
required promote to succeed with the verified bytes installed. CI's
windows-latest runner permits that delete (this workstation's exclusive
share mode blocks it, so the path went unexercised here); on that runner,
renaming the now fully-unlinked staging handle back into existence is not
something every Windows build honors, so verifyPromotedTarget's post-
rename identity check finds nothing at targetPath, promote fails, and
restoreOriginalBinary moves the pre-update binary back — a safe, fail-
closed outcome, not a security regression.

The test now accepts either outcome after a real substitution: promote
succeeding with the verified bytes, or promote failing as long as the
attacker's substituted bytes were never installed. Any promote failure
that occurs without substitution having actually happened still fails
the test outright.

* fix(update): surface unverified recovery path, match .old case-insensitively

keepUnmarkedRecoveryCopy discarded the destination path when the rename
to it succeeded but the post-rename identity verification failed,
leaving restoreOriginalBinary's error telling the operator to save
oldPath after it had already been vacated. Return the attempted kept
path alongside the verification error so the caller can still name it.

existingRecoveryPaths compared "<target>.old" filenames case-sensitively,
but NTFS is case-insensitive/case-preserving, so a recovery or marker
file spelled e.g. "zero.exe.OLD" could be silently skipped by every
caller's fail-closed check. Fold both sides before matching.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(update): bound Windows recovery cleanup

Amp-Thread-ID: https://ampcode.com/threads/T-019fbdec-f8dc-71f9-abdd-ea044a902b9a
Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): secure Windows recovery cleanup

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): terminate Windows rename paths

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>

* test(update): allow namespaced recovery copies

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3
Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): bind Windows cleanup to handles, drop dead cleanup API

Failure-path staging cleanup on Windows re-resolved the staging pathname
after the exclusive handle was closed, so a principal who can write in the
installation directory could substitute that entry in the gap and have the
updater delete a file of their choosing. Removal is now requested through the
handle (FileDispositionInfo) before it is released, and nothing is removed by
name afterwards; the same change covers the staging-file verification failure
path and the partial recovery marker.

CleanupStaleBinary has had no production caller since bounded, identity-bound
cleanup moved into promote, so the exported no-op and the tests that could only
pass vacuously against it are removed. The POSIX link-regression tests now
drive createStagingFileAt, the primitive createStagedBinary actually uses,
instead of a path-taking helper kept alive only for them.

Also documents the fail-closed Windows recovery states in docs/UPDATE.md,
including the refusal a planted .old/.keep pair can cause.

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

* fix(update): bind Windows recovery to handles and bound its state

Addresses the 2026-08-02 review round on #751.

Keep the recovery restore bound to the object promote moved aside instead
of its pathname: promote now opens the target with delete access and no
delete sharing before renaming it aside, and restoreOriginalBinary renames
that same handle back (retrying through the handle, never re-resolving the
source name), so a directory writer cannot swap the aside entry during the
promotion gap and have the substitute installed.

Run the recovery-state checks as a standalone-install precondition, under
the same lock promotion uses, so "already up to date" can no longer report
success while a .keep/.recovery state is unresolved. The in-promotion check
stays to close the race.

Make the cleanup record a queue: entries are only retired once their object
is actually gone, so a copy held open by a scanner is retried by a later
update rather than leaking, while records for paths that vanished or that
this updater could never have created are retired instead of accumulating.

Also from review of the above:
- delete the pathname restore chain that lost its production callers
  (restoreOriginalBinary's pathname form, openIdentityFile,
  recordRecoveryCleanup) and point their tests at the live handle path
- name the relocation path in the error when the post-move verification
  fails, since oldPath is already vacated by then
- scope the recovery docs to Windows and describe POSIX replace semantics

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-11 20:03:18 +08:00
AnandanandVasanthdev2004 0c1de25a04 Add interactive terminal companions (#887)
* Add terminal companions

* Polish interactive terminal companions

* Fix terminal companion edge cases

* Strengthen terminal companion regression tests

* Preserve config layout when selecting pets

* Handle sparse pet preference layouts

* fix(terminalpet): reset SGR before erasing a sixel companion

On Windows Terminal the companion draws a filled black rectangle over the
interface, and dragging it mangles text under and around it. It looks like a
platform quirk. It is an untested code path that only Windows reaches.

Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty hands
the terminal an image id and the placement goes away. A sixel is already in the
cell grid, so clearRenderedImage writes literal spaces over the cells it
occupied. Those spaces are ordinary characters and carry whatever SGR the TUI
last set, and nothing normalised it, so every erase stamped a filled block in
the ambient background. A drag erases and repaints on every pixel, so the block
was continuously refreshed over the UI.

It stayed invisible everywhere else because ghostty, kitty, WezTerm and iTerm
all resolve to the Kitty protocol and delete by id, never reaching this code.
Windows Terminal is the only mainstream terminal that lands on the sixel branch,
which is why testing on Linux and macOS could not have caught it.

The erase now resets SGR before painting, and uses DECSC/DECRC rather than
CSI s/u because only DECSC saves the attributes this clobbers. Restoring them
matters: the erase is emitted mid-frame, between styled writes the TUI has
already begun.

Measured before and after rather than reasoned about. Before:

  \x1b[s\x1b[3;41H      \x1b[4;41H      \x1b[5;41H      \x1b[u

no reset anywhere. After, the reset leads and DECRC closes.

Tests assert the bytes, since bytes are what the terminal sees: SGR is reset
BEFORE the first row is painted rather than merely present somewhere, the erase
still covers every claimed cell so the fix cannot be satisfied by erasing
nothing, and the Kitty path still deletes by id rather than acquiring the
character-painting erase. Verified by mutation: restoring the old sequence fails
the first of those with the exact reported symptom.

Deliberately not fixed here, since both are design calls rather than defects:
the erase rectangle is computed from the app's own cell maths rather than the
terminal's real footprint, and the sixel path has no in-place move, so every
drag pixel is a full erase and repaint where Kitty gets a cheap reposition.

* fix(tui): ask sixel terminals for their cell size and erase what was painted

The companion blanks a row and a column of live interface on Windows Terminal.
The cause is one predicate.

petPixelProtocolSupported gates two unrelated questions that only looked like
one: whether pixel-precise dragging is available, and whether to ask the
terminal for its cell size with CSI 16 t. It answers true for Kitty only, so on
a sixel terminal the request was never sent, the reply never arrived, and
petCellPixelWidth/Height stayed zero for the whole session.

Everything downstream then took its fallback. petImageHeightPixels returned its
blind preferredHeight of 75 pixels instead of the cell-aware branch below it,
and the erase in clearRenderedImage used petImageColumns and petImageRows. Those
constants describe the RESERVED area, not what was painted. Windows Terminal
reports a 20x10 cell, so a 75-pixel sprite covers 4 rows and about 8 columns
while the erase blanked 5 by 9, taking a row and a column of interface with it
on every move. A drag repaints per pixel, so it did that continuously.

Windows Terminal answers CSI 16 t, confirmed by hand: it replies
ESC [6;20;10t. This was never a question of terminal support. Nothing asked.

So the request now goes out for every image protocol, via a predicate separate
from the dragging one, and the sixel footprint is computed from the rendered
pixel height and the reported cell size rather than from the constants. It is
still clamped to the reserved area, because the layout keeps that many cells
clear and the drag clamps to it, so a larger computed footprint would mean
erasing into live interface.

Deliberately NOT widening petPixelProtocolSupported itself: that would enable
pixel-precise dragging on sixel as a side effect, which is a separate feature
decision rather than part of this defect.

Kitty is untouched and a test pins that. There Columns and Rows are the
placement REQUEST handed to the terminal, which owns the region and scales into
it, so recomputing them would resize the image rather than fix an erase.

Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty
keeps its requested placement, the fallback without metrics stays the reserved
area rather than collapsing to zero and erasing nothing, and every image
protocol asks for the measurement.

* Keep Sixel companions in the reserved dock

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-08-11 09:24:06 +08:00
github-actions[bot] 2458e0c67b chore(main): release 0.7.0 (#837)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-10 11:13:32 +08:00
Vasanth T 027a67080b fix(mcp): name the non-text blocks a tool result drops (#874)
* fix(mcp): name the non-text blocks a tool result drops

TextContent keeps only blocks whose type is "text", so a server that
returns an image, audio, or an embedded resource produced "(empty MCP
tool result)". A screenshot or browser server would complete a call
successfully, hand back a perfectly good image, and the model would be
told nothing came back. It then usually retries, which spends another
call to reach the same empty answer, and the user never learns an image
existed at all.

Carrying the payload is the larger half of #823 and is not this change.
Dropping silently is the part that causes the retry loop, and it can be
fixed on its own.

Decode mimeType on Content, and add DroppedContentSummary, which
describes what TextContent discarded ("1 image/png block", "2 resource
blocks, 1 audio/wav block"), grouping by mime type and ordering by first
appearance so the same result always reads the same way. Servers that
omit mimeType fall back to the block type rather than printing an empty
label.

registryTool.Run appends that as a note, and only when something was
actually dropped, so a text-only result is byte-for-byte unchanged. The
note also says retrying will return the same thing, since telling the
model what happened without telling it not to retry only fixes half the
loop.

Refs #823.

* fix(mcp): do not promise a retry returns the same content

The dropped-block note said "Retrying will return the same thing".
registryTool.Run makes a fresh remote call on every retry, so the server
may well answer differently; that is a promise this code is in no
position to make.

What cannot change is that Zero still has nowhere to put a non-text
block, so the note now says retrying cannot RECOVER the payload. Same
guidance, a claim that is actually true.

The test pins both directions: the new wording must be present, and the
absolute claim must not come back.

Raised by CodeRabbit on #874.
2026-08-10 10:59:03 +08:00
euxaristiaandeuxaristia fb4f293c20 feat(providers): live model lists for OpenRouter and OpenGateway (#860)
* feat(providers): fetch live model lists for OpenRouter and OpenGateway

Prefer each aggregator public /v1/models catalog over the dead OpenGateway
models.json path and models.dev-only OpenRouter mapping so the picker shows
current routes with context windows and free labels.

Refs #859

* fix(providers): parse OpenRouter string pricing and simplify sort assertions

Parse OpenRouter prompt and completion string pricing fields into InputCost and OutputCost, and directly assert alphabetical model sort order in discovery tests.

Refs #859

* fix(providers): scale live pricing and restore OpenRouter fallback

Convert OpenRouter/OpenGateway per-token pricing to per-million for Model costs, and fall back to models.dev when the live OpenRouter fetch fails so the picker still degrades.

* fix(providers): fall back on OpenRouter parse errors; cover live-only merge

Route malformed OpenRouter HTTP 200 bodies through the models.dev fallback,
and exercise catalog vs live payload split so live-only models stay merged.

* fix(providers): address remaining CodeRabbit findings on live catalogs

Reject non-finite OpenRouter pricing, keep capability-marked live-only
models, and harden related regression tests.

---------

Co-authored-by: euxaristia <[email protected]>
2026-08-10 10:58:38 +08:00
Vasanth T ccc42b94e7 test(agent): raise the eager tool schema ceiling to 3650 (#877)
main is red. TestEagerToolSchemaTokenBudget fails at 3578 tokens against
a 3550 ceiling, and every open PR inherits it.

Bisected: edf660ab and 4ca4fa75 pass, cd0eb194 (#843, which added
view_image to the core set) fails at 3588. #867 later trimmed it to 3578.
#843's own CI was green, because the ceiling is only crossed once the
tool set is combined, so no per-PR check could have caught it.

Raised rather than paid down. The overrun is 28 tokens, and view_image
costs 82, the second cheapest tool in the set against exec_command's 909
and request_permissions' 397. It is simply the one that crossed the line.
The alternative was trimming a description to reclaim 28 tokens,
degrading a tool's usability to satisfy a line drawn against a 2026-07
measurement that predates two tools. The ratchet worked exactly as
intended: it caught the creep and forced a decision instead of a drift.

Also corrects the failure message. It advised "defer a tool", but this
test pins DeferThreshold at 0 so deferral is inactive and marking a tool
deferred leaves it exposed here, changing nothing. Deferring is still
worth doing for real sessions; it just cannot move this number. The
levers that can are a smaller schema, one fewer core tool, or a
deliberate raise, and the message now says so.
2026-08-10 10:57:42 +08:00
Vasanth T 2ec5c3ae6b fix(agent): tell the user a prefix approval leaves the sandbox (#885)
Approving a command prefix does not only stop the prompt from reappearing.
shellExecutionArgsForApproval rewrites the call to require_escalated, which
resolves to a nil sandbox engine, so the command runs unsandboxed on the host.
The engine's escalation gate is then satisfied by the PermissionGranted flag
from the approval the user just gave for the sandboxed form.

The escalation itself is deliberate and guarded: proposedCommandPrefix refuses
to offer a prefix when any other segment of the command is not known-safe,
precisely because approval covers the whole command. The defect is that the
prompt never said so. The user chose "allow command prefix for session" and got
"and also run it outside the sandbox".

So this discloses rather than removes. PermissionRequest gains
PrefixApprovalEscalates, computed by shellPrefixApprovalEscalates from the same
guards the rewrite consults, and the two prefix options append "(runs outside
the sandbox)" when it is set. Both the TUI and the ACP path carry it, since an
editor user decides on the same terms as a terminal user.

The main test asserts the disclosure and the rewrite agree on every input
rather than checking each against a fixed expectation, so the two cannot drift
apart later. Verified by mutation: reverting the helper to always-false fails
it, as does making the label unconditional.
2026-08-10 10:56:49 +08:00
ff62f73b7f fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees (#855)
* fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees

1. Prevent trailing redaction leaks in github_token, aws_access_key_id, and google_api_key by adding trailing word boundaries and allowing variable lengths. Refine the openai_key pattern to cleanly distinguish legacy keys and modern prefixed keys (sk-proj-, sk-svcacct-) from ordinary kebab-case phrases.
2. Implement auto-pruning of zero-owned git worktrees older than 24 hours at the start of worktrees.Prepare to prevent indefinite disk space leaks.

* fix(secrets,worktrees): close tail-leak edge case and worktree data-loss risk

Drop the trailing \b anchor on the four secret patterns whose body
class allows "-" (slack_token, google_api_key, the modern openai_key
branch, jwt). \b requires a word/non-word transition, so a secret
ending in "-" right before a delimiter has none, and the engine
backtracked the greedy quantifier to drop that last character instead
of failing the match, leaking it. The body character class already
provides the real stopping boundary, so the anchor was unnecessary.

Fix two issues in worktree Clean flagged in review:

- Staleness was decided by the worktree directory's own mtime, which
  only changes when an entry is added/removed/renamed directly inside
  it, not when a long-running task edits existing files deeper in the
  tree. Clean now walks the tree and treats any recently modified entry
  as live, and also skips any worktree a caller has explicitly locked
  via git worktree lock.
- baseDir ownership used a raw strings.HasPrefix, so a sibling like
  "<baseDir>-other" would false-match. Replaced with a filepath.Rel
  path-boundary check.

* fix(worktrees): check exit codes on removal, fail closed on inspection errors

defaultRunGit deliberately returns a nil error alongside a nonzero
CommandResult.ExitCode for a failed git invocation, so the worktree
remove call must check ExitCode itself instead of trusting a nil error
to mean success. Route it through gitOutput, which already does that.

worktreeIsStale treated an inspection failure (an unreadable file, a
WalkDir error) the same as "keep walking," which can let an
incompletely-inspected worktree be judged stale. Any inspection error
now makes it ineligible for removal instead.

* fix(secrets,worktrees): catch appended-suffix keys and scope pruning to owned worktrees

The scanner's trailing \b anchors made a credential vanish entirely when
followed by a word character outside its body class (an appended suffix
like AKIA...EXTRA, or ghp_..._suffix): the fixed or unbounded-greedy
quantifier had no valid word boundary to land on and the whole match
failed, so the real secret reached the redaction output unredacted.
Dropping the trailing anchors lets the body class itself stop the match,
so the credential prefix still gets redacted even when noise follows it.
Also recognize sk-admin- alongside sk-proj-/sk-svcacct- so OpenAI admin
keys aren't skipped by the narrowed modern-key branch.

Clean pruned any worktree under the caller-supplied BaseDir, but Prepare
only ever creates worktrees under a per-repository
zero-worktree-<repoKey> subtree of it. Scope pruning to that subtree so a
worktree a user manages by hand elsewhere under a shared BaseDir is never
force-removed. Also refuse to force-remove a worktree whose mtime looks
stale but that still has uncommitted or untracked changes: a task can
hold live work while waiting on a model, network, or user for longer
than the staleness window without writing to the tree again.

* fix(worktrees): make nested-activity test exercise the deep walk it claims to

activePath/internal was created by the same MkdirAll as the nested pkg
dir but never backdated, so it kept a fresh mtime and worktreeIsStale's
walk reported "not stale" as soon as it hit that directory, before ever
reaching the freshly-written file two levels deeper. The test passed
without actually exercising recursion past the first directory.

* fix(worktrees): lock zero-created worktrees and treat ignored files as dirty

Prepare never called git worktree lock, so the entry.locked skip in Clean
only ever protected worktrees a human locked by hand, never zero's own; a
worktree that finished committing and sat idle (e.g. waiting on a slow
model or network retry) for more than 24h looked clean-and-stale and got
force-removed by the mtime+dirty heuristic alone. Lock every worktree
Prepare creates so it gets the same protection.

worktreeIsDirty also used git status --porcelain with no --ignored, so a
worktree holding only .gitignore-matched task data (credentials, generated
drafts, artifacts) reported as clean and got force-removed with --force,
silently discarding it. Add --ignored so those files count as dirty too.

* fix(worktrees): release Zero's Prepare lock so Clean can reclaim finished worktrees

Prepare locks every worktree it creates so Clean's mtime+dirty staleness
heuristic never force-removes one Zero is still using, but nothing ever
unlocked it, making the automatic disk-space cleanup permanently inert.

Add Release (git worktree unlock) and wire it in two ways: zero exec
--worktree defers a release once its own run finishes, since that flow's
use of the worktree is bound to its own process. zero worktrees prepare
hands the path to a longer-lived external caller with no defined
end-of-life, so a new zero worktrees release <path> subcommand lets that
caller release it explicitly when done.

* fix(worktrees): normalize release path, aggregate Clean errors, unlock deleted worktrees

Address CodeRabbit's review on the lock-release fix:

- zero worktrees release now resolves its path argument to absolute
  before calling Release, since git worktree unlock matches against
  the path git recorded at creation, not whatever directory the
  caller happens to be running from.
- Clean now aggregates removal failures with errors.Join instead of
  overwriting lastErr, so multiple stale worktrees failing removal in
  the same pass are all reported, not just the last one.
- Release falls back to options.Cwd as the git working directory when
  the worktree path itself no longer exists (e.g. a caller deleted a
  locked worktree by hand instead of releasing it first), so the
  orphaned lock can still be cleared.

Added regression coverage for all three.

* style(worktrees): align aggregation test comments to gofmt output

* fix(worktrees,cli): restore reuse lease and scope unlock to owned locks

- Prepare re-locks a reused worktree so Clean's staleness heuristic cannot
  force-remove it while the new caller is still using it; a lock already
  held by a live external caller is kept in place and reported through the
  new Result.LockAcquired field.
- exec --worktree only releases the lock its own Prepare call acquired and
  surfaces a failed release on stderr with the affected path instead of
  discarding the error.
- worktrees release wires the resolved workspace root into Options.Cwd so
  the deleted-path recovery works outside the worktree directory.
- The relative-path release test derives its expected value via
  filepath.Abs, matching the resolution the CLI uses, so macOS /var vs
  /private/var spellings no longer break it.

* fix(worktrees,cli): reject in-use leases, validate before cleanup, add release -C

- Prepare rejects a worktree whose lock another run still holds, on both
  the reuse and the create-race paths, instead of handing a second live
  caller an unprotected shared checkout whose sole Git lock the first
  caller's exit would release.
- The automatic stale-worktree pruning runs only after the request itself
  validates, so a rejected command (an invalid --name) has no destructive
  cleanup side effect; covered end to end with real git in both directions.
- worktrees release accepts -C/--cwd naming the source repository, which
  the deleted-path recovery needs when launched outside the repo (the
  deleted worktree path is a one-way hash with no way back to its source).

* test(worktrees): canonicalize test roots to physical spelling

git records worktree paths in physical form, so the CI runners' symlinked
(/var -> /private/var) and 8.3-short (RUNNER~1) temp spellings made Clean's
containment check skip the test's stale entry and the pruning assertion
fail on macOS and Windows.

* fix(worktrees): preserve orphaned commits, verify release ownership, canonicalize base dir

Four fixes from the latest review round:

- Clean now creates a durable ref (refs/zero/orphaned-worktree/<sha>)
  for a detached worktree's HEAD before force-removing it, when that
  commit isn't already reachable from any other ref. Prepare always
  creates worktrees with `worktree add --detach`, so a commit made
  there had no ref pointing at it once the worktree was deleted,
  making it immediately eligible for git gc despite never having been
  merged/pushed elsewhere.

- Clean resolves its configured base directory through EvalSymlinks
  before comparing it against git's reported worktree paths: `git
  worktree list --porcelain` reports each worktree's PHYSICAL location
  (resolving symlink components), so a symlinked --worktree-dir made
  every worktree created under it permanently unprunable.

- Release now verifies path has a zero-worktree-<repoKey> ancestor
  directory component before running `git worktree unlock`, so it
  can't be used to clear the lock on a worktree a user or another tool
  manages by hand. The check doesn't need to know which --dir a given
  Prepare call used (nothing records that against a specific worktree,
  and the CLI never threads BaseDir through to Release) — the repoKey
  component is Prepare's actual ownership signature regardless of
  which directory it was created under.

- Route the release command's printed path and exec's release-failure
  diagnostic through the existing CLI redaction helper, and split the
  worktrees help text into prepare-specific and release-specific flag
  sections (release only ever supported -C/--cwd, not the --name/--dir/
  --json the shared block advertised).

All four have regression tests confirmed to fail without their fix
(two using real git worktrees, not just fakeRunner sequences). Build,
vet, and gofmt clean on linux/windows/darwin.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(worktrees): recoverable PID leases and reclaimable released worktrees

Two review findings on the cleanup lifecycle:

- A lock left by an abnormal exit (SIGKILL, crash, power loss) was
  skipped by Clean forever, recreating the permanent disk leak this PR
  set out to fix. exec --worktree now records its PID in the lease
  reason; Clean expires a lease whose recorded owner is provably dead,
  unlocking only after the staleness, dirty, and HEAD-preservation
  guards all pass. Human locks and PID-less leases (external
  `worktrees prepare` owners) remain permanent until explicit release,
  and any ambiguity in the liveness probe counts as alive.

- An explicitly released worktree holding only gitignored residue
  (node_modules, build output) was skipped at every age. Release is the
  owner's completion signal, so unlocked entries now block removal only
  on tracked/untracked changes; expired crashed leases keep the
  conservative --ignored probe since they never signaled completion.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(worktrees): address review feedback on lease detection and release safety

Split dead-lease PID checking into posix/windows implementations so
Windows can reliably tell a dead process from a live one. Fix a path
canonicalization mismatch in two release tests. Derive release
ownership from git worktree list instead of the git-dir parent, which
was wrong for repos with a separate git-dir. Refuse to clear a lock
that was not taken by Zero in the first place.

* fix(worktrees): canonicalize paths for clean/release ownership checks

Compare Clean containment and Release ownership against physical path
spellings so macOS /var vs /private/var and symlink TMPDIR layouts match
git worktree list. Require a registered porcelain entry and a Zero lease
reason before unlock; treat an already-unlocked Zero worktree as a no-op.

* fix(worktrees,secrets): address jatmn review findings on #632

- Prepare and Release now agree on repoKey regardless of which worktree
  (main or linked) Prepare runs from, by keying off git worktree list's
  first entry (always the main worktree) instead of --show-toplevel.
  A worktree prepared from a linked checkout previously failed its own
  ownership check on release and its lease could never be cleared.
- osProcessAlive on Windows no longer treats every OpenProcess failure
  as "process is dead": only ERROR_ACCESS_DENIED (a live process this
  caller lacks rights to query) is now distinguished from a genuinely
  missing PID, so Clean can no longer force-remove an active worktree
  whose owning process it simply couldn't query.
- The openai_key redaction pattern now recognizes sk-or-v1- (OpenRouter)
  alongside the existing sk-proj-/sk-svcacct-/sk-admin- prefixes, so
  hyphenated OpenAI-compatible provider keys are redacted again without
  reopening the sk-<kebab-case-phrase> false-positive this pattern was
  narrowed to avoid.
- Release/exec --worktree error text is redacted before reaching
  stderr, matching the already-redacted success path; ownership errors
  interpolate the caller-supplied path, which could carry a key-shaped
  segment.
- canonicalizePath resolves symlinks through the nearest existing
  ancestor when the target itself no longer exists, so the documented
  `release -C` recovery path works again for a worktree deleted by hand
  under a symlinked --worktree-dir.
- Prepare rolls back the worktree `git worktree add` just created if the
  subsequent lock call fails for a reason other than a concurrent
  racer, instead of leaking an unleased checkout until Clean's 24h
  staleness window reclaims it.

Not addressed here: the P2 finding that worktree ownership is provable
only by a directory-name convention plus a lock-reason prefix, both of
which a user can reproduce by hand. A durable per-worktree ownership
marker would close that gap, but internal/worktrees has been on main
since #70, so a marker requirement could reject worktrees an
already-installed zero created before this change existed. Needs a
decision on migration before implementing.

* fix(worktrees): prove Prepare ownership with a git-admin marker

Address review findings that path convention plus lease-reason prefix are
forgeable by hand. Prepare now writes a zero-owner marker into the worktree
admin dir; Release and Clean require it before force-touching a path. Clean
also keys its owned subtree off the main worktree root so linked-checkout
calls still prune the same bucket Prepare uses. Tests plant the marker and
list the main worktree first so fixtures match production.

* fix(cli): complete worktrees release in shell completions

* test(worktrees,cli): add coverage for linked-worktree Clean, forged lease rejection, and completions

- TestCleanFromLinkedWorktreePrunesStaleWorktree: pins Clean deriving its
  owned-subtree key from the main worktree root (not the invoking linked
  checkout's --show-toplevel) so Prepare/Clean run from a linked worktree
  actually reclaim the worktrees Prepare created there.
- TestReleaseRejectsForgedZeroLeaseWithoutOwnershipMarker: pins the
  ownership-marker requirement against the exact forgery jatmn described - a
  worktree under the predictable zero-worktree-<repoKey> path, manually
  locked with a reason that merely starts with the zero lease prefix.
- Fix TestPrepareCreatesDetachedGitWorktree: its fake git runner ran out of
  canned results at Prepare's post-lock ownership-marker write, so
  writeOwnershipMarker resolved gitDir to "" and os.WriteFile wrote
  "zero-owner" as a relative path into the test process's real working
  directory instead of failing loudly. Give it autoAbsoluteGitDir like the
  other Prepare-exercising tests use and assert on the marker-write call.
- completions_test.go: assert `worktrees`/`worktree` completions include
  `release` alongside `prepare`.

* fix(redaction,worktrees): sort extra secret values by length descending and canonicalize worktree paths

* fix(secrets,worktrees): restore Anthropic key redaction and handle legacy worktree cleanup

* fix(secrets): add bash output redaction regression test for Anthropic API keys

* fix(worktrees): touch worktree mtime on reuse to prevent stale pruning

When Prepare reuses an existing worktree, it now touches the directory
to refresh its mtime. This prevents Clean from force-removing a
long-running but idle worktree (e.g. waiting on a model) that has no
recent file changes but is still actively in use.

Addresses the data-loss risk flagged in the PR review where mtime-only
staleness + --force removal could discard live worktrees.

Co-authored-by: cairn-code
Co-Authored-By: cairn-code <[email protected]>

* fix(worktrees): properly handle os.Chtimes error on reused worktree path

Co-Authored-By: cairn-code <[email protected]>

* fix(worktrees,secrets): unlock using porcelain entry path, set RepoRoot to primaryRoot, and add anthropic_key pattern

Unlock using matched porcelain entry path, set Result.RepoRoot to primaryRoot in Prepare, add dedicated anthropic_key secret pattern, and canonicalize fake-runner test paths.

Refs #632

* fix(tools): correct redaction placeholder assertion in Anthropic key test

TestFormatBashOutputRedactsAnthropicKey checked for the openai_key
placeholder instead of anthropic_key, a copy-paste leftover. The
redaction itself was already correct; only the assertion was wrong.

* test(worktrees): physicalize test paths for macOS tempdir symlink resolution

* fix(secrets,worktrees): address CodeRabbit review findings

Align RedactString token boundaries with secrets.Scan, strengthen the
Anthropic bash redaction assertion, fix Clean fixtures so they reach
the guards under test, and fail closed on ambiguous processAlive probes.

* fix(secrets,worktrees): restore broad key redaction and legacy Clean safety

Address human review on #855: keep sk- bodies with a digit filter instead
of enumerated vendor prefixes, add a looser JWT form, restore the
sk-test fixture, probe legacy ownership before dirty, treat non-INVALID
Windows OpenProcess errors as alive, redact Abs/cwd release errors, and
reclaim dead-owner leases on Prepare reuse.

* fix(secrets,redaction): always redact known OpenAI key prefixes

Alphabet-only sk-proj-/sk-svcacct-/sk-admin- tokens are still credentials;
keep the digit filter only for unknown sk- vendor forms so kebab phrases
like sk-learn-… stay un-redacted.

* Preserve digit-free legacy keys during redaction.

Refs #855

* fix(secrets,worktrees): address CodeRabbit findings on PR #855

Redact full compact JWE tokens, pin git locale for lock parsing, write the
ownership marker atomically, surface missing-dir unlock failures, and
fail closed when HEAD probes are indeterminate. Align Clean fixtures and
digit-free known-prefix redaction tests with the shipped behavior.

Refs #855

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: cairn-code <[email protected]>
Co-authored-by: euxaristia <[email protected]>
2026-08-10 10:51:11 +08:00
0176ee49ff feat(agent): add PermissionModePlan for interactive read-only planning (#853)
* feat(agent): add PermissionModePlan for interactive read-only planning

* fix(agent): drop redundant modeName branch, add plan mode regression tests

string(permissionMode) already yields "plan" / "spec-draft", so the
if/else recomputing modeName in the denial message was dead branching
on the same values. Also add plan-mode coverage mirroring three of the
four existing spec-draft regression tests: advertised tool set, and
denied write_file/bash calls. The fourth (submit-and-stop review
control) has no plan-mode analog, since plan mode has no submit tool.

* fix(agent): deny request_permissions in plan/spec-draft even when the registry omits it

request_permissions is dispatched by name in executeToolCall before the
registry-based ToolAdvertised gate runs, so that gate only helps when
the tool happens to be present in the caller's registry. A plan- or
spec-draft-mode registry that simply omits the tool (rather than
registering it as denied) let the call fall through to a real
turn/session-scoped permission grant, defeating the read-only
boundary. Deny it unconditionally at the top of
executeRequestPermissions for both read-only modes instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(agent): suppress executable hooks while plan/spec-draft mode is active

Plan mode promises a read-only turn, but sessionStart/sessionEnd fire on
every run and beforeTool/afterTool fire around allowed read calls, and
all four execute configured host commands outside the advertised-tool
and sandbox gates — so a project hook could mutate the workspace or
spawn a process from a session that advertises it cannot. Gate all four
dispatch points on the run's permission mode, with a regression test
asserting no hook command launches during a plan-mode run.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): close plan-mode tool advertisement bypass

toolAdvertisedInPlan whitelisted ask_user and update_plan by name
alone, so a caller could register a mutating tool under either name
and have it advertised and executed in plan mode. Validate every tool
against its Safety() instead. Also exclude lsp_navigate, which is
marked SideEffectRead but lazily spawns a real language-server
process, contradicting plan mode's read-only guarantee. Add
regression tests for both.

* fix(agent): keep trust-gated hooks in spec-draft mode

Plan mode still suppresses executable hooks so a read-only planning turn
cannot spawn host processes via session or tool hooks. Spec-draft keeps
the existing trust model: project hooks fire when the workspace (or its
worktree trust root) is trusted. Unconditionally suppressing hooks in
spec-draft broke TestExecSpecWorktreeInheritsTrustEndToEnd for trusted
worktrees under --use-spec --worktree.

* fix(agent): cover plan-mode spoof and lsp_navigate denials

Expand the name-only spoof regression to both update_plan and ask_user,
and add an execution-path denial for lsp_navigate so plan mode cannot
spawn language servers even when a call still arrives.

* fix(agent): filter tool_search deferred candidates by plan/spec-draft visibility

tool_search resolved and ranked deferred tools by EnabledTools/DisabledTools
only, never by the run's permission-mode visibility. tool_search itself is
already denied at dispatch in plan/spec-draft (its Safety carries no side
effect, so it fails the same SideEffect==Read advertisement gate direct calls
use), so this was not reachable through the normal Run() path today. But it
is a real landmine: if that outer gate is ever loosened independently (e.g.
tool_search's no-side-effect Safety is judged advertisable), the loader had
no gate of its own and would hand a deferred write/mutator tool's name,
description, and full schema straight to a plan/spec-draft model.

Mirror agent.ToolAdvertised's plan/spec-draft branches inside the tools
package (toolAdvertisedForPermissionMode, next to the existing
toolAllowedByFilters mirror that avoids the same import cycle) and apply it
alongside the operator filters in visibleDeferredTools and
visibleEagerToolNames.

Added unit tests in tool_search_test.go for both modes, and an end-to-end
agent test that force-calls tool_search in plan mode and asserts no schema
leaks. Verified by reverting tool_search.go and confirming the new tests fail
(one shows load_tools resolving to the mutator's name); restored and
confirmed they pass. Also confirmed via a temporary probe that if plan mode's
outer advertisement gate is loosened, this filter is what actually stops the
leak.

* fix(agent): require Safety for spec-draft ask_user/submit_spec

Do not advertise or load re-registered control tools by name alone in
spec-draft mode. ask_user must be SideEffectRead+Allow and submit_spec
must be SideEffectWrite+Allow, matching the real tools. Apply the same
filter in tool_search and add spoof regression tests.

Refs Gitlawb/zero#642

* fix(agent): wire plan mode into TUI, CLI, and ACP entry points

Address the P1 finding that PermissionModePlan was documented but never
selected by /plan, zero exec, or ACP mode selectors. /plan on|off now
toggles the session permission mode (restoring the prior mode on off),
zero exec --plan selects plan for a run, and ACP advertises plan as a
client-selectable mode. Integration coverage for each entry path.

* fix(cli): reject --plan combined with --worktree

Worktree preparation runs in runExec before the plan permission mode is
assigned, so `zero exec --plan --worktree` could still trigger workspace
mutation ahead of the read-only gate. Reject the combination during
option validation, alongside the existing --use-spec/--skip-permissions-
unsafe conflict checks, so no worktree prep can occur.

Addresses a coderabbitai finding on PR #642.

* test(tools): assert spoofed tool schema doesn't leak via tool_search

The spoofed-control-tool regression only asserted on the description
string; Parameters() exposed no distinctive schema marker, so a
regression that leaked the schema without the description would still
have passed. Add a spoofed_secret property to the test tool's schema
and assert it's absent from result.Output alongside the description.

Addresses a coderabbitai finding on PR #642.

* fix(tui): gate local mutating commands behind plan mode

/plan on only flips the agent permission mode, which gates agent tool
calls. Local TUI commands that run entirely inside the TUI process
bypass that gate: /rewind restores workspace files from a checkpoint,
/export writes a transcript to disk, and /sandbox-setup spawns a
native host process. Add a shared plan-mode guard at the start of
dispatchCommand (mirroring the existing BTW-unavailable guard) that
rejects these three commands while permissionMode is
agent.PermissionModePlan, with regression coverage proving each is
blocked with no mutation/process spawn in plan mode and unaffected
outside it.

Addresses a coderabbitai finding on PR #642.

* fix(agent,specialist): layer plan mode system prompt and enforce read-only subagent mode

* Fix jatmn review findings for PR 642

* fix(agent,cli,tui): resolve CodeRabbit review comments on active turn model field and --plan permission mode conflict

* fix(agent): propagate permission mode to exec options and serialize ACP mode changes

Parse permissionMode in resolveExecPermissionMode, acquire turnMu.Lock in ACP handleSetMode to serialize mode changes with active turns, and block MCP subcommands in TUI plan mode.

Refs #642

* fix(agent,tui): update tests off removed tools.CoreTools/NewWriteFileTool/NewLSPNavigateTool wrappers

Those were thin unscoped wrappers around the Scoped variants, deleted
upstream in #706 since nothing else called them directly. Only these
tests still did; switch to the Scoped calls main's own tests already
use.

* fix(agent): fail-closed beforeTool vetoes and permission-mode plan guards

Keep beforeTool deny gates active under plan mode so hooksSuppressed no longer
fails open, and stop propagating --permission-mode for auto/ask/member children
so swarm members keep write tools. Apply --plan combination rejects to
--permission-mode plan as well.

Refs #853

* fix(agent): address CodeRabbit findings for plan-mode advertisement and entry paths

Unify plan/spec-draft tool advertisement in tools.ToolAdvertisedForPermissionMode
(with PermissionDeny short-circuit), cover ACP config and --permission-mode plan
list-tools paths, and allow bare /mcp while blocking mutating MCP subcommands.

Refs #853

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
2026-08-10 10:15:29 +08:00
Anandan 160e3bed1a Unify tool output views (#880)
* Unify tool output views

* Preserve finalized tool outcomes

* Make outcome tests portable
2026-08-10 09:58:05 +08:00
Vasanth T 7fbd864e91 fix(tui): fall back to CellMotion where AllMotion is unreliable (#872)
* fix(tui): fall back to CellMotion where AllMotion is unreliable

AllMotion (1003) buys exactly one thing over CellMotion (1002): hover
highlighting. Wheel, click and drag all arrive under either. But a
terminal that does not implement 1003 does not degrade to 1002, it sends
nothing at all, and because the TUI holds the alternate screen the
terminal's own scrollback and wheel are gone too. The user is left
unable to scroll by any means, and a long answer then reads as a hang
rather than as a missing highlight. That is how #870 arrived: a
completed turn with "59 more" lines below the fold, reported as a
freeze.

The existing code already conceded 1003 is fragile by carving out PRoot.
The legacy Windows console is the same case and was not covered, so a
bare zero.exe outside Windows Terminal asked for a mode its terminal
does not answer.

Move the decision into mouseModeFor, which trusts terminals that
identify themselves (WT_SESSION for Windows Terminal, TERM_PROGRAM for
hosts like VS Code and mintty) and assumes an unidentified Windows
terminal is the legacy console. Guessing wrong that way costs a hover
highlight; guessing wrong the other way costs every mouse event.

ZERO_MOUSE_MODE=all|cell|off overrides it, so a user on a terminal we
guessed wrong about can fix it without waiting for a release. An
unrecognised value is ignored rather than treated as "off", so a typo
cannot silently kill the mouse.

Refs #870.

* test(tui): pin the inherited-environment limit of the mouse-mode guess

WT_SESSION and TERM_PROGRAM are inherited, so they answer for whichever
process set them rather than for the console host attached now. A shell
started from Windows Terminal or Git Bash that later runs zero.exe
against the legacy console still carries them, and mouseModeFor then
asks for AllMotion on a host that may drop every mouse event: the #870
failure class through a narrower door.

Not fixed, and deliberately so. Telling an inherited variable from a live
one needs the console host rather than the environment, and erring the
other way costs every Windows Terminal user their hover highlighting for
a case that takes an unusual launch path to reach.

So the limit is written down in the code and pinned by a test that also
asserts ZERO_MOUSE_MODE=cell still overrides it, since that override is
the only recourse an affected user has. If real host detection lands
later the test fails by design, and its message says to delete it.

Raised by @jatmn on #872.
2026-08-10 09:57:44 +08:00
euxaristia 020c28eaf9 fix(tui): fill alt-screen frame with theme panel background (#850)
* fix(tui): fill alt-screen frame with theme panel background

Blank and cleared cells fell back to the terminal's own default
background instead of the theme's panel color, flashing white on
light themes during resize or when ClearScreen erases the frame.
Wrap the rendered content in zeroTheme.panel sized to the full frame
so every cell is explicitly painted.

* fix(tui): apply theme panel content to tea.View after render

tea.NewView stores the original string; reassigning the local content
variable left the view unchanged. Call SetContent after the panel
render so the alt-screen frame uses the themed output.
2026-08-10 09:57:25 +08:00
euxaristiaandgoogle-labs-jules[bot] 21e1e8d072 style(cli): flatten trust workspace resolve errors with %s (#849)
* fix: prevent leaking sensitive info in wrapped error responses

Modified `fmt.Errorf` calls in `internal/cli/trust.go` to use `%s` instead of `%w` when wrapping errors that are subsequently redacted via `redaction.ErrorMessage()`. This ensures the underlying error is flattened to a plain string, preventing sensitive information from being exposed later via `errors.Unwrap()`.

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

* chore(cli): drop agent sentinel from trust error-format tidy

Remove .jules/sentinel.md (local agent artifact, not in main). Keep the
trust.go %s flatten as a readability tidy only; redaction.ErrorMessage
already stringifies inline, so this is not a security fix.

* style(cli): note intentional %s flatten on trust resolve errors

Keep a short comment at both sites so the %s form is not "fixed" back to %w; ErrorMessage stringifies inline.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-10 09:50:44 +08:00
936aa4e77c fix(lsp): dispatch notifications off read loop (#759)
* fix(lsp): dispatch notifications off read loop

* fix(lsp): keep notification reads non-blocking

* fix(lsp): make the notification queue lossless

Addresses the P1 on #759.

The previous commit traded the read-loop deadlock for silent protocol-event
loss: at 64 queued items enqueueNotification discarded the OLDEST message. A
textDocument/publishDiagnostics is the server's only report for its URI, so a
dropped one makes session.waitForDiagnostics time out and Manager.Check return
no diagnostics even though the server published them — and the overflow test
codified that loss as intended behavior.

The queue is now unbounded: the read loop still never blocks (so a handler may
make a re-entrant Client.Call), and nothing is discarded. Both alternatives are
worse — blocking on a full buffer is the original deadlock, and dropping loses
state the protocol never repeats. Growth is bounded in practice by what the
server emits while a handler runs; the backing array is released as soon as the
queue drains, so a burst does not retain capacity afterwards.

Tests: TestClientNotificationQueueIsLossless replaces the drop-oldest test, and
the end-to-end burst test now also asserts that all 512 notifications sent while
a handler was blocked in a re-entrant call arrive, in order. Both were checked
against the old drop-oldest policy and fail there (64 of 512 delivered, FIFO
broken), so they have teeth.

* fix(lsp): stop retaining notifications after Close

Addresses the shutdown defect CodeRabbit found in the lossless queue.

Client.Close signals the worker loop to exit, but readLoop keeps consuming
frames until the transport ends — and Server.Shutdown closes the client BEFORE
closing stdin, so a language server emitting notifications while it handles
shutdown/exit fed an unbounded queue that nothing would ever drain.

failPending's closeOnce now marks the queue closed and releases whatever is in
it, and enqueueNotification drops instead of appending once that flag is set.
Both run under notifyMu, so an enqueue racing with shutdown either sees the flag
and drops its item or appends just before it and has the item cleared. Queued
work is dropped rather than drained: the worker is gone by definition of close,
and callers that need diagnostics collect them before shutting the client down.

TestClientDropsNotificationsAfterClose keeps the transport writable after Close,
as Server.Shutdown does, and requires 64 subsequent notifications to neither
reach the handler nor accumulate. Verified against the unguarded enqueue, where
it reports 64 retained notifications.

* fix(lsp): baseline diagnostics against receipt, bound the notification backlog

Addresses jatmn's two #759 review findings:

- Manager.Check captured its diagnostics baseline from publishCount,
  which only advances once session.handleNotification actually runs
  for a URI. Dispatch happens off a queue now, so a publish for a
  version Check is about to supersede could already be sitting
  received-but-undispatched at baseline-capture time, then run
  afterward and look newer than the baseline purely because handling
  lagged receipt. Since many servers omit the version field, the
  existing staleness check in handleNotification (positive-version
  only) couldn't catch it either. Stamp every notification with a
  receipt sequence at enqueue time (Client.NotificationSeq), carry it
  through the queue to the handler, and baseline against receipt
  instead of against how many publishes have been processed.

- The lossless notification queue had no failure policy: a language
  server sustaining a higher notification rate than the single
  handler can drain (no stuck handler required) would retain every
  full json.RawMessage payload without bound. Add notifyQueueLimit;
  exceeding it fails and closes the client observably, letting the
  manager's existing dead-session eviction take over on next use,
  instead of growing the queue until the heap gives out.

Both regression tests are verified to fail against the pre-fix code:
the receipt-vs-baseline race was reproduced directly against the
pre-fix API (confirmed real, not hypothetical) before porting the
test to the fixed API, and the overload test was confirmed to fail
with the limit check removed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(lsp): bound notification backlog bytes

Track retained notification payload bytes in addition to queue length and fail the client before the backlog exceeds 16 MiB. Wake diagnostic waiters when that failure closes the client, preserving already-received fresh diagnostics during debounce. Add race-enabled regression coverage for byte overload cleanup and client-failure wakeup.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-b80c-717b-b041-5d5135a61d96

Co-authored-by: Pierre Bruno <[email protected]>

* fix(lsp): preserve diagnostics across client closure

When a fresh publish and client closure wake a diagnostic waiter together, reconcile against the recorded receipt sequence instead of allowing select order to discard the publish.

Add a regression that repeatedly makes both wakeups ready together and verifies diagnostics handled before closure are preserved.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-5dd3-7118-a7a6-fba8f9d5af29

Co-authored-by: Pierre Bruno <[email protected]>

* fix(lsp): drain accepted notifications on close

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(lsp): pin the receipt boundary at read time and finish the drain

Stamping a notification's receipt seq at enqueue left a window async dispatch
had opened: readMessage consumes the frame, then json.Unmarshal and the enqueue
run later on the same goroutine while another can capture a publishBaseline. A
publishDiagnostics for superseded text could land above a baseline taken after
it was already off the wire and — most servers omit the version field the
staleness check needs — reach the caller as the answer for the new text. The
read loop now stamps every frame the moment it leaves the wire and hands that
seq to the enqueue, so decoding cost sits outside the boundary. ReceiptSeq
replaces NotificationSeq: the clock counts frames, and gaps in it are fine.

Notification drain handling is now uniform. Every exit from waitForDiagnostics
catches the worker up on notifications it already accepted before reading what
was recorded, instead of only the seq<=baseline closure arm doing so. That
covers cancellation racing an accepted-but-queued publish (which used to make
Check return (nil, nil) for text the server had answered), and the debounce
closure arm returning while a newer publish was still queued (which handed the
caller the older one). After closure the drain is bounded — admission is shut
and a handler's re-entrant Call fails immediately — so it is no longer cut short
by ctx; on a live client the catch-up is bounded by a short grace instead.

Also cap Content-Length before allocating the body, so the notification byte
budget is authoritative rather than measured after a huge frame is materialized.

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

* fix(lsp): follow publishes accepted while catch-up is waiting

Catch-up snapshotted the accepted sequence once, but the reader keeps running
while it waits. The worker finishing the item that was last at the start
satisfied the check, so catch-up returned with a newer publish for the same URI
still queued — and the caller read diagnosticsFor and got the older one. That is
the failure the closed-client drain exists to prevent, reproduced on the live
path by the async dispatch this PR introduces.

The target is now re-read every pass. This cannot spin forever: the live-client
grace already caps the wait no matter how fast the server publishes, and a
closed client still drains unconditionally because its backlog cannot grow.

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

* test(lsp): wait for follow-up notification enqueue

Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>

* test(lsp): pin receipt stamping to read time, not enqueue

Regression test for the gap flagged in review pullrequestreview-4834747507
on #759: TestPublishBaselineRejectsFrameReadBeforeBaseline calls
stampReceipt directly and never drives readLoop, so it doesn't pin where
the stamp is taken. A response frame (read but never enqueued as a
notification) distinguishes the two call sites directly.

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-08-10 09:50:03 +08:00
30f6d8f591 fix(specialist): make overwrites atomic (#757)
* fix(specialist): make overwrites atomic

* fix(specialist): harden atomic replacement

* fix(specialist): replace through a Windows-atomic, DACL-preserving primitive

Addresses both P1 findings on #757.

os.Rename — which fsutil.RenameWithRetry wraps — is documented non-atomic
outside Unix, and the retry loop only handles sharing violations. Worse, the
replacement is a freshly created temporary file, so renaming it over the
destination also publishes the directory's inherited DACL in place of the
destination's own: overwriting a specialist that had been explicitly restricted
inside a group-readable directory would expose its system prompt to that group.
temp.Chmod(0o600) cannot prevent that on Windows, where Go maps only the
owner-write bit.

New fsutil.ReplaceWithRetry publishes through a platform primitive:

- Windows uses ReplaceFileW with REPLACEFILE_WRITE_THROUGH, one operation that
  carries the replaced file's security descriptor, attributes, and streams over
  to the replacement. REPLACEFILE_IGNORE_ACL_ERRORS is deliberately not passed —
  silently losing the descriptor is the failure being fixed — so a merge failure
  surfaces and leaves the destination untouched. A missing destination falls back
  to a rename (nothing to replace or preserve), as do volumes that cannot perform
  a replace at all (FAT/exFAT, some network redirectors), which carry no ACL
  either. The transient-lock retry is unchanged.
- Unix keeps rename(2), which is already atomic within one filesystem.

Tests: Windows coverage asserts the destination's creation time survives (a
rename would not, proving the call goes through ReplaceFileW) and that a
protected owner-only DACL survives both at the fsutil layer and through the real
`Create --force` path; a plain rename was verified to replace that DACL with the
directory's inherited ACEs, so the assertion has teeth. POSIX covers both
destination shapes.

* fix(fsutil): stop ignoring ACL merge errors, recover partial ReplaceFileW

Addresses jatmn's two #757 review findings:

- The call passed REPLACEFILE_IGNORE_MERGE_ERRORS (0x2) while the
  comment claimed ACL failures were fail-closed. Microsoft documents
  0x2 and REPLACEFILE_IGNORE_ACL_ERRORS (0x4) identically: with
  either set, a call lacking WRITE_DAC "succeeds but the ACLs are not
  preserved". Passing 0x2 therefore let a --force overwrite silently
  publish the temp file's inherited directory DACL over a restricted
  specialist and expose its system prompt. Pass no flags at all.

- With no backup file, ERROR_UNABLE_TO_MOVE_REPLACEMENT (1176) and
  ERROR_UNABLE_TO_MOVE_REPLACEMENT_2 (1177) leave the destination
  deleted (1176) or renamed to an unreachable name (1177) while the
  replacement survives only under its temporary name. Returning those
  unrepaired let the caller's deferred temp-file cleanup remove the
  sole surviving copy, so one failed 'specialist create --force'
  destroyed both the old manifest and the new one. recoverPartialReplace
  now moves the replacement into place and still reports the error,
  since the destination's descriptor died with the destination.

Also drops the doc claim that REPLACEFILE_WRITE_THROUGH (0x1) flushes
before returning: Microsoft documents that value as "not supported".

The two fsutil regression tests are verified to fail against the
pre-fix code. The specialist-side test guards the caller half of the
recovery contract and does not fail pre-fix; its comment says so.

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

* fix(fsutil): preserve Windows replacement backups

Use a managed ReplaceFileW backup so partial failures can restore the original file and DACL. Fail closed when atomic replacement is unsupported, retry rollback and backup cleanup, and retain a known backup if rollback cannot complete.\n\nCover unsupported volumes, 1176/1177 semantics, cleanup, rollback retries, and symlink rejection.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-be8e-75eb-b274-b7e22af373a0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(specialist): report committed cleanup warnings

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* docs(specialist): describe what a failed overwrite actually leaves behind

The Windows partial-replace recovery errors told an operator the replacement
"remains at" the caller's temporary path, but the only caller removes that file
in a deferred cleanup before the error ever surfaces — it named a file that was
already gone. Recovery from a failed overwrite is always about the original, so
the errors now describe only what the replace itself left on disk: the state of
the destination and of the managed backup. The replacement path is no longer a
parameter, since nothing reports it.

The one terminal state that needs a human — 1177 whose rollback exhausted its
retries — now says so outright. The original then exists only under an opaque
.zero-replace-*.backup name, and the loader reads *.md, so the specialist stays
invisible until somebody moves it back. The error names both paths and the
action; docs/SPECIALISTS.md walks through it; and loadDirectory records why the
non-.md siblings an interrupted overwrite can leave are skipped rather than
guessed at.

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

* fix(specialist): serialize managed loads during replacement

Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef
Co-authored-by: Amp <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 09:49:32 +08:00
euxaristiaandgoogle-labs-jules[bot] c52db055b0 fix: suppress gosec G204 warning for trusted local config commands (#848)
Addresses a Gosec G204 (Command Injection) warning where provider command strings were passed directly into `sh -c` and `cmd /C`. Since these commands originate from the user's trusted local config and explicitly require shell features (like pipes and variable expansions) to function, removing the shell invocation introduces a breaking change. This adds a documented `#nosec` annotation to suppress the false positive instead of degrading functionality.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-10 09:12:58 +08:00
55fa05f0ab fix(sandbox): deny reads of Zero credential stores (#681)
* fix(sandbox): deny reads of zero credential stores

* fix(sandbox): address PR review comments

- Deny reads of mcp-oauth-tokens.json.secret in the default Zero
  config candidates: file-backed oauth stores keep their encryption
  secret in a sibling .secret file, so the default MCP token store
  needs the same protection as the override paths (CodeRabbit).
- Clarify that explicit credential-file overrides are still filtered
  by on-disk existence like every other candidate (Copilot).
- Align the zeroUserConfigDir doc comment with config.UserConfigDir:
  macOS honors XDG_CONFIG_HOME before falling back to ~/.config
  (Copilot).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): deny the whole Zero config dir, not itemized filenames

Address code review on PR #681:

- Deny credentialDenyReadPaths' Zero-config candidate as the containing
  directory instead of an itemized filename list. The token/credential/
  config stores each publish through a randomly-named .tmp-<pid>-<nanos>
  sibling before their atomic rename, and the legacy MCP token store
  leaves a mcp-oauth-tokens.json.migrated backup after importing it —
  none of those names were covered by the old itemized list, so a
  sandboxed command could read them under the read-all posture.

- Stop dropping default candidates that don't exist yet at profile-build
  time. A store created later in a long-lived sandboxed session (e.g. a
  concurrent ) previously got no deny rule at all; every
  backend already treats a deny rule over a not-yet-existing path as a
  harmless no-op that still takes effect once the path appears.

- Resolve ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH overrides
  the same way the token stores resolve them (relative-to-cwd, no ~
  expansion) instead of through normalizeProfilePath, which tilde-expands
  and so could derive a deny path different from where the store actually
  writes.

Adds regression coverage for the directory-wide deny (including the
migrated backup and synthetic temp-file siblings), for building the
profile before the store directory exists, and for the tilde-override
resolution mismatch.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(sandbox): address macOS deny-path test and config parity

Recompute the expected Zero config deny path after MkdirAll and resolve
the temp base with EvalSymlinks so macOS /var -> /private/var does not
flake TestPermissionProfileDeniesZeroCredentialFiles. Add a parity test
that sandbox.zeroUserConfigDir stays aligned with config.UserConfigDir.

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

* fix(sandbox): harden credential deny paths

* test(cli): normalize credential deny baseline

* fix(sandbox): close credential path gaps

* test(sandbox): normalize command credential path

* fix(sandbox): handle absent credential paths safely

* fix(sandbox): protect MCP OAuth siblings

* fix(sandbox): protect OAuth lock siblings

* docs(sandbox): clarify Linux future-path limits

* test(sandbox): probe credential store reads in the Linux smoke test

The smoke test wired HOME/XDG_CONFIG_HOME at credential directories but never
attempted a read through the sandbox, so it could not catch a real-bwrap
regression for the paths this change protects. Write a secret into ~/.aws and
$XDG_CONFIG_HOME/zero and add both to the deny-read table.

* fix(sandbox): keep the credential deny usable, current, and unguessable

Four problems with the credential deny baseline, all in the same mechanism.

The whole-directory deny over $XDG_CONFIG_HOME/zero also hid the supported
user plugin, specialist, and command roots, whose commands and scripts are
executed through this sandbox, so an installed user plugin could no longer
start. Narrowing the deny to itemized files was not an option: the stores
publish through temporary names and a concurrent login can add a store that
did not exist when the profile was built, and only a directory rule covers
both. The directory deny therefore stays, and the non-secret subtrees are
carved back out through a new DenyReadCarveouts field: seatbelt re-allows
them after the deny (SBPL is last-match-wins) plus ancestor metadata so path
resolution can reach them, and bubblewrap masks the directory with --perms
111 instead of 000 -- unlistable but traversable -- and re-binds the carveout
read-only before the tmpfs is remounted read-only.

A relative ZERO_OAUTH_TOKENS_PATH, ZERO_MCP_OAUTH_TOKENS_PATH, or
XDG_CONFIG_HOME was resolved against the command directory only, while
oauth.ResolveStorePath and mcp.ResolveTokenStorePath call filepath.Abs, i.e.
resolve against the Zero process working directory. A command run from a
subdirectory therefore had the wrong path denied and the real store stayed
readable under the read-all posture. Relative candidates now resolve against
both directories, since the process that resolves the value is not
necessarily the one that writes the store. The regression test runs the
command from a different directory than the process, which the previous
tests did not.

bubblewrap cannot mount over a path that is absent when the namespace is
assembled, so a store created mid-session stayed readable through the live
read-only host-root bind for the rest of a long-lived sandbox session. The
directories Zero owns -- its config directory and the per-store publication
directories -- are now reported as EnsureDenyReadDirs and created (0700)
before the namespace is assembled, so their mask always exists. Third-party
stores such as ~/.aws are still skipped when absent: Zero must not create
them, and that limitation stays documented on the field.

The fixed <store>.tmp publication sibling made the plaintext token, and via
the same path its encryption key, observable to a same-user sandboxed process
with write access to an override parent: it could wait for the known name and
rename the opened inode into an allowed path. Both stores now publish through
a per-store <store>.publish directory with a randomly-named file inside. The
directory name is derived from the store path, so the profile can deny it up
front, while the random name inside is what cannot be waited for or renamed
away. A test keeps the duplicated suffix in step with oauth.PublicationDirSuffix.

Also adds the seatbelt render coverage that was missing: dropping
DenyReadIfExists from denyReadRules made every credential store readable
again on macOS without failing a single test.

* fix(sandbox): close credential deny review gaps

Preserve process credential roots alongside command overrides while limiting host precreation and read carveouts to trusted process-derived paths.

Canonicalize future credential roots through existing path aliases, reject symlink carveouts, and collapse automatic masks already covered by user or automatic directory denies. Normalize the policy golden and add regression coverage across profile, Seatbelt, and bubblewrap rendering.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): normalize credential carveout paths

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): canonicalize backend credential masks

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* test(sandbox): normalize backend mask expectations

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): fail closed for replaceable token stores

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): allow keyring token path overrides

Amp-Thread-ID: https://ampcode.com/threads/T-019fbdfa-8469-731e-87c0-b26b7641cf59
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): harden command credential roots

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-c723-74d9-a323-026040b94436
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): close credential deny review gaps

Reapply credential denies after Seatbelt extension carveouts and model the legacy MCP token path as a migration input rather than an atomic writer.

Retain Bubblewrap fail-closed markers for OAuth outputs exposed through readable carveouts, and add regressions for both platform policies.

Amp-Thread-ID: https://ampcode.com/threads/T-019fbf55-f6b0-71fc-b384-652ba32b9645
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: PierrunoYT <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 07:33:29 +08:00
1057d74512 docs(readme,cli): document /new & zero upgrade, add Discord link, and sync help text (#489)
* docs(readme): document /new, zero upgrade, and add Discord link

Add the /new slash command and zero upgrade command to the README's
command references, expand docs/UPDATE.md to cover the --apply install
flow, and link the project Discord server.

* docs(update): escape pipe in --endpoint table cell

CodeRabbit flagged the unescaped | in the --endpoint placeholder
breaking the Markdown table layout.

* docs(readme-zh): bring /new and zero upgrade in line with README.md

README_ZH.md had picked up the Discord link but was still missing the
/new slash command and zero upgrade entries added to README.md,
letting the translation drift out of sync.

* cli: add upgrade to root help output

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* docs: make required update flag explicit in common commands

zero update errors without --check or --apply, so the bare form in
the common-commands examples was misleading. Addresses the Copilot
finding from the PR #489 review.

* docs: use a permanent (non-expiring) Discord invite in the READMEs

* docs(cli): match upgrade help summary to download/verify/install wording

Copilot flagged the root zero --help summary for upgrade as less
specific than the download/verify/install phrasing already used in
README.md and docs/UPDATE.md for the same command.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs: use discord.gg short link for the community invite

discord.com/invite/<code> and discord.gg/<code> resolve to the same
invite; discord.gg is the canonical short form to publish.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(update,readme-zh): keep npm --json stdout clean, add ZH Discussions route

`zero upgrade --json` (and `zero update --apply --json`) on an npm-managed
install wired npm's own stdout directly to the process stdout, so its
progress output landed ahead of the JSON result and made it unparseable.
Route npm's stdout into a buffer when JSON output is requested, only
surfacing it in the error message if the install fails.

README_ZH.md's Community section only pointed to Discord, unlike the
English README, which reserves Discord for real-time chat and routes
Q&A/ideas/sharing to GitHub Discussions per SUPPORT.md/CONTRIBUTING.md
policy. Bring the Chinese section in line with the same split.

Addresses the outstanding findings from jatmn's review on PR #489.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(update): qualify npm-managed JSON output

Document the npm subprocess output caveat instead of promising a single parseable JSON document for apply operations. Drop the JSON-specific updater plumbing and tests so the PR remains focused on documentation and help discovery.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-8bcd-71bc-9bde-9b4c28b78b38

Co-authored-by: Pierre Bruno <[email protected]>

* docs(update): align upgrade guidance

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(update): keep the upgrade recommendation about this machine

`zero update --check --target windows-x64` answers a question about a different
machine, but the new call to action told the user to run `zero upgrade`, which
only ever installs onto this one — replacing the target-specific download
guidance the flag used to print. The recommendation is now conditional: a
current-platform check keeps it, and a cross-target check gets the manual
instruction plus a note naming the machine `zero upgrade` would have touched
instead.

Also align two flag contracts with what the code does: `zero update --help`
described --repo/--endpoint as check-only although apply honors them, and
UPDATE.md did not mention that both are ignored on an npm-managed apply, which
takes its release from the npm registry rather than GitHub.

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

* fix(update): do not send a custom-source check to the default source

`zero upgrade` is a fresh invocation: it does not inherit the --repo/--endpoint
of the check that suggested it, so recommending it bare after a custom-source
check told the user to install from a repository they never asked about. The
check now records the flag it was given and the guidance repeats it, naming
where a bare upgrade would install from instead.

ZERO_UPDATE_RELEASE_URL is deliberately not recorded: every Check reads it from
the environment, including the one inside Apply, so a bare upgrade already
follows it and telling the user to repeat it would be wrong.

Two smaller edges from the same review: a cross-target check on a host with no
published release target no longer ends by describing what `zero upgrade` does
there, since it does not work there at all; and the flag table and help text now
carry the npm caveat the prose bullet already had, so docs and help agree.

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

* fix(update): keep source guidance accurate

Amp-Thread-ID: https://ampcode.com/threads/T-019fafa2-7d9d-75bc-8801-6d0efc8db1df
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 07:32:53 +08:00
Anandan f922cb378e Add cross-session messaging (#884)
* Add cross-session messaging

* Harden cross-session messaging

Authenticate sender metadata against live registry records, bound peer lifecycle state, preserve records across transient discovery failures, and make receipt delivery direct and bounded.

Also serialize TUI peer approvals, harden platform runtime paths and transport deadlines, widen session references, and add regression coverage for identity spoofing, queue limits, path boundaries, and tool failures.

* Support canonical macOS socket paths

Resolve Unix transport directories to their physical path before applying strict no-symlink traversal, allowing the standard /var to /private/var macOS alias without weakening runtime-directory validation.

* Fix macOS socket path regression test

Create the canonicalization fixture beneath a guaranteed short Unix path so it exercises the primary endpoint on macOS, and report temporary-directory cleanup failures.

* Harden peer messaging lifecycle

* Fix Windows peer directory traversal

* Handle Windows token-owned peer directories

* Accept equivalent Windows ACL masks

* Build Windows peer ACL explicitly

* Pin Windows ACL trustee SIDs

* Validate Windows peer ACL semantics

* Propagate Windows peer handle cleanup errors

* Use null handle for Windows close failure test

* Test held-message eviction callback
2026-08-09 18:12:09 +05:30
Anandan 7f39a63067 Centralize agent context planning (#879)
* Centralize agent context planning

* Fix context plan request accounting

* Freeze planned tool schemas
2026-08-08 16:40:17 +05:30
Anandan ff608c7834 Reduce compaction context while preserving task state (#873)
* Reduce compaction context while preserving task state

* Fix durable compaction evidence identity

* Preserve structured tool errors during compaction

* Unify manual and automatic compaction

* Report compaction truncation structurally

* Preserve automatic compaction metadata
2026-08-08 11:26:27 +05:30
Anandan 57858a31d1 Improve source read efficiency and model compatibility (#867)
* Improve source read efficiency and compatibility

* Preserve lexical context in ranged reads

* Fix bounded source read edge cases

* Prevent overflow in ranged minified reads

* Trim eager read tool schemas
2026-08-07 21:29:45 +05:30
euxaristia 1e73ecae8a docs(agents): Codify recurring review blockers in AGENTS.md (#857)
* docs(agents): codify recurring review blockers in AGENTS.md

Capture the high-frequency classes that drive multi-round maintainer reviews
(stale bases, path canonicalization, secret handling, partial setup, missing
regression tests, scope honesty) so coding agents fix them before first review.

Refs #856

* docs(agents): tighten security and atomic-write review guidance

Require open-time containment binding instead of pre-open EvalSymlinks, and
require temp-file then atomic replace rather than exclusive create alone.

Refs #856

* docs(agents): require traversal-wide containment for no-follow APIs

Final-component-only O_NOFOLLOW is insufficient on Linux and reparse
points need platform protections. Align the Security edges guidance
with the full-path containment requirement from review.
2026-08-07 20:18:11 +08:00
KRATOS cae0269e77 fix(tui): a copied NUL byte no longer panics the TUI on Windows (#876)
syscall.StringToUTF16 PANICS rather than returning an error when the
string contains a NUL — documented standard-library behaviour — and
github.com/atotto/clipboard's Windows path calls it directly. So one NUL
anywhere in a copied transcript selection killed the whole program:
"zero: tui error: program was killed: program experienced a panic",
reported from a real Windows machine.

The NUL reaches the clipboard because ansi.Strip removes escape
SEQUENCES and leaves C0 bytes untouched, and the transcript carries them
from two independent directions: tool output (a binary read, git's -z
NUL-separated listings — which this repo itself parses in
files_git_sweep.go) and Zero's own card protocol, whose row prefixes are
literally "\x00command-card\x00" and "\x00plan-card\x00".

Sanitised at the WRITE rather than at each source, because the sources
are unbounded: any tool result a user can select from is one, and a fix
per source is a fix the next tool re-breaks. The same pass protects the
OSC52 fallback, where a stray control byte terminates the escape
sequence early and silently corrupts the copy instead of crashing —
which is likely why this went unnoticed off Windows, where the identical
NUL produces a bad copy rather than a dead process.

\n and \t survive: a multi-line selection is the normal case and both
are legal clipboard content.

Fixes #875
2026-08-07 20:17:19 +08:00
Vasanth TandClaude Opus 5 cd0eb194e4 feat(tools): give tool results an image channel and add view_image (#843)
* feat(tools): give tool results an image channel and add view_image

Closes #824.

tools.Result carried only text, so no tool could hand the model a picture.
The sharpest edge was our own capture tooling: browser_screenshot,
desktop_screenshot and browser_pdf wrote a PNG, set Meta["artifact_path"]
and returned "Artifact captured: <path>". Nothing read that path
afterwards, so the model asked for a screenshot, was told one existed, and
could not look at it.

Zero was never image-blind on the way in — zeroruntime.ImageBlock,
Message.Images and internal/imageinput are mature. What was missing is the
model-initiated leg.

Result.Images is delivered as a following USER message, not on the tool
result itself. Every provider drops images on a tool-role message:
Anthropic maps a tool result to a tool_result block whose content is a
string, Gemini to a functionResponse, and OpenAI guards its image
content-parts to the user role with an explicit comment saying so. Attaching
them to the tool result would have plumbed the whole path and delivered
nothing. A separate message also keeps one tool result per tool call, which
the providers validate.

Blocks with no data, or a media type outside the provider allow-list, are
dropped rather than sent — a rejected image would fail the whole turn, and
the tool's text still stands on its own. The message names the tool, since
the images arrive detached from their result and nothing else says which
call produced them when several ran in one turn.

view_image resolves through resolveScopedReadPath before imageinput.LoadFile.
That scoping is load-bearing: LoadFile joins a relative path to the
workspace root and otherwise takes the path as given, so without it the tool
would read anywhere on disk. It is registered with the other scoped readers
and declared read-only and thread-safe, matching read_file.

The capture tools now attach what they wrote. A PDF or an unreadable file
yields no image and the text stands alone, so a capture is never failed over
its attachment.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(agent): keep tool results contiguous when a tool returns an image

The image messages are user-role, and the first version appended each one
immediately after its own tool result. With two tool calls in a turn that
produces tool_result, user, tool_result — and a user message between two
tool results breaks strict provider replay.

Anthropic makes it concrete. appendUserBlocks coalesces consecutive user
content into one block list, and tool_result blocks must come first in that
message, so the interleaved shape maps to:

    [tool_result, text, image, tool_result]

which the API rejects. loop.go already documents this exact rule three lines
below the change, for the self-correction feedback it defers for the same
reason.

Images are now collected during the tool loop and appended once every
tool_result is recorded — including the aborted placeholders on the abort,
stop-reason and repeated-failure paths, so an early exit cannot strand them
mid-sequence either.

The original test used a single tool call, which cannot exhibit the bug. The
new one runs two calls where the first returns an image and asserts the image
message does not fall between the first and last tool result.

Also from review: view_image joins the "execute" tool category, which grants
read_file but not view_image, so a specialist declared with tools: [execute]
could not look at a screenshot it had just captured. Covered by a test that
holds for every category granting read_file rather than naming one. And the
core read-only set is now asserted by tool name, not only by count — a count
still passes when one reader is swapped for another.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(agent): pin the tool-result/image ordering contract

The assertions only tracked the first and last tool-result index, so four
distinct broken orderings passed: a message wedged between the two tool results,
a duplicated image flush, a duplicated tool result, and both results answering
the same tool_call_id. Mutation-testing each of those against the real loop
confirmed the old assertions were green on all four.

Now it collects every tool-result index and id and requires exactly two, in
c1-then-c2 order, at adjacent indexes, with the image following the last one and
delivered exactly once. Adjacency is the assertion that carries the contract:
Anthropic needs every tool_result block ahead of any other block in the user
message they coalesce into, so anything between the two results is the 400 this
change exists to avoid.

The image is deliberately NOT pinned to exactly last+1. appendUserBlocks merges
consecutive user-role content, so [tool_result, tool_result, text, image] maps to
tool_result blocks first and replays fine; pinning would fail that valid shape
while killing nothing the adjacency and count assertions do not already catch.

messageShape reports the actual role ordering on failure, and the loop uses two
ifs rather than a switch so an image left on a tool-role message is reported as
the misplacement it is instead of "the image never reached the model".

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-06 21:35:41 +05:30
Vasanth T 4ca4fa75d3 fix(agent): end the additional_permissions retry loop (#864)
* fix(agent): end the additional_permissions retry loop

Asking the agent to run an ordinary read-only command on Windows could leave it
oscillating between two rejections instead of executing:

    additional_permissions requires sandbox_permissions set to "with_additional_permissions"
    additional_permissions must include at least one of network or file_system, ...

Both come from inlineAdditionalPermissionsProfile, and each names only the fix
that ADDS something. The first pushes the model toward setting the flag; the
second pushes it back toward requesting a permission the command does not need.
Neither ever says to omit the field, which is the correct action for a read-only
command, so the two messages form a two-state oscillator. Every rejection now
carries that remedy.

The schema half is why the object is attached at all. Neither field description
said to omit them for ordinary commands, so a model that legitimately used
with_additional_permissions once in a session had nothing telling it to stop
attaching a permission object to the next command. Both descriptions now lead
with "omit", and they are shared constants rather than duplicated verbatim in
bash.go and exec_command.go, where they were free to drift.

Rejection is still rejection. Nothing that was refused is now accepted on the
strength of an empty or mismatched payload, and sandbox_permissions is never
upgraded on the model's behalf; only the guidance changed.

The one behavioural change is that an explicit additional_permissions: null is
now treated as the omitted field, since a null grants nothing and carries no
flag. That flips three shapes from reject to execute: null with use_default,
with require_escalated, and with no mode at all. The escalated one still routes
through the normal escalation prompt and is not a new capability — the identical
request without the null key already behaved that way. A FLAGGED null still
errors, so with_additional_permissions plus null is not silently downgraded.

Fixes #845. Fixes #847.

* fix(tools): condition the omit guidance on needed access, not read-only

The description said to omit sandbox_permissions "for ordinary commands,
including every read-only one". That overstates it: curl and gh are read-only
and still need sandboxed network, so a model following it literally would omit
the override on a command that genuinely needs one and hit the opposite failure.

It now says to omit unless the command needs access the sandbox does not already
give it. That is the claim that is actually true, and it is one a model can act
on without first deciding what counts as read-only.

The schema test asserted the wrong half too — a bare substring check for
"read-only" pinned the overstatement rather than the contract. It now pins the
conditional, with the reasoning recorded so the weaker assertion does not come
back.

Reported by CodeRabbit on #864.
2026-08-06 21:34:54 +05:30
Anandan edf660ab85 perf: compact common test output (#846)
* perf: compact common test output

* test: strengthen command reducer guarantees

* fix: tighten command output summary detection
2026-08-06 20:35:13 +05:30
Vasanth T 91b413c514 fix(sandbox): stop the Windows write jail honouring Everyone-granted paths (#865)
* fix(sandbox): stop the Windows write jail honouring Everyone-granted paths

The restricted token put the World SID (S-1-1-0, Everyone) in its
restricted-SID list unconditionally, from the original sandbox baseline
and with no comment saying why.

A WRITE_RESTRICTED token runs two checks for a write and needs both: the
normal one against its enabled SIDs, and a second against the restricted
list. The write jail rests entirely on that second check only succeeding
where Zero has explicitly ACL'd a capability SID. Everyone is a SID every
principal carries, so on any path whose DACL grants Everyone write, the
restricted half passed for free and confinement fell back to the ordinary
user's own permissions -- the exact boundary this token exists to be
stricter than.

Reachable with no privilege, no symlink and no race: an Everyone-writable
directory is enough, and share roots opened with Everyone:F and loose
third-party installer ACLs supply them. Ruled out as the common case:
C:\Users\Public\Documents grants BATCH, not Everyone.

The runner already stated the rule this broke -- it declines to add the
user SID, Administrators or SYSTEM because "each has write access nearly
everywhere" -- while listing Everyone among the restricted SIDs.

Removed only from the WRITE_RESTRICTED token, because without that flag it
is load-bearing rather than gratuitous. The restricted-SID check then
applies to reads too, and default Windows DACLs grant BUILTIN\Users rather
than anything in this list, so a token with no Everyone cannot open cmd.exe
and dies at launch with STATUS_ACCESS_DENIED (0xC0000022). Dropping it
there was tried first and takes TestWindowsUnelevatedRealSandboxSmoke with
it. That path is taken only when a profile configures DenyRead, which is
already the posture trading capability for read-deny enforcement (#612), so
the bypass survives there deliberately and narrowly; closing it needs a
read-side grant that is not a universal group.

Verified against real tokens rather than reasoned about. The new smoke test
drives the real runner and asserts three outcomes that only agree when the
jail is intact: a granted root is writable, an ordinary outside path is
denied, and an Everyone-writable outside path is denied too. Before the
change its third case wrote the file with exit 0; the first two were
already correct, which is what made the gap invisible. Restoring the SID
unconditionally turns it red again.

* test(sandbox): make a denied write distinguishable from a runner failure

The denial assertions expected exit 1, which the runner also returns for
its own errors -- a failed marker validation, a bad argument, a token it
could not build. Written that way the test passes both when the sandbox
denies the write and when nothing ever ran, and the second case proves
nothing while being indistinguishable from success.

That is a poor property for any test and a bad one for this test, whose
entire job is to notice a confinement regression. The leading
"granted root is writable" assertion covered part of it, but said nothing
about whether the two later commands launched.

The denial commands now report a distinctive exit code the runner cannot
produce for itself, so the code proves cmd.exe actually ran and its
redirect was refused.

Re-checked in both directions rather than assumed: the test passes against
the fix, and restoring the World SID unconditionally fails it with
"exit code = 0, want 77" -- still catching the bug it was written for.

Raised by CodeRabbit on #865.
2026-08-04 18:51:35 +05:30
Vasanth T 021281ebba fix(cli): report MCP servers that failed to start (#822) (#827)
* fix(cli): report MCP servers that failed to start (#822)

`zero mcp check` inspected only the error returned by registration and then
printed "is reachable". RegisterTools is best-effort by design, so a server
that fails to connect is recorded on the runtime and registration returns
nil. That is right for startup, where one bad server must not stop Zero
launching, and wrong here: this is the command a user runs when a server is
misbehaving, and it told them the broken server was fine.

The failure data already existed as SkippedServer{Name, Err}; nothing
consumed it in this path. The check now fails with the recorded error, in
both text and JSON output.

Covered by a test that spawns a genuinely missing command rather than
stubbing the runtime, because the defect was precisely a mismatch between
what registration returns and what it records. Reverting the fix makes it
print "MCP server broken is reachable. 0 tool(s) available."

* test(cli): cover the unreachable --json contract

The text path was covered and the JSON path was not, though the change
adds its own return with a different payload: status "unreachable" plus a
redacted error, rather than status "ok" with a tool count. A caller
parsing that shape was relying on something nothing pinned.

Reverting the fix now fails both cases. The JSON one is the more useful
of the two, because the wrong output is still valid JSON and would decode
cleanly into a caller expecting success.
2026-08-03 22:50:15 +05:30
8e266797a8 perf: improve harness context efficiency (#838)
* Improve harness context efficiency

* Address harness efficiency review findings

* Make recaps idle and cancellable

Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.

Also strengthen the formatter safety regression to exercise formatter-modified edit output.

* Track every replace-all edit span

Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.

* Make review regressions cross-platform

Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.

* fix(tools): derive SeenWhole from the recorded ranges

SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.

That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:

    the intended change depends on content that has not been read exactly
    in this session. Read the affected lines with read_file, then retry.

Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.

SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.

Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(tools): key the large-file test on the resolved path

read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.

Also switches the line generator to fmt.Fprintf (staticcheck QF1012).

Both from CodeRabbit on #842.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(tools): actually switch the line generator to fmt.Fprintf

The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.

Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.

* fix: keep exact-read workflows recoverable

* test: cover permission normalization wiring

* fix: commit file observations after output budgeting

* fix: defer file observations across retries

* test: normalize patch line endings

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
2026-07-31 23:30:03 +05:30
Anandan d37de9214b fix: use OAuth credentials in provider health checks (#828)
* fix: use OAuth credentials in provider health checks

* fix(providerhealth): verify OAuth login for all probes

* fix(providerhealth): retain API key redaction

* fix(providerhealth): redact bare OAuth tokens

* test(cli): isolate OAuth doctor config

* test(cli): isolate doctor config path
2026-07-30 22:32:58 +05:30
Anandan 588635487f feat(tui): replace /retitle with local /rename (#826)
* Improve session naming and resume picker

* Address session rename review feedback
2026-07-30 19:58:02 +05:30
Anandan 845df6c7d2 fix(tui): hide unavailable sidebar shortcut (#821)
* fix(tui): hide unavailable sidebar shortcut

* test(tui): cover medium sidebar footer hint
2026-07-30 17:33:08 +05:30
Anandan 15eb16f736 fix(tui): exclude permission waits from turn time (#820)
* fix(tui): exclude permission waits from turn time

* fix(tui): exclude permission waits from TTFT
2026-07-30 17:03:27 +05:30
github-actions[bot] 097c2652cb chore(main): release 0.6.0 (#786)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-29 09:56:37 +08:00
15b7a5475c fix(acp): interrupt idle reads on cancellation (#782)
* fix(acp): interrupt idle reads on cancellation

* fix(acp): make idle-read interruption per-Read, not per-Serve-call

Addresses the P2 on #782.

The previous design raced a single, whole-connection Once (endRead) between the
read loop's own "I have a terminal result" claim and a watcher goroutine's
"ctx is done, treat this as interrupted" claim. This has a real, deterministic
failure mode that has nothing to do with goroutine-scheduling luck: bufio.Reader
can obtain data AND a terminal error from ONE underlying Read call (io.Reader
explicitly permits returning (n>0, err) together). If a line delimiter is found
within that data, ReadBytes returns the line with a nil error for that call, but
bufio caches the error internally and returns it — without ever touching the
underlying reader again — on the very next ReadBytes call. If cancellation
happens in the gap between that first, successful read and the next one (e.g.
while a synchronously-dispatched handler is still writing its response), the
watcher's ctx.Done() case fires and claims the Once BEFORE the read loop ever
gets back to a ReadBytes call — so when the cached error then surfaces (with
zero contact with the underlying reader, let alone anything closing it), it is
misattributed as a cancellation-driven interruption and silently swallowed as a
clean shutdown, hiding a genuine broken transport.

The fix moves interruption-detection down to the Read call itself instead of
bracketing the whole Serve loop. interruptibleReader wraps the connection's
reader: each Read runs the underlying call on its own goroutine and races it
against ctx.Done(). If the underlying call finishes first, its result is
returned untouched — cancellation a moment later has no bearing on a call that
already had its answer. If ctx.Done() fires first, the reader is closed (when
closable) to nudge a genuinely blocked read, a generation counter is bumped, and
the call still waits for and returns the underlying call's own result rather
than a synthesized one.

Serve then brackets each ReadBytes call (which may invoke the wrapped Read zero
or more times — zero when bufio answers from its own buffer, including a
previously-cached error) with a snapshot of that counter before and after. A
read whose bracket shows no change was never raced against cancellation — either
answered from the buffer or genuinely completed before ctx.Done() could matter —
so its outcome is reported truthfully regardless of the ctx state by the time it
surfaces. Only a bracket that changed proves this specific call actually took
the cancellation branch.

Verified the fix has teeth: reverted to the original implementation and
confirmed the new regression test (which forces the exact bufio-caching
sequence deterministically, with a panic guard proving the underlying reader is
never called a second time) fails 100% of the time against it, then restored
the fix and confirmed it passes.

Also applied the two open review threads on the existing tests: readStarted and
writeStarted are now buffered channels with non-blocking sends rather than
close(), so a future change causing an extra read or write can't panic on a
double-close.

Validated with go build/vet/test on Windows, plus go vet/build and
`go test -race ./internal/acp/... -count=50` (and count=100 focused on the
cancellation/error tests) under WSL/Linux — this session's Windows host has no
C toolchain for -race directly.

* fix(acp): preserve decided read results and require explicit reader ownership

Addresses jatmn's two #782 review findings:

- interruptibleReader.Read's select between resultCh and ctx.Done()
  chooses pseudo-randomly when both are ready, so a real terminal read
  error decided just before cancellation could be misattributed to
  shutdown and silently swallowed. Re-check resultCh non-blockingly
  before claiming a call for cancellation.

- NewConn inferred reader ownership from an io.Closer assertion and
  closed any reader that happened to implement it, including one a
  caller merely borrowed. Split into NewConn (never closes r) and
  NewOwnedConn (explicit ownership, closes r on cancellation), and
  switch the sole production call site (process stdin) to the latter.

Also applies CodeRabbit's atomic.Int64 and leading-ctx-parameter nits.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(acp): synchronize read completion with cancellation

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-9e4e-7506-85ab-e151888bfd71

Co-authored-by: Pierre Bruno <[email protected]>

* fix(acp): preserve read completion across cancellation

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-483a-7119-92aa-4953a72f2a76

Co-authored-by: Pierre Bruno <[email protected]>

* test(acp): bound buffered error synchronization

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-483a-7119-92aa-4953a72f2a76

Co-authored-by: Pierre Bruno <[email protected]>

* fix(acp): report shutdown transport errors

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* test(acp): inject cancellation context

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-07-29 09:46:55 +08:00
bdbda89f3e fix(swarm): close lifecycle admission on shutdown (#776)
* fix(swarm): close lifecycle admission on shutdown

* fix(swarm): admit lifecycle work without holding the lock across Launch

Addresses the P1 on #776 plus the two documentation P3s.

[P1] beginLifecycleAdmission returned with lifecycleMu.RLock() held for the
caller's entire duration — including the call into the injected
MemberLauncher.Launch. Close needs the write lock before it can call s.cancel(),
so a launcher that blocks in Launch waiting on its context (the ordinary shape of
a launcher that waits for a slot, a daemon connection, or a sandbox handshake)
deadlocked every Close caller: the launch held the read lock while Close waited
for the write lock to reach the very cancellation the launch needed to unblock.

beginLifecycleAdmission is now a counted ticket: it holds lifecycleMu only across
the closed check and a lifecycleWork.Add(1), then returns a release func
(lifecycleWork.Done) the caller defers. Close flips closed and releases the lock
BEFORE calling cancel, then waits out lifecycleWork (admitted-but-unfinished
work) followed by watchers (goroutines admitted work may still be starting) — so
it remains a true completion barrier without holding any lock across external
launcher code. Every call site (Spawn, Handoff, AdoptOrphans, the retry path in
watch, and afterExit) moved to the ticket pattern.

[P3] beginLifecycleAdmission's contract and the Scheduler/Close handoff are now
both documented at the point that matters: the func-returning signature makes
the release contract explicit in the type itself, and Scheduler's comment spells
out why the read/write lock ordering guarantees exactly one of Scheduler or
Close closes whatever scheduler ends up installed.

Tests: TestCloseCancelsLauncherBlockedInLaunch reproduces the exact deadlock
(spawn parked in Launch, Close in a second goroutine, both must complete once
cancellation reaches the launcher) — verified it hangs under the old held-lock
pattern by temporarily restoring it. TestCloseWaitsForAdmittedSpawn is the
converse: Close must not return while an admitted spawn is still running.

Validated with `go test -race ./internal/swarm/... -count=1` under WSL/Linux
(this session's Windows host has no C toolchain for -race directly), plus
`go vet ./...` under WSL and go build/vet/test on Windows.

* fix(swarm): fail late queue admissions and dequeues after shutdown

Addresses jatmn's two #776 review findings:

- Close swept (clearQueue) every team's queue before waiting for
  admitted-but-in-flight lifecycle operations to finish. A Spawn,
  Handoff, or AdoptOrphans call that got its ticket right before
  closed flipped could still append to a team's queue after the
  sweep ran, stranding that spec forever (never launched, since
  shutdown began; never failed, since the sweep won't run again).
  Reorder Close to wait for lifecycleWork before sweeping queues, so
  every queue append from already-admitted work is visible to the
  sweep.

- afterExitAdmitted dequeued a queued spec (t.onExit) and launched it
  unconditionally once its own ticket was granted, even though Close
  can flip closed in the gap between that dequeue (which makes the
  spec invisible to the clearQueue sweep) and the launch. Re-check
  closed after dequeuing so a spec pulled off the queue after
  shutdown began fails with ErrSwarmClosed instead of starting a new
  worker.

Both regression tests are verified to fail against the pre-fix code
(confirmed by temporarily reverting each fix and rerunning).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(swarm): snapshot teams after lifecycle work stops

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-a4d5-7078-a80b-802bab23607e

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): reject late admitted launches during shutdown

Re-check the closed state at the final launcher boundary so lifecycle work admitted before Close cannot start or retry a member after shutdown has already won the race.

Add deterministic late-dispatch and scheduler-handoff coverage, stress concurrent Spawn and Close under the race detector, and keep the late-team snapshot regression isolated from launcher behavior.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-4f7f-766e-a312-a7db232b9987

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): reject post-close launch commits

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): gate retry relaunch on the post-close commit

The post-Launch shutdown gate added for spawns and queue drains was missing
from the bounded-retry path, which called the launcher and swapped in the new
handle unguarded. Since FuncLauncher returns a live handle immediately without
consulting its context, a Close landing between the pre-check and Launch's
return left the watcher supervising a member relaunched after shutdown began —
and recording that member's outcome on the task.

Extract the commit gate as commitLaunch and route the relaunch through it via
relaunchAdmitted, so an initial launch and a retry pass the same three gates:
admission, the pre-Launch re-check, and the post-Launch commit. When shutdown
wins, the handle is reaped and the watcher records the terminal failure instead
of adopting it.

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-07-29 09:46:25 +08:00
Vasanth T 8acd42a3bd fix(providers): retry provably pre-send transport failures (#447) (#750)
* fix(providers): retry provably pre-send transport failures (#447)

A flaky link that failed the streaming completion POST at connect time
stopped the turn and forced a manual continue, because the shared retry
layer never retries transport errors: a completion POST is
non-idempotent, so a replay could duplicate billable work.

That rationale does not apply to a PROVABLY pre-send failure, where no
request bytes ever left this host: a replay cannot duplicate anything,
exactly like a 429/503 where the server told us it did not accept the
request. SendWithRetry now retries, with the existing backoff, only that
narrow class: DNS resolution failure, a refused or unreachable dial, and
a TLS handshake timeout. Every ambiguous or post-send failure stays
unretried and is checked first so a mixed message can never be misread:
connection reset, broken pipe, EOF, a bare i/o timeout, context
deadline. All three providers route their completion POST through this
layer, so one change covers them uniformly.

This deliberately narrows the issue's ask (which listed connection
reset) to the provably-safe subset, and keeps the reset-stays-unretried
test green. Tests cover the retry-on-pre-send, no-retry-on-ambiguous,
and predicate-classification paths.

NOTE FOR REVIEW: this refines a deliberately conservative, documented
no-retry stance. Opening it for the owner's sign-off on the stance, not
as a settled call.

* fix(providers): classify pre-send dials by errno so Windows retries too

CodeRabbit flagged that the pre-send predicate matched only POSIX dial
wording, so a refused or unreachable dial on Windows (whose text differs
entirely: 'No connection could be made because the target machine
actively refused it') would not be retried.

Match the syscall errno via errors.Is instead: Go maps ECONNREFUSED /
ENETUNREACH / EHOSTUNREACH / ECONNRESET to the host's real values,
including the WSA codes on Windows, so classification is identical on
every platform. Errno matching is also authoritative where the string
markers were only heuristic, closing the theoretical substring gap. The
string markers stay as a fallback for errors already flattened to text.
ECONNRESET is added to the post-send exclusions by identity so a Windows
reset is excluded too.

Tests build the production error shape (net.OpError -> os.SyscallError ->
errno) so the Windows path is exercised portably on every platform, plus
an end-to-end SendWithRetry case for an errno-wrapped refused dial. Cross
-compiled and vetted for linux and darwin.

* fix(providers): scope pre-send retry to the dial phase and fail fast on permanent errors

Addresses gnanam's two [Major] review findings on this PR.

[Major] Pre-send errno classification was not scoped to the dial, so a post-send
read/write carrying ECONNREFUSED/ENETUNREACH/EHOSTUNREACH (a route dropping
mid-generation) was misclassified "provably pre-send" and could replay a
non-idempotent completion POST up to 6 times. errors.Is matches an errno
anywhere in the chain regardless of which operation raised it, so
isPreSendTransportError now requires an Op=="dial" *net.OpError before admitting
those errnos or their POSIX wording. That makes "no request bytes were sent"
structural: a post-send failure can no longer replay a POST. The dial errno
match still covers Windows (Go maps the WSA* codes), so the platform coverage
this PR added is preserved.

[Major] Permanent failures hung the agent ~60s on the 429-tuned schedule.
NXDOMAIN (net.DNSError with IsNotFound) is now treated as permanent and not
retried, and the pre-send path gets its own short bound and sub-second schedule
(preSendMaxAttempts=3, 500ms/1s) instead of reusing the 2/4/8/16/30s rate-limit
backoff, so a mistyped host or a dead local daemon fails in about 1.5s.

[Minor] Fixed the stale SendWithRetry doc that still claimed transport errors are
never replayed.

Tests: post-send read/write errnos and NXDOMAIN now assert exactly 1 call (no
replay); dial errnos plus the string fallback assert the pre-send cap; added the
pre-send backoff schedule. Race detector and all provider packages green.

* fix(providers): match Windows WSA dial errnos so refused dials retry on Windows too

CodeRabbit caught a real Windows bug, which I verified with a real dial on
Windows: errors.Is(err, syscall.ECONNREFUSED) returns FALSE for a refused dial
there, because a real Windows dial carries the raw WSAECONNREFUSED (10061) while
Go's syscall.ECONNREFUSED is a distinct value the net package never produces. The
POSIX string fallback ("connection refused") does not match the Windows wording
("No connection could be made because the target machine actively refused it")
either. So the pre-send dial classification silently never fired on Windows, the
exact platform this PR set out to fix.

Move the dial errnos into a platform-specific list (dialPreSendErrnos): POSIX
everywhere, plus the WSA* codes on Windows. The existing wrapDialErrno fixtures
wrap the Go syscall constants, which is why they passed on both platforms and
missed this. Adds TestIsPreSendTransportErrorRealRefusedDial, which does a REAL
refused dial and asserts pre-send classification, so the platform's true error
shape is exercised on every OS.

* fix(providers): exclude Plan 9 from the POSIX dial-errno build tag

* fix(providers): keep the pre-send errno classification off Plan 9

retry.go referenced syscall.ECONNRESET and dialPreSendErrnos directly, but the
errno definitions live in build-tagged files that exclude Plan 9 (which has no
BSD-style errno constants), so the package stopped compiling for GOOS=plan9 on
this branch even though it builds on the merge base.

Move the two syscall touch-points behind per-platform helpers: retry.go now
calls isConnResetErrno and ranges dialPreSendErrnos, both provided by
retry_dialerrno.go, retry_dialerrno_windows.go, and a new
retry_dialerrno_plan9.go. The Plan 9 variant supplies an empty errno list and a
false isConnResetErrno, so retry.go falls back to its existing string-marker
heuristics there. Behavior on linux/darwin/windows is unchanged (isConnResetErrno
matches the same syscall.ECONNRESET as before). retry_test.go is tagged !plan9
because it constructs real syscall.Errno dial shapes.

Verified: GOOS=plan9 build and vet now pass; windows and linux build, vet, gofmt,
and the full providerio test suite stay green.

* fix(providers): stop pre-send retry from replaying a redirected POST

Addresses jatmn's review on #750.

[P1] client.Do could follow a 307/308 after already transmitting the original
POST, and a dial failure to the redirect target then satisfied
isPreSendTransportError and replayed the request, re-billing a completion the
first host already received. SendWithRetry now disables redirect following
(CheckRedirect returns http.ErrUseLastResponse on a cloned client), so any
dial/DNS/TLS error from Do can only come from the single initial request and
the pre-send classification is sound. A 3xx surfaces as the response for the
caller's existing non-2xx handling. Regression test asserts the POST is sent
once and the 307 is not followed.

[P2] The pre-send retry used the loop-wide attempt counter shared with 429/503
retries, so a couple of status retries exhausted the pre-send budget and the
first dial/DNS/TLS blip after them got no retry. Track pre-send failures and
their backoff ordinal in a separate counter. Regression test covers a
status-then-transport-failure sequence.

[P3] Correct the Plan 9 and wrapDialErrno comments: the Plan 9 string-marker
fallback keeps the pre-send inclusions and the separate reset exclusion, and
the errno fixtures do not reproduce a real Windows dial (that carries distinct
WSA errnos), which the real-dial test covers.

* fix(providers): decouple the status retry budget from pre-send retries

Addresses jatmn's follow-up on #750.

The previous change gave pre-send failures their own counter, but every retry
still advanced the loop-wide attempt counter that the 429/503 branch used. So
the coupling survived in the opposite direction: two safely replayed refused
dials shortened the later status retry window and started its backoff at a
later rung.

Give the status path its own statusAttempts counter and backoff ordinal. With
both budgets tracked separately the shared loop counter has no remaining
reader, so the loop no longer carries one. Behaviour is unchanged when only one
kind of failure occurs: the first retryable status still waits on rung 1 and
still gets maxAttempts requests.

Adds TestSendWithRetryStatusBudgetSurvivesPreSendRetries, the inverse of the
existing mixed-sequence test: two refused dials followed by rate limiting must
still leave the status path its full window.

* fix(providers): keep following redirects, but never replay a redirected POST

Addresses jatmn's follow-up on #750.

Blocking redirects outright was too blunt. SendWithRetry is the shared
completion path, so replacing every caller's CheckRedirect with
ErrUseLastResponse turned a legitimate 307/308 from a redirecting
OpenAI-compatible gateway into a terminal non-2xx that providers report as an
upstream error, and it discarded an injected client's redirect policy.

Observe the redirect instead of preventing it: a per-attempt flag is set from
CheckRedirect, which then delegates to the caller's policy when one is set and
otherwise mirrors net/http's default ten-hop cap. The pre-send retry is gated on
that flag, so an attempt that entered a redirect is never replayed while
redirecting endpoints keep working. The safety property is unchanged, since a
dial failure on a redirect hop follows a POST the origin already received.

Adds three regressions: a redirect is followed through to the final response, a
dial failure after a redirect is not replayed, and a caller-supplied
CheckRedirect still runs and still decides.

* test(providerio): pin the redirect asymmetry for retryable statuses

A reviewer read the redirect guard as incomplete, because it stops a redirected
POST being replayed after a transport error but not after a 429/503/529. The
asymmetry is intentional, and nothing recorded that, so the next reader would
reasonably reach the same conclusion.

The two cases differ in what is known about the request. A transport failure
after a redirect leaves it unknown whether the redirect target received and
began processing the POST, so replaying could duplicate billable work. A
retryable status means every hop answered: the 307/308 is the origin declining
and delegating, and the 429 is the target declining as well, so nothing accepted
the request and a replay duplicates nothing.

The exposure is also the same as a retryable status with no redirect involved,
which this package has always retried, so guarding here would not close a gap.
It would make a rate-limited completion behind a redirecting gateway fail
outright rather than back off, which is the regression class already raised
against disabling redirects wholesale.

Adds the case as a test asserting the intended behaviour, plus a comment at the
branch explaining why it is not guarded.

* fix(providers): retry connect timeouts and proxy CONNECT failures

Three classification gaps against #447, all raised in review.

A connect-phase timeout was never retried. It is provably pre-send for the same
structural reason a refused dial is, since the connection was never established
and no request bytes left this host, but it arrives carrying "i/o timeout" and
the blanket wording exclusion rejected it before the connect gate could admit
it. Intermittent connect timeouts are exactly the transient failure the issue
is about, so the most common case was the one not covered. It is now admitted
ahead of the wording exclusions, gated on the connect phase so a post-send read
timeout, which is also Timeout(), still cannot replay a POST.

A failed CONNECT through an HTTP proxy was never retried either. net tags it Op
"proxyconnect" rather than "dial", so it missed the gate entirely. Same
guarantee applies: the tunnel to the model host was never built. The gate now
accepts both, which matters most in the environment where transient connect
failures are most common.

ETIMEDOUT and WSAETIMEDOUT join the errno lists so a connect timeout still
classifies if it reaches us with the errno but without the timeout flag.

The pre-send budget also now resets after a hop that reaches the server. It is
meant to bound consecutive connect failures, not the lifetime of the call, and
without the reset an early run of blips permanently spent it, so a later blip
surfaced as a transport error while the status budget still had room. That is
the coupling the two counters were split apart to remove, in the one ordering
the earlier tests missed. Total work stays bounded, since every reset costs a
status attempt and those are capped.

Also corrects the SendWithAuthRetry comment, which still claimed the inner path
retried only 429/503/529.

Tests cover dial and proxyconnect timeouts as retryable and read/write timeouts
as not, a refused proxy CONNECT, and the interleaved pre-send/status/pre-send
sequence. Each fix fails its test when reverted.
2026-07-28 22:42:01 +08:00
Vasanth T 1c38e8cba5 fix(config): make the provider-command timeout an upper bound (#811) (#813)
providerCommandTimeout bounded only the wait, so the call could take arbitrarily
longer than the 5s its error text promised. Two phases sat outside it.

Starting the process ran before the clock began. On Windows that is
CREATE_SUSPENDED, job-object creation and assignment, and a system-wide thread
snapshot, none of it fast on a cold or contended machine. The deadline is now
armed first and the start races it, so process creation is inside the budget.

Draining after termination was unbounded too: the timer path called Terminate
and then received on the wait with no limit. Terminate normally makes that
immediate, which is exactly why it survived unnoticed, but a tree that resists
termination held the call open long past the deadline that had just expired.
That receive is now bounded, and a call that gives up leaves cmd.Wait running,
so the output buffers are synchronised: the I/O pump keeps writing into buffers
the caller already holds, which a plain bytes.Buffer cannot survive.

The timeout error also carried nothing actionable. It was a bare string with the
underlying error and the command's stderr both dropped, so a user whose provider
command was slow got no clue which phase ran out or what the command had said.
It now names the phase, wraps the cause, and includes the redacted output the
failure path already had.

Both bounds are asserted on extracted helpers rather than end to end, because
end to end they are untestable: a terminated command stops writing and its wait
returns at once, so an unbounded drain and an unsynchronised buffer both pass a
whole-call test. The first version of these tests did exactly that and proved
nothing. Removing either bound now fails, as does arming the deadline after the
start, which reports the wrong phase.
2026-07-28 22:41:09 +08:00
Felix-Ayush 58daea7a48 feat(providers): add Fireworks AI provider preset (#772)
* feat(providers): add Fireworks AI provider preset

Add an OpenAI-compatible Fireworks catalog entry with curated coding
models, models.dev alias mapping, onboarding tests, and README setup.

* fix(provider): use correct Fireworks DeepSeek V3 model ID

Update default and catalog model IDs to deepseek-v3-0324 per Fireworks API.
2026-07-28 22:40:41 +08:00
Vasanth T 3e7692d8f0 fix(sandbox): scrub the daemon token file pointer (#677) (#818)
scrubSensitiveEnv removed ZERO_DAEMON_REMOTE_TOKEN but not its file
counterpart. TokenFromEnv accepts either form, so a sandboxed command
still inherited the path to the bridge token, and the default posture is
read-all, so it could then read the token itself.

Scrubbing the pointer is the whole fix rather than half of one: the path
comes from this variable alone and TokenFromEnv has no default location
to fall back on, so a command that never sees the variable has nothing to
guess at. This mirrors GOOGLE_APPLICATION_CREDENTIALS, which is already
scrubbed for the same reason.

Extends the existing scrub table, which compares the surviving
environment exactly, so the case fails if the entry is dropped. Verified:
without the fix the assertion reports ZERO_DAEMON_REMOTE_TOKEN_FILE
surviving into the scrubbed environment.
2026-07-28 21:56:28 +08:00
Vasanth T 33c94ec7a3 fix(sandbox): deny reads of git's credential stores (#815) (#816)
* fix(sandbox): deny reads of git's credential stores (#815)

git's credential-store backend keeps host passwords and personal access tokens
in cleartext, at ~/.git-credentials or ~/.config/git/credentials depending on
whether the user is on the XDG layout. Neither was on the deny list, so any
command the agent runs could read them.

Scoped to those two files deliberately. #815 also covers SSH private keys and
the GPG keyring, and those are a harder trade: denying ~/.ssh stops a sandboxed
git push over SSH from working, and excluding that directory is not even
sufficient, since an IdentityFile directive can point key material anywhere.
These two cost nothing by comparison, because git reads them to authenticate
rather than to know who the user is, so a sandboxed git keeps working and
simply cannot act as the user.

The XDG one denies the file rather than the directory around it, which is the
opposite of the choice made for ~/.config/zero and is deliberate: ~/.config/git
also holds the global git config, which userGitConfigReadPaths grants on
purpose so a sandboxed git can read user.name and aliases instead of failing
outright. Denying the directory would take that away.

Tests assert both stores are denied, that the git config beside the XDG one
stays readable, and that an explicit read grant still re-includes them like
every other entry. Dropping either entry fails the first; widening the XDG
entry to the directory fails the config assertion.

* test(sandbox): assert the credential deny exists before the grant lifts it

The allow-read case only checked that the credential store was absent from
the deny list once an explicit grant covered it. A deny rule that was never
added satisfies that just as well, so the test passed with the production
change reverted and proved nothing about the interaction it was named for.

Check both runs: denied with no grant, absent with one. The first leg is
what establishes there is something for the grant to override.
2026-07-28 18:31:33 +08:00
Vasanth T c9f9dc02ef test(tools): stop internal/tools failing on stock Windows (#814)
* test(tools): stop internal/tools failing on stock Windows

This package fails on every run on a Windows box without PowerShell 7, which
is what a stock install is: PowerShell 7 is a separate download. CI never sees
any of it, because the runners have 7. Three distinct causes, all surfaced by
the move to PowerShell in #804.

The stop-intent test waited a fixed second for a session to die and asserted on
whatever was true by then. Under cmd.exe that was enough; under PowerShell the
tree is slower to stand up and tear down, and the FIRST stop in a fresh process
pays the interpreter's cold start, so it routinely returned with termination
under way but unfinished. The assertion read that as a failed stop. It now
re-sends the stop until the session reports it has exited. Repeating is safe,
since Terminate is a no-op against an already-dead tree, and it keeps the
interrupt flag that a poll with empty chars would drop.

Separately, and pre-existing, every test that starts a session hit a teardown
race: a terminated process does not release its handles the instant the session
reports exited, and Windows refuses to remove a directory with an open handle
where POSIX does not. t.TempDir removes once and fails the test on the sharing
violation, so ordinary teardown timing was reported as a broken test. Those
roots now clean up with a bounded retry, and give up quietly rather than
failing: the process is already terminated, so the worst case is a stale
directory under the OS temp root.

Last, the MSYS guard test asserted a windows_shell_syntax block using a command
containing `&&`. On PowerShell 5.1 the windows_powershell_version preflight
added by #804 fires first and shadows it. The `&&` was incidental to what that
case is about, so it is gone. This one is worth noting as a shape rather than a
typo: a new preflight silently changed which block an older assertion got, and
only on the shell CI does not run.

Verified by running the package repeatedly rather than once: previously failing
on every run, now passing 3 of 3, and the stop-intent test passing 8 of 8 alone
and 6 of 6 under CPU contention. Disabling the terminate path still fails it,
so the assertion continues to bind.

* test(tools): assert the stop intent killed the session

The retry loop added to survive a slow PowerShell teardown also made the
test unfalsifiable. The helper sleeps 5s, well inside the 30s deadline, so
a terminate that did nothing at all would let the sleep end on its own and
every existing assertion would still hold: the session reports exited, it
reports an exit code, and the interrupted flag echoes the request rather
than the outcome. Neutering KillProcessTree confirmed it, the test passed.

Assert instead that the helper's completion line never appears, which
distinguishes a killed session from a finished one without depending on
how long the loop took. Output is accumulated across every poll rather
than read off the last result, because each continuation returns only what
the session produced since the previous collection.
2026-07-28 18:00:41 +08:00
Vasanth T ec3b822505 fix(tui): paste clipboard images with Ctrl+V (#534) (#807)
* fix(tui): paste clipboard images with Ctrl+V

Ctrl+V did nothing when the clipboard held a screenshot, while right-click
paste attached it. The image plumbing was already there: routePaste probes the
OS clipboard for an image whenever a paste arrives with empty text.

The gap is that a clipboard holding only an image produces no bracketed paste
at all, since the terminal has no text to send, so routePaste never runs and its
probe never fires. Right-click reached the probe only because that path always
delivers a clipboardReadMsg, empty or not. Bubble's own Ctrl+V binding stays
disabled, so nothing else was listening for the key.

Handle Ctrl+V by issuing the image probe directly. It reads image content only,
never text, so bracketed paste remains the single text path and a Ctrl+V with
text on the clipboard is unaffected. The probe yields no message when there is
no image, keeping it a silent no-op, and it is gated on noBlockingModal so a
permission prompt or picker still swallows the key.

readClipboardImage becomes a var so the tests stub it instead of depending on
the machine's real clipboard.

Fixes #534

* fix(tui): also probe the clipboard on Cmd+V, and test the real key route

Two things raised in review, both fair.

The handler matched keyCtrl only, which is ModCtrl. macOS reports Command as
ModSuper, as the rest of the TUI already models it, so Cmd+V never reached the
image probe even though the change claimed to cover it. It now matches both, via
a keySuper helper alongside the existing modifier helpers.

Worth being precise about the limit: this only helps where the terminal delivers
the key to the application. A terminal that handles Cmd+V itself pastes the
clipboard TEXT and sends no key event at all, so an image-only clipboard still
produces nothing for this to react to. That is a separate problem from the one
being fixed here.

The tests also did not exercise the route they were named for. One asserted that
Update returned some non-nil command and then ran a freshly built probe; the
other never called Update at all. Both now stub the reader BEFORE Update and run
the command Update actually returned.

That matters more than it sounds. Making the branch return a different non-nil
command leaves the old assertion passing and fails the new one, which is exactly
the hole: the old test proved a command was issued, not that it was the image
probe. Removing the route entirely, or reverting to ModCtrl only, also fails now.

* fix(imageinput): report an unreadable clipboard image instead of silence

On macOS the clipboard reader shells to pngpaste or a PyObjC one-liner, and
neither ships with the OS: pngpaste is Homebrew-only and Apple dropped the
bundled PyObjC. A stock Mac therefore fails every time, and the failure was
returned as (nil, nil), which the caller reads as "no image on the clipboard"
and treats as a silent no-op. Pressing Ctrl+V produced nothing at all: no
image, no error, no hint that anything was missing.

The darwin path already knows the difference. It probes with `clipboard info`
first and only reaches the extraction step once the clipboard is confirmed to
hold an image, so a failure there is not absence, it is "there is an image and
nothing here can read it". That now returns ErrClipboardImageUnreadable, which
the existing error path turns into a transcript notice naming what to install.

The defect predates this branch, but this branch is what makes the path
reachable from Ctrl+V, so shipping without it would close #534 with a feature
that is inert on one of the three platforms. Raised by gnanam, who measured it
on a real Mac rather than inferring it.

Deliberately narrow. The Linux arm cannot tell "no tool" from "no image"
without probing separately, and Windows uses PowerShell, which is always
present, so neither is touched.

The test drives the whole path: stubbed reader returns the error, Ctrl+V
issues the probe, and the assertion is that the failure reaches the transcript
as an error row rather than stopping earlier. Swallowing the error again makes
it fail.

* test(imageinput): cover the darwin unreadable-clipboard decision itself

The test added with the fix asserted around the change rather than on it. It
substituted ReadClipboardImage with a stub returning the error, which proves
the TUI surfaces an error handed to it, but never reaches
readClipboardImageDarwin, where the decision lives. Reverting both production
return sites to nil,nil left every package green, which is the definition of a
test that is not testing the change. Caught by gnanam.

readClipboardImageDarwin shells out twice and had no seam, so the two steps are
now package-level vars, the same shape as the readClipboardImage var this
change already relied on. That makes the case that matters reachable: the info
probe succeeds and reports an image, and the extractor then fails. It cannot be
arranged from outside the function, because from there both steps are one
opaque call.

Five cases, and the negative ones carry as much weight as the positive. An
image with no working extractor, and an extractor that exits 0 with no bytes,
must both be actionable. A clipboard offering no image, and a failing info
probe, must both stay silent, because turning those into a notice would make
every stray Ctrl+V nag the user. The extractor must not run at all in either
silent case.

Repeating gnanam's experiment now fails: reverting both return sites to nil,nil
fails the two actionable cases and leaves the silent ones passing, which is
exactly the discrimination the fix is supposed to add.
2026-07-28 17:51:28 +08:00
Anandan 81a5e6cb71 fix(sandbox): preserve user config and retry denied writes (#801)
* fix(sandbox): preserve user config and retry denied writes

Keep user identity and configuration paths discoverable while retaining managed cache and temporary storage. Classify native sandbox failures as typed recoverable denials so approved commands can retry outside the sandbox.

Add regression coverage for global Git ignores, typed denial handling, and permission retry behavior.

* fix(sandbox): tighten denial retry safeguards

* test(sandbox): assert denial contract

* fix(sandbox): trust typed denials and restore Cargo state

Stop promoting command output keywords into recoverable unrestricted denials, so only structured adapter facts can enter the approved unsandboxed retry path.

Restore the managed CARGO_HOME directory and environment override so Cargo remains writable while the caller's real home stays read-only.

* fix(sandbox): mask preserved credential configs

Preserving caller configuration made credential-bearing tool files discoverable under Linux's read-all sandbox profile. Add default and environment-overridden npm, GitHub CLI, netrc, Docker, Kubernetes, and Zero config paths to the credential deny set, with regression coverage.

Clarify the typed-denial retry and platform read-model comments without changing the approval behavior.

* fix(sandbox): protect custom gcloud config

CLOUDSDK_CONFIG can relocate gcloud credentials outside the XDG-derived default. Include the configured directory in the credential deny profile and cover the override with a regression test.

* fix(sandbox): deny zero credential directory

* test(sandbox): keep zero credential directory deny path expectations
2026-07-27 15:35:04 +05:30
Anandan 5d1869e7bc feat: add persistent session goals (#803)
* feat: add persistent session goals

* Fix goal run isolation and guards

* Strengthen loop goal isolation test

* Cover goal edit state transitions

* fix(goal): bound automatic continuations

Persist a provider-independent continuation limit, require approval for model-created goals, and keep resume, BTW, and loop runs from starting or charging unrelated goal work.

Also reject trailing lifecycle arguments and bound persisted objective length.

* fix(goal): resume continuations after btw
2026-07-27 13:22:09 +05:30
Anandan 518d2e53ba fix(windows): use PowerShell for command execution (#804)
* fix(windows): prefer PowerShell for command execution

Detect PowerShell 7 or Windows PowerShell before falling back to cmd.exe, and share the selected host shell across agent tools and the interactive shell escape.

Configure UTF-8 output, preserve restricted-sandbox MSYS safeguards, and add Windows coverage for pipelines, workspace writes, and interactive stdin sessions.

* test(windows): align shell coverage with PowerShell

Make Windows command fixtures and guidance assertions follow the selected host shell, preserve cmd fallback coverage, and use a genuine MSYS executable when PowerShell aliases are available.

Remove the unsupported non-TTY PowerShell stdin assertion; Windows interactive stdin requires a separate PTY transport.

* fix(sandbox): classify Windows executable suffixes

Normalize .exe, .cmd, .bat, and .com command names before safety and network lookup so PowerShell invocations such as curl.cmd receive the same network permission handling as curl.

Add analyzer and engine-level regressions for the Windows PowerShell command shape observed in CI.

* fix(sandbox): normalize absolute Windows command paths

Extract drive-letter and UNC basenames before removing shell escape characters so fully qualified Windows executables continue matching canonical safety and network classifications.

Cover both drive and UNC curl paths with analyzer regressions.

* fix(sandbox): normalize relative Windows paths

Recognize drive-relative, explicit relative, and suffixed backslash executable paths before generic shell escape removal so Windows commands continue to match canonical sandbox risk entries.

* fix(windows): harden PowerShell execution

* fix(sandbox): classify PowerShell removal aliases
2026-07-27 13:21:47 +05:30
Vasanth T d82b94defe test(cli): order the prewarm probe against the turn (#809)
* test(cli): order the prewarm probe against the turn

TestRunExecOptimizedSessionUnderGate intermittently sees zero prewarm HEAD
probes on the Windows runner. It polled for up to 5s after the run returned,
reasoning that the deadline exceeded the 3s prewarm timeout so a slow probe
could not flake it. That reasoning does not hold. The probe is issued on the
run's context and runExec cancels that context as it returns, because the stop
function signalContext hands back is a CancelFunc. Once the run is over the
probe is cancelled rather than slow, so polling cannot observe one that has not
already landed, at any deadline.

Against a mock server the turn finishes in microseconds, which leaves the probe
racing teardown. The turn now blocks until the probe arrives, giving it a
window while the run is still alive. The wait is bounded, so a probe that never
fires still fails the assertion with a useful message.

This widens the window rather than closing it: prewarmTimeout is armed inside
the probe goroutine, so holding the turn cannot extend it. The comment says so,
and names prewarmTimeout as the thing to look at if this ever recurs.

* test(cli): fail when the prewarm hold gives up

The bounded wait let the turn proceed after its guard expired, so a probe that
arrived late still left the count at 1 and the test passed. That proves the
probe was eventually received, not that it preceded the turn, which is the only
thing this test claims. Raised by CodeRabbit.

The expiry is now recorded and checked before the count, since the count cannot
tell the two apart.

Verified by suppressing the probe, which makes the guard expire: the assertion
fires with its own message rather than the count assertion, and the run takes
the full guard duration.
2026-07-27 12:12:34 +08:00
88fb7b6192 fix(sandbox): add platform-specific write lock around grant state file (#752) (#755)
* fix(sandbox): add platform-specific write lock around grant state file (#752)

Add grants_lock_unix.go and grants_lock_windows.go with a lockStateFile
method that serializes access to the persisted grant state across
processes. Wire the lock into Grant, GrantCommandPrefix, Revoke,
RevokePath, and Clear.

Also add .gitattributes to force LF line endings for Go files so
gofmt checks pass on Windows.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(sandbox): do not lock the grants file when there is no notice to consume

Addresses jatmn's #755 finding.

ConsumeMigrationNotice took the interprocess lock unconditionally, and
lockStateFile MkdirAlls the grants directory and creates
<grants>.lockfile. Every startup and every 'zero exec' calls this, so it
turned reading the grant state into requiring a WRITABLE grants
directory: a user with no grants file whose ZERO_SANDBOX_GRANTS_PATH (or
its parent config directory) sits on a read-only mount failed outright
with 'failed to migrate sandbox grants', where the pre-lock code read an
empty state and wrote nothing.

Peek at the state without the lock first and return early when no
migration write is required. Only when one is — a schema/policy
migration, or clearing NoticePending — take the lock and re-read under
it, since the unlocked peek is a snapshot another process may have
already consumed or migrated, and is meaningless by contract when
needsMigration is set.

Both regression tests are verified to fail against the pre-fix code.
The read-only-directory test skips on Windows (mode bits do not deny
writes) and as root (permissions are bypassed), so a portable companion
asserts the same requirement by requiring that nothing is created at
all.

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-27 12:11:35 +08:00
PierrunoYTandClaude Sonnet 5 0125e70d0f Fix pinned Go quality tools on Go 1.26 (#689)
* fix pinned Go quality toolchain

* fix(make): use a POSIX GOTOOLCHAIN prefix for pinned tool targets

The Windows_NT branch used cmd.exe syntax (set "..." &&), but GNU Make
runs recipes through sh even on Windows (MSYS/Git Bash), where that
form fails and breaks make lint-static/deadcode/vulncheck. A plain
GOTOOLCHAIN=... prefix works everywhere, so the OS check is gone.

Also switch GO_VERSION/GO_TOOLCHAIN to recursive expansion so
'go list -m' only runs when a tool target actually needs it, instead
of at parse time for every target. Addresses PR review from CodeRabbit
and Copilot.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(build): make pinned-tool targets shell-neutral, fix README drift

Address code review on PR #689:

- lint-static/deadcode/vulncheck previously set GOTOOLCHAIN via a
  POSIX `VAR=value cmd` recipe prefix. GNU Make on Windows runs
  recipes through whatever SHELL is configured, and under a native
  cmd.exe setup that syntax is invalid — cmd.exe treats the whole
  "GOTOOLCHAIN=go1.26.5" token as the program to run and fails before
  go ever executes (reproduced locally: cmd.exe errors with
  "'MYVAR' is not recognized..." for the equivalent pattern). Switched
  to a target-specific `export`, which sets the variable in the child
  process environment via Make itself, before  is invoked —
  works under sh, cmd.exe, and PowerShell alike (verified against all
  three targets with SHELL := cmd.exe).

- README.md's Code Quality and Security Checks section still told
  contributors to run the raw `go run ...@version` commands this PR
  identifies as selecting the wrong (older) toolchain and failing to
  load this Go 1.26.5 module — only CI and AGENTS.md had been updated
  to the new make targets. Points steps 3-4 at `make lint-static` /
  `make vulncheck` instead, matching AGENTS.md.

Verified end-to-end: `make vulncheck`, `make deadcode`, and `make
lint-static` all run successfully and reach their tools (deadcode's
advisory report and lint-static's 35 pre-existing findings match the
PR's documented baseline).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(build): support stock macOS make

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-27 12:09:37 +08:00
Vasanth T f5fa4ffc64 fix(config): stop billing provider-command fixtures to the 5s timeout (#810)
Both background-child tests fail intermittently on the Windows runner, and
have since #690. #800 and #802 each relaxed an assertion, which is why neither
held: the deadline itself was the problem.

The tests drove LoadProviderCommand, so the fixture's own startup was billed to
the production 5s budget. On a loaded runner the outer timer won the select in
runProviderCommand, and both symptoms follow from that. The nonzero-exit test
saw a timeout instead of the exit status it asserts, because the timer
preempted the shell's exit. The other test lost its PID file, because the
timer's Terminate killed the background child before it could record a PID,
putting the file permanently out of reach however long the assertion polled.

Neither test is a claim about the 5s budget. They assert which error each
cmd.Wait path yields and that the leftover descendant is killed, so they now
call runProviderCommand directly with a budget of their own and the production
deadline stops competing with process startup. Making the child outlive that
budget lets both drop their elapsed-versus-lifetime bounds as well, so no
wall-clock assertion is left in either test.

That is only worth doing if the two timeout paths can be told apart, and they
could not: the WaitDelay path and the outer-timer path both returned the same
bare sentinel, so asserting on it would have been satisfied by the very timeout
the tests exist to rule out. The WaitDelay path now wraps rather than replaces.
Callers asking errors.Is for the sentinel are unaffected, so no user-visible
message changes.

The Windows fixture also waited for its ready marker by launching a second
PowerShell. That cold start is pure overhead added after the PID is already
written, so the wait now runs in cmd itself and is bounded.

Verified by looping both tests under CPU contention: 7 failures in 16
iterations before, none in 32 after, with per-iteration time going from a
spread of 2.3s to 66s down to a steady 2.1s. Removing the wrap makes the
WaitDelay assertion fail, so it is load-bearing rather than decorative.
2026-07-27 10:00:32 +08:00
Felix-Ayush 9fbdc34155 feat(skills): report hash drift in skill info (#795)
Mirror plugin info: compare on-disk SKILL.md to the skills.lock hash so
edited installs are visible. Also drop the nonexistent skills lock
command from EXTENDING.md.
2026-07-27 10:00:07 +08:00
583e6530bd fix(lint): resolve static analysis baseline (#769)
* fix(lint): resolve static analysis baseline

* fix(lint): clear linux staticcheck findings

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-b2ee-7442-8a31-80b2cf9dd270

Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-07-27 09:59:33 +08:00
Anandan ac50a5a840 test(config): wait for background PID file (#802)
* test(config): wait for background PID file

* test(config): make delayed PID check deterministic
2026-07-25 23:02:07 +05:30
Anandan a4a805a6c8 fix(tui): reset quiet clock for new runs (#799)
New turns inherited lastStreamActivity from the prior turn, so a fresh request could immediately look stalled. Initialize stream activity from the same timestamp as turnStartedAt and cover the stale-clock case with a deterministic regression test.
2026-07-24 17:38:10 +05:30
Anandan 9fe02ae52e fix(sandbox): clarify scoped permission prompts (#798) 2026-07-24 17:37:32 +05:30
Anandan 9d5c82d441 test(config): remove Windows startup timing assumptions (#800)
Cold PowerShell startup under concurrent Windows CI can consume most of the provider timeout, so an unsynchronized PID file may never be written and a fixed four-second bound can fail despite correct process-tree cleanup. Treat an absent PID as a helper that never reached startup, retain strict liveness checks for synchronized children, and bound completion by the child's natural lifetime instead.
2026-07-24 15:43:51 +08:00
euxaristiaandClaude Sonnet 5 97ef90d27a chore: remove code unreachable on every supported platform (#706)
* chore: remove code unreachable on every supported platform

The Security and code health job's advisory deadcode step reports 152
unreachable functions on main. Running the same check under GOOS=linux,
windows, and darwin shows 13 of those are false positives whose callers
live in windows build-tagged files, so only functions unreachable on all
three platforms are touched.

Functions with no references anywhere are deleted, including the
internal/reasoning package, which nothing imports. Functions that only
tests reference move into per-package export_test.go files so tests keep
their seams while the production binary sheds the code. Test call sites
of deleted convenience wrappers are ported to the live APIs (scoped tool
constructors, tools.DeferredSource, FinishReason checks).

Kept despite the report: cron.Store.Runs (only reader of runs.jsonl,
which production writes), and agenteval.TraceEventKeys and
MissingTraceEvents (used across packages by the trace contract test).

* docs(config): correct ValidateFile's errors.As claim

Message is a flattened string (.Error() already called on the parse
error), not a wrapped error, so callers can't recover the concrete
json error type via errors.As. Fix the comment to match.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* chore(trace,skills): drop obsolete Sink adapters and fix stale doc references

The dead-code pass removed the Emit methods but left Sink, NDJSONSink, and
TextSink exported with a contract nothing implements; remove the types with
their contract. Also remove the stray blank line at EOF of trace.go flagged
by gofmt, and update skills package docs and HOW_ZERO_WORKS.md to stop
directing callers to DiscoveryRoots/Duplicates, which are now test seams.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore: clean up remaining review nits on dead code removal

Replace a pointless fmt.Errorf(...%w...).Error() with fmt.Sprintf
using %v, since the wrapped error was discarded immediately anyway.
Remove a comment block that was duplicated verbatim.

* docs(skills): drop remaining references to test-only List/Get

Load and MergedSkills comments still named List/Get as production
entry points after they moved to export_test.go. Point callers at
Load and ListFromRoots instead.

* chore: remove now-unreferenced export_test.go seams

execSessionManager.len() and likelySandboxDenied/sandboxDenialKind had
no remaining callers after the sandbox unify-command-execution rebase;
the production code they wrapped for tests no longer exists.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-24 09:04:41 +08:00
PierrunoYTandClaude Sonnet 5 5c58256abc fix(config): kill provider-command process tree via job object on Windows (#690)
* fix(config): kill provider-command process tree via job object on Windows

taskkill /T walks the process tree by parent PID in user space and can
miss descendants, leaving the sleeping child holding the inherited
stdout/stderr pipes so cmd.Wait() blocked until natural completion
(~10s instead of the 5s timeout).

Assign the started command to a job object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and terminate the job on timeout so
the kernel kills the whole tree atomically; taskkill remains as a
fallback. Also set cmd.WaitDelay so Wait can never hang on pipes held
by an escaped orphan, and add a regression assertion that the sleeping
child is actually gone after the timeout returns.

Fixes #683

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(config): harden provider-command tree termination

Address code review on PR #690: terminate the process tree when
cmd.Wait() returns exec.ErrWaitDelay (a background descendant kept
inherited pipes open, previously left running); start the Windows
process suspended and assign it to the job object before resuming its
main thread, closing the window where a fast child could escape the
job; drop JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so a successful command's
detached helpers survive Close() instead of being reaped; escape
single quotes in generated PID-file paths; and correct a misnamed
Windows exit-code constant in a comment.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(config): avoid gofmt smart-quote rewrite in doc comment

gofmt's doc-comment formatter rewrites a trailing '' into a curly
closing quote, which CI's gofmt -l check flags as unformatted. Reword
to avoid the pattern instead of embedding a raw quote pair.

* fix(config): terminate leaked descendants on nonzero exit, avoid PID reuse

Terminate the process tree on any error returned from cmd.Wait, not only

exec.ErrWaitDelay. Go only returns ErrWaitDelay when the shell itself

exited successfully; a nonzero exit (e.g. sleep 600 & exit 7) yields the

bare *ExitError instead, so a detached background child that held the

inherited pipes open was never terminated on that path.

Also stop falling through to the taskkill/PID fallback once a job handle

successfully contained the tree: by the time Terminate runs on the

ErrWaitDelay path, cmd.Wait has already reaped the root process, so its

PID may have been reused by an unrelated process, and taskkill /T /PID

against a reused PID would force-kill the wrong tree.

Addresses PR #690 review feedback.

* fix(config): surface suspended-thread resume failures instead of hanging

resumeMainThread now reports whether it actually resumed at least one

thread. If Toolhelp/OpenThread/ResumeThread all fail, the launched

process would otherwise sit suspended forever, never exit on its own,

and get misreported as a plain 5s provider-command timeout instead of

the real resume failure. attachCommandProcess now terminates the

process immediately in that case.

Also documents the known, non-regressive limitation that the taskkill

fallback (used only when job creation/assignment itself fails) can

still miss a descendant that has reparented away from the tree it

walks by PID.

Addresses remaining PR #690 review feedback; PR description corrected

separately to drop the outdated JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE claim.

* fix(config): guard taskkill with retained process identity

* fix(config): retain POSIX process group identity

* test(tui): match provider wizard shortcut

* test(tui): pin credential-step env for deterministic footer assertion

The credential step's footer only shows the /→ shortcut once a key
is present, whether typed or inherited from the provider's
AuthEnvVars. The previous commit changed the assertion to
"Enter/→ continue" without accounting for that, so the test only
passed by coincidence when ANTHROPIC_API_KEY happened to be set in the
environment — failing deterministically on a clean CI runner (as
smoke reported on all three OSes). Pin the env var empty and assert
the no-key footer text so the test is correct regardless of ambient
environment.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(config): preserve provider process attributes

* test(config): keep SysProcAttr coverage portable

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-24 09:03:59 +08:00
Felix-Ayush fa8734f179 fix(cli): include plugins info in shell completions (#794)
zero plugins info landed in #773 but the completions tree still only
offered list/add/remove, so generated bash/zsh/fish scripts omitted it.
2026-07-24 08:49:01 +08:00
euxaristiaandClaude Fable 5 5bb1bc11c3 fix(tui): fix rendering corruption over multipass + Windows Terminal (#709)
* fix(tui): disable bubbletea hard scroll optimization unconditionally

Vendors a patched charm.land/bubbletea/v2 (via a local go.mod replace)
with hard scroll optimization (using terminal scroll-region sequences
to shift existing lines instead of repainting) disabled unconditionally,
instead of only on GOOS=="windows".

That GOOS check only catches a Windows terminal driving the program
directly. It misses a Linux process running inside a multipass VM
whose output is ultimately displayed by Windows Terminal over a
remote shell, which corrupts rendering badly (garbled/overlapping
text, worse on resize) the same way the check exists to prevent.
There's no reliable way to detect that remote hop from inside the VM,
so scroll optimization is now off in all cases.

* fix(tui): force redraw on streamed newlines to clear stale caret

The streaming caret (appendStreamingCursor) is appended to whichever
visual line is currently last. Over a remote terminal chain
(multipass VM output displayed by Windows Terminal), the renderer's
incremental cell diff sometimes fails to clear the caret's old cell
when a newline moves it to a new line, leaving ghost carets behind.

Fire tea.ClearScreen when a streamed delta contains a newline: that's
exactly the moment the caret moves and the risk exists, so it forces
a full redraw right then instead of leaving it to the incremental
diff, without doing so on every animation tick.

* chore(tui): trim upstream repo metadata from vendored bubbletea fork

The vendored bubbletea-v2-patched module is ~99% byte-identical to
upstream charm.land/bubbletea/v2 v2.0.8; only cursed_renderer.go
differs (one line disabling hard scroll optimization). The .github
workflows, README.md, UPGRADE_GUIDE_V2.md, Taskfile.yaml and CI config
files are upstream repo metadata that serve no purpose inside this
repo, so remove them to slim the PR. The Go source and testdata golden
files are kept because Go's replace directive requires the full module
to be present and buildable.

Refs #709

* fix(tui): address review round on the rendering workaround and vendored fork

Fork correctness fixes (all flagged by review):

- key.go: KeyMediaRecord repeated the previous const expression and
  decoded record presses as previous-track; map it to uv.KeyMediaRecord.
- mouse.go: MouseButton11 likewise collapsed onto MouseButton10.
- termios_unix.go: BSDLY/BS0 are output-delay bits; read them from Oflag
  instead of Lflag so useBackspace is set correctly.
- cursed_renderer.go: count wrapped rows with (lineWidth-1)/w so an
  exact terminal-width multiple no longer inserts a blank line; compare
  colors by normalized RGBA via a nil-safe helper instead of interface
  equality (which panics on non-comparable implementations); and make
  clearScreen invalidate lastView so the queued erase flushes with the
  next frame instead of deferring until an unrelated view change — the
  reordering that could leave the ghost caret in place.
- exec_test.go: implement reset() on the spy renderer and inject it
  before Run; assert exec preserves renderer state (releaseTerminal
  with reset=false).
- logging_test.go: restore the default logger's output/prefix/flags and
  fail fast on setup errors.
- tea_test.go: tie the Quit-pumping goroutine to a channel closed when
  Run returns instead of leaking it until process exit.

TUI scoping (review P2): the forced redraw on streamed newlines is now
rate-limited to ~10/s so heavy streaming output no longer turns every
newline into a full-screen repaint, and ZERO_NO_STREAM_CLEAR=1 opts a
correctly-rendering terminal back into the fast incremental path.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tui): guarantee deferred stream-clear redraw and other review fixes

A throttled stream-clear could drop the redraw entirely when a
newline arrived in the last delta of a turn, leaving a ghost caret on
screen. Add a one-shot deferred flush timer plus a flush on stream
end. Fix a stale comment and a test that leaked global logger state
in the vendored bubbletea fork.

* fix(tui): use m.now for stream-clear throttle and harden tests

Drive stream-clear rate limiting through the mockable clock and a
shared streamClearThrottle constant so both the agentTextMsg and
flush paths stay in sync. Add an explicit two-newline-within-window
regression and detect ClearScreen by function pointer so tests do not
evaluate Tick side effects.

* fix(tui): relocate bubbletea fork out of third_party

Move the maintained bubbletea/v2 hard-scroll patch from
third_party/bubbletea-v2-patched to patches/bubbletea-v2 and update the
root go.mod replace. third_party/ is immutable vendored content per
AGENTS.md; a maintained fork belongs outside that tree.

* fix(ci,docs): run vendored bubbletea fork tests and document unsupported go install path

patches/bubbletea-v2 is its own Go module so `go test ./...` from the repo
root never touched its 7 test files. Add a CI step (and a make test-patches
target for local use) that runs the fork's own suite across the smoke
matrix.

The root go.mod's local `replace charm.land/bubbletea/v2 =>
./patches/bubbletea-v2` also breaks `go install
github.com/Gitlawb/zero/cmd/zero@<version>`, since a versioned install can't
resolve a replace pointing at a filesystem path. Document that path as
unsupported in README.md and docs/INSTALL.md and point at git clone + go
build instead, per jatmn's review findings on PR #709.

* ci: run vendored bubbletea fork tests with -race

CodeRabbit review on this PR. The fork exists specifically to patch a
concurrency bug (charmbracelet/bubbletea#1690), so its regression test
needs the race detector to actually catch a reintroduced race instead
of just passing on a benign-looking run.

* fix(tui): drop vendored bubbletea fork; keep stream-clear workaround

Maintainers asked not to ship an in-tree bubbletea replace: it breaks
versioned go install and is a large maintenance surface for a narrow
multipass/Windows Terminal scroll bug. Remove patches/bubbletea-v2 and
the replace directive; keep the model.go full-redraw-on-streamed-newline
workaround (ZERO_NO_STREAM_CLEAR to opt out). Restore normal install docs.

Refs Gitlawb/zero#709

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-24 08:48:16 +08:00
PierrunoYTandClaude Sonnet 5 d9b882eb0a fix(cron): reserve job IDs atomically (#686)
* fix(cron): reserve job IDs atomically

* fix(cron): hold the per-ID lock from reservation through persistence

Address code review on PR #686: reserveID made the timestamp directory
visible via os.Mkdir before metadata.json existed, but Remove (which
takes the per-ID lock before deleting) never coordinated with that
window. A concurrent `zero cron rm <id>` could delete the bare
reservation and report success right after Add reserved it; writeJob's
MkdirAll would then silently resurrect the directory and persist the
job anyway. A second Add could also re-reserve the same id while the
first writer was still running, putting two writers on the same
metadata path.

reserveID now acquires the same per-ID lock (used by Get/Update/
Mutate/Remove/AppendRun) for each candidate id before attempting
os.Mkdir, and returns it still held; Add releases it only after
writeReservedJob commits (or tears down) the reservation. A concurrent
Remove for that id now blocks until the reservation is resolved one
way or the other, and a concurrent Add attempting the same candidate
id serializes through the lock rather than only racing the Mkdir.

Adds two regression tests: one proving Remove waits for and removes
the committed job rather than the bare reservation, and one proving a
second reserveID call for the same candidate id blocks on the shared
lock and moves on to the next candidate.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-22 19:28:04 +08:00
github-actions[bot] a50574f673 chore(main): release 0.5.0 (#714)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-22 09:37:36 +08:00
nb213andbinyangzhu000-sudo c00a4734b5 Add Atlas Cloud provider preset (#784)
Co-authored-by: binyangzhu000-sudo <[email protected]>
2026-07-22 08:20:21 +08:00
Felix-Ayush 2479884dea feat(plugins): add zero plugins info command (#773)
* feat(plugins): add zero plugins info command

Add plugins info to inspect manifest state, extension counts, and
lockfile source/hash with optional hash drift detection.

* fix(plugins): derive lock dir from plugin install path for info

Remove LockDir from InfoOptions and read the lockfile from
filepath.Dir(plugin.PluginDir) so lock metadata matches the resolved
plugin location. Consolidate duplicate lock hash output lines.
2026-07-22 08:14:00 +08:00
Anandan ab2f9fbd3e Add shell completion generation (#764)
* Add shell completion generation

Add zero completions for bash, zsh, fish, PowerShell, and Elvish using one shared command tree that covers aliases, nested commands, root flags, and common exec flags.

Validate missing and unsupported shell arguments as usage errors and document safe installation examples in command help.

Refs #499

* Complete shell target suggestions

Expose every supported shell as a completions subcommand candidate and document Elvish activation.

Refs #499

* Fix Elvish completion syntax

Close generated Elvish conditional branches, use value equality, and validate generated script structure plus native Bash and Zsh syntax.

Refs #499
2026-07-22 08:13:14 +08:00
PierrunoYTandAmp baa4be13ac fix: make extension installs transactional (#762)
Co-authored-by: Amp <[email protected]>
2026-07-22 08:12:49 +08:00
ddfdf2878e Fix ChatGPT OAuth model discovery and ACP model selection (#724)
* fix: expose authenticated ChatGPT models to ACP

Co-authored-by: Pierre Bruno <[email protected]>

* fix(acp): persist discovered model selections

* fix(acp): normalize model selections

---------

Co-authored-by: Amp <[email protected]>
2026-07-22 08:11:55 +08:00
Anandan 96859c9bd1 feat(sandbox): unify command execution and enforcement (#781)
* feat(sandbox): unify command execution and enforcement

* test(sandbox): make platform contracts portable

* fix(sandbox): close execution lifecycle races

Bound retained interactive output, preserve interruption state, and start workspace observation before process launch.

Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback.

* fix(sandbox): restore strict macOS isolation

Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces.

Track process reap completion so retained sessions cannot signal a stale numeric PID.
2026-07-21 18:55:13 +05:30
Vasanth T 3524f795e5 fix(cli): warn when ZERO_PROVIDER overrides a providers-use selection (#767)
Closes #721.

`zero providers use <name>` wrote activeProvider to config.json and reported
"Active provider set to <name>" even when ZERO_PROVIDER was set, which applyEnv
makes win over config.json unconditionally. So the switch was reported as a
success while `providers current` still showed the env-pinned provider, a silent
no-op.

Detect the override and say so: the text path prints a note to stderr naming the
env var and the provider that stays active, and the --json path adds
effectiveProvider / overriddenByEnv fields so a machine consumer is not misled
either. The config write still lands unchanged; only the reporting is honest.

The env read is injected via a new appDeps.getenv (os.Getenv in production, left
nil by fillAppDeps like exportActiveProvider) so tests stay hermetic against an
ambient ZERO_PROVIDER. The related env-derived-profile "not found" case the issue
also mentions is already handled by the unpersisted-provider path (#707) and #716.
2026-07-21 13:00:50 +05:30
Vasanth T b1f41735a7 fix(lsp): make the real-gopls check opt-in so a broken gopls can't fail the suite (#684) (#766)
* fix(lsp): make the real-gopls check opt-in so a broken gopls can't fail the suite

Closes #684.

TestManagerCheckRealGopls skipped only when gopls was absent from PATH, so an
installed-but-unhealthy gopls (e.g. an unusable persistent-index cache that makes
the server exit during initialization with EOF) failed `go test ./...` for every
developer who merely had gopls on PATH.

Gate it behind ZERO_GOPLS_INTEGRATION, matching the repo's other real-server
opt-ins (internal/dictation smoke tests). The default hermetic suite keeps its
coverage on the fake server and never touches the developer's global gopls; the
real check still runs on demand with ZERO_GOPLS_INTEGRATION=1.

* test(lsp): require ZERO_GOPLS_INTEGRATION=1 exactly, so =0 keeps the check off
2026-07-21 13:00:41 +05:30
Vasanth T 4945684fa2 fix(sandbox): bind Windows elevated ACL setup to one no-follow handle (#765)
Closes #728.

The Windows ACL setup resolved each target's pathname independently across
os.Stat, GetNamedSecurityInfo, and SetNamedSecurityInfo (and again on rollback),
so during elevated setup a lower-privileged local user could swap a target for a
symlink/junction between operations and redirect the ACL change onto a system
object the process never validated. That is a TOCTOU across a privilege boundary.

Open each target once with a no-follow handle (FILE_FLAG_OPEN_REPARSE_POINT plus
FILE_FLAG_BACKUP_SEMANTICS, READ_CONTROL|WRITE_DAC) and drive both the read
(GetSecurityInfo) and the write (SetSecurityInfo) through that one handle, so they
hit the same kernel object. isDir is read from the same handle rather than a
separate Stat. A target that resolves to a reparse point is refused outright,
since a setup target that is a symlink/junction during elevated setup is the
swap-attack signature. Rollback re-opens no-follow rather than restoring by
pathname; the residual window is small because the target is ACL-restricted by
then, and one caller discards the rollback closure so handles are not retained for
the whole sandbox lifetime.

Tests (windows-tagged, no elevation needed): the handle-based apply plus rollback
round-trip on a user-owned temp dir, the materialize-and-remove path, reparse
rejection via a junction (needs no admin, so it runs in CI), and the not-exist
mapping. The full privilege-boundary race can't be reproduced in CI, so the guard
is verified structurally (one handle for read and write) and by junction rejection.
2026-07-21 13:00:29 +05:30
Vasanth T 5140d4bf7b docs(contributing): add contribution volume and batching guidance (#783)
Documents expectations for keeping contribution flow sustainable during the
stabilization phase: one issue per bug (no bundled multi-finding audits), keep
only a few items open at a time, and hold AI-assisted contributions to the same
per-item verification bar. Complements the existing issue-approved gate, which
already covers PR-to-issue linkage and scope; the gap was submission volume.
2026-07-21 15:16:22 +08:00
KRATOSandKRATOS 3967d49d64 feat(tui): permission prompt takes free-text feedback inline (#780)
* feat(tui): let the permission prompt take free-text feedback inline

"No, and tell Zero what to do differently" now opens an inline input on the
permission card instead of silently cancelling: type an instruction, Enter sends
it, Esc returns to the option list. The text is delivered as a Deny decision whose
Reason is the typed instruction, so the agent surfaces it via deniedPermissionResult
as the tool result and the model adjusts in the SAME turn — where the old cancel
returned an error and aborted the run. Empty input + Enter falls back to a plain
cancel (the prior meaning).

No agent-side change: PermissionDecision.Reason already carries free text to the
model. The UI mirrors the ask_user "type your own answer" pattern — the feedback
field reuses the shared composer input and the card renders it in the transcript
body.

Also: a focused permission prompt now suppresses the composer (and idle
hints/plan panel), keeping only the status line, exactly like the ask_user modal.
Its keys are inert anyway, so a "describe a task for zero…" box below the card was
misleading — and in feedback mode the shared input would otherwise echo in two
places.

Tests cover open / submit-as-Deny-with-text / empty-cancels / Esc-returns, the
composer suppression in both modes, and that the feedback text renders exactly
once.

* fix(tui): preserve the composer draft across permission feedback (#780 review)

anand + CodeRabbit (Major): entering feedback mode cleared the shared composer
input via SetValue(""), and neither submit nor cancel restored it — so a user
with a half-typed or queued next-turn message who chose "tell Zero what to do
differently" lost that draft.

Snapshot the composer value into pendingPermissionPrompt.savedDraft when feedback
mode opens, and restore it on BOTH exits (submit and Esc-cancel). Only the
feedback text is consumed, delivered via the decision Reason. Adds regression
tests for both paths; mutation-verified against the SetValue("") regression.

* fix(tui): guard permission-feedback backspace and clicks (#780 review)

Vasanthdev2004 review, all on the permission path:

1. Real bug: Backspace on an empty feedback field dropped a staged attachment.
   The keyBackspace case runs before the typing branch and, on an empty composer
   (feedback mode clears it), fell to removeLastAttachment — silently dropping a
   staged image/doc that savedDraft does not restore. Route Backspace to the
   shared input while typing so it is a plain edit of the feedback text.

2. Defence-in-depth: the mouse click resolver in handleTranscriptSelectionMouse
   resolved a clicked permission option (Allow included) with no typing check. It
   is safe today only because renderFocusedPermissionPrompt returns nil clickable
   offsets in feedback mode — a single early-return in a function other PRs also
   edit. Add an explicit !typing guard on the click path so the safety is stated,
   not emergent.

3. Test hardening (also raised by CodeRabbit): assert len(got) == 1 before
   indexing got[0] in the draft-preservation test, so a regression fails cleanly
   instead of panicking.

Tests: TestPermissionFeedbackBackspaceKeepsStagedAttachment (mutation-verified
against the missing guard) and TestPermissionFeedbackRendersNoClickableOptionsWhileTyping.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 23:29:10 +05:30
KRATOSandKRATOS 89bdc6719a fix(tools): read_file recovers a backwards line range instead of erroring (#779)
* fix(tools): read_file recovers a backwards line range instead of erroring

renderReadFileRange hard-failed when end_line < start_line, costing the caller a
whole retry for an arithmetic slip. It was also inconsistent: start_line past EOF
returns a friendly note and end_line past EOF is auto-clamped to the last line —
only a backwards range hard-errored.

Recover it the same way: clamp end_line UP to start_line (read just that line) and
surface the adjustment in the output so it is never silent. Valid ranges
(end_line >= start_line) are byte-for-byte unchanged.

The change is in the argument-validation phase, before any file I/O, so there is
no runtime cost; if anything it saves the agent a wasted retry turn.

Tests: TestReadFileToolRecoversBackwardsRange (recovery + note + reads only
start_line, mutation-verified against the old hard error) and
TestReadFileToolValidRangeUnchangedByRecovery (valid ranges carry no note and read
the identical window).

* test(tools): assert full valid-range output in read_file recovery test (#779 review)

anand + CodeRabbit: TestReadFileToolValidRangeUnchangedByRecovery claimed
byte-for-byte preservation but only checked substrings, so an altered
header/separator or a stray out-of-range line would still pass. Compare the
whole result.Output against the exact expected rendering.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 22:57:25 +05:30
KRATOSandKRATOS 722bb31216 fix(tui): stop the permission card clashing on cool themes (#778)
The focused permission prompt was the only prompt card in the TUI that used
brand/warning colours for its interior, and both read badly on cool themes such
as dracula:

1. The selected option ("Yes, proceed") was filled with zeroTheme.badge — the
   brand chip meant for short labels (" 0 ", " ASK ", " SPEC REVIEW "), a
   full-brightness accent fill. Every other selectable list in the TUI (model
   picker, provider list, / palette) highlights its selected row with onSel/selBg
   instead. The permission card was the outlier, painting a saturated accent slab
   across the row and skipping the card tint every other line composed onto.

2. The card BODY was tinted with permBg, an amber-family wash intended to signal
   a warning surface. It works on the warm default palette but renders as a
   brown-yellow slab over cool themes, and it made the permission card the only
   prompt whose body carries a fill — ask_user, spec review and plan all use a
   transparent body (lipgloss.NewStyle()).

Fix both to match the rest of the TUI: the selected row uses onSel(ink) like
every other picker, and the body drops its permBg fill for a transparent one.
Warning identity is unchanged — the amber PERMISSION badge and the amber-mixed
border still mark it as a permission gate; only the clashing interior fills go.

TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip asserts against
palette entries (not hardcoded hex, so it survives a theme retune): the selected
row carries selBg and not the brand accent, the body carries no permBg wash, and
the PERMISSION badge keeps its amber fill so the card still reads as a warning.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 22:39:50 +05:30
KRATOSandKRATOS a21a052a4a feat(sandbox): disable the sandbox via config (#687) (#746)
* feat(sandbox): disable the sandbox via config (#687)

Add a sandbox.enabled field (`"sandbox": {"enabled": false}`). ModeDisabled
already short-circuits the engine but had no config surface.

- SandboxConfig.Enabled *bool (pointer distinguishes an explicit false from an
  omitted key).
- mergeConfig honors it (global config / CLI); applyConfiguredSandboxPolicy maps
  enabled:false to ModeDisabled.
- mergeProjectConfig intentionally does NOT merge it: a cloned repo must not be
  able to disable the sandbox that constrains it — same posture as
  AdditionalWriteRoots and the Network tighten-only rule.

* fix(config): honor sandbox.enabled from the CLI override path (#687 review)

applyOverrides copied BlockUnixSockets/MonitorDenials but not the new Enabled
tri-state pointer, so a CLI/programmatic Overrides{Sandbox:{Enabled:&false}} was
dropped — contradicting the 'global config / CLI can disable' contract. Preserve
the pointer in applyOverrides (runs after all config sources, so an explicit
override wins) and add a resolver regression test: an explicit false override
disables, and an explicit true override wins over a lower-precedence config false.

* fix(config): keep sandbox.enabled out of the provider-command path (#687 review)

mergeConfig now copies Sandbox.Enabled, and it is shared by two callers: the
global-config merge (trusted) and the provider-command merge (not). A provider
command is an arbitrary executable named in config, and LoadProviderCommand
parses its stdout into a full FileConfig -- so one returning a valid provider
plus {"sandbox":{"enabled":false}} could disable the sandbox meant to constrain
it, despite the setting being documented as global-config/CLI only. Confirmed
reproducible in both directions before the fix.

Clear Sandbox.Enabled from the provider-command config before merging, mirroring
the carve-out mergeProjectConfig already applies for the same reason. Done at the
call site rather than inside mergeConfig because that helper must keep honouring
the setting for the trusted global-config merge.

Adds TestResolveSandboxEnabledIgnoredFromProviderCommand covering both
directions (cannot disable, cannot enable) and asserting the provider itself is
still applied, so the guard cannot be satisfied by dropping the command wholesale.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 21:07:04 +05:30
KRATOSandKRATOS f079b90f82 fix(sandbox): AST second opinion for interactive-command bypasses (#473) (#745)
* fix(sandbox): AST second opinion for interactive-command bypasses (#473)

The interactive-command guard split shell commands with a hand-written parser
(splitShellSegments), which mis-handles unusual quoting, command substitution,
subshells, and newline separators — an interactive program hidden by one slips
through and the agent hangs until the per-command timeout.

Add astCommandFields(): mvdan.cc/sh/v3/syntax (already used by analyzer.go) to
re-extract the real simple commands, then apply the SAME per-program checks the
regex path uses (interactivePrograms + hasNonInteractiveFlag). This only ADDS
detections and classifies every program exactly as before — ssh with a trailing
command, python -c, etc. stay allowed — while catching the splitter's bypasses.
Unparseable input (Windows cmd.exe, obfuscation) yields no commands and falls
through to the regex path; the guard never hard-blocks on a parse error.

* fix(sandbox): AST pass runs the full pipeline + guards dynamic programs (#473 review)

Address review on #745:
- The AST second opinion now applies the complete detection pipeline via a new
  inspectCommandFields helper (multi-word interactive segments, sh -c payload
  recursion, then the per-program lookup), so parser-hidden bypasses like
  { git rebase -i HEAD~1; } and { sh -c 'vim file.txt'; } are caught, not just
  bare interactive programs.
- astCommandFields now skips a CallExpr whose program word is not a static
  literal (isLiteralWord), so a substitution concatenated with a literal
  ($(printf foo)vim -> foovim) can't fabricate an interactive program name.

Regression tests added for both boundaries. (The un-braced $(printf foo)vim
false positive is a separate, pre-existing hand-parser behavior — present on
main before this PR — and is out of scope here.)

* fix(sandbox): require literal words for the whole AST call (#473 review)

astCommandFields guarded only the program word, then ran every argument
through wordText, which keeps literal/quoted parts and silently drops
CmdSubst/ParamExp. A dynamic ARGUMENT was therefore reconstructed lossily:
`git $(printf foo)rebase -i HEAD~1` runs as `git foorebase -i` (a
non-existent, non-interactive subcommand) but collapsed to `git rebase -i`
and was blocked -- a false positive on a command the user never wrote, and
new on this branch (the base correctly reports not-interactive).

Extend the literalness guard to every word in the call: if any word carries
an expansion, skip that AST command rather than classify a reconstruction
whose runtime value is unknowable. Skipping is the safe direction -- the
hand-written passes still run first, and a miss falls through to the normal
permission prompt instead of hard-blocking.

Genuine detection is unchanged: `git rebase -i HEAD~1` and the #473 bypass
cases still classify exactly as before.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 21:01:55 +05:30
KRATOSandKRATOS b30c3971b4 fix(tui): model rows labelled by id when the description is prose; keep the sidebar under the / palette (#775)
* fix(tui): label model rows by id when the description is prose

The model picker (provider wizard AND onboarding, which share displayLabel)
labelled each row with the provider's Description, falling back to the ID only
when the description was "generic". That is right when Description holds a short
display name -- "Grok 4.3" for x-ai/grok-4.3, which is what the field is for and
why the row prefers it over an ugly raw ID.

Providers do not all honour that. Aggregators commonly return a prose blurb
instead ("Fast DeepSeek model for efficient chat, coding help, and agent
loops"), which fails as a label twice: it overflows the row, and because such
blurbs are written per model FAMILY rather than per model, several DIFFERENT
models collapse to byte-identical rows. Observed in a live picker with two
identical "Open-weight GPT model for self-hosted reasoning..." rows and two
identical "Kimi multimodal agent model..." rows -- the distinguishing ID was
visible only in the detail line of whichever row happened to be highlighted, so
the list could not be used to pick between them.

providerWizardGenericModelDescription cannot catch these: it rejects vague
descriptions ("live model", "catalog default"), whereas these are specific, just
shared and sentence-shaped. So gate on shape instead -- a display name is short
and few-worded; anything longer is prose and the ID (unique by construction)
makes the better label. Friendly names are still preferred where providers
supply them, so the existing behaviour is unchanged for well-behaved catalogs.

* fix(tui): keep the sidebar open under the / command palette

sidebarAvailable grouped suggestionsActive() with the full-screen overlays
(setup, wizards, pickers), whose stated rationale is that they "take over the
chat column and render at full width". That is not true of the `/` palette:
suggestionOverlay draws it through centerRenderedBlock at
minInt(width, suggestionPaletteMaxWidth), a centred box capped at 76 cells
floating over the chat column.

Treating it as full-screen collapsed the whole layout on every `/`: the sidebar
disappeared, the plan panel dropped out of it and re-rendered inline above the
composer, and the transcript re-wrapped to full width -- a jarring reflow for a
popup, and worst mid-run with a live plan on screen.

It cannot overlap the sidebar either: twoColumnTranscriptView renders the
overlay with width = chatColumnWidth, so the palette is capped at
min(chatColumn, 76) and centred inside the chat column. Mouse hit-testing is
unaffected because sidebarLineAtMouse carries its own suggestionsActive() guard,
so clicks still reach the palette rather than the sidebar rows beneath it.

The genuinely full-width overlays keep suppressing the sidebar, pinned by the
new test alongside the palette case.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 20:58:11 +05:30
Vasanth T da9fb50f54 fix(tools): give write_stdin's invalid-session errors the same recovery guidance (#749 follow-up) (#768)
* fix(tools): give write_stdin's invalid-session errors the same recovery guidance

Follow-up to #749 (write_stdin session-id probing) / #702.

write_stdin had three "no valid live session" error paths with three different
messages and signatures: a missing session_id returned "session_id is required",
a zero/negative/non-integer one returned "session_id must be at least 1", and
only a valid-but-unknown id got the rich UnknownExecSessionError recovery guidance
("do not guess or probe; start a session with exec_command, or use edit_file/
write_file"). So a model that called write_stdin with session_id 0 (the common
"I have no id" placeholder) got a terse error with no way forward, and naming the
minimum nudged it toward trying 1, 2, 3... the exact id-probing #749 suppresses.
The three signatures also meant a model moving 0 to 1 to 2 reset the
repeated-failure streak on each class of mistake.

Route the missing / non-integer / <1 cases through the same UnknownExecSessionError
message. Now every "no live session" mistake gives the same recovery guidance and,
because that message is id-invariant, shares one repeated-failure signature, so any
mix of missing/zero/probing accumulates toward the halt.

Updates TestWriteStdinRequiresPositiveSessionID to cover missing/zero/negative/
non-integer and assert the recovery message. Guardrail signature/halt tests
unchanged.

* test(tools): cover the explicit session_id: nil case for write_stdin
2026-07-20 13:59:11 +05:30
KRATOSandKRATOS 684901165d fix(sandbox): don't auto-allow shell when re-entrancy skips wrapping (#727) (#744)
The ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND markers are user-controlled
at an unsandboxed process boundary. Policy could auto-allow an ordinary shell
command (backend reports a native wrapping sandbox) while the runner, seeing the
same markers, returned an unwrapped pass-through plan — the command then ran
directly on the host with no isolation and no prompt.

shellSandboxActive now mirrors the runner: when IsAlreadySandboxed() (the markers
are present, so wrapping is skipped) the sandbox is not the boundary for this
command, so it reports inactive and the shell command takes the normal approval
path instead of auto-allowing. Immediate mitigation per the issue; authenticated
launch provenance is the longer-term fix.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 11:56:36 +05:30
Vasanth T e1975c1b39 fix(perfbench): grant write tools so mutating tasks measure real edits (#763)
* fix(perfbench): grant write tools so mutating tasks measure real edits

The turn benchmark invoked `zero exec` in its default read-only posture,
which exposes no write or shell tools (no edit_file/apply_patch/
write_file/exec_command/bash). So the mutating classes (edit/fix/
refactor) could never apply a change: a run either ground to the turn
ceiling hunting for an edit tool or reported a no-tool blocker, and the
only tasks that 'passed' were ones whose oracle grep matched the stamped
.zero-answer.txt narration rather than a real edit.

This invalidated every mutating-class number in both baselines. A live
trace of fix-07 (a one-line admin->user prefix change) showed the model
calling only read_file/grep/lsp_navigate/update_plan and then stating it
had no file-writing tool. Adding --skip-permissions-unsafe exposes the
full tool set; each task already runs in an isolated throwaway fixture
copy, so there is nothing to protect. With the flag, edit-01/fix-01/
fix-07/refactor-01 go from failing (or false-passing) to 4/4 real
passes, and tool_execution rises from ~0.4% to ~29% of wall as the write
tools are actually exercised.

Adds TestBuildTurnExecArgsGrantsWriteTools pinning the flag as a
correctness contract.

* fix(perfbench): use --auto member and let the oracle judge incomplete exits

Reviewer feedback on the write-tools grant: --skip-permissions-unsafe is
broader than the benchmark needs. It also drops the workspace, network,
and destructive-command guards and auto-retries sandbox-blocked commands
unsandboxed. Switch to --auto member, which grants exactly the write +
sandboxed-shell tools the mutating classes need while keeping those
safeguards.

member-auto's shell is sandboxed, though, so on a host without sandbox
setup the agent's own self-verification (go test/build) can't run and the
turn exits INCOMPLETE even after a correct edit. The runner used to treat
any nonzero exit as failure before consulting the oracle, which would
false-fail those correct edits. Make the oracle authoritative for
oracle-bearing tasks: the stamped test/grep against the actual fixture
files is ground truth, so a correct edit passes regardless of the exit
code. Latency-only tasks have no oracle, so a nonzero exit still fails
them.

Verified with a 4-task glm-5.2 smoke (edit-01/fix-01/fix-07/refactor-01):
4/4 real passes under --auto member. Updates the exec-args test to assert
--auto member is present and --skip-permissions-unsafe is absent, and adds
TestOracleAuthoritativeOnIncompleteExit and
TestNonzeroExitStillFailsLatencyOnly.

* fix(perfbench): reject fixtureless tasks so the write-tools grant can't escape isolation

CodeRabbit and gnanam both flagged the same structural gap: buildTurnExecArgs
grants the write + sandboxed-shell tools to every invocation, but the fixture
isolation was conditional (a task with no workspaceFixture ran in the caller's
cwd). So the grant and the guard could drift apart, and a fixtureless task would
run an agent with write/shell tools in the caller's real repo dir.

Make the isolation a checked precondition: NewTurnExecRunner now rejects a task
with no workspaceFixture before launching zero exec, failing loudly rather than
silently running in cwd. Every shipped task already declares a fixture, so this
only fires on a malformed or newly added one.

Adds TestTurnRunnerRejectsFixturelessTask (rejection is pre-launch: it returns
the fixture error even with a nonexistent binary).

* fix(perfbench): defer to the oracle only for INCOMPLETE (exit 4), not every nonzero exit

anand's follow-up: the previous commit cleared VerifyErr for any nonzero
run_end whenever a VerificationCommand was present, which would also convert
a crash (1), usage error (2), provider failure (3), or interruption (130)
into a passing result whenever a partial edit happened to satisfy the oracle.

Gate the oracle override on exitCode == exitIncomplete (4): the completion
gate's "work maybe done but not signaled" case, the only one where a correct
edit can legitimately exit nonzero (its sandboxed self-verify couldn't run
under --auto member). Every other nonzero exit stays authoritative, so a
partial edit can't launder a crashed or interrupted run into a pass.

The 4-task glm-5.2 smoke stays 4/4 (those tasks exit INCOMPLETE, not some
other code). Adds TestNonIncompleteExitStaysAuthoritative (a correct edit that
reports exit 1 must still fail) and mirrors internal/cli.exitIncomplete as a
local execExitIncomplete constant.

* test(perfbench): pin the exit-4 oracle deferral from both sides; drop dead fixture branch

Follow-ups from a self-review of the exit-4 gating:

- Add TestIncompleteExitStillFailsWhenOracleFails: an INCOMPLETE (exit 4) run
  that did NO work must still FAIL, because deferring to the oracle still means
  the oracle is consulted. The sibling TestOracleAuthoritativeOnIncompleteExit
  applies a correct edit and would pass either way, so without this a regression
  that turned exit 4 into a blanket pass would go unnoticed.
- Broaden TestNonIncompleteExitStaysAuthoritative to a table over exit 1/3/130
  (crash/provider/interrupt), not just 1, so every named launder-risk code is
  covered.
- Tighten the NewTurnExecRunner doc: the verdict gate is the VerificationCommand
  (which runs the stamped OracleTest via go test), not OracleTest on its own.
- Drop the now-dead "if WorkspaceFixture != empty" branch: the fixtureless guard
  added earlier already guarantees it is set.

No behavior change; the 4-task glm-5.2 smoke stays 4/4.
2026-07-20 11:34:20 +05:30
KRATOSandKRATOS 8c6d3022bd feat(tui): add /undo as an alias for /rewind (#698) (#747)
* feat(tui): add /undo as an alias for /rewind (#698)

/undo was unregistered and fell through to commandUnknown. Add it to the
/rewind command's aliases (the same mechanism /quit, /find, /mcp-status use), so
it dispatches to the existing rewind handler with args intact.

* test(tui): cover the /undo numeric-sequence form (#698 review)

Add a parseCommand("/undo 123") assertion so both the latest and numeric
<sequence> forms of the /rewind contract are exercised through the alias.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 11:32:44 +05:30
KRATOSandKRATOS 974fc036c2 fix(oauth): refuse redirects on credential POSTs (#729) (#741)
Token exchange/refresh and the device authorization/poll POSTs validated only
the initial endpoint, then let the http.Client follow a 307/308 that replays the
form body (code, PKCE verifier, refresh_token, client_secret) to an unvalidated
origin.

Add withoutRedirects(): a shallow client copy whose CheckRedirect refuses every
redirect (ErrUnsafeRedirect). Applied at PostToken (covers ExchangeCode, Refresh,
and MCP which delegate to it), RequestDeviceCode, and pollDeviceOnce. The caller's
client is never mutated; legitimate token endpoints that return 200 are unaffected.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 08:19:37 +05:30
PierrunoYT 4cbd144d11 fix(providers): stop "provider not found" for env-derived profiles (#716)
* fix(providers): stop "provider not found" for env-derived profiles

* fix(providers): address env profile review feedback

* fix(providers): keep env JSON response schemas stable
2026-07-20 10:23:44 +08:00
euxaristiaandClaude Fable 5 c0b6b9e5c9 docs(readme): note govulncheck/golangci-lint install to GOPATH/bin (#711)
* docs(readme): note govulncheck/golangci-lint install to GOPATH/bin

The installed binaries land in $GOPATH/bin (default ~/go/bin), which must
be on PATH to run govulncheck directly. Document the export snippet so the
lint/security tooling from the contributor setup actually resolves.

* docs(readme): say "them" instead of singling out govulncheck

golangci-lint installs to the same $GOPATH/bin as govulncheck, so the
PATH note applies to both, not just the one named.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(readme): honor GOBIN and make the PATH snippet idempotent

go install writes to GOBIN when set, with GOPATH/bin as the fallback, and
rerunning the old snippet appended the same directory repeatedly.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-20 10:21:54 +08:00
PierrunoYT d74ceb1127 fix(tui): cache settled alt-screen transcript (#647)
* fix(tui): cache settled alt-screen transcript

* fix(tui): invalidate settled alt-screen cache on in-place status row updates

setDoctorStatusRow, setSandboxSetupStatusRow, and setCompactStatusRow all
mutate an already-flushed transcript row in place (m.transcript[i] = row)
without invalidating the alt-screen settled cache. Since none of frontier,
width, or row count change on such an update, settleTranscript's rebuild
guard never fires and the View fast path serves the stale cached snapshot -
e.g. the doctor connectivity spinner freezes on its first frame.

Zero altScreenSettledWidth on the in-place path, mirroring the existing
pattern in toggleTranscriptRow, so the next settle rebuilds the cache.

Addresses PR #647 review feedback from Vasanthdev2004.

* fix(tui): invalidate settled transcript cache

* fix(tui): invalidate settled file selection cards
2026-07-20 10:20:46 +08:00
Vasanth T fbf85984f6 fix(agent): stop write_stdin session_id probing thrash (#702) (#749)
A model that calls write_stdin with no live session probed sequential
session_ids (1, 2, 3, …), each failing, and the run ground on for a long
time before halting. The repeated-failure guard keys on a normalized,
80-char-truncated signature of the tool error, and the old message
"Unknown exec session_id %d" put the varying id inside that prefix, so
every probe produced a fresh signature and the 6x-same-error halt never
fired.

- Rewrite the unknown-session error so stable recovery guidance leads and
  the id trails past the signature window: probing now collapses to one
  signature and trips the halt, and the model is told how to recover
  (start an exec_command, or use the file-edit tools).
- Pin session_id's schema Minimum to 1 so the advertised schema matches
  the runtime floor (intArg min 1).
- system_prompt: state that session_id is only ever an id returned by a
  still-running exec_command and must never be guessed or probed.

Tests pin the id-invariance of the signature and that distinct-id probing
now halts at the guard's stop threshold, plus the schema floor and the
recovery text. The TUI "Sent input" label on a failed write_stdin
(issue criterion 4) is a separate cosmetic concern in a shared hot path
and is left as a follow-up.
2026-07-19 22:13:35 +05:30
Anandan 9242b9e037 Add transcript-grounded structured task state (#761)
* Add transcript-grounded structured task state

Project existing plan, tool-result, changed-file, verification, and completion events into a deterministic per-run snapshot.

Emit content-free aggregate snapshots to traces. Carry bounded objective context through compaction and ground completion checks only when the projection matches the transcript; mismatches retain transcript-derived behavior and drop stale carried state.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check

* Preserve task objective across repeated compaction

Keep the immutable run objective when plan calls have been elided, while admitting mutable task fields only when plan parity still matches the transcript.

Make plan parsing match update_plan rejection semantics, coalesce and cap task-state trace events, rename parity to its plan-only scope, use explicit nilable dependencies, and remove confirmable objective fingerprints from traces.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check
2026-07-19 21:38:58 +05:30
Anandan 2e267bdbee feat(tui): add isolated /btw conversations (#748)
* feat(tui): add isolated btw conversations

Add /btw with inline-question support, isolated non-resumable session forks, background main-run routing, and safe return behavior.

Refs #637

* test(tui): cover btw return controls

Preserve drafts when Ctrl+C is pressed during an active BTW run and cover returning to the parent through the documented /btw toggle.

* Address BTW isolation review feedback

* Reject explicit resumes of side sessions

* Harden BTW isolation lifecycle
2026-07-19 20:37:38 +05:30
Anandan 739a47e3ea perf(agent): preserve prompt cache prefixes (#760)
* perf(agent): preserve prompt cache prefixes

Build the system prompt once per run, fingerprint the exact emitted prompt and ordered tool definitions, and expose the full system-prompt hash in traces.

Add normalized and serialized two-turn regressions covering append-only messages, stable tools, and a stable session cache key.

* fix(tools): stabilize registry iteration order

Return registered tools in name order so provider-visible tool lists and prompt-prefix fingerprints cannot drift with Go map iteration. Add a regression covering the registry ordering contract.
2026-07-19 20:29:58 +05:30
KRATOSandKRATOS ce4a996ffa fix(oauth): validate discovered endpoints before merge/use (#511) (#739)
Discovery metadata was merged without the https/loopback endpoint rule that
configured endpoints already pass, letting a malicious issuer downgrade the
authorization/token/device/registration endpoint after config validation.

- Add exported ValidateEndpointURL as the single endpoint-safety rule;
  validateTokenEndpoint now delegates to it.
- Backstop in BuildAuthorizationURL (the choke point both the provider and MCP
  flows build their browser URL through).
- Validate each discovered endpoint before merge in the provider resolveEndpoints
  and validate resolved MCP metadata in both branches; fail closed on insecure.

Co-authored-by: KRATOS <[email protected]>
2026-07-19 09:56:32 +05:30
Vasanth T 378d538e24 perf(agent): execution profiles with one-shot posture escalation (PR10b+PR10c) (#740)
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c)

* fix(execprofile): harden profiles against review findings

- fast escalates on critical-risk mutations, not high: the sandbox marks
  every shell command high before command analysis, so a high trigger
  spent the one-shot on the first go test of any task
- escalation can now restore the provider-default effort a profile
  displaced (PostureEscalation.RestoreDefaultEffort; a plain empty
  target means leave untouched)
- TUI /turns while a profile is active pins the budget: disarms the
  escalation turn target (mirroring exec's explicit --max-turns rule)
  and profile reverts use touched bits instead of value equality, so
  explicit choices survive even when they coincide with profile values
- spec-draft runs get the same escalation policy as the main run
  instead of the displaced budget with no safety net
- bench validates and canonicalizes --exec-profile before spawning:
  an unknown name previously produced an exit-0 report full of
  millisecond usage-error walls
- turn summary prints exec-profile alongside mode; zero exec rejects
  an inline-empty --exec-profile=; balanced trace label documented and
  pinned as deliberate attribution

* fix(execprofile): explicit effort choices disarm the escalation effort restore

An explicit effort set AFTER profile selection could be cleared by a
mid-run escalation (the effort analog of the /turns pin, introduced by
RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on
a policy clone, and the spec-draft path only arms it when no explicit
--spec-reasoning-effort governs the draft.

* fix(execprofile): address review findings on the profile surfaces

- --use-spec projects away a profile's self-correct knob instead of
  silently arming an option the draft runner ignores (the explicit
  --self-correct --use-spec combination is already a parse-time error)
- RunTurnBench canonicalizes and validates ExecProfile at the library
  boundary, so a direct caller's casing/whitespace cannot fork report
  labels and an unknown name fails before anything runs
- reverting a profile whose displaced budget was zero clears
  ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have
  kept the removed profile's budget)
- the TUI effort-disarm test fails instead of skipping when the catalog
  stops filling low effort

* fix(tui): reconcile an active profile's effort fill on model switch

The fill is per-model, not a session preference: fast selected on a
model without low fills nothing, and switching to a model that supports
it should behave like selecting the profile there; the reverse switch
must not keep sending a level the destination does not support. Both
switch paths (the /model command and the cross-provider picker) now
re-derive the fill, leaving explicitly touched efforts alone and
keeping the escalation's effort restore in step. Also: the revert-env
test asserts key removal via LookupEnv, and applyProfileTurnBudget's
pinned-budget parameter is named and documented as covering mode
presets, not just the flag.

* fix(tui): void profile effort bookkeeping when the model-switch drop erases it

The /model path's pre-existing unsupported-effort drop ran before the
profile reconciliation and left contradictory state behind: a dropped
touched override kept blocking the profile from governing, and a
dropped profile fill left a stale applied value with the effort restore
still armed. The drop itself is load-bearing (the TUI run path forwards
the effort to the provider unfiltered), so instead of preserving a
known-unsupported level, the voided choice now resets the profile's
effort bookkeeping and the reconcile re-derives from scratch. The whole
sequence lives in one ring-parameterized helper driven by the switch
target's authoritative ring, unit-tested against synthetic rings for
both the void-then-refill arc and the supported-override survival.

* fix(tui): only drop an explicit effort when the target ring is authoritative

A nil ring meant both a catalog model with no effort controls and a
live-discovered custom model the catalog cannot vouch for. The drop now
requires an authoritative ring (a resolved catalog entry, whose ring may
legitimately be empty); an unknown ring preserves an explicit preference,
matching the cross-provider picker's behavior, while the profile's own
fill still applies only where support is known.
2026-07-19 00:53:17 +05:30
Vasanth T 015452c1c9 fix(perfbench): keep the stamped answer file out of negative oracle greps (#737)
* fix(perfbench): keep the stamped answer file out of negative oracle greps

* fix(perfbench): exclude stamped answer from positive oracle greps too
2026-07-18 22:54:02 +05:30
Vasanth T af875df587 perf(agent): posture-escalation signals and controller (PR10a) (#736)
* trace(perf): add posture escalation counter

* feat(agent): typed posture-escalation policy and tool-result risk field

* feat(agent): posture controller wiring and executed-risk stamping

* test(agent): cover posture controller, escalation act path, and risk stamping

* test(agent): pin zero risk on not-executed results

* fix(agent): wire uncertain-path escalation and validate risk thresholds
2026-07-18 22:32:02 +05:30
Vasanth T dbd9443014 fix(perfbench): absolutize the bench binary and make errored tasks first-class (#730)
* fix(perfbench): absolutize the bench binary and make errored tasks first-class

* fix(perfbench): state that errored tasks count as pass-rate failures

* fix(perfbench): align errored-task wording with the actual tier accounting

* chore(perfbench): never commit generated baseline reports
2026-07-18 21:39:52 +05:30
Anandan 0fae754460 docs: refresh repository agent guidelines (#734)
* docs: refresh repository agent guidelines

* docs: make validation guidance non-mutating
2026-07-18 21:37:36 +05:30
Vasanth T 2cc6f43bf7 docs(community): wire Discussions as the front door for questions and ideas (#732)
* docs(community): wire Discussions as the front door for questions and ideas

* docs(community): restore LF endings and make feature routing coherent
2026-07-18 21:34:58 +05:30
uma-prasadandCursor 77960229b8 fix(doctor): detect missing native binary during runtime checks (#450)
* fix(doctor): report missing npm native binary in wrapper

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

* fix(doctor): preserve JSON output for missing native binary

* fix(doctor): preserve doctor CLI behavior in wrapper fallback

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

* style: gofmt npmwrapper doctor fallback tests

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

* fix(doctor): reuse shared Bun recovery helpers in wrapper fallback

Extract bunRecoveryParagraph and related helpers so the doctor missing-binary text path and the generic wrapper bail share the same copy. Add regression tests with ZERO_WRAPPER_SIMULATE_BUN to guard against future drift.

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

---------

Co-authored-by: Cursor <[email protected]>
2026-07-18 22:49:19 +08:00
Vasanth T 60dc84e7a3 perf(openai): optimized turn session — background prewarm and prefix telemetry (PR8) (#723)
* trace(perf): add provider prewarm span and prefix-stability counters

* feat(openai): optimized turn session with prewarm and prefix telemetry

* feat(providers): gate optimized OpenAI turn sessions behind env flag

* feat(cli): wire optimized turn sessions into headless exec

* docs(readme): document the optimized OpenAI turn-session toggle

* fix(openai): include tool descriptions in prefix digest, harden gate and test bounds

* fix(openai): isolate prewarm tracing, skip non-reusable transports, fingerprint wire order and cache key

* test(openai): pin keep-alive transports in probe-expecting tests

* feat(providers): preserve resolved capabilities on switched-model fallback sessions
2026-07-18 14:24:50 +05:30
Vasanth T 30e2c3f7ff perf(providers): provider capabilities and default turn-session adapter (PR7) (#720)
* perf(providers): add provider capabilities and default turn-session adapter (PR7)

* fix(providers): project effective reasoning efforts into capabilities

* feat(agent): carry optimized turn sessions across mid-run model switches

* test(agent): cover swap-time session open failure
2026-07-18 09:33:07 +05:30
Anandan 18cce358a2 Add deterministic completion policy (#719)
Extract completion decisions from the agent loop into a feature-gated typed policy with complete, incomplete, and uncertain outcomes. Preserve bounded plan-stall nudges and allow at most one semantic acceptance check for self-correcting runs.

Tested: make build

Tested: make test

Tested: go fmt ./...

Tested: go vet ./...

Tested: make lint

Tested: govulncheck ./...

Note: repository-wide pinned golangci-lint reports 36 pre-existing unrelated findings; internal/agent/... reports 0 issues.
2026-07-18 00:29:58 +05:30
KRATOSandKRATOS e5670c427f perf(output): add token-aware semantic output budgeting (PR11) (#717)
* perf(output): add token-aware budget contract

* perf(output): add semantic retention policies

* perf(output): budget results after redaction

* perf(output): propagate tool truncation metadata

* perf(output): trace semantic budget decisions

* perf(output): classify core tool output

* docs(output): describe semantic output budgeting

* test(output): make spill fallback portable on windows

* fix(output): address semantic budgeting review findings

* test(output): make hook budgeting tests portable

* fix(output): retain oversized single-line file content

* fix(output): apply semantic budgets to captured output

* fix(output): preserve hook budgets and search paths

* fix(output): preserve alias and truncation metadata

* test(trace): validate output budget metadata

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-18 00:02:43 +05:30
KRATOSandKRATOS 31d45d5f14 perf(agent): concurrent read-only tool batches via capability gate (#715)
Upgrade the parallel tool planner from SideEffectRead probes to the PR5
CapabilitiesOf contract: EffectReadOnly + ThreadSafe + auto-allowed, with
resource-key conflict boundaries so same-path reads stay sequential.

Mark audited pure reads ThreadSafe (read_file, read_minified_file,
list_directory, glob, grep, skill, web_fetch). Glob/grep use scopedScan
directory keys (nil for workspace-wide "." so scans do not false-conflict).

Tests cover the capability gate, key conflicts, extendParallelRun windows,
and catalog ThreadSafe audit for concurrent-safe reads.

Co-authored-by: KRATOS <[email protected]>
2026-07-17 17:52:11 +05:30
Vasanth T 727ad4d321 perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness (#712)
* perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness

The baseline manifest's pass/fail contract had three holes that let a weak
or no-op agent read as a pass:

  - edit grep oracles were substring checks: a reworded line (e.g.
    fmt.Println("debug: starting") -> fmt.Println("starting up")) passed
    `! grep 'debug: starting'`.
  - refactor lived in the build tier with a non-positive `go build ./...`,
    so a no-op refactor passed.
  - the 24 read-only tasks (nav/longproc/longctx/parallel) had no oracle;
    a zero-exit only proved the turn ran.

This makes the tiers honest:

  - edit/refactor now carry a stamped oracle_test.go compiled by
    `go test ./...` — the Go compiler is the structural verifier, so a
    no-op refactor, a missing field, or a reworded-but-not-removed line
    fails to compile/run. The test is stamped into the fixture copy AFTER
    the agent run, so it can't interfere with the agent's own build or be
    pre-seen.
  - nav gains an answer-oracle: the harness captures the agent's final
    answer (.zero-answer.txt) and greps it for determinable facts. Count
    tasks (nav-04/05/08) use a structured `count: N` token so the oracle is
    tight rather than a loose prose-contains on "0".
  - refactor graduates from build to correctness (its oracle is now
    positive); nav graduates from latency-only to correctness. The build
    tier is empty (buildOnlyClasses: []).
  - longproc/longctx/parallel stay permanently latency-only — there is no
    deterministic oracle for "report whether the build succeeds" or
    "summarize a large file"; documented in MANIFEST.md.

Tier counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor 6),
build 0, latency 14. Total 48 unchanged.

TurnSchemaVersion 2 -> 3: the result shape is unchanged (only class-list
membership shifts) but correctnessPassRate now means something strictly
stronger, so the bump prevents a v2->v2 cross-version comparison from
misreading the jump as a model improvement.

Prerequisite fix: the mutating fixtures had no go.mod, so `go test ./...`
in the temp copy failed with "cannot find main module". Added go.mod per
fixture (edit/fix/refactor/nav) and moved copyFixture's temp dir one level
below the system temp root — Go ignores go.mod in direct children of the
temp dir (a hijack guard), which would have broken every compiler-backed
oracle. This also fixes the pre-existing fix/refactor oracles, which were
silently broken because the test suite used fake runners that never ran
go test.

Tests: stub-binary gating tests prove each oracle fails the wrong thing
(no-op refactor, missing field, wrong nav answer, reworded debug print) and
passes the right thing (real refactor, added field, correct answer); an
edit-01 test proves the scoped negative grep doesn't false-fail a correct
rename that leaves the doc comment. Aggregation, schema, and unit tests
cover the v3 tier model and the answer-capture helper.

* Address CodeRabbit review on PR #712: tighten oracles + isolation

- nav count oracles (nav-01/04/05/08): anchored line-exact
  `^count: N$` with the fixture's exact expected count, so a substring
  like "the count: 0" or a wrong count like "count: 3" fails instead
  of rubber-stamping. nav-01 added to the structured-count contract.
- stampOracleAndAnswer now errors when a task has an oracle or
  verification but no workspace fixture, so verification can't run in
  the caller's cwd with the oracle never compiled (false-pass closed).
- copyFixture uses a unique 0700 os.MkdirTemp parent under the temp
  root and nests the copy beneath it (grandchild of temp root), so the
  Go go.mod hijack guard is satisfied and cleanup removes the whole
  parent. Avoids a predictable shared world-readable dir.
- Rejection tests require a non-empty VerifyErr via a shared
  assertVerifyFailed helper, proving the failure came from
  verification rather than a harness error or a silent non-pass.
- Unify streamJSONExitCode + streamJSONFinalText behind one
  parseStreamJSON parser (behavior-preserving; scanner limits and
  malformed-event handling now live in one place).

* Address gnanam1990 review on PR #712: close count=0 gameability + stronger oracles

- nav count tasks (nav-04/05/08) had a count=0 ground truth, so an agent
  that never opened the workspace and always emitted "count: 0" passed
  three correctness tasks -- the exact pass-rate-misread-as-correctness
  failure this PR closes. nav-04 now has a real TestGreet (count 1) and
  nav-05 a real TODO (count 1); nav-01's file count moves to 5. nav-08
  stays at 0 (a real third-party import would need a network fetch and
  break the offline suite) but its oracle now requires the answer to name
  both stdlib imports (fmt from main.go, testing from main_test.go), so a
  blind count:0 or a main.go-only read fails. nav-08's prompt now asks the
  agent to enumerate and classify every import, so naming the stdlib
  imports is part of a correct answer, not a false-fail.
- refactor-05: stamp a behavioral oracle (TestGreetWrapped) so deleting
  both Wrapper and greetWrapped, or stubbing the caller to return "",
  fails to compile/run. The negative grep alone passed that wrong edit.
- refactor-04: replace the brittle exact-text shell grep with a stamped
  file-reading oracle using a spacing-tolerant regex, so a bare
  map[string]int decl under any spacing/:= variant can't dodge it, and
  the Stats type must be referenced in main.go.
- edit-05: also assert the "debug: starting" message text is absent, so
  a respell onto fmt.Printf / backticks / log.Println / println that
  still prints the message fails (the fmt.Println(" check missed those).
- v3 doc comments on ClassSummary / TurnBenchResult /
  FormatTurnBenchSummary now describe the v3 tiers (correctness =
  edit/fix/nav/refactor, build empty, latency = longproc/longctx/
  parallel) instead of the stale v2 model.
- stampOracleAndAnswer failure now mutates the existing outcome instead
  of returning a fresh one, so the already-parsed trace and trace issue
  survive a stamp error (attribution is preserved, pass/fail unchanged).
- Schema comment provenance: "PR #701" -> neutral "Phase 0 oracle
  hardening" (this PR is #712).
- New gating + satisfiable stub tests for refactor-04/05 and edit-05, and
  count-oracle zero-guess / inspection-proof tests for nav-04/05/08.

* Fix macOS CI: copyFixture isolation test was slash-sensitive

TestCopyFixtureIsolatesSourceFromMutation compared filepath.Dir(parent)
against os.TempDir() verbatim. On macOS $TMPDIR ends in a trailing slash
("/var/folders/.../T/") while filepath.Dir strips it, so the equality
false-failed even though the parent is genuinely a direct child of the
temp root. Linux (/tmp, no slash) and Windows (no slash in this Go
build) matched by accident.

The production copyFixture nesting is correct on all platforms (the
grandchild copy sits one level below the temp root, satisfying Go's
go.mod hijack guard); only the assertion was slash-sensitive. Normalize
both sides with filepath.Clean.

* Close nav-04/05 blind-count-one gameability (anandh8x review)

nav-04 and nav-05 had a count-only oracle (anchored '^count:1$'). Once the
ground truth became 1, an agent that never inspects the fixture and always
emits 'count: 1' passed both — the blind-count-one surface the prior
non-zero-ground-truth fix opened. Pair the count with a workspace-derived
named fact that proves inspection, mirroring nav-08's fmt+testing:

- nav-04 now also requires 'TestGreet' (the one test function in
  main_test.go) — a specific, non-generic name a blind agent can't produce
  without reading main.go to learn a greet function exists. Fully closes
  the blind-count-one surface.
- nav-05 now also requires 'main.go' (the file holding the one TODO).
  main.go is the conventional entrypoint and the most guessable filename, so
  this reduces rather than fully closes the surface — a blind 'count: 1, in
  main.go' can still pass. Accepted for Phase 0: the blind floor drops below
  100% (a bare 'count: 1' fails), so a model still moves the pass rate by
  inspecting. MANIFEST.md documents this residual honestly.

Two gating tests per task: RejectsBlindCountOne (bare 'count: 1' now fails)
and, for nav-05, RejectsSubstring (main.go present but unanchored count
still fails — parity with nav-04). Existing accept tests still pass (their
answers already name TestGreet / main.go).

Doc nits also fixed: MANIFEST.md #701 -> #712; refactor-05 removed from the
'no oracleTest' inventory (it now has TestGreetWrapped); the nav-01 named-
fact prose corrected to the three files the oracle actually anchors on;
test comment #701 -> #712.
2026-07-17 17:32:45 +05:30
github-actions[bot] e666395d47 chore(main): release 0.4.0 (#625)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-17 13:25:31 +08:00
PierrunoYTandClaude Sonnet 5 9043baedcf fix(sandbox): scrub dynamic credential env vars (#682)
* fix(sandbox): scrub dynamic credential env vars

* fix(sandbox): tolerate env assignments in configured sensitive keys

A config value mistakenly given as a full assignment (e.g.
"COMPANY_LLM_SECRET=...") would never match the real env key during
scrubbing, silently re-exposing the credential to sandboxed commands.
normalizeSensitiveEnvKeys now keeps only the name part before '=' and
re-trims it, per PR review.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): scrub credentials on direct/degraded command plans

directCommandPlan (used when the native sandbox backend is unavailable,
disabled, or not required, including the EnforcementDegraded fallback)
left Env nil whenever spec.Env was nil, so exec.Cmd inherited the full
caller environment unscrubbed. Configured apiKeyEnv credentials and
dynamically named ZERO_OAUTH_<NAME>_CLIENT_SECRET variables leaked
through that path even though the wrapped sandbox plans already
scrubbed them.

Route direct plans through scrubSensitiveEnv the same way wrapped
plans do, snapshotting os.Environ() when spec.Env is nil so scrubbing
still applies. Adds a regression test covering the degraded-fallback
path with both a configured and a dynamically named secret.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-17 13:04:12 +08:00
Ashwinhegde19 4d8c31cf16 fix(config): enforce MCP trust boundary so project config cannot override user disable (#609)
A user-level MCP server disable is now sticky. The project config
(a cloned repo's ./.zero/config.json) cannot re-enable a server the
user disabled, and cannot disable a server the user explicitly enabled.

The previous merge just assigned base.Disabled = next.Disabled, so a repo
that set disabled:true could silently defeat a user's explicit mcp enable
<server>. The guard is now symmetric via a mergeMCPServer canReenable
flag: once a higher-trust scope has explicitly set the disabled flag, a
lower-trust layer may not override that decision in either direction. Only
the CLI override scope (canReenable=true) may lift a sticky user decision.

The prior disabledSet-before-check ordering bug is also fixed: the
higher-trust scope's prior disabledSet/Disabled is captured before folding
in next's flag, so a project layer can still disable a default-enabled
server (e.g. firecrawl) rather than being silently ignored.

Ports the fix onto main's refactored MCP merge (internal/config/mcp_merge.go
and mergeProjectMCPConfig), which splits project-scope merging from the
general mergeMCPConfig path.

Fixes #512
2026-07-17 13:03:32 +08:00
PierrunoYTandClaude Sonnet 5 824ecdbcf9 fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts (#606)
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts

On interrupt, Run returns with ctx already canceled. The deferred
dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose
context.WithTimeout derives an already-canceled child context, so the
hook process never actually launches (recorded as timed-out/error in
the audit log). Since most sessions end via interrupt, sessionEnd
hooks were effectively unreliable.

Detach the sessionEnd dispatch from the run context's cancellation
with context.WithoutCancel so the dispatcher's own timeout still
applies without being short-circuited by the run's cancellation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* test(agent): fall back to GOROOT for hook regression

* test(agent): justify GOROOT fallback

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-17 13:03:14 +08:00
6b9c2f0c08 feat(aimlapi): AI/ML API provider with guided onboarding (top-up + key issuance) (#655)
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance)

Add the AI/ML API provider integration to the zero CLI: a guided TUI
onboarding sub-flow that takes an existing key or an email top-up and
writes the issued key into the provider profile, attributed to the
Gitlawb rebate partner.

- internal/aimlapi: OpenAI-compatible client, partner-checkout top-up,
  key validation, streaming, and endpoint config (Paths A/B/C).
- providercatalog / providermodelcatalog: register the aimlapi provider
  with default headers and models.
- tui: aimlapi onboarding screen wired into the provider wizard and
  onboarding flow.

Default endpoints point at production (auth/app/api/pay/aimlapi.com);
each is overridable via AIMLAPI_*_URL env vars.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): address review findings in onboarding flow

- topup: use errors.As for the 5xx retry check so a wrapped APIError
  still retries instead of aborting a paid checkout.
- onboarding: a Path-A top-up on an email with no account now lands on a
  single coherent "key saved, balance not topped up" screen instead of a
  green "ready" plus a red error.
- onboarding: an empty amount field is rejected instead of silently
  falling through to the $25 default.
- collapse the two identical top-up prompt constants into MsgTopUpPrompt
  and drop the dead amountPrompt() branch.
- reuse stdlib maps.Clone instead of a third bespoke header-copy helper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): retry transient poll errors and sharpen amount validation

* fix(aimlapi): retry transient poll errors and surface real amount errors

* docs(aimlapi): document exported client API; drop out-of-scope gitignore entry

Addresses the two CodeRabbit PR checks on #655:

- Add doc comments to the exported aimlapi client API (Client, APIError,
  the session/checkout/onboarding types and their methods) and
  ParseAmountUSD, to satisfy the docstring-coverage check.
- Remove the unrelated /zero.exe~ .gitignore entry that an earlier
  review-fix commit added; it is out of scope for the provider work, so
  the PR no longer touches .gitignore.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): render aimlapi row like other recommended providers

* fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation)

* fix(aimlapi): validate pasted API keys

* test(aimlapi): clarify pasted key validation

* test(aimlapi): cover key-balance low-balance transition

* fix(aimlapi): reject non-finite top-up amounts

* fix(cli): scope and canonicalize provider headers

* fix(tui): distinguish invalid verification codes

* feat(aimlapi): resume account-bound top-ups safely

* feat(tui): offer top-up for existing AIMLAPI keys

* feat(tui): reuse AIMLAPI env key on first run

* feat(tui): reuse configured AIMLAPI profiles

* test(aimlapi): trim redundant flow coverage

* fix(tui): preserve existing AIMLAPI profile metadata

* fix(tui): clone AIMLAPI headers in env fallback

* fix(aimlapi): address remaining review findings

* fix(aimlapi): harden onboarding lifecycle

* fix(aimlapi): preserve validated checkout context

* fix(aimlapi): harden checkout retry state

* fix(tui): clarify AIMLAPI configured choices

* fix(aimlapi): make checkout retries idempotent

* fix(aimlapi): validate checkout responses

* fix(providers): reconcile aimlapi catalog after rebase

---------

Co-authored-by: Lookoff123 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-17 12:49:38 +08:00
KRATOSandKRATOS 8ef8576df7 perf(tools): add explicit effect metadata for safe concurrency (#705)
* perf(tools): add explicit tool effect metadata

Introduce fail-closed EffectClass and ToolCapabilities on every built-in
tool so a later concurrent read-only batch PR can decide safety from
explicit metadata rather than tool names. Plugin and MCP tools stay
EffectUnknown. No concurrent execution is enabled.

* fix(tools): address review feedback on effect metadata

Harden capability normalization and resource keys before PR6 consumes them:

- Force ThreadSafe=false for every non-ReadOnly effect at read time
- Reject duplicate builtin names in ValidateBuiltinCatalog
- Coerce numeric session ids and accept "session" for process keys
- Strip path/query before userinfo in endpoint host parsing
- Wire apply_patch keys from unified-diff paths + cwd
- Reclassify bash as WorkspaceWrite (one-shot shell mutator)
- Expand regression tests for keys, normalization, and gates

* fix(tools): keep multiFileResourceKeys used by apply_patch keys

Satisfies unused lint after switching apply_patch to applyPatchResourceKeys.

* fix(tools): resolve CodeRabbit residual capability/key issues

- Join apply_patch diff paths under cwd for conflict keys
- Validate constructor-declared capabilities before normalization
- Unify session/process ID key extraction helper

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-17 12:48:36 +08:00
Anandanandanandh8x 1c5c6e78a8 feat(perf): emit prompt-prefix hash fingerprint per turn (#704)
Compute a seven-field SHA-256 fingerprint of the cacheable prompt
prefix (base instructions, confirmation policy, project context, skills,
tools, tool schemas, complete) and emit one prefix_hash event per turn
through the trace recorder. The complete hash is the SHA-256 of the
canonical join of the other six, so any sub-component drift is
observable both individually and in aggregate.

The decomposition exists so a downstream observer can detect which
sub-component of the prefix broke cache stability across turns: a
stable complete_prefix hash means the cacheable prefix is byte-
identical, and the sub-hash that changed names the culprit.

No behavior change for untraced runs. For OpenAI, the existing
prompt_cache_key (session ID) is unchanged; the fingerprint is purely
additive observability. The Anthropic cache_control breakpoint work
already in main is not touched.

Co-authored-by: anandh8x <[email protected]>
2026-07-17 12:47:53 +08:00
Leonardo Faoro 7f669f4550 feat(tui): Ctrl+X leader chords and emacs menu navigation (#699)
* feat(tui): Ctrl+X leader chords and emacs menu navigation

Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ?
chord map), and Ctrl+P/N previous/next selection in modals and pickers.

* fix(tui): drop Ctrl+X e /edit leader chord

/edit replaces the composer and would discard an in-progress draft; leave
it unbound so leader shortcuts stay draft-preserving.

* fix(tui): consume idle Ctrl+N as a no-op

Reserve Ctrl+N for emacs menu navigation so it never falls through to
remapped global bindings when no selection surface is open.

* docs: drop manual CHANGELOG entries for TUI shortcuts
2026-07-17 12:44:00 +08:00
euxaristia 2b42cd567b fix(tui): stop the composer cursor blinking while typing or unfocused (#672)
* fix(tui): stop the composer cursor blinking while typing or unfocused

The composer's cursor blinked on a fixed timer unconditionally, including
mid-keystroke, which made typing feel janky since the cursor's job is to mark
the current position. Gate the existing blink tick on two states instead:
force solid while a keystroke landed within the last 500ms (reusing the
existing lastCharTime tracker), and hide it entirely while the terminal
window is unfocused (new terminalFocused field, driven by tea.FocusMsg/
BlurMsg). Idle and focused keeps the prior blink behavior unchanged.

Also decouples view.ReportFocus from the notifier (it was previously gated on
m.notifier != nil, which is effectively always true in production but left
cursor-focus behavior fragile if that ever changes) so focus reporting is
requested unconditionally.

* fix(tui): apply caret visibility synchronously and in the argument-hint line

- A keypress, focus, or blur now updates composerCursorVisible in its own
  message handler instead of waiting for the next 530ms composerBlinkMsg
  tick, which could render a typed character caret-less, leave a visible
  caret in an unfocused terminal, or keep it hidden after a refocus.
- commandArgumentHintComposerLine takes the caret visibility state instead
  of painting its cursor cell unconditionally, so the required-argument
  hint path follows the same focus and blink contract as the main
  composer renderer.
- New tests assert the state immediately after each message, with no blink
  tick in between, and cover the argument-hint render in both states.

* fix(tui): apply the caret state on composer paste

A paste is the same immediate-input transition as a keypress: routePaste now
refreshes lastCharTime and sets the caret visible for both the bracketed
tea.PasteMsg and right-click clipboardReadMsg paths, so a paste right after
the blink phase hid the caret renders solid immediately instead of waiting
for a tick that would toggle off a stale idle state.
2026-07-17 12:43:30 +08:00
euxaristia 665bd4b3d0 WIP: feat(tui): TUI theme presets (Dune and Neon) (#634)
* draft(tui): add theme interface and Claude/Codex presets proposal

Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md.

* fix(tui): implement Claude/Codex themes on the existing palette system

theme.go had replaced the tuiTheme/buildTheme/palette architecture every
renderer depends on with a standalone Theme interface using the wrong
lipgloss import, breaking the build and orphaning the existing theme
registry, /theme command, and picker.

Revert theme.go to the working implementation and add claude and codex
as real palette entries in theme_palettes.go, following the file's own
extension pattern. Both pass the WCAG AA contrast and gray-ramp
invariants asserted across the whole registry, and are picked up
automatically by --theme, ZERO_THEME, /theme, and the picker.

Rewrite docs/THEMES.md to describe the actual palette-registry
architecture instead of the removed interface.

* fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests

* style(tui): gofmt theme_select_test.go (drop trailing whitespace)

The ubuntu smoke run fails on gofmt -l; this clears the only unformatted
file in the draft so CI goes green.

* test(tui): cover the CLI/env resolution path for the dune and neon presets

TestNewThemePresetsWired only checked lookupTheme and the IsDark
grouping. Add coverage for the actual --theme/ZERO_THEME precedence
path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each
preset's own palette rather than a built-in fallback.

* fix(tui): raise Dune/Neon palette contrast and fix theme docs

Darken dune's accent, blue, and red tokens so the selected-row caret,
local-model dot, and diff deletion text clear WCAG AA against selBg and
delBg, including after ANSI-256 downsampling. Also correct THEMES.md's
description of bare /theme (it opens a live-preview picker, not a
list), and add the dune/neon names to the ZERO_THEME/--theme tables in
README.md and README_ZH.md.

* fix(tui): keep Dune and Neon readable after ANSI-256 downsampling

- Dune's accent becomes #724028: still AA on selBg and panel in truecolor
  (5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears
  AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to
  #875f00 at 3.81:1. Blue already survived quantization at 4.67:1.
- Neon's diff bands move to hexes that quantize to distinct green and red
  xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000,
  addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of
  collapsing to the same grays, while keeping every truecolor invariant.
- New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way
  a 256-color terminal does and asserts the Dune selected-row pairs stay AA
  and the Neon bands keep their green/red identity and mutual separation.
- docs/THEMES.md is now linked from the Documentation sections of both
  READMEs, and the Unreleased changelog inventory says twelve themes and
  names dune and neon.

* fix(tui): keep Neon diff content AA after quantization, fix error border

- faintest becomes #74c468: line numbers now quantize to #87d75f, which
  clears AA on the xterm-green addBg (#005f00) where the old #58af69
  quantized to 2.95:1, while staying dimmer than faint so the gray ramp
  stays monotonic.
- addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word
  band's #008700 (was 4.29:1). Both rendered diff-content pairs are now
  asserted in the ANSI-256 test instead of documented as omissions.
- cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and
  3.65:1 quantized, meeting the 3:1 non-text component threshold, with the
  border surface asserted in both renderings.
- docs/THEMES.md no longer claims the extended invariants run against the
  whole registry: it now says new palettes must be added to the
  per-palette contrast tests or given equivalent coverage.
2026-07-17 12:38:51 +08:00
666 changed files with 77005 additions and 7546 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
+6
View File
@@ -1,5 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Gitlawb/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+13 -3
View File
@@ -1,12 +1,22 @@
name: Feature request
description: Propose a new capability or change
name: Feature request (previously discussed)
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Please do not begin implementation until the issue has been reviewed and marked with the
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
`issue-approved` label (see CONTRIBUTING.md).
- type: input
id: discussion
attributes:
label: Ideas discussion
description: Link to the Discussions Ideas thread (or note which maintainer asked for this issue).
validations:
required: true
- type: textarea
id: problem
attributes:
+33 -12
View File
@@ -30,6 +30,36 @@ jobs:
go-version-file: go.mod
cache: true
- name: Install Make on Windows
if: matrix.os == 'windows-latest'
shell: pwsh
run: choco install make --no-progress -y
- name: Validate quality targets with stock macOS Make
if: matrix.os == 'macos-latest'
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: grep.patternType
GIT_CONFIG_VALUE_0: fixed
run: make -n vulncheck
- name: Check vulnerabilities through native Windows Make
if: matrix.os == 'windows-latest'
shell: cmd
run: make vulncheck
- name: Check dead code through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make deadcode
- name: Run static lint through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make lint-static
- name: Check formatting
if: matrix.os == 'ubuntu-latest'
shell: bash
@@ -100,27 +130,18 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
# published advisory on an unrelated PR — intentional: do not ship known vulns.
- name: govulncheck
run: go run golang.org/x/vuln/cmd/[email protected] ./...
run: make vulncheck
# Advisory: reports functions unreachable from any cmd/* main so dormant
# code is visible in CI. Non-blocking while the dormant subsystems are
# still being wired or removed.
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
run: make deadcode
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
@@ -130,4 +151,4 @@ jobs:
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
run: make lint-static
+3
View File
@@ -55,3 +55,6 @@ docs/superpowers/
zero-termux-arm64
zero-linux-sandbox
zero-seccomp
# Generated benchmark reports are evidence for one configuration, never tree state
internal/perfbench/reports/*.json
+1 -1
View File
@@ -1 +1 @@
{".":"0.3.0"}
{".":"0.7.0"}
+105 -19
View File
@@ -1,27 +1,113 @@
---
description: Project guidelines for the open-source CLI coding agent (zero) repository.
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
alwaysApply: false
---
# Repository Guidelines for Zero
# Repository Conventions for Zero
These instructions apply to all work in this repository. For the user-facing
guide to extending Zero with specialists, hooks, plugins, MCP, and skills, see
[docs/EXTENDING.md](docs/EXTENDING.md).
This file outlines the project conventions and repository guidelines for coding agents when working on the `zero` repository. For the general guide on how to extend Zero (write specialist sub-agents, hooks, plugins, MCP, skills), see [docs/EXTENDING.md](docs/EXTENDING.md).
## 1. Contribution and Pull Request Rules
## 1. Project Conventions
- Before opening any pull request, **all contributors**—including maintainers,
community contributors, and coding agents—must read and follow
[CONTRIBUTING.md](CONTRIBUTING.md).
- Community pull requests require an existing parent issue with the
`issue-approved` label. Team members may open pull requests through the
internal development process described in `CONTRIBUTING.md`.
- Keep each change focused on the approved or assigned scope. Do not include
unrelated fixes, refactors, formatting churn, generated output, or existing
local changes in the same commit or pull request.
- Discuss new implementation languages, runtimes, major dependency changes,
and broad architectural rewrites with maintainers before implementation.
- Pull request descriptions must explain what changed and why, link the parent
issue when required, and list the tests or verification performed. Include
screenshots or a short recording for user-visible UI changes when practical.
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
- Unify functions/methods where possible to prevent codebase inflation. Prefer a single cross-platform function with conditional checks over duplicating helpers per platform.
## 2. Repository and Implementation Conventions
## 2. Guidelines for Coding Agents
- Use the Go version declared in `go.mod`. Do not hardcode a different local
toolchain version in scripts or documentation.
- Use the repository build and release commands (`make` and
`go run ./cmd/zero-release ...`) instead of inventing parallel build flows.
- Keep tests beside their source files (`foo_test.go` next to `foo.go`). Add a
regression test for behavior changes and run affected concurrent code under
the race detector.
- Never edit files under `third_party/`; they are vendored.
- Prefer one cross-platform function with small conditional checks over
duplicated platform-specific helpers when the behavior can remain unified.
- Do not commit generated benchmark reports from
`internal/perfbench/reports/*.json`; reports are configuration-specific
evidence, not repository state.
- Preserve the user's working tree. Do not overwrite, delete, stage, or commit
unrelated tracked or untracked files.
If you are an AI coding agent executing tasks in this repository, you **MUST** run all Go code quality and security checks before committing code or completing your task:
## 3. Required Validation
1. **Format & Vet**: Run `go fmt ./...` and `go vet ./...`.
2. **Lint**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
3. **Security**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
Run validation from the repository root before committing, opening a pull
request, or completing an implementation task:
If any check fails or cannot be run, do not ignore it. Prompt the user for instructions or setup assistance before proceeding.
1. **Formatting check**: `make fmt-check`. If it fails, format with
`go fmt ./...` (or `make fmt`) and run the check again.
2. **Vet**: `go vet ./...` (or `make vet`).
3. **Tests**: `go test ./...`. Use `make test` for the full race-enabled suite,
or run focused tests with `-race`, when concurrency is affected.
4. **Build**: `go run ./cmd/zero-release build`.
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
6. **Advisory lint**: `make lint-static`.
7. **Security**: `make vulncheck`.
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
tracked changes).
`make lint` currently runs the formatting check and `go vet`; it does **not**
run golangci-lint. The pinned golangci-lint job is advisory in CI while the
existing repository-wide backlog is cleaned up. Fix findings introduced by or
related to the current change. Unrelated pre-existing advisory findings do not
justify expanding a focused pull request; report them separately.
Formatting, vet, tests, build, smoke, diff hygiene, and govulncheck are hard
requirements. If a related check fails, fix it. If a required check cannot run
because of the environment or fails for an unrelated external reason, report
the exact failure and obtain maintainer direction rather than silently ignoring
it.
## 4. Common Review Blockers
These classes drive multi-round reviews. Fix them before requesting review:
- **Fresh base:** Rebase onto the current PR base (`main` or stacked target)
before review. A stale head that rolls back mainline commits is a hard
blocker, not a merge-time detail. Resolve conflicts by keeping upstream
behavior unless the PR intentionally changes it.
- **Platform truth:** Code and tests must pass on Linux, macOS, and Windows.
Compare paths after canonicalization (physical/`EvalSymlinks` form); never
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
paths on Windows). Prefer one cross-platform function with small `GOOS`
checks over duplicated helpers that drift.
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
success and error paths (including stderr). Fail closed on ownership, lease,
and permission checks. Do not rely on pre-open path resolution
(`EvalSymlinks` then open) for containment: that is a check-to-use race.
Bind containment at open/use time with rooted or handle-relative,
traversal-resistant APIs. If a no-follow API is used, apply it to every
traversed component and enforce the platform's reparse-point protections;
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Normalize before matching:** Any transform that REMOVES bytes without
leaving a gap is also a reassembler, so it has to run before whatever matches
on the result. Redacting by shape and then stripping control bytes lets a
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
fragments and be rejoined on the way out; comparing a path against a form
produced by the same resolver the kernel just used makes a redirect agree with
itself. Strip, decode, or canonicalize first, then match. The unsplit value
passing is not evidence, so the test needs a split case.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
the full read-modify-write sequence for lockfiles and shared stores.
- **Tests match the claim:** Every behavior or security-boundary change needs a
regression test, including the failure path. Path-sensitive logic needs at
least one non-Linux path case (or a hermetic fake that exercises the same
normalization).
- **Honest scope:** PR description, help text, and comments must match what
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.
+174 -4
View File
@@ -6,6 +6,174 @@ 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.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
### Features
* **agent:** add PermissionModePlan for interactive read-only planning ([#853](https://github.com/Gitlawb/zero/issues/853)) ([0176ee4](https://github.com/Gitlawb/zero/commit/0176ee49ffddcff6f7edf6ae2ade4967e5b22cc3))
* **providers:** live model lists for OpenRouter and OpenGateway ([#860](https://github.com/Gitlawb/zero/issues/860)) ([fb4f293](https://github.com/Gitlawb/zero/commit/fb4f293c20eb860ca388a10d22fd566ce282ba1f))
* **tools:** give tool results an image channel and add view_image ([#843](https://github.com/Gitlawb/zero/issues/843)) ([cd0eb19](https://github.com/Gitlawb/zero/commit/cd0eb194e4df4b844785796c0701216740a56344))
* **tui:** replace /retitle with local /rename ([#826](https://github.com/Gitlawb/zero/issues/826)) ([5886354](https://github.com/Gitlawb/zero/commit/588635487f22ef4d62d350140a219b7b8bb24119))
### Bug Fixes
* **agent:** end the additional_permissions retry loop ([#864](https://github.com/Gitlawb/zero/issues/864)) ([4ca4fa7](https://github.com/Gitlawb/zero/commit/4ca4fa75d31b139dbe55a3badf17da86892ab001))
* **agent:** tell the user a prefix approval leaves the sandbox ([#885](https://github.com/Gitlawb/zero/issues/885)) ([2ec5c3a](https://github.com/Gitlawb/zero/commit/2ec5c3ae6b8fb543887bdeafdb34c4da057b8d36))
* **cli:** report MCP servers that failed to start ([#822](https://github.com/Gitlawb/zero/issues/822)) ([#827](https://github.com/Gitlawb/zero/issues/827)) ([021281e](https://github.com/Gitlawb/zero/commit/021281ebbab3f2bbe6a77b2dab4bf121bcfe6b52))
* **lsp:** dispatch notifications off read loop ([#759](https://github.com/Gitlawb/zero/issues/759)) ([936aa4e](https://github.com/Gitlawb/zero/commit/936aa4e77cf49738560a89395b8ba4d22263a396))
* **mcp:** name the non-text blocks a tool result drops ([#874](https://github.com/Gitlawb/zero/issues/874)) ([027a670](https://github.com/Gitlawb/zero/commit/027a67080bea511b876c284bc911ef011eceb308))
* **sandbox:** deny reads of Zero credential stores ([#681](https://github.com/Gitlawb/zero/issues/681)) ([55fa05f](https://github.com/Gitlawb/zero/commit/55fa05f0ab5a01b495345d43464606cd19a31b4f))
* **sandbox:** stop the Windows write jail honouring Everyone-granted paths ([#865](https://github.com/Gitlawb/zero/issues/865)) ([91b413c](https://github.com/Gitlawb/zero/commit/91b413c514cc1607d5b4e7936780d40569239814))
* **secrets,worktrees:** fix secret redaction leakage and prune stale worktrees ([#855](https://github.com/Gitlawb/zero/issues/855)) ([ff62f73](https://github.com/Gitlawb/zero/commit/ff62f73b7f4dc5e2472db21acba43cf6140c06d1))
* **specialist:** make overwrites atomic ([#757](https://github.com/Gitlawb/zero/issues/757)) ([30f6d8f](https://github.com/Gitlawb/zero/commit/30f6d8f591a0b81638b4182ff56c86c8a50a5c70))
* suppress gosec G204 warning for trusted local config commands ([#848](https://github.com/Gitlawb/zero/issues/848)) ([c52db05](https://github.com/Gitlawb/zero/commit/c52db055b0b213758a33c5c5627a411e052f8de4))
* **tui:** a copied NUL byte no longer panics the TUI on Windows ([#876](https://github.com/Gitlawb/zero/issues/876)) ([cae0269](https://github.com/Gitlawb/zero/commit/cae0269e77e7a0a493cf799b41c653c9ff9a19de)), closes [#875](https://github.com/Gitlawb/zero/issues/875)
* **tui:** exclude permission waits from turn time ([#820](https://github.com/Gitlawb/zero/issues/820)) ([15eb16f](https://github.com/Gitlawb/zero/commit/15eb16f736f903c94203207032f98aacbcf06318))
* **tui:** fall back to CellMotion where AllMotion is unreliable ([#872](https://github.com/Gitlawb/zero/issues/872)) ([7fbd864](https://github.com/Gitlawb/zero/commit/7fbd864e91433441489902571ea394ebc5b7bd0d))
* **tui:** fill alt-screen frame with theme panel background ([#850](https://github.com/Gitlawb/zero/issues/850)) ([020c28e](https://github.com/Gitlawb/zero/commit/020c28eaf95e2f38853f99fc06a13cd1568bf3c9))
* **tui:** hide unavailable sidebar shortcut ([#821](https://github.com/Gitlawb/zero/issues/821)) ([845df6c](https://github.com/Gitlawb/zero/commit/845df6c7d28e02f475e90acfe838733eb37e6aa2))
* use OAuth credentials in provider health checks ([#828](https://github.com/Gitlawb/zero/issues/828)) ([d37de92](https://github.com/Gitlawb/zero/commit/d37de9214b1ba86542f4f5ef3b060bb96849763b))
### Performance Improvements
* compact common test output ([#846](https://github.com/Gitlawb/zero/issues/846)) ([edf660a](https://github.com/Gitlawb/zero/commit/edf660ab85131f90a452cee51dc2c3a5cf62d632))
* improve harness context efficiency ([#838](https://github.com/Gitlawb/zero/issues/838)) ([8e26679](https://github.com/Gitlawb/zero/commit/8e266797a8305e689806eadcf9698da2796481ab))
## [0.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
### Features
* add persistent session goals ([#803](https://github.com/Gitlawb/zero/issues/803)) ([5d1869e](https://github.com/Gitlawb/zero/commit/5d1869e7bc193c97e07418b57b823905c1151d5d))
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
### Bug Fixes
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
* **cron:** reserve job IDs atomically ([#686](https://github.com/Gitlawb/zero/issues/686)) ([d9b882e](https://github.com/Gitlawb/zero/commit/d9b882eb0a94f38de8447cb54b57860ccd8ed053))
* **lint:** resolve static analysis baseline ([#769](https://github.com/Gitlawb/zero/issues/769)) ([583e653](https://github.com/Gitlawb/zero/commit/583e6530bd849a8efcb2b88bbe8c297e8592e62c))
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
* **sandbox:** clarify scoped permission prompts ([#798](https://github.com/Gitlawb/zero/issues/798)) ([9fe02ae](https://github.com/Gitlawb/zero/commit/9fe02ae52e3234cb9dae76e0c5594a287ccbb52a))
* **sandbox:** deny reads of git's credential stores ([#815](https://github.com/Gitlawb/zero/issues/815)) ([#816](https://github.com/Gitlawb/zero/issues/816)) ([33c94ec](https://github.com/Gitlawb/zero/commit/33c94ec7a3e7987e6e36b5fd5e037ff4e6e84c26))
* **sandbox:** preserve user config and retry denied writes ([#801](https://github.com/Gitlawb/zero/issues/801)) ([81a5e6c](https://github.com/Gitlawb/zero/commit/81a5e6cb716ba4b7ecf7ff3ff261dfa51fd01098))
* **sandbox:** scrub the daemon token file pointer ([#677](https://github.com/Gitlawb/zero/issues/677)) ([#818](https://github.com/Gitlawb/zero/issues/818)) ([3e7692d](https://github.com/Gitlawb/zero/commit/3e7692d8f0885d346809869935c81a9cf055d89e))
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
### Features
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
### Bug Fixes
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
### Performance Improvements
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
### Features
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
* **cli:** wire MCP serve WorkspaceRoot and --add-dir scope ([#694](https://github.com/Gitlawb/zero/issues/694)) ([75a78e7](https://github.com/Gitlawb/zero/commit/75a78e715bf23154f69c8c79cb58eb4b535b2a2a))
* **npm:** ship the native binary as platform optionalDependencies ([#626](https://github.com/Gitlawb/zero/issues/626)) ([5e1405d](https://github.com/Gitlawb/zero/commit/5e1405d0b7abff5b3ccb3cfdb66d64d6d3322922))
* **perf:** emit prompt-prefix hash fingerprint per turn ([#704](https://github.com/Gitlawb/zero/issues/704)) ([1c5c6e7](https://github.com/Gitlawb/zero/commit/1c5c6e78a8a0e228bdf53d7be90934fbce9d98c3))
* **providers:** add AI/ML API preset (rebased onto main) ([#621](https://github.com/Gitlawb/zero/issues/621)) ([d66a9dd](https://github.com/Gitlawb/zero/commit/d66a9dda69c32aa59f4bea903cfefe00d4b7adef))
* **providers:** refresh MiniMax model coverage ([#665](https://github.com/Gitlawb/zero/issues/665)) ([fa3052a](https://github.com/Gitlawb/zero/commit/fa3052a1422a4ad30a3a6295829564f42dee31a8))
* **skills:** discover shared ~/.agents/skills with multi-root skill loading ([#696](https://github.com/Gitlawb/zero/issues/696)) ([7d57999](https://github.com/Gitlawb/zero/commit/7d579996b43741a2e57e3e38fcdd8484fcfc34e9))
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
### Bug Fixes
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
* **cron:** prevent cron job Mutate from clobbering concurrent updates ([#630](https://github.com/Gitlawb/zero/issues/630)) ([e4bd703](https://github.com/Gitlawb/zero/commit/e4bd703cfb28dab2dfa3c2ddba46237e1bb2e164))
* **daemon:** handle os.ErrPermission as collision during O_EXCL lock creation ([#616](https://github.com/Gitlawb/zero/issues/616)) ([8ea5384](https://github.com/Gitlawb/zero/commit/8ea53841a5d54c37a153779ae86bea010659433c))
* **exec:** stop false INCOMPLETE downgrades on conversational final messages ([#608](https://github.com/Gitlawb/zero/issues/608)) ([b6117af](https://github.com/Gitlawb/zero/commit/b6117af86d6bc87a4ee66910e99d76cb16b03fed))
* harden MCP credential boundaries ([#597](https://github.com/Gitlawb/zero/issues/597)) ([fdddb05](https://github.com/Gitlawb/zero/commit/fdddb05ba84b1600ae6c3a20028bf83afe474c44))
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
* **openai:** omit prompt_cache_key for openai-compatible providers ([#636](https://github.com/Gitlawb/zero/issues/636)) ([1af5882](https://github.com/Gitlawb/zero/commit/1af58828eb3c22567599c000736c913a290959d2)), closes [#624](https://github.com/Gitlawb/zero/issues/624)
* **plugins:** resolve relative executable paths against plugin root ([#627](https://github.com/Gitlawb/zero/issues/627)) ([2efe6d5](https://github.com/Gitlawb/zero/commit/2efe6d539e29374b3ef39c2290bdea81f33a228b))
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
* **tui:** resolve pending askUser callbacks to prevent runner hangs ([#620](https://github.com/Gitlawb/zero/issues/620)) ([aa73a76](https://github.com/Gitlawb/zero/commit/aa73a76f1bd1b6fe97bac2fbff2d61b7474139f2))
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
### Performance Improvements
* **tools:** add explicit effect metadata for safe concurrency ([#705](https://github.com/Gitlawb/zero/issues/705)) ([8ef8576](https://github.com/Gitlawb/zero/commit/8ef8576df7d0775a5b815bc0776a794cadb75c34))
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
@@ -134,10 +302,12 @@ tagged. Until then, source builds report the version `dev`.
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`,
`solarized-light`, `dune`, and `neon` — selectable via `/theme <name>`, `--theme <name>`, or
`ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally
audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light
theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
+48 -5
View File
@@ -8,6 +8,13 @@ stabilizes. Community participation is still welcome, especially through bug
reports, feature requests, reproduction details, logs, and discussion on existing
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
Please read this policy before opening a pull request. Pull requests that do not
follow it may be closed without review.
@@ -33,7 +40,11 @@ unless the issue has been explicitly approved for outside contribution.
There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Open a feature request describing the problem, use case, and expected behavior.
2. Propose a feature in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
3. Add useful context to an existing issue, such as logs, screenshots, examples,
affected versions, or reproduction cases.
@@ -46,7 +57,8 @@ All community pull requests require an approved parent issue.
Before starting implementation work:
1. Open an issue using the appropriate issue template.
1. For a bug, open an issue using the bug-report template. For a feature, start
in Discussions Ideas; the feature-request issue comes after the discussion.
2. Describe the bug, feature request, or functional change clearly.
3. Wait for the core team to review the issue.
4. Do not open a pull request unless the issue has the `issue-approved` label.
@@ -55,6 +67,28 @@ An approved issue means only that the specific issue has been accepted for work.
It does not approve unrelated future work, future pull requests, or future issues
from the same author. Each issue is reviewed case by case.
## Contribution Volume and Batching
To keep triage and review sustainable while the project stabilizes, please keep
your contribution flow to a size the core team can realistically work through:
- One issue per bug. File each defect as its own issue with its own
reproduction. Please do not bundle several unrelated findings into a single
"audit" issue; separate issues are far easier to triage, approve, and close
individually.
- Keep only a few items open at a time. If you already have several issues or
pull requests awaiting review, let those move through the queue before opening
more. A large simultaneous burst from one contributor slows review for
everyone, even when each item is valid.
- AI-assisted contributions are welcome, held to the same bar. Automated audits
and generated patches are fine, but each issue and pull request must be
individually verified, scoped, and reproducible before you open it. Please do
not post bulk machine-generated findings unfiltered.
These apply to all contributors and are about review bandwidth, not the value of
any single report. Maintainers may ask you to consolidate, split, or hold
submissions to keep the queue manageable.
## Pull Request Requirements
Before opening a community pull request, make sure all of the following are true:
@@ -133,7 +167,12 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
When filing a feature request, please include:
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
include:
- The problem you are trying to solve.
- The behavior or functionality you are requesting.
@@ -156,5 +195,9 @@ contribution phase.
## Getting Help
If you are unsure whether a change is appropriate, open an issue first and ask.
Please keep discussion focused, specific, and respectful of maintainer time.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+26 -4
View File
@@ -1,7 +1,13 @@
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
# with `make` — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
GO_TOOLCHAIN = go$(GO_VERSION)
DEADCODE_VERSION := v0.46.0
GOLANGCI_LINT_VERSION := v2.12.2
GOVULNCHECK_VERSION := v1.3.0
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
# Build the main CLI binary into ./zero.
build:
@@ -33,6 +39,22 @@ fmt-check:
# Lint = formatting check + vet (no extra tooling required).
lint: fmt-check vet
# Versioned tools select the toolchain from their own modules when invoked with
# package@version. Read this module's go directive directly, without invoking
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
# The target-specific export is shell-independent.
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
deadcode:
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
vulncheck:
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
tidy:
go mod tidy
@@ -55,4 +77,4 @@ baseline: build
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
+57 -14
View File
@@ -8,6 +8,7 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -131,6 +132,7 @@ export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -147,6 +149,12 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
zero providers setup longcat --set-active
```
To configure Fireworks AI directly, run:
```bash
zero providers setup fireworks --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -191,9 +199,11 @@ Useful controls:
|---|---|
| `Enter` | send the prompt |
| `/` | open slash-command suggestions |
| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) |
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
| `Shift+Tab` | cycle permission mode |
| `Ctrl+B` | show/hide the sidebar |
| `Ctrl+C` | cancel or exit |
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
Common slash commands:
@@ -203,6 +213,8 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/new` | start a fresh session in place (previous session stays on disk) |
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
@@ -298,7 +310,8 @@ zero verify detect and run local verification checks
zero changes inspect and commit local git changes
zero usage token usage and estimated cost
zero cron scheduled agent jobs
zero update check for newer releases
zero update --check check for newer releases
zero upgrade download, verify, and install the latest release
```
## Extending Zero
@@ -339,7 +352,7 @@ manifest.
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
@@ -356,23 +369,32 @@ go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
session (background connection prewarm + request-prefix telemetry) for headless
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
variable unset and set.
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
2. **Vetting**: Run `go vet ./...` (or `make vet`).
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
2. **Vetting**: Run `make vet` (or `go vet ./...`).
3. **Linting**: Run `make lint-static`.
4. **Vulnerability Scan**: Run `make vulncheck`.
If you prefer to install these tools globally on your path, you can run:
Use the repository-managed targets rather than globally installed binaries:
the targets apply the module's required Go toolchain as well as the reviewed,
pinned tool versions.
The installed binaries land in `$GOBIN` when it is set, otherwise in
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
run them directly. If it isn't, add it:
```bash
# Install golangci-lint
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
# Install govulncheck
go install golang.org/x/vuln/cmd/[email protected]
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
```
### Cross-Compile Examples
@@ -386,6 +408,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [Install](docs/INSTALL.md)
- [Update flow](docs/UPDATE.md)
- [Themes](docs/THEMES.md)
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
- [Specialists](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
@@ -393,6 +416,26 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [Performance](docs/PERFORMANCE.md)
- [Agent evals](docs/AGENT_EVALS.md)
## Community
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
+20 -2
View File
@@ -8,6 +8,7 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -141,6 +142,7 @@ zero
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
@@ -226,7 +228,8 @@ zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
zero update 检查更新版本
zero update --check 检查更新版本
zero upgrade 下载、验证并安装最新版本
```
## 外观和无障碍
@@ -234,7 +237,7 @@ zero update 检查更新版本
| 控制 | 效果 |
|---|---|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
@@ -261,6 +264,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [安装](docs/INSTALL.md)
- [更新流程](docs/UPDATE.md)
- [主题](docs/THEMES.md)
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
- [专家](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
@@ -268,6 +272,20 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
## 社区
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
+36
View File
@@ -0,0 +1,36 @@
# Getting help with Zero
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
- `zero --version` output
- your OS (Windows, macOS, Linux) and how you installed Zero (npm, install
script, source)
- the provider and model you are using (`zero providers list`)
- `zero doctor` output for setup problems
- the exact command you ran and the exact error text
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
(see SECURITY.md).
+159 -15
View File
@@ -55,6 +55,133 @@ function localControlHelperManifest(packageRoot) {
return JSON.stringify({ version: 1, helpers });
}
// Mirrors internal/cli/observability.go parseDoctorArgs + writeDoctorHelp so the
// wrapper's missing-binary doctor fallback matches the Go doctor CLI surface.
const DOCTOR_HELP = `Usage:
zero doctor [flags]
Runs Go backend health checks for config and provider setup.
Flags:
--json Print JSON report
--connectivity Include provider endpoint connectivity probe when available
-h, --help Show this help
`;
const EXIT_USAGE = 2;
function installedByBun() {
if (process.env.ZERO_WRAPPER_SIMULATE_BUN === '1') {
return true;
}
return process.execPath.includes('bun') || !!process.versions?.bun;
}
function bunRecoveryParagraph() {
return (
'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
);
}
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
function missingNativeRecoveryParagraphs(postinstallScript) {
const ranByBun = installedByBun();
return (
missingBinaryContextParagraph() +
'\n' +
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
(ranByBun ? bunRecoveryParagraph() : '') +
buildFromSourceParagraph()
);
}
function formatGenericMissingNativeBinaryMessage(postinstallScript) {
return (
'[zero] No native binary is available for this install.\n' +
missingNativeRecoveryParagraphs(postinstallScript)
);
}
function parseDoctorArgs(args) {
let json = false;
for (const arg of args) {
switch (arg) {
case '-h':
case '--help':
case 'help':
return { kind: 'help' };
case '--json':
json = true;
break;
case '--connectivity':
break;
default:
return { kind: 'error', message: `unknown doctor flag ${JSON.stringify(arg)}` };
}
}
return { kind: 'run', json };
}
function missingNativeDoctorJSONReport(postinstallScript) {
return {
generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
ok: false,
checks: [
{
id: 'runtime.go',
label: 'Go runtime',
status: 'fail',
message: 'Native zero binary is missing next to the npm wrapper.',
details: {
remedy: `node "${postinstallScript}"`,
},
},
],
};
}
function missingNativeDoctorTextReport(postinstallScript) {
return (
'Zero doctor report (' +
new Date().toISOString() +
')\n' +
'Overall: fail\n' +
'[fail] runtime.go - Native zero binary is missing next to the npm wrapper.\n' +
' remedy: node "' +
postinstallScript +
'"\n' +
'\n' +
missingNativeRecoveryParagraphs(postinstallScript)
);
}
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
@@ -114,21 +241,38 @@ function resolveNativeBinary() {
const nativePath = resolveNativeBinary();
if (!nativePath) {
console.error(
'[zero] No native binary is available for this install.\n' +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.\n' +
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
'\n' +
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
);
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
// `zero doctor` is the diagnostic command: when the native binary is missing
// it's the one invocation that must NOT bail with the generic wrapper error,
// because that's exactly the blind alley issue #405 reports. Instead, surface
// a doctor-shaped FAIL line for the runtime so the user's diagnostic finds the
// real cause. We branch on a literal 'doctor' subcommand only (matching `zero
// doctor` and `zero doctor --connectivity`), preserving the existing bail for
// every other invocation (exec, providers list, TUI, --version, etc.).
const argv = process.argv.slice(2);
const isDoctor = argv.length > 0 && argv[0] === 'doctor';
if (isDoctor) {
const parsed = parseDoctorArgs(argv.slice(1));
if (parsed.kind === 'help') {
process.stdout.write(DOCTOR_HELP);
process.exit(0);
}
if (parsed.kind === 'error') {
process.stderr.write(`[zero] ${parsed.message}\n`);
process.exit(EXIT_USAGE);
}
if (parsed.json) {
process.stdout.write(JSON.stringify(missingNativeDoctorJSONReport(postinstallScript), null, 2) + '\n');
process.exit(1);
}
process.stdout.write(missingNativeDoctorTextReport(postinstallScript) + '\n');
process.exit(1);
}
console.error(formatGenericMissingNativeBinaryMessage(postinstallScript));
process.exit(1);
}
+17
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
@@ -92,3 +94,18 @@ func TestHelpText(t *testing.T) {
func emptyEnv(string) string {
return ""
}
func TestTurnExitCodeFailsWhenEveryTaskErrored(t *testing.T) {
var stderr bytes.Buffer
allErrored := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 3}
if code := turnExitCode(allErrored, &stderr); code != 1 {
t.Fatalf("exit=%d, want 1 when every task errored", code)
}
if !strings.Contains(stderr.String(), "no accepted benchmark sample") {
t.Fatalf("stderr missing explanation: %q", stderr.String())
}
partial := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 2}
if code := turnExitCode(partial, &stderr); code != 0 {
t.Fatalf("exit=%d, want 0 for partial errors (summary surfaces them)", code)
}
}
+42 -1
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
)
@@ -20,6 +21,7 @@ type turnOptions struct {
SuitePath string
Model string
Mode string
ExecProfile string
SelfCorrect bool
Binary string
Iterations int
@@ -64,6 +66,7 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
Model: options.Model,
Mode: options.Mode,
ExecProfile: options.ExecProfile,
SelfCorrect: options.SelfCorrect,
Version: options.Version,
Commit: options.Commit,
@@ -86,9 +89,23 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
return 0
return turnExitCode(result, stderr)
}
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
return turnExitCode(result, stderr)
}
// turnExitCode fails the command when every attempted task errored (no
// iteration produced an accepted benchmark sample): such a report measures
// nothing, and exiting 0 would let a broken configuration (missing binary, bad
// path, failing harness step) pass as a clean baseline. Partial errors keep
// exit 0 — the summary surfaces them loudly and the surviving samples are
// still valid measurements.
func turnExitCode(result perfbench.TurnBenchResult, stderr io.Writer) int {
if result.TasksAttempted > 0 && result.TasksErrored == result.TasksAttempted {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
return 1
}
return 0
}
@@ -123,6 +140,13 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
}
options.Mode = value
index = next
case "--exec-profile":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.ExecProfile = value
index = next
case "--binary":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
@@ -195,6 +219,20 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
return options, fmt.Errorf("--model is required (or pass --dry-run)")
}
// Validate the profile here, before anything spawns: an unknown name would
// otherwise make every child exit with a usage error in milliseconds, and
// those near-zero walls would be recorded as valid latency samples in an
// exit-0 report — exactly the misread-as-improvement trap the harness
// closed for spawn failures. Normalizing to the catalog name also keeps
// the stamped execProfile canonical (Lookup is case-insensitive), so two
// captures of the same posture always compare equal.
if raw := strings.TrimSpace(options.ExecProfile); raw != "" {
profile, ok := execprofile.Lookup(raw)
if !ok {
return options, fmt.Errorf("unknown execution profile %q for --exec-profile. Valid profiles: %s", raw, strings.Join(execprofile.Names(), ", "))
}
options.ExecProfile = profile.Name
}
return options, nil
}
@@ -225,6 +263,9 @@ func turnHelpText() string {
" --suite <path> Task set JSON file (required)",
" --model <model> Model to run (required unless --dry-run)",
" --mode <name> Exec mode preset to apply",
" --exec-profile <name>",
" Execution profile for every task (fast|balanced|thorough);",
" forwarded to zero exec and stamped into the result",
" --self-correct Enable the post-edit verify-and-correct loop",
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
" --iterations <n> Times to run each task (default: 1)",
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"strings"
"testing"
)
func TestParseTurnArgsExecProfile(t *testing.T) {
getenv := func(string) string { return "" }
options, err := parseTurnArgs([]string{
"--suite", "suite.json",
"--model", "m",
"--exec-profile", "fast",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs: %v", err)
}
if options.ExecProfile != "fast" {
t.Fatalf("ExecProfile = %q, want fast", options.ExecProfile)
}
// Inline form.
options, err = parseTurnArgs([]string{
"--suite=suite.json",
"--model=m",
"--exec-profile=thorough",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs inline: %v", err)
}
if options.ExecProfile != "thorough" {
t.Fatalf("ExecProfile = %q, want thorough", options.ExecProfile)
}
}
// The bench validates the profile BEFORE spawning anything: an unknown name
// would otherwise make every child exit with a usage error in milliseconds
// and record those near-zero walls as valid latency samples in an exit-0
// report. Lookup normalization also keeps the stamped name canonical so two
// captures of the same posture compare equal.
func TestParseTurnArgsExecProfileValidatesAndNormalizes(t *testing.T) {
getenv := func(string) string { return "" }
options, err := parseTurnArgs([]string{
"--suite", "suite.json", "--model", "m", "--exec-profile", "FAST",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs: %v", err)
}
if options.ExecProfile != "fast" {
t.Fatalf("ExecProfile = %q, want the canonical fast", options.ExecProfile)
}
_, err = parseTurnArgs([]string{
"--suite", "suite.json", "--model", "m", "--exec-profile", "blanced",
}, getenv)
if err == nil {
t.Fatal("an unknown profile must fail parse, before anything spawns")
}
for _, name := range []string{"balanced", "fast", "thorough"} {
if !strings.Contains(err.Error(), name) {
t.Fatalf("error must list %q, got %q", name, err.Error())
}
}
}
+3 -2
View File
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
```text
~/.local/share/zero/skills/ # primary (install/remove/lock target)
~/.local/share/zero/skills/ # primary (install/remove target)
run-benchmarks/
SKILL.md
write-changelog/
@@ -289,6 +289,7 @@ Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins remove github-pr-review # alias: rm
```
+17 -4
View File
@@ -272,7 +272,8 @@ sequenceDiagram
Agent->>Tool: execute(ctx, args)
Tool-->>Agent: output, error, metadata
Agent->>Hooks: afterTool hooks
Agent->>Agent: redact, truncate, classify success/failure
Agent->>Agent: redact secrets, apply semantic output budget, enforce byte ceiling
Agent->>Agent: classify success/failure
Agent-->>Surface: OnToolResult callback
Agent->>Transcript: append tool result message
end
@@ -604,10 +605,22 @@ flowchart TD
Prompt -- Yes --> Decision[Permission callback]
Prompt -- No --> Run[Registry.RunWithOptions]
Decision --> Run
Run --> Redact[Redact secrets + enforce output ceiling]
Redact --> Message[Return tool result to model]
Run --> Redact[Redact secrets]
Redact --> Budget[Token-aware semantic output budget]
Budget --> Ceiling[Existing hard byte ceiling]
Ceiling --> Message[Return one tool result to model]
```
Oversized results use deterministic, provider-neutral estimated-token budgets.
Policies retain useful structure for files, search matches, tests, process logs,
diffs, and worker conclusions; tools without a declared category use a UTF-8-safe
head/tail fallback. The estimate is intentionally conservative for non-ASCII
text and is not exact provider tokenization. Existing byte ceilings remain the
authoritative safety limit. When the existing spill mechanism can persist the
complete redacted text received by the budgeting layer, the result includes its
safe spill reference. This does not imply capture of subprocess bytes already
discarded by a tool's established internal buffer.
Core tool groups include:
- **Read-only tools**: file reads, directory listing, glob, grep, LSP navigation,
@@ -736,7 +749,7 @@ flowchart TD
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
`LoadFromRoots`; single-root `Load` remains for install/write.
- **Hooks** can observe or block tool lifecycle events.
## End-to-End Data Flow
+3 -2
View File
@@ -234,10 +234,11 @@ directory.
## Updating
Check for a newer release:
Check for a newer release, then download, verify, and install it in place:
```bash
zero update --check
zero upgrade
```
Then reinstall with npm or rerun the install script for the version you want.
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
+42
View File
@@ -138,3 +138,45 @@ output or stop a still-running task by id.
If Zero is restarted while a background task is still marked `running`, the new
manager marks that task `error` and clears its PID. This avoids sending
`TaskStop` to a stale PID that may now belong to an unrelated process.
## Recovering an Interrupted Overwrite
Zero writes and flushes a complete temporary file before publishing an overwrite,
so a write failure before publication leaves the existing manifest unchanged
instead of truncating it. On Unix, publication uses a same-directory rename and
preserves the existing file's permission bits.
On Windows, Zero uses `ReplaceFileW` to preserve the destination DACL instead of
silently replacing it with the temporary file's inherited DACL. `ReplaceFileW`
is not observer-atomic: another process can briefly observe the destination path
as absent during replacement. Zero serializes specialist loads and managed
mutations within one process, but cannot synchronize external processes or
editors.
Windows errors 1176 (`ERROR_UNABLE_TO_MOVE_REPLACEMENT`) and 1177
(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) are partial replacement failures. With
Zero's managed backup, 1176 leaves the original names intact and needs no manual
recovery. For 1177, Zero has moved the original aside and tries to move it back.
That rollback almost always succeeds, and the failed write changes nothing.
If the rollback itself fails — typically because another process is holding a
lock on the file — the original is not lost, but it is left under a name Zero
does not read:
```text
<specialist dir>/.zero-replace-<random>.backup
```
Only `*.md` files are loaded as specialists, so until that file is renamed the
specialist will not appear in `zero specialist list` or resolve by name. Zero's
error message includes both the backup path and destination path. Recover by
closing whatever holds the lock and renaming the backup back:
```powershell
Move-Item .zero-replace-<random>.backup <name>.md
```
A `.zero-replace-*.backup` can also linger after a *successful* overwrite if the
backup could not be deleted afterward. That case is reported as a warning rather
than an error — the new manifest is already in place, and the leftover file is
safe to delete.
+39
View File
@@ -0,0 +1,39 @@
# TUI Themes
Zero's TUI ships a set of built-in color themes. Pick one with `--theme <name>`,
the `ZERO_THEME` environment variable, or the `/theme <name>` command while
running. `auto` (the default) follows the terminal's detected background.
Run `/theme` with no argument to open a picker: move through the list to
live-preview each theme, press Enter to apply the highlighted one, or Esc to
cancel and restore the previously active theme. Run `/theme list` to print
the active theme and the registered names without opening the picker.
## Dune (`dune`)
A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft
amber accent.
## Neon (`neon`)
A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent.
## Adding a theme
Every theme is a `palette` (a table of color hex tokens) plus one entry in
`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in
`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style`
set every renderer reads from the active `zeroTheme`. Adding a theme means
adding a new `palette{...}` literal, a `themeRegistry` entry, and test
coverage for the new palette (see below).
Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic
WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the
selected-row band for every entry. The rendered-surface invariants beyond
those (permission surfaces, selected-row secondary text, diff gutters, and
the xterm-256 downsampling checks) are asserted per palette, not against the
whole registry: `TestExtendedThemeContrastInvariants` and
`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new
theme must be added to those tests (or given equivalent palette-specific
assertions), or CI can stay green while its permission, selected-row, and
diff surfaces ship unreadable.
+75 -7
View File
@@ -1,16 +1,23 @@
# Update Flow
`zero update --check` checks the latest GitHub release and compares it with the
local CLI version.
local CLI version. `zero update --apply` (or its shorthand, `zero upgrade`)
downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --target windows-x64
zero upgrade
zero update --apply
```
The command is intentionally check-only:
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--check` is check-only:
- It does not replace the running binary.
- It exits with code `0` when the check succeeds, even when an update is
@@ -19,14 +26,35 @@ The command is intentionally check-only:
- `--json` prints the same result in a machine-readable format for scripts and
CI.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
- On Windows, the running executable is renamed aside and cleaned up on the
next `zero update --apply` or `zero upgrade`, since it can't be overwritten
while running.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
also write progress output to stdout, so neither
`zero update --apply --json` nor `zero upgrade --json` is guaranteed to
produce a single parseable JSON document. Use `--check --json` for
machine-readable automation.
Useful flags:
| Flag | Purpose |
|---|---|
| `--repo <owner/repo>` | Check another GitHub repository. |
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--endpoint <url\|owner/repo>` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
| `--timeout <duration>` | Override the default release check timeout. |
| `--target <platform-arch>` | Validate release metadata for another supported target. |
| `--target <platform-arch>` | Validate release metadata for another supported target (`--check` only). |
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
@@ -40,5 +68,45 @@ Endpoint resolution order:
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
before reinstalling.
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
instead of reinstalling.
## Windows recovery state (standalone installs)
This section describes Windows only. On Linux and macOS a standalone update
renames the staged file directly over the executable path through the
installation directory's file descriptor, so the replacement is atomic and no
aside copy, marker, or recovery record is ever created — a failed update leaves
the previous binary in place and nothing to resolve.
On Windows, a running executable cannot be replaced in place, so the previous
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
records that exact file — bound to its filesystem identity, in per-user state
outside the installation directory — and deletes only that recorded copy after
the next update is verified in place. Backups it did not create are never
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
recorded copy that something else holds open (an on-access scanner, an editor)
stays recorded and is removed by a later update instead.
An update **refuses to run** while unresolved recovery state exists beside the
binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
and make every subsequent update refuse until an operator clears them. That is
the safe direction: the alternative is an update that overwrites the only
verified copy of the previous binary. If updates start refusing, inspect those
files before removing them — an installation directory that a lower-privileged
account can write to is itself worth fixing.
+3 -1
View File
@@ -6,20 +6,22 @@ require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/Microsoft/go-winio v0.6.2
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/coder/websocket v1.8.15
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.44.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
+4
View File
@@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
@@ -62,6 +64,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+191 -30
View File
@@ -11,6 +11,9 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -23,9 +26,10 @@ import (
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
// operation.
type Deps struct {
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
// exactly like the exec surface — never run unconfined on the host.
@@ -60,13 +64,16 @@ type acpSession struct {
// turnMu serializes prompt turns for one session: concurrent session/prompt
// calls run one at a time so they can't interleave history or clobber the
// single cancel slot.
turnMu sync.Mutex
turnMu sync.Mutex
modelMu sync.Mutex
mu sync.Mutex
mode agent.PermissionMode
model string // override; "" => config default
cancel context.CancelFunc
history []turnRecord
mu sync.Mutex
mode agent.PermissionMode
model string
models []SessionConfigOptionValue
restrictModels bool
cancel context.CancelFunc
history []turnRecord
}
// NewAgent builds the ACP server and registers its method handlers on conn.
@@ -83,9 +90,6 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
return a
}
// Serve runs the connection read loop until the stream closes or ctx is done.
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
// ---- initialize ----
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
@@ -120,7 +124,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
// ---- session lifecycle ----
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
var p NewSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/new params")
@@ -129,18 +133,23 @@ func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
}
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess := a.registerSession(meta.SessionID, root, nil)
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
return NewSessionResult{
SessionID: sess.id,
Modes: a.modeState(sess),
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
Modes: a.modeState(sess),
}, nil
}
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
var p LoadSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/load params")
@@ -161,7 +170,17 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
history, historyErr := a.loadHistory(meta.SessionID)
sess := a.registerSession(meta.SessionID, root, history)
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
}
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
model = persistedModel
if !modelChoiceExists(models, persistedModel) {
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
}
}
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
@@ -169,7 +188,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
historyErr,
)
return LoadSessionResult{
Modes: a.modeState(sess),
ConfigOptions: a.configOptions(sess),
Modes: a.modeState(sess),
}, nil
}
@@ -329,9 +349,11 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk:
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
@@ -354,11 +376,31 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
if p.ConfigID != configIDModel {
switch p.ConfigID {
case configIDModel:
model := strings.TrimSpace(p.Value)
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
return nil, err
}
case configIDMode:
// Same turnMu as handleSetMode so the two advertised mode doors (session
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
}
default:
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
}
sess.setModel(p.Value)
return SetSessionConfigOptionResult{}, nil
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
}
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
@@ -370,8 +412,24 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
sess.setModel(p.Model)
return ZeroSetModelResult{Model: p.Model}, nil
model := strings.TrimSpace(p.Model)
if err := a.updateModel(sess, model, false); err != nil {
return nil, err
}
return ZeroSetModelResult{Model: model}, nil
}
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
sess.modelMu.Lock()
defer sess.modelMu.Unlock()
if restrictModels && !sess.hasModel(model) {
return RPCError(codeInvalidParams, "unknown model: "+model)
}
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
return RPCError(codeInternalError, "save model selection: "+err.Error())
}
sess.setModel(model)
return nil
}
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
@@ -387,17 +445,91 @@ func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
// ---- advertising helpers ----
func (a *Agent) modeState(s *acpSession) *SessionModeState {
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access.
// auto/ask/plan are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access. Plan
// only narrows what a client can do (read-only, no write/shell tools), so
// unlike Unsafe there is no elevation risk in letting a client select it.
return &SessionModeState{
CurrentModeID: string(s.currentMode()),
AvailableModes: []SessionMode{
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{ID: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}
}
// resolveModelChoices advertises authenticated live models when discovery is
// available. The configured model is always retained as the sole fallback.
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
if err != nil {
return "", nil, false, err
}
selected := strings.TrimSpace(resolved.Provider.Model)
options := make([]SessionConfigOptionValue, 0, 8)
seen := make(map[string]bool)
add := func(id, description string) {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
return
}
seen[id] = true
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
}
add(selected, "")
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
restrictModels := knownProvider && !descriptor.Custom
if a.deps.DiscoverModels != nil {
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
if ctx.Err() != nil {
return "", nil, false, ctx.Err()
}
if discoverErr == nil && len(discovered) > 0 {
for _, model := range discovered {
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
add(model.ID, model.Description)
}
}
}
}
return selected, options, restrictModels, nil
}
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
for _, option := range models {
if option.Value == model {
return true
}
}
return false
}
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
model, models, mode := s.configState()
return []SessionConfigOption{{
ID: configIDModel,
Name: "Model",
Description: "Model used for this session.",
Category: configCategoryModel,
Type: configOptionTypeSelect,
CurrentValue: model,
Options: models,
}, {
ID: configIDMode,
Name: "Mode",
Description: "Permission mode used for this session.",
Category: configCategoryMode,
Type: configOptionTypeSelect,
CurrentValue: string(mode),
Options: []SessionConfigOptionValue{
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{Value: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}}
}
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
@@ -525,13 +657,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// session is returned unchanged rather than orphaning its turn or resetting its
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
// half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing
}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
a.sessions[id] = sess
return sess
}
@@ -570,7 +702,18 @@ func (s *acpSession) currentMode() agent.PermissionMode {
}
func (s *acpSession) setModel(model string) {
model = strings.TrimSpace(model)
s.mu.Lock()
found := false
for _, option := range s.models {
if option.Value == model {
found = true
break
}
}
if !found && model != "" {
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
}
s.model = model
s.mu.Unlock()
}
@@ -581,6 +724,24 @@ func (s *acpSession) currentModel() string {
return s.model
}
func (s *acpSession) hasModel(model string) bool {
model = strings.TrimSpace(model)
s.mu.Lock()
defer s.mu.Unlock()
for _, option := range s.models {
if option.Value == model {
return true
}
}
return false
}
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
s.mu.Lock()
defer s.mu.Unlock()
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
}
func (s *acpSession) appendHistory(rec turnRecord) {
s.mu.Lock()
s.history = append(s.history, rec)
+269
View File
@@ -3,6 +3,7 @@ package acp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -137,6 +139,12 @@ func TestACPEndToEndPrompt(t *testing.T) {
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
}
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
}
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
}
// session/prompt
var promptRes PromptResult
@@ -156,6 +164,201 @@ func TestACPEndToEndPrompt(t *testing.T) {
}
}
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "gpt-5.5"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
}}, nil
}
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: " gpt-5.4-mini ", Description: "Fast"},
{ID: "gpt-5.5", Description: "duplicate configured model"},
}, nil
}
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
option := created.ConfigOptions[0]
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
t.Fatalf("new model option = %+v", option)
}
for _, choice := range option.Options {
if choice.Name != choice.Value {
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
}
}
var selected SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
}, &selected); err != nil {
t.Fatalf("set_config_option: %v", err)
}
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
t.Fatalf("selected model = %q", got)
}
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
}, &SetSessionConfigOptionResult{}); err == nil {
t.Fatal("unknown standard model selection was accepted")
}
h.stop()
h = newHarness(t, deps)
defer h.stop()
var loaded LoadSessionResult
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
t.Fatalf("session/load: %v", err)
}
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
}
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
}
}
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
}}, nil
}
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, fmt.Errorf("discovery unavailable")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
option := created.ConfigOptions[0]
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
t.Fatalf("fallback model option = %+v", option)
}
}
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "configured-model"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
}}, nil
}
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
var selected SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
}, &selected); err != nil {
t.Fatalf("set custom model: %v", err)
}
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
t.Fatalf("custom model = %q", got)
}
h.stop()
h = newHarness(t, deps)
defer h.stop()
var loaded LoadSessionResult
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
t.Fatalf("session/load: %v", err)
}
option := loaded.ConfigOptions[0]
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
t.Fatalf("loaded custom model option = %+v", option)
}
}
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
a := &Agent{deps: Deps{
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
}}, nil
},
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
},
}}
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
if err != nil {
t.Fatal(err)
}
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
}
}
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := &Agent{deps: Deps{
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
},
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, context.Canceled
},
}}
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
t.Fatalf("resolve error = %v, want context canceled", err)
}
}
func TestACPConfigOptionWireSchema(t *testing.T) {
b, err := json.Marshal(SessionConfigOption{
ID: "model", Name: "Model", Description: "desc", Category: "model",
Type: configOptionTypeSelect, CurrentValue: "m1",
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
})
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(b, &wire); err != nil {
t.Fatal(err)
}
for _, key := range []string{"type", "category", "currentValue", "options"} {
if _, ok := wire[key]; !ok {
t.Errorf("wire field %q absent: %s", key, b)
}
}
if _, ok := wire["value"]; ok {
t.Errorf("obsolete option value field present: %s", b)
}
if _, ok := wire["values"]; ok {
t.Errorf("obsolete values field present: %s", b)
}
choice := wire["options"].([]any)[0].(map[string]any)
if choice["value"] != "m1" {
t.Errorf("options[].value = %#v", choice["value"])
}
}
func TestACPUnknownSessionPromptErrors(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
@@ -181,6 +384,41 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode ask: %v", err)
}
var configured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
}, &configured); err != nil {
t.Fatalf("set_config_option mode: %v", err)
}
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
t.Fatalf("configured mode = %q", got)
}
// Plan is accepted: it only narrows capability (read-only), so unlike Unsafe
// there is no elevation risk in letting a client select it.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
// Configuration path is a separate contract from MethodSessionSetMode: the
// mode option is advertised via configOptions and applied by handleSetConfigOption.
var planConfigured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModePlan),
}, &planConfigured); err != nil {
t.Fatalf("set_config_option plan: %v", err)
}
if got := planConfigured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModePlan) {
t.Fatalf("configured mode after plan = %q, want plan", got)
}
hasPlanOption := false
for _, opt := range planConfigured.ConfigOptions[1].Options {
if opt.Value == string(agent.PermissionModePlan) {
hasPlanOption = true
break
}
}
if !hasPlanOption {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
@@ -191,6 +429,37 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
func TestACPPlanModeWiresPermissionModeIntoAgentOptions(t *testing.T) {
deps := testDeps(t)
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("plan it out")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
}
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
// shell tools run confined, not unconfined on the host.
+11
View File
@@ -0,0 +1,11 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package acp
import "context"
// Serve runs the connection read loop until the stream closes or ctx is done.
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
func ImageBlock(base64Data, mimeType string) ContentBlock {
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
}
+138 -6
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"io"
"sync"
"sync/atomic"
)
// JSON-RPC 2.0 standard error codes.
@@ -80,8 +81,9 @@ type NotifyFunc func(ctx context.Context, params json.RawMessage)
// requests/notifications — needed because ACP is bidirectional (the agent calls
// the client for session/request_permission, fs/*, terminal/*).
type Conn struct {
reader *bufio.Reader
w io.Writer
rawReader io.Reader // wrapped lazily in Serve, once ctx is known — see interruptibleReader
readerCloser io.Closer
w io.Writer
writeMu sync.Mutex // serializes all writes to w
@@ -96,10 +98,15 @@ type Conn struct {
wg sync.WaitGroup // tracks in-flight inbound handlers
}
// NewConn builds a peer reading ndjson from r and writing ndjson to w.
// NewConn builds a peer reading ndjson from r and writing ndjson to w. This
// Conn does not own r: cancelling Serve's context cannot forcibly close it, so
// a Read on r that's genuinely idle (never returns on its own) leaves Serve
// blocked until r itself produces something — e.g. a test that closes r's own
// write side to unblock it. Use NewOwnedConn when this Conn should own r's
// lifetime.
func NewConn(r io.Reader, w io.Writer) *Conn {
return &Conn{
reader: bufio.NewReader(r),
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
@@ -107,6 +114,18 @@ func NewConn(r io.Reader, w io.Writer) *Conn {
}
}
// NewOwnedConn is like NewConn but declares that this Conn exclusively owns r:
// if r implements io.Closer, Serve closes it on context cancellation to
// interrupt an otherwise-idle blocking Read. Only pass a reader whose lifetime
// this Conn should control — e.g. the process's own stdin — never a reader a
// caller retains or shares elsewhere, since cancellation tears it down
// permanently for every consumer, not just this Conn.
func NewOwnedConn(r io.Reader, w io.Writer) *Conn {
c := NewConn(r, w)
c.readerCloser, _ = r.(io.Closer)
return c
}
// Handle registers a request handler for method.
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
@@ -128,17 +147,36 @@ func (c *Conn) Serve(ctx context.Context) error {
c.wg.Wait()
}()
interruptible := newInterruptibleReader(ctx, c.rawReader, c.readerCloser)
reader := bufio.NewReader(interruptible)
for {
// One ndjson value per line. Framing per line (rather than streaming the
// decoder) keeps a single malformed line from making the whole connection
// unrecoverable — we report -32700 and continue.
line, err := c.reader.ReadBytes('\n')
//
// generation brackets this SPECIFIC ReadBytes call: bufio may invoke
// interruptible.Read zero or more times underneath it (zero when the
// answer is already sitting in bufio's own buffer — including a
// previously-buffered, not-yet-surfaced error: bufio can return a
// complete line with a nil error while quietly caching an error it
// received ALONGSIDE that line's bytes, which then surfaces on the VERY
// NEXT call with no further contact with interruptible.Read at all). The
// generation only advances inside interruptible.Read's own cancellation
// branch, so "unchanged across this call" is proof this call's outcome
// didn't pass through that branch — whether because it was answered from
// the buffer or because it raced ctx.Done() and won. Only that proof
// makes it safe to call the outcome genuine.
before := interruptible.generation()
line, err := reader.ReadBytes('\n')
interrupted := interruptible.generation() != before
if len(bytes.TrimSpace(line)) > 0 {
c.handleLine(ctx, line)
}
if err != nil {
c.failAllPending(err)
if errors.Is(err, io.EOF) || ctx.Err() != nil {
if errors.Is(err, io.EOF) || interrupted {
return nil
}
return err
@@ -146,6 +184,100 @@ func (c *Conn) Serve(ctx context.Context) error {
}
}
// interruptibleReader wraps a reader so a context cancellation can unblock a
// currently in-flight Read without ever discarding — or misattributing — the
// real outcome of a call that wasn't actually blocked.
//
// Each Read runs the underlying call on its own goroutine and races it against
// ctx.Done(). If the underlying call finishes first, its result is returned
// exactly as received; ctx being cancelled a moment later has no bearing on a
// call that already had its answer. If ctx.Done() fires first, closer (when
// non-nil) is closed to nudge a genuinely blocked read, the generation counter
// is bumped, and the call STILL waits for and returns the underlying call's own
// result — never a synthesized one — since closing a reader's outcome is
// whatever the reader itself reports for it, not ours to invent.
//
// The counter is what lets Serve tell a call that was actually raced against
// cancellation apart from one merely answered while cancellation happened to be
// pending — the distinction the read loop's own doc comment explains.
type interruptibleReader struct {
inner io.Reader
closer io.Closer
ctx context.Context
closeOnce sync.Once
gen atomic.Int64
}
func newInterruptibleReader(ctx context.Context, inner io.Reader, closer io.Closer) *interruptibleReader {
return &interruptibleReader{inner: inner, closer: closer, ctx: ctx}
}
func (r *interruptibleReader) generation() int64 { return r.gen.Load() }
type interruptibleReadResult struct {
n int
err error
}
// interruptibleReadDecision arbitrates whether an underlying Read completed or
// cancellation claimed it first. Completion is recorded before the result is
// published so cancellation cannot misclassify a finished read merely because
// its goroutine has not sent the result yet.
type interruptibleReadDecision struct {
once sync.Once
}
func (d *interruptibleReadDecision) complete() { d.once.Do(func() {}) }
func (d *interruptibleReadDecision) interrupt() (claimed bool) {
d.once.Do(func() { claimed = true })
return claimed
}
// readInner records completion in the Read call's deferred epilogue. It also
// converts a reader panic into a terminal error so the helper goroutine always
// publishes a result for Read to join.
func (r *interruptibleReader) readInner(p []byte, decision *interruptibleReadDecision) (res interruptibleReadResult) {
defer func() {
if recovered := recover(); recovered != nil {
res = interruptibleReadResult{err: fmt.Errorf("acp: reader panicked: %v", recovered)}
}
decision.complete()
}()
res.n, res.err = r.inner.Read(p)
return res
}
func (r *interruptibleReader) Read(p []byte) (int, error) {
resultCh := make(chan interruptibleReadResult, 1)
var decision interruptibleReadDecision
go func() {
resultCh <- r.readInner(p, &decision)
}()
select {
case res := <-resultCh:
return res.n, res.err
case <-r.ctx.Done():
// resultCh may not be readable yet even when inner.Read has returned: its
// goroutine can be descheduled between recording completion and publishing
// the result. Only interrupt a call that cancellation actually claims.
if decision.interrupt() {
r.gen.Add(1)
if r.closer != nil {
r.closeOnce.Do(func() { _ = r.closer.Close() })
}
}
// Wait for the real result rather than returning synthetically: p is
// still being written by the goroutine above until it does, so returning
// early would race that write, and the actual error (whatever closing
// the reader produced, or whatever the reader was already about to
// report) is what the caller should see.
res := <-resultCh
return res.n, res.err
}
}
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
// with -32700 (id null) and keeps the connection alive.
func (c *Conn) handleLine(ctx context.Context, line []byte) {
+288
View File
@@ -3,11 +3,30 @@ package acp
import (
"context"
"encoding/json"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"time"
)
type testReadCloser struct {
read func([]byte) (int, error)
close func() error
}
func (r testReadCloser) Read(p []byte) (int, error) { return r.read(p) }
func (r testReadCloser) Close() error { return r.close() }
type testReader func([]byte) (int, error)
func (r testReader) Read(p []byte) (int, error) { return r(p) }
type testWriter func([]byte) (int, error)
func (w testWriter) Write(p []byte) (int, error) { return w(p) }
// connPair wires two Conns together over in-memory pipes and serves both.
func connPair(t *testing.T) (a, b *Conn, stop func()) {
t.Helper()
@@ -25,6 +44,275 @@ func connPair(t *testing.T) (a, b *Conn, stop func()) {
}
}
func TestConnServeCancellationInterruptsIdleRead(t *testing.T) {
pipeReader, writer := io.Pipe()
t.Cleanup(func() { _ = writer.Close() })
// Buffered + non-blocking send, not close: interruptibleReader retries each
// underlying Read on its own goroutine, so if bufio ever calls this more than
// once, a close() here would panic on the second call.
readStarted := make(chan struct{}, 1)
closeCalls := make(chan struct{}, 2)
reader := testReadCloser{
read: func(p []byte) (int, error) {
select {
case readStarted <- struct{}{}:
default:
}
return pipeReader.Read(p)
},
close: func() error {
closeCalls <- struct{}{}
return pipeReader.Close()
},
}
conn := NewOwnedConn(reader, io.Discard)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- conn.Serve(ctx) }()
<-readStarted
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Serve returned %v after cancellation, want nil", err)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after cancelling an idle connection")
}
if got := len(closeCalls); got != 1 {
t.Fatalf("reader Close calls = %d, want 1", got)
}
}
func TestConnServePreservesTerminalReadErrorDuringCancellation(t *testing.T) {
for _, closable := range []bool{false, true} {
name := "non-closable"
if closable {
name = "closable"
}
t.Run(name, func(t *testing.T) {
wantErr := errors.New("read failed")
read := testReader(func(p []byte) (int, error) {
return copy(p, "not json"), wantErr
})
closeCalls := make(chan struct{}, 1)
var reader io.Reader = read
if closable {
reader = testReadCloser{
read: read,
close: func() error {
closeCalls <- struct{}{}
return nil
},
}
}
// Buffered + non-blocking send, not close: a later change adding a
// second write (e.g. an additional error frame) must not panic on a
// repeated close of an already-closed channel.
writeStarted := make(chan struct{}, 1)
releaseWrite := make(chan struct{})
writer := testWriter(func(p []byte) (int, error) {
select {
case writeStarted <- struct{}{}:
default:
}
<-releaseWrite
return len(p), nil
})
newConn := NewConn
if closable {
// Ownership matters here: this case exists to prove that even a
// Conn entitled to close its reader on cancellation still
// preserves a terminal error that was already decided.
newConn = NewOwnedConn
}
conn := newConn(reader, writer)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- conn.Serve(ctx) }()
<-writeStarted
cancel()
close(releaseWrite)
select {
case err := <-done:
if !errors.Is(err, wantErr) {
t.Fatalf("Serve returned %v, want terminal read error %v", err, wantErr)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after terminal read error")
}
if got := len(closeCalls); got != 0 {
t.Fatalf("reader Close calls = %d, want 0 after terminal read", got)
}
})
}
}
// TestConnServePreservesReadErrorBufferedAlongsideAPriorLine is the deterministic
// regression test for jatmn's #782 finding: a real, non-EOF ReadBytes failure can
// be DECIDED before cancellation ever happens, yet only SURFACE afterward, and
// must still be reported rather than swallowed as a clean shutdown.
//
// bufio.Reader can obtain data AND a terminal error from ONE underlying Read call
// (io.Reader explicitly permits returning (n>0, err) together). If a delimiter is
// found within that data, ReadBytes returns the line with a nil error for THIS
// call, but bufio caches the error internally and returns it — WITHOUT calling the
// underlying reader again — on the very next ReadBytes call. This test forces that
// exact sequence: the underlying reader hands back one complete, validly-framed
// line together with wantErr in a SINGLE call (asserted below to be its ONLY
// call), the loop dispatches that line through a synchronous path that blocks the
// read loop, cancellation is asserted to have happened before the loop attempts
// its next read, and only then is the block released. By the time the read loop
// asks for more input, cancellation is already in effect and the underlying
// reader is never touched again — so if Serve attributed the resulting error to
// cancellation merely because ctx was already done, it would incorrectly return
// nil. It must instead recognize that this read was answered entirely from
// bufio's own cache, never raced against ctx.Done(), and report wantErr.
func TestConnServePreservesReadErrorBufferedAlongsideAPriorLine(t *testing.T) {
wantErr := errors.New("read failed")
var readCalls atomic.Int32
read := testReader(func(p []byte) (int, error) {
readCalls.Add(1)
// An unsupported jsonrpc version with an id takes the SYNCHRONOUS
// writeError path in handleLine (not a dispatched goroutine), giving this
// test a reliable blocking point between processing this line and the
// loop's next ReadBytes call.
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
})
writeStarted := make(chan struct{}, 1)
releaseWrite := make(chan struct{})
var releaseWriteOnce sync.Once
release := func() { releaseWriteOnce.Do(func() { close(releaseWrite) }) }
t.Cleanup(release)
writer := testWriter(func(p []byte) (int, error) {
select {
case writeStarted <- struct{}{}:
default:
}
<-releaseWrite
return len(p), nil
})
conn := NewConn(read, writer)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- conn.Serve(ctx) }()
// The synchronous write confirms the first (and, per the panic guard above,
// only) read has already returned and is being processed — cancelling now
// lands squarely in the gap between that read and the loop's next one, never
// racing an in-flight interruptibleReader.Read call.
select {
case <-writeStarted:
case <-time.After(time.Second):
t.Fatal("Serve did not reach the synchronous write")
}
cancel()
release()
select {
case err := <-done:
if !errors.Is(err, wantErr) {
t.Fatalf("Serve returned %v, want the buffered terminal read error %v", err, wantErr)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after the buffered terminal read error")
}
if got := readCalls.Load(); got != 1 {
t.Fatalf("underlying Read calls = %d, want exactly 1 (bufio must have served the second ReadBytes from its cache)", got)
}
}
// TestInterruptibleReaderPrefersDecidedResultOverCancellation is the regression
// test for jatmn's second #782 finding: interruptibleReader.Read's select
// between resultCh and ctx.Done() chooses pseudo-randomly when both are ready,
// so a real, already-decided outcome (e.g. a terminal transport error) could be
// misattributed to cancellation purely because ctx also happened to be done by
// the time the select ran. Each iteration lets the underlying Read return (so
// resultCh already holds the real outcome) before cancelling, maximizing the
// odds of landing exactly in that ambiguous window; the generation counter must
// never advance when the real, already-decided result is what gets returned.
func TestInterruptibleReaderPrefersDecidedResultOverCancellation(t *testing.T) {
wantErr := errors.New("read failed")
for i := 0; i < 100; i++ {
resultReady := make(chan struct{})
read := testReader(func(p []byte) (int, error) {
defer close(resultReady)
return copy(p, "x"), wantErr
})
ctx, cancel := context.WithCancel(context.Background())
r := newInterruptibleReader(ctx, read, nil)
readDone := make(chan interruptibleReadResult, 1)
go func() {
n, err := r.Read(make([]byte, 8))
readDone <- interruptibleReadResult{n, err}
}()
<-resultReady
time.Sleep(time.Millisecond) // bias resultCh's send ahead of cancel
cancel()
res := <-readDone
if !errors.Is(res.err, wantErr) {
t.Fatalf("iteration %d: Read returned %v, want %v", i, res.err, wantErr)
}
if got := r.generation(); got != 0 {
t.Fatalf("iteration %d: generation = %d, want 0 (a decided result must never be attributed to cancellation)", i, got)
}
cancel()
}
}
// TestInterruptibleReadCompletionWinsBeforeResultPublication exercises the
// actual inner-read wrapper at the handoff between the underlying Read and the
// helper goroutine's result publication. Cancellation must not reclassify a
// result returned by this wrapper as interrupted.
func TestInterruptibleReadCompletionWinsBeforeResultPublication(t *testing.T) {
wantErr := errors.New("read failed")
read := testReader(func(p []byte) (int, error) {
return copy(p, "x"), wantErr
})
r := newInterruptibleReader(context.Background(), read, nil)
var decision interruptibleReadDecision
readComplete := make(chan struct{})
resultCh := make(chan interruptibleReadResult)
go func() {
res := r.readInner(make([]byte, 8), &decision)
close(readComplete)
resultCh <- res
}()
<-readComplete
if decision.interrupt() {
t.Fatal("cancellation claimed a read that completed before result publication")
}
res := <-resultCh
if res.n != 1 || !errors.Is(res.err, wantErr) {
t.Fatalf("inner read result = (%d, %v), want (1, %v)", res.n, res.err, wantErr)
}
}
func TestConnServeReportsReaderPanicAsTerminalError(t *testing.T) {
read := testReader(func([]byte) (int, error) {
panic("boom")
})
conn := NewConn(read, io.Discard)
done := make(chan error, 1)
go func() { done <- conn.Serve(context.Background()) }()
select {
case err := <-done:
const want = "acp: reader panicked: boom"
if err == nil || err.Error() != want {
t.Fatalf("Serve returned %v, want %q", err, want)
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after reader panic")
}
}
func TestConnRequestResponse(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
+20 -4
View File
@@ -25,7 +25,7 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
}
options := make([]PermissionOption, 0, len(actions))
for _, action := range actions {
kind, name := optionKindFor(action)
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
if kind == "" {
continue // skip actions that have no clean ACP option (e.g. cancel)
}
@@ -41,18 +41,24 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
// 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).
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
//
// escalates reports that approving a command prefix will also run the command
// outside the sandbox. The two prefix options say so in their label, since the
// label is all an ACP client shows and the option id carries no room for it. An
// editor user is deciding on exactly the same terms as a terminal user, so the
// disclosure cannot be TUI-only.
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
switch action {
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
return PermAllowOnce, "Allow"
case agent.PermissionDecisionAllowForSession:
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
return PermAllowAlways, "Allow this command for the session"
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
case agent.PermissionDecisionAlwaysAllow:
return PermAllowAlways, "Always allow"
case agent.PermissionDecisionAlwaysAllowPrefix:
return PermAllowAlways, "Always allow this command"
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
case agent.PermissionDecisionDeny:
return PermRejectOnce, "Reject"
case agent.PermissionDecisionCancel:
@@ -123,3 +129,13 @@ func rawInputBytes(data []byte) json.RawMessage {
}
return json.RawMessage(data)
}
// withSandboxEscalationNote appends the escalation disclosure to a prefix
// option's label. Kept parallel to the TUI's wording so the same consequence is
// not described two different ways depending on which client the user runs.
func withSandboxEscalationNote(name string, escalates bool) string {
if !escalates {
return name
}
return name + " (runs outside the sandbox)"
}
@@ -0,0 +1,68 @@
package acp
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
return agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAlwaysAllowPrefix,
agent.PermissionDecisionDeny,
},
PrefixApprovalEscalates: escalates,
}
}
// An ACP client renders only Name, so if the escalation is not in the label the
// editor user approves it blind. They face exactly the outcome a terminal user
// faces, so the disclosure has to reach both clients or it is not a fix.
func TestACPPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
found := 0
for _, option := range buildPermissionOptions(escalationRequest(true)) {
action := agent.PermissionDecisionAction(option.OptionID)
if action != agent.PermissionDecisionAllowPrefix && action != agent.PermissionDecisionAlwaysAllowPrefix {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q carries the disclosure but does not escalate", option.Name)
}
continue
}
found++
if !strings.Contains(option.Name, "outside the sandbox") {
t.Errorf("prefix option %q does not say the command leaves the sandbox", option.Name)
}
}
if found != 2 {
t.Fatalf("expected both prefix options, saw %d", found)
}
}
func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
for _, option := range buildPermissionOptions(escalationRequest(false)) {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q warns about the sandbox when approving would not leave it", option.Name)
}
}
}
// The disclosure must live in the label only. The option id is the decision
// action round-tripped verbatim, and decisionFromOutcome matches it against
// 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) {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
request.AvailableDecisions,
)
if string(decision.Action) != option.OptionID {
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
}
}
}
+19 -19
View File
@@ -103,10 +103,6 @@ type ContentBlock struct {
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
func ImageBlock(base64Data, mimeType string) ContentBlock {
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
}
// ---- sessions ----
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
@@ -233,10 +229,6 @@ func ToolContent(block ContentBlock) ToolCallContent {
return ToolCallContent{Type: "content", Content: &block}
}
func ToolDiff(path, oldText, newText string) ToolCallContent {
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
}
type ToolCallLocation struct {
Path string `json:"path"`
Line *int `json:"line,omitempty"`
@@ -337,19 +329,22 @@ type SetSessionModeParams struct {
type SetSessionModeResult struct{}
// ---- session config options (model selection) ----
// ---- session config options ----
type SessionConfigOptionValue struct {
ID string `json:"id"`
Name string `json:"name"`
Value string `json:"value"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
type SessionConfigOption struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Value string `json:"value"`
Values []SessionConfigOptionValue `json:"values,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Type string `json:"type"`
CurrentValue string `json:"currentValue"`
Options []SessionConfigOptionValue `json:"options"`
}
type SetSessionConfigOptionParams struct {
@@ -373,6 +368,11 @@ type ZeroSetModelResult struct {
Model string `json:"model"`
}
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
// through the standard session/set_config_option method.
const configIDModel = "model"
const (
configIDModel = "model"
configIDMode = "mode"
configOptionTypeSelect = "select"
configCategoryModel = "model"
configCategoryMode = "mode"
)
@@ -5,9 +5,29 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
calls []map[string]any
}
func (tool *normalizationRecordingShellTool) Name() string { return "bash" }
func (tool *normalizationRecordingShellTool) Description() string {
return "record normalized shell arguments"
}
func (tool *normalizationRecordingShellTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: true}
}
func (tool *normalizationRecordingShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
}
func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
return tools.Result{Status: tools.StatusOK}
}
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
@@ -16,7 +36,7 @@ import (
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
registry.Register(tools.NewScopedBashTool(root, nil))
promptCalls := 0
result, err := executeToolCall(
@@ -51,7 +71,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
registry.Register(tools.NewScopedBashTool(root, nil))
promptCalls := 0
result, err := executeToolCall(
@@ -88,7 +108,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
registry.Register(tools.NewScopedBashTool(root, nil))
result, err := executeToolCall(
context.Background(),
@@ -107,9 +127,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
assertNamesOmitRemedy(t, "missing additional_permissions", result.Output)
}
// A well-formed additional_permissions payload must still reach the normal
@@ -117,7 +135,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
registry.Register(tools.NewScopedBashTool(root, nil))
promptCalls := 0
_, err := executeToolCall(
@@ -144,3 +162,237 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
// runShellCall drives one shell tool call through the real registry (and so
// through Registry.RunWithOptions) exactly as the agent loop would.
func runShellCall(t *testing.T, root string, arguments string) ToolResult {
t.Helper()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedExecCommandTool(root, nil, nil))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "exec_command", Arguments: arguments},
PermissionModeUnsafe,
Options{Cwd: root},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
return result
}
// TestAdditionalPermissionsRejectionsConverge is the regression guard for
// #845/#847. A model that attaches a permission object to a command needing
// none used to be told only "set sandbox_permissions", which pushed it into the
// OTHER invalid shape (flag set, payload empty) and its "include a permission"
// error pushed it straight back — the reported loop that burned turns on
// `git branch --show-current`.
//
// The contract this pins is convergence, not tolerance: both shapes are still
// REJECTED (nothing here grants access), but each rejection must name the one
// follow-up call that works, and that follow-up must actually succeed.
func TestAdditionalPermissionsRejectionsConverge(t *testing.T) {
root := t.TempDir()
const readOnly = `"cmd":"git --version"`
// Step 1 of the reported loop: a permission object with no flag.
first := runShellCall(t, root, `{`+readOnly+`,"additional_permissions":{"network":{"enabled":true}}}`)
if first.Status != tools.StatusError {
t.Fatalf("unflagged additional_permissions status = %q, want error (rejecting is correct)", first.Status)
}
assertNamesOmitRemedy(t, "unflagged additional_permissions", first.Output)
// Step 2 of the reported loop: the model keeps the flag but empties the
// payload. This must not be the dead end it was.
second := runShellCall(t, root, `{`+readOnly+`,"sandbox_permissions":"with_additional_permissions"}`)
if second.Status != tools.StatusError {
t.Fatalf("flag without payload status = %q, want error", second.Status)
}
assertNamesOmitRemedy(t, "flag without payload", second.Output)
// The call both errors point at must actually work.
converged := runShellCall(t, root, `{`+readOnly+`}`)
if converged.Status != tools.StatusOK {
t.Fatalf("the call both errors instruct the model to make failed: status=%q output=%q", converged.Status, converged.Output)
}
}
// assertNamesOmitRemedy pins the actionable half of the error text: the message
// must spell out that BOTH fields can simply be dropped. "Set the flag" or
// "include a permission" alone is what made the two errors chase each other.
func assertNamesOmitRemedy(t *testing.T, label string, output string) {
t.Helper()
if !strings.Contains(output, "omit both sandbox_permissions and additional_permissions") {
t.Fatalf("%s error = %q, want it to say to omit both fields entirely", label, output)
}
if !strings.Contains(output, "retry") {
t.Fatalf("%s error = %q, want it to tell the model to retry", label, output)
}
}
// The empty-payload rejection carries the same remedy. It is pinned on the
// validator directly because normalizeNoopAdditionalPermissions absorbs the
// empty payload before executeToolCall reaches this branch;
// inlineAdditionalPermissionsProfile is still the shared source of truth that
// buildPermissionEvent and the grant path re-run, so its text must converge too.
func TestEmptyAdditionalPermissionsErrorNamesOmitRemedy(t *testing.T) {
args := map[string]any{
"cmd": "git --version",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": false},
"file_system": map[string]any{"read": []any{}, "write": []any{}},
},
}
_, requested, err := inlineAdditionalPermissionsProfile(args, t.TempDir())
if !requested {
t.Fatal("requested = false, want true for an explicit with_additional_permissions call")
}
if err == nil {
t.Fatal("an empty additional_permissions payload was accepted; it must still be rejected")
}
assertNamesOmitRemedy(t, "empty payload", err.Error())
}
// A provider that materializes every optional property may send
// "additional_permissions": null. That grants nothing and carries no flag, so it
// is the field omitted — rejecting it stalled an ordinary command behind an
// error the model could not act on.
func TestExecuteToolCallTreatsNullAdditionalPermissionsAsOmitted(t *testing.T) {
result := runShellCall(t, t.TempDir(), `{"cmd":"git --version","additional_permissions":null}`)
if result.Status != tools.StatusOK {
t.Fatalf("null additional_permissions was rejected: status=%q output=%q", result.Status, result.Output)
}
}
// A null payload alongside the flag must still be rejected, and must land on
// the same error as an absent payload rather than being quietly downgraded.
func TestExecuteToolCallRejectsFlaggedNullAdditionalPermissions(t *testing.T) {
result := runShellCall(t, t.TempDir(),
`{"cmd":"git --version","sandbox_permissions":"with_additional_permissions","additional_permissions":null}`)
if result.Status != tools.StatusError {
t.Fatalf("flagged null status = %q, want error", result.Status)
}
if !strings.Contains(result.Output, "no additional_permissions object was provided") {
t.Fatalf("output = %q, want the missing-payload error", result.Output)
}
assertNamesOmitRemedy(t, "flagged null", result.Output)
}
func TestNormalizeProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
for _, mode := range []string{
string(tools.SandboxPermissionsUseDefault),
string(tools.SandboxPermissionsWithAdditionalPermissions),
} {
args := map[string]any{
"command": "./zero --version",
"sandbox_permissions": mode,
"additional_permissions": map[string]any{
"file_system": map[string]any{
"deny_read": []any{},
"entries": []any{},
"read": []any{},
"write": []any{},
},
"network": map[string]any{"enabled": false},
},
}
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("mode %q retained empty additional_permissions: %#v", mode, args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("mode %q normalized to %q, want use_default", mode, got)
}
}
}
func TestExecuteToolCallNormalizesProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
tool := &normalizationRecordingShellTool{}
registry := tools.NewRegistry()
registry.Register(tool)
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"./zero --version","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"file_system":{"deny_read":[],"entries":[],"read":[],"write":[]},"network":{"enabled":false}}}`,
},
PermissionModeAuto,
Options{Cwd: t.TempDir()},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("provider-expanded empty permissions were rejected: %s", result.Output)
}
if len(tool.calls) != 1 {
t.Fatalf("shell tool calls = %d, want 1", len(tool.calls))
}
args := tool.calls[0]
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("executeToolCall retained empty additional_permissions: %#v", args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("sandbox mode = %q, want use_default", got)
}
}
func TestNormalizeAdditionalPermissionsPreservesRealRequest(t *testing.T) {
args := map[string]any{
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
if _, exists := args["additional_permissions"]; !exists {
t.Fatal("non-empty additional_permissions was removed")
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsWithAdditionalPermissions) {
t.Fatalf("sandbox mode changed to %q", got)
}
}
func TestNormalizeAdditionalPermissionsRemovesWorkspaceReadAlreadyCoveredBySandbox(t *testing.T) {
root := t.TempDir()
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
})
args := map[string]any{
"command": "./zero --version",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"file_system": map[string]any{
"entries": []any{
map[string]any{
"access": "read",
"path": map[string]any{
"type": "path",
"path": ".",
},
},
},
"deny_read": []any{},
"read": []any{},
"write": []any{},
},
"network": map[string]any{"enabled": false},
},
}
normalizeNoopAdditionalPermissions(args, root, engine)
if _, exists := args["additional_permissions"]; exists {
t.Fatalf("already-covered additional_permissions was retained: %#v", args)
}
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
t.Fatalf("sandbox mode changed to %q", got)
}
}
+6 -1
View File
@@ -141,9 +141,11 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
registry := registryWithAskUser()
args := `{"questions":[{"question":"Which framework?"}]}`
provider := providerCallingAskUserThenAnswer(args, "done")
var toolResults []ToolResult
result, err := Run(context.Background(), "clarify", provider, Options{
Registry: registry,
Registry: registry,
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
OnAskUser: func(_ context.Context, _ AskUserRequest) (AskUserResponse, error) {
return AskUserResponse{}, context.Canceled
},
@@ -158,6 +160,9 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
if len(provider.requests) != 1 {
t.Fatalf("expected the run to stop after the canceled ask_user (1 turn), got %d", len(provider.requests))
}
if len(toolResults) != 1 || toolResults[0].ToolCallID == "" {
t.Fatalf("cancellation must emit exactly one result for the call, got %#v", toolResults)
}
// The recorded tool result must reflect cancellation, not a synthetic answer.
var toolMsg string
for _, m := range result.Messages {
-12
View File
@@ -501,15 +501,3 @@ func hasStringPrefix(values []string, prefix []string) bool {
}
return true
}
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
+101 -23
View File
@@ -38,8 +38,9 @@ const compactionTriggerRatio = 0.7
// transcript (and so tests can assert on it).
const summaryLabel = "[Summary of earlier conversation]"
// summaryInstructions is the system prompt handed to the summarizer model.
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
// CompactionSummaryInstructions is the system prompt handed to both automatic
// and manually requested compaction summarizers.
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
@@ -56,6 +57,10 @@ type CompactionOptions struct {
// injected so Compact stays pure and testable; the agent loop wires it to a
// real provider call.
Summarize func(toSummarize []zeroruntime.Message) (string, error)
// taskState is a snapshot supplied by the running agent. Its immutable
// objective is always preserved; mutable fields are admitted only when its
// plan projection still matches the transcript.
taskState *taskStateSnapshot
}
// CompactionResult is the metadata-bearing result returned by CompactMessages.
@@ -71,6 +76,12 @@ type CompactionResult struct {
// summary message also includes summaryLabel and any preserved structured
// state needed by later compactions.
SummaryText string
// ProjectedChars is the text content sent to the summarizer after semantic
// projection, excluding provider protocol framing.
ProjectedChars int
// Truncated reports whether projection dropped content to satisfy its
// bounded brief or previous-summary limits.
Truncated bool
// Compacted reports whether Messages contains an injected summary.
Compacted bool
}
@@ -212,15 +223,15 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
}, nil
}
summary, err := opts.Summarize(middle)
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
if err != nil {
return CompactionResult{}, err
}
summary = strings.TrimSpace(summary)
summary := summaryResult.SummaryText
// Preserve structured state (active plan + loaded skills) from the elided
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
compacted = append(compacted, messages[:systemEnd]...)
@@ -234,10 +245,53 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
RemovedCount: len(middle),
PreservedCount: len(messages) - len(middle),
SummaryText: summary,
ProjectedChars: summaryResult.ProjectedChars,
Truncated: summaryResult.Truncated,
Compacted: true,
}, nil
}
// CompactionSummaryResult describes the shared summarization input and output.
type CompactionSummaryResult struct {
SummaryText string
ProjectedChars int
Truncated bool
}
// SummarizeCompactionMessages applies the shared semantic projection before
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
// and manual session compaction both use this path.
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
if summarize == nil {
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
}
projection := projectCompactionInput(messages)
summaryInput := projection.messages
if len(summaryInput) == 0 {
summaryInput = messages
}
summary, err := summarize(summaryInput)
if err != nil {
return CompactionSummaryResult{}, err
}
return CompactionSummaryResult{
SummaryText: strings.TrimSpace(summary),
ProjectedChars: compactionMessageChars(summaryInput),
Truncated: projection.truncated,
}, nil
}
func compactionMessageChars(messages []zeroruntime.Message) int {
total := 0
for _, message := range messages {
total += len(message.Content)
for _, call := range message.ToolCalls {
total += len(call.Name) + len(call.Arguments)
}
}
return total
}
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
// the preserved suffix begins on a user or assistant message rather than a
// tool/tool_result message. A tool result with no preceding assistant tool call
@@ -326,6 +380,9 @@ type compactionState struct {
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
// but its token COST must still be counted so usage reports and budgets include it.
onUsage func(Usage)
task *taskState
planner *contextPlanner
trace *trace.Recorder
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
@@ -365,13 +422,17 @@ func (state *compactionState) calibratedTokens(raw int) int {
return int(float64(raw) * state.calibrationRatio)
}
func newCompactionState(options Options) *compactionState {
return &compactionState{
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,
}
return state
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
@@ -406,6 +467,14 @@ func (state *compactionState) maybeCompact(
// the bodies of old, large tool results (the model has already acted on
// them). If that brings us back under threshold, skip the paid summarizer
// entirely and preserve recent turns verbatim.
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages = pruned
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
if size <= state.threshold {
state.lowWaterMark = size
return messages
}
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages = pruned
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
@@ -417,7 +486,8 @@ func (state *compactionState) maybeCompact(
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: summarizeClosure(ctx, provider, state.onUsage),
Summarize: state.summarizeClosure(ctx, provider),
taskState: state.task.snapshotForCompaction(messages),
})
if err != nil {
// Summarizer failed: keep the original history. The reactive path (or a
@@ -467,7 +537,8 @@ func (state *compactionState) recover(
result, compactErr := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: summarizeClosure(ctx, provider, state.onUsage),
Summarize: state.summarizeClosure(ctx, provider),
taskState: state.task.snapshotForCompaction(messages),
})
if compactErr != nil {
// A genuine compaction attempt was made (and failed): the budget is spent
@@ -500,9 +571,12 @@ 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.
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, 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) {
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -514,7 +588,11 @@ func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage
// Non-context-limit errors (and a single message that still won't fit) surface
// to the caller unchanged.
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
}
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
if err == nil {
return summary, nil
}
@@ -523,11 +601,11 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
}
mid := len(messages) / 2
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
if leftErr != nil {
return "", leftErr
}
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
if rightErr != nil {
return "", rightErr
}
@@ -540,7 +618,7 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
combined := strings.TrimSpace(left + "\n\n" + right)
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: combined},
}, onUsage)
}, onUsage, planner, recorder)
if reduceErr != nil {
if isContextLimitError(reduceErr.Error()) {
// Even the two combined partials don't fit (extreme): fall back to the
@@ -556,15 +634,15 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
}
// summarizeMessagesOnce performs a single tool-less summarization call.
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
},
// No tools: this is a plain text summarization call.
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
requestMessages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
}
stream, err := provider.StreamCompletion(ctx, request)
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
recordContextPlanTrace(recorder, plan)
stream, err := provider.StreamCompletion(ctx, plan.Request)
if err != nil {
return "", err
}
+43 -2
View File
@@ -43,8 +43,14 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
if result.SummaryText != "manual summary" {
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
}
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
}
if result.Truncated {
t.Fatal("Truncated = true for a projection within its limits")
}
if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") {
t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured)
}
if len(result.Messages) != 4 {
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
@@ -96,11 +102,46 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
if result.SummaryText != "" {
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
}
if result.ProjectedChars != 0 || result.Truncated {
t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated)
}
if !reflect.DeepEqual(result.Messages, messages) {
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
}
}
func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) {
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}}
for index := range 20 {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)),
})
}
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"},
)
var captured []zeroruntime.Message
result, err := CompactMessages(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func(toSummarize []zeroruntime.Message) (string, error) {
captured = append([]zeroruntime.Message(nil), toSummarize...)
return "bounded summary", nil
},
})
if err != nil {
t.Fatal(err)
}
if !result.Compacted || !result.Truncated {
t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated)
}
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
}
}
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
+181 -40
View File
@@ -30,6 +30,10 @@ const (
const (
maxRecentEdits = 20
maxEditNoteBytes = 160
// maxTaskObjectiveBytes keeps the original objective recognizable after
// compaction without allowing an unusually large prompt to recreate the
// context pressure the compaction just removed.
maxTaskObjectiveBytes = 512
)
const (
@@ -76,32 +80,14 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
// "" on malformed arguments or an empty plan.
func formatPlanArguments(arguments string) string {
var parsed struct {
Plan []struct {
Content string `json:"content"`
Step string `json:"step"`
Status string `json:"status"`
Notes string `json:"notes"`
} `json:"plan"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
plan, ok := parseTaskPlan(arguments)
if !ok {
return ""
}
lines := make([]string, 0, len(parsed.Plan))
for _, item := range parsed.Plan {
content := strings.TrimSpace(item.Content)
if content == "" {
content = strings.TrimSpace(item.Step)
}
if content == "" {
continue
}
status := strings.TrimSpace(item.Status)
if status == "" {
status = "pending"
}
line := "- [" + status + "] " + content
if notes := strings.TrimSpace(item.Notes); notes != "" {
lines := make([]string, 0, len(plan))
for _, item := range plan {
line := "- [" + item.Status + "] " + item.Content
if notes := item.Notes; notes != "" {
line += "\n Notes: " + notes
}
lines = append(lines, line)
@@ -116,7 +102,7 @@ type skillEntry struct {
body string
}
// recentEdits returns the files mutated by write_file/edit_file calls in messages
// recentEdits returns files mutated by editing calls in messages
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
// note}. After compaction elides the editing turns, this tells the model WHAT it
// changed in each file (from the tool's result) so it need not re-read to
@@ -127,6 +113,7 @@ type skillEntry struct {
// rather than an earlier, now-stale entry.
func recentEdits(messages []zeroruntime.Message) []skillEntry {
pathByID := map[string]string{}
noteByPath := map[string]string{}
sequence := make([]string, 0)
for _, message := range messages {
for _, call := range message.ToolCalls {
@@ -143,12 +130,24 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
sequence = append(sequence, path)
}
}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 {
continue
}
for _, path := range message.ChangedFiles {
path = strings.TrimSpace(path)
if path == "" {
continue
}
sequence = append(sequence, path)
noteByPath[path] = editNote(message.Content)
}
}
order := lastSeenOrder(sequence)
if len(order) == 0 {
return nil
}
noteByPath := map[string]string{}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
continue
@@ -431,12 +430,30 @@ func capBody(body string) string {
// preservedState is the JSON shape of the carried-across-compaction block.
type preservedState struct {
Plan string `json:"plan,omitempty"`
Task *preservedTaskState `json:"task,omitempty"`
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
Tools []preservedTool `json:"tools,omitempty"`
Skills []preservedSkill `json:"skills,omitempty"`
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
}
type preservedTaskState struct {
Objective string `json:"objective"`
Status taskStatus `json:"status,omitempty"`
Pending int `json:"pending,omitempty"`
InProgress int `json:"in_progress,omitempty"`
Completed int `json:"completed,omitempty"`
Failed int `json:"failed,omitempty"`
VerificationPassed int `json:"verification_passed,omitempty"`
VerificationFailed int `json:"verification_failed,omitempty"`
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
Constraints []string `json:"constraints,omitempty"`
ChangedFiles []string `json:"changed_files,omitempty"`
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
Approvals []taskApprovalState `json:"approvals,omitempty"`
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
}
type preservedEdit struct {
Path string `json:"path"`
Note string `json:"note,omitempty"`
@@ -464,8 +481,39 @@ type preservedInstruction struct {
// may live only inside the injected summary message, which on a later compaction
// lands in middle with no real tool calls left to extract. Fresh tool calls and
// instruction blocks override the carried-forward copy by name/source.
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
task := priorState.Task
if taskSnapshot != nil {
task = &preservedTaskState{
Objective: capTaskObjective(taskSnapshot.Objective),
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
}
// Plan parity corroborates only the mutable task projection. The objective
// comes directly from the run prompt and is immutable, so it must survive
// even after compaction removes the plan tool call needed for comparison.
if taskSnapshot.PlanParity == taskPlanParityMatch {
task.Status = taskSnapshot.Status
task.Pending = taskSnapshot.Plan.Pending
task.InProgress = taskSnapshot.Plan.InProgress
task.Completed = taskSnapshot.Plan.Completed
task.Failed = taskSnapshot.Plan.Failed
task.VerificationPassed = taskSnapshot.Verification.Passed
task.VerificationFailed = taskSnapshot.Verification.Failed
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
}
}
freshConstraints := explicitConstraintsFromMessages(middle)
if len(freshConstraints) > 0 {
if task == nil {
task = &preservedTaskState{}
}
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
}
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
// plan preserved by an earlier compaction.
@@ -493,12 +541,114 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
projectInstructionEntries(middle),
)
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
summary += "\n\n" + block
}
return summary
}
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
var constraints []string
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
continue
}
if _, body := projectInstructionBlock(message.Content); body != "" {
continue
}
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
}
return constraints
}
func priorTaskConstraints(task *preservedTaskState) []string {
if task == nil {
return nil
}
return task.Constraints
}
func priorTaskChangedFiles(task *preservedTaskState) []string {
if task == nil {
return nil
}
return task.ChangedFiles
}
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
if task == nil {
return nil
}
return task.UnresolvedFailures
}
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
if task == nil {
return nil
}
return task.Approvals
}
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
if task == nil {
return nil
}
return task.Artifacts
}
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
resolvedSet := make(map[string]struct{}, len(resolved))
for _, key := range resolved {
resolvedSet[key] = struct{}{}
}
merged := make([]taskFailureState, 0, len(older)+len(newer))
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
if _, ok := resolvedSet[failure.Key]; ok {
continue
}
withoutSameKey := make([]taskFailureState, 0, len(merged))
for _, existing := range merged {
if existing.Key != failure.Key {
withoutSameKey = append(withoutSameKey, existing)
}
}
merged = append(withoutSameKey, failure)
}
if len(merged) > maxTaskEvidenceEntries {
merged = merged[len(merged)-maxTaskEvidenceEntries:]
}
return merged
}
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
merged := make([]T, 0, len(older)+len(newer))
seen := make(map[T]struct{}, len(older)+len(newer))
for _, value := range append(append([]T(nil), older...), newer...) {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
merged = append(merged, value)
}
if len(merged) > limit {
merged = merged[len(merged)-limit:]
}
return merged
}
func capTaskObjective(objective string) string {
objective = strings.TrimSpace(objective)
if len(objective) <= maxTaskObjectiveBytes {
return objective
}
const suffix = "…"
limit := maxTaskObjectiveBytes - len(suffix)
for limit > 0 && !utf8.RuneStart(objective[limit]) {
limit--
}
return strings.TrimSpace(objective[:limit]) + suffix
}
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
// original slot), a path touched again by a fresh edit MOVES to the newest
@@ -555,11 +705,11 @@ func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
// formatPreservedState renders state as the labelled, single-line
// JSON block. Returns "" when there is nothing to preserve.
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
return ""
}
state := preservedState{Plan: plan}
state := preservedState{Plan: plan, Task: task}
for _, e := range edits {
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
}
@@ -579,15 +729,6 @@ func formatPreservedState(plan string, edits, tools, skills, instructions []skil
return preservedStateLabel + "\n" + string(encoded)
}
// parsePreservedState recovers the plan + skills from a prior summary's preserved
// block. JSON escaping makes this lossless even when a skill body contains
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
// malformed.
func parsePreservedState(summaryContent string) (string, []skillEntry) {
state := parsePreservedStateBlock(summaryContent)
return state.Plan, preservedSkillsToEntries(state.Skills)
}
func parsePreservedStateBlock(summaryContent string) preservedState {
idx := strings.LastIndex(summaryContent, preservedStateLabel)
if idx < 0 {
+168 -5
View File
@@ -1,10 +1,12 @@
package agent
import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -59,6 +61,169 @@ func TestCompactPreservesActivePlan(t *testing.T) {
}
}
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
objective := strings.Repeat("世", maxTaskObjectiveBytes)
task := newTaskState(objective, nil)
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
}})
messages := stateConversation()
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
taskState: task.snapshotForCompaction(messages),
})
if err != nil {
t.Fatalf("Compact: %v", err)
}
state := parsePreservedStateBlock(compacted[1].Content)
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
t.Fatalf("unexpected compact task state: %#v", state.Task)
}
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
}
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
}
}
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
task := newTaskState("Please keep the change focused.", nil)
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
}})
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
}})
messages := stateConversation()
first, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
taskState: task.snapshotForCompaction(messages),
})
if err != nil {
t.Fatal(err)
}
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
}})
secondInput := append(append([]zeroruntime.Message{}, first...),
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
)
second, err := Compact(secondInput, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
taskState: task.snapshotForCompaction(secondInput),
})
if err != nil {
t.Fatal(err)
}
state := parsePreservedStateBlock(second[1].Content)
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
}
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
}
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
if !containsString(state.Task.Constraints, want) {
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
}
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
encoded, err := json.Marshal(prior)
if err != nil {
t.Fatal(err)
}
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
{Role: zeroruntime.MessageRoleUser, Content: "more"},
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
{Role: zeroruntime.MessageRoleUser, Content: "again"},
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
}
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
taskState: &taskStateSnapshot{
Objective: "current objective",
PlanParity: taskPlanParityMismatch,
},
})
if err != nil {
t.Fatalf("Compact: %v", err)
}
state := parsePreservedStateBlock(compacted[1].Content)
if state.Task == nil || state.Task.Objective != "current objective" {
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
}
if state.Task.Status != "" || state.Task.Pending != 0 {
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
}
}
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
task := newTaskState("keep this objective", nil)
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
messages := stateConversation()
first, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
taskState: task.snapshotForCompaction(messages),
})
if err != nil {
t.Fatalf("first Compact: %v", err)
}
secondInput := append(append([]zeroruntime.Message{}, first...),
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
)
snapshot := task.snapshotForCompaction(secondInput)
if snapshot.PlanParity != taskPlanParityMismatch {
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
}
second, err := Compact(secondInput, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
taskState: snapshot,
})
if err != nil {
t.Fatalf("second Compact: %v", err)
}
state := parsePreservedStateBlock(second[1].Content)
if state.Task == nil || state.Task.Objective != "keep this objective" {
t.Fatalf("objective lost after second compaction: %#v", state.Task)
}
if state.Task.Status != "" || state.Task.InProgress != 0 {
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
}
}
func TestCompactPreservesLoadedSkills(t *testing.T) {
summary := compactStateConversation(t, stateConversation())
if !strings.Contains(summary, preservedStateLabel) {
@@ -283,12 +448,10 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
}
}
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
if !strings.Contains(got, want) {
t.Fatalf("expected %q in formatted plan, got %q", want, got)
}
if got != "" {
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
}
}
+315
View File
@@ -0,0 +1,315 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
compactionUserWordBudget = 256
compactionAssistantWordBudget = 200
compactionToolCallsPerTurn = 8
compactionToolArgumentBytes = 120
compactionErrorBytes = 150
compactionResultBytes = 1200
compactionPreviousSummaryBytes = 16 * 1024
compactionBriefMaxBytes = 24 * 1024
)
var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`)
type compactionProjection struct {
messages []zeroruntime.Message
truncated bool
}
// projectCompactionInput removes reconstructible tool output before the model
// summary call. It retains a bounded transcript of user intent, assistant
// decisions, tool actions, and concise errors. Exact durable state is appended
// separately from the original messages after the summary returns.
func projectCompactionInput(messages []zeroruntime.Message) compactionProjection {
sections := make([]string, 0, len(messages))
previousSummary := ""
truncated := false
for index, message := range messages {
switch message.Role {
case zeroruntime.MessageRoleUser:
content := message.Content
if marker := strings.Index(content, preservedStateLabel); marker >= 0 {
content = content[:marker]
}
trimmed := strings.TrimSpace(content)
if strings.HasPrefix(trimmed, summaryLabel) {
fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel))
previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes)
truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes
continue
}
if content = clipInformativeWords(content, compactionUserWordBudget); content != "" {
sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content))
}
case zeroruntime.MessageRoleAssistant:
lines := make([]string, 0, 1+len(message.ToolCalls))
if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" {
lines = append(lines, content)
}
calls := message.ToolCalls
omitted := 0
if len(calls) > compactionToolCallsPerTurn {
omitted = len(calls) - compactionToolCallsPerTurn
calls = calls[omitted:]
}
if omitted > 0 {
lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted))
}
for _, call := range calls {
lines = append(lines, compactionToolCallLine(call))
}
if len(lines) > 0 {
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
}
case zeroruntime.MessageRoleTool:
// New tool-result messages carry the execution status directly, as the
// source ToolResult does. Keep the text check for older session history
// created before Message exposed IsError.
name := toolNameForResult(messages, index)
mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0
if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) {
continue
}
if name == "" {
name = "tool"
}
switch {
case message.IsError || isLikelyToolError(message.Content):
sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes)))
case name == "ask_user":
sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes)))
default:
sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name,
clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes)))
}
}
}
if len(sections) == 0 && previousSummary == "" {
return compactionProjection{}
}
brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n"))
truncated = truncated || briefTruncated
if previousSummary != "" {
brief = "[previous summary]\n" + previousSummary + "\n\n" + brief
}
return compactionProjection{
messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}},
truncated: truncated,
}
}
func capCompactionBrief(brief string) (string, bool) {
if len(brief) <= compactionBriefMaxBytes {
return brief, false
}
marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n"
available := compactionBriefMaxBytes - len(marker)
// Keep both chronological edges: the oldest material has not necessarily
// been summarized before, while the newest material is most actionable.
headBytes := available * 2 / 5
tailBytes := available - headBytes
head := clipPrefixAtBoundary(brief, headBytes)
tail := clipSuffixAtBoundary(brief, tailBytes)
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true
}
func compactionToolCallLine(call zeroruntime.ToolCall) string {
detail := ""
var arguments map[string]any
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
detail = value
break
}
}
if detail == "" && call.Name == "ask_user" {
detail = askUserQuestionsDetail(arguments)
}
if detail == "" && call.Name == "apply_patch" {
if patch, ok := arguments["patch"].(string); ok {
detail = strings.Join(patchPaths(patch), ", ")
}
}
}
if detail == "" {
detail = commandFromArguments(call.Arguments)
}
if detail == "" {
return "* " + call.Name
}
return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes))
}
func isLikelyToolError(content string) bool {
prefix := strings.TrimSpace(content)
if len(prefix) > compactionErrorBytes {
prefix = prefix[:compactionErrorBytes]
}
lower := strings.ToLower(prefix)
for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func clipInformativeWords(text string, limit int) string {
flat := strings.Join(strings.Fields(text), " ")
if flat == "" || limit <= 0 {
return ""
}
count := 0
for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) {
word := strings.ToLower(flat[location[0]:location[1]])
if compactionStopWords[word] {
continue
}
count++
if count > limit {
end := location[0]
return strings.TrimSpace(flat[:end]) + "...(truncated)"
}
}
return flat
}
func clipBytes(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
if limit <= 3 {
end := limit
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
end--
}
return text[:end]
}
end := limit - 3
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
end--
}
return strings.TrimSpace(text[:end]) + "..."
}
func clipHeadTailBytes(text string, limit int) string {
text = strings.TrimSpace(text)
if len(text) <= limit {
return text
}
if limit <= 0 {
return ""
}
marker := "\n...[middle omitted]...\n"
if limit <= len(marker) {
return clipBytes(text, limit)
}
available := limit - len(marker)
head := clipPrefixAtBoundary(text, available/2)
tail := clipSuffixAtBoundary(text, available-available/2)
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail)
}
func clipPrefixAtBoundary(text string, limit int) string {
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
end := limit
for end > 0 && text[end]&0xc0 == 0x80 {
end--
}
if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 {
end = boundary
}
return text[:end]
}
func clipSuffixAtBoundary(text string, limit int) string {
if limit <= 0 {
return ""
}
if len(text) <= limit {
return text
}
start := len(text) - limit
for start < len(text) && text[start]&0xc0 == 0x80 {
start++
}
if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 {
start += boundary + 2
}
return text[start:]
}
func askUserQuestionsDetail(arguments map[string]any) string {
raw, ok := arguments["questions"].([]any)
if !ok {
return ""
}
questions := make([]string, 0, len(raw))
for _, item := range raw {
object, ok := item.(map[string]any)
if !ok {
continue
}
if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" {
questions = append(questions, strings.TrimSpace(question))
}
}
return strings.Join(questions, " | ")
}
func patchPaths(patch string) []string {
var paths []string
seen := map[string]bool{}
for _, line := range strings.Split(patch, "\n") {
for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
if !strings.HasPrefix(line, prefix) {
continue
}
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
if path != "" && !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
var compactionStopWords = map[string]bool{
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true,
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
"should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true,
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true,
"by": true, "from": true, "as": true, "into": true, "through": true, "during": true,
"before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true,
"and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true,
"both": true, "either": true, "neither": true, "each": true, "every": true, "all": true,
"any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true,
"that": true, "this": true, "these": true, "those": true, "it": true, "its": true,
"i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true,
"he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true,
"who": true, "which": true, "what": true, "if": true, "then": true, "than": true,
"when": true, "where": true, "how": true, "just": true, "also": true,
}
@@ -0,0 +1,130 @@
package agent
import (
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
for _, limit := range []int{0, 1, 2, 3} {
if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) {
t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped)
}
}
}
func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) {
for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} {
for _, limit := range []int{-1, 0} {
if got := clip("世界", limit); got != "" {
t.Fatalf("clipper limit %d = %q, want empty", limit, got)
}
}
}
}
func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
largeBody := strings.Repeat("recoverable source line\n", 1000)
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."},
{Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"},
}
projection := projectCompactionInput(messages)
projected := projection.messages
if len(projected) != 1 {
t.Fatalf("projection = %#v, want one compact message", projected)
}
content := projected[0].Content
for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
}
if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") {
t.Fatalf("projection retained reconstructible or overlong tool output: %q", content)
}
if estimateTokens(projected)*20 >= estimateTokens(messages) {
t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected))
}
}
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
}
projected := projectCompactionInput(messages).messages
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
}
}
func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}},
}
content := projectCompactionInput(messages).messages[0].Content
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
}
}
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
calls := make([]zeroruntime.ToolCall, 12)
for index := range calls {
calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`}
}
projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages
content := projected[0].Content
if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") {
t.Fatalf("tool-call tail was not bounded: %q", content)
}
}
func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) {
previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision"
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}}
for index := range 500 {
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)})
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))})
}
projection := projectCompactionInput(messages)
content := projection.messages[0].Content
if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") {
t.Fatalf("previous summary was lost when the brief tail was capped: %q", content)
}
if !strings.Contains(content, "middle transcript omitted to fit compaction budget") {
t.Fatalf("oversized brief was not head/tail capped: %q", content)
}
if !projection.truncated {
t.Fatal("projection did not structurally report truncation")
}
}
func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) {
projection := projectCompactionInput([]zeroruntime.Message{{
Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]",
}})
if projection.truncated {
t.Fatal("literal user text produced a false truncation signal")
}
}
@@ -20,11 +20,15 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}},
}
edits := recentEdits(messages)
if len(edits) != 2 {
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
if len(edits) != 3 {
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
}
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
@@ -32,6 +36,9 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
}
if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" {
t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2])
}
}
// After compaction elides the editing turns, the preserved-state block still
+114 -11
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -58,12 +59,15 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
if last.Content != "most recent question" {
t.Fatalf("expected most recent message preserved, got %q", last.Content)
}
// The summarized middle excludes system and preserved suffix.
if len(captured) != 3 {
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
// The summarizer receives a compact semantic projection of the middle rather
// than reconstructible raw tool output.
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
t.Fatalf("expected one projected summary input, got %#v", captured)
}
if captured[0].Content != "first question" {
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
if !strings.Contains(captured[0].Content, want) {
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
}
}
// Compaction must shrink the conversation.
if estimateTokens(result) >= estimateTokens(messages) {
@@ -336,7 +340,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(t.TempDir()))
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
@@ -364,7 +368,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
},
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(t.TempDir()))
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
@@ -379,6 +383,43 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
}
}
func TestMaybeCompactStopsAfterSupersededReadsReachThreshold(t *testing.T) {
body := strings.Repeat("same source line\n", 1000)
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
}
pruned, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 {
t.Fatal("test setup did not reclaim superseded read output")
}
prunedSize := estimateTokens(pruned)
if estimateTokens(messages) <= prunedSize {
t.Fatal("test setup does not cross the compaction threshold")
}
provider := &summarizeRecordingProvider{}
state := &compactionState{
enabled: true,
threshold: prunedSize,
preserveLast: 2,
}
got := state.maybeCompact(context.Background(), provider, messages, nil)
if provider.summarizeCalls != 0 {
t.Fatalf("cheap pruning should avoid the summarizer, got %d calls", provider.summarizeCalls)
}
if state.lowWaterMark != prunedSize {
t.Fatalf("lowWaterMark = %d, want %d", state.lowWaterMark, prunedSize)
}
if !strings.Contains(got[2].Content, "superseded identical") || got[4].Content != body {
t.Fatalf("unexpected pruned messages: %#v", got)
}
}
// reactiveProvider builds up history with a tool call on turn 1, then errors
// with a context-limit message on turn 2 (once the history is large enough that
// compaction can shrink it), then succeeds on the same-turn retry. Summary
@@ -388,6 +429,7 @@ type reactiveProvider struct {
summarizeCalls int
turnRequests int
failedOnce bool
connectError bool
bigText string
finalText string
}
@@ -409,6 +451,9 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
case provider.turnRequests == 2 && !provider.failedOnce:
provider.failedOnce = true
if provider.connectError {
return nil, errors.New("context_length_exceeded")
}
return streamEvents([]zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
}), nil
@@ -417,6 +462,39 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
}
}
func TestRunConnectErrorCompactionRecordsReplacementPlan(t *testing.T) {
provider := &reactiveProvider{
connectError: true,
bigText: strings.Repeat("b", 6000),
finalText: "recovered",
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
recorder := trace.NewRecorder("connect-compaction", "run-1", "test")
var contextPlans []ContextBreakdown
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
OnContext: func(breakdown ContextBreakdown) {
contextPlans = append(contextPlans, breakdown)
},
})
if err != nil || result.FinalAnswer != "recovered" {
t.Fatalf("connect-error compaction failed: result=%#v err=%v", result, err)
}
if len(contextPlans) != 3 || contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
t.Fatalf("replacement request context not reported: %#v", contextPlans)
}
gotTrace := recorder.Finish()
if len(gotTrace.PrefixHashes) != 4 || gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
t.Fatalf("replacement request trace not recorded: %#v", gotTrace.PrefixHashes)
}
}
func TestRunReactiveCompactionRecovers(t *testing.T) {
provider := &reactiveProvider{
bigText: strings.Repeat("b", 6000),
@@ -427,7 +505,9 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// the compaction closure. read_file also returns a sizeable result that
// bloats the history.
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(t.TempDir()))
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
var contextPlans []ContextBreakdown
// ContextWindow large enough that proactive compaction never triggers, so
// only the reactive path can save the run.
@@ -436,6 +516,10 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
PermissionMode: PermissionModeUnsafe,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
OnContext: func(breakdown ContextBreakdown) {
contextPlans = append(contextPlans, breakdown)
},
})
if err != nil {
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
@@ -446,6 +530,25 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
if provider.summarizeCalls == 0 {
t.Fatal("expected reactive compaction to invoke the summarizer")
}
if len(contextPlans) != 3 {
t.Fatalf("main request context callbacks = %d, want initial turns plus compacted retry", len(contextPlans))
}
if contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
t.Fatalf("retry context was not updated after compaction: before=%d after=%d", contextPlans[1].MessageTokens, contextPlans[2].MessageTokens)
}
gotTrace := recorder.Finish()
if len(gotTrace.PrefixHashes) != 4 {
t.Fatalf("prefix evidence count = %d, want two turns, compaction, and retry: %#v", len(gotTrace.PrefixHashes), gotTrace.PrefixHashes)
}
if gotTrace.PrefixHashes[0].InvalidationReason != "initial" ||
gotTrace.PrefixHashes[1].InvalidationReason != "unchanged" ||
gotTrace.PrefixHashes[2].InvalidationReason != "initial" ||
gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
t.Fatalf("unexpected request-specific invalidation evidence: %#v", gotTrace.PrefixHashes)
}
if gotTrace.PrefixHashes[2].CompletePrefixHash == gotTrace.PrefixHashes[1].CompletePrefixHash {
t.Fatalf("compaction fingerprint reused the main request prefix: %#v", gotTrace.PrefixHashes)
}
}
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
@@ -491,7 +594,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
finalText: "recovered",
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(t.TempDir()))
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
var deltas []string
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
@@ -581,7 +684,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
}
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
}}}
@@ -628,7 +731,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
}
func TestRecoverDisabledIsNoop(t *testing.T) {
st := newCompactionState(Options{ContextWindow: 0})
st := newCompactionState(Options{ContextWindow: 0}, nil)
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
called := false
// recover must not invoke the provider/summarize when disabled, even on a
+86
View File
@@ -0,0 +1,86 @@
package agent
// CompletionDecision is the deterministic outcome of evaluating a text-only
// assistant turn. Uncertain means the loop needs one bounded follow-up action
// before it can finalize the run.
type CompletionDecision string
const (
CompletionUncertain CompletionDecision = "uncertain"
CompletionComplete CompletionDecision = "complete"
CompletionIncomplete CompletionDecision = "incomplete"
)
type completionAction string
const (
completionActionNone completionAction = ""
completionActionContinue completionAction = "continue"
completionActionSemanticCheck completionAction = "semantic_check"
)
type completionEvaluation struct {
Decision CompletionDecision
Action completionAction
Reason string
}
// completionPolicy owns the small amount of state needed to keep uncertain
// decisions bounded. requireSemanticCheck is set only for run profiles that
// already opted into self-correction; ordinary completion remains entirely
// local and adds no model call.
type completionPolicy struct {
continueNudges int
requireSemanticCheck bool
semanticCheckRequested bool
}
func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
}
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
// A direct admission is strong local evidence and takes precedence over all
// weaker signals, avoiding both continuation nudges and semantic checks.
if reason := selfReportedIncompletion(text); reason != "" {
return completionEvaluation{Decision: CompletionIncomplete, Reason: reason}
}
// A continuation cue is strong unfinished evidence; a pending plan is only
// weak evidence because bookkeeping can be stale. Both get a bounded chance
// to continue, but only a persisted cue is ultimately incomplete.
cue := endsWithContinuationCue(text)
if cue || context.PlanPending {
if policy.continueNudges < maxContinueNudges {
policy.continueNudges++
reason := "your message ended mid-step"
if !cue {
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
}
return completionEvaluation{
Decision: CompletionUncertain,
Action: completionActionContinue,
Reason: reason,
}
}
if cue {
return completionEvaluation{
Decision: CompletionIncomplete,
Reason: "your message ended mid-step",
}
}
}
// Profiles with self-correction enabled require one task-grounded semantic
// check. It is requested at most once per run; the next locally complete turn
// is accepted without another model call.
if policy.requireSemanticCheck && !policy.semanticCheckRequested {
policy.semanticCheckRequested = true
return completionEvaluation{
Decision: CompletionUncertain,
Action: completionActionSemanticCheck,
}
}
return completionEvaluation{Decision: CompletionComplete, Action: completionActionNone}
}
+75
View File
@@ -0,0 +1,75 @@
package agent
import (
"strings"
"testing"
)
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(false)
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
if complete.Decision != CompletionComplete {
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
}
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
if incomplete.Decision != CompletionIncomplete {
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
}
}
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
if got.Decision != CompletionIncomplete {
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
}
}
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("All set.", completionContext{PlanPending: true})
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("All set.", completionContext{PlanPending: true})
if got.Decision != CompletionComplete {
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
}
}
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(true)
first := policy.evaluate("Implemented and tested.", completionContext{})
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
}
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
if second.Decision != CompletionComplete {
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
}
if second.Action == completionActionSemanticCheck {
t.Fatal("semantic check was requested more than once")
}
}
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
objective := "make the requested behavior observable"
nudge := acceptanceVerificationNudge(objective)
if !strings.Contains(nudge, "Task objective: "+objective) {
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
}
}
+36
View File
@@ -23,6 +23,24 @@ type ContextBreakdown struct {
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
ContextWindow int // model context window; 0 when unknown
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
// Blocks explain why each model-visible category is present without exposing
// its content. The planner currently preserves all three categories.
Blocks []ContextBlock
// CompletePrefixHash identifies the provider-visible system+tool prefix.
CompletePrefixHash string
// PrefixInvalidationReason is "initial", "unchanged", or a stable comma-
// separated list of changed prefix categories.
PrefixInvalidationReason string
}
// ContextBlock is content-free evidence for one model-visible context category.
type ContextBlock struct {
Kind string
Tokens int
CacheClass string
Authority string
Recoverable bool
Reason string
}
// MeasureContext estimates the per-category token footprint of a request: the
@@ -41,6 +59,24 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
ContextWindow: contextWindow,
}
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
if breakdown.SystemTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "system", Tokens: breakdown.SystemTokens, CacheClass: "stable_prefix",
Authority: "required", Reason: "instructions, workspace policy, and safety policy",
})
}
if breakdown.ToolTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "tools", Tokens: breakdown.ToolTokens, CacheClass: "stable_prefix",
Authority: "capability", Reason: "currently permitted and selected tool definitions",
})
}
if breakdown.MessageTokens > 0 {
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
Kind: "conversation", Tokens: breakdown.MessageTokens, CacheClass: "dynamic_tail",
Authority: "transcript", Reason: "valid provider replay history and current task context",
})
}
if contextWindow > 0 {
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
}
@@ -32,6 +32,9 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
if breakdown.UsedFraction != wantFraction {
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
}
if len(breakdown.Blocks) != 3 {
t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks)
}
}
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
+185
View File
@@ -0,0 +1,185 @@
package agent
import (
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
contextWindow int
promptCacheKey string
serviceTier string
promptParts systemPromptParts
}
// contextPlanner is the single seam for constructing model-visible requests.
// Planning is deterministic and does not retrieve content, execute tools, or
// change permissions. The initial implementation deliberately preserves every
// message and tool definition while making composition and cache drift
// inspectable; later selection policies must continue to cross this seam.
type contextPlanner struct {
config contextPlannerConfig
previousPrefix prefixFingerprint
hasPrevious bool
toolSnapshotKey string
toolSnapshot []zeroruntime.ToolDefinition
}
type contextPlan struct {
Request zeroruntime.CompletionRequest
Breakdown ContextBreakdown
PrefixFingerprint prefixFingerprint
}
func newContextPlanner(config contextPlannerConfig) *contextPlanner {
return &contextPlanner{config: config}
}
// Plan returns a provider request snapshot plus content-free accounting.
// It intentionally performs no relevance filtering: preserving current model
// capability is the baseline contract for future planner policies.
func (planner *contextPlanner) Plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string) contextPlan {
return planner.plan(messages, toolDefs, reasoningEffort, planner.config.promptParts)
}
// planWithPromptParts plans a request whose stable system sections differ from
// the planner's configured main-run prompt, such as a compaction summary call.
func (planner *contextPlanner) planWithPromptParts(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
return planner.plan(messages, toolDefs, reasoningEffort, parts)
}
func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
requestMessages := copyMessages(messages)
requestSystemPrompt := leadingSystemContent(requestMessages)
if parts.prompt != requestSystemPrompt {
// Component boundaries from a different request would make the detailed
// invalidation reason misleading. Retain the exact request prefix and
// classify it conservatively as an unstructured system prompt instead.
parts = systemPromptParts{prompt: requestSystemPrompt}
}
fingerprint := computePrefixFingerprint(buildPromptSubstringsFromParts(parts, toolDefs))
request := zeroruntime.CompletionRequest{
Messages: requestMessages,
Tools: planner.snapshotTools(toolDefs, fingerprint),
ReasoningEffort: reasoningEffort,
ServiceTier: planner.config.serviceTier,
PromptCacheKey: planner.config.promptCacheKey,
}
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
breakdown.CompletePrefixHash = fingerprint.CompletePrefixHash
var previous *prefixFingerprint
if planner.hasPrevious {
previous = &planner.previousPrefix
}
breakdown.PrefixInvalidationReason = explainPrefixChange(previous, fingerprint)
planner.previousPrefix = fingerprint
planner.hasPrevious = true
return contextPlan{
Request: request,
Breakdown: breakdown,
PrefixFingerprint: fingerprint,
}
}
// snapshotTools freezes provider-visible tool definitions when their semantic
// fingerprint changes. Stable turns reuse the frozen schemas instead of
// recursively cloning every map and slice for every request.
func (planner *contextPlanner) snapshotTools(toolDefs []zeroruntime.ToolDefinition, fingerprint prefixFingerprint) []zeroruntime.ToolDefinition {
key := fingerprint.ToolsHash + "\x00" + fingerprint.SchemaHash
if planner.toolSnapshotKey != key {
planner.toolSnapshot = copyToolDefinitions(toolDefs)
planner.toolSnapshotKey = key
}
return planner.toolSnapshot
}
func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.ToolDefinition {
if toolDefs == nil {
return nil
}
copied := make([]zeroruntime.ToolDefinition, len(toolDefs))
for index, definition := range toolDefs {
copied[index] = definition
copied[index].Parameters = copySchemaMap(definition.Parameters)
}
return copied
}
func copySchemaMap(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
copied := make(map[string]any, len(schema))
for key, value := range schema {
copied[key] = copySchemaValue(value)
}
return copied
}
func copySchemaValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return copySchemaMap(typed)
case []any:
copied := make([]any, len(typed))
for index, item := range typed {
copied[index] = copySchemaValue(item)
}
return copied
case []string:
return append([]string(nil), typed...)
default:
return value
}
}
func leadingSystemContent(messages []zeroruntime.Message) string {
contents := make([]string, 0, 1)
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleSystem {
break
}
contents = append(contents, message.Content)
}
return strings.Join(contents, "\n\n")
}
func explainPrefixChange(previous *prefixFingerprint, current prefixFingerprint) string {
if previous == nil {
return "initial"
}
if previous.CompletePrefixHash == current.CompletePrefixHash {
return "unchanged"
}
reasons := make([]string, 0, 3)
if previous.SystemPromptHash != current.SystemPromptHash {
parts := make([]string, 0, 4)
if previous.BaseInstructionsHash != current.BaseInstructionsHash {
parts = append(parts, "base_instructions")
}
if previous.ConfirmationPolicyHash != current.ConfirmationPolicyHash {
parts = append(parts, "confirmation_policy")
}
if previous.ProjectContextHash != current.ProjectContextHash {
parts = append(parts, "project_context")
}
if previous.SkillsHash != current.SkillsHash {
parts = append(parts, "skills")
}
if len(parts) == 0 {
parts = append(parts, "system_prompt")
}
reasons = append(reasons, parts...)
}
if previous.ToolsHash != current.ToolsHash {
reasons = append(reasons, "tools")
}
if previous.SchemaHash != current.SchemaHash {
reasons = append(reasons, "schema")
}
if len(reasons) == 0 {
return "prefix_changed"
}
return strings.Join(reasons, ",")
}
+187
View File
@@ -0,0 +1,187 @@
package agent
import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "inspect this", Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{1, 2, 3}}}},
{Role: zeroruntime.MessageRoleAssistant, Content: "working", ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, Reasoning: []zeroruntime.ReasoningBlock{{Provider: "test", Type: "thinking", Signature: "sig"}}},
}
toolDefs := []zeroruntime.ToolDefinition{{
Name: "read_file", Description: "Read a file",
Parameters: map[string]any{
"type": "object",
"required": []string{"path"},
"properties": map[string]any{"path": map[string]any{"type": "string"}},
"anyOf": []any{map[string]any{"type": "object"}},
},
}}
planner := newContextPlanner(contextPlannerConfig{
contextWindow: 128_000,
promptCacheKey: "session-1",
serviceTier: "priority",
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
})
plan := planner.Plan(messages, toolDefs, "medium")
want := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefs,
ReasoningEffort: "medium",
ServiceTier: "priority",
PromptCacheKey: "session-1",
}
if !reflect.DeepEqual(plan.Request, want) {
t.Fatalf("planned request changed provider input:\n got: %#v\nwant: %#v", plan.Request, want)
}
stable := planner.Plan(messages, toolDefs, "medium")
if &stable.Request.Tools[0] != &plan.Request.Tools[0] {
t.Fatal("stable tool fingerprint rebuilt the immutable schema snapshot")
}
// A plan owns its request snapshot; later caller mutation cannot change its
// messages or its recursively nested tool schema.
messages[1].Content = "changed"
messages[1].Images[0].Data[0] = 9
toolDefs[0].Name = "changed_tool"
toolDefs[0].Parameters["required"].([]string)[0] = "other"
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
}
if plan.Request.Tools[0].Name != "read_file" ||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
t.Fatalf("planned tools alias caller state: %#v", plan.Request.Tools[0])
}
next := planner.Plan(messages, toolDefs, "medium")
if next.PrefixFingerprint == plan.PrefixFingerprint || next.Request.Tools[0].Name != "changed_tool" {
t.Fatalf("changed tool schema did not refresh snapshot: %#v", next)
}
if plan.Request.Tools[0].Name != "read_file" {
t.Fatalf("refresh mutated earlier request: %#v", plan.Request.Tools[0])
}
}
func TestContextPlannerReportsInspectableBlocks(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{contextWindow: 100_000})
plan := planner.Plan([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: filler(400)},
{Role: zeroruntime.MessageRoleUser, Content: filler(200)},
}, []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read a file"}}, "")
if plan.Breakdown.PrefixInvalidationReason != "initial" || plan.Breakdown.CompletePrefixHash == "" {
t.Fatalf("prefix evidence = %#v", plan.Breakdown)
}
if len(plan.Breakdown.Blocks) != 3 {
t.Fatalf("blocks = %#v, want system/tools/conversation", plan.Breakdown.Blocks)
}
wantKinds := []string{"system", "tools", "conversation"}
total := 0
for index, block := range plan.Breakdown.Blocks {
if block.Kind != wantKinds[index] || block.Tokens <= 0 || block.Reason == "" || block.Authority == "" || block.CacheClass == "" {
t.Fatalf("block %d = %#v", index, block)
}
total += block.Tokens
}
if total != plan.Breakdown.TotalTokens {
t.Fatalf("block tokens = %d, want total %d", total, plan.Breakdown.TotalTokens)
}
}
func TestContextPlannerFingerprintsActualRequestSystemPrompt(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
prompt: "configured system", baseInstructions: "configured system",
}})
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "request-specific system"},
{Role: zeroruntime.MessageRoleUser, Content: "task"},
}
plan := planner.Plan(messages, nil, "")
want := computePrefixFingerprint(buildPromptSubstringsFromParts(
systemPromptParts{prompt: "request-specific system"}, nil,
))
if plan.PrefixFingerprint != want {
t.Fatalf("fingerprint describes configured prompt instead of request:\n got: %#v\nwant: %#v", plan.PrefixFingerprint, want)
}
}
func TestContextPlannerExplainsPrefixInvalidation(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
prompt: "system", baseInstructions: "base", projectContext: "project",
}})
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}, {Role: zeroruntime.MessageRoleUser, Content: "task"}}
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read", Parameters: map[string]any{"type": "object"}}}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "initial" {
t.Fatalf("first reason = %q, want initial", got)
}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "unchanged" {
t.Fatalf("stable reason = %q, want unchanged", got)
}
tools[0].Description = "Read an exact file"
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "tools" {
t.Fatalf("tool reason = %q, want tools", got)
}
tools[0].Parameters["required"] = []any{"path"}
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "schema" {
t.Fatalf("schema reason = %q, want schema", got)
}
planner.config.promptParts.prompt = "system with changed project"
planner.config.promptParts.projectContext = "changed project"
messages[0].Content = "system with changed project"
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "project_context" {
t.Fatalf("project reason = %q, want project_context", got)
}
}
func TestExplainPrefixChangeNamesEveryChangedComponent(t *testing.T) {
baseline := prefixFingerprint{
SystemPromptHash: "system",
BaseInstructionsHash: "base",
ConfirmationPolicyHash: "policy",
ProjectContextHash: "project",
SkillsHash: "skills",
ToolsHash: "tools",
SchemaHash: "schema",
CompletePrefixHash: "complete",
}
tests := []struct {
name string
mutate func(*prefixFingerprint)
want string
}{
{name: "base instructions", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.BaseInstructionsHash = "changed", "changed"
}, want: "base_instructions"},
{name: "confirmation policy", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.ConfirmationPolicyHash = "changed", "changed"
}, want: "confirmation_policy"},
{name: "project context", mutate: func(value *prefixFingerprint) {
value.SystemPromptHash, value.ProjectContextHash = "changed", "changed"
}, want: "project_context"},
{name: "skills", mutate: func(value *prefixFingerprint) { value.SystemPromptHash, value.SkillsHash = "changed", "changed" }, want: "skills"},
{name: "unclassified system prompt", mutate: func(value *prefixFingerprint) { value.SystemPromptHash = "changed" }, want: "system_prompt"},
{name: "tools and schema", mutate: func(value *prefixFingerprint) { value.ToolsHash, value.SchemaHash = "changed", "changed" }, want: "tools,schema"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := baseline
test.mutate(&current)
current.CompletePrefixHash = "changed"
if got := explainPrefixChange(&baseline, current); got != test.want {
t.Fatalf("reason = %q, want %q", got, test.want)
}
})
}
}
+103 -9
View File
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
registry.Register(fakeToolSearchTool{})
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
}
if strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
if !strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
}
if !strings.Contains(search.Description, "mcp__srv__beta") {
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
}
}
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
@@ -368,9 +368,8 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
// No hidden tools means no dynamic discovery text is needed.
if reminder != "" {
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
}
}
@@ -798,3 +797,98 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
}
}
// fakeDeferredMutatorTool is a deferred-eligible tool with mutating Safety
// (SideEffectWrite), standing in for a real write/mutator MCP tool that would
// be hidden behind tool_search once deferral activates.
type fakeDeferredMutatorTool struct{ name string }
func (t fakeDeferredMutatorTool) Name() string { return t.name }
func (t fakeDeferredMutatorTool) Description() string { return "mutates the workspace, deferred" }
func (t fakeDeferredMutatorTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t fakeDeferredMutatorTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt, Reason: "mutates"}
}
func (t fakeDeferredMutatorTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "mutated"}
}
func (t fakeDeferredMutatorTool) Deferred() bool { return true }
// TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema guards the concern
// raised in PR #642 review (jatmn, P2): that tool_search filters deferred
// candidates only by EnabledTools/DisabledTools, not by plan-mode visibility,
// so a plan-mode model could call `tool_search select:<deferred write tool>`
// and receive that tool's full schema even though a direct call to it is
// correctly denied the following turn.
//
// tool_search's own Safety is SideEffectNone, and ToolAdvertisedForPermissionMode (the
// same gate executeToolCall uses to deny a direct call) requires
// SideEffect==Read to advertise a tool in plan mode. That means tool_search
// itself is never advertised, never activates deferral (loaderUsable in
// partitionToolsCached requires ToolAdvertised(loader, permissionMode)), and
// is denied at dispatch like any other hidden tool if a stale/adversarial
// call reaches it anyway — so a deferred mutator's schema can never reach the
// model through tool_search while in plan mode. This test pins that
// end-to-end: tool_search is absent from the advertised tool list, and a
// forced call to it is denied before rendering any tool schema.
func TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate"})
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate2"})
registry.Register(tools.NewToolSearchTool(registry))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: force a call to tool_search even though it should not be advertised.
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: tools.ToolSearchToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"query":"select:mcp__srv__mutate"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: final answer.
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
DeferThreshold: 2, // 2 deferred mutators registered => eligible for deferral.
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
// tool_search (and the deferred mutators) must not be advertised in plan
// mode's turn 1 tool list at all.
for _, def := range provider.requests[0].Tools {
if def.Name == tools.ToolSearchToolName {
t.Fatalf("plan mode must not advertise tool_search, got %#v", provider.requests[0].Tools)
}
if def.Name == "mcp__srv__mutate" || def.Name == "mcp__srv__mutate2" {
t.Fatalf("plan mode must not advertise a deferred mutator, got %#v", provider.requests[0].Tools)
}
}
// The forced call must be denied outright, never a loaded-schema result:
// the tool result must not mention the mutator's name or carry a
// load_tools signal.
var toolMessage string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolMessage = message.Content
break
}
}
if !strings.Contains(toolMessage, "not available in plan mode") {
t.Fatalf("expected tool_search call denied in plan mode, got %q", toolMessage)
}
if strings.Contains(toolMessage, "mcp__srv__mutate") {
t.Fatalf("denial must not leak the deferred mutator's name/schema, got %q", toolMessage)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
DisabledTools: []string{"read_file"},
+17 -6
View File
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
var switchedTo string
switchCount := 0
result, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
switchCount++
switchedTo = modelID
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
if len(firstProvider.requests) != 1 {
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
}
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
t.Fatalf("initial request service tier = %q, want priority", got)
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
}
if got := secondProvider.requests[0].ServiceTier; got != "" {
t.Fatalf("post-switch request retained service tier %q", got)
}
if result.FinalAnswer != "answered on the upgraded model" {
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
}
@@ -211,9 +218,10 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return nil, errors.New("provider build blew up")
},
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
if len(provider.requests) != 2 {
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
}
if got := provider.requests[1].ServiceTier; got != "priority" {
t.Fatalf("failed-switch request service tier = %q, want priority", got)
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
}
+41
View File
@@ -0,0 +1,41 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package agent
import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
// parsePreservedState recovers the plan + skills from a prior summary's preserved
// block. JSON escaping makes this lossless even when a skill body contains
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
// malformed.
func parsePreservedState(summaryContent string) (string, []skillEntry) {
state := parsePreservedStateBlock(summaryContent)
return state.Plan, preservedSkillsToEntries(state.Skills)
}
// partitionTools builds the per-turn advertised tool list and optional
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
// (dropped so it is never advertised when it cannot help), and the discovery text is
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
// through tool_search. Non-deferred tools (including tool_search) are always
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
// so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
+11 -13
View File
@@ -1,7 +1,6 @@
package agent
import (
"encoding/json"
"strconv"
"strings"
@@ -127,9 +126,8 @@ func endsWithContinuationCue(text string) bool {
// which the update_plan tool coerces to "pending") counts as remaining, matching
// that tool's own status normalization.
func planStatusRemaining(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
switch normalizeTaskPlanStatus(status) {
case "completed", "failed":
return false
default:
return true
@@ -307,8 +305,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
// passing treated as the objective being met; and a result that merely matches a
// baseline the task asked to beat or improve. General — no task-specific content.
func acceptanceVerificationNudge() string {
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
func acceptanceVerificationNudge(objective string) string {
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
"result merely matches a baseline you were asked to beat or improve. " +
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
@@ -316,6 +314,10 @@ func acceptanceVerificationNudge() string {
"thing the task asked you to produce, recover, fix, or optimize. " +
"If that check passes, reply PASS and cite the evidence. " +
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
if objective = capTaskObjective(objective); objective != "" {
nudge += "\n\nTask objective: " + objective
}
return nudge
}
// toolFailureHintMarker is a stable substring for tests.
@@ -553,16 +555,12 @@ func (state *guardState) pendingPlanItems() bool {
// many items are still remaining. Malformed arguments leave the prior count
// unchanged (best-effort — the plan panel itself tolerates the same).
func (state *guardState) observePlanUpdate(arguments string) {
var parsed struct {
Plan []struct {
Status string `json:"status"`
} `json:"plan"`
}
if json.Unmarshal([]byte(arguments), &parsed) != nil {
plan, ok := parseTaskPlan(arguments)
if !ok {
return
}
pending := 0
for _, item := range parsed.Plan {
for _, item := range plan {
if planStatusRemaining(item.Status) {
pending++
}
+38 -7
View File
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -169,6 +169,37 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
}
}
// The whole point of #702's fix: the unknown-session error's normalized
// signature must not vary with the session id, so a model probing ids 1, 2,
// 3, … produces one repeated signature the failure guard can count. If the id
// leaked into the first 80 normalized chars, each probe would reset the streak
// and the halt would never fire.
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
a := errorSignature(tools.UnknownExecSessionError(1))
b := errorSignature(tools.UnknownExecSessionError(999999))
if a != b {
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
}
}
// End to end: with an id-invariant signature, probing a different unknown id
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
// before the fix it never would.
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
var state guardState
var stoppedAt int
for i := 1; i <= toolFailureStopAt; i++ {
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureStopAt {
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
}
}
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
var state guardState
toolOnly := zeroruntime.CollectedStream{
@@ -229,7 +260,7 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -260,7 +291,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -288,7 +319,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{
@@ -318,7 +349,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewUpdatePlanTool())
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
@@ -352,7 +383,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewUpdatePlanTool())
turns := [][]zeroruntime.StreamEvent{
@@ -387,7 +418,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewScopedReadFileTool(root, nil))
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
for i := 0; i < toolOnlyProgressReminderAt; i++ {
+639 -245
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,255 @@
package agent
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type propagationOutputTool struct {
output string
}
func TestOutputBudgetHookHelperProcess(t *testing.T) {
for index, arg := range os.Args {
if arg == "--zero-output-budget-hook-repeat" && index+1 < len(os.Args) {
repeats, err := strconv.Atoi(os.Args[index+1])
if err != nil {
os.Exit(2)
}
if _, err := os.Stdout.WriteString(strings.Repeat("hook feedback ", repeats)); err != nil {
os.Exit(2)
}
os.Exit(0)
}
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
continue
}
if _, err := os.Stdout.WriteString(os.Args[index+1]); err != nil {
os.Exit(2)
}
os.Exit(0)
}
}
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
return outputBudgetHookDispatcherFor("propagation_output", 200)
}
func outputBudgetHookDispatcherFor(toolName string, repeats int) *hooks.Dispatcher {
feedback := strings.Repeat("hook feedback ", repeats)
hookArgs := []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook",
feedback,
}
if repeats > 1_000 {
hookArgs = []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook-repeat",
strconv.Itoa(repeats),
}
}
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{{
ID: "large-feedback",
Event: hooks.EventAfterTool,
Matcher: toolName,
Command: os.Args[0],
Args: hookArgs,
Enabled: true,
}},
}})
}
func TestExecuteToolCallCommitsReadObservationAfterFinalBoundary(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "target.txt")
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(root, "small.txt")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
resolvedPath, err := filepath.EvalSymlinks(linkPath)
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
tracker := tools.NewFileTracker()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "read-small", Name: "read_file", Arguments: `{"path":"small.txt"}`,
}, PermissionModeAuto, Options{Cwd: root, FileTracker: tracker})
if abortErr != nil || result.Status != tools.StatusOK {
t.Fatalf("executeToolCall: result=%#v err=%v", result, abortErr)
}
if !tracker.SeenWhole(resolvedPath) {
t.Fatal("unmodified final read output was not committed as observed")
}
}
func TestExecuteToolCallDoesNotCommitReadObservationBeforeHookBudget(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
root := t.TempDir()
targetPath := filepath.Join(root, "target.txt")
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
t.Fatal(err)
}
linkPath := filepath.Join(root, "small.txt")
if err := os.Symlink(targetPath, linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
resolvedPath, err := filepath.EvalSymlinks(linkPath)
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
tracker := tools.NewFileTracker()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "read-hook", Name: "read_file", Arguments: `{"path":"small.txt"}`,
}, PermissionModeAuto, Options{
Cwd: root, FileTracker: tracker, Hooks: outputBudgetHookDispatcherFor("read_file", 20_000),
})
if abortErr != nil {
t.Fatalf("executeToolCall: %v", abortErr)
}
if !result.Truncated {
t.Fatalf("precondition: hook feedback was not truncated: %#v", result)
}
if tracker.SeenWhole(resolvedPath) {
t.Fatal("read observation committed before hook output was budgeted")
}
}
func (tool propagationOutputTool) Name() string { return "propagation_output" }
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
func (tool propagationOutputTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (tool propagationOutputTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test read"}
}
func (tool propagationOutputTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: tool.output}
}
func TestExecuteToolCallPropagatesOutputTruncation(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: strings.Repeat("large output\n", 1000)})
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-budget",
Name: "propagation_output",
Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir()})
if abortErr != nil {
t.Fatalf("executeToolCall abort error: %v", abortErr)
}
if !result.Truncated {
t.Fatalf("agent ToolResult lost tools.Result.Truncated: %#v", result)
}
if result.Meta["spill_path"] == "" {
t.Fatalf("agent ToolResult lost spill metadata: %#v", result.Meta)
}
}
func TestRecordOutputBudgetTraceUsesOnlyCompactMetadata(t *testing.T) {
recorder := trace.NewRecorder("session", "run", "")
recorder.Start()
recordOutputBudgetTrace(recorder, ToolResult{
Name: "grep",
Truncated: true,
Output: "SECRET OUTPUT MUST NOT ENTER TRACE",
Meta: map[string]string{
"output_budget_category": "search",
"output_budget_original_bytes": "1000",
"output_budget_retained_bytes": "100",
"output_budget_estimated_original_tokens": "250",
"output_budget_estimated_retained_tokens": "25",
"output_budget_reason": "semantic_search_budget",
"output_budget_spill_created": "true",
"spill_path": "/secret/path/not-for-trace",
},
})
events := recorder.Finish().OutputBudgets
if len(events) != 1 {
t.Fatalf("events = %#v", events)
}
event := events[0]
if event.Tool != "grep" || event.Category != "search" || event.OriginalBytes != 1000 || event.RetainedBytes != 100 || !event.SpillCreated {
t.Fatalf("unexpected trace event: %#v", event)
}
}
func TestExecuteToolCallRebudgetsOversizedAfterToolFeedback(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: "tool output"})
dispatcher := largeOutputBudgetHookDispatcher()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-hook-budget",
Name: "propagation_output",
Arguments: `{}`,
}, PermissionModeAuto, Options{Hooks: dispatcher})
if abortErr != nil {
t.Fatalf("executeToolCall abort error: %v", abortErr)
}
if !result.Truncated || len(result.Output) > 80*4 {
t.Fatalf("afterTool feedback bypassed output budget: truncated=%t bytes=%d meta=%#v", result.Truncated, len(result.Output), result.Meta)
}
if result.Meta["output_budget_category"] == "" || result.Meta["output_budget_retained_bytes"] != strconv.Itoa(len(result.Output)) {
t.Fatalf("post-hook budget metadata does not describe final output: %#v", result.Meta)
}
}
func TestRunTraceReflectsPostHookBudget(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: "tool output"})
dispatcher := largeOutputBudgetHookDispatcher()
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-hook-trace", ToolName: "propagation_output"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-hook-trace", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-hook-trace"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
recorder := trace.NewRecorder("session", "run", "")
var toolResults []ToolResult
if _, err := Run(context.Background(), "budget hook", provider, Options{
Registry: registry,
Hooks: dispatcher,
Trace: recorder,
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(toolResults) != 1 || !toolResults[0].Truncated {
t.Fatalf("tool result = %#v, want one truncated post-hook result", toolResults)
}
events := recorder.Finish().OutputBudgets
if len(events) != 1 || !events[0].Truncated || events[0].RetainedBytes != len(toolResults[0].Output) {
t.Fatalf("trace does not describe final post-hook output: events=%#v result=%#v", events, toolResults[0])
}
}
+111 -11
View File
@@ -10,12 +10,23 @@ import (
// Parallel read-ahead for tool batches. When a turn requests several
// independent lookups (read_file + grep + glob is the common shape), executing
// them one after another serializes pure I/O waits. A consecutive run of
// auto-allowed read-only calls is executed concurrently instead; results are
// capability-safe read-only calls is executed concurrently instead; results are
// then consumed in the original call order, so guard counters, message
// ordering, abort semantics, and the surface's call/result event pairing are
// byte-identical to sequential execution. Runs never span a mutating call: a
// read that follows a write must observe the write, so eligibility is decided
// per consecutive run, not per batch.
//
// Eligibility uses the PR5 tool-effect contract (tools.CapabilitiesOf):
//
// Effect == ReadOnly
// AND ThreadSafe == true
// AND auto-allowed (no interactive permission prompt on the hot path)
// AND no resource-key conflict with an earlier call in the same concurrent window
//
// Unknown, mutators, interactive tools, and non-thread-safe reads stay
// sequential. Empty resource keys do not conflict (ThreadSafe is the safety
// gate); shared non-empty keys force a batch boundary.
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
const maxParallelReadTools = 8
@@ -28,28 +39,117 @@ type precomputedToolResult struct {
}
// parallelSafeToolCall reports whether call may run concurrently with its
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
// auto-allowed for these args, so no interactive prompt or workspace mutation
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
// and tool_search (mutates the deferred-tool set) stay sequential.
// neighbors under the PR5 capability contract. Loop-intercepted tools
// (ask_user, request_permissions) and tool_search (mutates the deferred-tool
// set) always stay sequential.
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
switch call.Name {
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
return false
}
tool, found := registry.Get(call.Name)
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
if !found {
return false
}
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return false
}
caps := tools.CapabilitiesOf(tool)
// Fail-closed: only audited concurrent-safe pure reads.
if caps.Effect != tools.EffectReadOnly || !caps.ThreadSafe {
return false
}
args, ok := decodeCallArgs(call)
if !ok {
return false
}
return effectivePermission(tool, args) == tools.PermissionAllow
}
// decodeCallArgs decodes tool call JSON arguments. Returns false on malformed
// input so the call stays sequential (never panics into the parallel path).
func decodeCallArgs(call ToolCall) (map[string]any, bool) {
args := map[string]any{}
if call.Arguments == "" {
return args, true
}
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil, false
}
return args, true
}
// resourceKeysForCall returns the conflict keys for a call, or nil when the
// tool has no ResourceKeys function / no keys for these args.
func resourceKeysForCall(registry *tools.Registry, call ToolCall) []string {
tool, found := registry.Get(call.Name)
if !found {
return nil
}
caps := tools.CapabilitiesOf(tool)
if caps.ResourceKeys == nil {
return nil
}
args, ok := decodeCallArgs(call)
if !ok {
return nil
}
return caps.ResourceKeys(args)
}
// resourceKeysConflict reports whether two key sets share any non-empty key.
// Empty key sets never conflict: ThreadSafe is the eligibility gate; keys only
// refine conflict detection among tools that declare specific resources.
func resourceKeysConflict(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
seen := make(map[string]struct{}, len(a))
for _, key := range a {
if key == "" {
continue
}
seen[key] = struct{}{}
}
for _, key := range b {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
return true
}
}
return false
}
// extendParallelRun returns the exclusive end index of a consecutive parallel-
// safe run starting at start. The run stops before the first call that is not
// parallel-safe or that conflicts on resource keys with any earlier call in
// the same window (so two read_file calls on the same path stay sequential).
func extendParallelRun(registry *tools.Registry, calls []ToolCall, start int, options Options) int {
if start >= len(calls) || !parallelSafeToolCall(registry, calls[start], options) {
return start
}
end := start + 1
keysWindow := [][]string{resourceKeysForCall(registry, calls[start])}
for end < len(calls) {
if !parallelSafeToolCall(registry, calls[end], options) {
break
}
nextKeys := resourceKeysForCall(registry, calls[end])
conflict := false
for _, prev := range keysWindow {
if resourceKeysConflict(prev, nextKeys) {
conflict = true
break
}
}
if conflict {
break
}
keysWindow = append(keysWindow, nextKeys)
end++
}
return end
}
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
// maxParallelReadTools) and returns results indexed relative to start. All
// execution-side callbacks that can fire inside executeToolCall are serialized
+104
View File
@@ -57,6 +57,20 @@ func (tool *probeTool) Parameters() tools.Schema {
func (tool *probeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
}
// Capabilities maps the probe's SideEffect into the PR5 capability contract so
// parallelSafeToolCall (CapabilitiesOf) can classify reads vs mutators. Without
// this, probes default to EffectUnknown and never enter the concurrent path.
func (tool *probeTool) Capabilities() tools.ToolCapabilities {
switch tool.sideEffect {
case tools.SideEffectRead:
return tools.ToolCapabilities{Effect: tools.EffectReadOnly, ThreadSafe: true}
case tools.SideEffectWrite:
return tools.ToolCapabilities{Effect: tools.EffectWorkspaceWrite, ThreadSafe: false}
default:
return tools.UnknownCapabilities()
}
}
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
id, _ := args["id"].(string)
tool.mu.Lock()
@@ -92,6 +106,11 @@ func TestParallelSafeToolCall(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
// Read-only but not ThreadSafe: must stay sequential under the PR5 gate.
registry.Register(&keyedProbeTool{
probeTool: probeTool{name: "probe_read_serial", sideEffect: tools.SideEffectRead},
threadSafe: false,
})
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
@@ -109,6 +128,91 @@ func TestParallelSafeToolCall(t *testing.T) {
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
t.Fatal("loop-intercepted tools must stay sequential")
}
if parallelSafeToolCall(registry, call("probe_read_serial", `{"id":"a"}`), Options{}) {
t.Fatal("ReadOnly without ThreadSafe must not be parallel-safe")
}
}
func TestResourceKeysConflict(t *testing.T) {
if resourceKeysConflict(nil, []string{"file:a"}) {
t.Fatal("empty vs non-empty must not conflict")
}
if resourceKeysConflict([]string{"file:a"}, nil) {
t.Fatal("non-empty vs empty must not conflict")
}
if resourceKeysConflict([]string{"file:a"}, []string{"file:b"}) {
t.Fatal("distinct keys must not conflict")
}
if !resourceKeysConflict([]string{"file:a", "file:b"}, []string{"file:b"}) {
t.Fatal("shared key must conflict")
}
if resourceKeysConflict([]string{"", "file:a"}, []string{""}) {
t.Fatal("empty-string keys must be ignored")
}
}
func TestExtendParallelRunResourceKeyBoundary(t *testing.T) {
// Two keyed reads on the same path must not share a concurrent window;
// distinct paths may batch together.
registry := tools.NewRegistry()
registry.Register(&keyedProbeTool{
probeTool: probeTool{name: "keyed_read", sideEffect: tools.SideEffectRead},
threadSafe: true,
keys: func(args map[string]any) []string {
id, _ := args["id"].(string)
if id == "" {
return nil
}
return []string{"file:" + id}
},
})
calls := []ToolCall{
{ID: "1", Name: "keyed_read", Arguments: `{"id":"a"}`},
{ID: "2", Name: "keyed_read", Arguments: `{"id":"b"}`},
{ID: "3", Name: "keyed_read", Arguments: `{"id":"a"}`}, // conflicts with call 0
}
// start=0: a and b share no key → run covers [0,2)
if end := extendParallelRun(registry, calls, 0, Options{}); end != 2 {
t.Fatalf("extend from 0 = %d, want 2 (stop before second file:a)", end)
}
// start=2: single remaining call
if end := extendParallelRun(registry, calls, 2, Options{}); end != 3 {
t.Fatalf("extend from 2 = %d, want 3", end)
}
// Empty keys never conflict: three keyless safe reads form one window.
registry.Register(&probeTool{name: "keyless_read", sideEffect: tools.SideEffectRead})
keyless := []ToolCall{
{ID: "1", Name: "keyless_read", Arguments: `{"id":"x"}`},
{ID: "2", Name: "keyless_read", Arguments: `{"id":"y"}`},
{ID: "3", Name: "keyless_read", Arguments: `{"id":"z"}`},
}
if end := extendParallelRun(registry, keyless, 0, Options{}); end != 3 {
t.Fatalf("keyless extend = %d, want 3", end)
}
}
// keyedProbeTool is a probe with explicit ResourceKeys / ThreadSafe for
// planner unit tests.
type keyedProbeTool struct {
probeTool
threadSafe bool
keys func(args map[string]any) []string
}
func (tool *keyedProbeTool) Capabilities() tools.ToolCapabilities {
caps := tools.ToolCapabilities{
Effect: tools.EffectReadOnly,
ThreadSafe: tool.threadSafe,
}
if tool.keys != nil {
caps.ResourceKeys = tool.keys
}
// Honor mutator side effects from the embedded probe when used as write.
if tool.sideEffect == tools.SideEffectWrite {
caps.Effect = tools.EffectWorkspaceWrite
caps.ThreadSafe = false
}
return caps
}
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "npm install",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
t.Fatal("an already-covered capability must not trigger another prompt")
}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("covered capability should still produce an auditable allow event")
}
if event.Action != PermissionActionAllow {
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
}
}
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "make test"}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("generic shell approval must produce an event")
}
if event.Reason != "" {
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
}
if event.DecisionReason != "runs a shell command" {
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
}
}
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "curl https://example.test",
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
"justification": "Run without sandboxing.",
}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("policy approval must produce an event")
}
if event.Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
}
}
@@ -0,0 +1,118 @@
package agent
import (
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
// call to require_escalated.
func escalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
policy := sandbox.DefaultPolicy()
policy.DenyRead = nil
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
}
// nonEscalatingEngine has a denied read, which is exactly the condition under
// which escalation is refused, so no disclosure should be made.
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
root := t.TempDir()
policy := sandbox.DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
}
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
// agree on every input, because the defect being fixed is precisely that the
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
// only "allow command prefix for session".
//
// Asserting agreement rather than asserting each side's expected value is what
// makes this hold up. If someone later widens or narrows the rewrite's guards
// and forgets the flag, the two diverge here and this fails, which a pair of
// independent expected-value tests would not catch.
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
escalating := Options{Sandbox: escalatingEngine(t)}
denied := Options{Sandbox: nonEscalatingEngine(t)}
cases := []struct {
name string
toolName string
args map[string]any
options Options
}{
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
{
"call already asks for escalation",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
escalating,
},
{
"call already asks for additional permissions",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
escalating,
},
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
// the disclosure has to fire. Included because the obvious reading of
// the guards says otherwise, and a case asserting the wrong thing here
// would have passed silently.
{
"unrecognized sandbox_permissions value",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
escalating,
},
{"empty args", "bash", map[string]any{}, escalating},
{"nil args", "bash", nil, escalating},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
// What the user's approval would actually be turned into.
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
if disclosed != escalates {
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
}
})
}
}
// The two prefix decisions are the only ones that escalate, so a disclosure
// attached to a request offering neither would be a false alarm. Checked
// against the same predicate the rewrite consults.
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
escalating := map[PermissionDecisionAction]bool{
PermissionDecisionAllowPrefix: true,
PermissionDecisionAlwaysAllowPrefix: true,
}
for _, action := range []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowStrict,
PermissionDecisionAllowForSession,
PermissionDecisionAllowPrefix,
PermissionDecisionAlwaysAllowPrefix,
PermissionDecisionAlwaysAllow,
PermissionDecisionDeny,
PermissionDecisionCancel,
} {
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package agent
import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// profileController observes per-turn run signals and decides at most one
// posture escalation per run for the armed execution profile. A nil policy (or
// a policy without Escalate) makes every method a no-op, so runs without a
// profile are byte-identical to before — the same opt-in convention as Trace
// and SelfCorrect.
//
// The controller only ever CHANGES loop policy knobs (turn ceiling, reasoning
// effort, completion gate) through the values the loop applies at its act
// point; it never injects messages, never touches the model or session, and
// never interacts with the model-initiated escalate_model path.
type profileController struct {
policy *PostureEscalation
escalated bool
uncertainSeen int
failureTripped bool
riskTripped bool
scTripped bool
uncertainTrip bool
}
// newProfileController is nil-safe: a nil ProfilePolicy (the default for every
// existing caller) or a nil Escalate yields a controller that observes and
// decides nothing.
func newProfileController(policy *ProfilePolicy) *profileController {
if policy == nil {
return &profileController{}
}
return &profileController{policy: policy.Escalate}
}
// observeToolOutcome watches two signals from an executed tool call: the
// repeated-failure guard's streak (reusing the guard's own counting, not a
// second failure model) and the sandbox risk classification of an executed
// result.
func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, result ToolResult) {
if c.policy == nil || c.escalated {
return
}
if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak {
c.failureTripped = true
}
if threshold := riskRank(c.policy.OnRiskyMutation); threshold > 0 &&
result.DenialReason == DenialNone &&
riskRank(result.Risk.Level) >= threshold {
// The call EXECUTED (was not denied): partial failures count too, since
// the mutation ran. An unrecognized threshold ranks 0 and disables the
// signal instead of matching everything.
c.riskTripped = true
}
}
// observeUncertain counts uncertain completion evaluations (continue nudges and
// semantic checks). Headless-only by construction: the completion gate never
// runs interactively.
func (c *profileController) observeUncertain() {
if c.policy == nil || c.escalated || c.policy.OnCompletionUncertain <= 0 {
return
}
c.uncertainSeen++
if c.uncertainSeen >= c.policy.OnCompletionUncertain {
c.uncertainTrip = true
}
}
// observeSelfCorrect watches the post-edit verification outcome. Any failing
// outcome (correcting, reported, aborted) is a signal; passed and disabled are
// not.
func (c *profileController) observeSelfCorrect(outcome Outcome) {
if c.policy == nil || c.escalated || !c.policy.OnSelfCorrectFailure {
return
}
switch outcome {
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
c.scTripped = true
}
}
// maybeEscalate reports the escalation target exactly once, the first time any
// armed trigger has fired. Subsequent calls return false for the rest of the
// run: escalation is one-shot and never de-escalates, so no cooldown state is
// needed.
func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
if c.policy == nil || c.escalated {
return PostureEscalation{}, false
}
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
return PostureEscalation{}, false
}
c.escalated = true
return *c.policy, true
}
// riskRank orders sandbox risk levels for threshold comparison. Unknown levels
// rank 0: an unrecognized RESULT level can never meet a valid threshold, and an
// unrecognized THRESHOLD is rejected before comparison (rank 0 disables the
// signal) so a typo in a profile can never make every result match.
func riskRank(level sandbox.RiskLevel) int {
switch level {
case sandbox.RiskLow:
return 1
case sandbox.RiskMedium:
return 2
case sandbox.RiskHigh:
return 3
case sandbox.RiskCritical:
return 4
default:
return 0
}
}
+345
View File
@@ -0,0 +1,345 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
for _, controller := range []*profileController{
newProfileController(nil),
newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil
} {
controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
controller.observeUncertain()
controller.observeSelfCorrect(OutcomeAborted)
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("nil policy must never escalate")
}
}
}
func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}})
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("streak below threshold must not escalate")
}
controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError})
target, fired := controller.maybeEscalate()
if !fired || target.MaxTurns != 80 {
t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target)
}
}
func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}})
// Medium risk on an executed result: below threshold.
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("medium risk must not trip a high threshold")
}
// Critical risk on a DENIED result: never counts (the mutation never ran).
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("a denied result must not trip the risky-mutation signal")
}
// Critical risk on an executed PARTIAL FAILURE (error status, no denial):
// the mutation ran, so it counts.
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("an executed partial failure at critical risk must trip a high threshold")
}
}
func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}})
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("an unrecognized threshold must disable the signal, not match every result")
}
}
func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}})
controller.observeSelfCorrect(OutcomePassed)
controller.observeSelfCorrect(OutcomeDisabled)
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("passing/disabled outcomes must not escalate")
}
controller.observeSelfCorrect(OutcomeReported)
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("a failing verification outcome must escalate")
}
}
func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}})
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("first uncertain evaluation must not trip a threshold of 2")
}
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("second uncertain evaluation must escalate")
}
}
func TestProfileControllerEscalatesAtMostOnce(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}})
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("expected first escalation")
}
controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("escalation must be one-shot per run")
}
}
// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act
// point: the completion gate's continue branch escapes the end-of-turn tail,
// so escalation must apply before the continue. A cue turn under the gate
// counts as uncertain, escalates, and the very next request carries the target
// effort.
func TestPostureEscalationOnUncertainCompletion(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("posture-session", "run-2", "fast")
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
Trace: recorder,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
ReasoningEffort: "high",
OnCompletionUncertain: 1,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "Done. All set." {
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
}
if provider.requests[1].ReasoningEffort != "high" {
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort)
}
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
t.Fatalf("posture_escalations = %d, want 1", got)
}
}
// A profile that filled the run's effort displaced the provider default ("");
// RestoreDefaultEffort lets escalation restore that default, which a plain ""
// ReasoningEffort target cannot express (it means "leave untouched").
func TestPostureEscalationRestoresDefaultEffort(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
ReasoningEffort: "low",
RequireCompletionSignal: true,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
RestoreDefaultEffort: true,
OnCompletionUncertain: 1,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "Done. All set." {
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
}
if provider.requests[0].ReasoningEffort != "low" {
t.Fatalf("pre-escalation request effort = %q, want the profile's low", provider.requests[0].ReasoningEffort)
}
if provider.requests[1].ReasoningEffort != "" {
t.Fatalf("post-escalation request effort = %q, want the restored provider default (empty)", provider.requests[1].ReasoningEffort)
}
}
// failingProfileTool always errors with the same signature so the repeated-
// failure guard builds a streak.
type failingProfileTool struct{}
func (failingProfileTool) Name() string { return "flaky_probe" }
func (failingProfileTool) Description() string { return "always fails identically (test)" }
func (failingProfileTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (failingProfileTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (failingProfileTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"}
}
// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path:
// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and
// finishes on a turn the original ceiling would have cut off, with the effort
// target applied to the post-escalation request and the counter emitted.
func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
toolTurn := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn,
toolTurn,
{
{Type: zeroruntime.StreamEventText, Content: "recovered after escalation"},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("posture-session", "run-1", "fast")
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
MaxTurns: 2,
Trace: recorder,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
MaxTurns: 4,
ReasoningEffort: "high",
OnToolFailureStreak: 2,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "recovered after escalation" {
t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns)
}
if len(provider.requests) != 3 {
t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests))
}
if provider.requests[2].ReasoningEffort != "high" {
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort)
}
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
t.Fatalf("posture_escalations = %d, want 1", got)
}
}
// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at
// the loop level: the identical failing script without a profile ends at the
// original ceiling with the max-turns answer.
func TestPostureEscalationAbsentWithoutProfile(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
toolTurn := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) > 3 {
t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "maximum number of turns") {
t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer)
}
}
// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk
// stamp on the unsandboxed path: a read-only tool classifies low.
func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "flaky_probe"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.Risk.Level == "" {
t.Fatal("executed result must carry a risk classification")
}
if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) {
t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level)
}
}
// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a
// tool (unknown name, nil tool) keeps the zero risk value instead of panicking
// or inventing a classification.
func TestUnknownToolResultCarriesZeroRisk(t *testing.T) {
registry := tools.NewRegistry()
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "no_such_tool"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.Status == tools.StatusOK {
t.Fatal("expected an error result for an unknown tool")
}
if result.Risk.Level != "" {
t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level)
}
}
+24 -6
View File
@@ -14,13 +14,26 @@ import (
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
// the auto-mode advertised set, including exec_command: auto mode must expose both
// process creation and write_stdin process interaction or the latter is unusable
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
// Raised deliberately from 3550 on 2026-08-08. view_image (#843) took the set
// to 3588, and #867 trimmed it back to 3578: still 28 tokens over.
//
// The overrun is a rounding error rather than a regression. view_image costs
// 82 tokens, the second cheapest tool in the set, against exec_command's 909
// and request_permissions' 397; it is simply the one that crossed the line.
// Raised rather than paid down because the alternative was trimming a
// description to claw back 28 tokens, degrading a tool's usability to satisfy
// a line drawn against a 2026-07 measurement that predates two tools.
//
// The ratchet did its job: it caught the creep and forced a decision instead
// of a drift. That is the point of it, so keep raising it deliberately rather
// than reflexively.
maxEagerToolSchemaTokens = 3650
)
func TestSystemPromptTokenBudget(t *testing.T) {
@@ -46,6 +59,11 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
// tool deferred leaves it exposed here and changes nothing. Deferral is
// still worth doing for real sessions, it just cannot move this number.
// The levers that do are a smaller schema, one fewer core tool, or a
// deliberate raise.
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)
}
}
+244
View File
@@ -0,0 +1,244 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// promptSubstrings are the cacheable components of the prompt a run sends to a
// model. systemPrompt is the exact joined system-message content. The remaining
// fields retain useful diagnostic boundaries within that prompt and tool list.
type promptSubstrings struct {
systemPrompt string
baseInstructions string
confirmationPolicy string
projectContext string
skills string
tools string
schema string
}
// buildPromptSubstrings builds the prompt and retains both its exact joined
// form and narrower diagnostic components. Callers that already built the
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
// reads.
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
}
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
return promptSubstrings{
systemPrompt: parts.prompt,
baseInstructions: parts.baseInstructions,
confirmationPolicy: parts.confirmationPolicy,
projectContext: parts.projectContext,
skills: parts.skills,
tools: toolsSubstr,
schema: schemaSubstr,
}
}
// toolSubstrings preserves the emitted tool order. Names and descriptions are
// fingerprinted separately from schemas so either kind of drift is identifiable.
// Order is part of request identity: silently sorting here would hide a provider-
// visible reordering and could report a false cache hit.
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
if len(exposed) == 0 {
return "", ""
}
var toolsSB, schemaSB strings.Builder
for _, def := range exposed {
writeLengthPrefixed(&toolsSB, def.Name)
writeLengthPrefixed(&toolsSB, def.Description)
// Parameters is rendered to a canonical JSON string per tool. The
// schema-render cache in internal/agent (used by partitionToolsCached)
// guarantees the same render is byte-identical across turns for the
// same tool, so this substring is a stable hash input.
writeLengthPrefixed(&schemaSB, def.Name)
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
}
return toolsSB.String(), schemaSB.String()
}
func writeLengthPrefixed(sb *strings.Builder, value string) {
fmt.Fprintf(sb, "%d:", len(value))
sb.WriteString(value)
}
// canonicalSchemaString renders a tool's parameter schema to a stable string.
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
// a different hash for the same Parameters value across calls and defeat
// the fingerprint. encoding/json marshals maps with keys sorted
// alphabetically, so json.Marshal is the primary stable render. The
// fallback for the rare non-JSON-compatible value (functions, channels,
// NaN/Inf floats, cyclic references) is a stable key-sorted stringifier
// that walks the value with sorted keys for maps and a leading
// "__non_json:" prefix so a future schema change is visible in the trace
// (a SchemaHash collision from the same value) rather than a silent hash
// drift.
//
// Note: SchemaHash is a stability signal, not a wire-identity signal. The
// bytes json.Marshal produces for a Go map[string]any may differ from
// the bytes the provider actually sends on the wire (provider encoders
// use their own JSON conventions; an Anthropic schema may serialize with
// different whitespace, key order, or number formatting). A consumer
// must NOT assume SchemaHash matches the provider's wire schema — only
// that two turns with the same Go-side Parameters produce the same
// SchemaHash. This is sufficient for the trace's purpose (drift
// detection) but not for a content-based equality check.
func canonicalSchemaString(params map[string]any) string {
if len(params) == 0 {
return ""
}
data, err := json.Marshal(params)
if err != nil {
return "__non_json:" + stableStringify(params)
}
return string(data)
}
// stableStringify renders v to a deterministic string. Maps have their keys
// sorted alphabetically; slices are walked in order; primitives use their
// natural Go format. The function is recursive but bounded by the size of
// the input, so a cyclic reference is not reachable here (the json.Marshal
// fallback is hit when json.Marshal itself returns an error, which for the
// common case is a non-JSON-compatible value, not a cycle — cycles are
// rare in tool schemas and acceptable to mis-render in the fallback path
// since the trace's contract is "the hash is stable for the same input,"
// not "the fallback is lossless"). Used only when json.Marshal fails.
func stableStringify(v any) string {
var sb strings.Builder
writeStable(&sb, v)
return sb.String()
}
func writeStable(sb *strings.Builder, v any) {
switch x := v.(type) {
case nil:
sb.WriteString("null")
case bool:
if x {
sb.WriteString("true")
} else {
sb.WriteString("false")
}
case string:
sb.WriteByte('"')
sb.WriteString(x)
sb.WriteByte('"')
case float64:
// Use %g for compact, deterministic float rendering. NaN and Inf are
// not JSON-encodable (which is why we are in the fallback path) and
// render as "NaN" / "+Inf" / "-Inf" — distinct, stable strings.
fmt.Fprintf(sb, "%g", x)
case float32:
fmt.Fprintf(sb, "%g", float64(x))
case int:
fmt.Fprintf(sb, "%d", x)
case int64:
fmt.Fprintf(sb, "%d", x)
case int32:
fmt.Fprintf(sb, "%d", x)
case uint:
fmt.Fprintf(sb, "%d", x)
case uint64:
fmt.Fprintf(sb, "%d", x)
case uint32:
fmt.Fprintf(sb, "%d", x)
case map[string]any:
sb.WriteByte('{')
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for i, k := range keys {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteByte('"')
sb.WriteString(k)
sb.WriteString(`":`)
writeStable(sb, x[k])
}
sb.WriteByte('}')
case []any:
sb.WriteByte('[')
for i, item := range x {
if i > 0 {
sb.WriteByte(',')
}
writeStable(sb, item)
}
sb.WriteByte(']')
default:
// Last-resort: include the type so distinct values produce distinct
// strings even if their default format collides. Without the type
// tag, fmt.Sprintf("%v", x) for two different types could produce
// the same bytes (rare, but the type prefix makes it impossible).
fmt.Fprintf(sb, "<%T:%v>", x, x)
}
}
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
// not already have one. The agent loop uses computePrefixFingerprint with the
// prompt parts it built once at run start, ensuring tracing observes the exact
// request content without repeating workspace I/O.
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
}
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
systemPrompt := sha256hex(subs.systemPrompt)
base := sha256hex(subs.baseInstructions)
policy := sha256hex(subs.confirmationPolicy)
project := sha256hex(subs.projectContext)
skills := sha256hex(subs.skills)
toolsH := sha256hex(subs.tools)
schema := sha256hex(subs.schema)
complete := sha256hex(strings.Join([]string{
systemPrompt, toolsH, schema,
}, "|"))
return prefixFingerprint{
SystemPromptHash: systemPrompt,
BaseInstructionsHash: base,
ConfirmationPolicyHash: policy,
ProjectContextHash: project,
SkillsHash: skills,
ToolsHash: toolsH,
SchemaHash: schema,
CompletePrefixHash: complete,
}
}
// prefixFingerprint is the agent-side shape of a prompt-prefix fingerprint. It
// is converted to a trace.PrefixHash at the loop boundary (see EmitPrefixHash
// in loop.go) so the trace package does not need to import this one. The field
// names match the trace.PrefixHash JSON tags 1:1.
type prefixFingerprint struct {
SystemPromptHash string
BaseInstructionsHash string
ConfirmationPolicyHash string
ProjectContextHash string
SkillsHash string
ToolsHash string
SchemaHash string
CompletePrefixHash string
}
// sha256hex returns the hex-encoded SHA-256 of s. Empty input produces the
// hash of the empty string, which is a constant; callers that want to
// distinguish "absent" from "empty" should check s before calling.
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
+264
View File
@@ -0,0 +1,264 @@
package agent
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
// the trace is meant to expose: two ComputePrefixFingerprint calls over the
// same Options and the same exposed tool list produce byte-identical hashes.
// This is the regression catch for "did anyone introduce non-determinism into
// the prompt prefix."
func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
exposed := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
{Name: "grep", Parameters: map[string]any{"type": "object"}},
}
first := ComputePrefixFingerprint(opts, exposed)
second := ComputePrefixFingerprint(opts, exposed)
if first != second {
t.Fatalf("fingerprint must be stable across calls with the same input:\n first=%+v\n second=%+v", first, second)
}
if first.CompletePrefixHash == "" {
t.Fatalf("CompletePrefixHash must be non-empty for a non-trivial prompt")
}
}
// Tool order and descriptions are provider-visible and therefore part of the
// cacheable prefix identity.
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
a := []zeroruntime.ToolDefinition{
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
}
b := []zeroruntime.ToolDefinition{
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
}
fpA := ComputePrefixFingerprint(opts, a)
fpB := ComputePrefixFingerprint(opts, b)
if fpA.ToolsHash == fpB.ToolsHash {
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
}
c := append([]zeroruntime.ToolDefinition(nil), a...)
c[0].Description = "read a file"
fpC := ComputePrefixFingerprint(opts, c)
if fpA.ToolsHash == fpC.ToolsHash {
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
}
}
// TestComputePrefixFingerprintSchemaChangeChangesSchemaHash asserts that a
// change to a tool's Parameters schema is visible in the SchemaHash. A schema
// edit that should be cache-stable (e.g. field reorder, doc tweak) will move
// the hash and surface in the trace; a schema edit that legitimately
// invalidates the cache also moves the hash, which is the right behavior.
func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
a := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"version": "v1"}},
}
b := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"version": "v2"}},
}
fpA := ComputePrefixFingerprint(opts, a)
fpB := ComputePrefixFingerprint(opts, b)
if fpA.SchemaHash == fpB.SchemaHash {
t.Fatalf("SchemaHash must change when tool Parameters change: a=%s b=%s", fpA.SchemaHash, fpB.SchemaHash)
}
}
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
// model addendum change) must move the base-instructions hash, which moves
// the complete-prefix hash, which is what the trace is for.
func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T) {
optsA := Options{Cwd: t.TempDir(), SystemPrompt: "core v1"}
optsB := Options{Cwd: t.TempDir(), SystemPrompt: "core v2"}
exposed := []zeroruntime.ToolDefinition{{Name: "noop", Parameters: map[string]any{}}}
fpA := ComputePrefixFingerprint(optsA, exposed)
fpB := ComputePrefixFingerprint(optsB, exposed)
if fpA.BaseInstructionsHash == fpB.BaseInstructionsHash {
t.Fatalf("BaseInstructionsHash must change when SystemPrompt changes: a=%s b=%s", fpA.BaseInstructionsHash, fpB.BaseInstructionsHash)
}
if fpA.CompletePrefixHash == fpB.CompletePrefixHash {
t.Fatalf("CompletePrefixHash must change when any sub-hash changes: a=%s b=%s", fpA.CompletePrefixHash, fpB.CompletePrefixHash)
}
}
// CompletePrefixHash represents the exact joined system prompt and the ordered
// tool definitions. Narrower system-section hashes remain diagnostic only.
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
fp := ComputePrefixFingerprint(opts, exposed)
// Manually compute what the canonical join should be.
expected := sha256hex(strings.Join([]string{
fp.SystemPromptHash,
fp.ToolsHash,
fp.SchemaHash,
}, "|"))
if fp.CompletePrefixHash != expected {
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
}
}
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
changed := base
changed.ResponseStyle = "concise"
fpBase := ComputePrefixFingerprint(base, nil)
fpChanged := ComputePrefixFingerprint(changed, nil)
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
t.Fatal("SystemPromptHash must change when any rendered system section changes")
}
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
t.Fatal("CompletePrefixHash must include the exact full system prompt")
}
}
func TestComputePrefixFingerprintCoversTransientSystemPrompt(t *testing.T) {
base := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
changed := base
changed.TransientSystemPrompt = "turn-only guidance"
fpBase := ComputePrefixFingerprint(base, nil)
fpChanged := ComputePrefixFingerprint(changed, nil)
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
t.Fatal("SystemPromptHash must change for transient system guidance")
}
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
t.Fatal("CompletePrefixHash must cover transient system guidance")
}
}
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
// substrings helper for default Options: which substrings are non-empty,
// which are empty, and that the substring-to-hash round-trip is lossless.
func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
opts := Options{
Cwd: t.TempDir(),
SystemPrompt: "test core",
}
subs := buildPromptSubstrings(opts, nil)
// Invariants the trace depends on (default Options):
// 1. baseInstructions substring equals the core system prompt bytes.
// 2. confirmationPolicy substring is non-empty (the embedded policy
// is always present, post TrimSpace).
// 3. skills, tools, and schema substrings are empty (no skills or
// tools configured for default Options).
if subs.baseInstructions != "test core" {
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
}
if subs.systemPrompt != buildSystemPrompt(opts) {
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
}
if subs.confirmationPolicy == "" {
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
}
if subs.skills != "" {
t.Fatalf("skills substring must be empty for default Options: got %q", subs.skills)
}
// Round-trip: the corresponding fingerprint hashes must equal
// sha256hex of the substrings, with no truncation or reformatting
// between the two layers.
fp := ComputePrefixFingerprint(opts, nil)
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
}
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
}
if fp.ConfirmationPolicyHash != sha256hex(subs.confirmationPolicy) {
t.Fatalf("ConfirmationPolicyHash in fingerprint must equal sha256hex of the substring")
}
}
// TestCanonicalSchemaStringStableForMapParameters asserts the headline
// property the trace needs: the same map[string]any produces the same
// canonical string across calls, even though Go's fmt.Sprintf("%v", m)
// iterates a map in random order. json.Marshal sorts map keys
// alphabetically, which is the fix.
func TestCanonicalSchemaStringStableForMapParameters(t *testing.T) {
params := map[string]any{
"description": "read a file",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []any{"path"},
"type": "object",
}
first := canonicalSchemaString(params)
second := canonicalSchemaString(params)
if first != second {
t.Fatalf("canonicalSchemaString must be stable across calls for the same Parameters:\n first=%s\n second=%s", first, second)
}
if first == "" {
t.Fatal("canonicalSchemaString must produce a non-empty string for a non-empty map")
}
// Two maps with the same key/value pairs (different declaration
// order in source) must produce identical canonical strings. This
// is the property that defeats Go's map iteration randomization.
reordered := map[string]any{
"type": "object",
"required": []any{"path"},
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"description": "read a file",
}
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
t.Fatal("canonicalSchemaString must produce identical output for maps with the same key/value pairs in different declaration orders")
}
}
// TestCanonicalSchemaStringFallbackStableForNonJSONValue exercises the
// non-JSON fallback in canonicalSchemaString: when Parameters contains a
// value json.Marshal cannot encode (here, a channel), the function must
// fall back to a key-sorted stringification that is stable across calls
// and across map-iteration order. The previous fallback used
// fmt.Sprintf("%v", params), which iterates the map in random order and
// produced a different hash for the same value across calls — defeating
// the fingerprint. The current fallback is stable by construction.
func TestCanonicalSchemaStringFallbackStableForNonJSONValue(t *testing.T) {
ch := make(chan int) // channels are not JSON-encodable
params := map[string]any{
"description": "a tool with a non-JSON value",
"channel": ch,
"z_first": 1,
"a_second": 2,
}
first := canonicalSchemaString(params)
second := canonicalSchemaString(params)
if first == "" {
t.Fatal("canonicalSchemaString must produce a non-empty string for the fallback path")
}
if first != second {
t.Fatalf("canonicalSchemaString fallback must be stable across calls:\n first=%s\n second=%s", first, second)
}
// A re-ordered map (same key/value pairs, different declaration order)
// must produce the same fallback string. This is the property the
// key-sorted stringifier provides that fmt.Sprintf("%v", m) does not.
reordered := map[string]any{
"a_second": 2,
"z_first": 1,
"channel": ch,
"description": "a tool with a non-JSON value",
}
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
t.Fatalf("canonicalSchemaString fallback must produce identical output for maps with the same key/value pairs in different declaration orders:\n first=%s\n second=%s", first, canonicalSchemaString(reordered))
}
// The fallback must include the "__non_json:" prefix so a consumer
// can tell the bytes are not a JSON-marshaled schema (a hash collision
// between a json.Marshal result and a stableStringify result is
// possible in theory; the prefix makes the source observable).
if !strings.HasPrefix(first, "__non_json:") {
t.Fatalf("canonicalSchemaString fallback must start with the __non_json: prefix, got: %s", first)
}
}
+64
View File
@@ -121,3 +121,67 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
func isPrunedPlaceholder(content string) bool {
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
}
var supersedableReadTools = map[string]bool{
"read_file": true,
"read_minified_file": true,
"grep": true,
"glob": true,
"list_directory": true,
}
// pruneSupersededReadResults removes only older byte-identical results of the
// same read call. It runs when context pressure already requires a rewrite, so
// it does not trade a warm provider-cache prefix for a small apparent saving.
func pruneSupersededReadResults(messages []zeroruntime.Message) ([]zeroruntime.Message, int) {
type seenResultKey struct {
call string
content string
}
seen := map[seenResultKey]struct{}{}
out := messages
reclaimed := 0
copied := false
for index := len(messages) - 1; index >= 0; index-- {
message := messages[index]
if message.Role != zeroruntime.MessageRoleTool || isPrunedPlaceholder(message.Content) {
continue
}
call, ok := toolCallForResult(messages, index)
if !ok || !supersedableReadTools[call.Name] {
continue
}
key := seenResultKey{
call: call.Name + "\x00" + strings.TrimSpace(call.Arguments),
content: message.Content,
}
if _, exists := seen[key]; exists {
placeholder := fmt.Sprintf("[superseded identical %s result — the newer result remains in context]", call.Name)
if !copied {
out = append([]zeroruntime.Message(nil), messages...)
copied = true
}
out[index].Content = placeholder
reclaimed += max(0, ApproxTextTokens(message.Content)-ApproxTextTokens(placeholder))
continue
}
seen[key] = struct{}{}
}
return out, reclaimed
}
func toolCallForResult(messages []zeroruntime.Message, resultIndex int) (zeroruntime.ToolCall, bool) {
id := messages[resultIndex].ToolCallID
if id == "" {
return zeroruntime.ToolCall{}, false
}
for index := resultIndex - 1; index >= 0; index-- {
for _, call := range messages[index].ToolCalls {
if call.ID == id {
return call, true
}
}
}
return zeroruntime.ToolCall{}, false
}
+73
View File
@@ -25,6 +25,13 @@ func toolCallMsg(id, name string) zeroruntime.Message {
}
}
func toolCallWithArgs(id, name, args string) zeroruntime.Message {
return zeroruntime.Message{
Role: zeroruntime.MessageRoleAssistant,
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name, Arguments: args}},
}
}
func TestPruneSkipsSmallSessions(t *testing.T) {
// Total reclaimable is under the gate → no pruning.
msgs := []zeroruntime.Message{
@@ -120,3 +127,69 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
}
}
func TestPruneSupersededReadResultsOnlyDropsIdenticalOlderReads(t *testing.T) {
body := strings.Repeat("same source line\n", 1000)
messages := []zeroruntime.Message{
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
toolCallWithArgs("different", "read_file", `{"path":"b.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "different", Content: body},
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
t.Fatalf("older duplicate was not pruned: reclaimed=%d output=%q", reclaimed, out[1].Content)
}
if out[3].Content != body || out[5].Content != body {
t.Fatal("different call and newest result must remain exact")
}
if messages[1].Content != body {
t.Fatal("input slice was mutated")
}
}
func TestPruneSupersededReadResultsKeepsChangedAndMutatingResults(t *testing.T) {
messages := []zeroruntime.Message{
toolCallWithArgs("r1", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r1", Content: "version one"},
toolCallWithArgs("r2", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r2", Content: "version two"},
toolCallWithArgs("e1", "edit_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "edited"},
toolCallWithArgs("e2", "edit_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "edited"},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed != 0 {
t.Fatalf("unsafe results were pruned: %d", reclaimed)
}
for index := range messages {
if out[index].Content != messages[index].Content {
t.Fatalf("message %d changed unexpectedly", index)
}
}
}
func TestPruneSupersededReadResultsFindsMatchingBodyAcrossChangedRead(t *testing.T) {
bodyA := strings.Repeat("version a\n", 100)
bodyB := strings.Repeat("version b\n", 100)
messages := []zeroruntime.Message{
toolCallWithArgs("old-a", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old-a", Content: bodyA},
toolCallWithArgs("middle-b", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "middle-b", Content: bodyB},
toolCallWithArgs("new-a", "read_file", `{"path":"a.go"}`),
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new-a", Content: bodyA},
}
out, reclaimed := pruneSupersededReadResults(messages)
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
t.Fatalf("older matching body was not pruned across a changed result: reclaimed=%d output=%q", reclaimed, out[1].Content)
}
if out[3].Content != bodyB || out[5].Content != bodyA {
t.Fatal("changed middle result and newest matching result must remain exact")
}
}
+1 -1
View File
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
},
}
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
}
}
@@ -26,6 +26,16 @@ func TestSpecialistDelegationContext(t *testing.T) {
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
for _, want := range []string{"reduce total context", "Do not delegate small direct work", "bounded assignment"} {
if !strings.Contains(got, want) {
t.Fatalf("missing evidence-driven delegation guidance %q: %q", want, got)
}
}
for _, unwanted := range []string{"delegate to it proactively", "launch several specialists in parallel"} {
if strings.Contains(got, unwanted) {
t.Fatalf("retained broad delegation guidance %q: %q", unwanted, got)
}
}
}
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
+54 -19
View File
@@ -11,6 +11,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -70,6 +71,22 @@ const (
// guidelines), and the safety confirmation policy. It is built once per run so
// every turn shares one (cacheable) system turn.
func buildSystemPrompt(options Options) string {
return buildSystemPromptParts(options).prompt
}
// systemPromptParts retains both the exact joined prompt and the diagnostic
// sections used by prompt-prefix tracing. Building these together prevents the
// tracing path from re-reading workspace state and guarantees that the hash is
// computed from the same prompt bytes placed in the request.
type systemPromptParts struct {
prompt string
baseInstructions string
confirmationPolicy string
projectContext string
skills string
}
func buildSystemPromptParts(options Options) systemPromptParts {
core := strings.TrimSpace(options.SystemPrompt)
if core == "" {
core = strings.TrimSpace(coreSystemPrompt)
@@ -78,6 +95,9 @@ func buildSystemPrompt(options Options) string {
core = fallbackSystemPrompt
}
sections := []string{core}
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
sections = append(sections, transient)
}
if addendum := modelPromptAddendum(options.Model); addendum != "" {
sections = append(sections, addendum)
}
@@ -97,22 +117,34 @@ func buildSystemPrompt(options Options) string {
if user := userGuidelines(); user != "" {
sections = append(sections, user)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
project := workspaceContext(options.Cwd)
if project != "" {
sections = append(sections, project)
}
if modeCtx := permissionModeContext(options); modeCtx != "" {
sections = append(sections, modeCtx)
}
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
skillsBlock := skillsContext(options)
if skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
policy := strings.TrimSpace(confirmationPolicy)
if policy != "" {
sections = append(sections, policy)
}
return strings.Join(sections, "\n\n")
return systemPromptParts{
prompt: strings.Join(sections, "\n\n"),
baseInstructions: core,
confirmationPolicy: policy,
projectContext: project,
skills: skillsBlock,
}
}
// responseStyleContext renders the operator-selected reply style (TUI /style) as
@@ -153,21 +185,19 @@ func approvedCommandPrefixContext(options Options) string {
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
}
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
// tool outputs out of the main context. It renders only when specialists are
// known (which is only where the Task tool is actually registered), so a run with
// no delegatable specialists produces the previous prompt unchanged.
// specialistDelegationContext describes when a specialist earns its extra
// context and coordination cost. It renders only when specialists are known
// (which is only where the Task tool is actually registered), so a run with no
// delegatable specialists produces the previous prompt unchanged.
func specialistDelegationContext(options Options) string {
if len(options.Specialists) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<specialists>\n")
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
b.WriteString("Use the Task tool when a specialist's focused context, expertise, or an independent parallel subtask is likely to improve quality or reduce total context. ")
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
b.WriteString("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
for _, info := range options.Specialists {
name := strings.TrimSpace(info.Name)
@@ -285,11 +315,7 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
@@ -659,3 +685,12 @@ func gitBranchForPrompt(cwd string) string {
}
return ref
}
func permissionModeContext(options Options) string {
switch options.PermissionMode {
case PermissionModePlan:
return "Plan mode is active on this session. Your role is read-only exploration and planning: inspect the workspace and shape the plan with update_plan, but do not make changes to files or execute commands."
default:
return ""
}
}
+19 -14
View File
@@ -30,16 +30,13 @@ work.
read-before-edit discipline: inspect the target file and nearby callers,
tests, or config before you modify behavior. Never edit a file you have not
read.
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
keep it live. The plan bar is the user's progress signal — call update_plan
after EACH concrete unit of work (every file written, every command run), not
just at coarse milestones: mark the finished item completed and the next one
in_progress before you start it. A plan stuck at 0/N while files are landing is
a bug, not economy — these calls are cheap, expected, and the update_plan cards
are hidden from the transcript, so frequent updates cost the user nothing and
never clutter the conversation. Keep at most one item in_progress, and never
batch the updates to the end of the turn. Skip the plan for trivial one-step
tasks.
2. **Plan.** Use update_plan for implementation or investigation that has at
least three meaningful dependent steps, then keep it current at milestones.
Mark completed work and the next in-progress step together when practical;
do not spend separate turns updating it after every file or command. Keep at
most one item in_progress. Skip plans for simple lookups, explanations, code
navigation, and other short tasks. Never create a plan after the work is
already complete merely to describe what you did.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -54,11 +51,16 @@ work.
## Editing discipline
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
apply_patch - over shelling out to cat/sed/awk/python for file operations.
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- Prefer read_minified_file when initially exploring source code; it preserves
code while removing comments and redundant whitespace. Use read_file when
exact text, comments, or line numbers are needed.
- 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
@@ -100,6 +102,9 @@ work.
- Use exec_command with `tty: true` for interactive terminal-style commands that
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
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.
- 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
+2 -1
View File
@@ -71,5 +71,6 @@ const geminiPromptAddendum = `<model_guidance>
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
- Use update_plan only for work with at least three meaningful dependent steps;
skip it for simple lookups and never create one after the work is complete.
</model_guidance>`
+10
View File
@@ -51,6 +51,16 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
}
}
func TestTransientSystemPromptIsIncludedOnlyWhenProvided(t *testing.T) {
const transient = "Handle this peer request under the current session policy."
if prompt := buildSystemPrompt(Options{}); strings.Contains(prompt, transient) {
t.Fatal("ordinary system prompt unexpectedly contains transient instructions")
}
if prompt := buildSystemPrompt(Options{TransientSystemPrompt: transient}); !strings.Contains(prompt, transient) {
t.Fatalf("transient instructions missing from system prompt:\n%s", prompt)
}
}
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
for _, want := range []string{
+566
View File
@@ -0,0 +1,566 @@
package agent
import (
"crypto/sha256"
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type taskStatus string
const (
taskStatusActive taskStatus = "active"
taskStatusComplete taskStatus = "complete"
taskStatusIncomplete taskStatus = "incomplete"
)
type taskPlanParity string
const (
taskPlanParityUnknown taskPlanParity = "unknown"
taskPlanParityMatch taskPlanParity = "match"
taskPlanParityMismatch taskPlanParity = "mismatch"
)
type taskPlanItem struct {
Content string `json:"content"`
Status string `json:"status"`
Notes string `json:"notes,omitempty"`
}
type taskPlanState struct {
Items []taskPlanItem `json:"items,omitempty"`
Pending int `json:"pending"`
InProgress int `json:"in_progress"`
Completed int `json:"completed"`
Failed int `json:"failed"`
}
type taskToolState struct {
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
type taskVerificationState struct {
Passed int `json:"passed"`
Failed int `json:"failed"`
LastOutcome Outcome `json:"last_outcome,omitempty"`
}
type taskFailureState struct {
Key string `json:"key,omitempty"`
Tool string `json:"tool"`
Command string `json:"command,omitempty"`
Summary string `json:"summary"`
}
type taskApprovalState struct {
Tool string `json:"tool"`
Decision PermissionDecisionAction `json:"decision,omitempty"`
Scope string `json:"scope,omitempty"`
Reason string `json:"reason,omitempty"`
}
type taskArtifactState struct {
Tool string `json:"tool"`
Path string `json:"path"`
}
type taskStateSnapshot struct {
Revision int `json:"revision"`
Objective string `json:"objective"`
Status taskStatus `json:"status"`
Plan taskPlanState `json:"plan"`
Tools taskToolState `json:"tools"`
Verification taskVerificationState `json:"verification"`
ChangedFiles []string `json:"changed_files,omitempty"`
Constraints []string `json:"constraints,omitempty"`
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
Approvals []taskApprovalState `json:"approvals,omitempty"`
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
ResolvedFailureKeys []string `json:"-"`
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
CompletionReason string `json:"completion_reason,omitempty"`
PlanParity taskPlanParity `json:"plan_parity"`
}
type taskStateEventKind int
const (
taskStateEventPlan taskStateEventKind = iota + 1
taskStateEventToolResult
taskStateEventVerification
taskStateEventCompletion
taskStateEventPermission
)
type taskStateEvent struct {
kind taskStateEventKind
arguments string
toolResult ToolResult
verification Outcome
completion completionEvaluation
permission PermissionEvent
}
// taskState is a deterministic projection of facts the loop already observes.
// It does not initiate work or replace the transcript; consumers must check
// transcript parity before using its compact representation.
type taskState struct {
snapshotValue taskStateSnapshot
changedFiles map[string]struct{}
recorder *trace.Recorder
planObserved bool
}
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
state := &taskState{
snapshotValue: taskStateSnapshot{
Objective: strings.TrimSpace(objective),
Status: taskStatusActive,
PlanParity: taskPlanParityUnknown,
},
changedFiles: map[string]struct{}{},
}
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
state.recorder = recorder
return state
}
func (state *taskState) observe(event taskStateEvent) bool {
if state == nil {
return false
}
changed := false
switch event.kind {
case taskStateEventPlan:
plan, ok := parseTaskPlan(event.arguments)
if !ok {
return false
}
state.snapshotValue.Plan = summarizeTaskPlan(plan)
state.planObserved = true
state.markActive()
changed = true
case taskStateEventToolResult:
state.markActive()
if event.toolResult.Status == tools.StatusOK {
state.snapshotValue.Tools.Succeeded++
} else {
state.snapshotValue.Tools.Failed++
}
for _, path := range event.toolResult.ChangedFiles {
path = strings.TrimSpace(path)
if path != "" {
state.changedFiles[path] = struct{}{}
}
}
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
state.observeToolEvidence(event.arguments, event.toolResult)
changed = true
case taskStateEventVerification:
if event.verification == OutcomeDisabled {
return false
}
state.markActive()
state.snapshotValue.Verification.LastOutcome = event.verification
switch event.verification {
case OutcomePassed:
state.snapshotValue.Verification.Passed++
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
state.snapshotValue.Verification.Failed++
}
changed = true
case taskStateEventCompletion:
state.snapshotValue.CompletionDecision = event.completion.Decision
state.snapshotValue.CompletionReason = event.completion.Reason
switch event.completion.Decision {
case CompletionComplete:
state.snapshotValue.Status = taskStatusComplete
case CompletionIncomplete:
state.snapshotValue.Status = taskStatusIncomplete
default:
state.snapshotValue.Status = taskStatusActive
}
changed = true
case taskStateEventPermission:
state.markActive()
state.observePermission(event.permission)
changed = true
}
if changed {
state.snapshotValue.Revision++
if event.kind != taskStateEventToolResult {
state.emit()
}
}
return changed
}
func (state *taskState) markActive() {
state.snapshotValue.Status = taskStatusActive
state.snapshotValue.CompletionDecision = ""
state.snapshotValue.CompletionReason = ""
}
func (state *taskState) snapshot() taskStateSnapshot {
if state == nil {
return taskStateSnapshot{}
}
snapshot := state.snapshotValue
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
return snapshot
}
const (
maxTaskEvidenceEntries = 8
maxTaskEvidenceBytes = 320
maxTaskConstraints = 10
)
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
command := capTaskEvidence(commandFromArguments(arguments))
key := taskFailureKey(result.Name, command, arguments)
if result.Status == tools.StatusOK {
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
}
} else {
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
}, maxTaskEvidenceEntries)
}
for _, metaKey := range []string{"spill_path", "artifact_path"} {
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
}
}
}
func taskFailureKey(toolName, command, arguments string) string {
identity := command
if identity == "" {
canonical := []byte(strings.TrimSpace(arguments))
var value any
if json.Unmarshal(canonical, &value) == nil {
if encoded, err := json.Marshal(value); err == nil {
canonical = encoded
}
}
digest := sha256.Sum256(canonical)
identity = fmt.Sprintf("args:%x", digest[:8])
}
return strings.TrimSpace(toolName) + "\x00" + identity
}
func (state *taskState) observePermission(event PermissionEvent) {
decision := event.DecisionAction
if decision == "" {
decision = PermissionDecisionAction(event.Action)
}
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
}, maxTaskEvidenceEntries)
}
func commandFromArguments(arguments string) string {
var object map[string]json.RawMessage
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
return ""
}
for _, key := range []string{"cmd", "command", "script", "shell"} {
var command string
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
return strings.TrimSpace(command)
}
}
return ""
}
func firstLine(value string) string {
value = strings.TrimSpace(value)
if index := strings.IndexByte(value, '\n'); index >= 0 {
value = value[:index]
}
return value
}
func capTaskEvidence(value string) string {
value = strings.TrimSpace(value)
if len(value) <= maxTaskEvidenceBytes {
return value
}
limit := maxTaskEvidenceBytes - 3
for limit > 0 && !utf8.RuneStart(value[limit]) {
limit--
}
return strings.TrimSpace(value[:limit]) + "..."
}
var explicitConstraintPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
regexp.MustCompile(`(?i)\bdon'?t want\b`),
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
}
func extractExplicitConstraints(text string) []string {
var constraints []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
continue
}
for _, pattern := range explicitConstraintPatterns {
if pattern.MatchString(line) {
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
break
}
}
if len(constraints) == maxTaskConstraints {
break
}
}
return constraints
}
func appendBounded[T any](values []T, value T, limit int) []T {
values = append(values, value)
if len(values) > limit {
values = values[len(values)-limit:]
}
return values
}
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
filtered := values[:0]
for _, existing := range values {
if existing != value {
filtered = append(filtered, existing)
}
}
return appendBounded(filtered, value, limit)
}
func hasTaskFailure(values []taskFailureState, key string) bool {
for _, value := range values {
if value.Key == key {
return true
}
}
return false
}
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
out := values[:0]
for _, value := range values {
if value.Key != key {
out = append(out, value)
}
}
return out
}
func removeString(values []string, target string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value != target {
out = append(out, value)
}
}
return out
}
// observePlanParity compares only the latest plan projection with the plan tool
// calls still present in messages. It mutates the snapshot and emits when the
// parity value changes; objective, tool, and verification fields are not part of
// this comparison.
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
if state == nil {
return taskPlanParityUnknown
}
transcriptPlan, found, valid := latestTaskPlan(messages)
tracked := state.snapshotValue.Plan.Items
parity := taskPlanParityMatch
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
parity = taskPlanParityMismatch
}
if state.snapshotValue.PlanParity != parity {
state.snapshotValue.PlanParity = parity
state.snapshotValue.Revision++
state.emit()
}
return parity
}
type completionContext struct {
Objective string
PlanPending bool
PlanMatchesTranscript bool
}
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
context := completionContext{PlanPending: transcriptPlanPending}
if state == nil {
return context
}
context.Objective = state.snapshotValue.Objective
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
if context.PlanMatchesTranscript {
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
}
return context
}
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
if state == nil {
return nil
}
state.observePlanParity(messages)
snapshot := state.snapshot()
return &snapshot
}
func (state *taskState) emit() {
if state == nil || state.recorder == nil {
return
}
snapshot := state.snapshotValue
state.recorder.EmitTaskState(trace.TaskStateEvent{
Revision: snapshot.Revision,
Status: string(snapshot.Status),
PlanPending: snapshot.Plan.Pending,
PlanInProgress: snapshot.Plan.InProgress,
PlanCompleted: snapshot.Plan.Completed,
PlanFailed: snapshot.Plan.Failed,
ToolsSucceeded: snapshot.Tools.Succeeded,
ToolsFailed: snapshot.Tools.Failed,
VerificationPassed: snapshot.Verification.Passed,
VerificationFailed: snapshot.Verification.Failed,
VerificationOutcome: string(snapshot.Verification.LastOutcome),
ChangedFileCount: len(snapshot.ChangedFiles),
CompletionDecision: string(snapshot.CompletionDecision),
PlanParity: string(snapshot.PlanParity),
})
}
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
var object map[string]json.RawMessage
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
return nil, false
}
rawPlan, ok := object["plan"]
if !ok || string(rawPlan) == "null" {
return nil, false
}
var parsed []struct {
ID *string `json:"id"`
Content string `json:"content"`
Status string `json:"status"`
Notes string `json:"notes"`
}
if json.Unmarshal(rawPlan, &parsed) != nil {
return nil, false
}
plan := make([]taskPlanItem, 0, len(parsed))
for _, raw := range parsed {
if raw.Content == "" {
return nil, false
}
plan = append(plan, taskPlanItem{
Content: raw.Content,
Status: normalizeTaskPlanStatus(raw.Status),
Notes: raw.Notes,
})
}
lastInProgress := -1
for index := range plan {
if plan[index].Status == "in_progress" {
if lastInProgress >= 0 {
plan[lastInProgress].Status = "completed"
}
lastInProgress = index
}
}
if len(plan) == 0 {
return nil, true
}
return plan, true
}
func normalizeTaskPlanStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
return "completed"
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
return "in_progress"
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
return "failed"
default:
return "pending"
}
}
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
for _, item := range items {
switch item.Status {
case "completed":
plan.Completed++
case "in_progress":
plan.InProgress++
case "failed":
plan.Failed++
default:
plan.Pending++
}
}
return plan
}
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
for i := len(messages) - 1; i >= 0; i-- {
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
call := messages[i].ToolCalls[j]
if call.Name != planToolName {
continue
}
plan, ok := parseTaskPlan(call.Arguments)
return plan, true, ok
}
}
return nil, false, true
}
func sortedKeys(values map[string]struct{}) []string {
keys := make([]string, 0, len(values))
for value := range values {
keys = append(keys, value)
}
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
// deterministic even if events originate from parallel read batches later.
sort.Strings(keys)
return keys
}
+266
View File
@@ -0,0 +1,266 @@
package agent
import (
"context"
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestTaskStateReplayIsDeterministic(t *testing.T) {
events := []taskStateEvent{
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
{kind: taskStateEventVerification, verification: OutcomePassed},
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
}
first := newTaskState("ship the change", nil)
second := newTaskState("ship the change", nil)
for _, event := range events {
first.observe(event)
second.observe(event)
}
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
}
got := first.snapshot()
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
t.Fatalf("unexpected plan snapshot: %#v", got)
}
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
t.Fatalf("unexpected evidence snapshot: %#v", got)
}
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
}
}
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
recorder := trace.NewRecorder("session", "run", "")
state := newTaskState("objective", recorder)
for range 20 {
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
}
if events := recorder.Finish().TaskStates; len(events) != 0 {
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
}
recorder = trace.NewRecorder("session", "run-2", "")
state = newTaskState("objective", recorder)
for range 20 {
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
}
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
events := recorder.Finish().TaskStates
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
}
}
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("session", "run", "")
result, err := Run(context.Background(), "implement the change", provider, Options{
Registry: registry,
Trace: recorder,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
events := recorder.Finish().TaskStates
if len(events) < 3 {
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
}
last := events[len(events)-1]
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
t.Fatalf("unexpected final task snapshot: %#v", last)
}
}
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
snapshot := state.snapshot()
snapshot.Plan.Items[0].Content = "mutated"
snapshot.ChangedFiles[0] = "mutated.go"
fresh := state.snapshot()
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
}
}
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
arguments := `{"cmd":"go test ./internal/agent"}`
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
}})
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
Scope: "/tmp", DecisionReason: "write a temporary fixture",
}})
snapshot := state.snapshot()
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
}
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
}
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
}
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
}})
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
}})
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
}
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
}})
snapshot = state.snapshot()
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
}
for _, key := range snapshot.ResolvedFailureKeys {
if key == snapshot.UnresolvedFailures[0].Key {
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
}
}
}
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
}}}
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
t.Fatalf("parity = %q, want match", parity)
}
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
t.Fatalf("parity = %q, want mismatch", parity)
}
}
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
state := newTaskState("objective", nil)
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
snapshot := state.snapshot()
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
}
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
}
empty := newTaskState("objective", nil)
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
}
}
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
}}}
context := state.completionContext(messages, true)
if !context.PlanPending || context.PlanMatchesTranscript {
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
}
if context.Objective != "objective" {
t.Fatalf("objective lost from completion context: %#v", context)
}
}
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
}}}
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
}
mismatching := append([]zeroruntime.Message(nil), matching...)
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
}
}
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
for _, arguments := range []string{
`{}`,
`{"plan":null}`,
`{"plan":[{"step":"alias is not accepted"}]}`,
`{"plan":[{"content":""}]}`,
`{"plan":[{"content":"valid"},{"content":""}]}`,
`{"plan":[{"id":4,"content":"valid"}]}`,
} {
if plan, ok := parseTaskPlan(arguments); ok {
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
}
}
}
+207
View File
@@ -0,0 +1,207 @@
package agent
import (
"context"
"fmt"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// imageTool returns a tool result carrying an image, the way a screenshot tool
// should be able to.
type imageTool struct {
media string
data []byte
}
func (imageTool) Name() string { return "capture" }
func (imageTool) Description() string { return "Captures an image." }
func (imageTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (imageTool) Safety() tools.Safety {
return tools.Safety{Permission: tools.PermissionAllow}
}
func (t imageTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{
Status: tools.StatusOK,
Output: "Captured a screenshot.",
Images: []zeroruntime.ImageBlock{{MediaType: t.media, Data: t.data}},
}
}
// A tool that produces an image must get that image in front of the model.
//
// It cannot ride the tool-result message: every provider drops images there.
// Anthropic maps a tool result to a tool_result block with string content,
// Gemini to a functionResponse, and OpenAI guards its image parts to the user
// role explicitly. So the image has to arrive as a following user message,
// which is also the only shape that keeps one tool result per tool call.
func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "I can see it."},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "screenshot please", provider, Options{
Registry: registry,
MaxTurns: 2,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
var carrier *zeroruntime.Message
for index := range result.Messages {
if len(result.Messages[index].Images) > 0 {
carrier = &result.Messages[index]
break
}
}
if carrier == nil {
t.Fatal("no message carried the tool's image; the model never saw it")
}
if carrier.Role != zeroruntime.MessageRoleUser {
t.Errorf("image rode a %q message; every provider only serializes images on the user role", carrier.Role)
}
if got := carrier.Images[0].MediaType; got != "image/png" {
t.Errorf("media type = %q, want image/png", got)
}
// The tool-result pairing must be untouched: one tool result per tool call.
toolResults := 0
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResults++
}
}
if toolResults != 1 {
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
}
}
// textTool is a second, image-free tool so a turn can carry two calls.
type textTool struct{}
func (textTool) Name() string { return "note" }
func (textTool) Description() string { return "Notes something." }
func (textTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (textTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
func (textTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "noted"}
}
// Tool results for one assistant turn must stay contiguous. The image messages
// are user-role, and a user message between two tool_results breaks strict
// provider replay: Anthropic coalesces consecutive user content into one block
// list and requires tool_result blocks first, so interleaving produces
// [tool_result, text, image, tool_result] and the request is rejected.
//
// The first version of this feature appended each image immediately after its
// own tool result, which is exactly that shape. A single-tool-call test passed
// and said nothing about it.
func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
registry.Register(textTool{})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "capture"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "note"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c2", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
if err != nil {
t.Fatalf("Run: %v", err)
}
// Two separate ifs rather than a switch: a tool-role message that also
// carried images would match only the first arm of a switch, so a regression
// that put the image back on the tool result would read as "no image at all"
// instead of naming what actually happened.
type toolResult struct {
index int
id string
}
var toolResults []toolResult
imageIndex := -1
for index, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResults = append(toolResults, toolResult{index: index, id: message.ToolCallID})
}
if len(message.Images) > 0 {
if imageIndex >= 0 {
t.Fatalf("image delivered twice, at %d and %d; recorded %s", imageIndex, index, messageShape(result.Messages))
}
imageIndex = index
}
}
if imageIndex < 0 {
t.Fatalf("the image never reached the model; recorded %s", messageShape(result.Messages))
}
if len(toolResults) != 2 {
t.Fatalf("got %d tool-result messages for 2 tool calls; recorded %s", len(toolResults), messageShape(result.Messages))
}
if toolResults[0].id != "c1" || toolResults[1].id != "c2" {
t.Errorf("tool results answered %q then %q, want c1 then c2", toolResults[0].id, toolResults[1].id)
}
// The contract Anthropic enforces: every tool_result block has to come before
// any other block in the user message it lands in. Anything wedged between
// the two results breaks that, which is the 400 this PR exists to avoid.
if toolResults[1].index != toolResults[0].index+1 {
t.Errorf("tool results at %d and %d are not adjacent; %q message at %d splits them; recorded %s",
toolResults[0].index, toolResults[1].index, result.Messages[toolResults[0].index+1].Role,
toolResults[0].index+1, messageShape(result.Messages))
}
// Follows the last tool result, deliberately NOT pinned to exactly last+1.
// appendUserBlocks coalesces consecutive user-role content, so
// [tool_result, tool_result, text, image] still maps to tool_result blocks
// first and replays fine; pinning would redden that valid shape for nothing.
if imageIndex <= toolResults[1].index {
t.Errorf("image message at %d does not follow the last tool result at %d; recorded %s",
imageIndex, toolResults[1].index, messageShape(result.Messages))
}
}
// messageShape renders the conversation as a compact role list so a failure says
// what the ordering actually was instead of dumping whole messages.
func messageShape(messages []zeroruntime.Message) string {
parts := make([]string, 0, len(messages))
for index, message := range messages {
part := fmt.Sprintf("%d:%s", index, message.Role)
if message.ToolCallID != "" {
part += "(" + message.ToolCallID + ")"
}
if len(message.Images) > 0 {
part += "+image"
}
parts = append(parts, part)
}
return "[" + strings.Join(parts, " ") + "]"
}
+21
View File
@@ -0,0 +1,21 @@
package agent
import (
"context"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// sessionProvider adapts a TurnSession back to the Provider interface so the
// loop's existing Provider-shaped call sites (streamWithReconnect, the
// compaction summarizer, finalAnswerAfterMaxTurns) route their stream I/O
// through the session without any signature change. For the default adapter
// this reduces to the wrapped provider's StreamCompletion, so behavior is
// byte-identical; an optimized session (PR8) takes effect here transparently.
type sessionProvider struct {
session zeroruntime.TurnSession
}
func (s sessionProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return s.session.Stream(ctx, request)
}
+367
View File
@@ -0,0 +1,367 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// fakeTurnSession wraps an inner provider for streaming while counting the
// session lifecycle, so tests can observe exactly how the loop drives it.
type fakeTurnSession struct {
inner Provider
prewarms int
streams int
closes int
closeErr error
}
func (s *fakeTurnSession) Prewarm(context.Context) error { s.prewarms++; return nil }
func (s *fakeTurnSession) Stream(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
s.streams++
return s.inner.StreamCompletion(ctx, request)
}
func (s *fakeTurnSession) Compact(context.Context, zeroruntime.CompletionRequest) ([]zeroruntime.Message, error) {
return nil, zeroruntime.ErrCompactionUnsupported
}
func (s *fakeTurnSession) Close() error { s.closes++; return s.closeErr }
// fakeTurnSessionProvider opens a scripted session (or fails to).
type fakeTurnSessionProvider struct {
session *fakeTurnSession
openErr error
opens int
}
func (p *fakeTurnSessionProvider) OpenTurnSession(context.Context) (zeroruntime.TurnSession, error) {
p.opens++
if p.openErr != nil {
return nil, p.openErr
}
return p.session, nil
}
func (p *fakeTurnSessionProvider) Capabilities() zeroruntime.ProviderCapabilities {
return zeroruntime.ProviderCapabilities{}
}
func singleAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
return [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: answer},
{Type: zeroruntime.StreamEventDone},
}}
}
// TestRunOpenTurnSessionFailureSurfaces verifies a failed session open is a
// clean run-start error: no panic, no stream ever issued, nothing to close.
func TestRunOpenTurnSessionFailureSurfaces(t *testing.T) {
positional := &mockProvider{turns: singleAnswerTurns("never reached")}
tsp := &fakeTurnSessionProvider{openErr: errors.New("handshake refused")}
_, err := Run(context.Background(), "go", positional, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err == nil {
t.Fatal("expected an error from a failed session open")
}
if !strings.Contains(err.Error(), "open turn session") || !strings.Contains(err.Error(), "handshake refused") {
t.Fatalf("expected a wrapped open-turn-session error, got %v", err)
}
if len(positional.requests) != 0 {
t.Fatalf("expected no provider request after a failed open, got %d", len(positional.requests))
}
if tsp.opens != 1 {
t.Fatalf("expected exactly one open attempt, got %d", tsp.opens)
}
}
// TestRunCloseFailureIsSafe verifies a Close error at teardown is swallowed:
// the run still returns its normal result.
func TestRunCloseFailureIsSafe(t *testing.T) {
inner := &mockProvider{turns: singleAnswerTurns("done")}
session := &fakeTurnSession{inner: inner, closeErr: errors.New("close blew up")}
tsp := &fakeTurnSessionProvider{session: session}
result, err := Run(context.Background(), "go", inner, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err != nil {
t.Fatalf("expected Close failure to be swallowed, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected normal result despite Close error, got %q", result.FinalAnswer)
}
if session.closes != 1 {
t.Fatalf("expected exactly one Close at teardown, got %d", session.closes)
}
}
// TestRunStreamsThroughTurnSession verifies that when a TurnSessionProvider is
// wired, every model request of the run flows through the session (and the
// session is prewarmed once and closed once).
func TestRunStreamsThroughTurnSession(t *testing.T) {
inner := &mockProvider{turns: singleAnswerTurns("via session")}
session := &fakeTurnSession{inner: inner}
tsp := &fakeTurnSessionProvider{session: session}
// The positional provider is a DIFFERENT mock: it must stay untouched,
// proving the session (not the positional provider) carried the run.
positional := &mockProvider{turns: singleAnswerTurns("wrong path")}
result, err := Run(context.Background(), "go", positional, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "via session" {
t.Fatalf("expected the session-backed answer, got %q", result.FinalAnswer)
}
if len(positional.requests) != 0 {
t.Fatalf("expected the positional provider to be bypassed, got %d requests", len(positional.requests))
}
if session.streams == 0 || session.streams != len(inner.requests) {
t.Fatalf("expected every request to flow through the session: streams=%d inner=%d", session.streams, len(inner.requests))
}
if session.prewarms != 1 {
t.Fatalf("expected exactly one prewarm, got %d", session.prewarms)
}
if session.closes != 1 {
t.Fatalf("expected exactly one close, got %d", session.closes)
}
}
// TestRunDefaultTurnSessionMatchesRawProvider verifies the default (nil
// TurnSessionProvider) path is behavior-identical to passing an explicit
// default adapter over the same provider: same result, same request count.
func TestRunDefaultTurnSessionMatchesRawProvider(t *testing.T) {
run := func(explicit bool) (Result, *mockProvider) {
provider := &mockProvider{turns: escalateThenAnswerTurns("identical")}
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: ""})
options := Options{Registry: registry, MaxTurns: 4}
if explicit {
options.TurnSessionProvider = zeroruntime.NewProviderTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{})
}
result, err := Run(context.Background(), "go", provider, options)
if err != nil {
t.Fatal(err)
}
return result, provider
}
defaultResult, defaultProvider := run(false)
explicitResult, explicitProvider := run(true)
if defaultResult.FinalAnswer != explicitResult.FinalAnswer {
t.Fatalf("final answers diverged: default=%q explicit=%q", defaultResult.FinalAnswer, explicitResult.FinalAnswer)
}
if defaultResult.Turns != explicitResult.Turns {
t.Fatalf("turn counts diverged: default=%d explicit=%d", defaultResult.Turns, explicitResult.Turns)
}
if len(defaultProvider.requests) != len(explicitProvider.requests) {
t.Fatalf("request counts diverged: default=%d explicit=%d", len(defaultProvider.requests), len(explicitProvider.requests))
}
}
// TestRunModelSwitchClosesSessionAndContinues verifies a mid-run model switch
// closes the active session exactly once and streams the rest of the run on
// the new provider.
func TestRunModelSwitchClosesSessionAndContinues(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
firstSession := &fakeTurnSession{inner: firstInner}
tsp := &fakeTurnSessionProvider{session: firstSession}
secondProvider := &mockProvider{turns: singleAnswerTurns("answered after switch")}
result, err := Run(context.Background(), "go", firstInner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: tsp,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return secondProvider, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "answered after switch" {
t.Fatalf("expected the post-switch answer, got %q", result.FinalAnswer)
}
// The original session handled exactly the escalation turn, then was closed
// once by the swap (the teardown defer closes the NEW session, not this one).
if firstSession.streams != 1 {
t.Fatalf("expected the original session to stream exactly the escalation turn, got %d", firstSession.streams)
}
if firstSession.closes != 1 {
t.Fatalf("expected the swap to close the original session exactly once, got %d", firstSession.closes)
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected the post-switch turn on the new provider, got %d requests", len(secondProvider.requests))
}
}
// TestRunModelSessionSwitcherCarriesOptimizedSession verifies the target-aware
// switcher: the switched-to TurnSessionProvider's DISTINCT session receives the
// post-switch streams, is prewarmed exactly once, and is closed exactly once at
// teardown — and the legacy ModelSwitcher is never consulted when both are set.
func TestRunModelSessionSwitcherCarriesOptimizedSession(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
firstSession := &fakeTurnSession{inner: firstInner}
initial := &fakeTurnSessionProvider{session: firstSession}
switchedInner := &mockProvider{turns: singleAnswerTurns("answered on the switched session")}
switchedSession := &fakeTurnSession{inner: switchedInner}
switched := &fakeTurnSessionProvider{session: switchedSession}
var switchedTo string
legacyCalled := false
result, err := Run(context.Background(), "go", firstInner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: initial,
ModelSessionSwitcher: func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
switchedTo = modelID
return switched, nil
},
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
legacyCalled = true
return nil, errors.New("legacy switcher must not be consulted")
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "answered on the switched session" {
t.Fatalf("expected the switched session's answer, got %q", result.FinalAnswer)
}
if switchedTo != "claude-opus-4.1" {
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
}
if legacyCalled {
t.Fatal("legacy ModelSwitcher was consulted despite ModelSessionSwitcher being set")
}
if switched.opens != 1 {
t.Fatalf("expected exactly one open of the switched provider, got %d", switched.opens)
}
// The switched session carries the rest of the run: it streams the answer
// turn, is prewarmed exactly once at the swap, and is closed exactly once by
// the teardown defer.
if switchedSession.streams != 1 || len(switchedInner.requests) != 1 {
t.Fatalf("expected the switched session to stream the post-switch turn, streams=%d inner=%d", switchedSession.streams, len(switchedInner.requests))
}
if switchedSession.prewarms != 1 {
t.Fatalf("expected the switched session to be prewarmed exactly once, got %d", switchedSession.prewarms)
}
if switchedSession.closes != 1 {
t.Fatalf("expected the switched session to be closed exactly once at teardown, got %d", switchedSession.closes)
}
// The original session ends with the swap: one stream, one close.
if firstSession.streams != 1 || firstSession.closes != 1 {
t.Fatalf("expected the original session to end at the swap (streams=%d closes=%d)", firstSession.streams, firstSession.closes)
}
}
// TestRunModelSessionSwitcherOpenFailureIsNonFatal verifies the swap-time open
// failure: the switcher returns a TurnSessionProvider whose OpenTurnSession
// fails. The run stays non-fatal on the ORIGINAL session (closed only at
// teardown), and the switch-failure note reaches the next request.
func TestRunModelSessionSwitcherOpenFailureIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered after failed open")}
session := &fakeTurnSession{inner: inner}
failingOpen := &fakeTurnSessionProvider{openErr: errors.New("switched handshake refused")}
result, err := Run(context.Background(), "go", inner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
return failingOpen, nil
},
})
if err != nil {
t.Fatalf("expected a failed swap open to be non-fatal, got %v", err)
}
if result.FinalAnswer != "recovered after failed open" {
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
}
if failingOpen.opens != 1 {
t.Fatalf("expected exactly one open attempt on the switched provider, got %d", failingOpen.opens)
}
// The original session serves both turns and is closed only at teardown —
// never by the failed swap.
if session.streams != 2 || session.closes != 1 {
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
}
var sawNote bool
for _, message := range inner.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(message.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
}
}
// TestRunModelSessionSwitcherErrorIsNonFatal verifies a ModelSessionSwitcher
// error keeps the run on the current session with a transcript note — the same
// non-fatal contract as ModelSwitcher.
func TestRunModelSessionSwitcherErrorIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered on original session")}
session := &fakeTurnSession{inner: inner}
result, err := Run(context.Background(), "go", inner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
return nil, errors.New("session build blew up")
},
})
if err != nil {
t.Fatalf("expected session-switcher error to be non-fatal, got %v", err)
}
if result.FinalAnswer != "recovered on original session" {
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
}
// Both turns stream through the original session; it closes once at teardown.
if session.streams != 2 || session.closes != 1 {
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
}
var sawNote bool
for _, request := range inner.requests[1].Messages {
if request.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(request.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
}
}
+149 -18
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -25,6 +26,15 @@ const (
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode. It applies
// to the CURRENT session (unlike spec-draft, which drafts in a separate
// session): the agent may inspect the workspace and shape the plan with
// update_plan/ask_user, but no mutating tool is advertised, so it cannot
// write files, run shell, or implement while planning. Entry points:
// the TUI's /plan on (exit with /plan off, which restores whatever mode
// was active before), `zero exec --plan`, and the ACP session mode
// selector ("plan").
PermissionModePlan PermissionMode = "plan"
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
// advertises the in-workspace mutators a member needs to build (write/edit +
// shell) on top of the Auto set, while the sandbox engine still gates them at
@@ -61,17 +71,33 @@ const (
)
type ToolResult struct {
ToolCallID string
Name string
Status tools.Status
Output string
Meta map[string]string
ToolCallID string
Name string
Status tools.Status
Output string
// Truncated reports that the tool's model-visible output omitted content.
// The full result may be recoverable through Meta["spill_path"].
Truncated bool
Meta map[string]string
// Images the tool produced, delivered to the model as a following user
// message rather than on this result. See tools.Result.Images.
Images []zeroruntime.ImageBlock
Redacted bool
ChangedFiles []string
Display tools.Display
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
Outcome tools.ToolOutcome
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
// Risk is the sandbox risk classification of this call, stamped for EXECUTED
// results so run-policy observers (the execution-profile controller) can see
// the risk level of an allowed mutation. It mirrors the classification the
// permission path already computes; denied or canceled results keep the zero
// value. Pure observation: nothing about permissions or sandboxing changes.
Risk sandbox.Risk
// LoadedTools carries the deferred-tool names a tool_search call asked the
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
// ordinary tool result; only tool_search populates it.
@@ -83,6 +109,24 @@ type ToolResult struct {
RequestedModel string
}
// ModelOutput returns the bounded provider-facing result while preserving
// compatibility with synthetic and restored results created before outcomes
// were finalized.
func (result ToolResult) ModelOutput() string {
if result.Outcome.Finalized() {
return result.Outcome.ModelView
}
return result.Output
}
// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
if result.Outcome.Finalized() {
return result.Outcome.HumanView
}
return result.Display
}
// DenialCategory classifies why a tool call was blocked before it executed.
type DenialCategory string
@@ -95,6 +139,54 @@ const (
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
)
// ProfilePolicy is the loop-facing slice of a selected execution profile.
// The surface (exec flag, TUI command) resolves a named profile into this
// policy; the loop only ever sees the policy, never the catalog.
type ProfilePolicy struct {
// Name labels trace counters and diagnostics (e.g. "fast"). The trace
// recorder's profile label is set by the caller at recorder construction.
Name string
// Escalate, when non-nil, arms one-shot in-run posture escalation.
Escalate *PostureEscalation
}
// PostureEscalation describes a one-shot escalation to stricter knob values,
// applied mid-run when an armed trigger fires. Targets are the values the
// selected profile DISPLACED at run start (i.e. "restore the balanced
// posture"), so escalation can never introduce a value that was not already
// valid for this run and model. Zero-valued targets leave that knob untouched.
type PostureEscalation struct {
// MaxTurns raises the turn ceiling to this value when greater than the
// ceiling in effect. 0 leaves the ceiling untouched.
MaxTurns int
// ReasoningEffort replaces the run's effort when non-empty.
ReasoningEffort string
// RestoreDefaultEffort clears the run's effort override (back to the
// provider/model default) when true. It exists because the displaced value
// of a profile-filled effort is "" — which as a ReasoningEffort target
// means "leave untouched" — so restoring the default needs its own signal.
// Ignored when ReasoningEffort is non-empty.
RestoreDefaultEffort bool
// RestoreCompletionGate re-enables RequireCompletionSignal (headless
// completion semantics) when true.
RestoreCompletionGate bool
// Triggers. A zero value disables that signal entirely.
// OnToolFailureStreak fires when the repeated-failure guard observes a
// same-tool retriable-failure streak of at least this length.
OnToolFailureStreak int
// OnCompletionUncertain fires on the Nth uncertain completion evaluation
// (continue nudge or semantic check). Headless only: the completion gate
// never runs interactively.
OnCompletionUncertain int
// OnSelfCorrectFailure fires when a post-edit verification cycle reports a
// failing outcome (correcting, reported, or aborted).
OnSelfCorrectFailure bool
// OnRiskyMutation fires when an EXECUTED tool result carries a sandbox risk
// level at or above this threshold. Empty disables the signal.
OnRiskyMutation sandbox.RiskLevel
}
type PermissionRequest struct {
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
@@ -112,6 +204,22 @@ type PermissionRequest struct {
Grant *sandbox.Grant `json:"grant,omitempty"`
CommandPrefix []string `json:"commandPrefix,omitempty"`
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
// PrefixApprovalEscalates reports that approving a command prefix will also
// run the command OUTSIDE the sandbox, not merely stop asking about it.
//
// It exists because that consequence was real but invisible. Approving a
// prefix rewrites the call to sandbox_permissions: require_escalated, which
// resolves to a nil engine and genuinely unsandboxed execution, and the
// engine's own escalation prompt is then satisfied by the approval just
// given for the sandboxed form. So the operator authorized one thing and got
// a wider one, having been shown only "allow command prefix for session".
//
// The escalation itself is deliberate and guarded: proposedCommandPrefix
// refuses to offer a prefix while any other segment of the command is not
// known-safe, precisely so an unreviewed segment cannot ride out of the
// sandbox on it. What was missing was telling the person deciding, so this
// surfaces it rather than removing it.
PrefixApprovalEscalates bool `json:"prefixApprovalEscalates,omitempty"`
}
type PermissionDecision struct {
@@ -217,8 +325,12 @@ type Options struct {
ProviderName string
Model string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
// Empty preserves the ordinary system prompt byte-for-byte.
TransientSystemPrompt string
// ResponseStyle is the operator-selected reply style from the TUI /style
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
// system prompt as a short directive. Empty or "balanced" adds nothing — the
@@ -269,9 +381,11 @@ type Options struct {
// specialist child process emits while running. The toolCallID identifies
// which Task tool call the progress belongs to. nil is a no-op.
OnToolProgress func(toolCallID string, event streamjson.Event)
// OnContext, when set, is called once per turn with the per-category context
// budget of the request about to be sent, so a surface (TUI/CLI) can show
// context utilization. Opt-in like the other callbacks; nil is a no-op.
// OnContext, when set, is called for each main agent request with its
// per-category context budget, including a replacement request after
// compaction or a stall retry. Internal summarizer requests are excluded so
// surfaces keep showing the active conversation budget. Opt-in like the other
// callbacks; nil is a no-op.
OnContext func(ContextBreakdown)
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
// mid-run: the loop calls it with the requested model id and, on success,
@@ -280,6 +394,27 @@ type Options struct {
// request), so every existing caller is unaffected. A returned error is
// non-fatal: the run continues on the current model.
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
// TurnSessionProvider, when set, supplies the turn session the run streams
// through — the seam an optimized provider session (connection reuse,
// prewarm, native compaction) plugs into without touching the loop. nil
// keeps the default: the loop wraps the passed provider in a no-op session
// whose Stream IS provider.StreamCompletion, so behavior is byte-identical
// and every existing caller is unaffected.
TurnSessionProvider zeroruntime.TurnSessionProvider
// ModelSessionSwitcher, when set, is the target-aware escalation hook: the
// loop prefers it over ModelSwitcher, and its TurnSessionProvider keeps an
// optimized session (and its capabilities) across a mid-run model switch.
// nil falls back to ModelSwitcher, whose bare Provider is wrapped in the
// default no-op session — today's behavior, unchanged. Same non-fatal error
// contract as ModelSwitcher: a returned error records a note and the run
// continues on the current model and session.
ModelSessionSwitcher func(ctx context.Context, modelID string) (zeroruntime.TurnSessionProvider, error)
// Profile, when set, arms the execution-profile posture controller for this
// run (auto-escalation to stricter knob values on failure/uncertainty/risky
// mutation signals). nil — the default everywhere today — leaves the loop
// byte-identical: no observation, no escalation, no counters. Same opt-in
// convention as Trace and SelfCorrect.
Profile *ProfilePolicy
// Trace, when set, records per-turn timing for the run: the loop stamps
// spans (prompt build, generation, tool execution, permission wait,
// compaction, provider connect) and counters (model requests, tool calls,
@@ -312,8 +447,11 @@ type Options struct {
// continuation cue ("…Let me check the config:"). The loop then nudges the
// model to continue instead, bounded by maxContinueNudges (and still by
// MaxTurns and the run deadline); if the model keeps stalling, the run
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. When the
// run's profile also enables SelfCorrect, an otherwise-complete turn gets one
// task-grounded semantic check before success; profiles without SelfCorrect
// add no model call. Default false leaves the loop byte-identical, so the
// interactive TUI is unaffected.
RequireCompletionSignal bool
runPermissions *permissionRunState
@@ -342,13 +480,6 @@ type Result struct {
IncompleteReason string
}
// Truncated reports whether the final response ended abnormally (cut off at the
// output token cap or withheld by a content filter) rather than completing
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
func (result Result) Truncated() bool {
return result.FinishReason != ""
}
// TruncationNotice returns a user-facing warning when the final response was
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
// wording stays consistent.
-4
View File
@@ -31,10 +31,6 @@ type AgentRunner interface {
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
return fn(ctx, input)
}
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
// runaway agent cannot exhaust memory or bloat the benchmark report.
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
+8
View File
@@ -0,0 +1,8 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package agenteval
import "context"
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
return fn(ctx, input)
}
+1 -3
View File
@@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report {
if len(task.RequiredTraceEvents) > 0 {
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
}
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
report.Results = append(report.Results, result)
}
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
report.finishSummary()
return report
}
+3 -3
View File
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
if info.PrimaryLanguage != "" {
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
}
if langs := topLanguages(info.Languages, 5); langs != "" {
b.WriteString("- Languages: " + langs + "\n")
}
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
}
if len(info.BuildTools) > 0 {
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
+259
View File
@@ -0,0 +1,259 @@
package aimlapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// PaymentMethod selects how a partner-checkout top-up is paid for.
type PaymentMethod string
const (
PaymentMethodCard PaymentMethod = "card"
PaymentMethodCrypto PaymentMethod = "crypto"
)
// SessionStatus is the lifecycle state of a partner-checkout session, as reported
// by the aimlapi.com backend while polling for payment.
type SessionStatus string
const (
SessionStatusPendingAuth SessionStatus = "pending_auth"
SessionStatusPendingPayment SessionStatus = "pending_payment"
SessionStatusPaid SessionStatus = "paid"
SessionStatusExchanging SessionStatus = "exchanging"
SessionStatusExchanged SessionStatus = "exchanged"
SessionStatusCancelled SessionStatus = "cancelled"
SessionStatusExpired SessionStatus = "expired"
SessionStatusFailed SessionStatus = "failed"
)
// AuthResult is the bearer token minted by a passwordless sign-in / sign-up.
type AuthResult struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
}
// PartnerCheckoutSession is a partner-attributed top-up session; its SessionToken
// addresses it on later pay/exchange/poll calls.
type PartnerCheckoutSession struct {
ID string `json:"id"`
SessionToken string `json:"sessionToken"`
PartnerID string `json:"partnerId"`
PartnerName *string `json:"partnerName"`
UserID *int64 `json:"userId"`
AmountUSDMinor *int64 `json:"amountUsdMinor"`
Status SessionStatus `json:"status"`
IssuedKeyID *string `json:"issuedKeyId"`
ReturnURL *string `json:"returnUrl"`
}
// PaymentSession is the hosted payment-provider checkout; PayURL is the page the
// user opens to pay.
type PaymentSession struct {
ProviderSessionID string `json:"providerSessionId"`
PayURL string `json:"payUrl"`
}
// PayResult pairs the hosted checkout with the updated partner-checkout session
// returned when a top-up payment is initiated.
type PayResult struct {
Checkout PaymentSession `json:"checkout"`
PartnerCheckout PartnerCheckoutSession `json:"partnerCheckout"`
}
// ExchangeResult holds the API key minted when a paid session is exchanged.
type ExchangeResult struct {
APIKey string `json:"apiKey"`
APIKeyID string `json:"apiKeyId"`
}
// TopUpByKeyRequest funds a partner-checkout session with the API key that owns
// the account (Path A / env key), instead of an email session.
type TopUpByKeyRequest struct {
SessionToken string
AmountUSDMinor int
PaymentSessionID string // idempotency: same id → same checkout, never a second charge
AutoTopUp bool // enroll the account in automatic top-up at checkout (card, saved off-session)
SuccessURL string
CancelURL string
}
// TopUpByKeyResult is the hosted checkout returned by TopUpByKey.
type TopUpByKeyResult struct {
Checkout PaymentSession `json:"checkout"`
}
// APIError is a non-2xx HTTP response from the aimlapi.com API. Status carries the
// code so callers can branch (e.g. 401 = bad key, 5xx = retryable).
type APIError struct {
Message string
Status int
Body string
}
// Error renders the method, endpoint, status, and (when present) the response body.
func (e APIError) Error() string {
if strings.TrimSpace(e.Body) != "" {
return fmt.Sprintf("%s: HTTP %d: %s", e.Message, e.Status, strings.TrimSpace(e.Body))
}
return fmt.Sprintf("%s: HTTP %d", e.Message, e.Status)
}
// Client talks to the aimlapi.com auth, app, and inference APIs for the onboarding
// and partner-checkout top-up flows.
type Client struct {
endpoints Endpoints
httpClient *http.Client
}
func NewClient(endpoints Endpoints, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 60 * time.Second}
}
return &Client{endpoints: endpoints, httpClient: httpClient}
}
// CreateSession opens a partner-checkout session attributed to partnerID; the
// returned SessionToken addresses it on the pay/exchange/poll calls.
func (c *Client) CreateSession(ctx context.Context, partnerID string, partnerName string, returnURL string) (PartnerCheckoutSession, error) {
body := map[string]any{"partnerId": partnerID}
if strings.TrimSpace(partnerName) != "" {
body["partnerName"] = strings.TrimSpace(partnerName)
}
if strings.TrimSpace(returnURL) != "" {
body["returnUrl"] = strings.TrimSpace(returnURL)
}
var result PartnerCheckoutSession
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions", "", body, &result)
return result, err
}
// GetSession fetches the current state of a partner-checkout session; used to poll
// for payment completion.
func (c *Client) GetSession(ctx context.Context, sessionToken string) (PartnerCheckoutSession, error) {
var result PartnerCheckoutSession
err := c.request(ctx, http.MethodGet, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken), "", nil, &result)
return result, err
}
// Pay initiates payment for a session and returns the hosted checkout URL.
// autoTopUp enrolls the account in automatic top-up at checkout time.
func (c *Client) Pay(ctx context.Context, bearer string, sessionToken string, amountUSDMinor int, paymentSessionID string, method PaymentMethod, successURL string, cancelURL string, autoTopUp bool) (PayResult, error) {
body := map[string]any{
"amountUsdMinor": amountUSDMinor,
"paymentSessionId": paymentSessionID,
"method": method,
}
if strings.TrimSpace(successURL) != "" {
body["successUrl"] = strings.TrimSpace(successURL)
}
if strings.TrimSpace(cancelURL) != "" {
body["cancelUrl"] = strings.TrimSpace(cancelURL)
}
// Only sent when enabled: enrolls the account in automatic top-up. The backend
// honours this field at checkout time.
if autoTopUp {
body["autoTopUp"] = true
}
var result PayResult
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/pay", bearer, body, &result)
return result, err
}
// Exchange converts a paid session into a freshly minted API key (new-account flow).
func (c *Client) Exchange(ctx context.Context, bearer string, sessionToken string) (ExchangeResult, error) {
var result ExchangeResult
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/exchange", bearer, nil, &result)
return result, err
}
// TopUpByKey funds a partner-checkout session using the raw API key that owns the
// account (Path A / env key), returning the hosted checkout. It binds the session
// to the key's account server-side, so no email session is needed and no key is
// exchanged. req.PaymentSessionID makes a retry idempotent (same id → same
// checkout, never a second charge).
func (c *Client) TopUpByKey(ctx context.Context, apiKey string, req TopUpByKeyRequest) (TopUpByKeyResult, error) {
body := map[string]any{
"sessionToken": req.SessionToken,
"amountUsdMinor": req.AmountUSDMinor,
"paymentSessionId": req.PaymentSessionID,
}
if req.AutoTopUp {
body["autoTopUp"] = true
}
if strings.TrimSpace(req.SuccessURL) != "" {
body["successUrl"] = strings.TrimSpace(req.SuccessURL)
}
if strings.TrimSpace(req.CancelURL) != "" {
body["cancelUrl"] = strings.TrimSpace(req.CancelURL)
}
var result TopUpByKeyResult
err := c.request(ctx, http.MethodPost, c.billingV2URL("/billing/topup"), apiKey, body, &result)
return result, err
}
// billingV2URL builds a v2 billing API URL from the inference base. The top-up
// endpoint lives next to the balance endpoint on the API gateway, one version up
// (".../v1" → ".../v2/billing/..."), so it re-anchors the version while keeping any
// host/prefix an override supplied.
func (c *Client) billingV2URL(path string) string {
base := strings.TrimRight(strings.TrimSpace(c.endpoints.InferenceBaseURL), "/")
base = strings.TrimSuffix(base, "/v1")
return strings.TrimRight(base, "/") + "/v2" + path
}
func (c *Client) request(ctx context.Context, method string, endpoint string, bearer string, body any, out any) error {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(data)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if strings.TrimSpace(bearer) != "" {
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("network request to %s failed: %w", endpoint, err)
}
defer response.Body.Close()
text, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil {
return readErr
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return APIError{Message: method + " " + endpoint, Status: response.StatusCode, Body: string(text)}
}
if out == nil {
return nil
}
if len(bytes.TrimSpace(text)) == 0 {
return APIError{Message: method + " " + endpoint + " returned empty body", Status: response.StatusCode}
}
if err := json.Unmarshal(text, out); err != nil {
return APIError{Message: method + " " + endpoint + " returned non-JSON body", Status: response.StatusCode, Body: string(text)}
}
return nil
}
func urlPathEscape(value string) string {
return url.PathEscape(value)
}
+106
View File
@@ -0,0 +1,106 @@
package aimlapi
import (
"context"
"fmt"
"net/http"
"strings"
)
// Additional client methods backing the interactive TUI onboarding flow. They
// target the endpoints shipped in the aimlapi backend:
// - GET {inference}/billing/balance (Path A: validate key + balance)
// - PATCH{auth}/v1/auth/account (Path B: does the account exist?)
// - POST {auth}/v1/auth/sign-in/code(/verify) (Path B existing: passwordless login)
// - POST {auth}/v1/auth/account/passwordless (Path B new: email-only signup)
// - POST {app}/v1/keys (mint a key once we hold a session)
// BalanceResult is the wallet balance returned by GetBalance, with the backend's
// low-balance flag and threshold.
type BalanceResult struct {
Balance float64 `json:"balance"`
LowBalance bool `json:"lowBalance"`
LowBalanceThreshold float64 `json:"lowBalanceThreshold"`
}
// CheckResult reports whether an email already has an account, driving the
// sign-in vs sign-up branch of the onboarding flow.
type CheckResult struct {
// "sign-in" (account exists) or "sign-up" (no account yet).
Action string `json:"action"`
Provider *string `json:"provider"`
}
// CreatedKey is an API key minted by CreateKey.
type CreatedKey struct {
Key string `json:"key"`
ID string `json:"id"`
}
func (c *Client) authURL(path string) string {
return strings.TrimRight(c.endpoints.AuthBaseURL, "/") + path
}
func (c *Client) appURL(path string) string {
return strings.TrimRight(c.endpoints.AppBaseURL, "/") + path
}
func (c *Client) inferenceURL(path string) string {
return strings.TrimRight(c.endpoints.InferenceBaseURL, "/") + path
}
// GetBalance doubles as key validation: a 401 means the key is invalid, a 2xx
// returns the wallet balance. Auth is the raw API key itself.
func (c *Client) GetBalance(ctx context.Context, apiKey string) (BalanceResult, error) {
var result BalanceResult
err := c.request(ctx, http.MethodGet, c.inferenceURL("/billing/balance"), apiKey, nil, &result)
return result, err
}
// CheckAccount reports whether an email already has an aimlapi.com account
// (Action "sign-in") or not ("sign-up").
func (c *Client) CheckAccount(ctx context.Context, email string) (CheckResult, error) {
var result CheckResult
err := c.request(ctx, http.MethodPatch, c.authURL("/v1/auth/account"), "", map[string]any{"email": email}, &result)
return result, err
}
// SendSignInCode emails a 6-digit sign-in code to an existing account.
func (c *Client) SendSignInCode(ctx context.Context, email string) error {
return c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code"), "", map[string]any{"email": email}, nil)
}
// VerifySignInCode exchanges an emailed code for a session bearer token.
func (c *Client) VerifySignInCode(ctx context.Context, email string, code string) (AuthResult, error) {
var result AuthResult
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code/verify"), "", map[string]any{"email": email, "code": code}, &result)
if err == nil && strings.TrimSpace(result.Token) == "" {
err = fmt.Errorf("aimlapi.com did not return an auth token")
}
return result, err
}
// CreatePasswordlessAccount registers a new email-only account and returns its
// session bearer token.
func (c *Client) CreatePasswordlessAccount(ctx context.Context, email string) (AuthResult, error) {
var result AuthResult
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/account/passwordless"), "", map[string]any{"email": email}, &result)
if err == nil && strings.TrimSpace(result.Token) == "" {
err = fmt.Errorf("aimlapi.com did not return an auth token")
}
return result, err
}
// CreateKey mints a fresh API key for a session-holding user.
func (c *Client) CreateKey(ctx context.Context, bearer string, name string) (CreatedKey, error) {
body := map[string]any{}
if strings.TrimSpace(name) != "" {
body["name"] = strings.TrimSpace(name)
}
var result CreatedKey
err := c.request(ctx, http.MethodPost, c.appURL("/v1/keys"), bearer, body, &result)
if err == nil && strings.TrimSpace(result.Key) == "" {
err = fmt.Errorf("aimlapi.com did not return an API key")
}
return result, err
}
+159
View File
@@ -0,0 +1,159 @@
package aimlapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestCreateKeySendsAuthenticatedRequestAndDecodesKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/keys" {
t.Fatalf("request = %s %s, want POST /v1/keys", r.Method, r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer session-token" {
t.Fatalf("Authorization = %q", got)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["name"] != "Zero CLI" {
t.Fatalf("name = %q", body["name"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"key":"sk-issued","id":"key-id"}`))
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
key, err := client.CreateKey(context.Background(), " session-token ", " Zero CLI ")
if err != nil {
t.Fatal(err)
}
if key.Key != "sk-issued" || key.ID != "key-id" {
t.Fatalf("key = %#v", key)
}
}
func TestCreateKeyRejectsEmptyKeyResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"key-id"}`))
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
if _, err := client.CreateKey(context.Background(), "session-token", "Zero"); err == nil {
t.Fatal("CreateKey accepted a response without an API key")
}
}
func TestAuthMethodsRejectEmptyTokenResponses(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":""}`))
}))
defer server.Close()
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
tests := []struct {
name string
call func() error
}{
{
name: "verify sign-in code",
call: func() error {
_, err := client.VerifySignInCode(context.Background(), "[email protected]", "123456")
return err
},
},
{
name: "create passwordless account",
call: func() error {
_, err := client.CreatePasswordlessAccount(context.Background(), "[email protected]")
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := test.call(); err == nil {
t.Fatal("accepted a response without an auth token")
}
})
}
}
func TestClientReturnsTypedAPIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "invalid key", http.StatusUnauthorized)
}))
defer server.Close()
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
_, err := client.GetBalance(context.Background(), "bad-key")
var apiErr APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %T %v, want APIError", err, err)
}
if apiErr.Status != http.StatusUnauthorized || apiErr.Body != "invalid key\n" {
t.Fatalf("APIError = %#v", apiErr)
}
}
func TestTypedResponseRejectsEmptySuccessBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
if _, err := client.GetBalance(context.Background(), "key"); err == nil {
t.Fatal("GetBalance accepted an empty successful response")
}
}
func TestBodylessOperationAcceptsEmptySuccessBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
if err := client.SendSignInCode(context.Background(), "[email protected]"); err != nil {
t.Fatalf("SendSignInCode() error = %v", err)
}
}
func TestParseAmountUSD(t *testing.T) {
tests := []struct {
name string
value string
want int
wantErr bool
}{
{name: "default", want: DefaultAmountUSDMinor},
{name: "minimum", value: "20", want: 2000},
{name: "cents", value: "25.49", want: 2549},
{name: "below minimum", value: "19.99", wantErr: true},
{name: "above maximum", value: "10001", wantErr: true},
{name: "not a number", value: "twenty", wantErr: true},
{name: "nan", value: "NaN", wantErr: true},
{name: "infinity", value: "Inf", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ParseAmountUSD(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("ParseAmountUSD(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("ParseAmountUSD(%q) = %d, want %d", test.value, got, test.want)
}
})
}
}
+113
View File
@@ -0,0 +1,113 @@
package aimlapi
import (
"net/url"
"os"
"strings"
)
type Endpoints struct {
AuthBaseURL string
AppBaseURL string
InferenceBaseURL string
PayBaseURL string
// VerificationBaseURL is the aimlapi web-app frontend base. The browser is sent
// back here after the partner checkout completes (see BuildPartnerReturnURL), so
// it must point at the same environment's front as the one the CLI is talking to
// to land the user there. Empty falls back to DefaultReturnURL.
VerificationBaseURL string
}
const (
DefaultPartnerID = "part_62yQoGYDq4Yqnrj2R1iGrDNJ"
DefaultPartnerName = "Gitlawb"
PartnerHeaderName = "X-AIMLAPI-Partner-ID"
// DefaultReturnURL is the https fallback the browser is sent to after the
// checkout / OAuth consent finishes when no frontend base is known. It must be
// a real web page the browser can open — NOT a custom scheme like
// zero://aimlapi/complete, which fails with "the scheme does not have a
// registered handler" because the CLI registers no OS protocol handler. The
// CLI learns of success by polling; this URL is purely the browser's landing.
DefaultReturnURL = "https://aimlapi.com/app"
DefaultModel = "anthropic/claude-sonnet-5"
MinAmountUSDMinor = 2000
MaxAmountUSDMinor = 1000000
DefaultAmountUSDMinor = 2500
)
func ResolveEndpoints() Endpoints {
return Endpoints{
AuthBaseURL: envOrDefault("AIMLAPI_AUTH_URL", "https://auth.aimlapi.com"),
AppBaseURL: envOrDefault("AIMLAPI_APP_URL", "https://app.aimlapi.com"),
InferenceBaseURL: envOrDefault("AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1"),
PayBaseURL: envOrDefault("AIMLAPI_PAY_URL", "https://pay.aimlapi.com"),
VerificationBaseURL: envOrDefault("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com/app"),
}
}
// ResolvePartnerID applies the same explicit-option, environment, and default
// precedence everywhere partner attribution is used. Keeping checkout creation
// and saved inference profiles on one resolver prevents them from attributing
// the same user to different partners.
func ResolvePartnerID(explicit string) string {
if value := strings.TrimSpace(explicit); value != "" {
return value
}
return envOrDefault("AIMLAPI_PARTNER_ID", DefaultPartnerID)
}
// WithResolvedPartnerHeader returns a copy with the effective partner ID set.
// Header matching is case-insensitive so an existing spelling is replaced rather
// than duplicated. Callers remain responsible for applying catalog attribution
// only to the canonical AIMLAPI endpoint.
func WithResolvedPartnerHeader(headers map[string]string) map[string]string {
resolved := make(map[string]string, len(headers)+1)
for key, value := range headers {
if strings.EqualFold(strings.TrimSpace(key), PartnerHeaderName) {
continue
}
resolved[key] = value
}
resolved[PartnerHeaderName] = ResolvePartnerID("")
return resolved
}
func BuildPartnerCheckoutReturnURLs(appBaseURL string, sessionToken string) (successURL string, cancelURL string) {
base := strings.TrimRight(strings.TrimSpace(appBaseURL), "/")
if base == "" {
// With no base there is nothing to anchor an absolute URL to. Return empty
// so the caller omits the return URLs entirely (client.Pay skips empty ones,
// leaving the backend's generic checkout) instead of emitting a broken
// relative "/checkout?...".
return "", ""
}
token := url.QueryEscape(sessionToken)
return base + "/checkout?checkout=success&partnerCheckout=1&sessionToken=" + token,
base + "/checkout?checkout=cancel&partnerCheckout=1&sessionToken=" + token
}
// BuildPartnerReturnURL returns the https page the browser is sent back to once
// the partner checkout or OAuth consent completes — the aimlapi web app the user
// opened the flow from. frontendBaseURL is normally endpoints.VerificationBaseURL
// (the front that hosts the login/consent page), so the return follows the same
// environment. AIMLAPI_RETURN_URL overrides it outright; an empty base falls back
// to DefaultReturnURL. It is deliberately NOT a custom scheme (zero://…): no
// browser can hand a custom scheme off without an OS-registered handler, which a
// CLI does not install.
func BuildPartnerReturnURL(frontendBaseURL string) string {
if override := strings.TrimSpace(os.Getenv("AIMLAPI_RETURN_URL")); override != "" {
return override
}
if base := strings.TrimRight(strings.TrimSpace(frontendBaseURL), "/"); base != "" {
return base
}
return DefaultReturnURL
}
func envOrDefault(name string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
+55
View File
@@ -0,0 +1,55 @@
package aimlapi
import "testing"
func TestResolveEndpointsDefaults(t *testing.T) {
t.Setenv("AIMLAPI_AUTH_URL", "")
t.Setenv("AIMLAPI_APP_URL", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PAY_URL", "")
endpoints := ResolveEndpoints()
if endpoints.AuthBaseURL != "https://auth.aimlapi.com" ||
endpoints.AppBaseURL != "https://app.aimlapi.com" ||
endpoints.InferenceBaseURL != "https://api.aimlapi.com/v1" ||
endpoints.PayBaseURL != "https://pay.aimlapi.com" {
t.Fatalf("unexpected endpoints: %#v", endpoints)
}
}
func TestResolvedPartnerHeaderUsesEnvironmentOverrideWithoutDuplicates(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
headers := WithResolvedPartnerHeader(map[string]string{
"x-aimlapi-partner-id": "part_catalog",
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
})
if got := headers[PartnerHeaderName]; got != "part_override" {
t.Fatalf("partner header = %q, want part_override", got)
}
if _, ok := headers["x-aimlapi-partner-id"]; ok {
t.Fatalf("case-insensitive partner header was duplicated: %#v", headers)
}
if got := headers["X-AIMLAPI-Integration-Repo"]; got != "Gitlawb/zero" {
t.Fatalf("unrelated header changed: %#v", headers)
}
}
func TestBuildPartnerReturnURL(t *testing.T) {
t.Setenv("AIMLAPI_RETURN_URL", "")
// The frontend base (the web app the flow was opened from) is returned as-is,
// trailing slash trimmed — an https page the browser can actually land on.
if got := BuildPartnerReturnURL("https://aimlapi.com/app/"); got != "https://aimlapi.com/app" {
t.Fatalf("return URL = %q, want the frontend base", got)
}
// An empty base falls back to the https default, never a custom scheme.
if got := BuildPartnerReturnURL(""); got != DefaultReturnURL {
t.Fatalf("empty base return URL = %q, want %q", got, DefaultReturnURL)
}
t.Setenv("AIMLAPI_RETURN_URL", "https://example.test/done")
if got := BuildPartnerReturnURL("https://aimlapi.com/app"); got != "https://example.test/done" {
t.Fatalf("env override return URL = %q", got)
}
}
+42
View File
@@ -0,0 +1,42 @@
package aimlapi
// UI strings shared by the first-run and provider-wizard onboarding surfaces.
const (
// Path A — existing API key. Pick-path prompt + option labels.
MsgAPIKeyInputPrompt = "Enter your aimlapi.com key."
MsgAPIKeyInvalid = "API key is invalid. Please make sure you enter a valid aimlapi.com key."
MsgPickPathPrompt = "Do you have aimlapi.com key?"
MsgPickPathHaveKey = "I already have aimlapi.com key"
MsgPickPathHaveHint = "Proceed to paste the key"
MsgPickPathNewUser = "I am a new user"
MsgPickPathNewHint = "One click set up"
// Path B — email.
MsgEnterEmail = "Enter your email.\nTo access aimlapi.com dashboard."
MsgEmailInvalid = "Email format is incorrect."
MsgAccountActionInvalid = "aimlapi.com returned an unsupported account action. Please try again."
// %s = email
MsgCodeSent = "We sent a 6-digit code to %s.\nEnter it below to continue."
MsgCodeIncorrect = "Code you've entered is incorrect."
// Balance check.
MsgLowBalance = "Your aimlapi.com balance is running low.\nIt is recommended to top up your balance."
MsgEverythingRuns = "Everything is ready."
// Low-balance choices (spec wording).
MsgLowBalanceTopUp = "Sure, let's do that"
MsgLowBalanceSkip = "I'll skip topping up the balance for now"
// Top-up workflow.
MsgTopUpPrompt = "Add credits.\nEnter an amount (min $20)."
MsgAmountRequired = "Please enter a top-up amount."
// %s = checkout URL
MsgTopUpBrowserFallback = "If the browser did not open automatically please use this link to top up your account: %s"
MsgTopUpFailed = "Top up failed. Please try again."
MsgTopUpSuccess = "Top-up successful."
// Success — key delivered.
// %s = amount in dollars (e.g. "25")
MsgTopUpAddedFmt = "$%s has been added to your balance."
// %s = email
MsgSuccessMagicLink = "We've emailed you a magic link to %s. Use it to access your aimlapi.com account and review your usage and balance."
)
+26
View File
@@ -0,0 +1,26 @@
package aimlapi
import "testing"
func TestValidEmailRejectsIncompleteDomains(t *testing.T) {
tests := []struct {
value string
valid bool
}{
{value: "[email protected]", valid: true},
{value: "[email protected]", valid: true},
{value: "123@123.", valid: false},
{value: "123@123", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
}
for _, test := range tests {
t.Run(test.value, func(t *testing.T) {
if got := ValidEmail(test.value); got != test.valid {
t.Fatalf("ValidEmail(%q) = %v, want %v", test.value, got, test.valid)
}
})
}
}
+335
View File
@@ -0,0 +1,335 @@
package aimlapi
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// StreamTopUpOptions drives a session-based partner-checkout top-up for the
// interactive (TUI) onboarding. The caller already holds a user session —
// obtained from an email-code sign-in (Path B existing) or a
// passwordless new account (Path B sign-up) — so this skips authentication and
// runs create-session → pay → wait-for-payment, optionally exchanging the paid
// session into a fresh key (new accounts) versus keeping the caller's key.
type StreamTopUpOptions struct {
SessionToken string // user session bearer (required)
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
// InferenceBaseURL pins key-bound billing and the returned provider profile to
// the endpoint that validated the credential. Empty uses ResolveEndpoints.
InferenceBaseURL string
Method PaymentMethod
AutoTopUp bool // enroll the account in automatic top-up at checkout time
Exchange bool // exchange the paid session into a new key (new accounts)
Model string
PartnerID string
PartnerName string
NoOpen bool
// ResumeSessionToken, when set, resumes that partner-checkout session instead
// of opening a new one, so a retry after an ambiguous failure (a lost Pay/
// Exchange response) can never double-charge or strand an already-paid session.
ResumeSessionToken string
// PaymentSessionID is generated once per amount/auto-top-up intent and reused
// on retry so an ambiguous Pay response cannot create a second checkout.
PaymentSessionID string
HTTPClient *http.Client
OpenBrowser func(string) error
OnStatus func(Status, string)
// OnSession reports the partner-checkout session token the moment it exists (a
// fresh CreateSession, or the resumed one), so the caller can retain it and
// resume on a later failure. It fires with "" when the session is dead and the
// retained token should be dropped (a fresh run must start over).
OnSession func(sessionToken string)
}
// topupPhase is where StreamTopUp enters its create-checkout → poll → exchange
// pipeline. A fresh run starts at phasePay; a resume skips ahead to whatever the
// existing session's status makes safe.
type topupPhase int
const (
phasePay topupPhase = iota // (re-)open the Stripe checkout, then poll + exchange
phasePoll // checkout already open; poll for payment, never re-pay
phaseExchange // already paid; exchange only (recovers a lost key)
phaseWaitExchange // an exchange claim is in flight; poll, never call Exchange twice
)
// StreamTopUp performs the browser-checkout top-up and reports progress via
// OnStatus.
func StreamTopUp(ctx context.Context, options StreamTopUpOptions) (ProvisionedKey, error) {
endpoints := ResolveEndpoints()
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
endpoints.InferenceBaseURL = baseURL
}
client := NewClient(endpoints, options.HTTPClient)
partnerID := ResolvePartnerID(options.PartnerID)
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
method := options.Method
if method != PaymentMethodCrypto {
method = PaymentMethodCard
}
amount, err := ParseAmountUSD(options.AmountUSD)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(options.SessionToken) == "" {
return ProvisionedKey{}, fmt.Errorf("a session is required to top up")
}
if strings.TrimSpace(options.PaymentSessionID) == "" {
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
}
status(options.OnStatus, StatusCreatingSession, "")
sessionToken, phase, err := resolveTopupSession(ctx, client, options, partnerID, partnerName, endpoints)
if err != nil {
return ProvisionedKey{}, err
}
// Retain the live token so an ambiguous failure below can resume this session
// rather than open a second checkout on the next attempt.
reportSession(options.OnSession, sessionToken)
if phase <= phasePay {
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
status(options.OnStatus, StatusOpeningCheckout, "")
pay, err := client.Pay(ctx, options.SessionToken, sessionToken, amount, options.PaymentSessionID, method, successURL, cancelURL, options.AutoTopUp)
if err != nil {
return ProvisionedKey{}, err
}
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
if err != nil {
return ProvisionedKey{}, err
}
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
}
paidToken := sessionToken
if phase <= phasePoll {
status(options.OnStatus, StatusWaitingPayment, "")
paid, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession)
if err != nil {
return ProvisionedKey{}, err
}
paidToken = paid.SessionToken
if paid.Status == SessionStatusExchanging {
// Another/lost request claimed exchange while we were polling payment.
// Carry that fact into the provisioning branch so it follows the claim
// instead of issuing a second single-flight Exchange request.
phase = phaseWaitExchange
}
}
result := ProvisionedKey{
BaseURL: endpoints.InferenceBaseURL,
Model: firstNonEmpty(options.Model, DefaultModel),
}
if options.Exchange {
status(options.OnStatus, StatusProvisioningKey, "")
if phase == phaseWaitExchange {
return ProvisionedKey{}, pollUntilExchangeSettled(ctx, client, sessionToken, options.OnSession)
}
exchange, err := client.Exchange(ctx, options.SessionToken, paidToken)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(exchange.APIKey) == "" {
return ProvisionedKey{}, fmt.Errorf("aimlapi.com did not return an API key")
}
result.APIKey = exchange.APIKey
result.APIKeyID = exchange.APIKeyID
}
return result, nil
}
// resolveTopupSession returns the partner-checkout session token to drive and the
// phase to enter. A fresh run opens a new session at phasePay. A resume inspects
// the existing session and enters the earliest phase that cannot re-charge:
// - pending_auth → phasePay: payment never started, so (re-)Pay is safe.
// - pending_payment → phasePoll: a checkout is already open; poll, never re-Pay.
// - paid → phaseExchange: exchange the paid session once.
// - exchanging → phaseWaitExchange: another/lost request owns the atomic
// exchange claim; poll it, never issue a second Exchange request.
// - exchanged → terminal: the one-shot raw key is gone; point at dashboard
// recovery and keep the token so a re-run repeats that guidance, not a charge.
// - cancelled/expired/failed → terminal: drop the dead token so a re-run is fresh.
func resolveTopupSession(ctx context.Context, client *Client, options StreamTopUpOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
resume := strings.TrimSpace(options.ResumeSessionToken)
if resume == "" {
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
if err != nil {
return "", phasePay, err
}
return session.SessionToken, phasePay, nil
}
session, err := client.GetSession(ctx, resume)
if err != nil {
if isTerminalSessionAPIError(err) {
reportSession(options.OnSession, "")
}
return "", phasePay, err
}
switch session.Status {
case SessionStatusPendingAuth:
return resume, phasePay, nil
case SessionStatusPendingPayment:
return resume, phasePoll, nil
case SessionStatusPaid:
return resume, phaseExchange, nil
case SessionStatusExchanging:
return resume, phaseWaitExchange, nil
case SessionStatusExchanged:
return "", phasePay, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
default:
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
}
}
func reportSession(callback func(string), sessionToken string) {
if callback != nil {
callback(sessionToken)
}
}
// announceCheckout opens the hosted checkout in the browser (unless suppressed)
// and reports the URL either way, so the TUI can always show the direct link.
func announceCheckout(onStatus func(Status, string), open func(string) error, noOpen bool, checkoutURL string) {
if noOpen || open == nil {
status(onStatus, StatusOpeningCheckout, checkoutURL)
return
}
if err := open(checkoutURL); err != nil {
status(onStatus, StatusOpeningCheckout, "Open manually: "+checkoutURL)
return
}
status(onStatus, StatusOpeningCheckout, checkoutURL)
}
func validatedCheckoutURL(value string) (string, error) {
checkoutURL := strings.TrimSpace(value)
parsed, err := url.Parse(checkoutURL)
if err != nil || !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Hostname() == "" || parsed.User != nil {
return "", fmt.Errorf("payment provider did not return a valid HTTPS checkout URL")
}
return checkoutURL, nil
}
// StreamTopUpByKeyOptions drives a top-up funded with the API key that already
// owns the account (Path A pasted key, or AIMLAPI_API_KEY). Unlike StreamTopUp it
// needs no email session and never exchanges a key — the caller already holds one,
// so it only funds the wallet: create-session → pay-by-key → wait-for-payment.
type StreamTopUpByKeyOptions struct {
APIKey string // raw key that owns the account (required)
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
InferenceBaseURL string // validated key endpoint; empty uses ResolveEndpoints
AutoTopUp bool // enroll the account in automatic top-up at checkout time
PartnerID string
PartnerName string
NoOpen bool
// ResumeSessionToken resumes an existing partner-checkout session instead of
// opening a new one. PaymentSessionID is the idempotency handle carried across
// retries so a re-issued pay returns the same checkout, never a second charge.
ResumeSessionToken string
PaymentSessionID string // required; generate once via NewPaymentSessionID, reuse on retry
HTTPClient *http.Client
OpenBrowser func(string) error
OnStatus func(Status, string)
OnSession func(sessionToken string)
}
// StreamTopUpByKey funds the account behind APIKey via the hosted checkout and
// reports progress through OnStatus. It returns an empty ProvisionedKey: the
// pasted/env key is unchanged, only the balance grows.
func StreamTopUpByKey(ctx context.Context, options StreamTopUpByKeyOptions) (ProvisionedKey, error) {
endpoints := ResolveEndpoints()
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
endpoints.InferenceBaseURL = baseURL
}
client := NewClient(endpoints, options.HTTPClient)
partnerID := ResolvePartnerID(options.PartnerID)
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
amount, err := ParseAmountUSD(options.AmountUSD)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(options.APIKey) == "" {
return ProvisionedKey{}, fmt.Errorf("an API key is required to top up")
}
if strings.TrimSpace(options.PaymentSessionID) == "" {
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
}
status(options.OnStatus, StatusCreatingSession, "")
sessionToken, phase, err := resolveByKeySession(ctx, client, options, partnerID, partnerName, endpoints)
if err != nil {
return ProvisionedKey{}, err
}
reportSession(options.OnSession, sessionToken)
if phase <= phasePay {
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
status(options.OnStatus, StatusOpeningCheckout, "")
pay, err := client.TopUpByKey(ctx, options.APIKey, TopUpByKeyRequest{
SessionToken: sessionToken,
AmountUSDMinor: amount,
PaymentSessionID: options.PaymentSessionID,
AutoTopUp: options.AutoTopUp,
SuccessURL: successURL,
CancelURL: cancelURL,
})
if err != nil {
return ProvisionedKey{}, err
}
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
if err != nil {
return ProvisionedKey{}, err
}
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
}
if phase <= phasePoll {
status(options.OnStatus, StatusWaitingPayment, "")
if _, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession); err != nil {
return ProvisionedKey{}, err
}
}
// No exchange: the account is funded and the caller keeps its existing key.
return ProvisionedKey{}, nil
}
// resolveByKeySession mirrors resolveTopupSession for the by-key flow. Because
// by-key never exchanges, a paid/exchanging/exchanged session all mean "already
// funded, done" (phaseExchange skips straight past pay + poll to the empty result).
func resolveByKeySession(ctx context.Context, client *Client, options StreamTopUpByKeyOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
resume := strings.TrimSpace(options.ResumeSessionToken)
if resume == "" {
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
if err != nil {
return "", phasePay, err
}
return session.SessionToken, phasePay, nil
}
session, err := client.GetSession(ctx, resume)
if err != nil {
if isTerminalSessionAPIError(err) {
reportSession(options.OnSession, "")
}
return "", phasePay, err
}
switch session.Status {
case SessionStatusPendingAuth:
return resume, phasePay, nil
case SessionStatusPendingPayment:
return resume, phasePoll, nil
case SessionStatusPaid, SessionStatusExchanging, SessionStatusExchanged:
return resume, phaseExchange, nil
default:
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
}
}
+457
View File
@@ -0,0 +1,457 @@
package aimlapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestStreamTopUpByKeyFundsWithoutExchange(t *testing.T) {
var topupCalls, exchangeCalls int
var topupBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
topupCalls++
_ = json.NewDecoder(r.Body).Decode(&topupBody)
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
exchangeCalls++
t.Errorf("by-key must never exchange a key")
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
t.Setenv("AIMLAPI_INFERENCE_URL", server.URL+"/v1")
var opened string
result, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
APIKey: "sk-user",
AmountUSD: "20",
AutoTopUp: true,
PaymentSessionID: "pay-1",
OpenBrowser: func(u string) error { opened = u; return nil },
})
if err != nil {
t.Fatalf("StreamTopUpByKey = %v", err)
}
if result.APIKey != "" {
t.Fatalf("by-key must not mint a key, got %q", result.APIKey)
}
if topupCalls != 1 || exchangeCalls != 0 {
t.Fatalf("topup=%d exchange=%d, want 1/0", topupCalls, exchangeCalls)
}
if opened != "https://pay/checkout" {
t.Fatalf("opened checkout = %q", opened)
}
if topupBody["autoTopUp"] != true || topupBody["paymentSessionId"] != "pay-1" {
t.Fatalf("topup body missing autoTopUp/paymentSessionId: %#v", topupBody)
}
}
func TestValidatedCheckoutURLRequiresAbsoluteHTTPS(t *testing.T) {
tests := []struct {
name string
value string
want string
wantErr bool
}{
{name: "https", value: " https://pay.example.test/checkout?id=1 ", want: "https://pay.example.test/checkout?id=1"},
{name: "empty", wantErr: true},
{name: "relative", value: "/checkout", wantErr: true},
{name: "http", value: "http://pay.example.test/checkout", wantErr: true},
{name: "file", value: "file:///tmp/checkout", wantErr: true},
{name: "custom protocol", value: "wallet://checkout/123", wantErr: true},
{name: "userinfo", value: "https://[email protected]/checkout", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := validatedCheckoutURL(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("validatedCheckoutURL(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("validatedCheckoutURL(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestStreamTopUpByKeyPinsValidatedInferenceEndpoint(t *testing.T) {
validatedCalls := 0
validated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
validatedCalls++
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
default:
t.Errorf("unexpected validated-endpoint request: %s %s", r.Method, r.URL.Path)
}
}))
defer validated.Close()
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("validated key leaked to process override: %s %s", r.Method, r.URL.Path)
}))
defer override.Close()
t.Setenv("AIMLAPI_APP_URL", validated.URL)
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
_, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
APIKey: "production-key",
AmountUSD: "20",
InferenceBaseURL: validated.URL,
PaymentSessionID: "payment-1",
OpenBrowser: func(string) error { return nil },
})
if err != nil {
t.Fatalf("StreamTopUpByKey() error = %v", err)
}
if validatedCalls != 1 {
t.Fatalf("validated endpoint top-up calls = %d, want 1", validatedCalls)
}
}
func TestStreamTopUpEmailRetrySendsStablePaymentSessionID(t *testing.T) {
getCalls := 0
var paymentSessionID string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
status := SessionStatusPendingAuth
if getCalls > 0 {
status = SessionStatusPaid
}
getCalls++
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_retry", Status: status})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
paymentSessionID, _ = body["paymentSessionId"].(string)
_ = json.NewEncoder(w).Encode(PayResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pcs_retry",
PaymentSessionID: "payment-stable",
AmountUSD: "20",
NoOpen: true,
})
if err != nil {
t.Fatalf("StreamTopUp() error = %v", err)
}
if paymentSessionID != "payment-stable" {
t.Fatalf("paymentSessionId = %q, want stable retry id", paymentSessionID)
}
}
// resumeServer is a partner-checkout backend fake that fails the test if the
// re-charging endpoints (create-session / pay) are ever hit, and serves a
// scripted GetSession status sequence so a resume can be driven deterministically.
type resumeServer struct {
t *testing.T
statuses []string // GetSession status per call, last value repeats
getCalls int
payCalls int
createCall int
exchanged int
}
func (rs *resumeServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
rs.createCall++
rs.t.Errorf("resume must not create a new session")
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
rs.payCalls++
rs.t.Errorf("resume must not re-pay (double charge)")
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
rs.exchanged++
_ = json.NewEncoder(w).Encode(ExchangeResult{APIKey: "sk-resumed", APIKeyID: "key_1"})
case r.Method == http.MethodGet:
status := rs.statuses[len(rs.statuses)-1]
if rs.getCalls < len(rs.statuses) {
status = rs.statuses[rs.getCalls]
}
rs.getCalls++
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pc_tok", Status: SessionStatus(status)})
default:
rs.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
}
func TestStreamTopUpResumePaidExchangesWithoutRecharge(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"paid"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
var seen string
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
OnSession: func(token string) { seen = token },
})
if err != nil {
t.Fatalf("StreamTopUp resume = %v", err)
}
if result.APIKey != "sk-resumed" {
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
}
if rs.exchanged != 1 {
t.Fatalf("exchange calls = %d, want 1", rs.exchanged)
}
if seen != "pc_tok" {
t.Fatalf("OnSession token = %q, want retained pc_tok", seen)
}
}
func TestStreamTopUpResumePendingPaymentPollsNeverRepays(t *testing.T) {
// First GetSession (resume resolution) sees the still-open checkout; the next
// (poll) sees it settle. Pay must never be called — the fake fails the test if
// it is.
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "paid"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err != nil {
t.Fatalf("StreamTopUp resume = %v", err)
}
if result.APIKey != "sk-resumed" {
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
}
if rs.payCalls != 0 {
t.Fatalf("pay calls = %d, want 0", rs.payCalls)
}
}
func TestStreamTopUpPollingDeadSessionClearsRetainedToken(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "expired"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
seen := []string{}
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
OnSession: func(token string) { seen = append(seen, token) },
})
if err == nil || !strings.Contains(err.Error(), "payment expired") {
t.Fatalf("err = %v, want expired payment error", err)
}
if len(seen) == 0 || seen[len(seen)-1] != "" {
t.Fatalf("OnSession events = %#v, want terminal clear", seen)
}
}
func TestPollUntilPaidTerminal4xxClearsRetainedToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session rejected", http.StatusNotFound)
}))
defer server.Close()
seen := "retained"
_, err := pollUntilPaid(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pc_missing", func(token string) {
seen = token
})
if err == nil {
t.Fatal("pollUntilPaid() error = nil, want terminal 4xx")
}
if seen != "" {
t.Fatalf("retained session = %q, want cleared", seen)
}
}
func TestPollUntilExchangeSettledClearsDeadSession(t *testing.T) {
for _, test := range []struct {
name string
statusCode int
status SessionStatus
}{
{name: "terminal API rejection", statusCode: http.StatusNotFound},
{name: "expired exchange claim", status: SessionStatusExpired},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if test.statusCode != 0 {
http.Error(w, "session rejected", test.statusCode)
return
}
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_dead", Status: test.status})
}))
defer server.Close()
seen := "retained"
err := pollUntilExchangeSettled(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pcs_dead", func(token string) {
seen = token
})
if err == nil {
t.Fatal("pollUntilExchangeSettled() error = nil")
}
if seen != "" {
t.Fatalf("retained session = %q, want cleared", seen)
}
})
}
}
func TestResumeHelpersClearOnlyRejectedSessions(t *testing.T) {
tests := []struct {
name string
status int
wantClear bool
}{
{name: "terminal 4xx", status: http.StatusNotFound, wantClear: true},
{name: "retryable 5xx", status: http.StatusServiceUnavailable, wantClear: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "session lookup failed", test.status)
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
for _, flow := range []struct {
name string
resolve func(func(string)) error
}{
{
name: "session bearer",
resolve: func(onSession func(string)) error {
_, _, err := resolveTopupSession(context.Background(), client, StreamTopUpOptions{
ResumeSessionToken: "pc_rejected", OnSession: onSession,
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
return err
},
},
{
name: "api key",
resolve: func(onSession func(string)) error {
_, _, err := resolveByKeySession(context.Background(), client, StreamTopUpByKeyOptions{
ResumeSessionToken: "pc_rejected", OnSession: onSession,
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
return err
},
},
} {
t.Run(flow.name, func(t *testing.T) {
seen := "retained"
if err := flow.resolve(func(token string) { seen = token }); err == nil {
t.Fatal("resolve error = nil")
}
if cleared := seen == ""; cleared != test.wantClear {
t.Fatalf("session cleared = %v, want %v", cleared, test.wantClear)
}
})
}
})
}
}
func TestStreamTopUpResumeExchangedReturnsRecoveryError(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want an already-exchanged recovery error", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 (no second key)", rs.exchanged)
}
}
func TestStreamTopUpResumeExchangingPollsWithoutSecondExchange(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"exchanging", "exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want dashboard recovery guidance", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 while an exchange claim is in flight", rs.exchanged)
}
if rs.createCall != 0 || rs.payCalls != 0 {
t.Fatalf("resume created/paid a new checkout: create=%d pay=%d", rs.createCall, rs.payCalls)
}
}
func TestStreamTopUpPollObservesExchangingWithoutSecondExchange(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "exchanging", "exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want dashboard recovery guidance", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 after poll observed an existing claim", rs.exchanged)
}
}
+186
View File
@@ -0,0 +1,186 @@
package aimlapi
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// Status is a progress phase reported by StreamTopUp through its OnStatus callback.
type Status string
const (
StatusCreatingSession Status = "creating-session"
StatusOpeningCheckout Status = "opening-checkout"
StatusWaitingPayment Status = "waiting-payment"
StatusProvisioningKey Status = "provisioning-key"
)
// ProvisionedKey is the outcome of a top-up: the (optionally minted) API key plus
// the inference base URL and model to write into the provider profile.
type ProvisionedKey struct {
APIKey string
APIKeyID string
BaseURL string
Model string
}
const (
pollInterval = 3 * time.Second
pollTimeout = 20 * time.Minute
)
// ParseAmountUSD parses a dollar string into USD minor units (cents), enforcing the
// min/max top-up bounds. An empty value yields the default amount.
func ParseAmountUSD(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultAmountUSDMinor, nil
}
dollars, err := strconv.ParseFloat(value, 64)
// NaN/±Inf parse without error and slip past the min/max comparisons below
// (every ordered comparison with NaN is false), so reject them explicitly
// before the cents conversion turns them into a garbage minor-unit amount.
if err != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0 {
return 0, fmt.Errorf("invalid amount %q; pass a positive USD amount", value)
}
minor := int(dollars*100 + 0.5)
if minor < MinAmountUSDMinor {
return 0, fmt.Errorf("minimum top-up is $%d", MinAmountUSDMinor/100)
}
if minor > MaxAmountUSDMinor {
return 0, fmt.Errorf("maximum top-up is $%d", MaxAmountUSDMinor/100)
}
return minor, nil
}
// NewPaymentSessionID returns a random UUIDv4-shaped idempotency id for a top-up.
// The caller generates it once per top-up intent and reuses it on retry so the
// backend (and Stripe) coalesce retries onto the same checkout instead of a
// second charge.
func NewPaymentSessionID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func pollUntilPaid(ctx context.Context, client *Client, sessionToken string, onSession func(string)) (PartnerCheckoutSession, error) {
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
session, err := client.GetSession(ctx, sessionToken)
if err != nil {
// Context cancellation/deadline is terminal (also covers an in-flight
// request cancelled mid-poll, whose transport error wraps ctx.Err()).
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return PartnerCheckoutSession{}, err
}
// A 4xx API response is terminal. Everything else — a 5xx or a transient
// transport error (non-APIError, e.g. a dropped connection during the
// wait) — is retried until the poll deadline.
if isTerminalSessionAPIError(err) {
reportSession(onSession, "")
return PartnerCheckoutSession{}, err
}
if err := sleepContext(ctx, pollInterval); err != nil {
return PartnerCheckoutSession{}, err
}
continue
}
switch session.Status {
case SessionStatusPaid, SessionStatusExchanging:
return session, nil
case SessionStatusExchanged:
return PartnerCheckoutSession{}, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
// The checkout cannot recover from these states. Drop the retained token
// (and the TUI's paired by-key idempotency ID) now so the very next retry
// creates a fresh payment intent.
reportSession(onSession, "")
return PartnerCheckoutSession{}, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
default:
if err := sleepContext(ctx, pollInterval); err != nil {
return PartnerCheckoutSession{}, err
}
}
}
return PartnerCheckoutSession{}, fmt.Errorf("timed out waiting for payment; re-run once the payment clears")
}
func isTerminalSessionAPIError(err error) bool {
var apiErr APIError
return errors.As(err, &apiErr) && apiErr.Status >= http.StatusBadRequest && apiErr.Status < http.StatusInternalServerError
}
// pollUntilExchangeSettled follows an exchange claim that was already in flight
// when a retry resumed the session. Exchange is single-flight and the raw key is
// returned only to the winning request, so issuing Exchange again is never safe.
// Once the claim completes, direct the user to the dashboard recovery path.
func pollUntilExchangeSettled(ctx context.Context, client *Client, sessionToken string, onSession func(string)) error {
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
session, err := client.GetSession(ctx, sessionToken)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
if isTerminalSessionAPIError(err) {
reportSession(onSession, "")
return err
}
if err := sleepContext(ctx, pollInterval); err != nil {
return err
}
continue
}
switch session.Status {
case SessionStatusExchanged:
return fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
case SessionStatusExchanging:
if err := sleepContext(ctx, pollInterval); err != nil {
return err
}
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
reportSession(onSession, "")
return fmt.Errorf("key provisioning %s; rotate the key from the aimlapi.com dashboard", session.Status)
default:
return fmt.Errorf("key provisioning returned to %s; re-run the top-up", session.Status)
}
}
return fmt.Errorf("timed out waiting for key provisioning; retry to check the same session")
}
func sleepContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func status(callback func(Status, string), value Status, detail string) {
if callback != nil {
callback(value, detail)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
+38
View File
@@ -0,0 +1,38 @@
package aimlapi
import (
"net/mail"
"strings"
)
// ValidEmail performs the lightweight validation used before starting the
// passwordless onboarding request.
func ValidEmail(value string) bool {
value = strings.TrimSpace(value)
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value {
return false
}
at := strings.LastIndexByte(value, '@')
if at <= 0 || at == len(value)-1 {
return false
}
domain := value[at+1:]
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
return false
}
dot := strings.LastIndexByte(domain, '.')
if dot <= 0 || dot == len(domain)-1 {
return false
}
tld := domain[dot+1:]
if len(tld) < 2 {
return false
}
for _, char := range tld {
if char < 'A' || char > 'Z' && char < 'a' || char > 'z' {
return false
}
}
return true
}
+4 -74
View File
@@ -3,11 +3,10 @@
package background
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// terminationGracePeriod is how long a process has to exit after SIGTERM before
@@ -22,10 +21,7 @@ var (
// the negative PID (the group), so any process the child forks dies with it
// instead of being orphaned. Must be called before cmd.Start.
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
execution.ConfigureProcessGroup(cmd)
}
// terminateProcess stops a background process. It first asks politely with
@@ -41,71 +37,5 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
// back to signalling only the individual PID, which also avoids reporting a false
// success when a non-leader group-signal returns ESRCH.
func terminateProcess(pid int) error {
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
// every process we can signal. A real child PID is always > 1, so never let a
// bogus 0/1 expand into either.
if pid <= 1 {
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
}
// Signal the whole group only when pid leads its own group; otherwise the
// individual process. See the doc comment for why a non-leader is signalled
// directly rather than via its (foreign) group.
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if processGoneError(err) {
return nil // already gone
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if processGoneError(err) {
return nil
}
return err
}
// Poll liveness so we return promptly once it exits, rather than always
// waiting out the full grace period.
deadline := time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if !alive() {
return nil
}
// Still alive after the grace period: force-kill.
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
return err
}
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
// so this helper only reports success once the target is actually gone —
// otherwise the caller gets nil while descendants are still racing.
deadline = time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
// processGoneError reports whether an error means the process group has already
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
// no process in the target group remains.
func processGoneError(err error) bool {
return errors.Is(err, syscall.ESRCH)
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
}
+3 -6
View File
@@ -149,12 +149,9 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
// The forked child must be gone too (poll until reaped by init).
deadline := time.Now().Add(2 * time.Second)
for {
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
break // child no longer exists
}
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
Loaded 100 of 666 files, more files were not shown because too many files have changed in this diff. Show more