mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72440a85bf | ||
|
|
ed38f57fab | ||
|
|
7e6f5e2a4a | ||
|
|
27b319ca88 | ||
|
|
1fde941803 | ||
|
|
7acac7acee | ||
|
|
1649008506 | ||
|
|
6fe0d1edcd | ||
|
|
e59bf78cab | ||
|
|
6645f9744b | ||
|
|
2749447cdc | ||
|
|
ad34dc8d81 | ||
|
|
6edf9a8b78 | ||
|
|
90dcfd127e | ||
|
|
01c3498205 | ||
|
|
1ec7219aee | ||
|
|
1a81292d3f | ||
|
|
a78c6c357e | ||
|
|
8dd33ec39e | ||
|
|
d7ac85cbf9 | ||
|
|
d66ad71532 | ||
|
|
d065467cd3 | ||
|
|
0eab63c908 | ||
|
|
64a783b08c | ||
|
|
9c292092a2 | ||
|
|
060d38ce71 | ||
|
|
04fd3c01a7 | ||
|
|
dc15e82298 | ||
|
|
2d2450e9a7 | ||
|
|
cabfeefc4e | ||
|
|
0c1de25a04 | ||
|
|
2458e0c67b | ||
|
|
027a67080b | ||
|
|
fb4f293c20 | ||
|
|
ccc42b94e7 | ||
|
|
2ec5c3ae6b | ||
|
|
ff62f73b7f | ||
|
|
0176ee49ff | ||
|
|
160e3bed1a | ||
|
|
7fbd864e91 | ||
|
|
020c28eaf9 | ||
|
|
21e1e8d072 | ||
|
|
936aa4e77c | ||
|
|
30f6d8f591 | ||
|
|
c52db055b0 | ||
|
|
55fa05f0ab | ||
|
|
1057d74512 | ||
|
|
f922cb378e | ||
|
|
7f39a63067 | ||
|
|
ff608c7834 | ||
|
|
57858a31d1 | ||
|
|
1e73ecae8a | ||
|
|
cae0269e77 | ||
|
|
cd0eb194e4 | ||
|
|
4ca4fa75d3 | ||
|
|
edf660ab85 | ||
|
|
91b413c514 | ||
|
|
021281ebba | ||
|
|
8e266797a8 | ||
|
|
d37de9214b | ||
|
|
588635487f | ||
|
|
845df6c7d2 | ||
|
|
15eb16f736 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.6.0"}
|
||||
{".":"0.8.0"}
|
||||
@@ -67,3 +67,86 @@ requirements. If a related check fails, fix it. If a required check cannot run
|
||||
because of the environment or fails for an unrelated external reason, report
|
||||
the exact failure and obtain maintainer direction rather than silently ignoring
|
||||
it.
|
||||
|
||||
## 4. Common Review Blockers
|
||||
|
||||
These classes drive multi-round reviews. Fix them before requesting review:
|
||||
|
||||
- **Fresh base:** Rebase onto the current PR base (`main` or stacked target)
|
||||
before review. A stale head that rolls back mainline commits is a hard
|
||||
blocker, not a merge-time detail. Resolve conflicts by keeping upstream
|
||||
behavior unless the PR intentionally changes it.
|
||||
- **Platform truth:** Code and tests must pass on Linux, macOS, and Windows.
|
||||
Compare paths after canonicalization (physical/`EvalSymlinks` form); never
|
||||
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
|
||||
paths on Windows). Prefer one cross-platform function with small `GOOS`
|
||||
checks over duplicated helpers that drift.
|
||||
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
|
||||
success and error paths (including stderr). Fail closed on ownership, lease,
|
||||
and permission checks. Do not rely on pre-open path resolution
|
||||
(`EvalSymlinks` then open) for containment: that is a check-to-use race.
|
||||
Bind containment at open/use time with rooted or handle-relative,
|
||||
traversal-resistant APIs. If a no-follow API is used, apply it to every
|
||||
traversed component and enforce the platform's reparse-point protections;
|
||||
final-component-only no-follow is insufficient. On multi-step setup, roll back
|
||||
only what this run created; never destroy pre-existing resources you did
|
||||
not create; never report success when cleanup or unlock failed.
|
||||
- **Normalize before matching:** Any transform that REMOVES bytes without
|
||||
leaving a gap is also a reassembler, so it has to run before whatever matches
|
||||
on the result. Redacting by shape and then stripping control bytes lets a
|
||||
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
|
||||
fragments and be rejoined on the way out; comparing a path against a form
|
||||
produced by the same resolver the kernel just used makes a redirect agree with
|
||||
itself. Strip, decode, or canonicalize first, then match. The unsplit value
|
||||
passing is not evidence, so the test needs a split case.
|
||||
- **Atomic shared state:** Write a complete temporary file, then atomically
|
||||
replace the destination so concurrent readers never see a partial write.
|
||||
Exclusive create or a write lock alone is not enough for readers. Serialize
|
||||
the full read-modify-write sequence for lockfiles and shared stores.
|
||||
- **Tests match the claim:** Every behavior or security-boundary change needs a
|
||||
regression test, including the failure path. Path-sensitive logic needs at
|
||||
least one non-Linux path case (or a hermetic fake that exercises the same
|
||||
normalization).
|
||||
- **Honest scope:** PR description, help text, and comments must match what
|
||||
shipped. Wire advertised entry points or shrink the claim. Do not bundle
|
||||
unrelated fixes. Do not widen security allowlists by name alone; check
|
||||
classification and side effects too.
|
||||
- **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.
|
||||
@@ -6,6 +6,63 @@ 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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent:** add PermissionModePlan for interactive read-only planning ([#853](https://github.com/Gitlawb/zero/issues/853)) ([0176ee4](https://github.com/Gitlawb/zero/commit/0176ee49ffddcff6f7edf6ae2ade4967e5b22cc3))
|
||||
* **providers:** live model lists for OpenRouter and OpenGateway ([#860](https://github.com/Gitlawb/zero/issues/860)) ([fb4f293](https://github.com/Gitlawb/zero/commit/fb4f293c20eb860ca388a10d22fd566ce282ba1f))
|
||||
* **tools:** give tool results an image channel and add view_image ([#843](https://github.com/Gitlawb/zero/issues/843)) ([cd0eb19](https://github.com/Gitlawb/zero/commit/cd0eb194e4df4b844785796c0701216740a56344))
|
||||
* **tui:** replace /retitle with local /rename ([#826](https://github.com/Gitlawb/zero/issues/826)) ([5886354](https://github.com/Gitlawb/zero/commit/588635487f22ef4d62d350140a219b7b8bb24119))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** end the additional_permissions retry loop ([#864](https://github.com/Gitlawb/zero/issues/864)) ([4ca4fa7](https://github.com/Gitlawb/zero/commit/4ca4fa75d31b139dbe55a3badf17da86892ab001))
|
||||
* **agent:** tell the user a prefix approval leaves the sandbox ([#885](https://github.com/Gitlawb/zero/issues/885)) ([2ec5c3a](https://github.com/Gitlawb/zero/commit/2ec5c3ae6b8fb543887bdeafdb34c4da057b8d36))
|
||||
* **cli:** report MCP servers that failed to start ([#822](https://github.com/Gitlawb/zero/issues/822)) ([#827](https://github.com/Gitlawb/zero/issues/827)) ([021281e](https://github.com/Gitlawb/zero/commit/021281ebbab3f2bbe6a77b2dab4bf121bcfe6b52))
|
||||
* **lsp:** dispatch notifications off read loop ([#759](https://github.com/Gitlawb/zero/issues/759)) ([936aa4e](https://github.com/Gitlawb/zero/commit/936aa4e77cf49738560a89395b8ba4d22263a396))
|
||||
* **mcp:** name the non-text blocks a tool result drops ([#874](https://github.com/Gitlawb/zero/issues/874)) ([027a670](https://github.com/Gitlawb/zero/commit/027a67080bea511b876c284bc911ef011eceb308))
|
||||
* **sandbox:** deny reads of Zero credential stores ([#681](https://github.com/Gitlawb/zero/issues/681)) ([55fa05f](https://github.com/Gitlawb/zero/commit/55fa05f0ab5a01b495345d43464606cd19a31b4f))
|
||||
* **sandbox:** stop the Windows write jail honouring Everyone-granted paths ([#865](https://github.com/Gitlawb/zero/issues/865)) ([91b413c](https://github.com/Gitlawb/zero/commit/91b413c514cc1607d5b4e7936780d40569239814))
|
||||
* **secrets,worktrees:** fix secret redaction leakage and prune stale worktrees ([#855](https://github.com/Gitlawb/zero/issues/855)) ([ff62f73](https://github.com/Gitlawb/zero/commit/ff62f73b7f4dc5e2472db21acba43cf6140c06d1))
|
||||
* **specialist:** make overwrites atomic ([#757](https://github.com/Gitlawb/zero/issues/757)) ([30f6d8f](https://github.com/Gitlawb/zero/commit/30f6d8f591a0b81638b4182ff56c86c8a50a5c70))
|
||||
* suppress gosec G204 warning for trusted local config commands ([#848](https://github.com/Gitlawb/zero/issues/848)) ([c52db05](https://github.com/Gitlawb/zero/commit/c52db055b0b213758a33c5c5627a411e052f8de4))
|
||||
* **tui:** a copied NUL byte no longer panics the TUI on Windows ([#876](https://github.com/Gitlawb/zero/issues/876)) ([cae0269](https://github.com/Gitlawb/zero/commit/cae0269e77e7a0a493cf799b41c653c9ff9a19de)), closes [#875](https://github.com/Gitlawb/zero/issues/875)
|
||||
* **tui:** exclude permission waits from turn time ([#820](https://github.com/Gitlawb/zero/issues/820)) ([15eb16f](https://github.com/Gitlawb/zero/commit/15eb16f736f903c94203207032f98aacbcf06318))
|
||||
* **tui:** fall back to CellMotion where AllMotion is unreliable ([#872](https://github.com/Gitlawb/zero/issues/872)) ([7fbd864](https://github.com/Gitlawb/zero/commit/7fbd864e91433441489902571ea394ebc5b7bd0d))
|
||||
* **tui:** fill alt-screen frame with theme panel background ([#850](https://github.com/Gitlawb/zero/issues/850)) ([020c28e](https://github.com/Gitlawb/zero/commit/020c28eaf95e2f38853f99fc06a13cd1568bf3c9))
|
||||
* **tui:** hide unavailable sidebar shortcut ([#821](https://github.com/Gitlawb/zero/issues/821)) ([845df6c](https://github.com/Gitlawb/zero/commit/845df6c7d28e02f475e90acfe838733eb37e6aa2))
|
||||
* use OAuth credentials in provider health checks ([#828](https://github.com/Gitlawb/zero/issues/828)) ([d37de92](https://github.com/Gitlawb/zero/commit/d37de9214b1ba86542f4f5ef3b060bb96849763b))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* compact common test output ([#846](https://github.com/Gitlawb/zero/issues/846)) ([edf660a](https://github.com/Gitlawb/zero/commit/edf660ab85131f90a452cee51dc2c3a5cf62d632))
|
||||
* improve harness context efficiency ([#838](https://github.com/Gitlawb/zero/issues/838)) ([8e26679](https://github.com/Gitlawb/zero/commit/8e266797a8305e689806eadcf9698da2796481ab))
|
||||
|
||||
## [0.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
<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>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
@@ -75,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
|
||||
@@ -212,6 +213,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/new` | start a fresh session in place (previous session stays on disk) |
|
||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
@@ -308,7 +310,8 @@ zero verify detect and run local verification checks
|
||||
zero changes inspect and commit local git changes
|
||||
zero usage token usage and estimated cost
|
||||
zero cron scheduled agent jobs
|
||||
zero update check for newer releases
|
||||
zero update --check check for newer releases
|
||||
zero upgrade download, verify, and install the latest release
|
||||
```
|
||||
|
||||
## Extending Zero
|
||||
@@ -368,9 +371,13 @@ go run ./cmd/zero-perf-bench
|
||||
|
||||
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
||||
session (background connection prewarm + request-prefix telemetry) for headless
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
|
||||
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
|
||||
variable unset and set.
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
|
||||
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
|
||||
with the variable unset and set.
|
||||
|
||||
Native ChatGPT Responses sessions are enabled by default. Set
|
||||
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
|
||||
transport.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
@@ -415,6 +422,8 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
## Community
|
||||
|
||||
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
|
||||
+20
-3
@@ -6,8 +6,9 @@
|
||||
|
||||
<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>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
@@ -56,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
|
||||
@@ -141,6 +142,7 @@ zero
|
||||
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||
| `/image` | 为视觉模型附加图片 |
|
||||
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||
| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
|
||||
| `/compact`、`/context` | 管理上下文使用 |
|
||||
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||
@@ -226,7 +228,8 @@ zero verify 检测和运行本地验证检查
|
||||
zero changes 检查和提交本地 Git 变更
|
||||
zero usage Token 使用量和估算成本
|
||||
zero cron 定时智能体任务
|
||||
zero update 检查更新版本
|
||||
zero update --check 检查更新版本
|
||||
zero upgrade 下载、验证并安装最新版本
|
||||
```
|
||||
|
||||
## 外观和无障碍
|
||||
@@ -269,6 +272,20 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
- [性能](docs/PERFORMANCE.md)
|
||||
- [智能体评估](docs/AGENT_EVALS.md)
|
||||
|
||||
## 社区
|
||||
|
||||
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
|
||||
|
||||
提问、安装帮助、想法和分享都在
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
| 分类 | 用途 |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
|
||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
|
||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
|
||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
|
||||
|
||||
+1
-1
@@ -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`).
|
||||
+86
-10
@@ -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
|
||||
|
||||
@@ -234,10 +289,31 @@ directory.
|
||||
|
||||
## Updating
|
||||
|
||||
Check for a newer release:
|
||||
Check for a newer release, then download, verify, and install it in place:
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
zero upgrade
|
||||
```
|
||||
|
||||
Then reinstall with npm or rerun the install script for the version you want.
|
||||
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
|
||||
|
||||
`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.
|
||||
@@ -138,3 +138,45 @@ output or stop a still-running task by id.
|
||||
If Zero is restarted while a background task is still marked `running`, the new
|
||||
manager marks that task `error` and clears its PID. This avoids sending
|
||||
`TaskStop` to a stale PID that may now belong to an unrelated process.
|
||||
|
||||
## Recovering an Interrupted Overwrite
|
||||
|
||||
Zero writes and flushes a complete temporary file before publishing an overwrite,
|
||||
so a write failure before publication leaves the existing manifest unchanged
|
||||
instead of truncating it. On Unix, publication uses a same-directory rename and
|
||||
preserves the existing file's permission bits.
|
||||
|
||||
On Windows, Zero uses `ReplaceFileW` to preserve the destination DACL instead of
|
||||
silently replacing it with the temporary file's inherited DACL. `ReplaceFileW`
|
||||
is not observer-atomic: another process can briefly observe the destination path
|
||||
as absent during replacement. Zero serializes specialist loads and managed
|
||||
mutations within one process, but cannot synchronize external processes or
|
||||
editors.
|
||||
|
||||
Windows errors 1176 (`ERROR_UNABLE_TO_MOVE_REPLACEMENT`) and 1177
|
||||
(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) are partial replacement failures. With
|
||||
Zero's managed backup, 1176 leaves the original names intact and needs no manual
|
||||
recovery. For 1177, Zero has moved the original aside and tries to move it back.
|
||||
That rollback almost always succeeds, and the failed write changes nothing.
|
||||
|
||||
If the rollback itself fails — typically because another process is holding a
|
||||
lock on the file — the original is not lost, but it is left under a name Zero
|
||||
does not read:
|
||||
|
||||
```text
|
||||
<specialist dir>/.zero-replace-<random>.backup
|
||||
```
|
||||
|
||||
Only `*.md` files are loaded as specialists, so until that file is renamed the
|
||||
specialist will not appear in `zero specialist list` or resolve by name. Zero's
|
||||
error message includes both the backup path and destination path. Recover by
|
||||
closing whatever holds the lock and renaming the backup back:
|
||||
|
||||
```powershell
|
||||
Move-Item .zero-replace-<random>.backup <name>.md
|
||||
```
|
||||
|
||||
A `.zero-replace-*.backup` can also linger after a *successful* overwrite if the
|
||||
backup could not be deleted afterward. That case is reported as a warning rather
|
||||
than an error — the new manifest is already in place, and the leftover file is
|
||||
safe to delete.
|
||||
+75
-7
@@ -1,16 +1,23 @@
|
||||
# Update Flow
|
||||
|
||||
`zero update --check` checks the latest GitHub release and compares it with the
|
||||
local CLI version.
|
||||
local CLI version. `zero update --apply` (or its shorthand, `zero upgrade`)
|
||||
downloads, verifies, and installs it.
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
zero update --check --json
|
||||
zero update --check --repo Gitlawb/zero
|
||||
zero update --check --target windows-x64
|
||||
|
||||
zero upgrade
|
||||
zero update --apply
|
||||
```
|
||||
|
||||
The command is intentionally check-only:
|
||||
`--check` and `--apply` are mutually exclusive. `zero update` requires one of
|
||||
them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
||||
|
||||
`--check` is check-only:
|
||||
|
||||
- It does not replace the running binary.
|
||||
- It exits with code `0` when the check succeeds, even when an update is
|
||||
@@ -19,14 +26,35 @@ The command is intentionally check-only:
|
||||
- `--json` prints the same result in a machine-readable format for scripts and
|
||||
CI.
|
||||
|
||||
`--apply` installs the update in place:
|
||||
|
||||
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
|
||||
- Standalone installs download the release archive, verify its checksum,
|
||||
extract it, and atomically replace the running binary plus any installed
|
||||
optional sandbox helpers.
|
||||
- On Windows, the running executable is renamed aside and cleaned up on the
|
||||
next `zero update --apply` or `zero upgrade`, since it can't be overwritten
|
||||
while running.
|
||||
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
|
||||
since applying always installs onto the current machine.
|
||||
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
|
||||
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
|
||||
takes its release from the npm registry, not from GitHub. They still apply to
|
||||
`--check` there.
|
||||
- `--json` serializes Zero's final result. For npm-managed installs, npm may
|
||||
also write progress output to stdout, so neither
|
||||
`zero update --apply --json` nor `zero upgrade --json` is guaranteed to
|
||||
produce a single parseable JSON document. Use `--check --json` for
|
||||
machine-readable automation.
|
||||
|
||||
Useful flags:
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--repo <owner/repo>` | Check another GitHub repository. |
|
||||
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
|
||||
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
|
||||
| `--endpoint <url\|owner/repo>` | Use a specific release API URL or repository slug for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
|
||||
| `--timeout <duration>` | Override the default release check timeout. |
|
||||
| `--target <platform-arch>` | Validate release metadata for another supported target. |
|
||||
| `--target <platform-arch>` | Validate release metadata for another supported target (`--check` only). |
|
||||
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
||||
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
||||
@@ -40,5 +68,45 @@ Endpoint resolution order:
|
||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
|
||||
before reinstalling.
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||
instead of reinstalling.
|
||||
|
||||
## Windows recovery state (standalone installs)
|
||||
|
||||
This section describes Windows only. On Linux and macOS a standalone update
|
||||
renames the staged file directly over the executable path through the
|
||||
installation directory's file descriptor, so the replacement is atomic and no
|
||||
aside copy, marker, or recovery record is ever created — a failed update leaves
|
||||
the previous binary in place and nothing to resolve.
|
||||
|
||||
On Windows, a running executable cannot be replaced in place, so the previous
|
||||
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
|
||||
records that exact file — bound to its filesystem identity, in per-user state
|
||||
outside the installation directory — and deletes only that recorded copy after
|
||||
the next update is verified in place. Backups it did not create are never
|
||||
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
|
||||
recorded copy that something else holds open (an on-access scanner, an editor)
|
||||
stays recorded and is removed by a later update instead.
|
||||
|
||||
An update **refuses to run** while unresolved recovery state exists beside the
|
||||
binary:
|
||||
|
||||
| State on disk | Meaning |
|
||||
|---|---|
|
||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
|
||||
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
|
||||
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
|
||||
|
||||
The refusal names the paths involved and the two moves that end the state:
|
||||
either move the recovery binary back over the executable path, or — if the
|
||||
installed binary is the one you want — delete the `.keep` marker (or the
|
||||
`.recovery` copy, once you have verified the installed binary) and update again.
|
||||
|
||||
This is deliberately fail-closed and differs from older releases, which deleted
|
||||
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
|
||||
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
|
||||
and make every subsequent update refuse until an operator clears them. That is
|
||||
the safe direction: the alternative is an update that overwrites the only
|
||||
verified copy of the previous binary. If updates start refusing, inspect those
|
||||
files before removing them — an installation directory that a lower-privileged
|
||||
account can write to is itself worth fixing.
|
||||
@@ -1,25 +1,27 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.5
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/bubbletea/v2 v2.0.9
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sys v0.47.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
||||
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
@@ -62,6 +64,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
|
||||
+30
-6
@@ -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) {
|
||||
@@ -349,9 +363,11 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
sess.turnMu.Lock()
|
||||
defer sess.turnMu.Unlock()
|
||||
mode := agent.PermissionMode(p.ModeID)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
return SetSessionModeResult{}, nil
|
||||
@@ -381,9 +397,13 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
|
||||
return nil, err
|
||||
}
|
||||
case configIDMode:
|
||||
// Same turnMu as handleSetMode so the two advertised mode doors (session
|
||||
// set_mode and set_config_option) serialize mode flips consistently.
|
||||
sess.turnMu.Lock()
|
||||
defer sess.turnMu.Unlock()
|
||||
mode := agent.PermissionMode(p.Value)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
case agent.PermissionModeUnsafe:
|
||||
@@ -439,13 +459,16 @@ func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
// ---- advertising helpers ----
|
||||
|
||||
func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
|
||||
// handleSetMode) so a client can't grant itself no-prompt host access.
|
||||
// auto/ask/plan are offered over ACP; Unsafe is gated to the operator (see
|
||||
// handleSetMode) so a client can't grant itself no-prompt host access. Plan
|
||||
// only narrows what a client can do (read-only, no write/shell tools), so
|
||||
// unlike Unsafe there is no elevation risk in letting a client select it.
|
||||
return &SessionModeState{
|
||||
CurrentModeID: string(s.currentMode()),
|
||||
AvailableModes: []SessionMode{
|
||||
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
{ID: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -516,6 +539,7 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||
Options: []SessionConfigOptionValue{
|
||||
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
{Value: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -393,6 +393,32 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
|
||||
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("configured mode = %q", got)
|
||||
}
|
||||
// Plan is accepted: it only narrows capability (read-only), so unlike Unsafe
|
||||
// there is no elevation risk in letting a client select it.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
|
||||
t.Fatalf("set_mode plan: %v", err)
|
||||
}
|
||||
// Configuration path is a separate contract from MethodSessionSetMode: the
|
||||
// mode option is advertised via configOptions and applied by handleSetConfigOption.
|
||||
var planConfigured SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModePlan),
|
||||
}, &planConfigured); err != nil {
|
||||
t.Fatalf("set_config_option plan: %v", err)
|
||||
}
|
||||
if got := planConfigured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModePlan) {
|
||||
t.Fatalf("configured mode after plan = %q, want plan", got)
|
||||
}
|
||||
hasPlanOption := false
|
||||
for _, opt := range planConfigured.ConfigOptions[1].Options {
|
||||
if opt.Value == string(agent.PermissionModePlan) {
|
||||
hasPlanOption = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPlanOption {
|
||||
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
|
||||
}
|
||||
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected Unsafe mode to be rejected over ACP")
|
||||
@@ -403,6 +429,37 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
|
||||
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
|
||||
// the same gap this test's TUI counterpart covers for /plan on.
|
||||
func TestACPPlanModeWiresPermissionModeIntoAgentOptions(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
captured = opts
|
||||
return agent.Result{FinalAnswer: "ok"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
|
||||
t.Fatalf("set_mode plan: %v", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("plan it out")}}, &PromptResult{}); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if captured.PermissionMode != agent.PermissionModePlan {
|
||||
t.Fatalf("agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
|
||||
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
|
||||
// shell tools run confined, not unconfined on the host.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
-22
@@ -15,17 +15,10 @@ 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)
|
||||
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
|
||||
if kind == "" {
|
||||
continue // skip actions that have no clean ACP option (e.g. cancel)
|
||||
}
|
||||
@@ -38,21 +31,61 @@ 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).
|
||||
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||
//
|
||||
// escalates reports that approving a command prefix will also run the command
|
||||
// outside the sandbox. The two prefix options say so in their label, since the
|
||||
// label is all an ACP client shows and the option id carries no room for it. An
|
||||
// editor user is deciding on exactly the same terms as a terminal user, so the
|
||||
// disclosure cannot be TUI-only.
|
||||
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
|
||||
switch action {
|
||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||
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:
|
||||
return PermAllowAlways, "Allow this command for the session"
|
||||
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
|
||||
case agent.PermissionDecisionAlwaysAllow:
|
||||
return PermAllowAlways, "Always allow"
|
||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||
return PermAllowAlways, "Always allow this command"
|
||||
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
|
||||
case agent.PermissionDecisionDeny:
|
||||
return PermRejectOnce, "Reject"
|
||||
case agent.PermissionDecisionCancel:
|
||||
@@ -64,19 +97,31 @@ func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||
|
||||
// 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:
|
||||
@@ -84,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
|
||||
}
|
||||
}
|
||||
@@ -123,3 +168,13 @@ func rawInputBytes(data []byte) json.RawMessage {
|
||||
}
|
||||
return json.RawMessage(data)
|
||||
}
|
||||
|
||||
// withSandboxEscalationNote appends the escalation disclosure to a prefix
|
||||
// option's label. Kept parallel to the TUI's wording so the same consequence is
|
||||
// not described two different ways depending on which client the user runs.
|
||||
func withSandboxEscalationNote(name string, escalates bool) string {
|
||||
if !escalates {
|
||||
return name
|
||||
}
|
||||
return name + " (runs outside the sandbox)"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func escalationRequest(escalates bool) agent.PermissionRequest {
|
||||
return agent.PermissionRequest{
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowPrefix,
|
||||
agent.PermissionDecisionAlwaysAllowPrefix,
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
PrefixApprovalEscalates: escalates,
|
||||
}
|
||||
}
|
||||
|
||||
// An ACP client renders only Name, so if the escalation is not in the label the
|
||||
// editor user approves it blind. They face exactly the outcome a terminal user
|
||||
// faces, so the disclosure has to reach both clients or it is not a fix.
|
||||
func TestACPPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
|
||||
found := 0
|
||||
for _, option := range buildPermissionOptions(escalationRequest(true)) {
|
||||
action := agent.PermissionDecisionAction(option.OptionID)
|
||||
if action != agent.PermissionDecisionAllowPrefix && action != agent.PermissionDecisionAlwaysAllowPrefix {
|
||||
if strings.Contains(option.Name, "sandbox") {
|
||||
t.Errorf("option %q carries the disclosure but does not escalate", option.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if !strings.Contains(option.Name, "outside the sandbox") {
|
||||
t.Errorf("prefix option %q does not say the command leaves the sandbox", option.Name)
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Fatalf("expected both prefix options, saw %d", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
|
||||
for _, option := range buildPermissionOptions(escalationRequest(false)) {
|
||||
if strings.Contains(option.Name, "sandbox") {
|
||||
t.Errorf("option %q warns about the sandbox when approving would not leave it", option.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The disclosure must live in the label only. The option id is the decision
|
||||
// action round-tripped verbatim, and decisionFromOutcome matches it against
|
||||
// what was offered, so decorating it would fail every prefix approval closed.
|
||||
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
|
||||
request := escalationRequest(true)
|
||||
options := buildPermissionOptions(request)
|
||||
for _, option := range options {
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
|
||||
options,
|
||||
)
|
||||
if string(decision.Action) != option.OptionID {
|
||||
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,29 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type normalizationRecordingShellTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *normalizationRecordingShellTool) Name() string { return "bash" }
|
||||
func (tool *normalizationRecordingShellTool) Description() string {
|
||||
return "record normalized shell arguments"
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: true}
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
@@ -107,9 +127,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "missing additional_permissions", result.Output)
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
@@ -144,3 +162,237 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// runShellCall drives one shell tool call through the real registry (and so
|
||||
// through Registry.RunWithOptions) exactly as the agent loop would.
|
||||
func runShellCall(t *testing.T, root string, arguments string) ToolResult {
|
||||
t.Helper()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedExecCommandTool(root, nil, nil))
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "exec_command", Arguments: arguments},
|
||||
PermissionModeUnsafe,
|
||||
Options{Cwd: root},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TestAdditionalPermissionsRejectionsConverge is the regression guard for
|
||||
// #845/#847. A model that attaches a permission object to a command needing
|
||||
// none used to be told only "set sandbox_permissions", which pushed it into the
|
||||
// OTHER invalid shape (flag set, payload empty) and its "include a permission"
|
||||
// error pushed it straight back — the reported loop that burned turns on
|
||||
// `git branch --show-current`.
|
||||
//
|
||||
// The contract this pins is convergence, not tolerance: both shapes are still
|
||||
// REJECTED (nothing here grants access), but each rejection must name the one
|
||||
// follow-up call that works, and that follow-up must actually succeed.
|
||||
func TestAdditionalPermissionsRejectionsConverge(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const readOnly = `"cmd":"git --version"`
|
||||
|
||||
// Step 1 of the reported loop: a permission object with no flag.
|
||||
first := runShellCall(t, root, `{`+readOnly+`,"additional_permissions":{"network":{"enabled":true}}}`)
|
||||
if first.Status != tools.StatusError {
|
||||
t.Fatalf("unflagged additional_permissions status = %q, want error (rejecting is correct)", first.Status)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "unflagged additional_permissions", first.Output)
|
||||
|
||||
// Step 2 of the reported loop: the model keeps the flag but empties the
|
||||
// payload. This must not be the dead end it was.
|
||||
second := runShellCall(t, root, `{`+readOnly+`,"sandbox_permissions":"with_additional_permissions"}`)
|
||||
if second.Status != tools.StatusError {
|
||||
t.Fatalf("flag without payload status = %q, want error", second.Status)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "flag without payload", second.Output)
|
||||
|
||||
// The call both errors point at must actually work.
|
||||
converged := runShellCall(t, root, `{`+readOnly+`}`)
|
||||
if converged.Status != tools.StatusOK {
|
||||
t.Fatalf("the call both errors instruct the model to make failed: status=%q output=%q", converged.Status, converged.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNamesOmitRemedy pins the actionable half of the error text: the message
|
||||
// must spell out that BOTH fields can simply be dropped. "Set the flag" or
|
||||
// "include a permission" alone is what made the two errors chase each other.
|
||||
func assertNamesOmitRemedy(t *testing.T, label string, output string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(output, "omit both sandbox_permissions and additional_permissions") {
|
||||
t.Fatalf("%s error = %q, want it to say to omit both fields entirely", label, output)
|
||||
}
|
||||
if !strings.Contains(output, "retry") {
|
||||
t.Fatalf("%s error = %q, want it to tell the model to retry", label, output)
|
||||
}
|
||||
}
|
||||
|
||||
// The empty-payload rejection carries the same remedy. It is pinned on the
|
||||
// validator directly because normalizeNoopAdditionalPermissions absorbs the
|
||||
// empty payload before executeToolCall reaches this branch;
|
||||
// inlineAdditionalPermissionsProfile is still the shared source of truth that
|
||||
// buildPermissionEvent and the grant path re-run, so its text must converge too.
|
||||
func TestEmptyAdditionalPermissionsErrorNamesOmitRemedy(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"cmd": "git --version",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": false},
|
||||
"file_system": map[string]any{"read": []any{}, "write": []any{}},
|
||||
},
|
||||
}
|
||||
_, requested, err := inlineAdditionalPermissionsProfile(args, t.TempDir())
|
||||
if !requested {
|
||||
t.Fatal("requested = false, want true for an explicit with_additional_permissions call")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("an empty additional_permissions payload was accepted; it must still be rejected")
|
||||
}
|
||||
assertNamesOmitRemedy(t, "empty payload", err.Error())
|
||||
}
|
||||
|
||||
// A provider that materializes every optional property may send
|
||||
// "additional_permissions": null. That grants nothing and carries no flag, so it
|
||||
// is the field omitted — rejecting it stalled an ordinary command behind an
|
||||
// error the model could not act on.
|
||||
func TestExecuteToolCallTreatsNullAdditionalPermissionsAsOmitted(t *testing.T) {
|
||||
result := runShellCall(t, t.TempDir(), `{"cmd":"git --version","additional_permissions":null}`)
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("null additional_permissions was rejected: status=%q output=%q", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A null payload alongside the flag must still be rejected, and must land on
|
||||
// the same error as an absent payload rather than being quietly downgraded.
|
||||
func TestExecuteToolCallRejectsFlaggedNullAdditionalPermissions(t *testing.T) {
|
||||
result := runShellCall(t, t.TempDir(),
|
||||
`{"cmd":"git --version","sandbox_permissions":"with_additional_permissions","additional_permissions":null}`)
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("flagged null status = %q, want error", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "no additional_permissions object was provided") {
|
||||
t.Fatalf("output = %q, want the missing-payload error", result.Output)
|
||||
}
|
||||
assertNamesOmitRemedy(t, "flagged null", result.Output)
|
||||
}
|
||||
|
||||
func TestNormalizeProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
||||
for _, mode := range []string{
|
||||
string(tools.SandboxPermissionsUseDefault),
|
||||
string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
} {
|
||||
args := map[string]any{
|
||||
"command": "./zero --version",
|
||||
"sandbox_permissions": mode,
|
||||
"additional_permissions": map[string]any{
|
||||
"file_system": map[string]any{
|
||||
"deny_read": []any{},
|
||||
"entries": []any{},
|
||||
"read": []any{},
|
||||
"write": []any{},
|
||||
},
|
||||
"network": map[string]any{"enabled": false},
|
||||
},
|
||||
}
|
||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("mode %q retained empty additional_permissions: %#v", mode, args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("mode %q normalized to %q, want use_default", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallNormalizesProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
||||
tool := &normalizationRecordingShellTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tool)
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"./zero --version","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"file_system":{"deny_read":[],"entries":[],"read":[],"write":[]},"network":{"enabled":false}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{Cwd: t.TempDir()},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("provider-expanded empty permissions were rejected: %s", result.Output)
|
||||
}
|
||||
if len(tool.calls) != 1 {
|
||||
t.Fatalf("shell tool calls = %d, want 1", len(tool.calls))
|
||||
}
|
||||
args := tool.calls[0]
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("executeToolCall retained empty additional_permissions: %#v", args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("sandbox mode = %q, want use_default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdditionalPermissionsPreservesRealRequest(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
||||
if _, exists := args["additional_permissions"]; !exists {
|
||||
t.Fatal("non-empty additional_permissions was removed")
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsWithAdditionalPermissions) {
|
||||
t.Fatalf("sandbox mode changed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdditionalPermissionsRemovesWorkspaceReadAlreadyCoveredBySandbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
})
|
||||
args := map[string]any{
|
||||
"command": "./zero --version",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"file_system": map[string]any{
|
||||
"entries": []any{
|
||||
map[string]any{
|
||||
"access": "read",
|
||||
"path": map[string]any{
|
||||
"type": "path",
|
||||
"path": ".",
|
||||
},
|
||||
},
|
||||
},
|
||||
"deny_read": []any{},
|
||||
"read": []any{},
|
||||
"write": []any{},
|
||||
},
|
||||
"network": map[string]any{"enabled": false},
|
||||
},
|
||||
}
|
||||
|
||||
normalizeNoopAdditionalPermissions(args, root, engine)
|
||||
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("already-covered additional_permissions was retained: %#v", args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("sandbox mode changed to %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+265
-29
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -38,8 +40,9 @@ const compactionTriggerRatio = 0.7
|
||||
// transcript (and so tests can assert on it).
|
||||
const summaryLabel = "[Summary of earlier conversation]"
|
||||
|
||||
// summaryInstructions is the system prompt handed to the summarizer model.
|
||||
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
// CompactionSummaryInstructions is the system prompt handed to both automatic
|
||||
// and manually requested compaction summarizers.
|
||||
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
|
||||
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
|
||||
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
||||
@@ -75,6 +78,12 @@ type CompactionResult struct {
|
||||
// summary message also includes summaryLabel and any preserved structured
|
||||
// state needed by later compactions.
|
||||
SummaryText string
|
||||
// ProjectedChars is the text content sent to the summarizer after semantic
|
||||
// projection, excluding provider protocol framing.
|
||||
ProjectedChars int
|
||||
// Truncated reports whether projection dropped content to satisfy its
|
||||
// bounded brief or previous-summary limits.
|
||||
Truncated bool
|
||||
// Compacted reports whether Messages contains an injected summary.
|
||||
Compacted bool
|
||||
}
|
||||
@@ -137,11 +146,17 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
for _, tool := range tools {
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
total += ApproxTextTokens(string(tool.Type))
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
if tool.Format != nil {
|
||||
total += ApproxTextTokens(tool.Format.Type)
|
||||
total += ApproxTextTokens(tool.Format.Syntax)
|
||||
total += ApproxTextTokens(tool.Format.Definition)
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
}
|
||||
return total
|
||||
@@ -216,11 +231,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary, err := opts.Summarize(middle)
|
||||
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
||||
if err != nil {
|
||||
return CompactionResult{}, err
|
||||
}
|
||||
summary = strings.TrimSpace(summary)
|
||||
summary := summaryResult.SummaryText
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
@@ -238,10 +253,53 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
RemovedCount: len(middle),
|
||||
PreservedCount: len(messages) - len(middle),
|
||||
SummaryText: summary,
|
||||
ProjectedChars: summaryResult.ProjectedChars,
|
||||
Truncated: summaryResult.Truncated,
|
||||
Compacted: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompactionSummaryResult describes the shared summarization input and output.
|
||||
type CompactionSummaryResult struct {
|
||||
SummaryText string
|
||||
ProjectedChars int
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// SummarizeCompactionMessages applies the shared semantic projection before
|
||||
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
|
||||
// and manual session compaction both use this path.
|
||||
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
|
||||
if summarize == nil {
|
||||
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
|
||||
}
|
||||
projection := projectCompactionInput(messages)
|
||||
summaryInput := projection.messages
|
||||
if len(summaryInput) == 0 {
|
||||
summaryInput = messages
|
||||
}
|
||||
summary, err := summarize(summaryInput)
|
||||
if err != nil {
|
||||
return CompactionSummaryResult{}, err
|
||||
}
|
||||
return CompactionSummaryResult{
|
||||
SummaryText: strings.TrimSpace(summary),
|
||||
ProjectedChars: compactionMessageChars(summaryInput),
|
||||
Truncated: projection.truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compactionMessageChars(messages []zeroruntime.Message) int {
|
||||
total := 0
|
||||
for _, message := range messages {
|
||||
total += len(message.Content)
|
||||
for _, call := range message.ToolCalls {
|
||||
total += len(call.Name) + len(call.Arguments)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
|
||||
// the preserved suffix begins on a user or assistant message rather than a
|
||||
// tool/tool_result message. A tool result with no preceding assistant tool call
|
||||
@@ -331,6 +389,8 @@ type compactionState struct {
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
planner *contextPlanner
|
||||
trace *trace.Recorder
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
@@ -338,6 +398,27 @@ type compactionState struct {
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
|
||||
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
||||
// (Options.Summarizer) for the current main model. summarizerProvider
|
||||
// memoizes the built provider and summarizerResolved records that the
|
||||
// factory ran (it may legitimately return no provider: the main model is
|
||||
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
||||
// build or call fails, the run stays on the main provider until the next
|
||||
// model switch — a misconfigured cheap model must never break (or
|
||||
// repeatedly delay) compaction.
|
||||
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
summarizerProvider Provider
|
||||
summarizerResolved bool
|
||||
summarizerBroken bool
|
||||
// window is the context window the threshold was derived from. The
|
||||
// reactive free-prune stage compares against it (the limit the provider
|
||||
// enforces), not against the 70% proactive threshold — a pruned history
|
||||
// between the two would still succeed on retry.
|
||||
window int
|
||||
// mainModel is the model the run currently streams on; the summarizer
|
||||
// choice is made against it and remade after a mid-run switch.
|
||||
mainModel string
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
@@ -372,15 +453,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
window: options.ContextWindow,
|
||||
mainModel: options.Model,
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
summarizerFactory: options.Summarizer,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// updateWindow re-derives the compaction threshold after a mid-run model
|
||||
// switch. The low-water mark is reset: it was measured against the old
|
||||
// threshold, and keeping it could suppress a compaction the smaller window
|
||||
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
||||
func (state *compactionState) updateWindow(window int) {
|
||||
if window <= 0 {
|
||||
return
|
||||
}
|
||||
state.enabled = true
|
||||
state.window = window
|
||||
state.threshold = compactionThreshold(window)
|
||||
state.lowWaterMark = 0
|
||||
if state.planner != nil {
|
||||
state.planner.config.contextWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// switchModel records a mid-run model switch: the window is re-derived
|
||||
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
||||
// the next compaction re-resolves it against the new main model. A run that
|
||||
// started on the cheap model (no dedicated summarizer) and escalated to an
|
||||
// expensive one therefore regains cheap summaries — the exact workflow a
|
||||
// dedicated summarizer exists for.
|
||||
func (state *compactionState) switchModel(modelID string, window int) {
|
||||
state.mainModel = modelID
|
||||
state.updateWindow(window)
|
||||
state.summarizerProvider = nil
|
||||
state.summarizerResolved = false
|
||||
state.summarizerBroken = false
|
||||
}
|
||||
|
||||
// summarizeProvider returns the provider summarization calls should use and
|
||||
// whether it is the dedicated summarizer: that one when it applies to the
|
||||
// current main model and is healthy, else the main provider. The factory runs
|
||||
// at most once per main model; a build error marks the summarizer broken for
|
||||
// that model. Callers branch on the boolean rather than comparing Provider
|
||||
// values — interface comparison panics on a non-comparable dynamic type.
|
||||
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
||||
if state.summarizerFactory == nil || state.summarizerBroken {
|
||||
return main, false
|
||||
}
|
||||
if !state.summarizerResolved {
|
||||
state.summarizerResolved = true
|
||||
built, err := state.summarizerFactory(ctx, state.mainModel)
|
||||
if err != nil {
|
||||
state.summarizerBroken = true
|
||||
return main, false
|
||||
}
|
||||
state.summarizerProvider = built
|
||||
}
|
||||
if state.summarizerProvider == nil {
|
||||
return main, false
|
||||
}
|
||||
return state.summarizerProvider, true
|
||||
}
|
||||
|
||||
// fitsWindow reports whether a calibrated size is safely under the limit the
|
||||
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
||||
// for the paid summarizer, not the hard limit; the reactive path compares
|
||||
// against the window itself, with a small margin for estimate error, so a
|
||||
// history pruned to, say, 85% of the window takes the free retry.
|
||||
func (state *compactionState) fitsWindow(size int) bool {
|
||||
if state.window <= 0 {
|
||||
return size <= state.threshold
|
||||
}
|
||||
return size <= state.window-state.window/20
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
|
||||
// when the history is under threshold, or when the history has not grown past
|
||||
@@ -413,6 +567,14 @@ func (state *compactionState) maybeCompact(
|
||||
// the bodies of old, large tool results (the model has already acted on
|
||||
// them). If that brings us back under threshold, skip the paid summarizer
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
}
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
@@ -424,7 +586,7 @@ func (state *compactionState) maybeCompact(
|
||||
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -466,16 +628,40 @@ func (state *compactionState) recover(
|
||||
// context-limit error never triggers an unexpected summarization call.
|
||||
return messages, false, nil
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
if !isContextLimitError(errorMessage) {
|
||||
return messages, false, nil
|
||||
}
|
||||
if !isContextLimitError(errorMessage) {
|
||||
|
||||
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
||||
// often be fixed for free by dropping superseded reads and pruning old
|
||||
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
||||
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
||||
// prune). The free retry is taken only when the pruned history fits the
|
||||
// window the provider enforces — otherwise a retry would fail the same
|
||||
// way, so fall through to the paid summarizer in this same call.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
prunedAny := false
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if prunedAny {
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if state.fitsWindow(size) {
|
||||
state.lowWaterMark = size
|
||||
return messages, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if state.reactiveAttempted {
|
||||
return messages, false, nil
|
||||
}
|
||||
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
@@ -509,9 +695,22 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
||||
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
||||
// call fails the summarizer is marked broken for the current model and the
|
||||
// same slice is retried once on the main provider, so the caller sees at most
|
||||
// one (main-provider-quality) error.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
||||
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
||||
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
if err == nil || !dedicated {
|
||||
return summary, err
|
||||
}
|
||||
state.summarizerBroken = true
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +722,11 @@ func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage
|
||||
// Non-context-limit errors (and a single message that still won't fit) surface
|
||||
// to the caller unchanged.
|
||||
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
|
||||
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
|
||||
}
|
||||
|
||||
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
|
||||
if err == nil {
|
||||
return summary, nil
|
||||
}
|
||||
@@ -532,11 +735,11 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
}
|
||||
|
||||
mid := len(messages) / 2
|
||||
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
|
||||
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
|
||||
if leftErr != nil {
|
||||
return "", leftErr
|
||||
}
|
||||
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
||||
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
||||
if rightErr != nil {
|
||||
return "", rightErr
|
||||
}
|
||||
@@ -549,7 +752,7 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
combined := strings.TrimSpace(left + "\n\n" + right)
|
||||
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
||||
}, onUsage)
|
||||
}, onUsage, planner, recorder)
|
||||
if reduceErr != nil {
|
||||
if isContextLimitError(reduceErr.Error()) {
|
||||
// Even the two combined partials don't fit (extreme): fall back to the
|
||||
@@ -565,15 +768,15 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
||||
}
|
||||
|
||||
// summarizeMessagesOnce performs a single tool-less summarization call.
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||
},
|
||||
// No tools: this is a plain text summarization call.
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||
requestMessages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
|
||||
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
|
||||
recordContextPlanTrace(recorder, plan)
|
||||
stream, err := provider.StreamCompletion(ctx, plan.Request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -590,8 +793,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
||||
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
||||
// search results), yet the summary instructions only need their gist — the
|
||||
// model that ACTED on the full output already turned it into decisions the
|
||||
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
||||
// head+tail of a result while cutting the summarizer input by 5-10x on
|
||||
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
||||
const summaryToolResultClamp = 2000
|
||||
|
||||
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
||||
// a write_file call carries the whole file body in its arguments.
|
||||
const summaryToolArgsClamp = 500
|
||||
|
||||
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
||||
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
||||
func clampForSummary(text string, max int) string {
|
||||
if max <= 0 || len(text) <= max {
|
||||
return text
|
||||
}
|
||||
head := max * 3 / 4
|
||||
tail := max - head
|
||||
for head > 0 && !utf8.RuneStart(text[head]) {
|
||||
head--
|
||||
}
|
||||
start := len(text) - tail
|
||||
for start < len(text) && !utf8.RuneStart(text[start]) {
|
||||
start++
|
||||
}
|
||||
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
||||
}
|
||||
|
||||
// renderTranscript flattens messages into a plain-text transcript for the
|
||||
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
||||
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
||||
// above): the summary needs what was done and decided, not the raw payloads.
|
||||
func renderTranscript(messages []zeroruntime.Message) string {
|
||||
lines := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
@@ -601,13 +837,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
|
||||
if len(message.ToolCalls) > 0 {
|
||||
calls := make([]string, 0, len(message.ToolCalls))
|
||||
for _, call := range message.ToolCalls {
|
||||
calls = append(calls, call.Name+"("+call.Arguments+")")
|
||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
||||
}
|
||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case zeroruntime.MessageRoleTool:
|
||||
lines = append(lines, "tool result: "+message.Content)
|
||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
||||
default:
|
||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// compactableHistory returns a history whose middle is large enough to trip
|
||||
// the proactive threshold for a 1000-token window.
|
||||
func compactableHistory() []zeroruntime.Message {
|
||||
return []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
|
||||
}
|
||||
}
|
||||
|
||||
// Summarization must run on the dedicated (cheap) summarizer when one is
|
||||
// configured — the main provider sees no summarize traffic at all.
|
||||
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
|
||||
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
main := &mockProvider{}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
|
||||
|
||||
if len(cheap.requests) == 0 {
|
||||
t.Fatal("dedicated summarizer never received the summarize call")
|
||||
}
|
||||
if len(main.requests) != 0 {
|
||||
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
|
||||
t.Fatal("compaction did not shrink the history")
|
||||
}
|
||||
found := false
|
||||
for _, message := range compacted {
|
||||
if strings.Contains(message.Content, "CHEAP SUMMARY") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cheap summarizer's summary missing from the compacted history")
|
||||
}
|
||||
}
|
||||
|
||||
// A failing summarizer falls back to the main provider for the SAME slice and
|
||||
// stays broken for the rest of the run — no repeated failed cheap calls.
|
||||
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
|
||||
|
||||
summary, err := summarize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback must succeed via the main provider: %v", err)
|
||||
}
|
||||
if summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, want the main provider's", summary)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
|
||||
}
|
||||
if !state.summarizerBroken {
|
||||
t.Fatal("a failed summarizer must be marked broken for the run")
|
||||
}
|
||||
|
||||
// Second summarization: straight to main, no cheap retry.
|
||||
if _, err := summarize(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
|
||||
}
|
||||
if len(main.requests) != 2 {
|
||||
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A factory that errors (misconfigured model) is equivalent to no summarizer:
|
||||
// main provider used, factory not retried.
|
||||
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return nil, errors.New("unknown model")
|
||||
},
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
|
||||
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("after a factory failure the run must stay on the main provider")
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool payloads are clamped in the summarizer transcript: results to
|
||||
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
|
||||
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
|
||||
bigResult := strings.Repeat("r", 50_000)
|
||||
bigArgs := strings.Repeat("w", 20_000)
|
||||
transcript := renderTranscript([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "c1", Name: "write_file", Arguments: bigArgs},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
|
||||
})
|
||||
|
||||
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
|
||||
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
|
||||
}
|
||||
if !strings.Contains(transcript, "chars omitted for summarization") {
|
||||
t.Fatal("clamped transcript must carry the omission marker")
|
||||
}
|
||||
if !strings.Contains(transcript, "keep going") {
|
||||
t.Fatal("non-tool content must survive unclamped")
|
||||
}
|
||||
// Head AND tail of the clamped payloads survive.
|
||||
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
|
||||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
|
||||
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// bigToolHistory returns a history with n large tool results (~40k estimated
|
||||
// tokens each): the trailing protection window keeps the newest verbatim,
|
||||
// the older ones are prunable.
|
||||
func bigToolHistory(n int) []zeroruntime.Message {
|
||||
bigBody := strings.Repeat("x", 160_000)
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
|
||||
for i := 0; i < n; i++ {
|
||||
id := string(rune('a' + i))
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
|
||||
)
|
||||
}
|
||||
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
|
||||
}
|
||||
|
||||
func summaryProvider(text string) *mockProvider {
|
||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
}
|
||||
|
||||
// A context-limit error is first answered with the free prune stage; when the
|
||||
// pruned history fits the window the retry is free (no summarizer call) and
|
||||
// the one-shot reactive budget is untouched. The paid summarizer only runs
|
||||
// when a later error finds nothing left to prune.
|
||||
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// The protected trailing tool output (~40k tokens) must fit the window.
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("prunable history that fits must produce a free retry")
|
||||
}
|
||||
if len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
t.Fatal("a free prune retry must not consume the reactive budget")
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
|
||||
// Second context-limit error on the pruned history: nothing meaningfully
|
||||
// prunable remains, so the paid summarizer fires and consumes the budget.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("second recover must compact and retry")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("second recover must reach the summarizer")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("a paid recover must consume the reactive budget")
|
||||
}
|
||||
}
|
||||
|
||||
// When pruning reclaims something but the pruned history still exceeds the
|
||||
// window, recover falls through to the paid summarizer in the SAME call
|
||||
// instead of spending a retry that would fail the same way.
|
||||
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// A 1,000-token window: even the protected trailing output overflows it.
|
||||
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("insufficient prune must still end in a retry via summarization")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("the paid summarizer consumes the reactive budget")
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(messages) {
|
||||
t.Fatal("recover did not shrink the history")
|
||||
}
|
||||
}
|
||||
|
||||
// The free prune stage is not gated behind the one-shot budget: after a paid
|
||||
// reactive compaction, a later context-limit error still gets a free retry
|
||||
// when pruning makes the history fit.
|
||||
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
state.reactiveAttempted = true
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
// With nothing left to prune and the budget spent, the caller keeps its
|
||||
// original error — recover neither loops nor summarizes.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil || retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
|
||||
}
|
||||
}
|
||||
|
||||
// The free-retry gate compares against the window the provider enforces, not
|
||||
// the 70% proactive threshold: a history pruned to ~80% of the window still
|
||||
// takes the free retry.
|
||||
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
messages := bigToolHistory(5)
|
||||
pruned, _ := pruneStaleToolOutput(messages, 2)
|
||||
prunedTokens := estimateTokens(pruned)
|
||||
// Choose a window so the pruned size sits between 70% and 95% of it.
|
||||
window := prunedTokens * 100 / 80
|
||||
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
|
||||
if prunedTokens <= state.threshold {
|
||||
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
|
||||
}
|
||||
|
||||
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// After a mid-run model switch the summarizer is re-resolved against the new
|
||||
// main model: a run that started on the cheap model (no dedicated summarizer)
|
||||
// and escalated gets a cheap summarizer, and a previously broken one is
|
||||
// retried for the new model.
|
||||
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
|
||||
cheap := summaryProvider("CHEAP SUMMARY")
|
||||
main := summaryProvider("MAIN SUMMARY")
|
||||
var factoryModels []string
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Model: "cheap-model",
|
||||
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
|
||||
factoryModels = append(factoryModels, mainModelID)
|
||||
if mainModelID == "cheap-model" {
|
||||
return nil, nil // main already is the cheap model
|
||||
}
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("on the cheap model the main provider must summarize")
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
|
||||
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
state.switchModel("big-model", 400_000)
|
||||
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
|
||||
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
|
||||
}
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
|
||||
}
|
||||
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
|
||||
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
// A broken summarizer is retried after the next switch.
|
||||
state.summarizerBroken = true
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("a broken summarizer must fall back to main")
|
||||
}
|
||||
state.switchModel("other-model", 0)
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionUpdateWindow(t *testing.T) {
|
||||
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
|
||||
state.lowWaterMark = 50_000
|
||||
before := state.threshold
|
||||
|
||||
state.updateWindow(0) // unknown model: unchanged
|
||||
if state.threshold != before || state.lowWaterMark != 50_000 {
|
||||
t.Fatal("updateWindow(0) must be a no-op")
|
||||
}
|
||||
|
||||
state.updateWindow(10_000)
|
||||
if state.threshold >= before {
|
||||
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
|
||||
}
|
||||
if state.lowWaterMark != 0 {
|
||||
t.Fatal("low-water mark must reset on a window change")
|
||||
}
|
||||
if !state.enabled {
|
||||
t.Fatal("a positive window must keep compaction enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: after escalate_model switches providers, the loop re-derives the
|
||||
// context window via ContextWindowFor, visible in the OnContext report.
|
||||
func TestRunEscalationRederivesContextWindow(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "big-model"})
|
||||
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
var windows []int
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
ContextWindow: 100_000,
|
||||
Model: "small-model",
|
||||
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
if modelID == "big-model" {
|
||||
return 400_000
|
||||
}
|
||||
return 0
|
||||
},
|
||||
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(windows) < 2 {
|
||||
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
|
||||
}
|
||||
if windows[0] != 100_000 {
|
||||
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
|
||||
}
|
||||
if windows[len(windows)-1] != 400_000 {
|
||||
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// nonComparableProvider has a slice field, so comparing two Provider interface
|
||||
// values holding it would panic; the summarizer path must never compare them.
|
||||
type nonComparableProvider struct {
|
||||
inner *mockProvider
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
return p.inner.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
|
||||
}, nil)
|
||||
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
|
||||
}
|
||||
// And with no dedicated summarizer at all the closure still works.
|
||||
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,14 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
||||
if result.SummaryText != "manual summary" {
|
||||
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
||||
}
|
||||
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
|
||||
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
|
||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
||||
}
|
||||
if result.Truncated {
|
||||
t.Fatal("Truncated = true for a projection within its limits")
|
||||
}
|
||||
if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") {
|
||||
t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured)
|
||||
}
|
||||
if len(result.Messages) != 4 {
|
||||
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
||||
@@ -96,11 +102,46 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
|
||||
if result.SummaryText != "" {
|
||||
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
|
||||
}
|
||||
if result.ProjectedChars != 0 || result.Truncated {
|
||||
t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Messages, messages) {
|
||||
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) {
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}}
|
||||
for index := range 20 {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)),
|
||||
})
|
||||
}
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"},
|
||||
)
|
||||
|
||||
var captured []zeroruntime.Message
|
||||
result, err := CompactMessages(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
captured = append([]zeroruntime.Message(nil), toSummarize...)
|
||||
return "bounded summary", nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted || !result.Truncated {
|
||||
t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated)
|
||||
}
|
||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
|
||||
@@ -102,7 +102,7 @@ type skillEntry struct {
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// recentEdits returns files mutated by editing calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
@@ -113,6 +113,7 @@ type skillEntry struct {
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
noteByPath := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
@@ -129,12 +130,24 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
sequence = append(sequence, path)
|
||||
}
|
||||
}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, path := range message.ChangedFiles {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
sequence = append(sequence, path)
|
||||
noteByPath[path] = editNote(message.Content)
|
||||
}
|
||||
}
|
||||
order := lastSeenOrder(sequence)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
@@ -425,15 +438,20 @@ type preservedState struct {
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
@@ -468,7 +486,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
|
||||
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
|
||||
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
|
||||
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
|
||||
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
@@ -484,6 +507,13 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
freshConstraints := explicitConstraintsFromMessages(middle)
|
||||
if len(freshConstraints) > 0 {
|
||||
if task == nil {
|
||||
task = &preservedTaskState{}
|
||||
}
|
||||
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -517,6 +547,95 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
return summary
|
||||
}
|
||||
|
||||
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
|
||||
var constraints []string
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
|
||||
continue
|
||||
}
|
||||
if _, body := projectInstructionBlock(message.Content); body != "" {
|
||||
continue
|
||||
}
|
||||
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
|
||||
}
|
||||
return constraints
|
||||
}
|
||||
|
||||
func priorTaskConstraints(task *preservedTaskState) []string {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Constraints
|
||||
}
|
||||
|
||||
func priorTaskChangedFiles(task *preservedTaskState) []string {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.ChangedFiles
|
||||
}
|
||||
|
||||
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.UnresolvedFailures
|
||||
}
|
||||
|
||||
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Approvals
|
||||
}
|
||||
|
||||
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Artifacts
|
||||
}
|
||||
|
||||
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
|
||||
resolvedSet := make(map[string]struct{}, len(resolved))
|
||||
for _, key := range resolved {
|
||||
resolvedSet[key] = struct{}{}
|
||||
}
|
||||
merged := make([]taskFailureState, 0, len(older)+len(newer))
|
||||
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
|
||||
if _, ok := resolvedSet[failure.Key]; ok {
|
||||
continue
|
||||
}
|
||||
withoutSameKey := make([]taskFailureState, 0, len(merged))
|
||||
for _, existing := range merged {
|
||||
if existing.Key != failure.Key {
|
||||
withoutSameKey = append(withoutSameKey, existing)
|
||||
}
|
||||
}
|
||||
merged = append(withoutSameKey, failure)
|
||||
}
|
||||
if len(merged) > maxTaskEvidenceEntries {
|
||||
merged = merged[len(merged)-maxTaskEvidenceEntries:]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
|
||||
merged := make([]T, 0, len(older)+len(newer))
|
||||
seen := make(map[T]struct{}, len(older)+len(newer))
|
||||
for _, value := range append(append([]T(nil), older...), newer...) {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
merged = append(merged, value)
|
||||
}
|
||||
if len(merged) > limit {
|
||||
merged = merged[len(merged)-limit:]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,9 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
|
||||
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
|
||||
}})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
@@ -80,6 +84,69 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
|
||||
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
|
||||
task := newTaskState("Please keep the change focused.", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||
}})
|
||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
|
||||
}})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||
}})
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
)
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: task.snapshotForCompaction(secondInput),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
|
||||
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
|
||||
}
|
||||
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
|
||||
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
|
||||
}
|
||||
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
|
||||
if !containsString(state.Task.Constraints, want) {
|
||||
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const (
|
||||
compactionUserWordBudget = 256
|
||||
compactionAssistantWordBudget = 200
|
||||
compactionToolCallsPerTurn = 8
|
||||
compactionToolArgumentBytes = 120
|
||||
compactionErrorBytes = 150
|
||||
compactionResultBytes = 1200
|
||||
compactionPreviousSummaryBytes = 16 * 1024
|
||||
compactionBriefMaxBytes = 24 * 1024
|
||||
)
|
||||
|
||||
var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`)
|
||||
|
||||
type compactionProjection struct {
|
||||
messages []zeroruntime.Message
|
||||
truncated bool
|
||||
}
|
||||
|
||||
// projectCompactionInput removes reconstructible tool output before the model
|
||||
// summary call. It retains a bounded transcript of user intent, assistant
|
||||
// decisions, tool actions, and concise errors. Exact durable state is appended
|
||||
// separately from the original messages after the summary returns.
|
||||
func projectCompactionInput(messages []zeroruntime.Message) compactionProjection {
|
||||
sections := make([]string, 0, len(messages))
|
||||
previousSummary := ""
|
||||
truncated := false
|
||||
for index, message := range messages {
|
||||
switch message.Role {
|
||||
case zeroruntime.MessageRoleUser:
|
||||
content := message.Content
|
||||
if marker := strings.Index(content, preservedStateLabel); marker >= 0 {
|
||||
content = content[:marker]
|
||||
}
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if strings.HasPrefix(trimmed, summaryLabel) {
|
||||
fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel))
|
||||
previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes)
|
||||
truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes
|
||||
continue
|
||||
}
|
||||
if content = clipInformativeWords(content, compactionUserWordBudget); content != "" {
|
||||
sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content))
|
||||
}
|
||||
case zeroruntime.MessageRoleAssistant:
|
||||
lines := make([]string, 0, 1+len(message.ToolCalls))
|
||||
if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" {
|
||||
lines = append(lines, content)
|
||||
}
|
||||
calls := message.ToolCalls
|
||||
omitted := 0
|
||||
if len(calls) > compactionToolCallsPerTurn {
|
||||
omitted = len(calls) - compactionToolCallsPerTurn
|
||||
calls = calls[omitted:]
|
||||
}
|
||||
if omitted > 0 {
|
||||
lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted))
|
||||
}
|
||||
for _, call := range calls {
|
||||
lines = append(lines, compactionToolCallLine(call))
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
|
||||
}
|
||||
case zeroruntime.MessageRoleTool:
|
||||
// New tool-result messages carry the execution status directly, as the
|
||||
// source ToolResult does. Keep the text check for older session history
|
||||
// created before Message exposed IsError.
|
||||
name := toolNameForResult(messages, index)
|
||||
mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0
|
||||
if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = "tool"
|
||||
}
|
||||
switch {
|
||||
case message.IsError || isLikelyToolError(message.Content):
|
||||
sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
||||
case name == "ask_user":
|
||||
sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes)))
|
||||
default:
|
||||
sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name,
|
||||
clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sections) == 0 && previousSummary == "" {
|
||||
return compactionProjection{}
|
||||
}
|
||||
brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n"))
|
||||
truncated = truncated || briefTruncated
|
||||
if previousSummary != "" {
|
||||
brief = "[previous summary]\n" + previousSummary + "\n\n" + brief
|
||||
}
|
||||
return compactionProjection{
|
||||
messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}},
|
||||
truncated: truncated,
|
||||
}
|
||||
}
|
||||
|
||||
func capCompactionBrief(brief string) (string, bool) {
|
||||
if len(brief) <= compactionBriefMaxBytes {
|
||||
return brief, false
|
||||
}
|
||||
marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n"
|
||||
available := compactionBriefMaxBytes - len(marker)
|
||||
// Keep both chronological edges: the oldest material has not necessarily
|
||||
// been summarized before, while the newest material is most actionable.
|
||||
headBytes := available * 2 / 5
|
||||
tailBytes := available - headBytes
|
||||
head := clipPrefixAtBoundary(brief, headBytes)
|
||||
tail := clipSuffixAtBoundary(brief, tailBytes)
|
||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true
|
||||
}
|
||||
|
||||
func compactionToolCallLine(call zeroruntime.ToolCall) string {
|
||||
detail := ""
|
||||
if call.Freeform && call.Name == "apply_patch" {
|
||||
detail = strings.Join(patchPaths(call.Arguments), ", ")
|
||||
}
|
||||
var arguments map[string]any
|
||||
if detail == "" && json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
|
||||
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
detail = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if detail == "" && call.Name == "ask_user" {
|
||||
detail = askUserQuestionsDetail(arguments)
|
||||
}
|
||||
if detail == "" && call.Name == "apply_patch" {
|
||||
if patch, ok := arguments["patch"].(string); ok {
|
||||
detail = strings.Join(patchPaths(patch), ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
detail = commandFromArguments(call.Arguments)
|
||||
}
|
||||
if detail == "" {
|
||||
return "* " + call.Name
|
||||
}
|
||||
return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes))
|
||||
}
|
||||
|
||||
func isLikelyToolError(content string) bool {
|
||||
prefix := strings.TrimSpace(content)
|
||||
if len(prefix) > compactionErrorBytes {
|
||||
prefix = prefix[:compactionErrorBytes]
|
||||
}
|
||||
lower := strings.ToLower(prefix)
|
||||
for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clipInformativeWords(text string, limit int) string {
|
||||
flat := strings.Join(strings.Fields(text), " ")
|
||||
if flat == "" || limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
count := 0
|
||||
for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) {
|
||||
word := strings.ToLower(flat[location[0]:location[1]])
|
||||
if compactionStopWords[word] {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > limit {
|
||||
end := location[0]
|
||||
return strings.TrimSpace(flat[:end]) + "...(truncated)"
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
func clipBytes(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit <= 3 {
|
||||
end := limit
|
||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
return text[:end]
|
||||
}
|
||||
end := limit - 3
|
||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
return strings.TrimSpace(text[:end]) + "..."
|
||||
}
|
||||
|
||||
func clipHeadTailBytes(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
marker := "\n...[middle omitted]...\n"
|
||||
if limit <= len(marker) {
|
||||
return clipBytes(text, limit)
|
||||
}
|
||||
available := limit - len(marker)
|
||||
head := clipPrefixAtBoundary(text, available/2)
|
||||
tail := clipSuffixAtBoundary(text, available-available/2)
|
||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail)
|
||||
}
|
||||
|
||||
func clipPrefixAtBoundary(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
end := limit
|
||||
for end > 0 && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 {
|
||||
end = boundary
|
||||
}
|
||||
return text[:end]
|
||||
}
|
||||
|
||||
func clipSuffixAtBoundary(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
start := len(text) - limit
|
||||
for start < len(text) && text[start]&0xc0 == 0x80 {
|
||||
start++
|
||||
}
|
||||
if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 {
|
||||
start += boundary + 2
|
||||
}
|
||||
return text[start:]
|
||||
}
|
||||
|
||||
func askUserQuestionsDetail(arguments map[string]any) string {
|
||||
raw, ok := arguments["questions"].([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
questions := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" {
|
||||
questions = append(questions, strings.TrimSpace(question))
|
||||
}
|
||||
}
|
||||
return strings.Join(questions, " | ")
|
||||
}
|
||||
|
||||
func patchPaths(patch string) []string {
|
||||
var paths []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(patch, "\n") {
|
||||
for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
if path != "" && !seen[path] {
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
var compactionStopWords = map[string]bool{
|
||||
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true,
|
||||
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
|
||||
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
|
||||
"should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true,
|
||||
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true,
|
||||
"by": true, "from": true, "as": true, "into": true, "through": true, "during": true,
|
||||
"before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true,
|
||||
"and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true,
|
||||
"both": true, "either": true, "neither": true, "each": true, "every": true, "all": true,
|
||||
"any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true,
|
||||
"that": true, "this": true, "these": true, "those": true, "it": true, "its": true,
|
||||
"i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true,
|
||||
"he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true,
|
||||
"who": true, "which": true, "what": true, "if": true, "then": true, "than": true,
|
||||
"when": true, "where": true, "how": true, "just": true, "also": true,
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
|
||||
for _, limit := range []int{0, 1, 2, 3} {
|
||||
if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) {
|
||||
t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) {
|
||||
for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} {
|
||||
for _, limit := range []int{-1, 0} {
|
||||
if got := clip("世界", limit); got != "" {
|
||||
t.Fatalf("clipper limit %d = %q, want empty", limit, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
|
||||
largeBody := strings.Repeat("recoverable source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"},
|
||||
}
|
||||
|
||||
projection := projectCompactionInput(messages)
|
||||
projected := projection.messages
|
||||
if len(projected) != 1 {
|
||||
t.Fatalf("projection = %#v, want one compact message", projected)
|
||||
}
|
||||
content := projected[0].Content
|
||||
for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") {
|
||||
t.Fatalf("projection retained reconstructible or overlong tool output: %q", content)
|
||||
}
|
||||
if estimateTokens(projected)*20 >= estimateTokens(messages) {
|
||||
t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
|
||||
}
|
||||
|
||||
projected := projectCompactionInput(messages).messages
|
||||
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
|
||||
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
|
||||
{ID: "raw-patch", Name: "apply_patch", Freeform: true, Arguments: "*** Begin Patch\n*** Update File: internal/api.go\n*** Add File: internal/config.go\n*** End Patch\n"},
|
||||
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
|
||||
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}},
|
||||
}
|
||||
|
||||
content := projectCompactionInput(messages).messages[0].Content
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "internal/api.go", "internal/config.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
|
||||
calls := make([]zeroruntime.ToolCall, 12)
|
||||
for index := range calls {
|
||||
calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`}
|
||||
}
|
||||
projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages
|
||||
content := projected[0].Content
|
||||
if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") {
|
||||
t.Fatalf("tool-call tail was not bounded: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) {
|
||||
previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision"
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}}
|
||||
for index := range 500 {
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)})
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))})
|
||||
}
|
||||
|
||||
projection := projectCompactionInput(messages)
|
||||
content := projection.messages[0].Content
|
||||
if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") {
|
||||
t.Fatalf("previous summary was lost when the brief tail was capped: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "middle transcript omitted to fit compaction budget") {
|
||||
t.Fatalf("oversized brief was not head/tail capped: %q", content)
|
||||
}
|
||||
if !projection.truncated {
|
||||
t.Fatal("projection did not structurally report truncation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) {
|
||||
projection := projectCompactionInput([]zeroruntime.Message{{
|
||||
Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]",
|
||||
}})
|
||||
if projection.truncated {
|
||||
t.Fatal("literal user text produced a false truncation signal")
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,15 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}},
|
||||
}
|
||||
|
||||
edits := recentEdits(messages)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
if len(edits) != 3 {
|
||||
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
@@ -32,6 +36,9 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||
}
|
||||
if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" {
|
||||
t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2])
|
||||
}
|
||||
}
|
||||
|
||||
// After compaction elides the editing turns, the preserved-state block still
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -58,12 +59,15 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
|
||||
if last.Content != "most recent question" {
|
||||
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
||||
}
|
||||
// The summarized middle excludes system and preserved suffix.
|
||||
if len(captured) != 3 {
|
||||
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
|
||||
// The summarizer receives a compact semantic projection of the middle rather
|
||||
// than reconstructible raw tool output.
|
||||
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
|
||||
t.Fatalf("expected one projected summary input, got %#v", captured)
|
||||
}
|
||||
if captured[0].Content != "first question" {
|
||||
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
|
||||
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
|
||||
if !strings.Contains(captured[0].Content, want) {
|
||||
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
|
||||
}
|
||||
}
|
||||
// Compaction must shrink the conversation.
|
||||
if estimateTokens(result) >= estimateTokens(messages) {
|
||||
@@ -271,6 +275,27 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
|
||||
if estimateToolDefTokens(one) <= 0 {
|
||||
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
||||
}
|
||||
typed := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
typed[0].Type = zeroruntime.ToolDefinitionFreeform
|
||||
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
|
||||
t.Fatal("a non-empty tool type must increase the estimated request cost")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
|
||||
} {
|
||||
t.Run("format "+test.name, func(t *testing.T) {
|
||||
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
formatted[0].Format = &test.format
|
||||
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
|
||||
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
||||
Name: "write_file",
|
||||
Description: "Write contents to a file in the workspace.",
|
||||
@@ -379,6 +404,43 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeCompactStopsAfterSupersededReadsReachThreshold(t *testing.T) {
|
||||
body := strings.Repeat("same source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
||||
}
|
||||
pruned, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed <= 0 {
|
||||
t.Fatal("test setup did not reclaim superseded read output")
|
||||
}
|
||||
prunedSize := estimateTokens(pruned)
|
||||
if estimateTokens(messages) <= prunedSize {
|
||||
t.Fatal("test setup does not cross the compaction threshold")
|
||||
}
|
||||
|
||||
provider := &summarizeRecordingProvider{}
|
||||
state := &compactionState{
|
||||
enabled: true,
|
||||
threshold: prunedSize,
|
||||
preserveLast: 2,
|
||||
}
|
||||
got := state.maybeCompact(context.Background(), provider, messages, nil)
|
||||
|
||||
if provider.summarizeCalls != 0 {
|
||||
t.Fatalf("cheap pruning should avoid the summarizer, got %d calls", provider.summarizeCalls)
|
||||
}
|
||||
if state.lowWaterMark != prunedSize {
|
||||
t.Fatalf("lowWaterMark = %d, want %d", state.lowWaterMark, prunedSize)
|
||||
}
|
||||
if !strings.Contains(got[2].Content, "superseded identical") || got[4].Content != body {
|
||||
t.Fatalf("unexpected pruned messages: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// reactiveProvider builds up history with a tool call on turn 1, then errors
|
||||
// with a context-limit message on turn 2 (once the history is large enough that
|
||||
// compaction can shrink it), then succeeds on the same-turn retry. Summary
|
||||
@@ -388,6 +450,7 @@ type reactiveProvider struct {
|
||||
summarizeCalls int
|
||||
turnRequests int
|
||||
failedOnce bool
|
||||
connectError bool
|
||||
bigText string
|
||||
finalText string
|
||||
}
|
||||
@@ -409,6 +472,9 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
||||
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
|
||||
case provider.turnRequests == 2 && !provider.failedOnce:
|
||||
provider.failedOnce = true
|
||||
if provider.connectError {
|
||||
return nil, errors.New("context_length_exceeded")
|
||||
}
|
||||
return streamEvents([]zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
||||
}), nil
|
||||
@@ -417,6 +483,39 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectErrorCompactionRecordsReplacementPlan(t *testing.T) {
|
||||
provider := &reactiveProvider{
|
||||
connectError: true,
|
||||
bigText: strings.Repeat("b", 6000),
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
recorder := trace.NewRecorder("connect-compaction", "run-1", "test")
|
||||
var contextPlans []ContextBreakdown
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
ContextWindow: 10_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
Trace: recorder,
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
contextPlans = append(contextPlans, breakdown)
|
||||
},
|
||||
})
|
||||
if err != nil || result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("connect-error compaction failed: result=%#v err=%v", result, err)
|
||||
}
|
||||
if len(contextPlans) != 3 || contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
||||
t.Fatalf("replacement request context not reported: %#v", contextPlans)
|
||||
}
|
||||
gotTrace := recorder.Finish()
|
||||
if len(gotTrace.PrefixHashes) != 4 || gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
||||
t.Fatalf("replacement request trace not recorded: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
provider := &reactiveProvider{
|
||||
bigText: strings.Repeat("b", 6000),
|
||||
@@ -428,6 +527,8 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
|
||||
var contextPlans []ContextBreakdown
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -436,6 +537,10 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
ContextWindow: 10_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
Trace: recorder,
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
contextPlans = append(contextPlans, breakdown)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
|
||||
@@ -446,6 +551,25 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
if provider.summarizeCalls == 0 {
|
||||
t.Fatal("expected reactive compaction to invoke the summarizer")
|
||||
}
|
||||
if len(contextPlans) != 3 {
|
||||
t.Fatalf("main request context callbacks = %d, want initial turns plus compacted retry", len(contextPlans))
|
||||
}
|
||||
if contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
||||
t.Fatalf("retry context was not updated after compaction: before=%d after=%d", contextPlans[1].MessageTokens, contextPlans[2].MessageTokens)
|
||||
}
|
||||
gotTrace := recorder.Finish()
|
||||
if len(gotTrace.PrefixHashes) != 4 {
|
||||
t.Fatalf("prefix evidence count = %d, want two turns, compaction, and retry: %#v", len(gotTrace.PrefixHashes), gotTrace.PrefixHashes)
|
||||
}
|
||||
if gotTrace.PrefixHashes[0].InvalidationReason != "initial" ||
|
||||
gotTrace.PrefixHashes[1].InvalidationReason != "unchanged" ||
|
||||
gotTrace.PrefixHashes[2].InvalidationReason != "initial" ||
|
||||
gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
||||
t.Fatalf("unexpected request-specific invalidation evidence: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
if gotTrace.PrefixHashes[2].CompletePrefixHash == gotTrace.PrefixHashes[1].CompletePrefixHash {
|
||||
t.Fatalf("compaction fingerprint reused the main request prefix: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
}
|
||||
|
||||
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
|
||||
|
||||
@@ -23,6 +23,24 @@ type ContextBreakdown struct {
|
||||
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
||||
ContextWindow int // model context window; 0 when unknown
|
||||
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
|
||||
// Blocks explain why each model-visible category is present without exposing
|
||||
// its content. The planner currently preserves all three categories.
|
||||
Blocks []ContextBlock
|
||||
// CompletePrefixHash identifies the provider-visible system+tool prefix.
|
||||
CompletePrefixHash string
|
||||
// PrefixInvalidationReason is "initial", "unchanged", or a stable comma-
|
||||
// separated list of changed prefix categories.
|
||||
PrefixInvalidationReason string
|
||||
}
|
||||
|
||||
// ContextBlock is content-free evidence for one model-visible context category.
|
||||
type ContextBlock struct {
|
||||
Kind string
|
||||
Tokens int
|
||||
CacheClass string
|
||||
Authority string
|
||||
Recoverable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// MeasureContext estimates the per-category token footprint of a request: the
|
||||
@@ -41,6 +59,24 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
ContextWindow: contextWindow,
|
||||
}
|
||||
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
|
||||
if breakdown.SystemTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "system", Tokens: breakdown.SystemTokens, CacheClass: "stable_prefix",
|
||||
Authority: "required", Reason: "instructions, workspace policy, and safety policy",
|
||||
})
|
||||
}
|
||||
if breakdown.ToolTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "tools", Tokens: breakdown.ToolTokens, CacheClass: "stable_prefix",
|
||||
Authority: "capability", Reason: "currently permitted and selected tool definitions",
|
||||
})
|
||||
}
|
||||
if breakdown.MessageTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "conversation", Tokens: breakdown.MessageTokens, CacheClass: "dynamic_tail",
|
||||
Authority: "transcript", Reason: "valid provider replay history and current task context",
|
||||
})
|
||||
}
|
||||
if contextWindow > 0 {
|
||||
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
|
||||
if breakdown.UsedFraction != wantFraction {
|
||||
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
|
||||
}
|
||||
if len(breakdown.Blocks) != 3 {
|
||||
t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type contextPlannerConfig struct {
|
||||
contextWindow int
|
||||
promptCacheKey string
|
||||
serviceTier string
|
||||
promptParts systemPromptParts
|
||||
}
|
||||
|
||||
// contextPlanner is the single seam for constructing model-visible requests.
|
||||
// Planning is deterministic and does not retrieve content, execute tools, or
|
||||
// change permissions. The initial implementation deliberately preserves every
|
||||
// message and tool definition while making composition and cache drift
|
||||
// inspectable; later selection policies must continue to cross this seam.
|
||||
type contextPlanner struct {
|
||||
config contextPlannerConfig
|
||||
previousPrefix prefixFingerprint
|
||||
hasPrevious bool
|
||||
toolSnapshotKey string
|
||||
toolSnapshot []zeroruntime.ToolDefinition
|
||||
}
|
||||
|
||||
type contextPlan struct {
|
||||
Request zeroruntime.CompletionRequest
|
||||
Breakdown ContextBreakdown
|
||||
PrefixFingerprint prefixFingerprint
|
||||
}
|
||||
|
||||
func newContextPlanner(config contextPlannerConfig) *contextPlanner {
|
||||
return &contextPlanner{config: config}
|
||||
}
|
||||
|
||||
// Plan returns a provider request snapshot plus content-free accounting.
|
||||
// It intentionally performs no relevance filtering: preserving current model
|
||||
// capability is the baseline contract for future planner policies.
|
||||
func (planner *contextPlanner) Plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string) contextPlan {
|
||||
return planner.plan(messages, toolDefs, reasoningEffort, planner.config.promptParts)
|
||||
}
|
||||
|
||||
// planWithPromptParts plans a request whose stable system sections differ from
|
||||
// the planner's configured main-run prompt, such as a compaction summary call.
|
||||
func (planner *contextPlanner) planWithPromptParts(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
||||
return planner.plan(messages, toolDefs, reasoningEffort, parts)
|
||||
}
|
||||
|
||||
func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
||||
requestMessages := copyMessages(messages)
|
||||
requestSystemPrompt := leadingSystemContent(requestMessages)
|
||||
if parts.prompt != requestSystemPrompt {
|
||||
// Component boundaries from a different request would make the detailed
|
||||
// invalidation reason misleading. Retain the exact request prefix and
|
||||
// classify it conservatively as an unstructured system prompt instead.
|
||||
parts = systemPromptParts{prompt: requestSystemPrompt}
|
||||
}
|
||||
fingerprint := computePrefixFingerprint(buildPromptSubstringsFromParts(parts, toolDefs))
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: requestMessages,
|
||||
Tools: planner.snapshotTools(toolDefs, fingerprint),
|
||||
ReasoningEffort: reasoningEffort,
|
||||
ServiceTier: planner.config.serviceTier,
|
||||
PromptCacheKey: planner.config.promptCacheKey,
|
||||
}
|
||||
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
|
||||
breakdown.CompletePrefixHash = fingerprint.CompletePrefixHash
|
||||
var previous *prefixFingerprint
|
||||
if planner.hasPrevious {
|
||||
previous = &planner.previousPrefix
|
||||
}
|
||||
breakdown.PrefixInvalidationReason = explainPrefixChange(previous, fingerprint)
|
||||
planner.previousPrefix = fingerprint
|
||||
planner.hasPrevious = true
|
||||
return contextPlan{
|
||||
Request: request,
|
||||
Breakdown: breakdown,
|
||||
PrefixFingerprint: fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotTools freezes provider-visible tool definitions when their semantic
|
||||
// fingerprint changes. Stable turns reuse the frozen schemas instead of
|
||||
// recursively cloning every map and slice for every request.
|
||||
func (planner *contextPlanner) snapshotTools(toolDefs []zeroruntime.ToolDefinition, fingerprint prefixFingerprint) []zeroruntime.ToolDefinition {
|
||||
key := fingerprint.ToolsHash + "\x00" + fingerprint.SchemaHash
|
||||
if planner.toolSnapshotKey != key {
|
||||
planner.toolSnapshot = copyToolDefinitions(toolDefs)
|
||||
planner.toolSnapshotKey = key
|
||||
}
|
||||
return planner.toolSnapshot
|
||||
}
|
||||
|
||||
func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.ToolDefinition {
|
||||
if toolDefs == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make([]zeroruntime.ToolDefinition, len(toolDefs))
|
||||
for index, definition := range toolDefs {
|
||||
copied[index] = definition
|
||||
copied[index].Parameters = copySchemaMap(definition.Parameters)
|
||||
if definition.Format != nil {
|
||||
format := *definition.Format
|
||||
copied[index].Format = &format
|
||||
}
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func copySchemaMap(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]any, len(schema))
|
||||
for key, value := range schema {
|
||||
copied[key] = copySchemaValue(value)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func copySchemaValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return copySchemaMap(typed)
|
||||
case []any:
|
||||
copied := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
copied[index] = copySchemaValue(item)
|
||||
}
|
||||
return copied
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func leadingSystemContent(messages []zeroruntime.Message) string {
|
||||
contents := make([]string, 0, 1)
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleSystem {
|
||||
break
|
||||
}
|
||||
contents = append(contents, message.Content)
|
||||
}
|
||||
return strings.Join(contents, "\n\n")
|
||||
}
|
||||
|
||||
func explainPrefixChange(previous *prefixFingerprint, current prefixFingerprint) string {
|
||||
if previous == nil {
|
||||
return "initial"
|
||||
}
|
||||
if previous.CompletePrefixHash == current.CompletePrefixHash {
|
||||
return "unchanged"
|
||||
}
|
||||
reasons := make([]string, 0, 3)
|
||||
if previous.SystemPromptHash != current.SystemPromptHash {
|
||||
parts := make([]string, 0, 4)
|
||||
if previous.BaseInstructionsHash != current.BaseInstructionsHash {
|
||||
parts = append(parts, "base_instructions")
|
||||
}
|
||||
if previous.ConfirmationPolicyHash != current.ConfirmationPolicyHash {
|
||||
parts = append(parts, "confirmation_policy")
|
||||
}
|
||||
if previous.ProjectContextHash != current.ProjectContextHash {
|
||||
parts = append(parts, "project_context")
|
||||
}
|
||||
if previous.SkillsHash != current.SkillsHash {
|
||||
parts = append(parts, "skills")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "system_prompt")
|
||||
}
|
||||
reasons = append(reasons, parts...)
|
||||
}
|
||||
if previous.ToolsHash != current.ToolsHash {
|
||||
reasons = append(reasons, "tools")
|
||||
}
|
||||
if previous.SchemaHash != current.SchemaHash {
|
||||
reasons = append(reasons, "schema")
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return "prefix_changed"
|
||||
}
|
||||
return strings.Join(reasons, ",")
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "inspect this", Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{1, 2, 3}}}},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working", ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, Reasoning: []zeroruntime.ReasoningBlock{{Provider: "test", Type: "thinking", Signature: "sig"}}},
|
||||
}
|
||||
toolDefs := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file", Description: "Read a file",
|
||||
Format: &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: "start: PATH"},
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"path"},
|
||||
"properties": map[string]any{"path": map[string]any{"type": "string"}},
|
||||
"anyOf": []any{map[string]any{"type": "object"}},
|
||||
},
|
||||
}}
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: 128_000,
|
||||
promptCacheKey: "session-1",
|
||||
serviceTier: "priority",
|
||||
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
|
||||
})
|
||||
|
||||
plan := planner.Plan(messages, toolDefs, "medium")
|
||||
want := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: "medium",
|
||||
ServiceTier: "priority",
|
||||
PromptCacheKey: "session-1",
|
||||
}
|
||||
if !reflect.DeepEqual(plan.Request, want) {
|
||||
t.Fatalf("planned request changed provider input:\n got: %#v\nwant: %#v", plan.Request, want)
|
||||
}
|
||||
stable := planner.Plan(messages, toolDefs, "medium")
|
||||
if &stable.Request.Tools[0] != &plan.Request.Tools[0] {
|
||||
t.Fatal("stable tool fingerprint rebuilt the immutable schema snapshot")
|
||||
}
|
||||
|
||||
// A plan owns its request snapshot; later caller mutation cannot change its
|
||||
// messages or its recursively nested tool schema.
|
||||
messages[1].Content = "changed"
|
||||
messages[1].Images[0].Data[0] = 9
|
||||
toolDefs[0].Name = "changed_tool"
|
||||
toolDefs[0].Parameters["required"].([]string)[0] = "other"
|
||||
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
|
||||
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
|
||||
toolDefs[0].Format.Definition = "changed"
|
||||
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
|
||||
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" ||
|
||||
plan.Request.Tools[0].Format == nil || plan.Request.Tools[0].Format.Definition != "start: PATH" ||
|
||||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
|
||||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
|
||||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
|
||||
t.Fatalf("planned tools alias caller state: %#v", plan.Request.Tools[0])
|
||||
}
|
||||
|
||||
next := planner.Plan(messages, toolDefs, "medium")
|
||||
if next.PrefixFingerprint == plan.PrefixFingerprint || next.Request.Tools[0].Name != "changed_tool" {
|
||||
t.Fatalf("changed tool schema did not refresh snapshot: %#v", next)
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" {
|
||||
t.Fatalf("refresh mutated earlier request: %#v", plan.Request.Tools[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerReportsInspectableBlocks(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{contextWindow: 100_000})
|
||||
plan := planner.Plan([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: filler(400)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: filler(200)},
|
||||
}, []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read a file"}}, "")
|
||||
|
||||
if plan.Breakdown.PrefixInvalidationReason != "initial" || plan.Breakdown.CompletePrefixHash == "" {
|
||||
t.Fatalf("prefix evidence = %#v", plan.Breakdown)
|
||||
}
|
||||
if len(plan.Breakdown.Blocks) != 3 {
|
||||
t.Fatalf("blocks = %#v, want system/tools/conversation", plan.Breakdown.Blocks)
|
||||
}
|
||||
wantKinds := []string{"system", "tools", "conversation"}
|
||||
total := 0
|
||||
for index, block := range plan.Breakdown.Blocks {
|
||||
if block.Kind != wantKinds[index] || block.Tokens <= 0 || block.Reason == "" || block.Authority == "" || block.CacheClass == "" {
|
||||
t.Fatalf("block %d = %#v", index, block)
|
||||
}
|
||||
total += block.Tokens
|
||||
}
|
||||
if total != plan.Breakdown.TotalTokens {
|
||||
t.Fatalf("block tokens = %d, want total %d", total, plan.Breakdown.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerFingerprintsActualRequestSystemPrompt(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
||||
prompt: "configured system", baseInstructions: "configured system",
|
||||
}})
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "request-specific system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "task"},
|
||||
}
|
||||
|
||||
plan := planner.Plan(messages, nil, "")
|
||||
want := computePrefixFingerprint(buildPromptSubstringsFromParts(
|
||||
systemPromptParts{prompt: "request-specific system"}, nil,
|
||||
))
|
||||
if plan.PrefixFingerprint != want {
|
||||
t.Fatalf("fingerprint describes configured prompt instead of request:\n got: %#v\nwant: %#v", plan.PrefixFingerprint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerExplainsPrefixInvalidation(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
||||
prompt: "system", baseInstructions: "base", projectContext: "project",
|
||||
}})
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}, {Role: zeroruntime.MessageRoleUser, Content: "task"}}
|
||||
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read", Parameters: map[string]any{"type": "object"}}}
|
||||
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "initial" {
|
||||
t.Fatalf("first reason = %q, want initial", got)
|
||||
}
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "unchanged" {
|
||||
t.Fatalf("stable reason = %q, want unchanged", got)
|
||||
}
|
||||
tools[0].Description = "Read an exact file"
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "tools" {
|
||||
t.Fatalf("tool reason = %q, want tools", got)
|
||||
}
|
||||
tools[0].Parameters["required"] = []any{"path"}
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "schema" {
|
||||
t.Fatalf("schema reason = %q, want schema", got)
|
||||
}
|
||||
planner.config.promptParts.prompt = "system with changed project"
|
||||
planner.config.promptParts.projectContext = "changed project"
|
||||
messages[0].Content = "system with changed project"
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "project_context" {
|
||||
t.Fatalf("project reason = %q, want project_context", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainPrefixChangeNamesEveryChangedComponent(t *testing.T) {
|
||||
baseline := prefixFingerprint{
|
||||
SystemPromptHash: "system",
|
||||
BaseInstructionsHash: "base",
|
||||
ConfirmationPolicyHash: "policy",
|
||||
ProjectContextHash: "project",
|
||||
SkillsHash: "skills",
|
||||
ToolsHash: "tools",
|
||||
SchemaHash: "schema",
|
||||
CompletePrefixHash: "complete",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*prefixFingerprint)
|
||||
want string
|
||||
}{
|
||||
{name: "base instructions", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.BaseInstructionsHash = "changed", "changed"
|
||||
}, want: "base_instructions"},
|
||||
{name: "confirmation policy", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.ConfirmationPolicyHash = "changed", "changed"
|
||||
}, want: "confirmation_policy"},
|
||||
{name: "project context", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.ProjectContextHash = "changed", "changed"
|
||||
}, want: "project_context"},
|
||||
{name: "skills", mutate: func(value *prefixFingerprint) { value.SystemPromptHash, value.SkillsHash = "changed", "changed" }, want: "skills"},
|
||||
{name: "unclassified system prompt", mutate: func(value *prefixFingerprint) { value.SystemPromptHash = "changed" }, want: "system_prompt"},
|
||||
{name: "tools and schema", mutate: func(value *prefixFingerprint) { value.ToolsHash, value.SchemaHash = "changed", "changed" }, want: "tools,schema"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := baseline
|
||||
test.mutate(¤t)
|
||||
current.CompletePrefixHash = "changed"
|
||||
if got := explainPrefixChange(&baseline, current); got != test.want {
|
||||
t.Fatalf("reason = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||
}
|
||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||
if !strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
|
||||
}
|
||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -368,9 +368,8 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// No hidden tools means no dynamic discovery text is needed.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,3 +797,98 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
|
||||
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeDeferredMutatorTool is a deferred-eligible tool with mutating Safety
|
||||
// (SideEffectWrite), standing in for a real write/mutator MCP tool that would
|
||||
// be hidden behind tool_search once deferral activates.
|
||||
type fakeDeferredMutatorTool struct{ name string }
|
||||
|
||||
func (t fakeDeferredMutatorTool) Name() string { return t.name }
|
||||
func (t fakeDeferredMutatorTool) Description() string { return "mutates the workspace, deferred" }
|
||||
func (t fakeDeferredMutatorTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (t fakeDeferredMutatorTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt, Reason: "mutates"}
|
||||
}
|
||||
func (t fakeDeferredMutatorTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "mutated"}
|
||||
}
|
||||
func (t fakeDeferredMutatorTool) Deferred() bool { return true }
|
||||
|
||||
// TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema guards the concern
|
||||
// raised in PR #642 review (jatmn, P2): that tool_search filters deferred
|
||||
// candidates only by EnabledTools/DisabledTools, not by plan-mode visibility,
|
||||
// so a plan-mode model could call `tool_search select:<deferred write tool>`
|
||||
// and receive that tool's full schema even though a direct call to it is
|
||||
// correctly denied the following turn.
|
||||
//
|
||||
// tool_search's own Safety is SideEffectNone, and ToolAdvertisedForPermissionMode (the
|
||||
// same gate executeToolCall uses to deny a direct call) requires
|
||||
// SideEffect==Read to advertise a tool in plan mode. That means tool_search
|
||||
// itself is never advertised, never activates deferral (loaderUsable in
|
||||
// partitionToolsCached requires ToolAdvertised(loader, permissionMode)), and
|
||||
// is denied at dispatch like any other hidden tool if a stale/adversarial
|
||||
// call reaches it anyway — so a deferred mutator's schema can never reach the
|
||||
// model through tool_search while in plan mode. This test pins that
|
||||
// end-to-end: tool_search is absent from the advertised tool list, and a
|
||||
// forced call to it is denied before rendering any tool schema.
|
||||
func TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate"})
|
||||
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate2"})
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{ // turn 1: force a call to tool_search even though it should not be advertised.
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: tools.ToolSearchToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"query":"select:mcp__srv__mutate"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{ // turn 2: final answer.
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
DeferThreshold: 2, // 2 deferred mutators registered => eligible for deferral.
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// tool_search (and the deferred mutators) must not be advertised in plan
|
||||
// mode's turn 1 tool list at all.
|
||||
for _, def := range provider.requests[0].Tools {
|
||||
if def.Name == tools.ToolSearchToolName {
|
||||
t.Fatalf("plan mode must not advertise tool_search, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
if def.Name == "mcp__srv__mutate" || def.Name == "mcp__srv__mutate2" {
|
||||
t.Fatalf("plan mode must not advertise a deferred mutator, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
}
|
||||
|
||||
// The forced call must be denied outright, never a loaded-schema result:
|
||||
// the tool result must not mention the mutator's name or carry a
|
||||
// load_tools signal.
|
||||
var toolMessage string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolMessage = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(toolMessage, "not available in plan mode") {
|
||||
t.Fatalf("expected tool_search call denied in plan mode, got %q", toolMessage)
|
||||
}
|
||||
if strings.Contains(toolMessage, "mcp__srv__mutate") {
|
||||
t.Fatalf("denial must not leak the deferred mutator's name/schema, got %q", toolMessage)
|
||||
}
|
||||
}
|
||||
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
var switchedTo string
|
||||
switchCount := 0
|
||||
result, err := Run(context.Background(), "go", firstProvider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
||||
switchCount++
|
||||
switchedTo = modelID
|
||||
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
if len(firstProvider.requests) != 1 {
|
||||
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
||||
}
|
||||
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
|
||||
t.Fatalf("initial request service tier = %q, want priority", got)
|
||||
}
|
||||
if len(secondProvider.requests) != 1 {
|
||||
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
||||
}
|
||||
if got := secondProvider.requests[0].ServiceTier; got != "" {
|
||||
t.Fatalf("post-switch request retained service tier %q", got)
|
||||
}
|
||||
if result.FinalAnswer != "answered on the upgraded model" {
|
||||
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
||||
}
|
||||
@@ -211,9 +218,10 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
return nil, errors.New("provider build blew up")
|
||||
},
|
||||
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
if got := provider.requests[1].ServiceTier; got != "priority" {
|
||||
t.Fatalf("failed-switch request service tier = %q, want priority", got)
|
||||
}
|
||||
if result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type customApplyPatchTool struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
type fixedPathScope struct {
|
||||
roots []string
|
||||
}
|
||||
|
||||
func (scope fixedPathScope) Roots() []string {
|
||||
return append([]string(nil), scope.roots...)
|
||||
}
|
||||
|
||||
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
|
||||
t.Helper()
|
||||
resolved := make([]string, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
absolute, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", root, err)
|
||||
}
|
||||
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
absolute = physical
|
||||
}
|
||||
resolved = append(resolved, absolute)
|
||||
}
|
||||
return fixedPathScope{roots: resolved}
|
||||
}
|
||||
|
||||
func (*customApplyPatchTool) Name() string { return "apply_patch" }
|
||||
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
|
||||
func (*customApplyPatchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
|
||||
}
|
||||
func (*customApplyPatchTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
|
||||
}
|
||||
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
|
||||
tool.calls++
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(root, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(extra, "hello.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read extra-root patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
|
||||
for _, extraHasMatchingPath := range []bool{false, true} {
|
||||
name := "extra target missing"
|
||||
if extraHasMatchingPath {
|
||||
name = "extra target exists"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspaceTarget := filepath.Join(workspace, "src", "a.go")
|
||||
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extraTarget := filepath.Join(extra, "src", "a.go")
|
||||
if extraHasMatchingPath {
|
||||
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
extraCreated := filepath.Join(extra, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/a.go",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + extraCreated,
|
||||
"+extra",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
|
||||
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
if extraHasMatchingPath {
|
||||
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
|
||||
t.Fatalf("mixed-root patch created extra file: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
useExtraRoot bool
|
||||
}{
|
||||
{name: "workspace"},
|
||||
{name: "extra root", useExtraRoot: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := workspace
|
||||
if test.useExtraRoot {
|
||||
root = extra
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "src", "a.go")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatePath := target
|
||||
if !test.useExtraRoot {
|
||||
updatePath = filepath.Join("src", "a.go")
|
||||
}
|
||||
created := filepath.Join(root, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: " + updatePath,
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + created,
|
||||
"+created",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
|
||||
t.Fatalf("updated content=%q err=%v", content, err)
|
||||
}
|
||||
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
|
||||
t.Fatalf("created content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
granted := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
scope := newFixedPathScope(t, root, granted)
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(outside, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("ungranted absolute patch created target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating directory symlinks requires privileges on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
intermediate := filepath.Join(extra, "nested")
|
||||
if err := os.Mkdir(intermediate, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
scope := newFixedPathScope(t, root, extra)
|
||||
tool := tools.NewScopedApplyPatchTool(root, scope)
|
||||
target := filepath.Join(intermediate, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(intermediate); err != nil {
|
||||
t.Fatalf("Remove intermediate: %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, intermediate); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
|
||||
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("unknown freeform status = %s, want error", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
|
||||
custom := &customApplyPatchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(custom)
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
|
||||
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
|
||||
}
|
||||
if custom.calls != 0 {
|
||||
t.Fatalf("custom apply_patch executed %d times", custom.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeAsk, Options{
|
||||
Cwd: root,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("denied freeform status = %s, want error", result.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("denied freeform patch changed the workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
|
||||
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
|
||||
safe := historySafeToolCalls([]ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
|
||||
}})
|
||||
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
|
||||
t.Fatalf("freeform history changed raw call: %#v", safe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
|
||||
messages := appendAbortedToolResults(nil, []ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
|
||||
}})
|
||||
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
|
||||
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ const (
|
||||
// knows before spending more tool turns.
|
||||
toolOnlyProgressReminderAt = 6
|
||||
|
||||
// planReminderTurn is the turn (1-based) by the end of which a multi-step
|
||||
// task should have called update_plan; if it hasn't, a one-time reminder is
|
||||
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
|
||||
// without a spurious planning nag.
|
||||
planReminderTurn = 3
|
||||
// planReminderTurn and planReminderToolThreshold keep bounded implementation
|
||||
// work plan-free while still nudging a genuinely sustained run that has not
|
||||
// established any structured progress. Both thresholds must be met.
|
||||
planReminderTurn = 7
|
||||
planReminderToolThreshold = 7
|
||||
|
||||
// planToolName is the planning tool the loop watches for by name.
|
||||
planToolName = "update_plan"
|
||||
@@ -591,13 +591,12 @@ func (state *guardState) planReminder(turn int) string {
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
|
||||
// called update_plan if it's doing a multi-step task (>=1 other tool call).
|
||||
// One-shot for the whole run.
|
||||
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
|
||||
// sustained work should have called update_plan. One-shot for the whole run.
|
||||
if !state.notCalledReminderSent &&
|
||||
!state.planEverCalled &&
|
||||
turn >= planReminderTurn &&
|
||||
state.totalToolCalls >= 1 {
|
||||
state.totalToolCalls >= planReminderToolThreshold {
|
||||
state.notCalledReminderSent = true
|
||||
return planNotCalledReminder()
|
||||
}
|
||||
|
||||
@@ -257,19 +257,16 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
if planReminderToolThreshold != expectedThreshold {
|
||||
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
|
||||
}
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
|
||||
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
|
||||
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
@@ -287,6 +284,44 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
|
||||
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
|
||||
state := guardState{totalToolCalls: 7}
|
||||
if got := state.planReminder(6); got != "" {
|
||||
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
|
||||
}
|
||||
if state.notCalledReminderSent {
|
||||
t.Fatal("early turn marked the not-called reminder as sent")
|
||||
}
|
||||
}
|
||||
|
||||
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, count)
|
||||
for range count {
|
||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
+424
-152
@@ -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
|
||||
@@ -133,6 +141,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
|
||||
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
|
||||
options.Trace.Counter(trace.CounterCacheWriteTokens, int64(usage.CacheWriteTokens))
|
||||
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
|
||||
}
|
||||
}
|
||||
@@ -183,10 +192,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
promptParts := buildSystemPromptParts(options)
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: options.ContextWindow,
|
||||
promptCacheKey: options.SessionID,
|
||||
serviceTier: options.ServiceTier,
|
||||
promptParts: promptParts,
|
||||
})
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
|
||||
guards := newGuardState()
|
||||
task := newTaskState(prompt, options.Trace)
|
||||
onPermission := options.OnPermission
|
||||
options.OnPermission = func(event PermissionEvent) {
|
||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: event})
|
||||
if onPermission != nil {
|
||||
onPermission(event)
|
||||
}
|
||||
}
|
||||
compactor := newCompactionState(options, task)
|
||||
defer func() {
|
||||
// A final transcript comparison is observational. It records drift but
|
||||
@@ -275,42 +297,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Fingerprint the exact system prompt built at run start plus this turn's
|
||||
// provider-visible tool definitions in their emitted order. Reusing the
|
||||
// retained prompt parts avoids rebuilding workspace context while ensuring
|
||||
// the trace describes the same bytes carried by the request.
|
||||
if options.Trace != nil {
|
||||
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fp.SystemPromptHash,
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
SkillsHash: fp.SkillsHash,
|
||||
ToolsHash: fp.ToolsHash,
|
||||
SchemaHash: fp.SchemaHash,
|
||||
CompletePrefixHash: fp.CompletePrefixHash,
|
||||
})
|
||||
}
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
||||
compactionSpan := options.Trace.Span(trace.SpanCompaction)
|
||||
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
|
||||
compactionSpan.End()
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
// show utilization. Opt-in: a no-op when OnContext is unset.
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
|
||||
}
|
||||
plan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request := plan.Request
|
||||
recordContextPlan(options, plan)
|
||||
|
||||
// A transient upstream disconnect on the initial connect is retried with
|
||||
// backoff (before any content is forwarded, so no OnText is duplicated);
|
||||
@@ -335,12 +330,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// on registry+loaded, not on the messages, so they stay valid after
|
||||
// compaction. Using the bare toolDefinitions here would route through an
|
||||
// empty-loaded partition, re-hiding every already-loaded deferred tool.
|
||||
request = zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request = retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
// Pre-content connect after a context-limit compaction: route through the
|
||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||
// whole run and re-burn every token (AUDIT-L1).
|
||||
@@ -424,12 +416,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// Reuse the SAME active-mode partition (exposed) from this turn rather
|
||||
// than the bare toolDefinitions: exposed depends on registry+loaded (not
|
||||
// the messages), so it stays valid after compaction.
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryStreamErr != nil {
|
||||
return collected, retryStreamErr
|
||||
@@ -487,12 +476,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
}
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -656,6 +642,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// between tool_results breaks strict provider replay) — same after-batch
|
||||
// rationale as turnRequestedModel above.
|
||||
var changedFilesThisBatch []string
|
||||
// Images produced by tools this turn, held until every tool_result is
|
||||
// recorded. They travel as user messages, and a user message between two
|
||||
// tool_results breaks strict provider replay — Anthropic coalesces them
|
||||
// into one user block list and requires the tool_result blocks first, so
|
||||
// interleaving yields [tool_result, text, image, tool_result] and a 400.
|
||||
// Same reason the self-correction feedback below is deferred.
|
||||
var toolImageMessages []zeroruntime.Message
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
@@ -691,7 +684,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -705,10 +698,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate.
|
||||
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
|
||||
toolImageMessages = append(toolImageMessages, imageMessage)
|
||||
}
|
||||
|
||||
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
|
||||
// returns context.Canceled rather than fabricating a headless answer. Stop
|
||||
@@ -719,12 +725,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
if abortErr != nil {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, abortErr
|
||||
}
|
||||
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
result.FinalAnswer = toolResult.Output
|
||||
messages = append(messages, toolImageMessages...)
|
||||
result.FinalAnswer = toolResult.ModelOutput()
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -737,7 +745,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// aren't fixed by reformatting the call, so a "match this schema" hint
|
||||
// would misdirect the model toward JSON shape or blocked behavior.
|
||||
retriableFailure := isRetriableToolError(toolResult)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.ModelOutput())
|
||||
posture.observeToolOutcome(outcome, toolResult)
|
||||
if outcome.Stop {
|
||||
// The assistant message advertised EVERY collected tool call, but
|
||||
@@ -747,12 +755,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// messages stay valid for a strict provider replay (Anthropic
|
||||
// rejects a tool_use with no answering tool_result).
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
if outcome.InjectHint && failureHint == "" {
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.ModelOutput())
|
||||
}
|
||||
|
||||
// Collect the files this successful mutating tool changed. Self-correct
|
||||
@@ -765,6 +774,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
// Every tool_result for this turn is now recorded, including aborted
|
||||
// placeholders, so the images can follow without splitting them.
|
||||
messages = append(messages, toolImageMessages...)
|
||||
toolImageMessages = nil
|
||||
|
||||
// Run post-edit self-correction once over the union of files this turn
|
||||
// changed, then append any feedback after every tool_result is recorded so
|
||||
@@ -827,12 +840,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
_ = session.Prewarm(ctx)
|
||||
provider = sessionProvider{session: session}
|
||||
options.Model = turnRequestedModel
|
||||
// Service tiers are model capabilities. A successful escalation may
|
||||
// target a model that does not support the tier selected before the
|
||||
// switch, so let the destination use its default.
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
// here, so a switch to a model with a different window keeps compacting
|
||||
// against the original budget. Fixing it needs the switcher to also
|
||||
// report the new window — out of scope for this change.
|
||||
// Re-derive the context window for the new model so compaction and
|
||||
// the OnContext budget report track the model actually in force —
|
||||
// without this a switch to a smaller-window model can overflow the
|
||||
// target, and a switch to a larger one over-compacts. An unknown
|
||||
// model (<= 0) keeps the original window. The compactor also
|
||||
// re-resolves its dedicated summarizer against the new model.
|
||||
window := 0
|
||||
if options.ContextWindowFor != nil {
|
||||
window = options.ContextWindowFor(turnRequestedModel)
|
||||
}
|
||||
if window > 0 {
|
||||
options.ContextWindow = window
|
||||
planner.config.contextWindow = window
|
||||
}
|
||||
compactor.switchModel(turnRequestedModel, window)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -896,7 +924,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -920,6 +948,21 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
if recorder == nil || result.Meta["output_budget_category"] == "" {
|
||||
return
|
||||
}
|
||||
if result.Outcome.Finalized() {
|
||||
diagnostics := result.Outcome.Diagnostics
|
||||
recorder.EmitOutputBudget(trace.OutputBudgetEvent{
|
||||
Tool: result.Name,
|
||||
Category: diagnostics.Category,
|
||||
OriginalBytes: diagnostics.OriginalBytes,
|
||||
RetainedBytes: diagnostics.ModelBytes,
|
||||
EstimatedOriginalTokens: diagnostics.EstimatedOriginalTokens,
|
||||
EstimatedRetainedTokens: diagnostics.EstimatedModelTokens,
|
||||
Truncated: diagnostics.Truncated,
|
||||
Reason: diagnostics.Reason,
|
||||
SpillCreated: result.Outcome.Artifact != nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
parseInt := func(key string) int {
|
||||
value, _ := strconv.Atoi(result.Meta[key])
|
||||
return value
|
||||
@@ -938,7 +981,32 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
func recordContextPlan(options Options, plan contextPlan) {
|
||||
recordContextPlanTrace(options.Trace, plan)
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(plan.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
fingerprint := plan.PrefixFingerprint
|
||||
recorder.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fingerprint.SystemPromptHash,
|
||||
BaseInstructionsHash: fingerprint.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fingerprint.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fingerprint.ProjectContextHash,
|
||||
SkillsHash: fingerprint.SkillsHash,
|
||||
ToolsHash: fingerprint.ToolsHash,
|
||||
SchemaHash: fingerprint.SchemaHash,
|
||||
CompletePrefixHash: fingerprint.CompletePrefixHash,
|
||||
InvalidationReason: plan.Breakdown.PrefixInvalidationReason,
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -947,12 +1015,9 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
// The max-turns final-answer call is a pre-content connect, often after a long
|
||||
// autonomous/cron run — route it through the reconnect helper so a single
|
||||
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
plan := planner.Plan(finalMessages, toolDefs, options.ReasoningEffort)
|
||||
recordContextPlan(options, plan)
|
||||
stream, err := streamWithReconnect(ctx, provider, plan.Request, reconnectNoticeFor(options))
|
||||
if err != nil {
|
||||
return "", messages, ""
|
||||
}
|
||||
@@ -982,6 +1047,9 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
|
||||
safe := make([]ToolCall, len(calls))
|
||||
for i, call := range calls {
|
||||
safe[i] = call
|
||||
if call.Freeform {
|
||||
continue
|
||||
}
|
||||
args := strings.TrimSpace(call.Arguments)
|
||||
switch {
|
||||
case args == "":
|
||||
@@ -1065,8 +1133,28 @@ func decodeToolArguments(arguments string, v any) error {
|
||||
}
|
||||
|
||||
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if call.Freeform {
|
||||
if !toolFound || !tools.IsBuiltInApplyPatch(tool) {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Unsupported freeform tool call for " + call.Name + ".",
|
||||
}, nil
|
||||
}
|
||||
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid freeform apply_patch input: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
args = prepared
|
||||
} else if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
@@ -1076,6 +1164,9 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if isShellCommandTool(call.Name) {
|
||||
normalizeNoopAdditionalPermissions(args, options.Cwd, options.Sandbox)
|
||||
}
|
||||
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
|
||||
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
|
||||
// discovery tool exposed to the model rejects at dispatch time (an inescapable
|
||||
@@ -1091,13 +1182,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
|
||||
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
|
||||
modeName := string(permissionMode)
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
|
||||
Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`,
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
@@ -1315,7 +1406,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
|
||||
@@ -1354,7 +1445,8 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Cwd: options.Cwd,
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// a file that changed on disk outside Zero since it was last read.
|
||||
FileTracker: options.FileTracker,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
|
||||
// synchronously inside edit_file/write_file and block every mutating tool
|
||||
// call on the language server (≥300ms debounce floor, 10s cap). The loop
|
||||
@@ -1406,6 +1498,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
result = registry.RebudgetAfterHook(call.Name, args, result)
|
||||
}
|
||||
}
|
||||
result = registry.CommitFileObservation(result, options.FileTracker)
|
||||
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
|
||||
// observers can see the risk of an allowed mutation. Prefer the preflight
|
||||
// decision's classification when the sandbox evaluated the call; otherwise
|
||||
@@ -1428,13 +1521,15 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Output: result.ModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
@@ -1495,7 +1590,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)
|
||||
@@ -1543,7 +1638,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)
|
||||
@@ -1690,20 +1785,21 @@ func sandboxRestrictionRetryReason(result tools.Result) string {
|
||||
|
||||
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1718,20 +1814,21 @@ func unsandboxedRetryArgs(args map[string]any) map[string]any {
|
||||
|
||||
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1770,9 +1867,31 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
}
|
||||
}
|
||||
|
||||
// hooksSuppressed reports whether advisory (non-veto) hooks must not run for
|
||||
// this run's permission mode. Plan mode promises a read-only turn, but
|
||||
// sessionStart/sessionEnd/afterTool hooks execute configured host commands
|
||||
// outside the advertised-tool and sandbox gates, so dispatching them would let
|
||||
// merely starting a plan session or finishing a read mutate the workspace.
|
||||
//
|
||||
// beforeTool is intentionally NOT suppressed: a non-zero exit is a deny gate,
|
||||
// and skipping it fails open (operators who block secret-file reads via
|
||||
// beforeTool would lose that protection under /plan on). See dispatchBeforeTool.
|
||||
//
|
||||
// Spec-draft keeps the existing trust-gated hook model: project hooks still
|
||||
// fire when the workspace (or its worktree trust root) is trusted. That is
|
||||
// intentional; trust inheritance for --use-spec --worktree is covered by
|
||||
// TestExecSpecWorktreeInheritsTrustEndToEnd.
|
||||
func hooksSuppressed(options Options) bool {
|
||||
return options.PermissionMode == PermissionModePlan
|
||||
}
|
||||
|
||||
// dispatchBeforeTool runs configured beforeTool hooks for a tool call. A hook
|
||||
// that exits non-zero vetoes the call: the returned bool is true and the tool
|
||||
// must not run. A nil dispatcher (no hooks wired) is a no-op.
|
||||
//
|
||||
// Unlike advisory hooks, beforeTool still runs under plan mode. Suppressing it
|
||||
// would fail open: a project policy that blocks reads of secrets via
|
||||
// beforeTool would silently stop applying the moment permission mode is plan.
|
||||
func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, args map[string]any) (hooks.DispatchOutcome, bool) {
|
||||
if options.Hooks == nil {
|
||||
return hooks.DispatchOutcome{}, false
|
||||
@@ -1797,7 +1916,7 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
|
||||
// returns any advisory output (e.g. a formatter or vet result) to surface back
|
||||
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
|
||||
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
|
||||
if options.Hooks == nil {
|
||||
if options.Hooks == nil || hooksSuppressed(options) {
|
||||
return ""
|
||||
}
|
||||
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
||||
@@ -1821,7 +1940,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
|
||||
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
|
||||
// never block the run.
|
||||
func dispatchSessionStart(ctx context.Context, options Options) {
|
||||
if options.Hooks == nil {
|
||||
if options.Hooks == nil || hooksSuppressed(options) {
|
||||
return
|
||||
}
|
||||
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
||||
@@ -1842,7 +1961,7 @@ func dispatchSessionStart(ctx context.Context, options Options) {
|
||||
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
|
||||
// exits, including early error returns. Lifecycle hooks are advisory.
|
||||
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
|
||||
if options.Hooks == nil {
|
||||
if options.Hooks == nil || hooksSuppressed(options) {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
@@ -2029,13 +2148,14 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Output: result.ModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
}
|
||||
}
|
||||
return ToolResult{
|
||||
@@ -2141,6 +2261,20 @@ type requestPermissionsArgs struct {
|
||||
}
|
||||
|
||||
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
// request_permissions is dispatched by name above, before the registry-based
|
||||
// ToolAdvertised gate runs, so a read-only mode's registry omitting this tool
|
||||
// (rather than registering it as denied) must not fall through to a real
|
||||
// grant. Deny it here unconditionally for spec-draft/plan, independent of
|
||||
// whether the caller's registry happens to contain the tool.
|
||||
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: `Error: Tool "` + call.Name + `" is not available in ` + string(permissionMode) + ` mode.`,
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
parsed, err := parseRequestPermissionsArgs(args)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
@@ -2686,9 +2820,36 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
|
||||
Grant: event.Grant,
|
||||
CommandPrefix: append([]string(nil), event.CommandPrefix...),
|
||||
AvailableDecisions: availablePermissionDecisions(event, args, options),
|
||||
// Computed from the SAME conditions shellExecutionArgsForApproval uses,
|
||||
// so the prompt cannot claim an escalation that will not happen or stay
|
||||
// silent about one that will. Keeping the two in step matters more than
|
||||
// the wording: a label that overstates gets ignored, and one that
|
||||
// understates is the bug being fixed.
|
||||
PrefixApprovalEscalates: shellPrefixApprovalEscalates(event.ToolName, args, options),
|
||||
}
|
||||
}
|
||||
|
||||
// shellPrefixApprovalEscalates reports whether approving a command prefix would
|
||||
// also take this command out of the sandbox.
|
||||
//
|
||||
// Deliberately mirrors shellExecutionArgsForApproval's guards rather than
|
||||
// re-deriving them: same tool test, same sandbox availability test, and the same
|
||||
// two opt-outs for a call that already asked for additional permissions or
|
||||
// escalation. Those already carry their own disclosure, so labelling them again
|
||||
// would be noise.
|
||||
func shellPrefixApprovalEscalates(toolName string, args map[string]any, options Options) bool {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return false
|
||||
}
|
||||
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return false
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
|
||||
if event.Action != PermissionActionPrompt {
|
||||
return nil
|
||||
@@ -2748,21 +2909,89 @@ func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
|
||||
// normalizeNoopAdditionalPermissions treats a provider-expanded empty or
|
||||
// already-covered additional_permissions object as if it had been omitted.
|
||||
// Some strict-schema providers materialize every optional property and may add
|
||||
// a workspace read that the default sandbox already grants; rejecting either
|
||||
// no-op makes the model retry an otherwise valid command. Requests that broaden
|
||||
// access and malformed payloads are left untouched so validation stays closed.
|
||||
func normalizeNoopAdditionalPermissions(args map[string]any, basePath string, engine *sandbox.Engine) {
|
||||
raw, exists := args["additional_permissions"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
// An explicit null is the field omitted, not a request: drop the key so the
|
||||
// rest of the pipeline sees the same args a model that omitted it would send.
|
||||
// sandbox_permissions is deliberately left alone — a flagged null must reach
|
||||
// the same "no additional_permissions object was provided" error as a flagged
|
||||
// absent field rather than being silently downgraded to a different outcome.
|
||||
if raw == nil {
|
||||
delete(args, "additional_permissions")
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var profile sandbox.RequestPermissionProfile
|
||||
if err := json.Unmarshal(data, &profile); err != nil {
|
||||
return
|
||||
}
|
||||
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
noop := normalized.Empty()
|
||||
if !noop && engine != nil {
|
||||
if grantProfile, grantErr := sandbox.RequestPermissionGrantProfile(normalized); grantErr == nil {
|
||||
noop = engine.CoversRequestPermissions(grantProfile)
|
||||
}
|
||||
}
|
||||
if !noop {
|
||||
return
|
||||
}
|
||||
delete(args, "additional_permissions")
|
||||
if rawMode, ok := args["sandbox_permissions"].(string); ok &&
|
||||
strings.TrimSpace(rawMode) == string(tools.SandboxPermissionsWithAdditionalPermissions) {
|
||||
args["sandbox_permissions"] = string(tools.SandboxPermissionsUseDefault)
|
||||
}
|
||||
}
|
||||
|
||||
// omitAdditionalPermissionsHint is the one instruction that ends the retry loop
|
||||
// in #845/#847. Every additional_permissions rejection must carry it: the model
|
||||
// reaches these errors by attaching a permission object to a command that needs
|
||||
// none, so "omit it and retry" is the answer in practice, and an error that
|
||||
// only explains how to build a VALID permission object sends the model round
|
||||
// the loop again with a different invalid shape.
|
||||
const omitAdditionalPermissionsHint = "If this command does not need extra sandbox access — ordinary " +
|
||||
"read-only commands such as `git branch --show-current` do not — omit both sandbox_permissions and " +
|
||||
"additional_permissions entirely and retry; that is the fix in almost every case."
|
||||
|
||||
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
|
||||
if !shellCommandAdditionalPermissionsRequested(args) {
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
// An explicit null grants nothing and carries no flag, so it is
|
||||
// indistinguishable from an omitted field; rejecting it would be a pure
|
||||
// false positive against providers that materialize every optional
|
||||
// property.
|
||||
if raw, exists := args["additional_permissions"]; exists && raw != nil {
|
||||
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf(
|
||||
"additional_permissions was provided without sandbox_permissions set to %q, so it cannot be applied. %s "+
|
||||
"Only if the command genuinely needs file or network access the sandbox denies, resend with "+
|
||||
`sandbox_permissions: %q AND a non-empty additional_permissions, for example {"network": {"enabled": true}}`,
|
||||
tools.SandboxPermissionsWithAdditionalPermissions,
|
||||
omitAdditionalPermissionsHint,
|
||||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
return sandbox.RequestPermissionProfile{}, false, nil
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
|
||||
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
|
||||
"omit sandbox_permissions entirely and retry",
|
||||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
"sandbox_permissions was set to %q but no additional_permissions object was provided. %s "+
|
||||
`Otherwise include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}`,
|
||||
tools.SandboxPermissionsWithAdditionalPermissions,
|
||||
omitAdditionalPermissionsHint)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2778,8 +3007,10 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"additional_permissions must include at least one of network or file_system, for example " +
|
||||
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||||
"additional_permissions granted nothing, so it cannot be applied. %s "+
|
||||
`Otherwise include a real permission, for example {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}`,
|
||||
omitAdditionalPermissionsHint)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -2898,13 +3129,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)
|
||||
|
||||
@@ -2938,22 +3171,20 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// 2. tool_search — always present, right after the stable block;
|
||||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||
// transition grows the tail instead of inserting into the eager block.
|
||||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||
// tool_search's compact catalog also stays byte-stable as tools load, so only
|
||||
// the appended full-schema tail changes.
|
||||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||
var hiddenTools []tools.Tool
|
||||
var deferredCatalog []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue // added explicitly between the eager block and the loaded tail
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
deferredCatalog = append(deferredCatalog, tool)
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -2966,15 +3197,16 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
return loadedTail[left].Name < loadedTail[right].Name
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
// Keep the catalog byte-stable as tools load. tool_search already redirects
|
||||
// requests for an exposed tool back to the current tool list, so retaining
|
||||
// loaded names costs only compact catalog lines and avoids invalidating the
|
||||
// provider cache at the loader definition on every load.
|
||||
discovery := tools.BuildToolSearchDescription(deferredCatalog)
|
||||
|
||||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||
// eager block and carries the discovery list for still-hidden tools.
|
||||
// eager block and carries the stable deferred-tool catalog.
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
@@ -3012,15 +3244,21 @@ func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition,
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
definition := zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
if tools.IsBuiltInApplyPatch(tool) {
|
||||
definition.Type = zeroruntime.ToolDefinitionFreeform
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -3101,11 +3339,17 @@ func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
|
||||
}
|
||||
|
||||
func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
|
||||
// Denied tools are never advertised in any mode. Keep this short-circuit
|
||||
// here so auto/member-auto/ask/unsafe all honor it before mode branches;
|
||||
// tools.ToolAdvertisedForPermissionMode repeats it for the tool_search path
|
||||
// which never enters this function.
|
||||
if tool.Safety().Permission == tools.PermissionDeny {
|
||||
return false
|
||||
}
|
||||
if permissionMode == PermissionModeSpecDraft {
|
||||
return toolAdvertisedInSpecDraft(tool)
|
||||
// plan/spec-draft policy lives in tools so tool_search and the dispatch
|
||||
// gate cannot drift. PermissionDeny was already checked above.
|
||||
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
|
||||
return tools.ToolAdvertisedForPermissionMode(tool, string(permissionMode))
|
||||
}
|
||||
if permissionMode == PermissionModeAuto {
|
||||
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
|
||||
@@ -3128,17 +3372,6 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
|
||||
switch tool.Name() {
|
||||
case "ask_user", "submit_spec":
|
||||
return true
|
||||
case "update_plan":
|
||||
return false
|
||||
}
|
||||
safety := tool.Safety()
|
||||
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
|
||||
}
|
||||
|
||||
func stopReasonFromToolResult(result ToolResult) StopReason {
|
||||
if result.Meta == nil {
|
||||
return ""
|
||||
@@ -3174,9 +3407,11 @@ func loadedToolsFromResult(meta map[string]string) []string {
|
||||
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
|
||||
for _, call := range remaining {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
@@ -3207,9 +3442,46 @@ func copyMessages(messages []Message) []Message {
|
||||
if message.Reasoning != nil {
|
||||
copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...)
|
||||
}
|
||||
if message.ChangedFiles != nil {
|
||||
copied[index].ChangedFiles = append([]string(nil), message.ChangedFiles...)
|
||||
}
|
||||
// Deep-copy image attachments (slice AND each Data byte slice) so the
|
||||
// raw image bytes are never aliased across history/request/result copies.
|
||||
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
// toolResultImageMessage builds the user message that carries a tool's images
|
||||
// to the model, or reports false when the tool produced none.
|
||||
//
|
||||
// The text names the tool so the model can tell which call an image came from
|
||||
// when several ran in one turn — the images arrive detached from their tool
|
||||
// result, so nothing else associates them.
|
||||
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
|
||||
for _, image := range result.Images {
|
||||
if len(image.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
mediaType := zeroruntime.NormalizeImageMediaType(image.MediaType)
|
||||
if mediaType == "" {
|
||||
// Outside the provider allow-list. Dropping it beats sending bytes a
|
||||
// provider will reject and failing the whole turn.
|
||||
continue
|
||||
}
|
||||
images = append(images, zeroruntime.ImageBlock{MediaType: mediaType, Data: image.Data})
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return zeroruntime.Message{}, false
|
||||
}
|
||||
label := result.Name
|
||||
if label == "" {
|
||||
label = "tool"
|
||||
}
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + ":",
|
||||
Images: images,
|
||||
}, true
|
||||
}
|
||||
+697
-5
@@ -332,7 +332,8 @@ type sandboxDeniedExecCommandRetryTool struct {
|
||||
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
|
||||
|
||||
type sandboxNetworkDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
calls []map[string]any
|
||||
deferFileObservationCommit []bool
|
||||
}
|
||||
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
|
||||
@@ -355,6 +356,7 @@ func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[str
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
|
||||
if shellNetworkAllowed(ctx, options.Sandbox, args) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "server started"}
|
||||
}
|
||||
@@ -397,7 +399,8 @@ func agentNativeBackendStub() sandbox.Backend {
|
||||
}
|
||||
|
||||
type sandboxNamespaceLimitedRetryTool struct {
|
||||
calls []map[string]any
|
||||
calls []map[string]any
|
||||
deferFileObservationCommit []bool
|
||||
}
|
||||
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
|
||||
@@ -416,8 +419,12 @@ func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) RunWithOptions(_ context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
|
||||
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
|
||||
}
|
||||
@@ -483,6 +490,9 @@ func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing
|
||||
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
|
||||
}
|
||||
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
|
||||
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
|
||||
}
|
||||
@@ -634,6 +644,9 @@ func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
|
||||
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
|
||||
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
|
||||
}
|
||||
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
|
||||
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
|
||||
}
|
||||
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("permission requests = %#v, want one network approval", requests)
|
||||
}
|
||||
@@ -1840,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 != "" {
|
||||
@@ -3378,6 +3391,378 @@ func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeAdvertisesOnlySafeTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, definition := range provider.requests[0].Tools {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
for _, want := range []string{"read_file", "list_directory", "glob", "grep", "skill", "ask_user", "update_plan"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("plan mode tools missing %q from %#v", want, names)
|
||||
}
|
||||
}
|
||||
for _, denied := range []string{"write_file", "edit_file", "apply_patch", "bash", "web_fetch", "lsp_navigate"} {
|
||||
if names[denied] {
|
||||
t.Fatalf("plan mode advertised denied tool %q in %#v", denied, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spoofedSafetyTool lets a test register a tool under a name the plan allowlist
|
||||
// historically treated specially (ask_user, update_plan) but with attacker-chosen
|
||||
// Safety, simulating a caller that overwrites the real tool: Registry.Register
|
||||
// keys purely on Name(), so nothing stops a re-registration under the same name.
|
||||
type spoofedSafetyTool struct {
|
||||
name string
|
||||
safety tools.Safety
|
||||
run func(ctx context.Context, args map[string]any) tools.Result
|
||||
}
|
||||
|
||||
func (tool spoofedSafetyTool) Name() string { return tool.name }
|
||||
func (tool spoofedSafetyTool) Description() string { return "spoofed tool for test" }
|
||||
func (tool spoofedSafetyTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (tool spoofedSafetyTool) Safety() tools.Safety { return tool.safety }
|
||||
func (tool spoofedSafetyTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tool.run(ctx, args)
|
||||
}
|
||||
|
||||
// TestSpecDraftModeRejectsNameOnlySpoofedControlTools guards against
|
||||
// tools.ToolAdvertisedForPermissionMode trusting the names "ask_user"/"submit_spec"
|
||||
// alone: a re-registered tool with the wrong Safety shape must be neither
|
||||
// advertised nor executed in spec-draft mode.
|
||||
func TestSpecDraftModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
safety tools.Safety
|
||||
}{
|
||||
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
|
||||
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow, Reason: "spoof"}},
|
||||
{name: "ask_user", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny, Reason: "spoof"}},
|
||||
{name: "submit_spec", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionDeny, Reason: "spoof"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/"+string(tc.safety.SideEffect)+"/"+string(tc.safety.Permission), func(t *testing.T) {
|
||||
written := filepath.Join(t.TempDir(), "spoofed.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(spoofedSafetyTool{
|
||||
name: tc.name,
|
||||
safety: tc.safety,
|
||||
run: func(ctx context.Context, args map[string]any) tools.Result {
|
||||
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
|
||||
return tools.Result{Status: tools.StatusOK, Output: "spoofed"}
|
||||
},
|
||||
})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: tc.name},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := Run(context.Background(), "spec", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeSpecDraft,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, definition := range provider.requests[0].Tools {
|
||||
if definition.Name == tc.name {
|
||||
t.Fatalf("spec-draft advertised spoofed %s with safety %+v", tc.name, tc.safety)
|
||||
}
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available") {
|
||||
t.Fatalf("expected spoofed %s denial, got %q", tc.name, denied)
|
||||
}
|
||||
if _, err := os.Stat(written); !os.IsNotExist(err) {
|
||||
t.Fatalf("spoofed %s should not have run, stat err=%v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanModeRejectsNameOnlySpoofedControlTools guards against
|
||||
// tools.ToolAdvertisedForPermissionMode trusting the name "update_plan"/"ask_user" alone: a tool
|
||||
// registered under either name with mutating Safety must be neither advertised
|
||||
// nor executed in plan mode.
|
||||
func TestPlanModeRejectsNameOnlySpoofedControlTools(t *testing.T) {
|
||||
for _, name := range []string{"update_plan", "ask_user"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
written := filepath.Join(root, "spoofed.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(spoofedSafetyTool{
|
||||
name: name,
|
||||
safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "spoofed"},
|
||||
run: func(ctx context.Context, args map[string]any) tools.Result {
|
||||
_ = os.WriteFile(written, []byte("spoofed"), 0o644)
|
||||
return tools.Result{Status: tools.StatusOK, Output: "spoofed write"}
|
||||
},
|
||||
})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: name},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, definition := range provider.requests[0].Tools {
|
||||
if definition.Name == name {
|
||||
t.Fatalf("plan mode advertised a spoofed %s carrying mutating Safety", name)
|
||||
}
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected spoofed %s denial, got %q", name, denied)
|
||||
}
|
||||
if _, err := os.Stat(written); !os.IsNotExist(err) {
|
||||
t.Fatalf("spoofed %s should not have run, stat err=%v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanModeDeniesLSPNavigateToolCalls locks the process-spawning boundary:
|
||||
// lsp_navigate is classified SideEffectRead but lazily starts a language server
|
||||
// via exec. Even if the model still emits a call (e.g. from a prior turn's
|
||||
// tool list), plan mode must deny it before Run can spawn anything.
|
||||
func TestPlanModeDeniesLSPNavigateToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedLSPNavigateTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "lsp_navigate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"op":"definition","path":"main.go","line":1,"character":1}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected plan mode lsp_navigate denial, got %q", denied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeDeniesHiddenToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("done")
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected plan mode denial, got %q", denied)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("write_file should not have written notes.txt, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeDeniesBashToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"printf ran > ran.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "plan", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in plan mode") {
|
||||
t.Fatalf("expected plan mode bash denial, got %q", denied)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "ran.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("bash should not have written ran.txt, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt guards against
|
||||
// the read-only gate's blind spot: request_permissions is dispatched by name in
|
||||
// executeToolCall before the registry-based ToolAdvertised check runs, so a
|
||||
// plan/spec-draft registry that simply omits the tool (rather than registering
|
||||
// it as denied) must not let the call fall through to a real grant.
|
||||
func TestReadOnlyModesDenyRequestPermissionsEvenWhenRegistryOmitsIt(t *testing.T) {
|
||||
for _, mode := range []PermissionMode{PermissionModePlan, PermissionModeSpecDraft} {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"need access","permissions":{"file_system":{"write":["/tmp/outside"]}}}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var promptCount int
|
||||
|
||||
result, err := Run(context.Background(), "task", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: mode,
|
||||
MaxTurns: 2,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
promptCount++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer)
|
||||
}
|
||||
if promptCount != 0 {
|
||||
t.Fatalf("request_permissions must be denied before reaching OnPermissionRequest, got %d prompts", promptCount)
|
||||
}
|
||||
var denied string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
denied = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(denied, "not available in "+string(mode)+" mode") {
|
||||
t.Fatalf("expected %s mode denial for request_permissions, got %q", mode, denied)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsWhenSubmitSpecReturnsReviewControl(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -3579,6 +3964,11 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
if !strings.Contains(strings.ToLower(placeholder), "aborted") {
|
||||
t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "flaky-2" && !message.IsError {
|
||||
t.Fatalf("aborted placeholder must carry error status: %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_use in the final assistant message must have a matching result.
|
||||
for _, message := range result.Messages {
|
||||
@@ -3593,6 +3983,33 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "failed-call" {
|
||||
if !message.IsError {
|
||||
t.Fatalf("failed tool result lost its structured status: %#v", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("failed tool result missing from message history: %#v", result.Messages)
|
||||
}
|
||||
|
||||
type secretEmittingTool struct{ output string }
|
||||
|
||||
func (t secretEmittingTool) Name() string { return "leak" }
|
||||
@@ -3679,11 +4096,13 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
|
||||
// caller's callback AND stamps token counters, and the run completes.
|
||||
func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 10, CompletionTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
onUsageCalls := 0
|
||||
onContextCalls := 0
|
||||
var contextPlan ContextBreakdown
|
||||
rec := trace.NewRecorder("tracing-session", "run-1", "test")
|
||||
if _, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "tracing-session",
|
||||
@@ -3692,6 +4111,10 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
Model: "test-model",
|
||||
Trace: rec,
|
||||
OnUsage: func(Usage) { onUsageCalls++ },
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
onContextCalls++
|
||||
contextPlan = breakdown
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -3712,12 +4135,21 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
|
||||
t.Fatalf("cached input token counter = %d, want 20", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterCacheWriteTokens); got != 10 {
|
||||
t.Fatalf("cache-write token counter = %d, want 10", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
|
||||
t.Fatalf("output token counter = %d, want 40", got)
|
||||
}
|
||||
if onUsageCalls == 0 {
|
||||
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
|
||||
}
|
||||
if onContextCalls != 1 || len(contextPlan.Blocks) != 2 || contextPlan.PrefixInvalidationReason != "initial" {
|
||||
t.Fatalf("context plan callback = calls %d, plan %#v", onContextCalls, contextPlan)
|
||||
}
|
||||
if len(tr.PrefixHashes) != 1 || tr.PrefixHashes[0].InvalidationReason != "initial" || tr.PrefixHashes[0].CompletePrefixHash != contextPlan.CompletePrefixHash {
|
||||
t.Fatalf("trace context evidence = %#v, plan %#v", tr.PrefixHashes, contextPlan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
|
||||
@@ -3743,3 +4175,263 @@ func TestRunNilTraceForwardsUsage(t *testing.T) {
|
||||
t.Fatal("OnUsage not forwarded when Trace is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSuppressesAdvisoryHooksInPlanMode: plan mode promises a read-only
|
||||
// turn for advisory hooks (sessionStart/sessionEnd/afterTool), which execute
|
||||
// configured host commands outside the advertised-tool and sandbox gates.
|
||||
// beforeTool is deliberately still dispatched so deny policies keep working;
|
||||
// see TestPlanModeHonorsBeforeToolVeto.
|
||||
func TestRunSuppressesAdvisoryHooksInPlanMode(t *testing.T) {
|
||||
goBinary, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
|
||||
goBinary = filepath.Join(goRoot, "bin", "go")
|
||||
if runtime.GOOS == "windows" {
|
||||
goBinary += ".exe"
|
||||
}
|
||||
if _, statErr := os.Stat(goBinary); statErr != nil {
|
||||
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
|
||||
}
|
||||
}
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
sessionMarker := filepath.Join(t.TempDir(), "session-marker-dir")
|
||||
afterToolMarker := filepath.Join(t.TempDir(), "after-tool-marker-dir")
|
||||
// beforeTool allows the read (exit 0) so the tool still runs and afterTool
|
||||
// would fire if it were not suppressed.
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(sessionMarker, "go.mod"), "marker"}, Enabled: true},
|
||||
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
|
||||
{ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"version"}, Enabled: true},
|
||||
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "read_file", Command: goBinary, Args: []string{"mod", "init", "-modfile", filepath.Join(afterToolMarker, "go.mod"), "marker"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
})
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("write notes.txt: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "plan drafted"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "plan something", provider, Options{
|
||||
SessionID: "session-plan",
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
events, err := audit.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
sawBeforeTool := false
|
||||
for _, event := range events {
|
||||
if event.Type != "hook_execution_started" {
|
||||
continue
|
||||
}
|
||||
switch event.Event {
|
||||
case hooks.EventBeforeTool:
|
||||
sawBeforeTool = true
|
||||
case hooks.EventSessionStart, hooks.EventSessionEnd, hooks.EventAfterTool:
|
||||
t.Fatalf("advisory hook %q executed during a plan-mode run", event.Event)
|
||||
}
|
||||
}
|
||||
if !sawBeforeTool {
|
||||
t.Fatal("expected beforeTool to still dispatch under plan mode (deny-gate must not fail open)")
|
||||
}
|
||||
for _, marker := range []string{sessionMarker, afterToolMarker} {
|
||||
if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("plan-mode run let advisory hook touch the filesystem via %q: %v", marker, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanModeHonorsBeforeToolVeto guards the fail-open hole where hooksSuppressed
|
||||
// used to skip beforeTool under plan mode, so a deny-policy hook that blocks
|
||||
// secret reads in auto mode would silently allow them under PermissionModePlan.
|
||||
func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
|
||||
goBinary, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
|
||||
goBinary = filepath.Join(goRoot, "bin", "go")
|
||||
if runtime.GOOS == "windows" {
|
||||
goBinary += ".exe"
|
||||
}
|
||||
if _, statErr := os.Stat(goBinary); statErr != nil {
|
||||
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
|
||||
}
|
||||
}
|
||||
// A non-zero exit from beforeTool is a veto. "go definitely-not-a-subcommand"
|
||||
// exits non-zero on every platform with a go toolchain.
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: goBinary, Args: []string{"definitely-not-a-go-subcommand"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
root := t.TempDir()
|
||||
secret := filepath.Join(root, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("SUPERSECRET"), 0o644); err != nil {
|
||||
t.Fatalf("write secret.txt: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
var toolOutputs []string
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"secret.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "blocked"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "read the secret", provider, Options{
|
||||
SessionID: "session-plan-veto",
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
PermissionMode: PermissionModePlan,
|
||||
MaxTurns: 2,
|
||||
OnToolResult: func(result ToolResult) {
|
||||
toolOutputs = append(toolOutputs, result.Output)
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(toolOutputs) == 0 {
|
||||
t.Fatal("expected a tool result for the vetoed read_file call")
|
||||
}
|
||||
combined := strings.Join(toolOutputs, "\n")
|
||||
if strings.Contains(combined, "SUPERSECRET") {
|
||||
t.Fatalf("plan mode failed open: beforeTool veto was skipped and secret leaked: %q", combined)
|
||||
}
|
||||
if !strings.Contains(combined, "blocked") && !strings.Contains(combined, "zero.veto") && !strings.Contains(strings.ToLower(combined), "hook") {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,6 +20,16 @@ type propagationOutputTool struct {
|
||||
|
||||
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||
for index, arg := range os.Args {
|
||||
if arg == "--zero-output-budget-hook-repeat" && index+1 < len(os.Args) {
|
||||
repeats, err := strconv.Atoi(os.Args[index+1])
|
||||
if err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
if _, err := os.Stdout.WriteString(strings.Repeat("hook feedback ", repeats)); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
|
||||
continue
|
||||
}
|
||||
@@ -30,25 +41,102 @@ func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
|
||||
feedback := strings.Repeat("hook feedback ", 200)
|
||||
return outputBudgetHookDispatcherFor("propagation_output", 200)
|
||||
}
|
||||
|
||||
func outputBudgetHookDispatcherFor(toolName string, repeats int) *hooks.Dispatcher {
|
||||
feedback := strings.Repeat("hook feedback ", repeats)
|
||||
hookArgs := []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook",
|
||||
feedback,
|
||||
}
|
||||
if repeats > 1_000 {
|
||||
hookArgs = []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook-repeat",
|
||||
strconv.Itoa(repeats),
|
||||
}
|
||||
}
|
||||
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{{
|
||||
ID: "large-feedback",
|
||||
Event: hooks.EventAfterTool,
|
||||
Matcher: "propagation_output",
|
||||
Matcher: toolName,
|
||||
Command: os.Args[0],
|
||||
Args: []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook",
|
||||
feedback,
|
||||
},
|
||||
Args: hookArgs,
|
||||
Enabled: true,
|
||||
}},
|
||||
}})
|
||||
}
|
||||
|
||||
func TestExecuteToolCallCommitsReadObservationAfterFinalBoundary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkPath := filepath.Join(root, "small.txt")
|
||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
tracker := tools.NewFileTracker()
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "read-small", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
||||
}, PermissionModeAuto, Options{Cwd: root, FileTracker: tracker})
|
||||
if abortErr != nil || result.Status != tools.StatusOK {
|
||||
t.Fatalf("executeToolCall: result=%#v err=%v", result, abortErr)
|
||||
}
|
||||
if !tracker.SeenWhole(resolvedPath) {
|
||||
t.Fatal("unmodified final read output was not committed as observed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallDoesNotCommitReadObservationBeforeHookBudget(t *testing.T) {
|
||||
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkPath := filepath.Join(root, "small.txt")
|
||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
tracker := tools.NewFileTracker()
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "read-hook", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
||||
}, PermissionModeAuto, Options{
|
||||
Cwd: root, FileTracker: tracker, Hooks: outputBudgetHookDispatcherFor("read_file", 20_000),
|
||||
})
|
||||
if abortErr != nil {
|
||||
t.Fatalf("executeToolCall: %v", abortErr)
|
||||
}
|
||||
if !result.Truncated {
|
||||
t.Fatalf("precondition: hook feedback was not truncated: %#v", result)
|
||||
}
|
||||
if tracker.SeenWhole(resolvedPath) {
|
||||
t.Fatal("read observation committed before hook output was budgeted")
|
||||
}
|
||||
}
|
||||
|
||||
func (tool propagationOutputTool) Name() string { return "propagation_output" }
|
||||
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
||||
func (tool propagationOutputTool) Parameters() tools.Schema {
|
||||
|
||||
@@ -31,6 +31,19 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
|
||||
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
|
||||
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
|
||||
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
|
||||
if custom.Type != "" {
|
||||
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// escalatingEngine allows unsandboxed escalation: no denied reads, so
|
||||
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
|
||||
// call to require_escalated.
|
||||
func escalatingEngine(t *testing.T) *sandbox.Engine {
|
||||
t.Helper()
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.DenyRead = nil
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
|
||||
}
|
||||
|
||||
// nonEscalatingEngine has a denied read, which is exactly the condition under
|
||||
// which escalation is refused, so no disclosure should be made.
|
||||
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
|
||||
}
|
||||
|
||||
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
|
||||
// agree on every input, because the defect being fixed is precisely that the
|
||||
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
|
||||
// only "allow command prefix for session".
|
||||
//
|
||||
// Asserting agreement rather than asserting each side's expected value is what
|
||||
// makes this hold up. If someone later widens or narrows the rewrite's guards
|
||||
// and forgets the flag, the two diverge here and this fails, which a pair of
|
||||
// independent expected-value tests would not catch.
|
||||
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
|
||||
escalating := Options{Sandbox: escalatingEngine(t)}
|
||||
denied := Options{Sandbox: nonEscalatingEngine(t)}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
toolName string
|
||||
args map[string]any
|
||||
options Options
|
||||
}{
|
||||
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
|
||||
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
|
||||
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
|
||||
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
|
||||
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
|
||||
{
|
||||
"call already asks for escalation",
|
||||
"bash",
|
||||
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
|
||||
escalating,
|
||||
},
|
||||
{
|
||||
"call already asks for additional permissions",
|
||||
"bash",
|
||||
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
|
||||
escalating,
|
||||
},
|
||||
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
|
||||
// the disclosure has to fire. Included because the obvious reading of
|
||||
// the guards says otherwise, and a case asserting the wrong thing here
|
||||
// would have passed silently.
|
||||
{
|
||||
"unrecognized sandbox_permissions value",
|
||||
"bash",
|
||||
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
|
||||
escalating,
|
||||
},
|
||||
{"empty args", "bash", map[string]any{}, escalating},
|
||||
{"nil args", "bash", nil, escalating},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
|
||||
|
||||
// What the user's approval would actually be turned into.
|
||||
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
|
||||
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
|
||||
|
||||
if disclosed != escalates {
|
||||
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The two prefix decisions are the only ones that escalate, so a disclosure
|
||||
// attached to a request offering neither would be a false alarm. Checked
|
||||
// against the same predicate the rewrite consults.
|
||||
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
|
||||
escalating := map[PermissionDecisionAction]bool{
|
||||
PermissionDecisionAllowPrefix: true,
|
||||
PermissionDecisionAlwaysAllowPrefix: true,
|
||||
}
|
||||
for _, action := range []PermissionDecisionAction{
|
||||
PermissionDecisionAllow,
|
||||
PermissionDecisionAllowStrict,
|
||||
PermissionDecisionAllowForSession,
|
||||
PermissionDecisionAllowPrefix,
|
||||
PermissionDecisionAlwaysAllowPrefix,
|
||||
PermissionDecisionAlwaysAllow,
|
||||
PermissionDecisionDeny,
|
||||
PermissionDecisionCancel,
|
||||
} {
|
||||
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
|
||||
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,20 @@ import (
|
||||
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 3550
|
||||
// Raised deliberately from 3550 on 2026-08-08. view_image (#843) took the set
|
||||
// to 3588, and #867 trimmed it back to 3578: still 28 tokens over.
|
||||
//
|
||||
// The overrun is a rounding error rather than a regression. view_image costs
|
||||
// 82 tokens, the second cheapest tool in the set, against exec_command's 909
|
||||
// and request_permissions' 397; it is simply the one that crossed the line.
|
||||
// Raised rather than paid down because the alternative was trimming a
|
||||
// description to claw back 28 tokens, degrading a tool's usability to satisfy
|
||||
// a line drawn against a 2026-07 measurement that predates two tools.
|
||||
//
|
||||
// The ratchet did its job: it caught the creep and forced a decision instead
|
||||
// of a drift. That is the point of it, so keep raising it deliberately rather
|
||||
// than reflexively.
|
||||
maxEagerToolSchemaTokens = 3650
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
@@ -46,6 +59,29 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
|
||||
// tool deferred leaves it exposed here and changes nothing. Deferral is
|
||||
// still worth doing for real sessions, it just cannot move this number.
|
||||
// The levers that do are a smaller schema, one fewer core tool, or a
|
||||
// deliberate raise.
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAdvertisesBothEditTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
|
||||
names := make(map[string]bool, len(exposed))
|
||||
for _, definition := range exposed {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
if !names["apply_patch"] {
|
||||
t.Fatal("agent must retain apply_patch for multi-hunk changes")
|
||||
}
|
||||
if !names["edit_file"] {
|
||||
t.Fatal("agent must receive edit_file for targeted exact replacements")
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,18 @@ func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSu
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
writeLengthPrefixed(&toolsSB, string(def.Type))
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
if def.Format != nil {
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Type)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Definition)
|
||||
}
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,38 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
|
||||
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
|
||||
|
||||
typed := base
|
||||
typed.Type = zeroruntime.ToolDefinitionFreeform
|
||||
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
|
||||
t.Fatal("ToolsHash must change when tool Type changes")
|
||||
}
|
||||
|
||||
formatCases := []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
|
||||
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
|
||||
}
|
||||
for _, test := range formatCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withFormat := base
|
||||
withFormat.Format = &test.format
|
||||
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
|
||||
if got.SchemaHash == baseFingerprint.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change for %s format input", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
@@ -123,6 +155,21 @@ func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintCoversTransientSystemPrompt(t *testing.T) {
|
||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
changed := base
|
||||
changed.TransientSystemPrompt = "turn-only guidance"
|
||||
|
||||
fpBase := ComputePrefixFingerprint(base, nil)
|
||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||
t.Fatal("SystemPromptHash must change for transient system guidance")
|
||||
}
|
||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||
t.Fatal("CompletePrefixHash must cover transient system guidance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
|
||||
// substrings helper for default Options: which substrings are non-empty,
|
||||
// which are empty, and that the substring-to-hash round-trip is lossless.
|
||||
|
||||
@@ -121,3 +121,67 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
|
||||
func isPrunedPlaceholder(content string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
|
||||
}
|
||||
|
||||
var supersedableReadTools = map[string]bool{
|
||||
"read_file": true,
|
||||
"read_minified_file": true,
|
||||
"grep": true,
|
||||
"glob": true,
|
||||
"list_directory": true,
|
||||
}
|
||||
|
||||
// pruneSupersededReadResults removes only older byte-identical results of the
|
||||
// same read call. It runs when context pressure already requires a rewrite, so
|
||||
// it does not trade a warm provider-cache prefix for a small apparent saving.
|
||||
func pruneSupersededReadResults(messages []zeroruntime.Message) ([]zeroruntime.Message, int) {
|
||||
type seenResultKey struct {
|
||||
call string
|
||||
content string
|
||||
}
|
||||
seen := map[seenResultKey]struct{}{}
|
||||
out := messages
|
||||
reclaimed := 0
|
||||
copied := false
|
||||
|
||||
for index := len(messages) - 1; index >= 0; index-- {
|
||||
message := messages[index]
|
||||
if message.Role != zeroruntime.MessageRoleTool || isPrunedPlaceholder(message.Content) {
|
||||
continue
|
||||
}
|
||||
call, ok := toolCallForResult(messages, index)
|
||||
if !ok || !supersedableReadTools[call.Name] {
|
||||
continue
|
||||
}
|
||||
key := seenResultKey{
|
||||
call: call.Name + "\x00" + strings.TrimSpace(call.Arguments),
|
||||
content: message.Content,
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
placeholder := fmt.Sprintf("[superseded identical %s result — the newer result remains in context]", call.Name)
|
||||
if !copied {
|
||||
out = append([]zeroruntime.Message(nil), messages...)
|
||||
copied = true
|
||||
}
|
||||
out[index].Content = placeholder
|
||||
reclaimed += max(0, ApproxTextTokens(message.Content)-ApproxTextTokens(placeholder))
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return out, reclaimed
|
||||
}
|
||||
|
||||
func toolCallForResult(messages []zeroruntime.Message, resultIndex int) (zeroruntime.ToolCall, bool) {
|
||||
id := messages[resultIndex].ToolCallID
|
||||
if id == "" {
|
||||
return zeroruntime.ToolCall{}, false
|
||||
}
|
||||
for index := resultIndex - 1; index >= 0; index-- {
|
||||
for _, call := range messages[index].ToolCalls {
|
||||
if call.ID == id {
|
||||
return call, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return zeroruntime.ToolCall{}, false
|
||||
}
|
||||
@@ -25,6 +25,13 @@ func toolCallMsg(id, name string) zeroruntime.Message {
|
||||
}
|
||||
}
|
||||
|
||||
func toolCallWithArgs(id, name, args string) zeroruntime.Message {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name, Arguments: args}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSkipsSmallSessions(t *testing.T) {
|
||||
// Total reclaimable is under the gate → no pruning.
|
||||
msgs := []zeroruntime.Message{
|
||||
@@ -120,3 +127,69 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
|
||||
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsOnlyDropsIdenticalOlderReads(t *testing.T) {
|
||||
body := strings.Repeat("same source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
||||
toolCallWithArgs("different", "read_file", `{"path":"b.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "different", Content: body},
|
||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
||||
}
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
||||
t.Fatalf("older duplicate was not pruned: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
||||
}
|
||||
if out[3].Content != body || out[5].Content != body {
|
||||
t.Fatal("different call and newest result must remain exact")
|
||||
}
|
||||
if messages[1].Content != body {
|
||||
t.Fatal("input slice was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsKeepsChangedAndMutatingResults(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("r1", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r1", Content: "version one"},
|
||||
toolCallWithArgs("r2", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r2", Content: "version two"},
|
||||
toolCallWithArgs("e1", "edit_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "edited"},
|
||||
toolCallWithArgs("e2", "edit_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "edited"},
|
||||
}
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed != 0 {
|
||||
t.Fatalf("unsafe results were pruned: %d", reclaimed)
|
||||
}
|
||||
for index := range messages {
|
||||
if out[index].Content != messages[index].Content {
|
||||
t.Fatalf("message %d changed unexpectedly", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsFindsMatchingBodyAcrossChangedRead(t *testing.T) {
|
||||
bodyA := strings.Repeat("version a\n", 100)
|
||||
bodyB := strings.Repeat("version b\n", 100)
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("old-a", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old-a", Content: bodyA},
|
||||
toolCallWithArgs("middle-b", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "middle-b", Content: bodyB},
|
||||
toolCallWithArgs("new-a", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new-a", Content: bodyA},
|
||||
}
|
||||
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
|
||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
||||
t.Fatalf("older matching body was not pruned across a changed result: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
||||
}
|
||||
if out[3].Content != bodyB || out[5].Content != bodyA {
|
||||
t.Fatal("changed middle result and newest matching result must remain exact")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,16 @@ func TestSpecialistDelegationContext(t *testing.T) {
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
for _, want := range []string{"reduce total context", "Do not delegate small direct work", "bounded assignment"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing evidence-driven delegation guidance %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"delegate to it proactively", "launch several specialists in parallel"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("retained broad delegation guidance %q: %q", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||
|
||||
@@ -95,6 +95,9 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
sections := []string{core}
|
||||
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
|
||||
sections = append(sections, transient)
|
||||
}
|
||||
if addendum := modelPromptAddendum(options.Model); addendum != "" {
|
||||
sections = append(sections, addendum)
|
||||
}
|
||||
@@ -118,6 +121,9 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
if project != "" {
|
||||
sections = append(sections, project)
|
||||
}
|
||||
if modeCtx := permissionModeContext(options); modeCtx != "" {
|
||||
sections = append(sections, modeCtx)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
@@ -179,21 +185,19 @@ func approvedCommandPrefixContext(options Options) string {
|
||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
|
||||
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
||||
// tool outputs out of the main context. It renders only when specialists are
|
||||
// known (which is only where the Task tool is actually registered), so a run with
|
||||
// no delegatable specialists produces the previous prompt unchanged.
|
||||
// specialistDelegationContext describes when a specialist earns its extra
|
||||
// context and coordination cost. It renders only when specialists are known
|
||||
// (which is only where the Task tool is actually registered), so a run with no
|
||||
// delegatable specialists produces the previous prompt unchanged.
|
||||
func specialistDelegationContext(options Options) string {
|
||||
if len(options.Specialists) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<specialists>\n")
|
||||
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
|
||||
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
||||
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
|
||||
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
||||
b.WriteString("Use the Task tool when a specialist's focused context, expertise, or an independent parallel subtask is likely to improve quality or reduce total context. ")
|
||||
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
|
||||
b.WriteString("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
|
||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||
for _, info := range options.Specialists {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -681,3 +685,12 @@ func gitBranchForPrompt(cwd string) string {
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func permissionModeContext(options Options) string {
|
||||
switch options.PermissionMode {
|
||||
case PermissionModePlan:
|
||||
return "Plan mode is active on this session. Your role is read-only exploration and planning: inspect the workspace and shape the plan with update_plan, but do not make changes to files or execute commands."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -30,16 +30,10 @@ work.
|
||||
read-before-edit discipline: inspect the target file and nearby callers,
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it live. The plan bar is the user's progress signal — call update_plan
|
||||
after EACH concrete unit of work (every file written, every command run), not
|
||||
just at coarse milestones: mark the finished item completed and the next one
|
||||
in_progress before you start it. A plan stuck at 0/N while files are landing is
|
||||
a bug, not economy — these calls are cheap, expected, and the update_plan cards
|
||||
are hidden from the transcript, so frequent updates cost the user nothing and
|
||||
never clutter the conversation. Keep at most one item in_progress, and never
|
||||
batch the updates to the end of the turn. Skip the plan for trivial one-step
|
||||
tasks.
|
||||
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
|
||||
work. Skip it for bounded changes in one component. Update only when the
|
||||
overall phase changes, keep at most one item in_progress, and never create a
|
||||
plan after the work is complete.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
@@ -54,33 +48,49 @@ work.
|
||||
## Editing discipline
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
|
||||
apply_patch - over shelling out to cat/sed/awk/python for file operations.
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
edit_file, apply_patch, write_file - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- For edits to existing files, prefer edit_file 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.
|
||||
- Use read_minified_file only to explore large or unfamiliar source. Use
|
||||
read_file for small or likely edit targets and exact text, comments, or line
|
||||
numbers. Do not read both unless a new need for exact content appears.
|
||||
- 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, use edit_file for a targeted change (old_string
|
||||
must match the file exactly and be unique, so include a few surrounding
|
||||
lines) and apply_patch when one coherent change spans several hunks or
|
||||
files. Use write_file only to create a file or when most of it changes; do
|
||||
not rewrite a whole file to change a few lines. Match the existing
|
||||
indentation, imports, and idioms. Match the file's comment density: do not
|
||||
add explanatory comments unless the user asks or the code is already
|
||||
comment-dense.
|
||||
- A successful edit result already confirms the change; do not re-read a file
|
||||
just to verify an edit that succeeded. If an edit fails, read the error, fix
|
||||
the old_string or hunk, and retry the same tool rather than switching to a
|
||||
full rewrite.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
over-built; if a 200-line solution could be 50, rewrite it. Once the required
|
||||
behavior passes validation, stop instead of adding optional cleanup.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
## Testing gate (mandatory)
|
||||
|
||||
- After any change to code, verify after edits by running the project's
|
||||
validators before you summarize or commit: tests, type-checks, linters, and/or
|
||||
the build, as appropriate. Scope them to the change while iterating; reserve
|
||||
full-suite runs for milestones.
|
||||
- If you are unsure which validators apply, search the repo (Makefile, package
|
||||
manifests, CI config) to find them.
|
||||
- After code changes, run the project's documented appropriate validators.
|
||||
Scope iteration checks, combine compatible commands when diagnostics stay
|
||||
useful, and run the final set once after the last edit. Rerun only after a
|
||||
change or failure.
|
||||
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
|
||||
do not invent a parallel flow.
|
||||
- Never claim a task is done, and never commit, while validators are failing. If
|
||||
they fail, fix the cause and rerun; do not paper over it. If you could not run
|
||||
a validator, say so explicitly rather than implying success.
|
||||
- A successful native patch confirms the edit. Reread only when the next change
|
||||
needs exact content or the result was ambiguous.
|
||||
|
||||
## Tool use
|
||||
|
||||
@@ -102,7 +112,7 @@ work.
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
with exec_command, or use edit_file/apply_patch/write_file for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
@@ -138,6 +148,7 @@ work.
|
||||
- Default to concise, skimmable output. Lead with the answer or the result.
|
||||
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
||||
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
||||
short snippets. Reference code as `file:line` so it is clickable.
|
||||
short snippets. Reference code with file paths and include line numbers when
|
||||
already known; do not look them up solely for the final summary.
|
||||
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
|
||||
say so; when something is done and verified, state it plainly without hedging.
|
||||
@@ -60,8 +60,11 @@ const openAIPromptAddendum = `<model_guidance>
|
||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||
commands, and symbols.
|
||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
file work. Make one tool call per file; do not batch file writes into a script.
|
||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
|
||||
file work. Use them directly; never run apply_patch through exec_command.
|
||||
read_file, edit_file and write_file take one file per call, while one coherent
|
||||
apply_patch may span several files. Keep independent native calls in the same
|
||||
turn, and 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>`
|
||||
@@ -71,5 +74,6 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||
- Be concise and concrete. When you run a shell command with side effects, state
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
- Use update_plan only for work with at least three meaningful dependent steps;
|
||||
skip it for simple lookups and never create one after the work is complete.
|
||||
</model_guidance>`
|
||||
@@ -29,6 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
|
||||
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
|
||||
@@ -29,14 +29,17 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
|
||||
if strings.Contains(prompt, "clickable file paths") {
|
||||
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"read-before-edit",
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"prefer edit_file or apply_patch",
|
||||
"for edits to existing files, use edit_file",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
@@ -44,6 +47,13 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"do not recognize",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
"skip it for bounded changes in one component",
|
||||
"do not read both unless a new need for exact content appears",
|
||||
"combine compatible commands when diagnostics stay useful",
|
||||
"do not invent a parallel flow",
|
||||
"a successful native patch confirms the edit",
|
||||
"once the required behavior passes validation, stop",
|
||||
"do not look them up solely for the final summary",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -51,6 +61,16 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransientSystemPromptIsIncludedOnlyWhenProvided(t *testing.T) {
|
||||
const transient = "Handle this peer request under the current session policy."
|
||||
if prompt := buildSystemPrompt(Options{}); strings.Contains(prompt, transient) {
|
||||
t.Fatal("ordinary system prompt unexpectedly contains transient instructions")
|
||||
}
|
||||
if prompt := buildSystemPrompt(Options{TransientSystemPrompt: transient}); !strings.Contains(prompt, transient) {
|
||||
t.Fatalf("transient instructions missing from system prompt:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
for _, want := range []string{
|
||||
|
||||
+217
-10
@@ -1,10 +1,14 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
@@ -52,17 +56,41 @@ type taskVerificationState struct {
|
||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type taskFailureState struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
type taskApprovalState struct {
|
||||
Tool string `json:"tool"`
|
||||
Decision PermissionDecisionAction `json:"decision,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type taskArtifactState struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type taskStateSnapshot struct {
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||
ResolvedFailureKeys []string `json:"-"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
@@ -72,6 +100,7 @@ const (
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
taskStateEventPermission
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
@@ -80,6 +109,7 @@ type taskStateEvent struct {
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
permission PermissionEvent
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
@@ -101,6 +131,7 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
@@ -134,6 +165,7 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
state.observeToolEvidence(event.arguments, event.toolResult)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
@@ -160,6 +192,10 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventPermission:
|
||||
state.markActive()
|
||||
state.observePermission(event.permission)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
@@ -183,9 +219,180 @@ func (state *taskState) snapshot() taskStateSnapshot {
|
||||
snapshot := state.snapshotValue
|
||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
|
||||
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
|
||||
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
|
||||
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
|
||||
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const (
|
||||
maxTaskEvidenceEntries = 8
|
||||
maxTaskEvidenceBytes = 320
|
||||
maxTaskConstraints = 10
|
||||
)
|
||||
|
||||
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
|
||||
command := capTaskEvidence(commandFromArguments(arguments))
|
||||
key := taskFailureKey(result.Name, command, arguments)
|
||||
if result.Status == tools.StatusOK {
|
||||
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
|
||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
|
||||
}
|
||||
} else {
|
||||
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
|
||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
|
||||
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
|
||||
}, maxTaskEvidenceEntries)
|
||||
}
|
||||
for _, metaKey := range []string{"spill_path", "artifact_path"} {
|
||||
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
|
||||
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
|
||||
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func taskFailureKey(toolName, command, arguments string) string {
|
||||
identity := command
|
||||
if identity == "" {
|
||||
canonical := []byte(strings.TrimSpace(arguments))
|
||||
var value any
|
||||
if json.Unmarshal(canonical, &value) == nil {
|
||||
if encoded, err := json.Marshal(value); err == nil {
|
||||
canonical = encoded
|
||||
}
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
identity = fmt.Sprintf("args:%x", digest[:8])
|
||||
}
|
||||
return strings.TrimSpace(toolName) + "\x00" + identity
|
||||
}
|
||||
|
||||
func (state *taskState) observePermission(event PermissionEvent) {
|
||||
decision := event.DecisionAction
|
||||
if decision == "" {
|
||||
decision = PermissionDecisionAction(event.Action)
|
||||
}
|
||||
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
|
||||
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
|
||||
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
|
||||
}, maxTaskEvidenceEntries)
|
||||
}
|
||||
|
||||
func commandFromArguments(arguments string) string {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"cmd", "command", "script", "shell"} {
|
||||
var command string
|
||||
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
|
||||
return strings.TrimSpace(command)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstLine(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if index := strings.IndexByte(value, '\n'); index >= 0 {
|
||||
value = value[:index]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func capTaskEvidence(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= maxTaskEvidenceBytes {
|
||||
return value
|
||||
}
|
||||
limit := maxTaskEvidenceBytes - 3
|
||||
for limit > 0 && !utf8.RuneStart(value[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(value[:limit]) + "..."
|
||||
}
|
||||
|
||||
var explicitConstraintPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
|
||||
regexp.MustCompile(`(?i)\bdon'?t want\b`),
|
||||
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
|
||||
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
|
||||
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
|
||||
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
|
||||
}
|
||||
|
||||
func extractExplicitConstraints(text string) []string {
|
||||
var constraints []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
|
||||
continue
|
||||
}
|
||||
for _, pattern := range explicitConstraintPatterns {
|
||||
if pattern.MatchString(line) {
|
||||
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(constraints) == maxTaskConstraints {
|
||||
break
|
||||
}
|
||||
}
|
||||
return constraints
|
||||
}
|
||||
|
||||
func appendBounded[T any](values []T, value T, limit int) []T {
|
||||
values = append(values, value)
|
||||
if len(values) > limit {
|
||||
values = values[len(values)-limit:]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
|
||||
filtered := values[:0]
|
||||
for _, existing := range values {
|
||||
if existing != value {
|
||||
filtered = append(filtered, existing)
|
||||
}
|
||||
}
|
||||
return appendBounded(filtered, value, limit)
|
||||
}
|
||||
|
||||
func hasTaskFailure(values []taskFailureState, key string) bool {
|
||||
for _, value := range values {
|
||||
if value.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
|
||||
out := values[:0]
|
||||
for _, value := range values {
|
||||
if value.Key != key {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeString(values []string, target string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value != target {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// observePlanParity compares only the latest plan projection with the plan tool
|
||||
// calls still present in messages. It mutates the snapshot and emits when the
|
||||
// parity value changes; objective, tool, and verification fields are not part of
|
||||
|
||||
@@ -115,6 +115,68 @@ func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
|
||||
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
|
||||
arguments := `{"cmd":"go test ./internal/agent"}`
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
|
||||
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
|
||||
}})
|
||||
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
|
||||
Scope: "/tmp", DecisionReason: "write a temporary fixture",
|
||||
}})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
|
||||
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
|
||||
}
|
||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
|
||||
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
|
||||
}
|
||||
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
|
||||
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
|
||||
}
|
||||
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
|
||||
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
|
||||
}})
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
|
||||
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
|
||||
}
|
||||
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
|
||||
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
|
||||
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
|
||||
}})
|
||||
snapshot = state.snapshot()
|
||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
|
||||
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
|
||||
}
|
||||
for _, key := range snapshot.ResolvedFailureKeys {
|
||||
if key == snapshot.UnresolvedFailures[0].Key {
|
||||
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// imageTool returns a tool result carrying an image, the way a screenshot tool
|
||||
// should be able to.
|
||||
type imageTool struct {
|
||||
media string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (imageTool) Name() string { return "capture" }
|
||||
func (imageTool) Description() string { return "Captures an image." }
|
||||
func (imageTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (imageTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t imageTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "Captured a screenshot.",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: t.media, Data: t.data}},
|
||||
}
|
||||
}
|
||||
|
||||
// A tool that produces an image must get that image in front of the model.
|
||||
//
|
||||
// It cannot ride the tool-result message: every provider drops images there.
|
||||
// Anthropic maps a tool result to a tool_result block with string content,
|
||||
// Gemini to a functionResponse, and OpenAI guards its image parts to the user
|
||||
// role explicitly. So the image has to arrive as a following user message,
|
||||
// which is also the only shape that keeps one tool result per tool call.
|
||||
func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "I can see it."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
var carrier *zeroruntime.Message
|
||||
for index := range result.Messages {
|
||||
if len(result.Messages[index].Images) > 0 {
|
||||
carrier = &result.Messages[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if carrier == nil {
|
||||
t.Fatal("no message carried the tool's image; the model never saw it")
|
||||
}
|
||||
if carrier.Role != zeroruntime.MessageRoleUser {
|
||||
t.Errorf("image rode a %q message; every provider only serializes images on the user role", carrier.Role)
|
||||
}
|
||||
if got := carrier.Images[0].MediaType; got != "image/png" {
|
||||
t.Errorf("media type = %q, want image/png", got)
|
||||
}
|
||||
|
||||
// The tool-result pairing must be untouched: one tool result per tool call.
|
||||
toolResults := 0
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults++
|
||||
}
|
||||
}
|
||||
if toolResults != 1 {
|
||||
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
|
||||
}
|
||||
}
|
||||
|
||||
// textTool is a second, image-free tool so a turn can carry two calls.
|
||||
type textTool struct{}
|
||||
|
||||
func (textTool) Name() string { return "note" }
|
||||
func (textTool) Description() string { return "Notes something." }
|
||||
func (textTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (textTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
|
||||
func (textTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "noted"}
|
||||
}
|
||||
|
||||
// Tool results for one assistant turn must stay contiguous. The image messages
|
||||
// are user-role, and a user message between two tool_results breaks strict
|
||||
// provider replay: Anthropic coalesces consecutive user content into one block
|
||||
// list and requires tool_result blocks first, so interleaving produces
|
||||
// [tool_result, text, image, tool_result] and the request is rejected.
|
||||
//
|
||||
// The first version of this feature appended each image immediately after its
|
||||
// own tool result, which is exactly that shape. A single-tool-call test passed
|
||||
// and said nothing about it.
|
||||
func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
registry.Register(textTool{})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c2", ToolName: "note"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c2", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// Two separate ifs rather than a switch: a tool-role message that also
|
||||
// carried images would match only the first arm of a switch, so a regression
|
||||
// that put the image back on the tool result would read as "no image at all"
|
||||
// instead of naming what actually happened.
|
||||
type toolResult struct {
|
||||
index int
|
||||
id string
|
||||
}
|
||||
var toolResults []toolResult
|
||||
imageIndex := -1
|
||||
for index, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults = append(toolResults, toolResult{index: index, id: message.ToolCallID})
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
if imageIndex >= 0 {
|
||||
t.Fatalf("image delivered twice, at %d and %d; recorded %s", imageIndex, index, messageShape(result.Messages))
|
||||
}
|
||||
imageIndex = index
|
||||
}
|
||||
}
|
||||
if imageIndex < 0 {
|
||||
t.Fatalf("the image never reached the model; recorded %s", messageShape(result.Messages))
|
||||
}
|
||||
if len(toolResults) != 2 {
|
||||
t.Fatalf("got %d tool-result messages for 2 tool calls; recorded %s", len(toolResults), messageShape(result.Messages))
|
||||
}
|
||||
if toolResults[0].id != "c1" || toolResults[1].id != "c2" {
|
||||
t.Errorf("tool results answered %q then %q, want c1 then c2", toolResults[0].id, toolResults[1].id)
|
||||
}
|
||||
// The contract Anthropic enforces: every tool_result block has to come before
|
||||
// any other block in the user message it lands in. Anything wedged between
|
||||
// the two results breaks that, which is the 400 this PR exists to avoid.
|
||||
if toolResults[1].index != toolResults[0].index+1 {
|
||||
t.Errorf("tool results at %d and %d are not adjacent; %q message at %d splits them; recorded %s",
|
||||
toolResults[0].index, toolResults[1].index, result.Messages[toolResults[0].index+1].Role,
|
||||
toolResults[0].index+1, messageShape(result.Messages))
|
||||
}
|
||||
// Follows the last tool result, deliberately NOT pinned to exactly last+1.
|
||||
// appendUserBlocks coalesces consecutive user-role content, so
|
||||
// [tool_result, tool_result, text, image] still maps to tool_result blocks
|
||||
// first and replays fine; pinning would redden that valid shape for nothing.
|
||||
if imageIndex <= toolResults[1].index {
|
||||
t.Errorf("image message at %d does not follow the last tool result at %d; recorded %s",
|
||||
imageIndex, toolResults[1].index, messageShape(result.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// messageShape renders the conversation as a compact role list so a failure says
|
||||
// what the ordering actually was instead of dumping whole messages.
|
||||
func messageShape(messages []zeroruntime.Message) string {
|
||||
parts := make([]string, 0, len(messages))
|
||||
for index, message := range messages {
|
||||
part := fmt.Sprintf("%d:%s", index, message.Role)
|
||||
if message.ToolCallID != "" {
|
||||
part += "(" + message.ToolCallID + ")"
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
part += "+image"
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
+78
-9
@@ -26,6 +26,15 @@ const (
|
||||
PermissionModeAsk PermissionMode = "ask"
|
||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
||||
// PermissionModePlan is an interactive, read-only planning mode. It applies
|
||||
// to the CURRENT session (unlike spec-draft, which drafts in a separate
|
||||
// session): the agent may inspect the workspace and shape the plan with
|
||||
// update_plan/ask_user, but no mutating tool is advertised, so it cannot
|
||||
// write files, run shell, or implement while planning. Entry points:
|
||||
// the TUI's /plan on (exit with /plan off, which restores whatever mode
|
||||
// was active before), `zero exec --plan`, and the ACP session mode
|
||||
// selector ("plan").
|
||||
PermissionModePlan PermissionMode = "plan"
|
||||
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
||||
// advertises the in-workspace mutators a member needs to build (write/edit +
|
||||
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
||||
@@ -68,14 +77,18 @@ type ToolResult struct {
|
||||
Output string
|
||||
// Truncated reports that the tool's model-visible output omitted content.
|
||||
// The full result may be recoverable through Meta["spill_path"].
|
||||
Truncated bool
|
||||
Meta map[string]string
|
||||
Truncated bool
|
||||
Meta map[string]string
|
||||
// Images the tool produced, delivered to the model as a following user
|
||||
// message rather than on this result. See tools.Result.Images.
|
||||
Images []zeroruntime.ImageBlock
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
Display tools.Display
|
||||
Outcome tools.ToolOutcome
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
@@ -96,6 +109,24 @@ type ToolResult struct {
|
||||
RequestedModel string
|
||||
}
|
||||
|
||||
// ModelOutput returns the bounded provider-facing result while preserving
|
||||
// compatibility with synthetic and restored results created before outcomes
|
||||
// were finalized.
|
||||
func (result ToolResult) ModelOutput() string {
|
||||
if result.Outcome.Finalized() {
|
||||
return result.Outcome.ModelView
|
||||
}
|
||||
return result.Output
|
||||
}
|
||||
|
||||
// HumanDisplay returns the presentation intended for interactive surfaces.
|
||||
func (result ToolResult) HumanDisplay() tools.Display {
|
||||
if result.Outcome.Finalized() {
|
||||
return result.Outcome.HumanView
|
||||
}
|
||||
return result.Display
|
||||
}
|
||||
|
||||
// DenialCategory classifies why a tool call was blocked before it executed.
|
||||
type DenialCategory string
|
||||
|
||||
@@ -173,6 +204,22 @@ type PermissionRequest struct {
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
||||
// PrefixApprovalEscalates reports that approving a command prefix will also
|
||||
// run the command OUTSIDE the sandbox, not merely stop asking about it.
|
||||
//
|
||||
// It exists because that consequence was real but invisible. Approving a
|
||||
// prefix rewrites the call to sandbox_permissions: require_escalated, which
|
||||
// resolves to a nil engine and genuinely unsandboxed execution, and the
|
||||
// engine's own escalation prompt is then satisfied by the approval just
|
||||
// given for the sandboxed form. So the operator authorized one thing and got
|
||||
// a wider one, having been shown only "allow command prefix for session".
|
||||
//
|
||||
// The escalation itself is deliberate and guarded: proposedCommandPrefix
|
||||
// refuses to offer a prefix while any other segment of the command is not
|
||||
// known-safe, precisely so an unreviewed segment cannot ride out of the
|
||||
// sandbox on it. What was missing was telling the person deciding, so this
|
||||
// surfaces it rather than removing it.
|
||||
PrefixApprovalEscalates bool `json:"prefixApprovalEscalates,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionDecision struct {
|
||||
@@ -278,8 +325,12 @@ type Options struct {
|
||||
ProviderName string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
ServiceTier string
|
||||
Cwd string
|
||||
SystemPrompt string
|
||||
// TransientSystemPrompt adds trusted runtime guidance for this run only.
|
||||
// Empty preserves the ordinary system prompt byte-for-byte.
|
||||
TransientSystemPrompt string
|
||||
// ResponseStyle is the operator-selected reply style from the TUI /style
|
||||
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
||||
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
||||
@@ -297,10 +348,26 @@ type Options struct {
|
||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||
CompactionPreserveLast int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// Summarizer, when set, lazily builds the provider used for compaction
|
||||
// summarization calls — typically a cheap/fast model, since summaries at
|
||||
// main-model prices are the single most expensive recurring event in a
|
||||
// long run. It receives the main model currently in force and may return
|
||||
// (nil, nil) when no dedicated summarizer applies to it (the main model is
|
||||
// already the cheap one). Built on the first paid compaction and again
|
||||
// after a mid-run model switch. Any failure (build or call) falls back to
|
||||
// the run's main provider until the next switch, so a misconfigured
|
||||
// summarizer can never break compaction. nil keeps today's behavior.
|
||||
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
// ContextWindowFor, when set, resolves a model ID to its context window so
|
||||
// the compactor can re-derive its threshold after a mid-run model switch
|
||||
// (escalate_model). Without it a switch keeps compacting against the
|
||||
// original model's window — overflowing a smaller target or over-compacting
|
||||
// a larger one. Return <= 0 when the model is unknown (window unchanged).
|
||||
ContextWindowFor func(modelID string) int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// FileTracker records per-session file read/write versions so the write tools
|
||||
// can detect a file changed on disk outside Zero since it was last read. nil
|
||||
// disables the check. Created once per session and threaded into every tool run.
|
||||
@@ -330,9 +397,11 @@ type Options struct {
|
||||
// specialist child process emits while running. The toolCallID identifies
|
||||
// which Task tool call the progress belongs to. nil is a no-op.
|
||||
OnToolProgress func(toolCallID string, event streamjson.Event)
|
||||
// OnContext, when set, is called once per turn with the per-category context
|
||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||
// OnContext, when set, is called for each main agent request with its
|
||||
// per-category context budget, including a replacement request after
|
||||
// compaction or a stall retry. Internal summarizer requests are excluded so
|
||||
// surfaces keep showing the active conversation budget. Opt-in like the other
|
||||
// callbacks; nil is a no-op.
|
||||
OnContext func(ContextBreakdown)
|
||||
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
||||
// mid-run: the loop calls it with the requested model id and, on success,
|
||||
|
||||
@@ -29,10 +29,6 @@ func ParseTraceEventKeys(stdout string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func TraceEventKeys(stdout string) []string {
|
||||
return ParseTraceEventKeys(stdout)
|
||||
}
|
||||
|
||||
func MissingTraceEvents(required []string, stdout string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, key := range ParseTraceEventKeys(stdout) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,3 +39,21 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
|
||||
// terminateOwnedProcess terminates cmd's process group directly when its launch
|
||||
// configuration proves it was made a group leader. This avoids fragile Getpgid
|
||||
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
||||
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
||||
//
|
||||
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
|
||||
// own convention. A command made its own session leader via Setsid (as opposed
|
||||
// to Setpgid) is also its own process-group leader in practice, but takes the
|
||||
// slower rediscovery path here since Setsid isn't checked. Harmless today
|
||||
// because TerminateCommand has exactly one caller in this codebase; worth
|
||||
// covering explicitly if Setsid-configured commands start using this path too.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
||||
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
return false, terminateProcess(cmd.Process.Pid)
|
||||
}
|
||||
@@ -147,11 +147,12 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
t.Fatalf("terminateProcess: %v", err)
|
||||
}
|
||||
|
||||
// The forked child must be gone too (poll until reaped by init).
|
||||
// The forked child must no longer be running. An orphaned zombie is already
|
||||
// dead but may remain visible briefly until the platform's init reaps it.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
// processStopped treats only ESRCH or an observed zombie state as stopped;
|
||||
// other errors (for example EPERM) remain live and keep polling.
|
||||
for !processStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||
@@ -159,3 +160,73 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandStopsUnconfiguredCommand(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("unconfigured command was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
|
||||
grace, poll := terminationGracePeriod, terminationPollInterval
|
||||
terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond
|
||||
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
|
||||
|
||||
// The leader exits immediately after launching the child. TerminateCommand
|
||||
// must capture and signal the process group before Wait reaps the leader and
|
||||
// makes its group identity unavailable.
|
||||
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read forked child pid: %v", err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse forked child pid %q: %v", line, err)
|
||||
}
|
||||
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !processStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived TerminateCommand", childPID)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func processStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
}
|
||||
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
|
||||
}
|
||||
@@ -3,11 +3,20 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
|
||||
// that has not terminated. x/sys/windows does not expose Windows' STILL_ACTIVE
|
||||
// constant.
|
||||
const stillActiveExitCode uint32 = 259
|
||||
|
||||
var terminateProcessForTest = terminateProcess
|
||||
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
||||
// setup is required (the POSIX build sets Setpgid here instead).
|
||||
@@ -16,3 +25,28 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, 0, 0)
|
||||
}
|
||||
|
||||
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
|
||||
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
|
||||
// can discover descendants only while the root PID still exists; unlike a POSIX
|
||||
// process group, a Windows tree has no independently addressable identity after
|
||||
// its root exits.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
var (
|
||||
alreadyExited bool
|
||||
terminateErr error
|
||||
)
|
||||
err := cmd.Process.WithHandle(func(handle uintptr) {
|
||||
var exitCode uint32
|
||||
if windows.GetExitCodeProcess(windows.Handle(handle), &exitCode) == nil {
|
||||
alreadyExited = exitCode != stillActiveExitCode
|
||||
}
|
||||
// Keep the exact process identity pinned while the PID-based taskkill
|
||||
// operation runs, preventing the PID from being recycled underneath it.
|
||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build windows
|
||||
|
||||
package background
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
treeErr := errors.New("taskkill /T failed")
|
||||
originalTerminateProcess := terminateProcessForTest
|
||||
terminateProcessForTest = func(pid int) error {
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
return errors.Join(treeErr, err)
|
||||
}
|
||||
return treeErr
|
||||
}
|
||||
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
|
||||
|
||||
err := TerminateCommand(cmd)
|
||||
if !errors.Is(err, treeErr) {
|
||||
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("root process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
|
||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
// Let the child exit without calling Wait so cleanup exercises the Windows
|
||||
// taskkill/TerminateProcess race while it still owns the process handle.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
|
||||
// Process.Kill fallback) both fail here, but the reap below still succeeds —
|
||||
// the process-handle exit-code check proves the leader was already gone, so
|
||||
// TerminateCommand must not surface the expected kill-attempt error.
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("exited process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read descendant pid: %v", err)
|
||||
}
|
||||
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse descendant pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
|
||||
})
|
||||
|
||||
// Wait until the leader is observably exited but deliberately leave it
|
||||
// unreaped, matching the POSIX regression's ordering.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for processExistsOnWindows(cmd.Process.Pid) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("leader did not exit")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
|
||||
// Windows has no persistent group ID analogous to POSIX: once the leader is
|
||||
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
|
||||
// distinction rather than claiming TerminateCommand can provide it here.
|
||||
if !processExistsOnWindows(descendantPID) {
|
||||
t.Fatal("descendant unexpectedly stopped after its root exited")
|
||||
}
|
||||
}
|
||||
|
||||
func processExistsOnWindows(pid int) bool {
|
||||
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
const commandReapTimeout = 3 * time.Second
|
||||
|
||||
// TerminateProcess stops a background process by PID — on Windows its process
|
||||
// tree; on POSIX its whole process group when the PID leads its own group (the
|
||||
// invariant ConfigureChildProcessGroup establishes for processes started through
|
||||
@@ -12,3 +21,69 @@ package background
|
||||
func TerminateProcess(pid int) error {
|
||||
return terminateProcess(pid)
|
||||
}
|
||||
|
||||
// TerminateCommand stops a started command and reaps its leader. The caller must
|
||||
// have exclusive ownership of cmd: it must not have previously called Wait or
|
||||
// Process.Release, and no goroutine may call either concurrently. On POSIX it
|
||||
// stops the whole group when the command was configured as its leader; ordinary
|
||||
// commands safely fall back to PID/tree discovery. On Windows, success for a
|
||||
// leader that was already dead confirms only that the leader was reaped;
|
||||
// descendants may survive because Windows cannot rediscover a tree from a dead
|
||||
// root. `zero daemon start` needs this operation when readiness times out: it
|
||||
// launched the child, so it must both stop the tree and collect the leader.
|
||||
//
|
||||
// The order matters: the tree is signalled first, because Wait releases the
|
||||
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
|
||||
// to a recycled PID). Termination stays in the shared execution lifecycle
|
||||
// primitives rather than introducing a second platform-specific killer.
|
||||
//
|
||||
// If the bounded reap times out, the Wait call already started by this function
|
||||
// continues in its goroutine. From that point onward the caller must not access
|
||||
// cmd at all, including ProcessState, because that would race with Wait.
|
||||
func TerminateCommand(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return errors.New("terminate command: process was never started")
|
||||
}
|
||||
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
|
||||
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
|
||||
if reapErr != nil {
|
||||
if terminateErr != nil {
|
||||
return fmt.Errorf("%v (reap failed: %w)", terminateErr, reapErr)
|
||||
}
|
||||
return reapErr
|
||||
}
|
||||
if terminateErr != nil && !leaderAlreadyExited {
|
||||
return terminateErr
|
||||
}
|
||||
// Only discard a termination error when the platform demonstrated before
|
||||
// attempting termination that the leader had already exited. A successful
|
||||
// reap alone says nothing about whether a live descendant tree was stopped.
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
||||
// terminated is expected to report one (or a signal), so the only interesting
|
||||
// failure is Wait itself not working.
|
||||
func classifyWaitError(waitErr error) error {
|
||||
var exitErr *exec.ExitError
|
||||
if waitErr != nil && !errors.As(waitErr, &exitErr) {
|
||||
return fmt.Errorf("reap process: %w", waitErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForTerminatedCommandWithin bounds the reap so a child that somehow survives
|
||||
// termination cannot hang the caller — the daemon-start cleanup path runs while a
|
||||
// user waits at the CLI. On timeout its Wait goroutine is intentionally left to
|
||||
// finish: exec.Cmd permits only one Wait call, and abandoning it without a reaper
|
||||
// would leak the child once it eventually exits.
|
||||
func waitForTerminatedCommandWithin(cmd *exec.Cmd, timeout time.Duration) error {
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
select {
|
||||
case waitErr := <-waitDone:
|
||||
return classifyWaitError(waitErr)
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("process %d did not reap after termination", cmd.Process.Pid)
|
||||
}
|
||||
}
|
||||
+219
-51
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/peermsg"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -63,37 +64,53 @@ type appDeps struct {
|
||||
// getenv reads a process environment variable (production: os.Getenv, set in
|
||||
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
|
||||
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
releaseWorktree func(context.Context, worktrees.Options, string) error
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
createBranch func(context.Context, zerogit.BranchOptions) (zerogit.BranchResult, error)
|
||||
isDefaultBranch func(context.Context, zerogit.DefaultBranchOptions) (bool, string, string, error)
|
||||
currentGitUser func(context.Context, string) string
|
||||
headCommitSubject func(context.Context, string) string
|
||||
commitsAhead func(context.Context, string, string, string) (int, error)
|
||||
isUnbornRemote func(context.Context, string, string) (bool, error)
|
||||
refreshTrackingRef func(context.Context, string, string, string) error
|
||||
branchUpstreamRemote func(context.Context, string, string) string
|
||||
branchUpstreamRemoteAndMerge func(context.Context, string, string) (string, string)
|
||||
resolveRemoteBranchTip func(context.Context, string, string, string) (string, error)
|
||||
remoteHasBranch func(context.Context, string, string, string) (bool, error)
|
||||
currentGitBranch func(context.Context, string) string
|
||||
currentBranchTip func(context.Context, string) string
|
||||
deleteBranch func(context.Context, string, string, string) error
|
||||
resetBranchRef func(context.Context, string, string, string, string) error
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -187,6 +204,7 @@ func defaultAppDeps() appDeps {
|
||||
return mcp.RegisterTools(ctx, registry, cfg, options)
|
||||
},
|
||||
prepareWorktree: worktrees.Prepare,
|
||||
releaseWorktree: worktrees.Release,
|
||||
detectVerifyPlan: verify.DetectPlan,
|
||||
runVerify: verify.Run,
|
||||
runSelfVerify: selfverify.Run,
|
||||
@@ -195,11 +213,52 @@ func defaultAppDeps() appDeps {
|
||||
commitChanges: zerogit.Commit,
|
||||
pushChanges: zerogit.Push,
|
||||
createPR: zerogit.CreatePR,
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
createBranch: zerogit.CreateBranch,
|
||||
isDefaultBranch: zerogit.IsDefaultBranch,
|
||||
currentGitUser: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentGitUser(ctx, cwd, nil)
|
||||
},
|
||||
headCommitSubject: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.HeadCommitSubject(ctx, cwd, nil)
|
||||
},
|
||||
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
|
||||
return zerogit.CommitsAhead(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
|
||||
return zerogit.IsUnbornRemote(ctx, cwd, remote, nil)
|
||||
},
|
||||
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
|
||||
return zerogit.RefreshTrackingRef(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
|
||||
return zerogit.UpstreamRemote(ctx, cwd, branch, nil)
|
||||
},
|
||||
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
|
||||
return zerogit.UpstreamRemoteAndMergeBranch(ctx, cwd, branch, nil)
|
||||
},
|
||||
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
|
||||
return zerogit.ResolveRemoteBranchTip(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
|
||||
return zerogit.RemoteHasBranch(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
currentGitBranch: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentBranch(ctx, cwd, nil)
|
||||
},
|
||||
currentBranchTip: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentBranchTip(ctx, cwd, nil)
|
||||
},
|
||||
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
|
||||
return zerogit.DeleteBranch(ctx, cwd, fallbackBranch, branchToDelete, nil)
|
||||
},
|
||||
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
|
||||
return zerogit.ResetBranchRef(ctx, cwd, branch, newTip, nil, expectedOld)
|
||||
},
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,6 +591,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.prepareWorktree == nil {
|
||||
deps.prepareWorktree = defaults.prepareWorktree
|
||||
}
|
||||
if deps.releaseWorktree == nil {
|
||||
deps.releaseWorktree = defaults.releaseWorktree
|
||||
}
|
||||
if deps.detectVerifyPlan == nil {
|
||||
deps.detectVerifyPlan = defaults.detectVerifyPlan
|
||||
}
|
||||
@@ -556,6 +618,45 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.createPR == nil {
|
||||
deps.createPR = defaults.createPR
|
||||
}
|
||||
if deps.createBranch == nil {
|
||||
deps.createBranch = defaults.createBranch
|
||||
}
|
||||
if deps.isDefaultBranch == nil {
|
||||
deps.isDefaultBranch = defaults.isDefaultBranch
|
||||
}
|
||||
if deps.currentGitUser == nil {
|
||||
deps.currentGitUser = defaults.currentGitUser
|
||||
}
|
||||
if deps.headCommitSubject == nil {
|
||||
deps.headCommitSubject = defaults.headCommitSubject
|
||||
}
|
||||
if deps.commitsAhead == nil {
|
||||
deps.commitsAhead = defaults.commitsAhead
|
||||
}
|
||||
if deps.isUnbornRemote == nil {
|
||||
deps.isUnbornRemote = defaults.isUnbornRemote
|
||||
}
|
||||
if deps.refreshTrackingRef == nil {
|
||||
deps.refreshTrackingRef = defaults.refreshTrackingRef
|
||||
}
|
||||
if deps.branchUpstreamRemote == nil {
|
||||
deps.branchUpstreamRemote = defaults.branchUpstreamRemote
|
||||
}
|
||||
if deps.branchUpstreamRemoteAndMerge == nil {
|
||||
deps.branchUpstreamRemoteAndMerge = defaults.branchUpstreamRemoteAndMerge
|
||||
}
|
||||
if deps.remoteHasBranch == nil {
|
||||
deps.remoteHasBranch = defaults.remoteHasBranch
|
||||
}
|
||||
if deps.currentGitBranch == nil {
|
||||
deps.currentGitBranch = defaults.currentGitBranch
|
||||
}
|
||||
if deps.currentBranchTip == nil {
|
||||
deps.currentBranchTip = defaults.currentBranchTip
|
||||
}
|
||||
// resolveRemoteBranchTip, deleteBranch, and resetBranchRef stay nil when
|
||||
// unset so unit tests that mock createBranch without a real git tree do not
|
||||
// hit real git restore/delete commands.
|
||||
if deps.runTUI == nil {
|
||||
deps.runTUI = defaults.runTUI
|
||||
}
|
||||
@@ -585,6 +686,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
baseProbeProviderHealth := deps.probeProviderHealth
|
||||
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
|
||||
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
|
||||
if options.OAuthResolver == nil {
|
||||
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
|
||||
}
|
||||
return baseProbeProviderHealth(ctx, options)
|
||||
}
|
||||
return deps
|
||||
@@ -735,24 +839,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
mcpTokenStore = nil
|
||||
err = nil
|
||||
}
|
||||
criticalMCPConfig, optionalMCPConfig := splitMCPStartupConfig(mcpConfig)
|
||||
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
|
||||
if len(mcpConfig.Servers) > 0 {
|
||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
||||
if len(criticalMCPConfig.Servers) > 0 {
|
||||
runtime, registerErr := deps.registerMCPTools(context.Background(), registry, criticalMCPConfig, mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
closeMCPRuntime(stderr, mcpRuntime)
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
if registerErr != nil {
|
||||
closeMCPRuntime(stderr, runtime)
|
||||
return writeAppError(stderr, redaction.ErrorMessage(registerErr, redaction.Options{}), 1)
|
||||
}
|
||||
mcpRuntime = runtime
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// 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() {
|
||||
@@ -783,13 +889,57 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if permissionMode == "" {
|
||||
permissionMode = agent.PermissionModeAsk
|
||||
}
|
||||
sessionStore := deps.newSessionStore()
|
||||
peerClass := peermsg.PermissionPrompting
|
||||
if permissionMode == agent.PermissionModeUnsafe {
|
||||
peerClass = peermsg.PermissionBypass
|
||||
}
|
||||
peerService, peerErr := peermsg.New(peermsg.Options{
|
||||
Identity: peermsg.Identity{
|
||||
Cwd: workspaceRoot,
|
||||
PermissionClass: peerClass,
|
||||
},
|
||||
InboundPolicy: peermsg.InboundPolicy(resolved.CrossSessionInbound),
|
||||
})
|
||||
if peerErr != nil {
|
||||
fmt.Fprintf(stderr, "warning: cross-session messaging unavailable: %s\n", peerErr)
|
||||
peerService = nil
|
||||
} else {
|
||||
for _, tool := range tools.NewPeerSessionTools(peerService) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
// Activate deferred MCP-tool loading for the interactive run only when the
|
||||
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
||||
// The registry is complete (core + specialist + MCP + plugins) here, so the
|
||||
// count is accurate; below threshold this is a no-op and the surface is
|
||||
// unchanged. The interactive surface applies no operator tool filters, so
|
||||
// enabled/disabled are nil — matching the AgentOptions below.
|
||||
// This first pass covers every prompt-critical tool. Optional built-in MCP
|
||||
// defaults re-run the same gate after publishing their atomic batch, so a
|
||||
// later catalog generation cannot miss its loader. The interactive surface
|
||||
// applies no operator tool filters, so enabled/disabled are nil — matching the
|
||||
// AgentOptions below.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
var optionalMCPRuntime *optionalMCPStartup
|
||||
var awaitToolReadiness func(context.Context)
|
||||
if len(optionalMCPConfig.Servers) > 0 {
|
||||
optionalMCPRuntime = startOptionalMCP(
|
||||
context.Background(),
|
||||
registry,
|
||||
optionalMCPConfig,
|
||||
mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
},
|
||||
deps.registerMCPTools,
|
||||
func() {
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
},
|
||||
)
|
||||
defer closeMCPRuntime(stderr, optionalMCPRuntime)
|
||||
awaitToolReadiness = func(ctx context.Context) {
|
||||
optionalMCPRuntime.Await(ctx, optionalMCPPromptGrace)
|
||||
}
|
||||
}
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
@@ -813,6 +963,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
SavedPet: resolved.Preferences.Pet,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
@@ -823,10 +974,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecentModels: resolved.Preferences.RecentModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
CompactionModel: resolved.Preferences.CompactionModel,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
},
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
PrepareRunCompletionWarning: func() {
|
||||
scratchBaseline = scratchFileSnapshot(workspaceRoot)
|
||||
},
|
||||
@@ -834,7 +995,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
||||
},
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
AwaitToolReadiness: awaitToolReadiness,
|
||||
SessionStore: sessionStore,
|
||||
PeerService: peerService,
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
@@ -1196,7 +1359,8 @@ Commands:
|
||||
mcp Manage MCP backend settings
|
||||
auth Log in to model providers via OAuth
|
||||
sandbox Inspect sandbox policy and persistent grants
|
||||
update Check for Zero CLI updates
|
||||
update Check or apply Zero CLI updates (requires --check or --apply)
|
||||
upgrade Download, verify, and install available Zero CLI updates
|
||||
worktrees Prepare isolated git worktrees
|
||||
verify Detect and run local verification checks
|
||||
eval Validate offline agent eval suites
|
||||
@@ -1358,6 +1522,10 @@ Flags:
|
||||
--spec-model <model> Override the draft model when --use-spec is set
|
||||
--spec-reasoning-effort <effort>
|
||||
Override draft reasoning effort when --use-spec is set
|
||||
--plan Read-only planning mode: write and shell tools are hidden
|
||||
--permission-mode <mode> Set permission mode directly (plan, spec-draft, auto, member,
|
||||
ask, unsafe). Outranks --auto; prefer --plan / --auto for
|
||||
interactive use. Used by specialist/swarm child processes.
|
||||
--max-turns <number> Override the maximum agent loop turns
|
||||
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
|
||||
posture only (turn budget, effort, self-correction, escalation);
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -211,12 +212,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 +228,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())
|
||||
}
|
||||
}
|
||||
@@ -486,6 +487,74 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsPaintsBeforeOptionalDefaultMCPIsReady(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPermissionStore() error = %v", err)
|
||||
}
|
||||
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
configResult := make(chan error, 1)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
userConfigPath: func() (string, error) { return userConfigPath, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: config.DefaultMCPServers()}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return permissionStore, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return tokenStore, nil },
|
||||
registerMCPTools: func(_ context.Context, registry *tools.Registry, cfg config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
var configErr error
|
||||
if _, ok := cfg.Servers["exa"]; !ok || len(cfg.Servers) != 1 {
|
||||
configErr = fmt.Errorf("optional MCP config = %#v, want only exa", cfg.Servers)
|
||||
}
|
||||
configResult <- configErr
|
||||
close(started)
|
||||
<-release
|
||||
registry.Register(cliFakeMCPRegistryTool{})
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("optional MCP initialization did not start")
|
||||
}
|
||||
if options.AwaitToolReadiness == nil {
|
||||
t.Fatal("TUI did not receive optional tool readiness barrier")
|
||||
}
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); ok {
|
||||
t.Fatal("optional MCP tool was published before registration completed")
|
||||
}
|
||||
close(release)
|
||||
options.AwaitToolReadiness(ctx)
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); !ok {
|
||||
t.Fatal("optional MCP tool was not visible after readiness completed")
|
||||
}
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if configErr := <-configResult; configErr != nil {
|
||||
t.Fatal(configErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -565,6 +634,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
runtimeClosed := false
|
||||
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
@@ -591,7 +661,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
return closeFunc(func() error {
|
||||
runtimeClosed = true
|
||||
return nil
|
||||
}), errors.New("register mcp tools failed")
|
||||
}), fmt.Errorf("register mcp tools failed: %s", secret)
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
t.Fatal("TUI should not launch after MCP registration fails")
|
||||
@@ -605,6 +675,12 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "register mcp tools failed") {
|
||||
t.Fatalf("stderr missing registration error: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), secret) {
|
||||
t.Fatalf("stderr leaked MCP registration secret: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), redaction.RedactedSecret) {
|
||||
t.Fatalf("stderr missing redaction marker: %s", stderr.String())
|
||||
}
|
||||
if !runtimeClosed {
|
||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
||||
}
|
||||
@@ -1457,6 +1533,20 @@ func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopLevelHelpDocumentsRequiredUpdateMode(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "update Check or apply Zero CLI updates (requires --check or --apply)") {
|
||||
t.Fatalf("expected top-level help to document required update mode, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateReportsUpToDate(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -1660,6 +1750,7 @@ func assertHelpOutput(t *testing.T, args []string) {
|
||||
"mcp",
|
||||
"sandbox",
|
||||
"update",
|
||||
"upgrade",
|
||||
"worktrees",
|
||||
"verify",
|
||||
"serve",
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
secret := "sk-proj-" + strings.Repeat("a", 24)
|
||||
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
@@ -161,7 +161,7 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
|
||||
|
||||
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
secret := "sk-proj-" + strings.Repeat("b", 24)
|
||||
secret := "sk-proj-" + strings.Repeat("b", 23) + "0"
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
|
||||
@@ -24,7 +24,8 @@ var completionRoot = completionNode{
|
||||
children: []completionNode{
|
||||
{names: []string{"exec"}, flags: []string{
|
||||
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
|
||||
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--max-turns", "--exec-profile",
|
||||
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--plan", "--permission-mode",
|
||||
"--max-turns", "--exec-profile",
|
||||
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
|
||||
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
|
||||
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
|
||||
@@ -84,7 +85,7 @@ var completionRoot = completionNode{
|
||||
}},
|
||||
{names: []string{"update"}},
|
||||
{names: []string{"upgrade"}},
|
||||
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")},
|
||||
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare", "release")},
|
||||
{names: []string{"verify"}},
|
||||
{names: []string{"trust"}, children: leafNodes("list", "remove")},
|
||||
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
|
||||
|
||||
@@ -155,6 +155,8 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
|
||||
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
|
||||
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
|
||||
assertCandidates(t, byPath["worktrees"], "prepare", "release")
|
||||
assertCandidates(t, byPath["worktree"], "prepare", "release")
|
||||
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
|
||||
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
|
||||
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
|
||||
|
||||
@@ -40,10 +40,11 @@ func runContext(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
report, err := contextreport.Build(contextreport.Options{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Provider: resolved.Provider,
|
||||
Registry: newCoreRegistry(workspaceRoot),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Provider: resolved.Provider,
|
||||
Registry: newCoreRegistry(workspaceRoot),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
|
||||
+52
-10
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -160,20 +161,61 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
|
||||
if err := cmd.Start(); err != nil {
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return daemonReachable(paths) }, 5*time.Second, 25*time.Millisecond); err != nil {
|
||||
if errors.Is(err, errDaemonStartTimeout) {
|
||||
return writeAppError(stderr, err.Error()+"; see "+logPath, exitCrash)
|
||||
}
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
var errDaemonStartTimeout = errors.New("daemon did not come up within timeout")
|
||||
|
||||
func startAndAwaitDaemonProcess(cmd *exec.Cmd, reachable func() bool, timeout time.Duration, pollInterval time.Duration) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
|
||||
if waitForDaemonReadiness(reachable, timeout, pollInterval) {
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
cleanupErr := terminateAndReapDaemonProcess(cmd)
|
||||
if cleanupErr != nil {
|
||||
return fmt.Errorf("release daemon process: %w (cleanup failed: %v)", err, cleanupErr)
|
||||
}
|
||||
return fmt.Errorf("release daemon process: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := terminateAndReapDaemonProcess(cmd); err != nil {
|
||||
return fmt.Errorf("%w; cleanup failed: %v", errDaemonStartTimeout, err)
|
||||
}
|
||||
return errDaemonStartTimeout
|
||||
}
|
||||
|
||||
func waitForDaemonReadiness(reachable func() bool, timeout time.Duration, pollInterval time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if reachable() {
|
||||
return true
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(min(pollInterval, remaining))
|
||||
}
|
||||
// The loop's last reachable() call already ran immediately before this one
|
||||
// (right after the final Sleep, with the same deadline check separating
|
||||
// them), so this adds no extra waiting time and is not a grace window for a
|
||||
// slow-starting daemon — it only re-checks the same instant in case
|
||||
// reachable() itself is non-deterministic. To tolerate a genuinely slow
|
||||
// start, raise the timeout passed to startAndAwaitDaemonProcess instead.
|
||||
return reachable()
|
||||
}
|
||||
|
||||
func terminateAndReapDaemonProcess(cmd *exec.Cmd) error {
|
||||
return background.TerminateCommand(cmd)
|
||||
}
|
||||
|
||||
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
@@ -2,8 +2,15 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
)
|
||||
|
||||
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
||||
@@ -121,3 +128,84 @@ func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDaemonReadinessChecksTimeoutBoundary(t *testing.T) {
|
||||
checks := 0
|
||||
ready := waitForDaemonReadiness(func() bool {
|
||||
checks++
|
||||
return checks == 2
|
||||
}, 0, time.Millisecond)
|
||||
if !ready {
|
||||
t.Fatal("daemon that became reachable at the timeout boundary was not detected")
|
||||
}
|
||||
if checks != 2 {
|
||||
t.Fatalf("reachability checks = %d, want initial and final checks", checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateAndReapDaemonProcess(t *testing.T) {
|
||||
cmd := daemonDetachedChildCommand("hang")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start helper process: %v", err)
|
||||
}
|
||||
|
||||
if err := terminateAndReapDaemonProcess(cmd); err != nil {
|
||||
t.Fatalf("terminate and reap helper process: %v", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatalf("helper process was not reaped: state=%v", cmd.ProcessState)
|
||||
}
|
||||
if cmd.ProcessState.Success() {
|
||||
t.Fatalf("helper process unexpectedly exited successfully: %v", cmd.ProcessState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAndAwaitDaemonProcessTimeoutTerminatesAndReaps(t *testing.T) {
|
||||
cmd := daemonDetachedChildCommand("hang")
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return false }, 0, time.Millisecond); !errors.Is(err, errDaemonStartTimeout) {
|
||||
t.Fatalf("start and await error = %v, want timeout", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("timed-out helper process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAndAwaitDaemonProcessReleasesReadyChild(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "child-finished")
|
||||
cmd := daemonDetachedChildCommand("mark", "ZERO_TEST_DAEMON_CHILD_MARKER="+marker)
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return true }, time.Second, time.Millisecond); err != nil {
|
||||
t.Fatalf("start and await ready helper: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("released helper process did not remain alive to write its marker")
|
||||
}
|
||||
|
||||
func daemonDetachedChildCommand(mode string, extraEnv ...string) *exec.Cmd {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess")
|
||||
cmd.Env = append(os.Environ(), "ZERO_TEST_DAEMON_DETACHED_CHILD="+mode)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestDaemonDetachedChildProcess(t *testing.T) {
|
||||
switch os.Getenv("ZERO_TEST_DAEMON_DETACHED_CHILD") {
|
||||
case "":
|
||||
return
|
||||
case "mark":
|
||||
if err := os.WriteFile(os.Getenv("ZERO_TEST_DAEMON_CHILD_MARKER"), []byte("ready"), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}
|
||||
@@ -77,11 +77,10 @@ func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredEligibleCountIgnoresCoreTools(t *testing.T) {
|
||||
func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
// newCoreRegistry holds only built-ins; none implement Deferred().
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 2 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want at least 2 optional built-ins", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+66
-20
@@ -78,11 +78,18 @@ type execOptions struct {
|
||||
// fill-only-if-unset rule, so precedence is explicit flag > mode > profile.
|
||||
// Distinct from the mode preset also named "fast" (which picks a model) and
|
||||
// from the legacy inert --profile above. See internal/execprofile.
|
||||
execProfile string
|
||||
reasoningEffort string
|
||||
useSpec bool
|
||||
specModel string
|
||||
specReasoningEffort string
|
||||
execProfile string
|
||||
reasoningEffort string
|
||||
useSpec bool
|
||||
specModel string
|
||||
specReasoningEffort string
|
||||
// plan selects PermissionModePlan for this run: the same read-only,
|
||||
// in-session planning mode the TUI enters with /plan on. Unlike --use-spec
|
||||
// (a separate draft session with its own review flow), this only swaps the
|
||||
// permission mode for the run already being made — ToolAdvertised gates
|
||||
// write/shell tools generically for any mode, so no other exec plumbing
|
||||
// needs to know about it.
|
||||
plan bool
|
||||
maxTurns int
|
||||
cwd string
|
||||
inputFormat execInputFormat
|
||||
@@ -104,6 +111,7 @@ type execOptions struct {
|
||||
worktreeName string
|
||||
worktreeDir string
|
||||
skipPermissionsUnsafe bool
|
||||
permissionMode string
|
||||
// allowEscalation opts the run into mid-run model escalation: it registers
|
||||
// the escalate_model tool and wires agent.Options.ModelSwitcher. Off by
|
||||
// default — a run without the flag is byte-identical to before (no tool, nil
|
||||
@@ -199,11 +207,33 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
Name: options.worktreeName,
|
||||
BaseDir: options.worktreeDir,
|
||||
Now: deps.now,
|
||||
// The worktree's lifetime is bound to this process (the deferred
|
||||
// release below), so record the PID: if this process dies without
|
||||
// releasing, Clean can expire the lease instead of skipping the
|
||||
// locked worktree forever.
|
||||
LeasePID: os.Getpid(),
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
workspaceRoot = preparedWorktree.Path
|
||||
// When this run's own Prepare call took the worktree lock, its
|
||||
// lifetime is bound to this function: release the lock once it returns
|
||||
// so Clean can reclaim the worktree later if it goes stale. A reused
|
||||
// worktree whose lock an external `zero worktrees prepare` caller
|
||||
// still holds reports LockAcquired=false; releasing it here would
|
||||
// clear that caller's lease and expose its workspace to Clean, so the
|
||||
// matching release stays that caller's responsibility. A failed unlock
|
||||
// leaves a lock Clean will permanently skip, so it must not pass
|
||||
// silently; the run's primary result has already been emitted by the
|
||||
// time the defer runs, so surface it as a diagnostic.
|
||||
if preparedWorktree.LockAcquired {
|
||||
defer func() {
|
||||
if releaseErr := deps.releaseWorktree(context.Background(), worktrees.Options{Cwd: trustRoot}, preparedWorktree.Path); releaseErr != nil {
|
||||
fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %s\n", redactCLIString(preparedWorktree.Path), redactCLIString(releaseErr.Error()))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
@@ -240,6 +270,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
permissionMode = agent.PermissionModeSpecDraft
|
||||
}
|
||||
if options.plan {
|
||||
permissionMode = agent.PermissionModePlan
|
||||
}
|
||||
var mcpRuntime mcpToolRuntime
|
||||
var mcpSkip trustSkip
|
||||
// Make local plugins live for this run: register their declared tools into the
|
||||
@@ -306,11 +339,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
if permissionMode != agent.PermissionModePlan {
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
@@ -578,8 +613,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// warned, so --auto high silently ran without notice.
|
||||
if permissionMode == agent.PermissionModeUnsafe {
|
||||
reason := "--auto high"
|
||||
if options.skipPermissionsUnsafe {
|
||||
switch {
|
||||
case options.skipPermissionsUnsafe:
|
||||
reason = "--skip-permissions-unsafe"
|
||||
case strings.EqualFold(strings.TrimSpace(options.permissionMode), "unsafe"),
|
||||
strings.EqualFold(strings.TrimSpace(options.permissionMode), "high"):
|
||||
reason = "--permission-mode unsafe"
|
||||
}
|
||||
writer.warning(fmt.Sprintf("Unsafe permissions are active for this run because %s was passed.", reason))
|
||||
if writer.err != nil {
|
||||
@@ -643,16 +682,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ModelSwitcher: modelSwitcher,
|
||||
TurnSessionProvider: turnSessions,
|
||||
ModelSessionSwitcher: modelSessionSwitcher,
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
Summarizer: summarizerFactory(resolved, deps.newProvider),
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -900,6 +943,9 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
|
||||
if deferThreshold <= 0 {
|
||||
return
|
||||
}
|
||||
if _, registered := registry.Get(tools.ToolSearchToolName); registered {
|
||||
return
|
||||
}
|
||||
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,6 +20,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--permission-mode":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.permissionMode = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--permission-mode="):
|
||||
value, err := requiredInlineFlagValue(arg, "--permission-mode")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.permissionMode = value
|
||||
case arg == "--skip-permissions-unsafe":
|
||||
options.skipPermissionsUnsafe = true
|
||||
case arg == "--list-tools":
|
||||
@@ -171,6 +184,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
options.reasoningEffort = strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
|
||||
case arg == "--use-spec":
|
||||
options.useSpec = true
|
||||
case arg == "--plan":
|
||||
options.plan = true
|
||||
case arg == "--spec-model":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -437,6 +452,27 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
if !options.useSpec && options.specReasoningEffort != "" {
|
||||
return options, false, execUsageError{"--spec-reasoning-effort requires --use-spec."}
|
||||
}
|
||||
// Plan mode may be entered via --plan or --permission-mode plan. Combination
|
||||
// guards must key off both: otherwise --permission-mode plan bypasses the
|
||||
// worktree/unsafe/use-spec rejects that --plan alone enforces, and worktree
|
||||
// prep mutates the filesystem before the read-only mode is assigned.
|
||||
planMode := options.plan || strings.EqualFold(strings.TrimSpace(options.permissionMode), "plan")
|
||||
if planMode && options.useSpec {
|
||||
return options, false, execUsageError{"Use either --plan or --use-spec, not both."}
|
||||
}
|
||||
if planMode && options.skipPermissionsUnsafe {
|
||||
return options, false, execUsageError{"Use either --plan or --skip-permissions-unsafe, not both."}
|
||||
}
|
||||
if planMode && options.worktree {
|
||||
// Worktree prep (copying/branching the workspace) runs before the plan
|
||||
// permission mode is assigned, so it would happen even under plan mode's
|
||||
// read-only, no-side-effects promise. Reject the combination outright
|
||||
// rather than let a mutation slip in ahead of the mode gate.
|
||||
return options, false, execUsageError{"--plan cannot be combined with --worktree."}
|
||||
}
|
||||
if options.plan && options.permissionMode != "" && strings.ToLower(options.permissionMode) != "plan" {
|
||||
return options, false, execUsageError{"--plan cannot be combined with --permission-mode=" + options.permissionMode + "."}
|
||||
}
|
||||
if options.initSessionID != "" && (options.resume != "" || options.resumeLatest) {
|
||||
return options, false, execUsageError{"Use --init-session-id only when creating or forking a session."}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestParseExecArgsRecognizesPlanFlag(t *testing.T) {
|
||||
options, _, err := parseExecArgs([]string{"--plan", "draft a plan"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if !options.plan {
|
||||
t.Fatal("expected --plan to set options.plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecArgsRejectsPlanWithUseSpec(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--plan", "--use-spec", "draft a plan"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not both") {
|
||||
t.Fatalf("expected --plan/--use-spec validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecArgsRejectsPlanWithSkipPermissionsUnsafe(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--plan", "--skip-permissions-unsafe", "draft a plan"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not both") {
|
||||
t.Fatalf("expected --plan/--skip-permissions-unsafe validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseExecArgsRejectsPlanWithWorktree guards against worktree
|
||||
// preparation (a filesystem mutation) running ahead of the plan mode gate:
|
||||
// options.worktree is processed in runExec before the plan permission mode is
|
||||
// assigned, so the combination must be rejected during option validation,
|
||||
// before any worktree prep can occur.
|
||||
func TestParseExecArgsRejectsPlanWithWorktree(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--plan", "--worktree", "draft a plan"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--worktree") {
|
||||
t.Fatalf("expected --plan/--worktree validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecArgsRejectsPlanWithNonPlanPermissionMode(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--plan", "--permission-mode=ask", "draft a plan"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--permission-mode") {
|
||||
t.Fatalf("expected --plan/--permission-mode validation, got %v", err)
|
||||
}
|
||||
|
||||
options, _, err := parseExecArgs([]string{"--plan", "--permission-mode=plan", "draft a plan"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected --plan with --permission-mode=plan to succeed, got %v", err)
|
||||
}
|
||||
if !options.plan || options.permissionMode != "plan" {
|
||||
t.Fatalf("expected options.plan=true and permissionMode=plan, got plan=%v mode=%q", options.plan, options.permissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseExecArgsPermissionModePlanSharesCombinationGuards ensures the
|
||||
// combination rejects that key off --plan also fire for --permission-mode plan,
|
||||
// which reaches PermissionModePlan without setting options.plan.
|
||||
func TestParseExecArgsPermissionModePlanSharesCombinationGuards(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"worktree", []string{"--permission-mode", "plan", "--worktree", "draft a plan"}, "--worktree"},
|
||||
{"use-spec", []string{"--permission-mode=plan", "--use-spec", "draft a plan"}, "not both"},
|
||||
{"skip-permissions-unsafe", []string{"--permission-mode", "plan", "--skip-permissions-unsafe", "draft a plan"}, "not both"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := parseExecArgs(tc.args)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected --permission-mode plan / %s validation containing %q, got %v", tc.name, tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Bare --permission-mode plan (no conflicting flags) still parses.
|
||||
options, _, err := parseExecArgs([]string{"--permission-mode", "plan", "draft a plan"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected bare --permission-mode plan to succeed, got %v", err)
|
||||
}
|
||||
if options.permissionMode != "plan" || options.plan {
|
||||
t.Fatalf("expected permissionMode=plan and plan=false, got mode=%q plan=%v", options.permissionMode, options.plan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunExecPlanHidesWriteAndShellToolsFromListing drives the real --plan
|
||||
// flag through runExec (via --list-tools, so no provider is needed) and
|
||||
// confirms write_file and bash — advertised under every other mode covered by
|
||||
// TestRunExecListToolsAppliesModeBeforeListing-style tests — are hidden,
|
||||
// mirroring the TUI /plan on gating end to end from the CLI entry point.
|
||||
func TestRunExecPlanHidesWriteAndShellToolsFromListing(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--plan", "--list-tools"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
listing := stdout.String()
|
||||
if !strings.Contains(listing, "Tools visible to model") {
|
||||
t.Fatalf("expected --plan --list-tools to list tools, got %q", listing)
|
||||
}
|
||||
if !strings.Contains(listing, "read_file") {
|
||||
t.Fatalf("expected plan mode to still list read_file, got %q", listing)
|
||||
}
|
||||
for _, hidden := range []string{"write_file", "edit_file", "apply_patch", "bash"} {
|
||||
if strings.Contains(listing, hidden) {
|
||||
t.Fatalf("expected --plan to hide %q from the tool listing, got %q", hidden, listing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunExecPermissionModePlanHidesWriteAndShellToolsFromListing covers the
|
||||
// --permission-mode plan entry path, which does not set options.plan and must
|
||||
// still hide write and shell tools the same way --plan does.
|
||||
func TestRunExecPermissionModePlanHidesWriteAndShellToolsFromListing(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--permission-mode", "plan", "--list-tools"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
listing := stdout.String()
|
||||
if !strings.Contains(listing, "Tools visible to model") {
|
||||
t.Fatalf("expected --permission-mode plan --list-tools to list tools, got %q", listing)
|
||||
}
|
||||
if !strings.Contains(listing, "read_file") {
|
||||
t.Fatalf("expected plan mode to still list read_file, got %q", listing)
|
||||
}
|
||||
for _, hidden := range []string{"write_file", "edit_file", "apply_patch", "bash"} {
|
||||
if strings.Contains(listing, hidden) {
|
||||
t.Fatalf("expected --permission-mode plan to hide %q from the tool listing, got %q", hidden, listing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecPermissionModePlanOverride(t *testing.T) {
|
||||
options := execOptions{autonomy: "low", plan: true}
|
||||
mode, err := resolveExecPermissionMode(options)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveExecPermissionMode: %v", err)
|
||||
}
|
||||
// resolveExecPermissionMode itself only resolves --auto; the --plan override
|
||||
// is applied by the caller (runExec) afterward, same as --use-spec. This
|
||||
// pins the precondition: --plan must not interfere with autonomy resolution.
|
||||
if mode != agent.PermissionModeAuto {
|
||||
t.Fatalf("resolveExecPermissionMode with --plan = %q, want auto (override applied by the caller)", mode)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,24 @@ func toolListContains(names []string, want string) bool {
|
||||
}
|
||||
|
||||
func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error) {
|
||||
if pm := strings.ToLower(strings.TrimSpace(options.permissionMode)); pm != "" {
|
||||
switch pm {
|
||||
case "plan":
|
||||
return agent.PermissionModePlan, nil
|
||||
case "spec-draft", "spec_draft":
|
||||
return agent.PermissionModeSpecDraft, nil
|
||||
case "auto":
|
||||
return agent.PermissionModeAuto, nil
|
||||
case "member", "member-auto", "member_auto":
|
||||
return agent.PermissionModeMemberAuto, nil
|
||||
case "ask":
|
||||
return agent.PermissionModeAsk, nil
|
||||
case "unsafe", "high":
|
||||
return agent.PermissionModeUnsafe, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("Invalid permission mode %q. Expected plan, spec-draft, auto, member, ask, or unsafe.", options.permissionMode)}
|
||||
}
|
||||
}
|
||||
// Validate --auto first, regardless of --skip-permissions-unsafe, so an
|
||||
// invalid autonomy value is always rejected. (Previously the unsafe path
|
||||
// short-circuited before validation, letting "--auto bogus" slip through
|
||||
|
||||
+24
-16
@@ -142,13 +142,15 @@ func (writer *execEventWriter) checkpoint(event sessions.Event) {
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
modelOutput := result.ModelOutput()
|
||||
display := result.HumanDisplay()
|
||||
if writer.format == execOutputJSON {
|
||||
payload := map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_call_id": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": result.Output,
|
||||
"output": modelOutput,
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
payload["meta"] = result.Meta
|
||||
@@ -162,14 +164,14 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
payload["changed_files"] = result.ChangedFiles
|
||||
}
|
||||
if result.Display.Summary != "" || result.Display.Kind != "" {
|
||||
payload["display"] = map[string]string{"summary": result.Display.Summary, "kind": result.Display.Kind}
|
||||
if display.Summary != "" || display.Kind != "" {
|
||||
payload["display"] = map[string]string{"summary": display.Summary, "kind": display.Kind}
|
||||
}
|
||||
writer.writeJSON(payload)
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
output, surfaceTruncated := truncateForStreamJSONOutput(result.Output)
|
||||
output, surfaceTruncated := truncateForStreamJSONOutput(modelOutput)
|
||||
truncated := result.Truncated || surfaceTruncated
|
||||
event := streamjson.Event{
|
||||
Type: streamjson.EventToolResult,
|
||||
@@ -186,13 +188,13 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
redacted := true
|
||||
event.Redacted = &redacted
|
||||
}
|
||||
if result.Display.Summary != "" || result.Display.Kind != "" {
|
||||
event.Display = &streamjson.Display{Summary: result.Display.Summary, Kind: result.Display.Kind}
|
||||
if display.Summary != "" || display.Kind != "" {
|
||||
event.Display = &streamjson.Display{Summary: display.Summary, Kind: display.Kind}
|
||||
}
|
||||
writer.writeStreamJSON(event)
|
||||
return
|
||||
}
|
||||
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
|
||||
writer.writeStderr("[result] " + truncateForStatus(modelOutput) + "\n")
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) permission(event agent.PermissionEvent) {
|
||||
@@ -265,23 +267,29 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
|
||||
func (writer *execEventWriter) usage(usage agent.Usage) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
"type": "usage",
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"total_tokens": usage.TotalTokens(),
|
||||
"type": "usage",
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"cached_input_tokens": usage.CachedInputTokens,
|
||||
"cache_write_tokens": usage.CacheWriteTokens,
|
||||
"total_tokens": usage.TotalTokens(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
promptTokens := usage.EffectiveInputTokens()
|
||||
completionTokens := usage.EffectiveOutputTokens()
|
||||
cachedInputTokens := usage.CachedInputTokens
|
||||
cacheWriteTokens := usage.CacheWriteTokens
|
||||
totalTokens := usage.TotalTokens()
|
||||
writer.writeStreamJSON(streamjson.Event{
|
||||
Type: streamjson.EventUsage,
|
||||
RunID: writer.runID,
|
||||
PromptTokens: &promptTokens,
|
||||
CompletionTokens: &completionTokens,
|
||||
TotalTokens: &totalTokens,
|
||||
Type: streamjson.EventUsage,
|
||||
RunID: writer.runID,
|
||||
PromptTokens: &promptTokens,
|
||||
CompletionTokens: &completionTokens,
|
||||
CachedInputTokens: &cachedInputTokens,
|
||||
CacheWriteTokens: &cacheWriteTokens,
|
||||
TotalTokens: &totalTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +52,39 @@ func TestExecWriterPropagatesToolResultTruncation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecWriterPropagatesCacheUsage(t *testing.T) {
|
||||
for _, format := range []execOutputFormat{execOutputJSON, execOutputStreamJSON} {
|
||||
t.Run(string(format), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
writer := execEventWriter{
|
||||
stdout: &stdout,
|
||||
format: format,
|
||||
runID: "run_usage",
|
||||
streamedText: &strings.Builder{},
|
||||
}
|
||||
writer.usage(agent.Usage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 20,
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 20,
|
||||
CachedInputTokens: 60,
|
||||
CacheWriteTokens: 10,
|
||||
})
|
||||
if writer.err != nil {
|
||||
t.Fatalf("usage: %v", writer.err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), err)
|
||||
}
|
||||
cachedKey, writeKey := "cached_input_tokens", "cache_write_tokens"
|
||||
if format == execOutputStreamJSON {
|
||||
cachedKey, writeKey = "cachedInputTokens", "cacheWriteTokens"
|
||||
}
|
||||
if payload[cachedKey] != float64(60) || payload[writeKey] != float64(10) {
|
||||
t.Fatalf("cache usage missing from payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestRunPluginsListsJSONAndText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunHooksListsRedactedJSONAndText(t *testing.T) {
|
||||
secret := "sk-proj-" + strings.Repeat("a", 24)
|
||||
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||
result := hooks.LoadResult{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestRunHooksAddRejectsUnknownEvent(t *testing.T) {
|
||||
|
||||
func TestRunHooksAddJSONRedactsSecretArgs(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
secret := "sk-proj-" + strings.Repeat("z", 24)
|
||||
secret := "sk-proj-" + strings.Repeat("z", 23) + "0"
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runHooksAdd([]string{"h1", "--event", "beforeTool", "--command", "sh", "--arg", "-c", "--arg", "echo " + secret, "--json"}, &stdout, &stderr, hooksManageDeps(cwd))
|
||||
if code != exitSuccess {
|
||||
|
||||
@@ -516,7 +516,7 @@ func TestRunMCPRemovePreservesUnrelatedConfigFields(t *testing.T) {
|
||||
func TestRunMCPListRedactsURLCredentialsAndSensitiveQueryParams(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
serverURL := "https://user:[email protected]/mcp?access_token=secret-token&api_key=secret-key&safe=value#access_token=fragment-secret"
|
||||
commandSecret := "sk-proj-" + strings.Repeat("a", 24)
|
||||
commandSecret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
|
||||
@@ -308,6 +308,34 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
|
||||
// A connect failure does not come back as err. RegisterTools is deliberately
|
||||
// best-effort so one unreachable server cannot stop Zero launching: it records
|
||||
// the failure on the runtime and returns nil. That is right for startup and
|
||||
// wrong to ignore here, because this is the command a user runs precisely when
|
||||
// a server is misbehaving, and reporting "is reachable" for a server that never
|
||||
// started sends them looking somewhere else.
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
if skipped.Name != serverName {
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("MCP server %s is not reachable", serverName)
|
||||
if skipped.Err != nil {
|
||||
message = fmt.Sprintf("%s: %s", message, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
if options.json {
|
||||
payload := struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}{ServerName: serverName, Status: "unreachable", Error: message}
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitCrash
|
||||
}
|
||||
return writeAppError(stderr, message, exitCrash)
|
||||
}
|
||||
|
||||
items := mcpToolList(registry)
|
||||
if options.json {
|
||||
payload := struct {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
optionalMCPPromptGrace = time.Second
|
||||
optionalMCPCloseGrace = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// splitMCPStartupConfig keeps explicitly configured servers on the
|
||||
// prompt-critical path. Only unchanged built-in defaults may initialize after
|
||||
// first paint; their failure was already intentionally silent.
|
||||
func splitMCPStartupConfig(cfg config.MCPConfig) (critical config.MCPConfig, optional config.MCPConfig) {
|
||||
critical.Servers = map[string]config.MCPServerConfig{}
|
||||
optional.Servers = map[string]config.MCPServerConfig{}
|
||||
for name, server := range cfg.Servers {
|
||||
if server.Disabled {
|
||||
continue
|
||||
}
|
||||
if config.IsUnconfiguredDefault(name, server) {
|
||||
optional.Servers[name] = server
|
||||
continue
|
||||
}
|
||||
critical.Servers[name] = server
|
||||
}
|
||||
return critical, optional
|
||||
}
|
||||
|
||||
type optionalMCPStartup struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
runtime mcpToolRuntime
|
||||
|
||||
closeOnce sync.Once
|
||||
runtimeCloseOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func startOptionalMCP(
|
||||
parent context.Context,
|
||||
registry *tools.Registry,
|
||||
cfg config.MCPConfig,
|
||||
options mcp.RegisterOptions,
|
||||
register func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error),
|
||||
onReady func(),
|
||||
) *optionalMCPStartup {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
startup := &optionalMCPStartup{cancel: cancel, done: make(chan struct{})}
|
||||
go func() {
|
||||
runtime, err := register(ctx, registry, cfg, options)
|
||||
if err == nil && onReady != nil {
|
||||
onReady()
|
||||
}
|
||||
startup.mu.Lock()
|
||||
startup.runtime = runtime
|
||||
startup.mu.Unlock()
|
||||
close(startup.done)
|
||||
}()
|
||||
return startup
|
||||
}
|
||||
|
||||
// Await gives optional tools one bounded opportunity to join the next turn's
|
||||
// immutable registry snapshot. A timeout leaves this turn on the existing core
|
||||
// tool set; a later turn observes the completed generation.
|
||||
func (startup *optionalMCPStartup) Await(ctx context.Context, timeout time.Duration) bool {
|
||||
if startup == nil {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
default:
|
||||
}
|
||||
if timeout <= 0 {
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-startup.done:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) Close() error {
|
||||
if startup == nil {
|
||||
return nil
|
||||
}
|
||||
startup.closeOnce.Do(func() {
|
||||
startup.cancel()
|
||||
reaped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(reaped)
|
||||
<-startup.done
|
||||
startup.closeRuntime()
|
||||
}()
|
||||
timer := time.NewTimer(optionalMCPCloseGrace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-reaped:
|
||||
case <-timer.C:
|
||||
}
|
||||
})
|
||||
startup.mu.Lock()
|
||||
defer startup.mu.Unlock()
|
||||
return startup.closeErr
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) closeRuntime() {
|
||||
startup.runtimeCloseOnce.Do(func() {
|
||||
startup.mu.Lock()
|
||||
runtime := startup.runtime
|
||||
startup.mu.Unlock()
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
err := runtime.Close()
|
||||
startup.mu.Lock()
|
||||
if err != nil && startup.closeErr == nil {
|
||||
startup.closeErr = err
|
||||
}
|
||||
startup.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (startup *optionalMCPStartup) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type closeTrackingMCPRuntime struct {
|
||||
closed chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (runtime *closeTrackingMCPRuntime) Close() error {
|
||||
close(runtime.closed)
|
||||
return runtime.err
|
||||
}
|
||||
|
||||
func (*closeTrackingMCPRuntime) Skipped() []mcp.SkippedServer { return nil }
|
||||
|
||||
func TestSplitMCPStartupConfigDefersOnlyUnconfiguredDefaults(t *testing.T) {
|
||||
defaults := config.DefaultMCPServers()
|
||||
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"exa": defaults["exa"],
|
||||
"docs": {Type: "http", URL: "https://docs.example.test/mcp"},
|
||||
"disabled": {Type: "http", URL: "https://disabled.example.test/mcp", Disabled: true},
|
||||
}}
|
||||
|
||||
critical, optional := splitMCPStartupConfig(cfg)
|
||||
if _, ok := critical.Servers["docs"]; !ok || len(critical.Servers) != 1 {
|
||||
t.Fatalf("critical servers = %#v, want only docs", critical.Servers)
|
||||
}
|
||||
if _, ok := optional.Servers["exa"]; !ok || len(optional.Servers) != 1 {
|
||||
t.Fatalf("optional servers = %#v, want only exa", optional.Servers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPAwaitTimesOutThenObservesPublishedTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
registry,
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(ctx context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
registry.RegisterBatch([]tools.Tool{tools.NewScopedReadFileTool(t.TempDir(), nil)})
|
||||
return noopMCPRuntime{}, nil
|
||||
case <-ctx.Done():
|
||||
return noopMCPRuntime{}, ctx.Err()
|
||||
}
|
||||
},
|
||||
nil,
|
||||
)
|
||||
defer func() { _ = startup.Close() }()
|
||||
<-started
|
||||
|
||||
if startup.Await(t.Context(), time.Millisecond) {
|
||||
t.Fatal("optional startup unexpectedly completed before release")
|
||||
}
|
||||
if got := len(registry.All()); got != 0 {
|
||||
t.Fatalf("registry published tools before readiness: %d", got)
|
||||
}
|
||||
close(release)
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("optional startup did not complete after release")
|
||||
}
|
||||
if _, ok := registry.Get("read_file"); !ok {
|
||||
t.Fatal("completed optional startup did not publish its tool batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPFailedRegistrationSkipsReadinessCallback(t *testing.T) {
|
||||
readyCalls := 0
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, errors.New("connect failed")
|
||||
},
|
||||
func() { readyCalls++ },
|
||||
)
|
||||
defer func() { _ = startup.Close() }()
|
||||
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("failed optional startup did not settle")
|
||||
}
|
||||
if readyCalls != 0 {
|
||||
t.Fatalf("readiness callback ran after failed registration: %d", readyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseCancelsBlockedStartup(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(ctx context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
<-started
|
||||
if err := startup.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseReturnsRuntimeErrorWithinGrace(t *testing.T) {
|
||||
closeErr := errors.New("close failed")
|
||||
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{}), err: closeErr}
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return runtime, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if !startup.Await(t.Context(), time.Second) {
|
||||
t.Fatal("optional startup did not settle")
|
||||
}
|
||||
if err := startup.Close(); !errors.Is(err, closeErr) {
|
||||
t.Fatalf("Close() error = %v, want %v", err, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMCPCloseBoundsRegistrationThatIgnoresCancellation(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
close(release)
|
||||
}
|
||||
}()
|
||||
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{})}
|
||||
startup := startOptionalMCP(
|
||||
context.Background(),
|
||||
tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: config.DefaultMCPServers()},
|
||||
mcp.RegisterOptions{},
|
||||
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return runtime, nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
<-started
|
||||
|
||||
closeResult := make(chan error, 1)
|
||||
go func() { closeResult <- startup.Close() }()
|
||||
select {
|
||||
case err := <-closeResult:
|
||||
if err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close blocked on registration after cancellation")
|
||||
}
|
||||
|
||||
close(release)
|
||||
released = true
|
||||
select {
|
||||
case <-runtime.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("late optional MCP runtime was not closed")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -219,3 +220,81 @@ func TestRunMCPCheckNormalizeError(t *testing.T) {
|
||||
t.Fatalf("want normalize error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPCheckReportsUnreachableServer covers the case the command exists
|
||||
// for: a server that fails to start.
|
||||
//
|
||||
// RegisterTools is best-effort by design, so a connect failure is recorded on
|
||||
// the runtime and registration returns nil. runMCPCheck used to inspect only
|
||||
// that nil error and then print "is reachable", which told the user their
|
||||
// broken server was fine. The command is spawned for real here rather than
|
||||
// stubbed, because the whole failure mode was a mismatch between what
|
||||
// registration returns and what it records.
|
||||
func TestRunMCPCheckReportsUnreachableServer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "broken"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatalf("mcp check must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
|
||||
}
|
||||
if got := out.String(); strings.Contains(got, "is reachable") {
|
||||
t.Fatalf("mcp check reported a failed server as reachable: %q", got)
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "not reachable") {
|
||||
t.Fatalf("want an unreachable error naming the server, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The --json contract for an unreachable server, which the text case above does
|
||||
// not touch.
|
||||
//
|
||||
// The JSON branch is a separate return path with its own payload, so a caller
|
||||
// parsing it would otherwise be relying on a shape nothing pins: reverting the
|
||||
// check leaves it emitting status "ok" with a tool count, which is valid JSON
|
||||
// and completely wrong.
|
||||
func TestRunMCPCheckReportsUnreachableServerJSON(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "broken", "--json"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatalf("mcp check --json must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
|
||||
}
|
||||
var payload struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
ToolCount int `json:"toolCount"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode mcp check --json output %q: %v", out.String(), err)
|
||||
}
|
||||
if payload.ServerName != "broken" {
|
||||
t.Fatalf("serverName = %q, want the server that was checked", payload.ServerName)
|
||||
}
|
||||
if payload.Status != "unreachable" {
|
||||
t.Fatalf("status = %q, want %q", payload.Status, "unreachable")
|
||||
}
|
||||
if !strings.Contains(payload.Error, "not reachable") {
|
||||
t.Fatalf("error = %q, want it to say the server is not reachable", payload.Error)
|
||||
}
|
||||
// A reachable payload carries a tool count; an unreachable one must not
|
||||
// imply zero tools were found, which reads as a server that simply exposes
|
||||
// none.
|
||||
if payload.ToolCount != 0 {
|
||||
t.Fatalf("toolCount = %d, want it absent from an unreachable payload", payload.ToolCount)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
@@ -28,46 +23,5 @@ import (
|
||||
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
|
||||
// resolver, and the onboarding presence check never diverge.
|
||||
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
// No login under any candidate (or unreadable/invalid keys) → API-key
|
||||
// auth, no resolver.
|
||||
return nil, ""
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
// Refreshing a token the user logged into (possibly a preset provider like
|
||||
// xAI) re-resolves that provider's OAuth config, which needs the preset.
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var rerr error
|
||||
if forceRefresh {
|
||||
token, rerr = manager.Handle401(ctx, key)
|
||||
} else {
|
||||
token, rerr = manager.GetFresh(ctx, key)
|
||||
}
|
||||
if errors.Is(rerr, oauth.ErrNoToken) {
|
||||
// The login was removed since construction → fall back to the API key.
|
||||
return "", "", false, nil
|
||||
}
|
||||
if rerr != nil {
|
||||
return "", "", false, rerr
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
return resolver, key
|
||||
return providers.OAuthLoginForProfile(profile)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
@@ -109,6 +112,65 @@ func TestRunDoctorConnectivityProbesProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityUsesOAuthLogin(t *testing.T) {
|
||||
tokenPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: tokenPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{
|
||||
AccessToken: "oauth-doctor-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed OAuth token: %v", err)
|
||||
}
|
||||
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profile := config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/v1",
|
||||
Model: "grok-4",
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"doctor", "--connectivity"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Provider: profile,
|
||||
Providers: []config.ProviderProfile{profile},
|
||||
ActiveProvider: profile.Name,
|
||||
}, nil
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
now: fixedCLITime("2026-07-29T20:00:00Z"),
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("doctor exit = %d, want success; stderr=%q\n%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
if gotAuth != "Bearer oauth-doctor-token" {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[pass] provider.connectivity") {
|
||||
t.Fatalf("doctor did not report passing connectivity:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityReportsProviderHealthFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
@@ -73,6 +73,18 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
entry["description"] = description
|
||||
}
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
entry["reasoning_efforts"] = append([]string{}, model.ReasoningEfforts...)
|
||||
}
|
||||
if effort := strings.TrimSpace(model.DefaultReasoningEffort); effort != "" {
|
||||
entry["default_reasoning_effort"] = effort
|
||||
}
|
||||
if len(model.ServiceTiers) > 0 {
|
||||
entry["service_tiers"] = append([]string{}, model.ServiceTiers...)
|
||||
}
|
||||
if tier := strings.TrimSpace(model.DefaultServiceTier); tier != "" {
|
||||
entry["default_service_tier"] = tier
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
payload := map[string]any{
|
||||
|
||||
+35
-21
@@ -18,6 +18,8 @@ type sandboxCommandOptions struct {
|
||||
path string
|
||||
}
|
||||
|
||||
const permissionProfileScopeNote = "permissionProfile is derived from this process's environment and working directory; a command run with its own environment can add further credential deny-read entries."
|
||||
|
||||
func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.")
|
||||
@@ -91,16 +93,25 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
return runSandboxPolicyEffective(options, workspaceRoot, policy, backend, plan, store.FilePath(), writeRoots, writeRootsErr, stdout)
|
||||
}
|
||||
if options.json {
|
||||
// The profile here is built from Zero's own environment and working
|
||||
// directory, because that is all this command has: no command has been
|
||||
// chosen yet. A nested command carries its own cwd and env, and the
|
||||
// credential baseline resolves relative token overrides against those, so
|
||||
// an executed command can carry MORE denyReadIfExists entries than this
|
||||
// view lists. Say so in the payload rather than leaving a consumer to
|
||||
// assume it is the whole picture (see PermissionProfileFromPolicy).
|
||||
payload := struct {
|
||||
Policy zeroSandbox.Policy `json:"policy"`
|
||||
Backend zeroSandbox.Backend `json:"backend"`
|
||||
Plan zeroSandbox.BackendPlan `json:"plan"`
|
||||
GrantsPath string `json:"grantsPath"`
|
||||
Policy zeroSandbox.Policy `json:"policy"`
|
||||
Backend zeroSandbox.Backend `json:"backend"`
|
||||
Plan zeroSandbox.BackendPlan `json:"plan"`
|
||||
GrantsPath string `json:"grantsPath"`
|
||||
PermissionProfileNote string `json:"permissionProfileNote"`
|
||||
}{
|
||||
Policy: policy,
|
||||
Backend: backend,
|
||||
Plan: plan,
|
||||
GrantsPath: store.FilePath(),
|
||||
Policy: policy,
|
||||
Backend: backend,
|
||||
Plan: plan,
|
||||
GrantsPath: store.FilePath(),
|
||||
PermissionProfileNote: permissionProfileScopeNote,
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
@@ -247,20 +258,22 @@ func runSandboxPolicyEffective(options sandboxCommandOptions, workspaceRoot stri
|
||||
// JSON consumers see the same signal as the text write_roots_error
|
||||
// line: a stale sandbox.additionalWriteRoots entry means the real
|
||||
// entrypoints would refuse to launch, not run workspace-only.
|
||||
WriteRootsError string `json:"writeRootsError,omitempty"`
|
||||
Policy zeroSandbox.Policy `json:"policy"`
|
||||
Backend zeroSandbox.Backend `json:"backend"`
|
||||
Plan zeroSandbox.BackendPlan `json:"plan"`
|
||||
Guards sandboxGuards `json:"guards"`
|
||||
GrantsPath string `json:"grantsPath"`
|
||||
WriteRootsError string `json:"writeRootsError,omitempty"`
|
||||
Policy zeroSandbox.Policy `json:"policy"`
|
||||
Backend zeroSandbox.Backend `json:"backend"`
|
||||
Plan zeroSandbox.BackendPlan `json:"plan"`
|
||||
Guards sandboxGuards `json:"guards"`
|
||||
GrantsPath string `json:"grantsPath"`
|
||||
PermissionProfileNote string `json:"permissionProfileNote"`
|
||||
}{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
WriteRoots: writeRoots,
|
||||
Policy: policy,
|
||||
Backend: backend,
|
||||
Plan: plan,
|
||||
Guards: guards,
|
||||
GrantsPath: grantsPath,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
WriteRoots: writeRoots,
|
||||
Policy: policy,
|
||||
Backend: backend,
|
||||
Plan: plan,
|
||||
Guards: guards,
|
||||
GrantsPath: grantsPath,
|
||||
PermissionProfileNote: permissionProfileScopeNote,
|
||||
}
|
||||
if writeRootsErr != nil {
|
||||
payload.WriteRootsError = writeRootsErr.Error()
|
||||
@@ -284,6 +297,7 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
|
||||
"network: " + string(policy.Network),
|
||||
"enforce_workspace: " + fmt.Sprintf("%t", policy.EnforceWorkspace),
|
||||
"write_roots: " + strings.Join(writeRoots, ", "),
|
||||
"permission_profile_note: " + permissionProfileScopeNote,
|
||||
}
|
||||
if writeRootsErr != nil {
|
||||
// Fail soft, visibly: a stale sandbox.additionalWriteRoots entry must
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -483,14 +485,23 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
// Point HOME at an empty directory so the default credential-store
|
||||
// deny-read entries (which depend on what exists in the real home, e.g.
|
||||
// ~/.aws on the macOS CI image) cannot leak host paths into the golden
|
||||
// comparison.
|
||||
// Point all user-config roots at an empty directory so the platform-specific
|
||||
// credential deny baseline can be asserted without leaking host paths into
|
||||
// the platform-neutral golden comparison.
|
||||
emptyHome := t.TempDir()
|
||||
t.Setenv("HOME", emptyHome)
|
||||
t.Setenv("USERPROFILE", emptyHome)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config"))
|
||||
t.Setenv("CLOUDSDK_CONFIG", "")
|
||||
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
|
||||
t.Setenv("NPM_CONFIG_USERCONFIG", "")
|
||||
t.Setenv("npm_config_userconfig", "")
|
||||
t.Setenv("GH_CONFIG_DIR", "")
|
||||
t.Setenv("NETRC", "")
|
||||
t.Setenv("DOCKER_CONFIG", "")
|
||||
t.Setenv("KUBECONFIG", "")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", "")
|
||||
t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "")
|
||||
store := newSandboxTestStore(t)
|
||||
workspace := t.TempDir()
|
||||
deps := appDeps{
|
||||
@@ -518,7 +529,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
got := stdout.String()
|
||||
got = replacePathToken(got, workspace, "$WORKSPACE")
|
||||
got = replacePathToken(got, store.FilePath(), "$GRANTS")
|
||||
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
|
||||
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome)
|
||||
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
@@ -528,7 +539,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
|
||||
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
|
||||
t.Helper()
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
@@ -549,6 +560,56 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
|
||||
plan, _ := value.(map[string]any)["plan"].(map[string]any)
|
||||
profile, _ := plan["permissionProfile"].(map[string]any)
|
||||
fileSystem, _ := profile["fileSystem"].(map[string]any)
|
||||
wantDenyRead := []string(nil)
|
||||
credentialHome := emptyHome
|
||||
if runtime.GOOS != "windows" {
|
||||
if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil {
|
||||
credentialHome = resolved
|
||||
}
|
||||
wantDenyRead = []string{
|
||||
filepath.Join(credentialHome, ".aws"),
|
||||
filepath.Join(credentialHome, ".azure"),
|
||||
// git's cleartext credential stores, in both the home and XDG
|
||||
// layouts (#816). Listed here so the exported policy JSON is what
|
||||
// catches a regression: this baseline is the contract a user reads
|
||||
// with `zero sandbox policy --json`.
|
||||
filepath.Join(credentialHome, ".git-credentials"),
|
||||
filepath.Join(credentialHome, ".config", "git", "credentials"),
|
||||
filepath.Join(credentialHome, ".npmrc"),
|
||||
filepath.Join(credentialHome, ".netrc"),
|
||||
filepath.Join(credentialHome, ".kube", "config"),
|
||||
filepath.Join(credentialHome, ".docker", "config.json"),
|
||||
filepath.Join(credentialHome, ".config", "gh", "hosts.yml"),
|
||||
filepath.Join(credentialHome, ".config", "gcloud"),
|
||||
filepath.Join(credentialHome, ".config", "zero"),
|
||||
}
|
||||
}
|
||||
gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"])
|
||||
sort.Strings(gotDenyRead)
|
||||
sort.Strings(wantDenyRead)
|
||||
if !reflect.DeepEqual(gotDenyRead, wantDenyRead) {
|
||||
t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead)
|
||||
}
|
||||
wantCarveouts := []string(nil)
|
||||
wantEnsureDirs := []string(nil)
|
||||
if runtime.GOOS != "windows" {
|
||||
zeroDir := filepath.Join(credentialHome, ".config", "zero")
|
||||
wantCarveouts = []string{
|
||||
filepath.Join(zeroDir, "plugins"),
|
||||
filepath.Join(zeroDir, "specialists"),
|
||||
filepath.Join(zeroDir, "commands"),
|
||||
}
|
||||
wantEnsureDirs = []string{zeroDir}
|
||||
}
|
||||
if gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]); !reflect.DeepEqual(gotCarveouts, wantCarveouts) {
|
||||
t.Fatalf("manager credential carveouts = %#v, want %#v", gotCarveouts, wantCarveouts)
|
||||
}
|
||||
if gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]); !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) {
|
||||
t.Fatalf("manager credential ensure dirs = %#v, want %#v", gotEnsureDirs, wantEnsureDirs)
|
||||
}
|
||||
delete(fileSystem, "denyReadIfExists")
|
||||
delete(fileSystem, "denyReadCarveouts")
|
||||
delete(fileSystem, "ensureDenyReadDirs")
|
||||
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
|
||||
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
|
||||
normalized, err := json.MarshalIndent(value, "", " ")
|
||||
@@ -558,6 +619,22 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
|
||||
return append(normalized, '\n')
|
||||
}
|
||||
|
||||
func jsonStringSlice(value any) []string {
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
|
||||
roots, ok := value.([]any)
|
||||
if !ok {
|
||||
@@ -720,7 +797,8 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
Network bool `json:"network"`
|
||||
Workspace bool `json:"workspace"`
|
||||
} `json:"guards"`
|
||||
GrantsPath string `json:"grantsPath"`
|
||||
GrantsPath string `json:"grantsPath"`
|
||||
PermissionProfileNote string `json:"permissionProfileNote"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
|
||||
@@ -737,6 +815,9 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
if payload.Plan.SupportLevel != string(sandbox.BackendSupportUnavailable) || payload.GrantsPath == "" {
|
||||
t.Fatalf("unexpected effective plan/grants: %#v %q", payload.Plan, payload.GrantsPath)
|
||||
}
|
||||
if payload.PermissionProfileNote != permissionProfileScopeNote {
|
||||
t.Fatalf("permissionProfileNote = %q, want %q", payload.PermissionProfileNote, permissionProfileScopeNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -748,6 +829,9 @@ func TestEffectiveSandboxPolicyListsWriteRoots(t *testing.T) {
|
||||
if !strings.Contains(output, "enforce_workspace: true\nwrite_roots: /ws, /extra") {
|
||||
t.Fatalf("write_roots should directly follow enforce_workspace, got:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "permission_profile_note: "+permissionProfileScopeNote) {
|
||||
t.Fatalf("effective text missing permission profile scope warning:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "write_roots_error") {
|
||||
t.Fatalf("unexpected write_roots_error line without an error:\n%s", output)
|
||||
}
|
||||
|
||||
@@ -286,7 +286,11 @@ func runSpecialistCreate(paths specialist.Paths, name string, options specialist
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if options.json {
|
||||
return writeSpecialistCreateResult(manifest, options.json, stdout, stderr)
|
||||
}
|
||||
|
||||
func writeSpecialistCreateResult(manifest specialist.Manifest, jsonOutput bool, stdout io.Writer, stderr io.Writer) int {
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, manifest); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
@@ -295,6 +299,11 @@ func runSpecialistCreate(paths specialist.Paths, name string, options specialist
|
||||
if _, err := fmt.Fprintf(stdout, "Created specialist %s at %s\n", manifest.Metadata.Name, manifest.FilePath); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
for _, warning := range manifest.Warnings {
|
||||
if _, err := fmt.Fprintf(stderr, "warning: %s\n", warning); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
)
|
||||
|
||||
func TestRunSpecialistListShowAndPath(t *testing.T) {
|
||||
@@ -187,6 +188,42 @@ func TestRunSpecialistCreateDeleteAndEdit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSpecialistCreateResultSurfacesWarningsOnlyForHumanOutput(t *testing.T) {
|
||||
manifest := specialist.Manifest{
|
||||
Metadata: specialist.Metadata{Name: "triage"},
|
||||
FilePath: "/specialists/triage.md",
|
||||
Warnings: []string{"replacement backup /specialists/.triage.bak retained: access denied"},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := writeSpecialistCreateResult(manifest, false, &stdout, &stderr); code != exitSuccess {
|
||||
t.Fatalf("human output exit code = %d", code)
|
||||
}
|
||||
if got, want := stdout.String(), "Created specialist triage at /specialists/triage.md\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := stderr.String(), "warning: replacement backup /specialists/.triage.bak retained: access denied\n"; got != want {
|
||||
t.Fatalf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := writeSpecialistCreateResult(manifest, true, &stdout, &stderr); code != exitSuccess {
|
||||
t.Fatalf("JSON output exit code = %d", code)
|
||||
}
|
||||
var payload specialist.Manifest
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.Warnings) != 1 || payload.Warnings[0] != manifest.Warnings[0] {
|
||||
t.Fatalf("JSON warnings = %#v", payload.Warnings)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("JSON warning duplicated to stderr: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSpecialistEditRejectsSymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs extra privileges on Windows")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// summarizerFactory adapts the resolved profile and the authenticated provider
|
||||
// builder into agent.Options.Summarizer; the selection rules live in
|
||||
// providers.CompactionSummarizerFactory, shared with the TUI.
|
||||
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
|
||||
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
|
||||
}
|
||||
@@ -100,5 +100,6 @@
|
||||
"shell commands are not wrapped by a native platform sandbox"
|
||||
]
|
||||
},
|
||||
"grantsPath": "$GRANTS"
|
||||
"grantsPath": "$GRANTS",
|
||||
"permissionProfileNote": "permissionProfile is derived from this process's environment and working directory; a command run with its own environment can add further credential deny-read entries."
|
||||
}
|
||||
@@ -44,7 +44,8 @@ func runTrust(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
|
||||
func trustCurrentDir(stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
|
||||
// Flatten with %s: ErrorMessage stringifies inline, so %w would be a no-op.
|
||||
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %s", err), redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := workspacetrust.Trust(cwd); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
@@ -86,7 +87,8 @@ func trustRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
case 0:
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
|
||||
// Flatten with %s: ErrorMessage stringifies inline, so %w would be a no-op.
|
||||
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %s", err), redaction.Options{}), exitCrash)
|
||||
}
|
||||
target = cwd
|
||||
case 1:
|
||||
|
||||
@@ -202,8 +202,8 @@ Flags:
|
||||
--check Check the latest GitHub release without installing
|
||||
--apply Download, verify, and install the latest release
|
||||
--json Print the update result as JSON
|
||||
--repo <owner/repo> Repository to check when no endpoint is provided
|
||||
--endpoint <url|repo> Release API URL or owner/repo slug to check
|
||||
--repo <owner/repo> Repository to use when no endpoint is provided (--check and --apply; ignored for the install source by an npm-managed apply)
|
||||
--endpoint <url|repo> Release API URL or owner/repo slug to use (--check and --apply; ignored for the install source by an npm-managed apply)
|
||||
--timeout <duration> Release check timeout (default 5s)
|
||||
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
|
||||
-h, --help Show this help
|
||||
|
||||
+2403
-19
File diff suppressed because it is too large.
Load diff
Loaded 100 of 493 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user