Compare commits

...
9 Commits
Author SHA1 Message Date
github-actions[bot] ad34dc8d81 chore(main): release 0.8.0 (#896)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-22 00:08:57 +08:00
beardthelion 6edf9a8b78 fix(sandbox): normalize launcher names before the command-prefix denylist (#934)
* fix(sandbox): retire recorded approvals through a policy bump

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

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

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

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

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

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

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

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

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

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

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

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

Reported by CodeRabbit on #934.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

Eleven tests, each confirmed to fail without its fix.

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

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

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

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

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

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

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

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

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

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

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

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

Refs #898

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

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

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

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

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

Refs #898

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

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

Refs #899

---------

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

* Fix structured apply patch handling

* Restore tool previews when resuming sessions

* Improve theme-aware code rendering

* Replace persistent sidebar with run details

* Remove inert transcript interactions

* Cover resumed edit previews

* Smooth active run status feedback

* Preserve semantic colors behind overlays

* Normalize ChatGPT model aliases

* Render staged image attachments in terminal

* Polish transcript and tool feedback

* Resolve rebased diff renderer conflict

* Address UI polish review findings

* Fix smoke test regressions

* Harden patch and transcript edge cases

* Bind structured patches to workspace root

* Use unique structured patch temp files

* Retry structured patch temp allocation

* Reuse rooted patch temp allocator

* Fix Windows CI process checks

* Fix structured patch safety boundaries

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

* Resolve plan test seam after rebase

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

* Address final review correctness gaps

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

* Harden patch transactions and review edges

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

* Bind patch rollback to committed versions

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

* Align structured patch failure semantics

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

* Preserve patch context bytes

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

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.7.0"}
{".":"0.8.0"}
+39
View File
@@ -111,3 +111,42 @@ These classes drive multi-round reviews. Fix them before requesting review:
shipped. Wire advertised entry points or shrink the claim. Do not bundle
unrelated fixes. Do not widen security allowlists by name alone; check
classification and side effects too.
- **Host tool version floors:** Zero shells out to `git` and other host tools,
and the project does not declare a minimum version for any of them. A flag,
subcommand, or config key that is not long-established therefore raises that
floor silently, and the failure on an older host is an unhelpful usage error
rather than a clear message. Before adding one, check when it landed and say
so where a caller would look. Record the floor for the exact subcommand you
call, not for the flag in general: `--end-of-options` reached Git's shared
parser in 2.24, but `git rev-parse` only accepted it from 2.30, and
`git checkout` and `git reset` later still. Likewise `--show-current` is
`git branch` 2.22, and `branch.autoSetupMerge=inherit` is 2.35. Then either
keep a fallback for older versions or gate the call. A test that depends on a
newer feature must skip below its floor instead of failing, and say which
version it needs.
- **Prove the test fails without the change:** A regression test earns its
place only if it fails on the unfixed code, for the reason it claims. Run it
both ways and quote the failure in the PR. The recurring miss is not a
missing test but a test that never reaches the behavior it names, because an
earlier guard rejects the input first, so it passes with and without the fix.
When a test targets a specific layer, call that layer directly rather than
relying on a public entry point that may refuse earlier.
- **Hermetic tests:** Tests must not read or write the developer's real config,
cache, or state directories. Redirect every root the code under test
resolves, not just config. `os.UserConfigDir` and `os.UserCacheDir` read
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` only on Linux and the BSDs. On macOS
they derive from `HOME`, and on Windows from `%AppData%` and `%LocalAppData%`.
Setting the XDG variables alone still leaves a macOS run writing to the real
home, so set `HOME` as well, or take the directory as a parameter. Apply it to
every test that reaches the same storage, not only the one where it was first
needed; a test that passes on a clean machine and pollutes a real one is a
blocker.
- **Error codes vary by platform and by flag combination:** A check written
against the code one target returns can be silently inert on another while
still looking correct. `openat(O_DIRECTORY|O_NOFOLLOW)` on a symlink may
report `ENOTDIR` or `ELOOP` depending on the platform, because the kernel can
reject the directory mismatch before it ever resolves the link; accept both
rather than assuming the one POSIX names. On Windows, `\\?\` and `\\.\`
prefixes are not UNC paths, and an `NtCreateFile` information class must match
the struct actually passed. Confirm the code each supported target returns
instead of reasoning from the man page.
+21
View File
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
### Features
* **security:** path-jail primitive, credential-store locking, worktree git hardening ([#891](https://github.com/Gitlawb/zero/issues/891)) ([060d38c](https://github.com/Gitlawb/zero/commit/060d38ce716feb7c5b6657b082cd35cb8dda3d75))
* **update:** recognise Homebrew installs and add mise, go install docs ([#910](https://github.com/Gitlawb/zero/issues/910)) ([01c3498](https://github.com/Gitlawb/zero/commit/01c3498205e69d26e5f929225f9455b276444f39))
* use Exa as default search provider ([#926](https://github.com/Gitlawb/zero/issues/926)) ([1ec7219](https://github.com/Gitlawb/zero/commit/1ec7219aee7b2840481ab0905f131644e27d2a4e))
* **zerogit:** auto-create a conventional branch before push/pr on default branch ([#671](https://github.com/Gitlawb/zero/issues/671)) ([d065467](https://github.com/Gitlawb/zero/commit/d065467cd3ec3ed91c5dc60554c36fc4b4d6de4c))
### Bug Fixes
* **acp:** three defects a desktop ACP client hits ([#915](https://github.com/Gitlawb/zero/issues/915)) ([a78c6c3](https://github.com/Gitlawb/zero/commit/a78c6c357e1d21146d7cd2e8030a754e06f7f1f5))
* bump Go to 1.26.6 for stdlib vulnerability fixes ([#903](https://github.com/Gitlawb/zero/issues/903)) ([dc15e82](https://github.com/Gitlawb/zero/commit/dc15e822987d8d7de467c62c375518f435b74d8c))
* **daemon:** clean up child after startup timeout ([#774](https://github.com/Gitlawb/zero/issues/774)) ([9c29209](https://github.com/Gitlawb/zero/commit/9c292092a26c9092fd873d072b0af1b0d9d39ef0))
* **dictation:** redact API keys from streaming transcriber errors ([#852](https://github.com/Gitlawb/zero/issues/852)) ([64a783b](https://github.com/Gitlawb/zero/commit/64a783b08caa2c7b21f188033b9ed84a14e00e84))
* **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6))
* **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d))
* **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a))
## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<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="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-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>
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
```bash
git clone https://github.com/Gitlawb/zero.git
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<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="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-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>
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.26.5+。
源码构建需要 Go 1.26.6+。
```bash
git clone https://github.com/Gitlawb/zero.git
+1 -1
View File
@@ -33,4 +33,4 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to the documented Firecrawl endpoint — see the README).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
+83 -8
View File
@@ -1,14 +1,30 @@
# Installing Zero
Zero is distributed as:
Pick whichever fits how you already install things. None of these is the
blessed one.
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.26.5+
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
`zero upgrade` follows whatever owns the binary, with one exception it cannot
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
keg and names `brew upgrade zero`, and replaces the binary in place for
everything else — including a mise-managed one, which is the exception.
mise is the one case it cannot detect: a mise-managed binary is an ordinary
standalone install on disk, so `zero upgrade` will replace it and leave mise
describing a version that is no longer there. Use `mise upgrade` instead.
Release archives are the substrate for most of the above: the install scripts
and the npm fallback both download a platform archive from a published GitHub
Release and verify its checksum.
## npm
@@ -111,6 +127,45 @@ Defaults:
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
## mise
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
mise-managed binary is an ordinary standalone install, but then mise's records
describe a version that is no longer on disk. Prefer `mise upgrade`.
## go install
```bash
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.6+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
macOS and Windows need no extra helper.
## From Source
```bash
@@ -125,7 +180,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
### Sandbox Helpers For Source Builds
@@ -242,3 +297,23 @@ zero upgrade
```
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
`zero upgrade` behaves differently depending on how Zero was installed, because
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
- **everything else**: downloads the verified release archive and replaces the
binary in place.
Homebrew is detected by the binary living inside a Cellar keg that carries
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
directory tree you built yourself can have exactly that shape, and refusing to
update an install Homebrew never touched would be a real cost for a made-up
reason. The receipt is the thing only Homebrew writes.
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
where that is also the Homebrew prefix.
+16 -2
View File
@@ -296,6 +296,16 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
if errors.Is(err, context.Canceled) {
return StopCancelled, nil
}
// A CLIENT THAT CANCELLED A PERMISSION PROMPT DID NOT FAIL. Only
// context.Canceled was matched here, so dismissing a permission dialog —
// a deliberate action, and for apply_patch the only refusal a client is
// offered — came back as JSON-RPC -32603 carrying the internal sentinel
// text. Editors and the desktop app both render that as a crashed turn,
// so declining a tool looked like ZERO falling over. It is a
// cancellation, and StopCancelled is what ACP has for saying so.
if errors.Is(err, agent.ErrPermissionApprovalCanceled) {
return StopCancelled, nil
}
return "", err
}
if result.FinishReason == "length" {
@@ -311,10 +321,14 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
// session/request_permission request and maps the outcome back. Failure to reach
// the client fails closed to deny.
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
// Built ONCE and used for both halves. Sending one list and validating the
// reply against another is the defect this whole path had; see
// decisionFromOutcome.
options := buildPermissionOptions(req)
params := RequestPermissionParams{
SessionID: sessionID,
ToolCall: permissionToolCall(req),
Options: buildPermissionOptions(req),
Options: options,
}
var result RequestPermissionResult
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
@@ -323,7 +337,7 @@ func (a *Agent) requestPermission(ctx context.Context, sessionID string, req age
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission request failed: " + err.Error()}, nil
}
return decisionFromOutcome(result.Outcome, req.AvailableDecisions), nil
return decisionFromOutcome(result.Outcome, options), nil
}
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
+217
View File
@@ -0,0 +1,217 @@
package acp
// desktopinterop_test.go covers three defects found by driving the real `zero
// acp` binary from a desktop ACP client. Each is a case where ZERO and a
// conforming client disagreed about the wire, and none of them failed loudly —
// they produced a silent denial, a spurious crash, or two buttons the user
// could not tell apart.
//
// A fourth, session/load restoring history without replaying it, is fixed by
// #914 rather than here: that one also gives the replayed messages stable ids
// and keeps session/resume replay-free, which this did not.
import (
"errors"
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
// The list the options were BUILT from and the list the answer was CHECKED
// against were different whenever ZERO did not enumerate: the client was sent
// Allow and Reject, and its reply was validated against an empty slice, so
// every button it could possibly show failed closed to deny. The user clicked
// Allow and ZERO recorded a denial, with nothing on screen to say so.
func TestAnOfferedOptionIsAccepted(t *testing.T) {
// No AvailableDecisions: the fallback path, which is every permission event
// that is not a prompt.
req := agent.PermissionRequest{ToolName: "bash"}
options := buildPermissionOptions(req)
if len(options) == 0 {
t.Fatal("no options were offered")
}
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
buildPermissionOptions(req),
)
if string(decision.Action) != option.OptionID {
t.Fatalf("option %q was answered with %q (%s) — an offered option must be accepted",
option.OptionID, decision.Action, decision.Reason)
}
}
}
// The narrowing this validation exists for still holds: a client must not be
// able to return a broader grant than it was shown.
func TestAnOptionThatWasNotOfferedIsStillRefused(t *testing.T) {
req := agent.PermissionRequest{ToolName: "bash"}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAlwaysAllow)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("an unoffered broader grant was accepted as %q", decision.Action)
}
}
// The resolver is the single source of truth for both sides, which is what
// makes the two agree by construction rather than by being kept in step.
func TestOfferedDecisionsMatchWhatWasSent(t *testing.T) {
for _, req := range []agent.PermissionRequest{
{ToolName: "bash"},
{ToolName: "bash", AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
}},
} {
offered := buildPermissionOptions(req)
for _, option := range buildPermissionOptions(req) {
if !actionOffered(option.OptionID, offered) {
t.Fatalf("option %q was sent but is not in the offered set %v", option.OptionID, offered)
}
}
}
}
// ---- two options the user can tell apart ----
// request_permissions offers plain allow and strict-review allow together, and
// both were labelled "Allow". The label is the only thing an ACP client shows,
// so the panel presented two identical buttons where one silently enabled
// strict auto-review of what was granted.
func TestEveryOfferedOptionHasADistinctLabel(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionDeny,
},
}
seen := map[string]string{}
for _, option := range buildPermissionOptions(req) {
if previous, clash := seen[option.Name]; clash {
t.Fatalf("options %q and %q share the label %q — a user cannot tell them apart",
previous, option.OptionID, option.Name)
}
seen[option.Name] = option.OptionID
}
}
// The two remain distinct decisions: the label changed, the round trip did not.
func TestStrictAllowStillRoundTrips(t *testing.T) {
req := agent.PermissionRequest{
ToolName: "request_permissions",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowStrict,
},
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAllowStrict)},
buildPermissionOptions(req),
)
if decision.Action != agent.PermissionDecisionAllowStrict {
t.Fatalf("strict allow round-tripped as %q", decision.Action)
}
}
// ---- cancelling a permission is a cancellation, not a crash ----
// Only context.Canceled was recognised, so dismissing a permission dialog came
// back as JSON-RPC -32603 carrying the internal sentinel text. Clients render
// that as a failed turn, so declining a tool looked like ZERO falling over —
// and for apply_patch, dismissing is the ONLY refusal a client is offered.
func TestCancellingAPermissionEndsTheTurnAsCancelled(t *testing.T) {
canceled := fmt.Errorf("%w for apply_patch", agent.ErrPermissionApprovalCanceled)
reason, err := stopReasonFor(agent.Result{}, canceled)
if err != nil {
t.Fatalf("stopReasonFor returned an error for a cancellation: %v", err)
}
if reason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", reason, StopCancelled)
}
}
// A genuine failure must still be an error. Mapping too much to cancelled would
// hide real faults behind a clean ending, which is the opposite mistake.
func TestARealFailureIsStillAnError(t *testing.T) {
failure := errors.New("provider exploded")
if _, err := stopReasonFor(agent.Result{}, failure); !errors.Is(err, failure) {
t.Fatalf("stopReasonFor(%v) = %v, want the failure preserved", failure, err)
}
}
// The sentinel is matched through errors.Is, so it survives the wrapping every
// return site applies — each one adds the tool name.
func TestPermissionCancellationSurvivesWrapping(t *testing.T) {
if !errors.Is(fmt.Errorf("%w for bash", agent.ErrPermissionApprovalCanceled), agent.ErrPermissionApprovalCanceled) {
t.Fatal("a wrapped permission cancellation was not recognised")
}
if errors.Is(errors.New("something else"), agent.ErrPermissionApprovalCanceled) {
t.Fatal("an unrelated error was reported as a permission cancellation")
}
}
// A decision that never became an option cannot be selected.
//
// PermissionDecisionCancel is the case that exists today: optionKindFor drops
// it because ACP expresses cancellation through the outcome, yet ZERO
// enumerates it in AvailableDecisions for shell commands and apply_patch. While
// the reply was validated against the DECISIONS rather than the sent options, a
// client could return {"outcome":"selected","optionId":"cancel"} — an id it was
// never shown — and abort the whole turn.
//
// Written over every option-less decision rather than over "cancel", so a
// future action that optionKindFor declines to render is covered the day it is
// added rather than the day someone remembers this test.
func TestADecisionThatIsNotAnOptionCannotBeSelected(t *testing.T) {
for _, toolName := range []string{"bash", "apply_patch"} {
req := agent.PermissionRequest{
ToolName: toolName,
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
agent.PermissionDecisionCancel,
},
}
options := buildPermissionOptions(req)
for _, action := range req.AvailableDecisions {
if actionOffered(string(action), options) {
continue // it was sent; selecting it is legitimate
}
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(action)},
options,
)
if decision.Action != agent.PermissionDecisionDeny {
t.Fatalf("%s: %q was never sent as an option but was accepted as %q",
toolName, action, decision.Action)
}
}
}
}
// Cancelling is still reachable — through the outcome, which is where ACP puts
// it. Closing the selected-id route must not close the real one.
func TestCancellingThroughTheOutcomeStillWorks(t *testing.T) {
options := buildPermissionOptions(agent.PermissionRequest{ToolName: "apply_patch"})
decision := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, options)
if decision.Action != agent.PermissionDecisionCancel {
t.Fatalf("outcome=cancelled gave %q, want cancel", decision.Action)
}
}
+57 -18
View File
@@ -15,14 +15,7 @@ import (
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
actions := req.AvailableDecisions
if len(actions) == 0 {
// Sensible default if ZERO didn't enumerate: allow once / reject.
actions = []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
actions := offeredDecisions(req)
options := make([]PermissionOption, 0, len(actions))
for _, action := range actions {
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
@@ -38,6 +31,32 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
return options
}
// offeredDecisions resolves what this request actually offers the client.
//
// ONE RESOLVER, USED BY BOTH SIDES, and that is the entire point of it existing.
// The fallback below used to live inside buildPermissionOptions while
// requestPermission validated the client's answer against the raw
// req.AvailableDecisions. When ZERO did not enumerate — which is every
// permission event that is not a prompt, and includes a shell call whose sandbox
// decision came back deny — the two disagreed completely: the client was sent
// "Allow" and "Reject", and validation was performed against an EMPTY list, so
// every option the client could possibly click failed closed to deny with
// "permission option was not offered".
//
// The user clicked Allow and ZERO recorded a denial. Nothing surfaced it,
// because a denial is a legitimate answer — the tool was simply refused and the
// turn carried on as though the user had rejected it.
func offeredDecisions(req agent.PermissionRequest) []agent.PermissionDecisionAction {
if len(req.AvailableDecisions) > 0 {
return req.AvailableDecisions
}
// Sensible default if ZERO didn't enumerate: allow once / reject.
return []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
}
}
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
// human label. Returns an empty kind for actions that ACP expresses through the
// outcome rather than an option (cancel).
@@ -49,8 +68,16 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
// disclosure cannot be TUI-only.
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
switch action {
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
case agent.PermissionDecisionAllow:
return PermAllowOnce, "Allow"
case agent.PermissionDecisionAllowStrict:
// SPLIT FROM PLAIN ALLOW because the label is the only thing an ACP
// client shows. Both were labelled "Allow", and request_permissions
// offers them together — so the panel presented two identical buttons,
// and whichever the user picked looked the same while one of them
// silently turned on strict auto-review of what was granted. The option
// id carries no room for the distinction, so it has to be in the name.
return PermAllowOnce, "Allow with strict review"
case agent.PermissionDecisionAllowForSession:
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
@@ -70,19 +97,31 @@ func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind,
// decisionFromOutcome maps the client's permission outcome back to a ZERO
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
// action verbatim (validated against what was offered); anything unrecognized
// fails closed to deny.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
// action verbatim (validated against what was actually sent); anything
// unrecognized fails closed to deny.
//
// IT TAKES THE OPTIONS THAT WERE SENT, not the decisions the request offered,
// and the two are not the same list. Some actions never become options —
// PermissionDecisionCancel is dropped by optionKindFor because ACP expresses
// cancellation through the outcome instead — yet it IS enumerated in
// AvailableDecisions for shell commands and apply_patch, the two commonest
// prompts. Validating against the decisions therefore accepted
// {"outcome":"selected","optionId":"cancel"}: an identifier no client was ever
// shown, aborting the whole turn.
//
// Passing the sent options makes "what we offered" and "what we accept" the
// same slice rather than two things kept in step, so anything optionKindFor
// declines to render is excluded structurally instead of by name.
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionOption) agent.PermissionDecision {
switch outcome.Outcome {
case OutcomeCancelled:
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
case OutcomeSelected:
action := agent.PermissionDecisionAction(outcome.OptionID)
// Bind to what was actually offered for THIS call: a client must not be able
// to return a broader grant (always_allow / allow_for_session) that wasn't
// presented. Anything not offered fails closed to deny.
if actionOffered(action, offered) {
return agent.PermissionDecision{Action: action}
if actionOffered(outcome.OptionID, offered) {
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
}
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
default:
@@ -90,9 +129,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.Permi
}
}
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
for _, a := range offered {
if a == action {
func actionOffered(optionID string, offered []PermissionOption) bool {
for _, option := range offered {
if option.OptionID == optionID {
return true
}
}
+3 -2
View File
@@ -56,10 +56,11 @@ func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
// what was offered, so decorating it would fail every prefix approval closed.
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
request := escalationRequest(true)
for _, option := range buildPermissionOptions(request) {
options := buildPermissionOptions(request)
for _, option := range options {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
request.AvailableDecisions,
options,
)
if string(decision.Action) != option.OptionID {
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
+8 -5
View File
@@ -39,11 +39,14 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
}
func TestDecisionFromOutcome(t *testing.T) {
offered := []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
}
offered := buildPermissionOptions(agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
},
})
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
t.Errorf("cancelled -> %q, want cancel", d.Action)
}
+37
View File
@@ -99,6 +99,43 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
}
}
// The launcher denylist is only worth anything if it holds on the path a user
// actually sees. A model naming its own prefix_rule is that path: before
// launcher names were normalized, "python3.11" was offered as a one-token
// grant, and hasStringPrefix then matched every later python3.11 invocation.
func TestProposedCommandPrefixRejectsLauncherSpellingsInRequestedPrefix(t *testing.T) {
for _, testCase := range []struct {
command string
rule []any
}{
{"python3.11 script.py", []any{"python3.11"}},
{"python.exe script.py", []any{"python.exe"}},
{"node.exe app.js", []any{"node.exe"}},
{"cmd /c whoami", []any{"cmd"}},
{"busybox sh -c id", []any{"busybox"}},
{"uv run main.py", []any{"uv", "run"}},
{"python3.11 -c import_os", []any{"python3.11", "-c"}},
} {
got := proposedCommandPrefix("bash", map[string]any{
"command": testCase.command,
"prefix_rule": testCase.rule,
})
if got != nil {
t.Errorf("prefix_rule %v for %q was offered as %#v, want rejected", testCase.rule, testCase.command, got)
}
}
}
func TestProposedCommandPrefixStillOffersOrdinaryCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "cargo build --release",
"prefix_rule": []any{"cargo", "build"},
})
if len(got) != 2 || got[0] != "cargo" || got[1] != "build" {
t.Fatalf("ordinary prefix rule = %#v, want [cargo build]", got)
}
}
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
cases := []string{
"cat < in > out",
+19 -7
View File
@@ -40,7 +40,15 @@ const (
toolResultControlSpecReview = "spec_review_required"
)
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
// ErrPermissionApprovalCanceled ends a run because the caller cancelled a
// permission prompt, rather than because anything failed.
//
// EXPORTED FOR THE SURFACES. A cancel is a normal ending and each surface says
// so its own way — the TUI unwinds, and ACP has a "cancelled" stop reason for
// exactly this. While it was unexported ACP could not distinguish it from a
// fault and reported it as an internal error, so declining a tool looked like
// the agent crashing.
var ErrPermissionApprovalCanceled = errors.New("permission approval cancelled")
// isImageRejectionError reports whether err is a provider 400 that rejects
// image/multimodal content. This is checked BEFORE the compaction-retry path
@@ -1364,7 +1372,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, decisionReason)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
@@ -1548,7 +1556,7 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
@@ -1596,7 +1604,7 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
@@ -3087,13 +3095,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// registered, model-visible, not in DisabledTools, and advertised in the
// current permission mode (e.g. not spec-draft, where tool_search is not
// advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
tools.ModelVisible(loader) &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
@@ -3208,7 +3218,9 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
return tools.ModelVisible(tool) &&
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
ToolAdvertised(tool, permissionMode)
}
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
+93 -1
View File
@@ -1853,7 +1853,7 @@ func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
},
})
if !errors.Is(err, errPermissionApprovalCanceled) {
if !errors.Is(err, ErrPermissionApprovalCanceled) {
t.Fatalf("expected permission approval cancel error, got %v", err)
}
if result.FinalAnswer != "" {
@@ -4340,3 +4340,95 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
t.Fatalf("expected tool result to mention the beforeTool veto, got %q", combined)
}
}
// A cancelled permission on either sandbox-retry path must abort the run with a
// recognisable cancellation, and must not run the escalated retry.
//
// BOTH PATHS, because both wrap ErrPermissionApprovalCanceled and each is
// reached by a different kind of denial: one by an unrestricted platform denial
// asking for approval, the other by an external-network denial. Surfaces map
// this sentinel onto their own idea of a cancel — ACP returns stopReason
// "cancelled" for it — so a path that produced a bare error instead would show
// the user a crash where they had simply declined.
func TestCancellingASandboxRetryAbortsWithoutRetrying(t *testing.T) {
denial := func(capability execution.CapabilityKind, scope string) tools.Result {
return tools.Result{
Status: tools.StatusError,
ExecutionOutcome: &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: capability, Scope: scope},
Source: execution.DenialSourcePlatformSandbox,
Reason: "denied by the sandbox",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
},
},
}
}
for _, testCase := range []struct {
name string
result tools.Result
run func(context.Context, *tools.Registry, ToolCall, tools.Tool, map[string]any, tools.Result, Options) error
}{
{
name: "unsandboxed retry",
result: denial(execution.CapabilityUnrestricted, "host"),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, _, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
{
name: "network retry",
result: denial(execution.CapabilityExternalNetwork, ""),
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
_, _, _, _, _, abortErr := maybeRetryWithNetworkAfterSandboxDenial(
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
return abortErr
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
retryTool := &sandboxNamespaceLimitedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
asked := 0
options := Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: sandbox.DefaultPolicy(),
}),
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
asked++
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "client cancelled"}, nil
},
}
call := ToolCall{ID: "call-1", Name: "bash", Arguments: `{"command":"curl example.com"}`}
args := map[string]any{"command": "curl example.com"}
abortErr := testCase.run(context.Background(), registry, call, retryTool, args, testCase.result, options)
if asked != 1 {
t.Fatalf("permission was requested %d times, want exactly 1", asked)
}
if !errors.Is(abortErr, ErrPermissionApprovalCanceled) {
t.Fatalf("abort error = %v, want it to match ErrPermissionApprovalCanceled", abortErr)
}
// The escalated retry is the thing the permission was gating. A
// cancelled prompt that still ran it would have run the command the
// user just declined.
if len(retryTool.calls) != 0 {
t.Fatalf("the escalated retry ran %d times after a cancel: %+v", len(retryTool.calls), retryTool.calls)
}
})
}
}
+18
View File
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
}
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
names := make(map[string]bool, len(exposed))
for _, definition := range exposed {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for existing-file changes")
}
if names["edit_file"] {
t.Fatal("agent must not receive the ambiguous string-replacement tool")
}
}
+7 -6
View File
@@ -52,7 +52,7 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
write_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
@@ -61,10 +61,11 @@ work.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
and enough unchanged context to identify the intended location. Match the
existing indentation, imports, and idioms. Match the file's comment density:
do not add explanatory comments unless the user asks or the code is already
comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
@@ -104,7 +105,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/edit_file/apply_patch for file changes.
with exec_command, or use write_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 -2
View File
@@ -60,14 +60,14 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
write_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
+1 -1
View File
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"prefer edit_file or apply_patch",
"for edits to existing files, prefer apply_patch",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
+32 -1
View File
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
}
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
if err != nil {
return Task{}, false, err
var migrated bool
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
if !migrated {
return Task{}, false, err
}
changed = true
}
if outputFile != task.OutputFile {
changed = true
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
return path, nil
}
// migrateLegacyOutputFile recovers task metadata written under a previous home
// or XDG data root. It accepts only the canonical per-task output
// name in the historic zero/background layout, and only when the corresponding
// regular file already exists inside the active manager root.
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
requested = strings.TrimSpace(requested)
if !filepath.IsAbs(requested) {
return "", false
}
requested = filepath.Clean(requested)
expectedName := taskID + ".ndjson"
if filepath.Base(requested) != expectedName {
return "", false
}
legacyRoot := filepath.Dir(requested)
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
return "", false
}
canonical := filepath.Join(manager.rootDir, expectedName)
info, err := os.Lstat(canonical)
if err != nil || !info.Mode().IsRegular() {
return "", false
}
return canonical, true
}
func validTaskID(taskID string) bool {
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
}
+83
View File
@@ -1,6 +1,7 @@
package background
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
}
}
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
canonicalOutput := filepath.Join(root, taskID+".ndjson")
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: legacyOutput,
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal legacy metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write legacy metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
task, ok := manager.Get(taskID)
if !ok {
t.Fatal("manager did not load migrated task")
}
if task.OutputFile != canonicalOutput {
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
}
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
t.Fatalf("migration produced load warnings: %#v", warnings)
}
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
if err != nil {
t.Fatalf("read migrated metadata: %v", err)
}
var saved Task
if err := json.Unmarshal(persisted, &saved); err != nil {
t.Fatalf("decode migrated metadata: %v", err)
}
if saved.OutputFile != canonicalOutput {
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
}
}
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, ok := manager.Get(taskID); ok {
t.Fatal("manager loaded task with an unrelated absolute output path")
}
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
t.Fatalf("load warnings = %q, want containment rejection", warnings)
}
}
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
root := t.TempDir()
manager, err := NewManager(root)
+1 -1
View File
@@ -856,7 +856,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent. A built-in default the user never
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
// configured (e.g. keyless Exa with no credentials) is the exception: it
// was never asked for, so its failure is not worth a startup warning — only
// servers the user actually configured warn on failure (issue #552).
for _, skipped := range mcpRuntime.Skipped() {
+3 -3
View File
@@ -211,12 +211,12 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"exa": config.DefaultMCPServers()["exa"],
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
{Name: "exa", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
@@ -227,7 +227,7 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stderr.String(), "firecrawl") {
if strings.Contains(stderr.String(), "exa") {
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
}
}
+8 -8
View File
@@ -9,15 +9,15 @@ import (
"github.com/Gitlawb/zero/internal/config"
)
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
// `zero mcp disable exa` must work even though exa is a built-in
// default that is not written to the user's config file until overridden.
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
func TestRunMCPDisableSeededExaDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
// A config with no firecrawl entry — the default lives in code, not the file.
// A config with no Exa entry — the default lives in code, not the file.
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
var out, errBuf bytes.Buffer
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
code := runWithDeps([]string{"mcp", "disable", "exa", "--json"}, &out, &errBuf, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
if code != exitSuccess {
@@ -31,8 +31,8 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
}
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
if payload.ServerName != "exa" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want exa disabled+changed", payload)
}
// End-to-end: resolving that config now turns the default off.
@@ -40,7 +40,7 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
if !cfg.Servers["exa"].Disabled {
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
}
}
+15 -5
View File
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const providerCommandTestBudget = 60 * time.Second
const (
providerCommandTestBudget = 90 * time.Second
providerCommandReadyWaitAttempts = 45
)
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
if providerCommandTestBudget < 2*readyWaitCeiling {
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
}
}
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
// runner while leaving the other half of providerCommandTestBudget for
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
+98 -13
View File
@@ -5,22 +5,20 @@ import (
"strings"
)
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
// search and scraping work out of the box with no setup and no API key. They are
// seeded before user/project config is merged (see ResolveMCP), so a user can
// override any field — for example point firecrawl at a self-hosted instance, or
// add an API-key header to lift the free-tier limit — or disable it entirely with
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
// web search and page fetching work out of the box with no setup and no API
// key. They are seeded before user/project config is merged (see ResolveMCP),
// so a user can override any field — for example add an API-key header to lift
// Exa's anonymous rate limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
// use. Zero only calls it over the network, so Firecrawl's license never reaches
// into Zero's own code.
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
// Exa API key for higher limits.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"firecrawl": {
"exa": {
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
URL: "https://mcp.exa.ai/mcp",
},
}
}
@@ -35,13 +33,13 @@ func IsDefaultMCPServer(name string) bool {
// IsUnconfiguredDefault reports whether server is one of Zero's built-in
// defaults that the user never wrote an entry for in their config — i.e. it is
// running with whatever Zero ships (e.g. keyless Firecrawl, no credentials).
// running with whatever Zero ships (e.g. keyless Exa, no credentials).
//
// Both conditions below must hold:
// - !server.configured: the user's JSON never declared an object for this
// server key at all (set by MCPServerConfig.UnmarshalJSON only when it
// actually ran for this key). Any explicit action — including a
// disable/enable toggle like `zero mcp enable firecrawl` that leaves the
// disable/enable toggle like `zero mcp enable exa` that leaves the
// resolved value unchanged — sets configured, so it always counts as
// user-configured, even though the value comparison below could not tell
// the difference on its own.
@@ -58,3 +56,90 @@ func IsUnconfiguredDefault(name string, server MCPServerConfig) bool {
def, ok := DefaultMCPServers()[strings.TrimSpace(name)]
return ok && !server.configured && reflect.DeepEqual(def, server)
}
// retiredDefaultMCPServer describes a built-in default Zero no longer ships.
type retiredDefaultMCPServer struct {
// successor is the default that replaced it.
successor string
// shipped is the value Zero used to seed for this name. A user entry
// written while the default was still shipped may name only an override
// (a header, say) and rely on these fields for the rest, so they have to
// outlive the default itself.
shipped MCPServerConfig
}
// retiredDefaultMCPServers maps a retired built-in default to what replaced it
// and to the value Zero used to ship for it.
var retiredDefaultMCPServers = map[string]retiredDefaultMCPServer{
"firecrawl": {
successor: "exa",
shipped: MCPServerConfig{
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
},
},
}
// migrateRetiredDefaultMCPServers keeps an upgrade across a default-provider
// swap from changing what the user's config means. It handles the two ways a
// rename can break a config written against the old default:
//
// - A user who ran `zero mcp disable <old default>` made an explicit choice
// not to open that outbound connection. Without carrying that decision to
// the successor, the upgrade re-opens it under the new name — and because
// the replacement looks like an untouched default, even the startup
// warning stays quiet (see IsUnconfiguredDefault and issue #552).
// - A user who customized the old default could name only the field they
// were changing, because the seeded default supplied the transport. Once
// the default stops being seeded that entry resolves to a server with no
// type, url, or command, which fails NormalizeConfig and takes the whole
// startup down rather than just that one server.
//
// It runs immediately after the user layer merges (see ResolveMCP), which
// scopes it to user-level decisions — the only scope whose disable is sticky.
// The carried-over disable applies only when the user never declared the
// successor themselves: an explicit `exa` entry wins whether it enables or
// disables. Because the disable is recorded as a user-level decision, the
// lower-trust project layer cannot lift it, while `zero mcp enable exa` still
// can (the CLI override scope merges with canReenable=true).
func migrateRetiredDefaultMCPServers(cfg *MCPConfig) {
for name, retired := range retiredDefaultMCPServers {
entry, ok := cfg.Servers[name]
if !ok {
continue
}
if inheritsRetiredTransport(entry) {
// Re-seed what Zero used to ship underneath the user's fields, so a
// partial entry still resolves to the complete server it named when
// it was written. Their own values still win — this only fills the
// gap the retired default used to cover.
entry = mergeMCPServer(retired.shipped, entry, true)
cfg.Servers[name] = entry
}
if !entry.disabledSet || !entry.Disabled {
continue
}
replacement, ok := cfg.Servers[retired.successor]
if !ok || replacement.configured {
continue
}
replacement.Disabled = true
replacement.disabledSet = true
cfg.Servers[retired.successor] = replacement
}
}
// inheritsRetiredTransport reports whether entry named no transport of its own
// — no type, url, command, or args — and so relied entirely on the retired
// default to supply one.
//
// Anything else is a complete definition the user owns (a self-hosted url, a
// stdio command) and must be left alone: grafting the retired default's http
// type onto an entry that names a command produces a server that is rejected
// outright, turning a working config into a startup failure.
func inheritsRetiredTransport(entry MCPServerConfig) bool {
return strings.TrimSpace(entry.Type) == "" &&
strings.TrimSpace(entry.URL) == "" &&
strings.TrimSpace(entry.Command) == "" &&
len(entry.Args) == 0
}
+197 -36
View File
@@ -7,10 +7,10 @@ import (
)
func TestIsDefaultMCPServer(t *testing.T) {
if !IsDefaultMCPServer("firecrawl") {
t.Fatal("firecrawl should be a built-in default")
if !IsDefaultMCPServer("exa") {
t.Fatal("exa should be a built-in default")
}
if IsDefaultMCPServer(" firecrawl ") == false {
if IsDefaultMCPServer(" exa ") == false {
t.Fatal("IsDefaultMCPServer should trim whitespace")
}
if IsDefaultMCPServer("not-a-default") {
@@ -18,45 +18,45 @@ func TestIsDefaultMCPServer(t *testing.T) {
}
}
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
func TestResolveMCPSeedsEnabledExaDefault(t *testing.T) {
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl, ok := cfg.Servers["firecrawl"]
exa, ok := cfg.Servers["exa"]
if !ok {
t.Fatal("expected the firecrawl default to be seeded with no user config")
t.Fatal("expected the exa default to be seeded with no user config")
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
if exa.Type != "http" || exa.URL != "https://mcp.exa.ai/mcp" {
t.Fatalf("unexpected exa default: %#v", exa)
}
if firecrawl.Disabled {
t.Fatal("the firecrawl default must be enabled out of the box")
if exa.Disabled {
t.Fatal("the exa default must be enabled out of the box")
}
}
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
if !cfg.Servers["exa"].Disabled {
t.Fatal("a user must be able to disable the default by writing over it")
}
}
func TestIsUnconfiguredDefault(t *testing.T) {
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
if !IsUnconfiguredDefault("exa", DefaultMCPServers()["exa"]) {
t.Fatal("an untouched exa default should be reported as unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://example.com/mcp"}) {
t.Fatal("a server overriding the default URL is no longer unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://mcp.exa.ai/mcp", Auth: "bearer"}) {
t.Fatal("a server with credentials added is no longer unconfigured")
}
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
@@ -65,57 +65,173 @@ func TestIsUnconfiguredDefault(t *testing.T) {
}
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
// `zero mcp enable exa` after a prior disable writes {"disabled":false}
// explicitly. The resolved value is identical to the untouched default (both
// enabled, no credentials), but the user DID take an explicit action here, so
// IsUnconfiguredDefault must not treat it as untouched (issue #563 review).
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", firecrawl)
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", exa)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("an explicit enable/disable toggle must count as user-configured, even though the resolved value matches the default")
}
}
func TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault(t *testing.T) {
// A user who copies firecrawl's exact default type/url into their config
// A user who copies Exa's exact default type/url into their config
// (e.g. from an example file, planning to add credentials later) produces a
// resolved value byte-identical to DefaultMCPServers()["firecrawl"] — the
// resolved value byte-identical to DefaultMCPServers()["exa"] — the
// same trap TestResolveMCPExplicitReenableIsNotUnconfiguredDefault covers for
// the disabled toggle. IsUnconfiguredDefault must still treat this as
// user-configured because the user's JSON declared an entry for it, even
// though a plain resolved-value comparison could not tell the difference.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"type":"http","url":"https://mcp.exa.ai/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
want := DefaultMCPServers()["firecrawl"]
if firecrawl.Type != want.Type || firecrawl.URL != want.URL || firecrawl.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", firecrawl)
exa := cfg.Servers["exa"]
want := DefaultMCPServers()["exa"]
if exa.Type != want.Type || exa.URL != want.URL || exa.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", exa)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("redeclaring the default's exact values is still an explicit user configuration, not an untouched default")
}
}
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// Point firecrawl at a self-hosted instance; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
// Point Exa at a proxy; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"url":"https://example.com/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.URL != "https://example.com/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", exa)
}
if exa.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", exa)
}
}
func TestResolveMCPCarriesLegacyDefaultDisableToSuccessor(t *testing.T) {
// Upgrade path: the user disabled the firecrawl default Zero used to ship.
// Swapping the default to exa must not re-open an outbound connection they
// explicitly switched off.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatalf("a prior disable of the retired default must carry to its replacement: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPExplicitSuccessorBeatsLegacyDefaultDisable(t *testing.T) {
// The user disabled the old default but has since explicitly configured exa.
// Their newer, explicit choice wins — the migration must not override it.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true},"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
exa := cfg.Servers["exa"]
if exa.Disabled {
t.Fatalf("an explicit exa entry must survive the legacy-disable migration: %#v", exa)
}
if IsUnconfiguredDefault("exa", exa) {
t.Fatal("an explicitly configured exa is not an untouched default")
}
}
func TestResolveMCPLegacyDefaultLeftEnabledDoesNotDisableSuccessor(t *testing.T) {
// A user who configured firecrawl without disabling it (e.g. added a header)
// never opted out of the default search server, so exa stays enabled.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatal("only an explicit disable of the retired default should carry over")
}
}
func TestResolveMCPLegacyDefaultDisableIsLiftableByOverride(t *testing.T) {
// The carried-over disable is a user-level decision, not a permanent one:
// `zero mcp enable exa` merges through the CLI override scope, which is the
// one layer allowed to re-enable.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
enabled := MCPServerConfig{Disabled: false, disabledSet: true}
cfg, err := ResolveMCP(ResolveOptions{
UserConfigPath: path,
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"exa": enabled}}},
})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if cfg.Servers["exa"].Disabled {
t.Fatalf("an explicit enable must lift the carried-over disable: %#v", cfg.Servers["exa"])
}
}
func TestResolveMCPLegacyDisableSurvivesProjectReenable(t *testing.T) {
// The project layer is lower-trust and must not lift the carried-over
// user-level disable — the same guard a direct `zero mcp disable exa` gets.
dir := t.TempDir()
userPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(userPath, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "project.json")
if err := os.WriteFile(projectPath, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("project config must not re-enable a server the user disabled")
}
}
func TestResolveMCPKeepsRetiredDefaultTransportForPartialEntry(t *testing.T) {
// A user who added an API key to the firecrawl default wrote only the header
// — the seeded default supplied the http type and URL. Retiring the default
// must not strip those out from under them and leave an unusable server.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
@@ -123,10 +239,55 @@ func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.URL != "http://localhost:3002/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a partial entry must keep the retired default's transport: %#v", firecrawl)
}
if firecrawl.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("the user's own field must still win: %#v", firecrawl)
}
}
func TestResolveMCPDoesNotGraftRetiredDefaultOntoOwnTransport(t *testing.T) {
// A self-hosted firecrawl over stdio is a complete definition the user owns.
// Re-seeding the retired http default under it would produce an http server
// carrying a command, which NormalizeConfig rejects outright.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Type != "" || firecrawl.URL != "" {
t.Fatalf("an entry naming its own transport must be left alone: %#v", firecrawl)
}
if firecrawl.Command != "firecrawl-mcp" {
t.Fatalf("the user's command must survive: %#v", firecrawl)
}
}
func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
// The disable carries to exa, and the retired entry itself keeps a usable
// transport so a later `zero mcp enable firecrawl` does not resolve to a
// server with no type, url, or command.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if !firecrawl.Disabled {
t.Fatalf("the retired entry must stay disabled: %#v", firecrawl)
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("a disabled retired entry should still carry a usable transport: %#v", firecrawl)
}
if !cfg.Servers["exa"].Disabled {
t.Fatal("the disable must still carry to the successor")
}
}
@@ -34,7 +34,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must always be present")
}
if _, ok := cfg.Servers["firecrawl"]; !ok {
if _, ok := cfg.Servers["exa"]; !ok {
t.Fatal("the built-in default must always be present")
}
})
@@ -54,7 +54,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
if _, ok := cfg.Servers["user-srv"]; !ok {
t.Fatal("the user server must survive when the project layer is dropped")
}
if _, ok := cfg.Servers["firecrawl"]; !ok {
if _, ok := cfg.Servers["exa"]; !ok {
t.Fatal("the built-in default must survive when the project layer is dropped")
}
})
+5 -1
View File
@@ -180,7 +180,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
}
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
// Seed Zero's built-in default MCP servers (e.g. keyless Exa for free,
// no-setup web search/scrape) BEFORE merging user/project config, so the user
// can override any field or disable a default by writing over it.
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
@@ -194,6 +194,10 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// server the user disabled (a user-level disable is sticky, but the user
// scope itself may lift it).
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
// Reconcile config written against a default Zero has since retired,
// here while the user layer is the only one merged — so a carried-over
// disable counts as a user-level decision the project layer cannot lift.
migrateRetiredDefaultMCPServers(&cfg.MCP)
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
+3 -2
View File
@@ -151,7 +151,8 @@ type KeyBindingsConfig struct {
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
// TogglePlan is retained for configuration compatibility. Plan updates render
// in the transcript and no longer have a persistent panel to toggle.
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
@@ -464,7 +465,7 @@ type MCPServerConfig struct {
// fields it set or what values they hold. A built-in default seeded by
// DefaultMCPServers() is never unmarshaled from JSON, so it starts false;
// any explicit entry in the user/project file — even one that happens to
// repeat a default's exact field values (e.g. re-declaring firecrawl's
// repeat a default's exact field values (e.g. re-declaring Exa's
// default URL) — sets it true. IsUnconfiguredDefault checks this alongside
// a resolved-value comparison, so redeclaring default values verbatim still
// counts as user-configured.
+10 -1
View File
@@ -9,7 +9,16 @@ import (
"strings"
)
const PolicyVersion = 1
// PolicyVersion is the version of the approval policy that recorded grants were
// evaluated under. Bump it whenever a change narrows what may be approved: the
// grant store then backs the file up, keeps deny grants, invalidates prior
// approvals, and tells the user once, instead of silently honoring approvals
// that the current rules would have refused.
//
// v2 tightened command-prefix validation so a launcher spelled with a version
// or an executable extension ("python3.11", "python.exe") can no longer be
// approved as an ordinary command.
const PolicyVersion = 2
type Origin string
+1 -1
View File
@@ -35,7 +35,7 @@ type Server struct {
// credential lookup uses it to avoid reusing legacy user tokens by name.
ProjectConfigured bool
// UnconfiguredDefault is true when this server is one of Zero's built-in
// defaults (e.g. keyless Firecrawl) that the user never touched in their
// defaults (e.g. keyless Exa) that the user never touched in their
// config — no credentials, no overrides. Callers use it to avoid warning
// loudly when a server nobody configured fails to connect.
UnconfiguredDefault bool
+118 -3
View File
@@ -1,6 +1,8 @@
package mcp
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -90,7 +92,7 @@ func TestNormalizeConfigValidatesTransportBoundaries(t *testing.T) {
func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"exa": config.DefaultMCPServers()["exa"],
"web": {
Type: "http",
URL: "https://example.com/mcp",
@@ -107,8 +109,8 @@ func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
byName[server.Name] = server
}
if !byName["firecrawl"].UnconfiguredDefault {
t.Fatal("an untouched firecrawl default should be flagged UnconfiguredDefault")
if !byName["exa"].UnconfiguredDefault {
t.Fatal("an untouched exa default should be flagged UnconfiguredDefault")
}
if byName["web"].UnconfiguredDefault {
t.Fatal("a server the user configured must not be flagged UnconfiguredDefault")
@@ -240,3 +242,116 @@ func TestCopyStringMapTrimsKeysAndPreservesValues(t *testing.T) {
t.Fatalf("copied[TOKEN] = %q, want value preserved verbatim", copied["TOKEN"])
}
}
// Startup behavior for the firecrawl -> exa default migration: a user who
// disabled the retired default must end up with no Exa server to connect to,
// not merely a disabled entry in the resolved config.
func TestNormalizeConfigSkipsExaAfterLegacyFirecrawlDisable(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
t.Fatalf("exa must not be started after the user disabled the default it replaced: %#v", server)
}
}
}
// The same path with no legacy disable still starts Exa, so the migration
// cannot quietly switch off the default for everyone else.
func TestNormalizeConfigStartsExaWithoutLegacyDisable(t *testing.T) {
cfg, err := config.ResolveMCP(config.ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("NormalizeConfig: %v", err)
}
for _, server := range servers {
if server.Name == "exa" {
if !server.UnconfiguredDefault {
t.Fatalf("an untouched exa default should be flagged unconfigured: %#v", server)
}
return
}
}
t.Fatal("expected the exa default to be started out of the box")
}
// The failure path CodeRabbit flagged: a firecrawl entry that named only a
// header relied on the retired default for its transport. Resolving it must
// still produce a complete server — an incomplete one fails NormalizeConfig,
// which takes down every other server's startup with it, not just this one.
func TestNormalizeConfigResolvesPartialRetiredDefaultEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a header-only legacy entry must not break startup: %v", err)
}
var firecrawl *Server
var sawExa bool
for i := range servers {
switch servers[i].Name {
case "firecrawl":
firecrawl = &servers[i]
case "exa":
sawExa = true
}
}
if firecrawl == nil {
t.Fatalf("expected the customized firecrawl server to survive: %#v", servers)
}
if firecrawl.Type != ServerTypeHTTP || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("firecrawl lost the transport its entry relied on: %#v", *firecrawl)
}
if firecrawl.Headers["Authorization"] != "Bearer k" {
t.Fatalf("firecrawl lost the user's header: %#v", *firecrawl)
}
if !sawExa {
t.Fatal("the new exa default should still start alongside it")
}
}
// The mirror case: an entry that names its own stdio transport must not have
// the retired http default grafted onto it, which would be rejected outright.
func TestNormalizeConfigLeavesRetiredEntryWithOwnTransportAlone(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
servers, err := NormalizeConfig(cfg)
if err != nil {
t.Fatalf("a self-hosted stdio entry must keep working: %v", err)
}
for _, server := range servers {
if server.Name != "firecrawl" {
continue
}
if server.Type != ServerTypeStdio || server.Command != "firecrawl-mcp" {
t.Fatalf("the user's own stdio transport was corrupted: %#v", server)
}
return
}
t.Fatalf("expected the self-hosted firecrawl server to survive: %#v", servers)
}
+9 -9
View File
@@ -153,16 +153,16 @@ func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
}
func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
// firecrawl is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure (e.g. the real HTTP 401 from
// issue #552) must be recorded as UnconfiguredDefault so callers can avoid
// Exa is seeded by config.DefaultMCPServers with no credentials; when a
// user never configures it, a connect failure must be recorded as
// UnconfiguredDefault so callers can avoid
// warning about a server the user never asked for. "custom" is configured by
// the user and must NOT be flagged even though it also fails to connect.
registry := tools.NewRegistry()
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
"custom": {Type: "stdio", Command: "custom-mcp"},
"exa": config.DefaultMCPServers()["exa"],
"custom": {Type: "stdio", Command: "custom-mcp"},
}}, RegisterOptions{
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
return nil, errors.New(server.Name + " connect failed")
@@ -177,12 +177,12 @@ func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
for _, skipped := range runtime.Skipped() {
byName[skipped.Name] = skipped
}
firecrawl, ok := byName["firecrawl"]
exa, ok := byName["exa"]
if !ok {
t.Fatalf("Skipped() = %#v, want an entry for firecrawl", runtime.Skipped())
t.Fatalf("Skipped() = %#v, want an entry for exa", runtime.Skipped())
}
if !firecrawl.UnconfiguredDefault {
t.Fatalf("Skipped()[firecrawl] = %#v, want UnconfiguredDefault", firecrawl)
if !exa.UnconfiguredDefault {
t.Fatalf("Skipped()[exa] = %#v, want UnconfiguredDefault", exa)
}
custom, ok := byName["custom"]
if !ok {
+6
View File
@@ -231,6 +231,12 @@ func reasoningEffortsForModelName(name string) []ReasoningEffort {
// the gateway's translation concern — unknown fields are ignored, so the
// worst case is a silent no-op rather than a 400.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
case strings.Contains(n, "deepseek"):
// DeepSeek V4 (deepseek-v4-flash / deepseek-v4-pro) exposes thinking-mode
// effort control through the OpenAI-compatible reasoning_effort parameter.
// The API accepts low/high/max and maps medium/xhigh onto high, so only
// the three distinct tiers are advertised.
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}
default:
return nil
}
@@ -20,6 +20,10 @@ func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
{"hy3", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"hunyuan-t1", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"openai/o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}}, // github-style vendor/ id
{"deepseek-v4-flash", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-v4-pro", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-reasoner", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"deepseek-chat", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
{"gpt-4o-mini", nil},
{"ollama/llama3.1", nil},
+1 -1
View File
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
}
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("Windows access token has no default owner")
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
+4
View File
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
switch strings.TrimSpace(provider.ID) {
case "chatgpt":
// ChatGPT authenticates against the Codex backend, while its public
// model metadata is published under the OpenAI provider on models.dev.
return "openai"
case "dashscope":
return "alibaba"
case "github":
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
tests := map[string]string{
"chatgpt": "openai",
"github": "github-models",
"moonshot": "moonshotai",
"nvidia-nim": "nvidia",
+2 -1
View File
@@ -539,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
hasCatalog := len(byID) > 0
// Aggregators publish the live list as the source of truth. Keep live-only
// ids even when a remote catalog also loaded, instead of intersecting.
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
preferLive := provider.Local ||
providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
@@ -139,6 +139,68 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
}
}
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{
"openai": {"models": {
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"description": "Frontier coding model",
"tool_call": true,
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"limit": {"context": 1050000},
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
}
}}
}`))
case "/backend-api/codex/models":
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
return
}
}))
defer server.Close()
provider, ok := providercatalog.Get("chatgpt")
if !ok {
t.Fatal("ChatGPT provider missing from catalog")
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
ModelsDevURL: server.URL + "/api.json",
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer test", true, nil
},
})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if len(models) != 1 {
t.Fatalf("models = %#v, want one ChatGPT live model", models)
}
model := models[0]
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
}
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
t.Fatalf("input modalities = %q, want text,image,pdf", got)
}
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
@@ -164,6 +226,29 @@ func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
}
}
func TestMergeLocalModelsKeepsLiveOnlyEntries(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "lmstudio", Local: true},
[]Model{
{ID: "qwen3-coder-30b", ContextWindow: 32768},
{ID: "text-embedding-local"},
{ID: "qwen2.5-coder-32b-instruct", Description: "live description"},
},
[]Model{{ID: "qwen2.5-coder-32b-instruct", Description: "catalog description", ToolCall: true}},
)
if got, want := strings.Join(modelIDs(models), ","), "qwen3-coder-30b,qwen2.5-coder-32b-instruct"; got != want {
t.Fatalf("models = %q, want %q", got, want)
}
for _, model := range models {
if model.ID == "qwen2.5-coder-32b-instruct" {
if model.Description != "catalog description" || !model.ToolCall {
t.Fatalf("merged catalog metadata = %#v", model)
}
}
}
}
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
// Catalog and live endpoints return distinct payloads so the merge must keep
// live-only ids that are absent from the remote catalog response.
+16 -1
View File
@@ -486,7 +486,7 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
Function: toolFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Parameters: normalizeToolParameters(tool.Parameters),
},
})
}
@@ -494,6 +494,21 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
return mapped
}
// normalizeToolParameters keeps schemas accepted by strict OpenAI-compatible
// servers such as LM Studio. No-argument tools still need an object-valued
// parameters.properties field instead of an omitted field.
func normalizeToolParameters(parameters map[string]any) map[string]any {
normalized := make(map[string]any, len(parameters)+1)
for key, value := range parameters {
normalized[key] = value
}
properties, ok := normalized["properties"].(map[string]any)
if !ok || properties == nil {
normalized["properties"] = map[string]any{}
}
return normalized
}
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
// kill switch is set to a truthy value. "0" and "false" (any case) are
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
+130
View File
@@ -3,6 +3,7 @@ package openai
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
@@ -105,6 +106,72 @@ func TestStreamCompletionPostsChatCompletionRequest(t *testing.T) {
if tool["type"] != "function" {
t.Fatalf("unexpected tool wrapper: %#v", tool)
}
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
// TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject locks in the
// normalizeToolParameters fix: a tool whose parameters carry a typed-nil
// properties map must serialize "properties":{} on the wire, never
// "properties":null, because strict OpenAI-compatible servers (LM Studio)
// reject the null form.
func TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": map[string]any(nil)},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
func TestNewRequiresModelButNotAPIKey(t *testing.T) {
@@ -1479,3 +1546,66 @@ func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
}
}
// TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject locks in the
// normalizeToolParameters type-assert branch: when a tool's parameters carry a
// `properties` field whose value is a non-map (e.g., an empty slice), it must
// be normalized to "properties":{} on the wire, never "properties":[] or
// "properties":null.
func TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject(t *testing.T) {
var gotBody map[string]any
var gotRaw []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotRaw = raw
if err := json.Unmarshal(raw, &gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "no_args",
Description: "Takes no arguments",
Parameters: map[string]any{"type": "object", "properties": []any{}},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if !strings.Contains(string(gotRaw), `"properties":{}`) {
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":null`) {
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
}
if strings.Contains(string(gotRaw), `"properties":[]`) {
t.Fatalf("request body must not serialize properties as an empty array, got: %s", gotRaw)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
functionDefinition := tool["function"].(map[string]any)
parameters := functionDefinition["parameters"].(map[string]any)
properties, ok := parameters["properties"].(map[string]any)
if !ok || len(properties) != 0 {
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
}
}
+105 -10
View File
@@ -148,8 +148,10 @@ func unsafeCommandPrefix(prefix []string) bool {
return true
}
}
normalized := append([]string(nil), prefix...)
normalized[0] = normalizeLauncherName(normalized[0])
for _, banned := range bannedCommandPrefixSuggestions {
if sameStringSlice(prefix, banned) {
if sameStringSlice(prefix, banned) || sameStringSlice(normalized, banned) {
return true
}
}
@@ -165,25 +167,118 @@ func unsafeCommandPrefixPart(part string) bool {
}
func unsafeCommandPrefixLauncher(program string) bool {
program = strings.ToLower(strings.TrimSpace(program))
if strings.ContainsAny(program, `/\`) {
// A path is already refused. ":" and "~" are refused for the same reason:
// on Windows they introduce names that resolve to a different file than the
// one matched here, and no list can keep up with them. "PYTHON~1.EXE" is
// python.exe under an 8.3 short name, and 8.3 truncates the stem, so
// "POWERS~1.EXE" cannot be recognized by name at all; "python.exe::$DATA"
// names the same executable through its default stream. Refusing the shape
// costs a permission prompt on names no real command uses.
if strings.ContainsAny(program, `/\:~`) {
return true
}
switch program {
case "bash", "sh", "zsh", "/bin/bash", "/bin/zsh",
"pwsh", "powershell", "powershell.exe",
"env", "sudo", "doas", "su", "run0", "osascript",
name := normalizeLauncherName(program)
return bannedLauncherName(name) || bannedLauncherName(strings.TrimRight(name, "0123456789"))
}
func bannedLauncherName(name string) bool {
switch name {
case "bash", "sh", "zsh", "dash", "ash", "ksh", "csh", "tcsh", "fish", "busybox",
"pwsh", "powershell", "cmd", "wsl",
"env", "sudo", "sudoedit", "doas", "su", "run0", "osascript",
"command", "eval", "exec", "time",
"find", "xargs", "timeout", "nice", "nohup", "watch", "setsid", "stdbuf", "ionice",
"ssh", "make", "npm", "npx",
"python", "python3", "py", "pythonw", "pyw", "pypy", "pypy3",
"node", "perl", "ruby", "php", "lua", "deno", "bun":
"ssh", "make", "npm", "npx", "pnpm", "yarn", "bunx",
"python", "py", "pythonw", "pyw", "pypy", "uv", "uvx",
"node", "nodejs", "perl", "ruby", "php", "lua", "deno", "bun":
return true
default:
return false
}
}
// normalizeLauncherName reduces a program name to the launcher it actually runs
// so the list above cannot be stepped around by spelling the same interpreter
// differently: "python3.11", "python.exe" and "PYTHON3" all reach it as a name
// the list matches. internal/agent's commandName already normalizes case and
// Windows executable extensions on the allow side; this side matched raw, so
// every versioned or .exe-suffixed launcher validated as an ordinary command
// and persisted as a prefix grant that auto-allows every later invocation.
//
// A name is only ever narrowed toward an existing entry, so the failure
// direction is an extra permission prompt, never a silent grant.
func normalizeLauncherName(program string) string {
name := strings.ToLower(strings.TrimSpace(program))
// Windows discards trailing dots and spaces when resolving a filename, so
// "python." starts python.exe. Drop them before anything matches on the
// result, on every platform: the deny side must not depend on which OS is
// reading the grant, since the grants file travels with a synced home
// directory.
name = strings.TrimRight(name, ". ")
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com", ".ps1"} {
if strings.HasSuffix(name, suffix) {
name = strings.TrimSuffix(name, suffix)
name = strings.TrimRight(name, ". ")
break
}
}
return trimLauncherVersion(trimLauncherABISuffix(trimLauncherBuildSuffix(name)))
}
// trimLauncherBuildSuffix drops the build-channel suffixes distributions and
// upstreams append to an otherwise unchanged interpreter, so "python3.11-dbg",
// "bash-static" and "pwsh-preview" are the launchers they say they are. The set
// is closed on purpose: a general "-word" strip would also swallow
// "node-gyp", "python3-config" and "ruby-lsp", which are ordinary tools a user
// should still be able to approve.
func trimLauncherBuildSuffix(name string) string {
for _, suffix := range []string{"-dbg", "-debug", "-static", "-nightly", "-preview", "-beta"} {
if strings.HasSuffix(name, suffix) {
return strings.TrimSuffix(name, suffix)
}
}
return name
}
// trimLauncherABISuffix drops CPython's ABI flags from a versioned interpreter
// name, so "python3.7m" and "python3.6dm" reduce to their version, and the
// free-threaded builds "python3.13t" and "python3.13td" reduce to theirs. The
// flags are only removed when a digit sits underneath them, which leaves
// ordinary names that happen to end in those letters alone: "sha256sum" keeps
// its "sum", "zstd" its "td", "cat" its "t".
func trimLauncherABISuffix(name string) string {
trimmed := strings.TrimRight(name, "dmut")
if trimmed == name || trimmed == "" {
return name
}
if last := trimmed[len(trimmed)-1]; last < '0' || last > '9' {
return name
}
return trimmed
}
// trimLauncherVersion drops a trailing "<separator><digits>" version from a
// program name, so "python3.11" becomes "python3" and "python2.7" becomes
// "python2". Digits without a separator are part of the name, which keeps
// "base64", "7z" and "sha256sum" intact; the caller handles the "python3" shape
// by also testing the digit-stripped form.
func trimLauncherVersion(name string) string {
for {
trimmed := strings.TrimRight(name, "0123456789")
if trimmed == name || trimmed == "" {
return name
}
if !strings.HasSuffix(trimmed, ".") && !strings.HasSuffix(trimmed, "-") {
return name
}
next := trimmed[:len(trimmed)-1]
if next == "" {
return name
}
name = next
}
}
func sameStringSlice(left []string, right []string) bool {
if len(left) != len(right) {
return false
+115
View File
@@ -0,0 +1,115 @@
package sandbox
import "testing"
func TestUnsafeCommandPrefixLauncherIgnoresVersionAndExtension(t *testing.T) {
for _, program := range []string{
"python3", "python", "node", "git", "sh", "bash", "sudo",
"python3.11", "python3.12", "python2.7", "python3.13.1",
"python.exe", "python3.exe", "node.exe", "git.exe", "bash.exe",
"PYTHON3.11", "Node.EXE",
"cmd", "cmd.exe", "wsl", "wsl.exe",
// Windows resolves these to the same executable.
"python.", "python3.", "python3.11.", "python.exe.", "PYTHON.",
"python.cmd", "python.bat", "python.com", "python.ps1",
"PYTHON~1.EXE", "POWERS~1.EXE", "python.exe::$DATA",
// Versioned and ABI-suffixed interpreters.
"python3.7m", "python3.6dm", "pythonw3.11", "python-3.11",
// Free-threaded CPython (PEP 703) ships beside the GIL build.
"python3.13t", "python3.13t.exe", "python3.14t", "python3.13td",
// Distribution and build-channel spellings of the same interpreter.
"nodejs", "nodejs.exe", "node-nightly", "python3-dbg", "python3.11-dbg",
"python3.13t-dbg", "python3-debug", "python3.11-debug", "node-beta",
"pwsh-preview", "bash-static", "sudoedit",
// Twins of npm and npx, which the list already refuses.
"pnpm", "yarn", "bunx", "pnpm.cmd", "yarn.exe",
"perl5.36", "ruby3.2", "php8", "lua5.4", "node22.exe", "bash5",
} {
if !unsafeCommandPrefix([]string{program}) {
t.Errorf("unsafeCommandPrefix([%q]) = false, want true", program)
}
}
}
func TestUnsafeCommandPrefixKeepsOrdinaryCommandsGrantable(t *testing.T) {
for _, prefix := range [][]string{
{"cargo", "build"},
{"go", "test"},
{"ls", "-la"},
{"rg", "--json"},
{"7z", "l"},
{"base64", "-d"},
{"kubectl", "get", "pods"},
{"docker", "ps"},
{"sha256sum", "file"},
{"gcc-13", "-c"},
{"node-gyp", "build"},
{"python3-config", "--includes"},
{"s3cmd", "ls"},
{"cat", "file"},
{"zstd", "-d"},
{"sqlite3", "db"},
{"yt-dlp", "url"},
{"perl-doc", "-h"},
{"php-fpm", "-t"},
{"ruby-lsp", "--version"},
{"lua-language-server", "--check"},
{"nodemon", "app.js"},
} {
if unsafeCommandPrefix(prefix) {
t.Errorf("unsafeCommandPrefix(%q) = true, want false", prefix)
}
}
}
func TestNormalizeLauncherName(t *testing.T) {
for _, testCase := range []struct{ in, want string }{
{"python3.11", "python3"},
{"python2.7", "python2"},
{"pypy3", "pypy3"},
{"node.exe", "node"},
{"POWERSHELL.EXE", "powershell"},
{"7z", "7z"},
{"base64", "base64"},
{"sha256sum", "sha256sum"},
{"python.", "python"},
{"python3.11.exe.", "python3"},
{"python3.7m", "python3"},
{"python3.6dm", "python3"},
{"python3.13t", "python3"},
{"python3.13td", "python3"},
{"python3.11-dbg", "python3"},
{"node-nightly", "node"},
{"node-gyp", "node-gyp"},
{"python3-config", "python3-config"},
{"zstd", "zstd"},
{"cat", "cat"},
{"cargo", "cargo"},
{"", ""},
} {
if got := normalizeLauncherName(testCase.in); got != testCase.want {
t.Errorf("normalizeLauncherName(%q) = %q, want %q", testCase.in, got, testCase.want)
}
}
}
// Session-scoped grants are the agent's other entry point (loop.go grants them
// for a single turn without touching the store), so they must refuse the same
// launcher spellings the persisted path does.
func TestGrantCommandPrefixForSessionRefusesLauncherVariants(t *testing.T) {
engine := &Engine{commandPrefixes: newCommandPrefixGrantSet()}
engine.GrantCommandPrefixForSession("bash", []string{"cargo", "build"})
if _, ok := engine.LookupCommandPrefixForSession("bash", []string{"cargo", "build", "--release"}); !ok {
t.Fatal("CONTROL BROKEN: an ordinary session prefix grant did not match")
}
for _, prefix := range [][]string{
{"python3.11", "-c"},
{"python.exe", "-c"},
{"cmd", "/c"},
} {
engine.GrantCommandPrefixForSession("bash", prefix)
command := append(append([]string(nil), prefix...), "whatever")
if _, ok := engine.LookupCommandPrefixForSession("bash", command); ok {
t.Errorf("session grant %q matched %q, want refused", prefix, command)
}
}
}
+22
View File
@@ -242,6 +242,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
} {
t.Run(tc.name, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), Request{
@@ -315,6 +316,27 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
}
}
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
for _, patch := range []string{
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "apply_patch",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"patch": patch},
})
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
}
}
}
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
+66 -1
View File
@@ -2,12 +2,15 @@ package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
@@ -343,7 +346,8 @@ func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
if err != nil {
t.Fatalf("read rewritten grant file: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
policyLine := fmt.Sprintf(`"policyVersion": %d`, execution.PolicyVersion)
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), policyLine) || !strings.Contains(string(raw), `"write_file": [`) {
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
}
backup, err := os.ReadFile(path + ".v1.backup")
@@ -406,6 +410,62 @@ func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing
}
}
// A prefix grant recorded before launcher-name normalization existed (a
// versioned or .exe-suffixed interpreter) no longer validates. It must reach
// the user through the policy migration — backup, denies preserved, one notice
// — and never make the whole grants file unreadable, which would silently drop
// their persisted deny grants too.
func TestGrantStoreMigratesPreNormalizationPrefixInsteadOfRejectingTheFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":3,"policyVersion":1,"grants":{"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["cargo","build"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
t.Fatalf("grants after migration = %#v err=%v, want the deny preserved", grants, err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil || len(prefixes) != 0 {
t.Fatalf("prefixes after migration = %#v err=%v, want all re-approved", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
t.Fatalf("notice = %q err=%v", notice, err)
}
if backup, err := os.ReadFile(path + ".policy-v1.backup"); err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v", backup, err)
}
}
// The legacy-schema path is a separate migration from the policy bump above and
// prunes per grant rather than wholesale, so a pre-normalization prefix must be
// dropped there while the valid prefix beside it survives.
func TestGrantStoreLegacyMigrationDropsOnlyThePreNormalizationPrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil {
t.Fatalf("ListCommandPrefixes after legacy migration returned error: %v", err)
}
if len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("prefixes = %#v, want only [git status]", prefixes)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
t.Fatalf("notice = %q err=%v", notice, err)
}
}
func TestGrantStorePersistsCommandPrefixes(t *testing.T) {
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
@@ -481,6 +541,11 @@ func TestGrantStoreRejectsUnsafeCommandPrefixes(t *testing.T) {
{"python", "script.py"},
{"./script.sh"},
{"git"},
{"python3.11", "-c"},
{"python.exe", "-c"},
{"node.exe", "-e"},
{"git.exe"},
{"cmd", "/c"},
} {
if _, err := store.GrantCommandPrefix(CommandPrefixInput{ToolName: "bash", Prefix: prefix}); err == nil {
t.Fatalf("GrantCommandPrefix(%#v) succeeded, want validation error", prefix)
+30 -2
View File
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
}
cwd := firstArgString(args, "cwd")
var paths []string
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -281,7 +281,7 @@ func applyPatchPathBlock(request Request) *pathBlock {
if patch == "" {
return nil
}
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -296,6 +296,34 @@ func applyPatchPathBlock(request Request) *pathBlock {
return nil
}
func applyPatchPaths(patch string) []string {
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
}
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
// the sandbox boundary. The executor validates the complete hunk grammar before
// writing; the sandbox only needs every possible target path so it can reject an
// unsafe request before the tool runs.
func structuredPatchHeaderPaths(patch string) []string {
var paths []string
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
if path != "" {
paths = append(paths, path)
}
break
}
}
}
return paths
}
func patchHeaderPaths(patch string) []string {
var paths []string
oldRemaining, newRemaining := 0, 0
+5 -2
View File
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "Unified diff patch to apply."},
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
@@ -56,6 +56,9 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if isStructuredPatch(patch) {
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
+19
View File
@@ -231,6 +231,25 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
}
}
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, key := range keys {
if !want[key] {
t.Fatalf("unexpected key %q in %v", key, keys)
}
delete(want, key)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
+106
View File
@@ -221,6 +221,112 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
}
}
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-beta",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if tracker.SeenWhole(trackedPath) {
t.Fatal("a partial structured patch credited the unread file as fully seen")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
}
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
t.Fatalf("file = %q, want %q", got, want)
}
}
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
t.Fatalf("read_file failed: %s", result.Output)
}
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-alpha",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(trackedPath) {
t.Fatal("structured patch discarded an existing whole-file observation")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusOK {
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
}
}
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: created.txt",
"+supplied",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(path) {
t.Fatal("a fully supplied structured add was not credited as fully seen")
}
}
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
+9
View File
@@ -0,0 +1,9 @@
package tools
// ModelVisible reports whether a registered tool is advertised to an agent.
// edit_file remains implemented for compatibility with existing integrations,
// but its text-only replacement contract cannot safely disambiguate repeated
// source fragments. Agents use apply_patch for contextual existing-file edits.
func ModelVisible(tool Tool) bool {
return tool != nil && tool.Name() != "edit_file"
}
+18
View File
@@ -0,0 +1,18 @@
package tools
import "testing"
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
registry := NewRegistry()
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
search := NewToolSearchTool(registry).(toolSearchTool)
eager := search.visibleEagerToolNames(nil, nil, "ask")
if eager["edit_file"] {
t.Fatal("tool_search must not describe edit_file as available to the model")
}
if !eager["apply_patch"] {
t.Fatal("tool_search must retain apply_patch as the available edit tool")
}
}
+34
View File
@@ -1,5 +1,7 @@
package tools
import "path/filepath"
// MutationTargets returns the workspace-relative paths a tool call will write to,
// so the session layer can snapshot their before-state for safe rewind. It is a
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
@@ -36,6 +38,19 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
if err != nil {
return nil
}
if isStructuredPatch(patch) {
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
paths := structuredPatchOperationPaths(operations)
for _, path := range paths {
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
return nil
}
}
return prefixPatchPaths(relativeRoot, paths)
}
// Enforce the same workspace confinement apply_patch applies (against the
// resolved apply dir), so a patch with a traversal path (../x) never yields
// an out-of-workspace target.
@@ -51,3 +66,22 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
return nil
}
}
func prefixPatchPaths(relativeRoot string, paths []string) []string {
if len(paths) == 0 {
return nil
}
seen := make(map[string]bool)
var out []string
for _, path := range paths {
workspacePath := path
if relativeRoot != "" && relativeRoot != "." {
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[workspacePath] {
seen[workspacePath] = true
out = append(out, workspacePath)
}
}
return out
}
+11
View File
@@ -17,6 +17,7 @@ func TestMutationTargets(t *testing.T) {
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
{"bash", map[string]any{"command": "echo hi"}, nil},
{"read_file", map[string]any{"path": "e.txt"}, nil},
{"grep", map[string]any{"pattern": "x"}, nil},
@@ -62,6 +63,10 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
}
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
}
}
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
@@ -82,6 +87,12 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
t.Fatalf("got %v, want %v", got, want)
}
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
if !reflect.DeepEqual(got, want) {
t.Fatalf("structured cwd: got %v, want %v", got, want)
}
// cwd "." (the default) must keep paths unprefixed.
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
if !reflect.DeepEqual(got, []string{"d.txt"}) {
+12 -1
View File
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromUnifiedDiff(patch) {
for _, p := range pathsFromApplyPatch(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
@@ -191,6 +191,17 @@ func applyPatchResourceKeys(args map[string]any) []string {
return keys
}
func pathsFromApplyPatch(patch string) []string {
if !isStructuredPatch(patch) {
return pathsFromUnifiedDiff(patch)
}
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
return structuredPatchOperationPaths(operations)
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
+763
View File
@@ -0,0 +1,763 @@
package tools
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/pathjail"
)
const (
structuredPatchBegin = "*** Begin Patch"
structuredPatchEnd = "*** End Patch"
structuredAddFile = "*** Add File: "
structuredDeleteFile = "*** Delete File: "
structuredUpdateFile = "*** Update File: "
structuredMoveTo = "*** Move to: "
structuredEndOfFile = "*** End of File"
structuredHunkContext = "@@ "
)
type structuredPatchKind uint8
const (
structuredPatchAdd structuredPatchKind = iota
structuredPatchDelete
structuredPatchUpdate
)
type structuredPatchOperation struct {
kind structuredPatchKind
path string
movePath string
contents string
chunks []structuredPatchChunk
line int
}
type structuredPatchChunk struct {
context string
hasContext bool
old []string
new []string
newSourceOffsets []int
endOfFile bool
}
type structuredPatchTarget struct {
requested string
absolute string
relative string
}
type structuredPatchChange struct {
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
}
func isStructuredPatch(patch string) bool {
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
}
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
operations, err := parseStructuredPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
workspace, err := os.OpenRoot(applyRoot)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
defer workspace.Close()
changes, err := planStructuredPatch(workspace, operations, options.FileTracker)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
wholeBefore := make(map[string]bool, len(changes))
if options.FileTracker != nil {
for _, change := range changes {
if change.kind == structuredPatchUpdate {
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
}
}
}
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
for _, change := range changes {
switch change.kind {
case structuredPatchDelete:
options.FileTracker.Forget(change.from.absolute)
case structuredPatchAdd:
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true)
case structuredPatchUpdate:
wasWhole := wholeBefore[change.from.absolute]
options.FileTracker.Forget(change.from.absolute)
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
}
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
return result
}
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
if normalized == "" {
return nil, fmt.Errorf("structured patch is empty")
}
lines := strings.Split(normalized, "\n")
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
}
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
}
var operations []structuredPatchOperation
for index := 1; index < len(lines)-1; {
header := strings.TrimSpace(lines[index])
lineNumber := index + 1
if header == "" {
index++
continue
}
switch {
case strings.HasPrefix(header, structuredAddFile):
path, err := structuredPatchPath(header, structuredAddFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchAdd, path: path, line: lineNumber}
index++
var content []string
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
if !strings.HasPrefix(lines[index], "+") {
return nil, structuredPatchLineError(index+1, "added-file contents must start with '+'")
}
content = append(content, strings.TrimPrefix(lines[index], "+"))
index++
}
if len(content) > 0 {
op.contents = strings.Join(content, "\n") + "\n"
}
operations = append(operations, op)
case strings.HasPrefix(header, structuredDeleteFile):
path, err := structuredPatchPath(header, structuredDeleteFile, lineNumber)
if err != nil {
return nil, err
}
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: path, line: lineNumber})
index++
case strings.HasPrefix(header, structuredUpdateFile):
path, err := structuredPatchPath(header, structuredUpdateFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchUpdate, path: path, line: lineNumber}
index++
if index < len(lines)-1 && strings.HasPrefix(strings.TrimSpace(lines[index]), structuredMoveTo) {
op.movePath, err = structuredPatchPath(strings.TrimSpace(lines[index]), structuredMoveTo, index+1)
if err != nil {
return nil, err
}
index++
}
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
line := lines[index]
trimmed := strings.TrimSpace(line)
if trimmed == structuredEndOfFile {
if len(op.chunks) == 0 || (!hasStructuredPatchContent(op.chunks[len(op.chunks)-1])) {
return nil, structuredPatchLineError(index+1, "*** End of File must follow an update hunk")
}
op.chunks[len(op.chunks)-1].endOfFile = true
index++
continue
}
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
chunk := structuredPatchChunk{}
if trimmed != "@@" {
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
chunk.hasContext = true
}
op.chunks = append(op.chunks, chunk)
index++
continue
}
if len(op.chunks) == 0 {
op.chunks = append(op.chunks, structuredPatchChunk{})
}
chunk := &op.chunks[len(op.chunks)-1]
switch {
case line == "":
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, "")
chunk.new = append(chunk.new, "")
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, " "):
content := strings.TrimPrefix(line, " ")
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, "+"):
chunk.new = append(chunk.new, strings.TrimPrefix(line, "+"))
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
case strings.HasPrefix(line, "-"):
chunk.old = append(chunk.old, strings.TrimPrefix(line, "-"))
default:
return nil, structuredPatchLineError(index+1, "update lines must start with ' ', '+', or '-'")
}
index++
}
if len(op.chunks) == 0 || !allStructuredPatchChunksHaveContent(op.chunks) {
return nil, structuredPatchLineError(lineNumber, "update file hunk is empty")
}
operations = append(operations, op)
default:
return nil, structuredPatchLineError(lineNumber, "expected an Add File, Delete File, or Update File header")
}
}
if len(operations) == 0 {
return nil, fmt.Errorf("structured patch contains no file operations")
}
return operations, nil
}
func structuredPatchPath(header, prefix string, line int) (string, error) {
path := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if path == "" {
return "", structuredPatchLineError(line, "file path is required")
}
return path, nil
}
func isStructuredPatchHeader(line string) bool {
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
}
func structuredPatchLineError(line int, message string) error {
return fmt.Errorf("invalid structured patch at line %d: %s", line, message)
}
func hasStructuredPatchContent(chunk structuredPatchChunk) bool {
return len(chunk.old) > 0 || len(chunk.new) > 0
}
func allStructuredPatchChunksHaveContent(chunks []structuredPatchChunk) bool {
for _, chunk := range chunks {
if !hasStructuredPatchContent(chunk) {
return false
}
}
return true
}
func structuredPatchOperationPaths(operations []structuredPatchOperation) []string {
seen := make(map[string]bool)
var paths []string
for _, operation := range operations {
for _, path := range []string{operation.path, operation.movePath} {
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, tracker *FileTracker) ([]structuredPatchChange, error) {
seen := make(map[string]struct{})
var changes []structuredPatchChange
for _, operation := range operations {
from, err := resolveStructuredPatchTarget(root.Name(), operation.path)
if err != nil {
return nil, err
}
to := from
if operation.movePath != "" {
to, err = resolveStructuredPatchTarget(root.Name(), operation.movePath)
if err != nil {
return nil, err
}
}
targets := []structuredPatchTarget{from}
if to.absolute != from.absolute {
targets = append(targets, to)
}
for _, target := range targets {
if _, exists := seen[target.absolute]; exists {
return nil, fmt.Errorf("structured patch changes %q more than once", target.relative)
}
seen[target.absolute] = struct{}{}
}
change := structuredPatchChange{kind: operation.kind, from: from, to: to, mode: 0o644}
switch operation.kind {
case structuredPatchAdd:
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot add %s because it already exists", to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate:
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
}
change.mode = info.Mode()
content, err := root.ReadFile(from.relative)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
}
if err := tracker.CheckConflict(from.absolute, content); err != nil {
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
change.before = string(content)
if operation.kind == structuredPatchUpdate {
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
if err != nil {
return nil, err
}
change.after = updated
if from.absolute != to.absolute {
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
}
}
}
changes = append(changes, change)
}
return changes, nil
}
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
}
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
if err != nil {
return structuredPatchTarget{}, err
}
return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil
}
func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchChunk) (string, error) {
lineEnding := structuredPatchLineEnding(content)
if lineEnding == "\r\n" {
content = strings.ReplaceAll(content, "\r\n", "\n")
}
lines := strings.Split(content, "\n")
trailingNewline := content != "" && len(lines) > 0 && lines[len(lines)-1] == ""
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
replacements := make([]structuredReplacement, 0, len(chunks))
lineIndex := 0
for _, chunk := range chunks {
if chunk.hasContext {
index, ambiguous := findStructuredPatchSequence(lines, []string{chunk.context}, lineIndex, false)
if ambiguous {
return "", fmt.Errorf("context %q is ambiguous in %s; provide a more specific hunk header", chunk.context, path)
}
if index < 0 {
return "", fmt.Errorf("could not find context %q in %s", chunk.context, path)
}
lineIndex = index + 1
}
if len(chunk.old) == 0 {
// A context anchor identifies the line immediately before a pure
// insertion. Only an explicit end-of-file hunk (or an unanchored
// insertion) belongs at the file end.
start := lineIndex
if chunk.endOfFile || !chunk.hasContext {
start = len(lines)
}
if start > len(lines) {
start = len(lines)
}
replacement, err := structuredPatchReplacement(chunk, lines, start)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: start, replacement: replacement})
lineIndex = start
continue
}
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
if ambiguous {
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
}
if index < 0 {
return "", fmt.Errorf("could not find expected lines in %s:\n%s", path, strings.Join(chunk.old, "\n"))
}
replacement, err := structuredPatchReplacement(chunk, lines, index)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: index, length: len(chunk.old), replacement: replacement})
lineIndex = index + len(chunk.old)
}
for index := 1; index < len(replacements); index++ {
previous := replacements[index-1]
current := replacements[index]
if current.start < previous.start+previous.length {
return "", fmt.Errorf("overlapping or out-of-order hunks in %s; provide non-overlapping context", path)
}
}
for index := len(replacements) - 1; index >= 0; index-- {
replacement := replacements[index]
lines = append(lines[:replacement.start], append(replacement.replacement, lines[replacement.start+replacement.length:]...)...)
}
if len(lines) == 0 {
return "", nil
}
updated := strings.Join(lines, lineEnding)
if trailingNewline {
updated += lineEnding
}
return updated, nil
}
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
if len(chunk.newSourceOffsets) != len(chunk.new) {
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
}
replacement := append([]string(nil), chunk.new...)
for outputIndex, sourceOffset := range chunk.newSourceOffsets {
if sourceOffset < 0 {
continue
}
sourceIndex := start + sourceOffset
if sourceIndex < 0 || sourceIndex >= len(source) {
return nil, fmt.Errorf("context source index %d is outside the matched file", sourceIndex)
}
replacement[outputIndex] = source[sourceIndex]
}
return replacement, nil
}
func structuredPatchLineEnding(content string) string {
if strings.Contains(content, "\r\n") && !strings.Contains(strings.ReplaceAll(content, "\r\n", ""), "\n") {
return "\r\n"
}
return "\n"
}
type structuredReplacement struct {
start int
length int
replacement []string
}
func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bool) (int, bool) {
if len(wanted) == 0 {
return start, false
}
if len(wanted) > len(lines) {
return -1, false
}
searchStart := start
if endOfFile {
searchStart = len(lines) - len(wanted)
if searchStart < start {
searchStart = start
}
}
for _, equal := range []func(string, string) bool{
func(left, right string) bool { return left == right },
func(left, right string) bool {
return strings.TrimRight(left, " \t") == strings.TrimRight(right, " \t")
},
func(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) },
} {
match := -1
for index := searchStart; index+len(wanted) <= len(lines); index++ {
matched := true
for offset, line := range wanted {
if !equal(lines[index+offset], line) {
matched = false
break
}
}
if matched {
if match >= 0 {
return -1, true
}
match = index
}
}
if match >= 0 {
return match, false
}
}
return -1, false
}
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
mutated := false
for _, change := range changes {
committed, err := applyStructuredPatchChange(root, change)
mutated = mutated || committed
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
if mutated {
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
}
return err
}
}
return nil
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
if tracker == nil {
return
}
for _, change := range changes {
tracker.Forget(change.from.absolute)
if change.to.absolute != change.from.absolute {
tracker.Forget(change.to.absolute)
}
}
}
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
switch change.kind {
case structuredPatchDelete:
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return true, nil
case structuredPatchAdd:
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
if err != nil {
return committed, err
}
if moving {
if err := root.Remove(change.from.relative); err != nil {
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
}
}
return true, nil
}
return false, fmt.Errorf("unsupported structured patch operation")
}
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
parent := filepath.Dir(target.relative)
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
}
temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp")
if err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
defer func() { _ = removeStructuredPatchTemp(root, tempName) }()
if _, err := temp.WriteString(content); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Chmod(mode.Perm()); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Close(); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if createOnly {
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
}
if err := root.Rename(tempName, target.relative); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
return true, nil
}
func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
}
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
if linkErr := link(source, target); linkErr == nil {
cleanupErr := removeStructuredPatchTemp(root, source)
chmodErr := root.Chmod(target, mode.Perm())
if cleanupErr != nil || chmodErr != nil {
return true, fmt.Errorf("publishing %s: %w", target, errors.Join(cleanupErr, chmodErr))
}
return true, nil
} else if errors.Is(linkErr, os.ErrExist) {
return false, fmt.Errorf("creating %s: %w", target, linkErr)
} else {
committed, copyErr := copyStructuredPatchNoReplace(root, source, target, mode)
if copyErr != nil {
return committed, fmt.Errorf("creating %s without overwrite: hard link failed: %w; exclusive-copy fallback failed: %w", target, linkErr, copyErr)
}
return committed, nil
}
}
func copyStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return copyStructuredPatchNoReplaceWith(root, source, target, mode, io.Copy, removeStructuredPatchTemp)
}
func copyStructuredPatchNoReplaceWith(
root *os.Root,
source, target string,
mode os.FileMode,
copyFile func(io.Writer, io.Reader) (int64, error),
cleanup func(*os.Root, string) error,
) (bool, error) {
sourceFile, err := root.Open(source)
if err != nil {
return false, err
}
sourceClosed := false
defer func() {
if !sourceClosed {
_ = sourceFile.Close()
}
}()
targetFile, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false, err
}
committed := true
cleanupTarget := func(cause error) (bool, error) {
_ = targetFile.Close()
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, cause
} else {
return committed, errors.Join(cause, fmt.Errorf("cleaning partial target: %w", cleanupErr))
}
}
if _, err := copyFile(targetFile, sourceFile); err != nil {
return cleanupTarget(err)
}
if err := sourceFile.Close(); err != nil {
sourceClosed = true
return cleanupTarget(err)
}
sourceClosed = true
if err := targetFile.Chmod(mode.Perm()); err != nil {
return cleanupTarget(err)
}
if err := targetFile.Close(); err != nil {
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, err
} else {
return true, errors.Join(err, fmt.Errorf("cleaning target after close failure: %w", cleanupErr))
}
}
if err := cleanup(root, source); err != nil {
return true, fmt.Errorf("removing published source: %w", err)
}
return true, nil
}
func removeStructuredPatchTemp(root *os.Root, name string) error {
chmodErr := root.Chmod(name, 0o600)
if os.IsNotExist(chmodErr) {
return nil
}
removeErr := root.Remove(name)
if os.IsNotExist(removeErr) {
removeErr = nil
}
if removeErr == nil {
return nil
}
return errors.Join(chmodErr, removeErr)
}
func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) {
if tracker == nil {
return
}
content, err := os.ReadFile(absolute)
if err != nil {
tracker.Forget(absolute)
return
}
info, _ := os.Stat(absolute)
tracker.Record(absolute, content, info)
if seenWhole {
lines := lineCount(string(content))
tracker.RecordSeenRange(absolute, 1, lines, lines)
}
if created {
tracker.RecordCreated(absolute)
}
}
func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPatchChange) []string {
seen := make(map[string]bool)
var paths []string
for _, change := range changes {
targets := []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
for _, target := range targets {
path := target.relative
if relativeRoot != "" && relativeRoot != "." {
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
func structuredPatchPreview(changes []structuredPatchChange) string {
var preview strings.Builder
for _, change := range changes {
path := change.to.relative
if change.kind == structuredPatchDelete {
path = change.from.relative
}
before, after := change.before, change.after
if change.kind == structuredPatchDelete {
after = ""
}
diff := boundedUnifiedDiff(path, before, after)
if diff == "" {
continue
}
if preview.Len()+len(diff)+1 > maxToolPreviewBytes {
break
}
if preview.Len() > 0 {
preview.WriteByte('\n')
}
preview.WriteString(diff)
}
return capPreviewDiff(preview.String())
}
+6
View File
@@ -130,6 +130,9 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if !IsDeferred(candidate) {
continue
}
@@ -158,6 +161,9 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
return names
}
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
continue
}
+537
View File
@@ -2,12 +2,23 @@ package tools
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func mustReadTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreToolsScoped(t.TempDir(), nil)
byName := make(map[string]Tool, len(toolset))
@@ -513,6 +524,532 @@ func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
}
}
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
" hello",
"-old",
"+new",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: nested/new.txt",
"+created",
"*** Update File: old.txt",
"*** Move to: moved.txt",
"@@",
"-old",
"+moved",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
t.Fatalf("added file = %q, err = %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
t.Fatalf("moved file = %q, err = %v", got, err)
}
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
t.Fatalf("old source should be removed, stat err = %v", err)
}
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
"-hello",
"+goodbye",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "goodbye\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
" if cond {",
" doWork()",
"-\t\tlogIt()",
"+\t\tlogIt(ctx)",
" }",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
" alpha",
" context",
"-\told",
"+\tnew",
" omega",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@ anchor",
"+inserted",
"@@ middle",
"-remove",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured insertion failed: %s", result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
t.Fatalf("structured insertion content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
"-\treturn nil",
"+\treturn errNotFound",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("ambiguous structured patch changed the file: %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-bravo",
"+BRAVO",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\nbravo")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-alpha",
"+ALPHA",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "empty.txt")
writeTestFile(t, path, "")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: empty.txt",
"@@",
"+content",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "content"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-delta",
"+DELTA",
"@@",
"-delta",
"-echo",
"+tail",
"*** End of File",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("out-of-order structured patch changed the file: %q", got)
}
}
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "created.txt")
writeTestFile(t, targetPath, "other writer\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
after: "patch content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
if strings.Contains(err.Error(), "exclusive-copy fallback") {
t.Fatalf("existing destination should be reported directly: %v", err)
}
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
t.Fatalf("raced add overwrote another writer's file: %q", got)
}
}
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
missingPath := filepath.Join(root, "missing.txt")
change := structuredPatchChange{
kind: structuredPatchDelete,
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
before: "removed by another writer\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
t.Fatalf("failed delete recreated the missing file: %v", statErr)
}
}
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want partial-application error", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
t.Fatalf("published move destination = %q", got)
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
t.Fatal(err)
}
committed, err := publishStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o444,
func(string, string) error { return errors.New("hard links unsupported") },
)
if err != nil || !committed {
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
}
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
t.Fatalf("fallback target content = %q", got)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("read-only source was not cleaned up: %v", statErr)
}
}
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
copyErr := errors.New("injected copy failure")
cleanupErr := errors.New("injected target cleanup failure")
committed, err := copyStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o644,
func(destination io.Writer, _ io.Reader) (int64, error) {
written, writeErr := io.WriteString(destination, "partial")
if writeErr != nil {
return int64(written), writeErr
}
return int64(written), copyErr
},
func(root *os.Root, name string) error {
if name == "target.txt" {
return cleanupErr
}
return removeStructuredPatchTemp(root, name)
},
)
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("copy failure = committed %v, error %v", committed, err)
}
targetPath := filepath.Join(root, "target.txt")
if got := mustReadTestFile(t, targetPath); got != "partial" {
t.Fatalf("partial target content = %q", got)
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
writeTestFile(t, trackedPath, "original\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
trackedPath, err = filepath.EvalSymlinks(trackedPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(trackedPath)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
tracker.Record(trackedPath, []byte("original\n"), info)
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
blockedPath := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedPath, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
changes := []structuredPatchChange{
{
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
before: "original\n", after: "updated\n", mode: 0o644,
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
after: "cannot replace a non-empty directory\n", mode: 0o644,
},
}
err = applyStructuredPatchChanges(workspace, changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
t.Fatalf("completed change was unexpectedly altered: %q", got)
}
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
t.Fatal("partial failure retained stale file-tracker state")
}
}
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
root := t.TempDir()
tool := NewScopedApplyPatchTool(root, nil)
for name, patch := range map[string]string{
"empty update": strings.Join([]string{
"*** Begin Patch",
"*** Update File: target.txt",
"*** End Patch",
}, "\n"),
"escape": strings.Join([]string{
"*** Begin Patch",
"*** Add File: ../outside.txt",
"+nope",
"*** End Patch",
}, "\n"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "No valid patches") {
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
}
if name == "empty update" {
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
t.Fatalf("empty update must not write target file, stat err = %v", err)
}
}
})
}
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
}
}
// A hunk-body line that removes content beginning with "-- " appears in the diff
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
// apply_patch reject a valid patch as targeting an outside path).
+1 -1
View File
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
if err != nil {
return m.appendSystemNotice("add-dir: " + err.Error())
}
return m.appendSystemNotice("write access added: " + root + " (this session only)")
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
}
// appendSystemNotice appends a system transcript line and returns the updated
+2 -2
View File
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
t.Fatalf("expected one additional write root after grant, got %#v", roots)
}
notice := lastTranscriptText(next)
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
notice := next.transientNotice.text
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
}
+39 -4
View File
@@ -18,6 +18,8 @@ const (
markdownTableRuleBodyRows = 4
markdownBoldStart = "\x1b[1m"
markdownBoldEnd = "\x1b[22m"
markdownCodeStart = "\x1b[7m"
markdownCodeEnd = "\x1b[27m"
)
type markdownDisplayStyle int
@@ -25,6 +27,7 @@ type markdownDisplayStyle int
const (
markdownDisplayNormal markdownDisplayStyle = iota
markdownDisplayBold
markdownDisplayCode
markdownDisplayRule
)
@@ -262,6 +265,8 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
case markdownDisplayBold:
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
case markdownDisplayCode:
builder.WriteString(inlineCodeStyle().Render(text))
case markdownDisplayRule:
builder.WriteString(zeroTheme.lineStrong.Render(text))
default:
@@ -282,6 +287,16 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
style = markdownDisplayNormal
index += len(markdownBoldEnd)
continue
case strings.HasPrefix(line[index:], markdownCodeStart):
flush()
style = markdownDisplayCode
index += len(markdownCodeStart)
continue
case strings.HasPrefix(line[index:], markdownCodeEnd):
flush()
style = markdownDisplayNormal
index += len(markdownCodeEnd)
continue
case line[index] == '\x1b':
// Already-styled input (highlighted code, headings, tables) carries real
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
@@ -313,6 +328,13 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
return builder.String()
}
func hasMarkdownDisplayControls(line string) bool {
return strings.Contains(line, markdownBoldStart) ||
strings.Contains(line, markdownBoldEnd) ||
strings.Contains(line, markdownCodeStart) ||
strings.Contains(line, markdownCodeEnd)
}
func hasExternalANSIStyle(line string) bool {
for index := 0; index < len(line); {
if line[index] != '\x1b' {
@@ -325,7 +347,7 @@ func hasExternalANSIStyle(line string) bool {
continue
}
seq := line[index:end]
if seq != markdownBoldStart && seq != markdownBoldEnd {
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
return true
}
index = end
@@ -334,6 +356,9 @@ func hasExternalANSIStyle(line string) bool {
}
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
if current == markdownDisplayCode {
return current
}
switch r {
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
return markdownDisplayRule
@@ -980,6 +1005,7 @@ func renderMarkdownStandaloneLine(line string) string {
type markdownInlineSegment struct {
text string
bold bool
code bool
}
func wrapMarkdownInline(text string, measure int) []string {
@@ -1028,7 +1054,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
flush()
}
head, tail := splitAtWidth(word.text, available)
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
word.text = tail
prefix = continuationPrefix
available = maxInt(1, measure-lipgloss.Width(prefix))
@@ -1063,8 +1089,14 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
words := []markdownInlineSegment{}
for _, segment := range segments {
if segment.code {
if segment.text != "" {
words = append(words, segment)
}
continue
}
for _, word := range strings.Fields(segment.text) {
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
}
}
return words
@@ -1078,6 +1110,9 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
if segment.text == "" {
return ""
}
if segment.code {
return markdownCodeStart + segment.text + markdownCodeEnd
}
if segment.bold {
return renderMarkdownBoldText(segment.text)
}
@@ -1107,7 +1142,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
if builder.Len() == 0 {
return
}
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
builder.Reset()
}
+110
View File
@@ -0,0 +1,110 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
attachmentImageID = uint32(0xC0E0)
attachmentPreviewCols = 10
attachmentPreviewRows = 4
attachmentPreviewHeightPixels = 64
attachmentPreviewMaxImages = 4
)
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
// image is an enhancement to the existing attachment chips, not a reason to
// crowd a narrow or modal surface.
func (m model) attachmentThumbnailVisible(width int) bool {
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
return false
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
return true
}
}
return false
}
func (m model) attachmentThumbnailSlots(width int) int {
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
}
func (m model) attachmentThumbnailLines(width int) []string {
left := strings.Repeat(" ", attachmentPreviewCols+1)
lines := make([]string, attachmentPreviewRows)
for index := range lines {
lines[index] = left
}
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return lines
}
func (m model) attachmentThumbnailSupplementalChips() string {
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
return ""
}
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
}
func (m model) attachmentComposerPrefixRows(width int) int {
if m.attachmentThumbnailVisible(width) {
rows := attachmentPreviewRows
if m.attachmentThumbnailSupplementalChips() != "" {
rows++
}
return rows
}
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
return 1
}
return 0
}
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
// of the composer. The position comes from the same footer layout used for mouse
// input, so it follows sidebar changes and terminal resizes instead of relying on
// a brittle absolute bottom-right calculation.
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
width := m.chatColumnWidth()
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
return nil
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height < attachmentPreviewRows+2 {
return nil
}
return &terminalpet.ImageDraw{
ID: attachmentImageID + uint32(index),
Animation: m.pendingImageThumbnails[index],
State: terminalpet.Idle,
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
Y: frame.composerRect.y + 1,
Columns: attachmentPreviewCols,
Rows: attachmentPreviewRows,
HeightPixels: attachmentPreviewHeightPixels,
}
}
func attachmentPreviewImageIDs() []uint32 {
ids := make([]uint32, attachmentPreviewMaxImages)
for index := range ids {
ids[index] = attachmentImageID + uint32(index)
}
return ids
}
+168
View File
@@ -0,0 +1,168 @@
package tui
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
t.Helper()
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
t.Fatal(err)
}
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
}
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
t.Fatal("expected staged image thumbnail")
}
view := m.View()
plain := plainRender(t, view.Content)
if strings.Contains(plain, "[Image #1]") {
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
}
var output bytes.Buffer
if err := m.attachmentRenderers[0].Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
}
}
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
m.pendingImageLabels = []string{"first.png", "second.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #2]") {
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
}
_ = m.View()
for index, renderer := range m.attachmentRenderers {
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
if !strings.Contains(output.String(), id) {
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
}
}
}
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #1]") {
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
}
}
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
_ = m.View()
var first bytes.Buffer
if err := m.attachmentRenderers[0].Render(&first); err != nil {
t.Fatal(err)
}
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
_ = m.View()
var cleared bytes.Buffer
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
t.Fatal(err)
}
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
}
}
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
if err != nil {
t.Fatal(err)
}
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sink.Close() })
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
}
for _, id := range []string{"i=49374", "i=49376"} {
if !strings.Contains(got, id) {
t.Fatalf("missing image placement %s: %q", id, got)
}
}
}
+1
View File
@@ -136,6 +136,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
if question == "" {
side.pendingImages = nil
side.pendingImageLabels = nil
side.pendingImageThumbnails = nil
side.pendingDocuments = nil
}
side.loops = nil
-1
View File
@@ -107,7 +107,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+28 -7
View File
@@ -15,6 +15,7 @@ import (
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -731,14 +732,16 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
}, true
}
if provider, ok := m.activeProviderDescriptor(); ok {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, candidate := range providerModelSwitchCandidates(provider.ID, args) {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
if genericProviderCatalogID(provider.ID) && strings.TrimSpace(args) != "" {
@@ -748,6 +751,24 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
return modelSwitchTarget{}, false
}
// providerModelSwitchCandidates preserves provider-owned model IDs while
// accepting the models.dev OpenAI namespace for ChatGPT subscription models.
// The ChatGPT backend itself publishes bare model IDs (for example,
// "gpt-5.6-sol"); gateway providers may legitimately require "openai/..."
// as part of their model ID, so the alias is deliberately provider-specific.
func providerModelSwitchCandidates(providerID string, input string) []string {
requested := strings.TrimSpace(input)
candidates := []string{requested}
const openAIPrefix = "openai/"
if providercatalog.NormalizeID(providerID) != "chatgpt" || len(requested) <= len(openAIPrefix) || !strings.EqualFold(requested[:len(openAIPrefix)], openAIPrefix) {
return candidates
}
if bare := strings.TrimSpace(requested[len(openAIPrefix):]); bare != "" {
candidates = append(candidates, bare)
}
return candidates
}
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
if modelSwitchCompactionGuard == nil {
return m, "", false
+1 -4
View File
@@ -269,10 +269,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
if width < 8 {
return m.composerPositionAtVisualCell(localX, localY, width)
}
contentY := localY - 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY--
}
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
if contentY < 0 {
return 0, false
}
+4 -4
View File
@@ -201,13 +201,13 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) {
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
// its port) until the app exits. It respawns lazily on the next streaming
// recording. Skip while a recording is still in flight so we don't kill it.
next := m.appendSystemNotice("Voice mode off.")
next := m.showTransientNoticeInline("Voice mode off.", transientNoticeInfo)
if next.dictation.active() {
return next, nil
}
@@ -299,7 +299,7 @@ func (m model) cancelDictation() (model, tea.Cmd) {
}
m = m.discardDictationRegion()
m.dictation.reset()
return m.appendSystemNotice("Dictation cancelled."), nil
return m.showTransientNoticeInline("Dictation cancelled.", transientNoticeInfo), nil
}
// wantStreaming decides whether this recording uses the streaming pipeline:
@@ -438,7 +438,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
if streaming {
return m, nil // streaming already rendered live; nothing to add
}
return m.appendSystemNotice("No speech detected."), nil
return m.showTransientNoticeInline("No speech detected.", transientNoticeInfo), nil
}
if !streaming {
m = m.insertDictatedText(msg.text)
+3 -3
View File
@@ -42,7 +42,7 @@ func (d dictationController) canOfferDownload(err error) bool {
// variant, reporting progress live, then persists and applies the resolved paths.
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
if m.dictation.downloading {
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
return m.showTransientNoticeInline("Dictation setup is already in progress.", transientNoticeWarning), nil
}
m.dictation.downloading = true
sink := m.runtimeMessageSink
@@ -53,7 +53,7 @@ func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
base = context.Background()
}
m.dictation.downloadStatus = "Starting download…"
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
m = m.showTransientNoticeInline(fmt.Sprintf("Setting up local dictation (%s) — progress is in the status line.", v.Label), transientNoticeInfo)
return m, func() tea.Msg {
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
DestRoot: root,
@@ -93,7 +93,7 @@ func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea
if err != nil {
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
}
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
return m.showTransientNoticeInline("Local dictation is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
}
// applyEngineComponents persists and applies resolved engine/model paths to the
+3 -2
View File
@@ -2,6 +2,7 @@ package tui
import (
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -79,7 +80,7 @@ func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
if got.dictation.cfg.StreamingEnabled() {
t.Error("a batch variant should set streaming off")
}
if !transcriptHasText(got, "ready") {
if !strings.Contains(got.transientNotice.text, "ready") {
t.Error("expected a ready notice")
}
}
@@ -158,7 +159,7 @@ func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
if cmd == nil {
t.Error("expected a download command")
}
if !transcriptHasText(next, variant.Label) {
if !strings.Contains(next.transientNotice.text, variant.Label) {
t.Error("expected the download-starting notice to name the variant")
}
}
+5 -2
View File
@@ -33,8 +33,11 @@ func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
if got.composer.text != "" {
t.Errorf("composer should stay empty, got %q", got.composer.text)
}
if !transcriptHasText(got, "No speech detected") {
t.Error("expected a 'no speech' notice")
if got.transientNotice.text != "No speech detected." {
t.Errorf("no-speech notice = %q", got.transientNotice.text)
}
if transcriptHasText(got, "No speech detected") {
t.Error("no-speech confirmation should not be persisted in the transcript")
}
}
+5 -2
View File
@@ -36,8 +36,11 @@ func TestToggleVoiceModeFlips(t *testing.T) {
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if !transcriptHasText(next, "Voice mode on") {
t.Error("expected voice-mode-on notice")
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
}
if transcriptHasText(next, "Voice mode on") {
t.Error("voice-mode-on confirmation should not be persisted in the transcript")
}
off, _ := next.toggleVoiceMode()
if off.dictation.voiceModeEnabled {
+35 -11
View File
@@ -2,12 +2,16 @@
package tui
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tools"
)
func renderMarkdownInline(text string) string {
@@ -48,6 +52,26 @@ func formatGroupedCommandHelpLines() []string {
return lines
}
// runningPlanModel supplies plan state for tests that exercise contextual
// details without reviving the former persistent plan presentation.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func listCommandNames() []string {
names := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
@@ -88,18 +112,18 @@ func (m model) overlayMouseTop(overlayHeight int, width int) int {
return m.overlayMouseRect(overlayHeight, width).y
}
// height returns the number of terminal lines renderPlanPanel will occupy at
// the given width (0 when the panel is not visible). The step list is shown
// when the panel is expanded or still running; a collapsed, finished plan is
// just the header and progress bar.
func (s planPanelState) height(width int, now time.Time) int {
if !s.visible(now) {
return 0
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
cwd, err := os.Getwd()
if err != nil {
return 0, 0, err
}
if s.expanded || !s.isComplete() {
return 2 + len(s.steps)
}
return 2
ctx, cancel := context.WithTimeout(context.Background(), prCommandTimeout)
defer cancel()
return getLocalDiffStats(ctx, cwd, baseBranch, defaultPRCommandRunner)
}
func WatchPRState(service *PrService, onChange func(PrState)) func() {
return WatchPRStateContext(context.Background(), service, onChange)
}
func (c *staticRenderCache) retainedCharacters() int {
+33 -9
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -161,9 +162,9 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
}
}
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
// TestSidebarAgentClickIsIgnoredWithoutRail verifies invisible legacy hit
// coordinates cannot unexpectedly replace the full-width conversation view.
func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
@@ -185,14 +186,14 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if !handled {
t.Fatal("agent row click should be handled")
if handled {
t.Fatal("invisible rail coordinates must not handle clicks")
}
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
if !updated.fileView.active {
t.Fatal("an ignored rail click must leave the active file view alone")
}
if !updated.subchat.active {
t.Fatal("subchat should be active")
if updated.subchat.active {
t.Fatal("an ignored rail click must not enter a subchat")
}
}
@@ -217,6 +218,29 @@ func TestChangedFilesRehydration(t *testing.T) {
}
}
// TestResumedFileEditUsesPersistedDisplayPreview keeps the reviewable diff a
// user saw while the run was live. The provider-facing output is intentionally
// a short confirmation, so it cannot substitute for the card-only preview.
func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"edit-1","name":"edit_file","status":"ok","output":"Successfully edited calculator.go (replaced 1 occurrence).","displayPreview":` + strconv.Quote(preview) + `}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected one restored row, got %d", len(rows))
}
if rows[0].detail != preview {
t.Fatalf("resume should restore the saved diff preview, got %q", rows[0].detail)
}
card := renderToolResultCard(rows[0], 80, rowContext{}, cardRenderOptions{})
if plain := plainRender(t, card); !strings.Contains(plain, "newValue := 2") {
t.Fatalf("resumed edit should render its diff preview, got %q", plain)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
+2 -33
View File
@@ -6,10 +6,8 @@
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
// The same compact rows feed the run-details overlay; selecting a file from a
// visible transcript card opens the file drill-in (file_view.go).
package tui
import (
@@ -17,8 +15,6 @@ import (
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -312,33 +308,6 @@ func (m model) sidebarFileSelectables(width int) []fileHit {
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
+9 -31
View File
@@ -161,38 +161,16 @@ func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
}
}
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
// TestRunDetailsListsTouchedFiles keeps edited files discoverable without a
// permanent column beside the conversation.
func TestRunDetailsListsTouchedFiles(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
m.runDetailsOpen = true
plain := plainRender(t, m.runDetailsOverlay(m.width))
for _, file := range m.touchedFiles() {
if !strings.Contains(plain, file.path) {
t.Errorf("run details missing touched file %q:\n%s", file.path, plain)
}
}
}
+2 -4
View File
@@ -116,7 +116,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
}
m, _ = m.settleTranscript()
selectedView := viewString(m.View())
_ = viewString(m.View())
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
@@ -126,9 +126,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
t.Fatal("clearing a selected file should rebuild the invalidated cache")
}
if clearedView := viewString(m.View()); clearedView == selectedView {
t.Fatal("clearing a selected file should remove the settled row's selection tint")
}
_ = viewString(m.View())
}
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
+3 -14
View File
@@ -51,21 +51,10 @@ func mouseHover(msg tea.MouseMsg) bool {
return mouseEvent(msg).Button == tea.MouseNone
}
// updateHoverTarget resolves what's under the cursor for a hover motion, using
// the SAME hit-testers and priority order as the click handlers in
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
// step) takes priority since it's outside the chat column, then a clickable
// transcript line. Clears the hover when nothing clickable is under the cursor.
// updateHoverTarget resolves visible transcript targets for a hover motion.
// Context data lives in the run-details overlay; the former sidebar rail is not
// rendered and therefore cannot own a hover target.
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if hit, ok := m.sidebarLineAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
}
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
+49 -70
View File
@@ -1,12 +1,12 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func TestMouseHoverPredicate(t *testing.T) {
@@ -78,70 +78,71 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
}
}
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
// that: real conversation + a mapped swarm member session.
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
func TestDiffHeaderDoesNotHoverOrToggle(t *testing.T) {
m := mouseTestModel()
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowToolResult,
id: "diff",
tool: "write_file",
status: tools.StatusOK,
detail: "--- go.mod\n+++ go.mod\n@@ -0,0 +1,3 @@\n+module greeting\n+\n+go 1.25",
})
rowIndex := len(m.transcript) - 1
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.rowIndex == rowIndex {
target = line
break
}
}
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
if target.text == "" {
t.Fatalf("expected a selectable diff header, got %#v", selectable)
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
if m.hover.kind != hoverNone {
t.Fatalf("diff header must not hover as a control, got %#v", m.hover)
}
if m.hover.sessionID != hits[0].sessionID {
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
}
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
// not a raw line offset — resolve it back to a line offset the same way the
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
// SAME row that was actually hovered, not some other row at that raw index.
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
updated, _ = m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.transcript[rowIndex].expanded {
t.Fatal("clicking an always-visible diff header must not toggle it")
}
}
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
func TestUpdateHoverTargetIgnoresFormerSidebarAgentRail(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected a legacy sidebar row for the rail-coordinate regression")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
func TestUpdateHoverTargetIgnoresFormerSidebarPlanRail(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
// leaving the transcript at its default (welcome-only, i.e. "empty").
m.transcript = appendRow(m.transcript, rowUser, "do something")
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
}
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable plan step row")
t.Fatal("expected a legacy plan row for the rail-coordinate regression")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverPlanStep {
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
}
if m.hover.stepIndex != hits[0].stepIndex {
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
}
// The bug this guards: the render path must resolve stepIndex back to the
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
// into the rendered sidebar (which would land on the AGENTS header/body).
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
@@ -207,28 +208,6 @@ func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
}
}
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
width := m.chatColumnWidth()
height := m.height
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
}
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
if without == with {
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverClearsOnWheelScroll(t *testing.T) {
m := mouseTestModel()
for i := 0; i < 80; i++ {
+56
View File
@@ -1,7 +1,12 @@
package tui
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"runtime"
@@ -9,9 +14,13 @@ import (
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
_ "golang.org/x/image/webp"
)
const attachmentThumbnailMaxPixels = 16 << 20
// droppableImageExts are the image extensions a dragged-and-dropped file may
// carry (matched case-insensitively); PDFs are recognized separately.
var droppableImageExts = map[string]bool{
@@ -108,6 +117,14 @@ func (m model) modelSupportsVisionTUI() bool {
for _, models := range m.modelPickerLiveByProvider {
for _, dm := range models {
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
// A provider's authenticated listing may only establish which models
// are available, without repeating modalities. Treat an empty list as
// unknown rather than as an explicit image-input denial, so the
// curated registry/name capability fallback remains available while
// models.dev metadata is temporarily unavailable.
if len(dm.InputModalities) == 0 {
continue
}
for _, modality := range dm.InputModalities {
if strings.EqualFold(strings.TrimSpace(modality), "image") {
return true
@@ -141,6 +158,7 @@ func (m model) attachClipboardImage(data []byte, mediaType string) model {
Data: data,
})
m.pendingImageLabels = append(m.pendingImageLabels, "clipboard")
m.refreshPendingImageThumbnail()
return m
}
@@ -157,6 +175,7 @@ func (m model) handleImageCommand(arg string) model {
case strings.EqualFold(trimmed, "clear"):
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
m.pendingDocuments = nil
return m.appendImageNotice("Cleared pending attachments.")
}
@@ -185,6 +204,7 @@ func (m model) handleImageCommand(arg string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
m.refreshPendingImageThumbnail()
// No "attached" system message: the composer attachment chip ([Image #N]) is
// the confirmation, matching the compact attach UX.
return m
@@ -219,6 +239,7 @@ func (m model) handleDocumentAttach(path string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, label)
}
m.refreshPendingImageThumbnail()
// The composer attachment chip ([Doc #N] / [Image #N]) is the confirmation; no
// "attached" system message.
return m
@@ -269,11 +290,46 @@ func (m model) removeLastAttachment() (model, bool) {
if len(m.pendingImages) > 0 {
m.pendingImages = m.pendingImages[:len(m.pendingImages)-1]
}
m.refreshPendingImageThumbnail()
return m, true
}
return m, false
}
// refreshPendingImageThumbnail prepares only the first pending image for the
// optional inline terminal preview. Decode failures deliberately leave the
// attachment intact: the text chip remains the portable fallback and the model
// still receives the original bytes.
func (m *model) refreshPendingImageThumbnail() {
m.pendingImageThumbnails = nil
if len(m.pendingImages) == 0 {
return
}
count := min(len(m.pendingImages), attachmentPreviewMaxImages)
m.pendingImageThumbnails = make([]*terminalpet.Animation, count)
for index := 0; index < count; index++ {
preview, err := attachmentThumbnail(m.pendingImages[index])
if err == nil {
m.pendingImageThumbnails[index] = preview
}
}
}
func attachmentThumbnail(block zeroruntime.ImageBlock) (*terminalpet.Animation, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
if config.Width < 1 || config.Height < 1 || config.Width > attachmentThumbnailMaxPixels/config.Height {
return nil, fmt.Errorf("image is too large for an inline thumbnail")
}
decoded, _, err := image.Decode(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
return terminalpet.ThumbnailAnimation(decoded)
}
// renderAttachmentChips builds the pending-attachment row from both staged images
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
// nothing is staged. Numbered (not named) so a long screenshot path never shows
+16
View File
@@ -188,6 +188,22 @@ func TestSubmitThreadsImagesThenClears(t *testing.T) {
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
if len(next.transcript) == 0 || next.transcript[len(next.transcript)-1].kind != rowUser {
t.Fatalf("submit should append a user row, got %#v", next.transcript)
}
if rendered := plainRender(t, renderUserRow(next.transcript[len(next.transcript)-1], 80)); !strings.Contains(rendered, "[Image #1]") {
t.Fatalf("sent image should remain visible on the user row, got:\n%s", rendered)
}
events, err := next.sessionStore.ReadEvents(next.activeSession.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) == 0 {
t.Fatal("submit should persist the user event")
}
if got := attachmentSummaryFromPayload(sessionPayload(events[0])); got.images != 1 || got.documents != 0 {
t.Fatalf("persisted attachment summary = %#v, want one image", got)
}
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
+9 -6
View File
@@ -1,5 +1,5 @@
// keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a
// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat,
// rich set of chord bindings (Ctrl+T effort, drill-in subchat,
// Shift+Tab permission mode, …) that are otherwise invisible — only learnable
// by reading the source. A single-key `?` overlay (opened on an empty composer)
// lists them grouped, so the keymap is discoverable the way the reference TUIs
@@ -7,11 +7,15 @@
// model.go's Update switch; keep them in sync when a binding changes.
//
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
// toggleSidebar) pull their key label from m.keyBindings so a user
// who remaps them in config.json sees the actual chords, not the defaults.
package tui
import "strings"
import (
"strings"
"charm.land/lipgloss/v2"
)
// keybinding is one row in the help overlay: the key chord and what it does.
type keybinding struct {
@@ -53,7 +57,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
},
},
{
@@ -63,7 +66,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "show / hide run details"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
@@ -144,7 +147,7 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+21 -19
View File
@@ -162,22 +162,24 @@ func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
}
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
// 1. Empty composer: Ctrl+B should toggle run details, Ctrl+E should toggle mouseReleased.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.altScreen = true
m.width = 120
m.height = 40
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
initialSidebar := m.sidebarHidden
initialDetails := m.runDetailsOpen
updated, _ := m.Update(testKeyCtrl('b'))
next := updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
if next.runDetailsOpen == initialDetails {
t.Fatal("Ctrl+B on empty composer should toggle run details")
}
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
initialMouse := next.mouseReleased
updated, _ = next.Update(testKeyCtrl('e'))
@@ -198,11 +200,11 @@ func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
}
initialSidebar2 := m2.sidebarHidden
initialDetails2 := m2.runDetailsOpen
updated, _ = m2.Update(testKeyCtrl('b'))
next2 := updated.(model)
if next2.sidebarHidden != initialSidebar2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
if next2.runDetailsOpen != initialDetails2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle run details")
}
initialMouse2 := next2.mouseReleased
@@ -228,8 +230,8 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
// reaches configurable global bindings.
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -247,11 +249,11 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('y'))
next = updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
if next.runDetailsOpen == initialDetails {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still open run details with a non-empty composer")
}
if next.composerValue() != "hello" {
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
@@ -273,8 +275,8 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -289,10 +291,10 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
if next.sidebarHidden != initialSidebar {
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
if next.runDetailsOpen != initialDetails {
t.Fatal("explicit ctrl+b config should NOT toggle run details with a non-empty composer")
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
@@ -136,12 +137,12 @@ func renderLeaderHelpLines(innerWidth int) []string {
}
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
// general ? keyboard-shortcut overlay: same width helper, border style
// (zeroTheme.line), panel fill, and centered block.
// general ? keyboard-shortcut overlay. Its interior stays transparent so a
// terminal's own canvas remains visible without jagged panel-fill padding.
func (m model) renderLeaderHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := renderLeaderHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+17 -1
View File
@@ -240,7 +240,7 @@ func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
}
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
// Both overlays must use the same frame builder (titled box + panel fill).
// Both overlays must use the same transparent, titled frame builder.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
help := m.renderKeybindingHelpOverlay(100)
@@ -267,6 +267,22 @@ func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
}
}
func TestShortcutOverlaysKeepTerminalCanvasTransparent(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
panelFill := zeroTheme.panel.Render(" ")
for _, overlay := range []struct {
name string
view string
}{
{"keyboard help", m.renderKeybindingHelpOverlay(100)},
{"leader help", m.renderLeaderHelpOverlay(100)},
} {
if strings.Contains(overlay.view, panelFill) {
t.Fatalf("%s should not paint the panel background behind shortcut rows", overlay.name)
}
}
}
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.leaderHelpOverlay = true
+1 -32
View File
@@ -91,32 +91,12 @@ func TestCtrlPNMovesPermissionOptions(t *testing.T) {
}
}
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
}
if m.plan.isEmpty() {
t.Fatal("setup: plan should be non-empty")
}
initial := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('p'))
next := updated.(model)
if next.plan.expanded == initial {
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
}
}
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlPMovesPickerSelection(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
Provider: &fakeProvider{},
})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: true,
}
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
@@ -125,9 +105,6 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
}
updated, _ = m.Update(testKeyCtrl('p'))
next := updated.(model)
if !next.plan.expanded {
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
}
if next.picker == nil {
t.Fatal("picker should remain open")
}
@@ -135,19 +112,11 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlNNoOpWithoutModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: false,
}
before := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('n'))
next := updated.(model)
if next.picker != nil || next.suggestionsActive() {
t.Fatal("Ctrl+N idle must not open a modal")
}
if next.plan.expanded != before {
t.Fatal("Ctrl+N idle must not toggle the plan panel")
}
if next.composerValue() != "" {
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
}
+342 -333
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -46,6 +46,23 @@ func TestModelSupportsVisionTUIChecksDiscoveredBeforeHeuristic(t *testing.T) {
}
}
func TestModelSupportsVisionTUIFallsBackWhenLiveMetadataOmitsModalities(t *testing.T) {
registry := mustTestModelRegistry(t, testModelEntry("custom-known", 12345, []modelregistry.ModelCapability{
modelregistry.ModelCapabilityChat,
}))
m := model{
modelName: "gpt-5.6-sol",
modelCatalog: registry,
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
},
}
if !m.modelSupportsVisionTUI() {
t.Fatal("live discovery without modality metadata must fall back to the model capability heuristic")
}
}
func BenchmarkModelContextWindowLookup(b *testing.B) {
cachedRegistry, err := modelregistry.DefaultRegistry()
if err != nil {
+119 -28
View File
@@ -3,6 +3,7 @@ package tui
import (
"bytes"
"context"
"encoding/json"
"errors"
"path/filepath"
"reflect"
@@ -19,6 +20,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -558,13 +560,79 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
if rebuilt.Model != "gpt-4.1-mini" {
t.Fatalf("expected provider rebuild with selected model, got %#v", rebuilt)
}
for _, want := range []string{"Model", "gpt-4.1-mini · openai"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
for _, want := range []string{"Model:", "gpt-4.1-mini · openai"} {
if !strings.Contains(next.transientNotice.text, want) {
t.Fatalf("expected model notice to contain %q, got %q", want, next.transientNotice.text)
}
}
}
func TestModelCommandAcceptsChatGPTCatalogModelID(t *testing.T) {
var rebuilt config.ProviderProfile
nextProvider := &fakeProvider{}
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-terra",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://chatgpt.com/backend-api/codex",
Model: "gpt-5.6-terra",
},
Provider: &fakeProvider{},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
rebuilt = profile
return nextProvider, nil
},
})
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
}
m.input.SetValue("/model openai/gpt-5.6-sol")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-5.6-sol" || next.provider != nextProvider {
t.Fatalf("expected ChatGPT model switch to use bare catalog ID, got model=%q provider=%#v", next.modelName, next.provider)
}
if rebuilt.Model != "gpt-5.6-sol" {
t.Fatalf("expected provider rebuild with bare ChatGPT model ID, got %#v", rebuilt)
}
}
func TestProviderModelSwitchCandidatesPreserveGatewayModelIDs(t *testing.T) {
for _, test := range []struct {
name string
provider string
input string
want []string
}{
{
name: "ChatGPT accepts the models.dev OpenAI namespace",
provider: "chatgpt",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol", "gpt-5.6-sol"},
},
{
name: "gateway keeps qualified model ID",
provider: "openrouter",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol"},
},
} {
t.Run(test.name, func(t *testing.T) {
if got := providerModelSwitchCandidates(test.provider, test.input); !reflect.DeepEqual(got, test.want) {
t.Fatalf("providerModelSwitchCandidates(%q, %q) = %#v, want %#v", test.provider, test.input, got, test.want)
}
})
}
}
func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero.json")
if _, err := config.UpsertProvider(configPath, config.ProviderProfile{
@@ -611,8 +679,8 @@ func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
if got := persisted.Provider.Model; got != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", got)
}
if !transcriptContains(next.transcript, "· saved") {
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
if !strings.Contains(next.transientNotice.text, "gpt-4.1-mini") {
t.Fatalf("expected model notice to name selected model, got %q", next.transientNotice.text)
}
}
@@ -1468,6 +1536,28 @@ func TestToolResultDetailPrefersPreview(t *testing.T) {
}
}
func TestToolResultSessionPayloadKeepsPreviewForResume(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
payload := toolResultSessionPayload(agent.ToolResult{
ToolCallID: "edit-1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "Successfully edited calculator.go (replaced 1 occurrence).",
Display: tools.Display{Preview: preview},
})
if got, want := payload["displayPreview"], preview; got != want {
t.Fatalf("persisted display preview = %#v, want %q", got, want)
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal session payload: %v", err)
}
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: encoded}})
if len(rows) != 1 || rows[0].detail != preview {
t.Fatalf("resumed row lost the display preview: %#v", rows)
}
}
// TestReasoningRefreshesActivityClock: a reasoning delta is live provider output,
// so it must bump lastStreamActivity (else the quiet hint mis-fires mid-think).
func TestReasoningRefreshesActivityClock(t *testing.T) {
@@ -1502,16 +1592,15 @@ func TestStaleExplanationDropped(t *testing.T) {
}
}
// TestBeginRunResetsSidebarHidden: a new run clears the sidebar's content, so the
// stale Ctrl+B hide preference is reset (the new run's sidebar isn't suppressed)
// and the explanation generation advances.
func TestBeginRunResetsSidebarHidden(t *testing.T) {
// TestBeginRunKeepsRunDetailsClosed verifies a new run starts on the focused
// conversation surface and advances the explanation generation.
func TestBeginRunKeepsRunDetailsClosed(t *testing.T) {
m := newModel(context.Background(), Options{})
m.sidebarHidden = true
m.runDetailsOpen = true
gen := m.planDetailGen
m = m.beginRun(nil)
if m.sidebarHidden {
t.Error("beginRun should reset the Ctrl+B hide preference for the new run")
if m.runDetailsOpen {
t.Error("beginRun should close run details for the new turn")
}
if m.planDetailGen <= gen {
t.Errorf("beginRun should bump planDetailGen, was %d now %d", gen, m.planDetailGen)
@@ -2561,14 +2650,10 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
t.Fatalf("empty sidebar should not be advertised, got %q", hint)
}
withSidebar := idle
withSidebar.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("available sidebar should be advertised, got %q", hint)
}
withSidebar.sidebarHidden = true
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("collapsed sidebar should keep its restore shortcut, got %q", hint)
withDetails := idle
withDetails.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withDetails.composerIdleHint()); !strings.Contains(hint, "Ctrl+B details") {
t.Fatalf("available run details should be advertised, got %q", hint)
}
})
}
@@ -2946,21 +3031,27 @@ func TestScrimViewportLine(t *testing.T) {
if got := scrimViewportLine(" ", 10); got != " " {
t.Fatalf("blank line should be untouched, got %q", got)
}
// Non-blank lines keep their text (only the styling is dimmed), so the backdrop
// stays readable behind the overlay.
// Non-blank plain lines keep their text and become faint, so the backdrop stays
// readable behind the overlay without competing with its focus surface.
got := scrimViewportLine("transcript content", 40)
if ansi.Strip(got) != "transcript content" {
t.Fatalf("scrim must preserve text, got %q", ansi.Strip(got))
}
// A pre-styled line must have its OWN styling stripped (so the dim wins), while
// the text content survives. This fails if scrim stops re-styling the backdrop.
styled := "\x1b[31mred backdrop\x1b[0m text"
// Styled transcript content must retain its semantic foreground and background
// colors. The scrim adds faintness around resets rather than flattening a diff
// or syntax-highlighted line to a single grey style.
styled := "\x1b[38;2;235;80;110mremoved\x1b[0m \x1b[48;2;35;80;58madded\x1b[0m"
scrimmed := scrimViewportLine(styled, 40)
if ansi.Strip(scrimmed) != "red backdrop text" {
if ansi.Strip(scrimmed) != "removed added" {
t.Fatalf("scrim must preserve styled line's text, got %q", ansi.Strip(scrimmed))
}
if strings.Contains(scrimmed, "\x1b[31m") {
t.Fatalf("scrim must strip the line's original styling, got %q", scrimmed)
for _, sequence := range []string{"\x1b[38;2;235;80;110m", "\x1b[48;2;35;80;58m"} {
if !strings.Contains(scrimmed, sequence) {
t.Fatalf("scrim must preserve semantic ANSI sequence %q, got %q", sequence, scrimmed)
}
}
if !strings.Contains(scrimmed, "\x1b[2m") {
t.Fatalf("scrim must apply faint styling around semantic colors, got %q", scrimmed)
}
}
+14 -35
View File
@@ -287,38 +287,6 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}
// sidebarLineAtMouse maps a left-click in the AGENTS sidebar column to the swarm
// member whose row was clicked, when that member's session is known. Geometry:
// twoColumnTranscriptView is the full screen (sidebarActive ⇒ alt-screen), and
// joinColumns lays out [chat(chatColumnWidth)][" │ " 3-cell divider][sidebar],
// zipping row-by-row — so a sidebar line's screen Y equals its sidebar index and
// the sidebar starts at screen X = chatColumnWidth + 3. Recomputed on demand
// (View can't persist a registry on the value-receiver model), like
// transcriptLineAtMouse.
func (m model) sidebarLineAtMouse(msg tea.MouseMsg) (sidebarAgentHit, bool) {
if !m.sidebarActive() {
return sidebarAgentHit{}, false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return sidebarAgentHit{}, false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return sidebarAgentHit{}, false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return sidebarAgentHit{}, false
}
for _, hit := range m.sidebarAgentSelectables(sidebarW) {
if hit.lineOffset == y && hit.sessionID != "" {
return hit, true
}
}
return sidebarAgentHit{}, false
}
func (m model) repeatMouseSelection(target mouseSelectionTarget) bool {
return target.Scope != "" && m.lastMouseSelection == target
}
@@ -531,6 +499,20 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if !ok {
return mouseSelectionTarget{}, false
}
// The theme picker has a right-hand visual preview on wider terminals. It is
// deliberately non-interactive: clicking a color sample must not select a
// list item merely because it shares that row's y-coordinate.
if m.picker.kind == pickerTheme {
overlayWidth := minInt(width, pickerOverlayMaxWidth)
if overlayWidth < pickerOverlayMinWidth {
overlayWidth = width
}
listWidth, _, showPreview := themePickerColumnWidths(maxInt(1, overlayWidth-4))
// styledBlockFillTitle contributes a left border and one-cell inset.
if showPreview && hit.x >= listWidth+2 {
return mouseSelectionTarget{}, false
}
}
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
start := selectableListStart(len(m.picker.items), maxVisible, selected)
@@ -549,9 +531,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
+24 -4
View File
@@ -9,7 +9,9 @@ import (
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMouseClickSelectsThenAppliesCommandSuggestionRow(t *testing.T) {
@@ -251,6 +253,27 @@ func TestComposerMouseClickMovesCursor(t *testing.T) {
}
}
func TestComposerMouseClickMovesCursorBelowImageThumbnail(t *testing.T) {
m := mouseTestModel()
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.refreshPendingImageThumbnail()
m.input.SetValue("hello world")
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + 5
y := frame.composerRect.y + 1 + attachmentPreviewRows
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, x, y))
next := updated.(model)
if cmd != nil {
t.Fatal("composer click should not return a command")
}
if got := next.currentComposerState().cursor; got != 5 {
t.Fatalf("composer cursor below thumbnail = %d, want 5", got)
}
}
func TestComposerMouseDragSelectsCopiesAndClears(t *testing.T) {
m := mouseTestModel()
m.input.SetValue("hello world")
@@ -985,10 +1008,7 @@ func composerMousePoint(t *testing.T, m model, column int) (int, int) {
if frame.composerRect.height <= 0 {
t.Fatalf("expected visible composer rect, frame=%#v", frame)
}
contentY := 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY++
}
contentY := 1 + m.attachmentComposerPrefixRows(width)
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + column
y := frame.composerRect.y + contentY
return x, y
+55 -22
View File
@@ -22,13 +22,19 @@ type terminalOutputFile interface {
type petImageOutput struct {
output terminalOutputFile
renderer *terminalpet.ImageRenderer
renderDisabled bool
renderers []*terminalpet.ImageRenderer
renderDisabled []bool
mu sync.Mutex
}
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
return &petImageOutput{output: output, renderer: renderer}
func newPetImageOutput(output terminalOutputFile, renderers ...*terminalpet.ImageRenderer) *petImageOutput {
active := make([]*terminalpet.ImageRenderer, 0, len(renderers))
for _, renderer := range renderers {
if renderer != nil {
active = append(active, renderer)
}
}
return &petImageOutput{output: output, renderers: active, renderDisabled: make([]bool, len(active))}
}
func (o *petImageOutput) Read(value []byte) (int, error) {
@@ -39,11 +45,13 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
if leavingAltScreen && !kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return 0, err
if leavingAltScreen {
for _, renderer := range o.renderers {
if !rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return 0, err
}
}
}
}
if leavingAltScreen {
@@ -51,26 +59,36 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
if err != nil {
return written, err
}
if kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return written, err
for _, renderer := range o.renderers {
if rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return written, err
}
}
}
return written, nil
}
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
o.renderer.Invalidate()
for _, renderer := range o.renderers {
renderer.Invalidate()
}
}
var imageUpdate bytes.Buffer
if !o.renderDisabled {
if err := o.renderer.Render(&imageUpdate); err != nil {
// Companions are decorative. Disable them for this output session after
// a renderer failure instead of terminating the interactive shell.
o.renderDisabled = true
imageUpdate.Reset()
for index, renderer := range o.renderers {
if o.renderDisabled[index] {
continue
}
var rendererUpdate bytes.Buffer
if err := renderer.Render(&rendererUpdate); err != nil {
// Terminal images are decorative. Disable only the failing renderer for
// this output session so an attachment issue cannot hide a companion
// (or vice versa) or terminate the interactive shell.
o.renderDisabled[index] = true
continue
}
imageUpdate.Write(rendererUpdate.Bytes())
}
if imageUpdate.Len() == 0 {
return writeChecked(o.output, value)
@@ -120,6 +138,15 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
return written, endErr
}
func rendererUsesKitty(renderer *terminalpet.ImageRenderer) bool {
switch renderer.Support().Protocol {
case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile:
return true
default:
return false
}
}
func writeChecked(writer io.Writer, value []byte) (int, error) {
written, err := writer.Write(value)
if err == nil && written != len(value) {
@@ -180,9 +207,15 @@ func (o *petImageOutput) clearImage() error {
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
return err
}
if err := o.renderer.Clear(writer); err != nil {
return err
for _, renderer := range o.renderers {
if err := renderer.Clear(writer); err != nil {
return err
}
}
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
if len(o.renderers) == 0 {
return nil
}
ids := append([]uint32{petAmbientImageID, petPreviewImageID}, attachmentPreviewImageIDs()...)
return o.renderers[0].DeleteImages(writer, ids...)
})
}
+4 -10
View File
@@ -86,7 +86,7 @@ func (m model) handlePetsCommand(argument string) (tea.Model, tea.Cmd) {
m.petPreview = nil
m.picker = nil
m.petTickSeq++
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
}
m.petRequestedSlug = argument
localEntries, _ := m.petClient.InstalledEntries()
@@ -242,13 +242,13 @@ func (m model) installPet(slug string) (tea.Model, tea.Cmd) {
m.petName = ""
m.petAnimation = nil
m.petTickSeq++
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
}
entry, ok := m.petEntries[slug]
if !ok {
return m.appendSystemNotice(fmt.Sprintf("Pet %q is no longer in the catalog.", slug)), nil
}
m = m.appendSystemNotice("Installing " + entry.Label() + "…")
m = m.showTransientNoticeInline("Installing "+entry.Label()+"…", transientNoticeInfo)
return m, func() tea.Msg {
animation, err := m.petClient.Install(m.ctx, entry)
return petInstalledMsg{entry: entry, animation: animation, err: err}
@@ -270,7 +270,7 @@ func (m model) applyPetInstall(msg petInstalledMsg) (tea.Model, tea.Cmd) {
m.petPlaybackState = terminalpet.Idle
m.petClickAnimationIndex = 0
m.petOutcome = terminalpet.Idle
m = m.appendSystemNotice(msg.entry.Label() + " is now your terminal companion. Use /pets off to hide it.")
m = m.showTransientNoticeInline(msg.entry.Label()+" is now your terminal companion. Use /pets off to hide it.", transientNoticeSuccess)
if m.reducedMotion {
return m, nil
}
@@ -396,9 +396,6 @@ func (m model) petLayoutActive() bool {
return false
}
layoutWidth := m.width
if m.sidebarActive() {
layoutWidth = m.chatColumnWidth()
}
if !m.altScreen || layoutWidth < petImageColumns+4 || m.height < petImageRows+8 || m.subchat.active || m.transcriptDetailed {
return false
}
@@ -615,9 +612,6 @@ func resizePetCoordinate(value, oldMaximum, newMaximum, edgeThreshold int) int {
}
func (m model) petHomePosition(width, height int) (int, int) {
if m.sidebarActive() {
width = m.chatColumnWidth()
}
maxX := maxInt(0, width-petImageColumns)
maxY := maxInt(0, height-petImageRows)
return clampInt(width-petImageColumns-2, 0, maxX), clampInt(height-petImageRows-1, 0, maxY)
+4 -4
View File
@@ -614,7 +614,7 @@ func TestAmbientPetRetainsSingleColumnComposerDock(t *testing.T) {
}
}
func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
func TestAmbientPetDoesNotChangeFullWidthFooterGeometry(t *testing.T) {
m := sidebarTestModel()
m.width, m.height = 120, 34
frame := image.NewNRGBA(image.Rect(0, 0, 12, 12))
@@ -639,12 +639,12 @@ func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
}
runes := []rune(line)
dockEdge := m.chatColumnWidth() - petReservedColumns - 1
if len(runes) < m.width || runes[dockEdge] != '╮' || runes[m.chatColumnWidth()+1] != '│' {
t.Fatalf("pet changed the normal full-height sidebar geometry: %q", line)
if len(runes) < m.width || runes[dockEdge] != '╮' {
t.Fatalf("pet changed the normal full-width composer geometry: %q", line)
}
return
}
t.Fatal("sidebar composer top not found")
t.Fatal("composer top not found")
}
func TestDockedPetFollowsSidebarLayoutWithoutBecomingFreePositioned(t *testing.T) {
+51 -23
View File
@@ -522,9 +522,43 @@ func (m model) recentModelPairsForPicker() []config.RecentModelEntry {
pairs := make([]config.RecentModelEntry, 0, len(m.recentModels)+1)
pairs = append(pairs, config.RecentModelEntry{Provider: m.providerName, Model: m.modelName})
pairs = append(pairs, m.recentModels...)
for index := range pairs {
pairs[index] = m.canonicalRecentModelPair(pairs[index])
}
return config.NormalizeRecentModels(pairs)
}
// canonicalRecentModelPair collapses legacy OpenAI-prefixed model IDs for a
// ChatGPT profile. ChatGPT's Codex endpoint uses bare model IDs, while older
// hand-entered configuration could retain the equivalent openai/<id> spelling.
// Keep this picker-only so OpenAI-compatible gateway IDs remain untouched.
func (m model) canonicalRecentModelPair(pair config.RecentModelEntry) config.RecentModelEntry {
pair.Provider = strings.TrimSpace(pair.Provider)
pair.Model = strings.TrimSpace(pair.Model)
if !m.recentPairUsesChatGPT(pair.Provider) {
return pair
}
if len(pair.Model) >= len("openai/") && strings.EqualFold(pair.Model[:len("openai/")], "openai/") {
pair.Model = strings.TrimSpace(pair.Model[len("openai/"):])
}
return pair
}
func (m model) recentPairUsesChatGPT(providerName string) bool {
if profile, ok := m.savedProviderByName(providerName); ok {
if descriptor, hasDescriptor := m.descriptorForProfile(profile); hasDescriptor {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
}
if !strings.EqualFold(strings.TrimSpace(providerName), strings.TrimSpace(m.providerName)) {
return false
}
if descriptor, ok := m.activeProviderDescriptor(); ok {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
return providercatalog.NormalizeID(m.providerProfile.CatalogID) == "chatgpt"
}
// modelPickerRecentItem resolves one "Recent" row for a provider+model pair,
// tagging it with OwnerProvider so choosing it can switch providers (like any
// other cross-provider picker row) even when it isn't the active provider.
@@ -1008,45 +1042,39 @@ func (m model) newEffortPicker() *commandPicker {
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
// newThemePicker lists the terminal-native system theme followed by named palettes
// in one flat list. Old dark/light preferences migrate to System rather than
// appearing as choices: they would imply a terminal-canvas change that Zero
// deliberately does not make.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
for _, name := range themeModes {
item := pickerItem{Label: name, Value: name}
if name == string(themeSystem) {
item.Label = "System"
} else if entry, ok := lookupTheme(name); ok {
item.Label = entry.Label
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
items = append(items, item)
if name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
return &commandPicker{kind: pickerTheme, title: "Choose a theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
// pickerMoved advances the open picker's cursor by delta. Theme candidates render
// only in the picker preview; their active palette is applied only after Enter.
// Safe to call with no picker open. Callers mutate through m.picker (a pointer),
// so the value receiver is fine.
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
if m.picker == nil {
return m, nil
}
m.picker.move(delta)
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+29 -5
View File
@@ -532,8 +532,8 @@ func TestModelPickerAppliesActiveProviderCatalogModelID(t *testing.T) {
if next.modelName != "openai/gpt-4.1" {
t.Fatalf("active model = %q, want raw OpenRouter model ID", next.modelName)
}
if !transcriptContains(next.transcript, "openai/gpt-4.1 ·") {
t.Fatalf("expected model switch status, got %#v", next.transcript)
if !strings.Contains(next.transientNotice.text, "openai/gpt-4.1") {
t.Fatalf("model switch notice = %q", next.transientNotice.text)
}
}
@@ -570,6 +570,30 @@ func TestRecentModelPairsForPickerPinsActiveDedupesAndCaps(t *testing.T) {
}
}
func TestRecentModelPairsForPickerCanonicalizesChatGPTOpenAIModelAliases(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-sol",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
},
RecentModels: []config.RecentModelEntry{
{Provider: "chatgpt", Model: "openai/gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
},
})
got := m.recentModelPairsForPicker()
want := []config.RecentModelEntry{
{Provider: "chatgpt", Model: "gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("recentModelPairsForPicker() = %#v, want %#v", got, want)
}
}
// The picker's cross-group de-dup must key on provider+model, not model id
// alone: the same model id can be offered by two different providers, and
// those are distinct, independently selectable rows (issue: recents should
@@ -930,8 +954,8 @@ func TestModelPickerNavigatesAndChoosesAppliesHandler(t *testing.T) {
if m.modelName != "claude-haiku-4.5" {
t.Fatalf("expected model switched to claude-haiku-4.5 via handler, got %q", m.modelName)
}
if !transcriptContains(m.transcript, "Model") {
t.Fatal("choosing should append the model handler's status text")
if !strings.Contains(m.transientNotice.text, "Model:") {
t.Fatalf("model picker notice = %q", m.transientNotice.text)
}
}
@@ -963,7 +987,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// Bare /theme opens the theme popup (contained preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
+7 -208
View File
@@ -1,15 +1,10 @@
// plan_panel.go renders the sticky plan panel for the Zero TUI. The panel
// surfaces the in-progress task plan produced by the update_plan tool: a
// one-line header with a live spinner and progress count, a text progress
// bar, and (while running or expanded) the per-step list with status icons
// and timings. planPanelState tracks per-step start/completion timestamps
// across the tool's full-replacement updates so durations stay stable as
// steps transition between pending, in_progress, completed, and failed.
// plan_panel.go maintains the current task-plan state for run details and
// step drill-in. The update_plan result itself renders as a transcript
// checklist, while planPanelState preserves per-step timestamps across the
// tool's full-replacement updates for the contextual detail view.
package tui
import (
"fmt"
"strings"
"time"
"github.com/Gitlawb/zero/internal/tools"
@@ -27,11 +22,10 @@ type planStep struct {
completedAt time.Time
}
// planPanelState holds the sticky plan panel's view state. It lives on the
// model and is synced from the update_plan tool's CurrentPlan() output.
// planPanelState holds the latest plan state. It lives on the model and is
// synced from the update_plan tool's CurrentPlan() output.
type planPanelState struct {
steps []planStep
expanded bool
completedAt time.Time // set once all steps reach a terminal status
startedAt time.Time // set on the first non-empty update
// frozenAt freezes the live plan clock while the agent is idle (between
@@ -41,10 +35,6 @@ type planPanelState struct {
frozenAt time.Time
}
// completedHideAfter is how long a finished plan stays pinned before the
// collapsed panel hides itself to reclaim screen space.
const completedHideAfter = 30 * time.Second
// updateFromItems syncs the planStep slice from the update_plan tool. The
// tool replaces the entire plan each call, so steps are matched by content
// to preserve start/completion timestamps. Timestamps are filled in for
@@ -128,10 +118,9 @@ func (s *planPanelState) updateFromItems(items []tools.PlanItem, now time.Time)
}
}
// clear resets all plan panel state (steps, expansion, timestamps).
// clear resets all plan state and timestamps.
func (s *planPanelState) clear() {
s.steps = nil
s.expanded = false
s.completedAt = time.Time{}
s.startedAt = time.Time{}
s.frozenAt = time.Time{}
@@ -194,18 +183,6 @@ func (s *planPanelState) completeRemaining(now time.Time) {
}
}
// visible reports whether renderPlanPanel should emit anything. A finished
// plan hides itself once completedHideAfter has elapsed, unless expanded.
func (s planPanelState) visible(now time.Time) bool {
if s.isEmpty() {
return false
}
if s.isComplete() && !s.expanded && !s.completedAt.IsZero() && now.Sub(s.completedAt) > completedHideAfter {
return false
}
return true
}
// planNow returns the clock used for live plan durations. While the agent is
// idle (no run in flight, activeRunID == 0) it freezes at the moment the last
// run ended, so an in_progress step left mid-plan when the agent yields (e.g.
@@ -218,128 +195,6 @@ func (m model) planNow() time.Time {
return m.now()
}
// renderPlanPanel renders the full sticky plan panel. It returns an empty
// string when the plan is empty or when a finished plan has been collapsed
// past completedHideAfter without being expanded.
func (m model) renderPlanPanel(width int) string {
if width < 20 {
width = 20
}
state := m.plan
now := m.planNow()
if !state.visible(now) {
return ""
}
total := len(state.steps)
done := 0
for _, step := range state.steps {
if step.status == "completed" || step.status == "failed" {
done++
}
}
elapsed := time.Duration(0)
if !state.startedAt.IsZero() {
elapsed = now.Sub(state.startedAt)
}
// The header already carries done/total and the per-step ✓/•/✗ icons convey
// progress; a separate filled █/░ bar restates it and reads heavier than the
// reference agents, so it's dropped.
header := renderPlanHeader(state, m.spinnerGlyph(), done, total, elapsed)
lines := []string{header}
showSteps := state.expanded || !state.isComplete()
if showSteps {
maxContent := width - 15
if maxContent < 4 {
maxContent = 4
}
for _, step := range state.steps {
lines = append(lines, renderPlanStepLine(step, now, maxContent))
}
}
return strings.Join(lines, "\n")
}
// renderPinnedPlanPanel renders the plan for the pinned slot above the
// composer. It returns the full panel when it fits within maxHeight, otherwise
// a one-line summary (so a long plan can't crowd out the transcript or the
// input). maxHeight <= 0 means "no budget" and always uses the summary line.
// Returns "" when the plan is not visible. Ctrl+P (expand) still forces the
// full list via m.plan.expanded, but the height budget always wins to keep the
// composer on screen.
func (m model) renderPinnedPlanPanel(width int, maxHeight int) string {
// The pinned panel is only for terminals where the context sidebar CANNOT
// host the plan (too narrow / inline mode). Whenever the two-column layout is
// available — whether the sidebar is shown OR collapsed with Ctrl+B — the
// plan's home is the sidebar, so suppress the pinned copy: a Ctrl+B hide
// should hide the plan entirely, not resurrect it above the composer. Gating
// on sidebarAvailable (not sidebarActive) covers the hidden case too. The
// hidePinnedPlan flag additionally suppresses it in the two-column chat-column
// copy. Both the view and the mouse-geometry frame call footerView, so this
// stays consistent.
if m.hidePinnedPlan || m.sidebarAvailable() {
return ""
}
if !m.plan.visible(m.now()) {
return ""
}
full := m.renderPlanPanel(width)
if full == "" {
return ""
}
if maxHeight > 0 && strings.Count(full, "\n")+1 <= maxHeight {
return full
}
return m.renderPlanSummaryLine(width)
}
// renderPlanSummaryLine renders the collapsed one-line plan summary used when
// the full panel won't fit the pinned slot: a spinner/check, the done/total
// count, and the current (or first incomplete) step, truncated to width.
func (m model) renderPlanSummaryLine(width int) string {
if width < 20 {
width = 20
}
state := m.plan
total := len(state.steps)
done := 0
current := ""
for _, step := range state.steps {
switch step.status {
case "completed", "failed":
done++
case "in_progress":
if current == "" {
current = step.content
}
}
}
if current == "" {
// No in-progress step: show the first not-yet-done step, else the first.
for _, step := range state.steps {
if step.status != "completed" && step.status != "failed" {
current = step.content
break
}
}
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN · %d/%d complete", done, total))
}
label := fmt.Sprintf("%s PLAN · %d/%d · ", m.spinnerGlyph(), done, total)
room := width - len([]rune(label)) - 1
if room < 4 {
room = 4
}
return zeroTheme.accent.Render(label + truncateStep(current, room))
}
// currentStepContent returns the content of the step the plan is "on": the
// in_progress step, else the first step not yet in a terminal status, else the
// first step. Used by the header so the title names the step actually being
@@ -361,62 +216,6 @@ func currentStepContent(steps []planStep) string {
return ""
}
// renderPlanHeader builds the single header line. While running it shows the
// live spinner, the truncated first step, the done/total count, and the
// elapsed time in the accent color; once complete it shows a green check and
// "PLAN COMPLETE".
func renderPlanHeader(state planPanelState, spinnerView string, done, total int, elapsed time.Duration) string {
// Show the step the plan is currently ON (in_progress, else first incomplete),
// not always step 1 — otherwise the header text never advances and a running
// plan reads as stuck even while the done/total count climbs.
current := ""
if total > 0 {
current = truncateStep(currentStepContent(state.steps), 40)
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN COMPLETE · %d/%d · %s", done, total, formatElapsedSeconds(elapsed)))
}
return zeroTheme.accent.Render(fmt.Sprintf("%s PLAN · %s · %d/%d · %s", spinnerView, current, done, total, formatElapsedSeconds(elapsed)))
}
// renderPlanStepLine renders one step row: an indent, a status icon, the
// (truncated) content styled by status, and a duration where applicable.
// Completed/failed steps show the startedAt→completedAt span; an in_progress
// step shows the elapsed time since it started; pending steps show no time.
func renderPlanStepLine(step planStep, now time.Time, maxContent int) string {
content := truncateStep(step.content, maxContent)
var icon, body, timeStr string
switch step.status {
case "completed":
icon = zeroTheme.green.Render("✓")
// Quiet the completed body (muted, not saturated green) so the single
// in-progress accent step is the obvious focus: past=quiet, now=bright,
// future=faint. The green ✓ icon still marks success.
body = zeroTheme.muted.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
case "in_progress":
icon = zeroTheme.accent.Render("•")
body = zeroTheme.accent.Render(content)
started := step.startedAt
if started.IsZero() {
started = now
}
timeStr = formatElapsedSeconds(now.Sub(started))
case "failed":
icon = zeroTheme.red.Render("✗")
body = zeroTheme.red.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
default: // pending
icon = zeroTheme.faint.Render("○")
body = zeroTheme.faint.Render(content)
}
line := " " + icon + " " + body
if timeStr != "" {
line += " " + zeroTheme.faint.Render(timeStr)
}
return line
}
// truncateStep caps s to max runes, appending an ellipsis when truncated.
func truncateStep(s string, max int) string {
if max <= 0 {
-186
View File
@@ -1,8 +1,6 @@
package tui
import (
"fmt"
"strings"
"testing"
"time"
@@ -112,92 +110,6 @@ func TestUpdateFromItemsDuplicateContentKeepsDistinctTimestamps(t *testing.T) {
}
}
func TestPlanPanelHeight(t *testing.T) {
var s planPanelState
now := time.Now()
// Empty plan: height 0
if h := s.height(80, now); h != 0 {
t.Errorf("empty plan height = %d, want 0", h)
}
// Running plan with 3 steps: 2 (header+bar) + 3 (steps) = 5
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "in_progress"},
{Content: "C", Status: "pending"},
}, now)
if h := s.height(80, now); h != 5 {
t.Errorf("running plan height = %d, want 5", h)
}
// Completed plan collapsed: 2 (header+bar only)
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "completed"},
{Content: "C", Status: "completed"},
}, now)
if h := s.height(80, now); h != 2 {
t.Errorf("completed collapsed plan height = %d, want 2", h)
}
// Expanded: 2 + 3 = 5
s.expanded = true
if h := s.height(80, now); h != 5 {
t.Errorf("expanded plan height = %d, want 5", h)
}
}
func TestPlanPanelVisibleHidesAfterTimeout(t *testing.T) {
var s planPanelState
t0 := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
}, t0)
// Visible right after completion
if !s.visible(t0.Add(10 * time.Second)) {
t.Error("plan should be visible within 30s of completion")
}
// Hidden after 30s
if s.visible(t0.Add(31 * time.Second)) {
t.Error("plan should hide after 30s of completion when not expanded")
}
// Expanded keeps it visible
s.expanded = true
if !s.visible(t0.Add(31 * time.Second)) {
t.Error("expanded plan should stay visible")
}
}
func TestPlanPanelRenderEmpty(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
if got := m.renderPlanPanel(80); got != "" {
t.Errorf("empty plan should render empty string, got %q", got)
}
}
func TestPlanPanelRenderRunning(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
m.plan.updateFromItems([]tools.PlanItem{
{Content: "Read encrypt.go", Status: "completed"},
{Content: "Fix retry loop", Status: "in_progress"},
{Content: "Run tests", Status: "pending"},
}, base)
got := m.renderPlanPanel(80)
if got == "" {
t.Fatal("expected non-empty plan panel render")
}
}
// TestPlanNowFreezeAndResume covers the idle-freeze fix: while the agent is idle
// (activeRunID == 0) and a freeze time is stamped, the plan clock is frozen so an
// in_progress step left mid-plan stops ticking; during a run it tracks live time.
@@ -228,101 +140,3 @@ func TestPlanPanelClearResetsFrozenAt(t *testing.T) {
t.Fatal("clear() must reset frozenAt so the next run starts with a live clock")
}
}
// runningPlanModel builds a model with an n-step running plan for the pinned
// panel tests.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func TestPinnedPlanFullWhenItFits(t *testing.T) {
m := runningPlanModel(t, 3)
// 3 steps => header + 3 step lines = 4; budget 10 fits.
got := m.renderPinnedPlanPanel(80, 10)
if strings.Count(got, "\n")+1 < 4 {
t.Fatalf("expected full multi-line panel within budget, got:\n%s", got)
}
if !strings.Contains(got, "Step number 1") || !strings.Contains(got, "Step number 3") {
t.Fatalf("full panel should list every step, got:\n%s", got)
}
}
func TestPinnedPlanCollapsesToSummaryWhenTooTall(t *testing.T) {
m := runningPlanModel(t, 12)
// 12 steps would be 14 lines; budget 5 forces the one-line summary.
got := m.renderPinnedPlanPanel(80, 5)
if strings.Contains(got, "\n") {
t.Fatalf("over-budget plan should collapse to one line, got:\n%s", got)
}
if !strings.Contains(got, "PLAN") {
t.Fatalf("summary line should still show PLAN, got:\n%s", got)
}
}
func TestPinnedPlanHiddenWhenEmpty(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
if got := m.renderPinnedPlanPanel(80, 10); got != "" {
t.Fatalf("no plan => empty pinned render, got %q", got)
}
}
// TestPinnedPlanHiddenWhenSidebarToggledOff: Ctrl+B (sidebarHidden) on a wide
// alt-screen terminal hides the plan entirely — it must NOT fall back to the
// pinned panel above the composer, since the sidebar is the plan's home there.
func TestPinnedPlanHiddenWhenSidebarToggledOff(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
m.headerPrinted = true
// Real conversation so the sidebar is available (not gated by the home screen).
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.sidebarActive() {
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
}
// Sidebar shown: the plan lives in the sidebar, no pinned panel.
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Fatalf("sidebar shown: pinned plan should be suppressed, got:\n%s", got)
}
// Ctrl+B collapses the sidebar -> the plan must be hidden entirely, not pinned.
m.sidebarHidden = true
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Fatalf("Ctrl+B should hide the plan entirely, but the pinned panel showed:\n%s", got)
}
// Sidebar unavailable (no alt-screen): the pinned panel is the plan's only
// home and must still show.
if got := runningPlanModel(t, 3).renderPinnedPlanPanel(80, 10); got == "" {
t.Fatal("sidebar unavailable: the pinned plan must still show")
}
}
func TestFooterIncludesPinnedPlanAboveComposer(t *testing.T) {
m := runningPlanModel(t, 3)
m.height = 40
footer := plainRender(t, m.footerView(m.chatColumnWidth()))
planIdx := strings.Index(footer, "PLAN")
composerIdx := strings.Index(footer, "describe a task")
if planIdx < 0 {
t.Fatalf("footer should contain the pinned plan, got:\n%s", footer)
}
if composerIdx >= 0 && planIdx > composerIdx {
t.Fatalf("pinned plan should render ABOVE the composer, got:\n%s", footer)
}
}
-26
View File
@@ -120,32 +120,6 @@ func (m model) sidebarPlanSelectables(width int) []planStepHit {
return hits
}
// planStepAtMouse maps a left-click in the context sidebar to a plan step index,
// mirroring sidebarLineAtMouse's column/x gate.
func (m model) planStepAtMouse(msg tea.MouseMsg) (int, bool) {
if !m.sidebarActive() {
return 0, false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return 0, false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return 0, false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return 0, false
}
for _, hit := range m.sidebarPlanSelectables(sidebarW) {
if hit.lineOffset == y {
return hit.stepIndex, true
}
}
return 0, false
}
// planStepDetailRowID is the stable transcript id for the single plan-step
// detail card, so re-clicking toggles it instead of stacking duplicates.
const planStepDetailRowID = "plan/step-detail"
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more