mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b805af1f | ||
|
|
5c5d0bf8d0 | ||
|
|
2749447cdc | ||
|
|
94ff28f5cb | ||
|
|
a31b8ea28d | ||
|
|
8081d0e194 | ||
|
|
e4f922af5e | ||
|
|
69958aa320 | ||
|
|
c77557cfaa | ||
|
|
2b582f1086 | ||
|
|
70d53c1f57 | ||
|
|
6a7c689155 | ||
|
|
3e4ee001d7 | ||
|
|
edb6db2070 | ||
|
|
8aa81b7139 | ||
|
|
f683c3cdb5 | ||
|
|
54a9ab2371 | ||
|
|
b0947b7d8f | ||
|
|
c3ae5c4835 | ||
|
|
e187290b39 | ||
|
|
a15ce26333 | ||
|
|
496637bea3 | ||
|
|
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 | ||
|
|
097c2652cb | ||
|
|
15b7a5475c | ||
|
|
bdbda89f3e | ||
|
|
8acd42a3bd | ||
|
|
1c38e8cba5 | ||
|
|
58daea7a48 | ||
|
|
3e7692d8f0 | ||
|
|
33c94ec7a3 | ||
|
|
c9f9dc02ef | ||
|
|
ec3b822505 | ||
|
|
81a5e6cb71 | ||
|
|
5d1869e7bc | ||
|
|
518d2e53ba | ||
|
|
d82b94defe | ||
|
|
88fb7b6192 | ||
|
|
0125e70d0f | ||
|
|
f5fa4ffc64 | ||
|
|
9fbdc34155 | ||
|
|
583e6530bd | ||
|
|
ac50a5a840 | ||
|
|
a4a805a6c8 | ||
|
|
9fe02ae52e | ||
|
|
9d5c82d441 | ||
|
|
97ef90d27a | ||
|
|
5c58256abc | ||
|
|
fa8734f179 | ||
|
|
5bb1bc11c3 | ||
|
|
d9b882eb0a | ||
|
|
a50574f673 | ||
|
|
c00a4734b5 | ||
|
|
2479884dea | ||
|
|
ab2f9fbd3e | ||
|
|
baa4be13ac | ||
|
|
ddfdf2878e | ||
|
|
96859c9bd1 | ||
|
|
3524f795e5 | ||
|
|
b1f41735a7 | ||
|
|
4945684fa2 | ||
|
|
5140d4bf7b | ||
|
|
3967d49d64 | ||
|
|
89bdc6719a | ||
|
|
722bb31216 | ||
|
|
a21a052a4a | ||
|
|
f079b90f82 | ||
|
|
b30c3971b4 | ||
|
|
da9fb50f54 | ||
|
|
684901165d | ||
|
|
e1975c1b39 | ||
|
|
8c6d3022bd | ||
|
|
974fc036c2 | ||
|
|
4cbd144d11 | ||
|
|
c0b6b9e5c9 | ||
|
|
d74ceb1127 | ||
|
|
fbf85984f6 | ||
|
|
9242b9e037 | ||
|
|
2e267bdbee | ||
|
|
739a47e3ea |
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
*.go text eol=lf
|
||||||
|
*.mod text eol=lf
|
||||||
|
*.sum text eol=lf
|
||||||
+33
-12
@@ -30,6 +30,36 @@ jobs:
|
|||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
- name: Install Make on Windows
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
shell: pwsh
|
||||||
|
run: choco install make --no-progress -y
|
||||||
|
|
||||||
|
- name: Validate quality targets with stock macOS Make
|
||||||
|
if: matrix.os == 'macos-latest'
|
||||||
|
env:
|
||||||
|
GIT_CONFIG_COUNT: 1
|
||||||
|
GIT_CONFIG_KEY_0: grep.patternType
|
||||||
|
GIT_CONFIG_VALUE_0: fixed
|
||||||
|
run: make -n vulncheck
|
||||||
|
|
||||||
|
- name: Check vulnerabilities through native Windows Make
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
shell: cmd
|
||||||
|
run: make vulncheck
|
||||||
|
|
||||||
|
- name: Check dead code through native Windows Make
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
continue-on-error: true
|
||||||
|
shell: cmd
|
||||||
|
run: make deadcode
|
||||||
|
|
||||||
|
- name: Run static lint through native Windows Make
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
continue-on-error: true
|
||||||
|
shell: cmd
|
||||||
|
run: make lint-static
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -100,27 +130,18 @@ jobs:
|
|||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
|
||||||
# their own modules, which can drop below the version go.mod requires and
|
|
||||||
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
|
||||||
# so all three run under it.
|
|
||||||
- name: Pin toolchain from go.mod
|
|
||||||
run: |
|
|
||||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
|
||||||
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||||
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||||
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||||
- name: govulncheck
|
- name: govulncheck
|
||||||
run: go run golang.org/x/vuln/cmd/[email protected] ./...
|
run: make vulncheck
|
||||||
|
|
||||||
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
||||||
# code is visible in CI. Non-blocking while the dormant subsystems are
|
# code is visible in CI. Non-blocking while the dormant subsystems are
|
||||||
# still being wired or removed.
|
# still being wired or removed.
|
||||||
- name: deadcode (advisory)
|
- name: deadcode (advisory)
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
run: make deadcode
|
||||||
|
|
||||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||||
# doesn't, unused private functions/assignments reachable within a
|
# doesn't, unused private functions/assignments reachable within a
|
||||||
@@ -130,4 +151,4 @@ jobs:
|
|||||||
# the repo are cleaned up incrementally (see #527).
|
# the repo are cleaned up incrementally (see #527).
|
||||||
- name: golangci-lint (advisory)
|
- name: golangci-lint (advisory)
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
run: make lint-static
|
||||||
@@ -1 +1 @@
|
|||||||
{".":"0.4.0"}
|
{".":"0.8.0"}
|
||||||
@@ -51,10 +51,8 @@ request, or completing an implementation task:
|
|||||||
or run focused tests with `-race`, when concurrency is affected.
|
or run focused tests with `-race`, when concurrency is affected.
|
||||||
4. **Build**: `go run ./cmd/zero-release build`.
|
4. **Build**: `go run ./cmd/zero-release build`.
|
||||||
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
||||||
6. **Advisory lint**:
|
6. **Advisory lint**: `make lint-static`.
|
||||||
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
7. **Security**: `make vulncheck`.
|
||||||
7. **Security**:
|
|
||||||
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
|
||||||
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
||||||
tracked changes).
|
tracked changes).
|
||||||
|
|
||||||
@@ -69,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
|
because of the environment or fails for an unrelated external reason, report
|
||||||
the exact failure and obtain maintainer direction rather than silently ignoring
|
the exact failure and obtain maintainer direction rather than silently ignoring
|
||||||
it.
|
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.
|
||||||
+135
@@ -6,6 +6,141 @@ 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
|
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`.
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add persistent session goals ([#803](https://github.com/Gitlawb/zero/issues/803)) ([5d1869e](https://github.com/Gitlawb/zero/commit/5d1869e7bc193c97e07418b57b823905c1151d5d))
|
||||||
|
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
|
||||||
|
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
|
||||||
|
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
|
||||||
|
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
|
||||||
|
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
|
||||||
|
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
|
||||||
|
* **cron:** reserve job IDs atomically ([#686](https://github.com/Gitlawb/zero/issues/686)) ([d9b882e](https://github.com/Gitlawb/zero/commit/d9b882eb0a94f38de8447cb54b57860ccd8ed053))
|
||||||
|
* **lint:** resolve static analysis baseline ([#769](https://github.com/Gitlawb/zero/issues/769)) ([583e653](https://github.com/Gitlawb/zero/commit/583e6530bd849a8efcb2b88bbe8c297e8592e62c))
|
||||||
|
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
|
||||||
|
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
|
||||||
|
* **sandbox:** clarify scoped permission prompts ([#798](https://github.com/Gitlawb/zero/issues/798)) ([9fe02ae](https://github.com/Gitlawb/zero/commit/9fe02ae52e3234cb9dae76e0c5594a287ccbb52a))
|
||||||
|
* **sandbox:** deny reads of git's credential stores ([#815](https://github.com/Gitlawb/zero/issues/815)) ([#816](https://github.com/Gitlawb/zero/issues/816)) ([33c94ec](https://github.com/Gitlawb/zero/commit/33c94ec7a3e7987e6e36b5fd5e037ff4e6e84c26))
|
||||||
|
* **sandbox:** preserve user config and retry denied writes ([#801](https://github.com/Gitlawb/zero/issues/801)) ([81a5e6c](https://github.com/Gitlawb/zero/commit/81a5e6cb716ba4b7ecf7ff3ff261dfa51fd01098))
|
||||||
|
* **sandbox:** scrub the daemon token file pointer ([#677](https://github.com/Gitlawb/zero/issues/677)) ([#818](https://github.com/Gitlawb/zero/issues/818)) ([3e7692d](https://github.com/Gitlawb/zero/commit/3e7692d8f0885d346809869935c81a9cf055d89e))
|
||||||
|
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
|
||||||
|
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
|
||||||
|
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
|
||||||
|
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
|
||||||
|
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
|
||||||
|
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
|
||||||
|
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
|
||||||
|
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
|
||||||
|
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
|
||||||
|
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
|
||||||
|
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
|
||||||
|
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
|
||||||
|
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
|
||||||
|
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
|
||||||
|
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
|
||||||
|
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
|
||||||
|
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
|
||||||
|
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
|
||||||
|
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
|
||||||
|
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
|
||||||
|
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
|
||||||
|
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
|
||||||
|
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
|
||||||
|
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
|
||||||
|
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
|
||||||
|
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
|
||||||
|
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
|
||||||
|
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
|
||||||
|
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
|
||||||
|
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
|
||||||
|
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
|
||||||
|
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
|
||||||
|
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
|
||||||
|
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
|
||||||
|
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
|
||||||
|
|
||||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,28 @@ An approved issue means only that the specific issue has been accepted for work.
|
|||||||
It does not approve unrelated future work, future pull requests, or future issues
|
It does not approve unrelated future work, future pull requests, or future issues
|
||||||
from the same author. Each issue is reviewed case by case.
|
from the same author. Each issue is reviewed case by case.
|
||||||
|
|
||||||
|
## Contribution Volume and Batching
|
||||||
|
|
||||||
|
To keep triage and review sustainable while the project stabilizes, please keep
|
||||||
|
your contribution flow to a size the core team can realistically work through:
|
||||||
|
|
||||||
|
- One issue per bug. File each defect as its own issue with its own
|
||||||
|
reproduction. Please do not bundle several unrelated findings into a single
|
||||||
|
"audit" issue; separate issues are far easier to triage, approve, and close
|
||||||
|
individually.
|
||||||
|
- Keep only a few items open at a time. If you already have several issues or
|
||||||
|
pull requests awaiting review, let those move through the queue before opening
|
||||||
|
more. A large simultaneous burst from one contributor slows review for
|
||||||
|
everyone, even when each item is valid.
|
||||||
|
- AI-assisted contributions are welcome, held to the same bar. Automated audits
|
||||||
|
and generated patches are fine, but each issue and pull request must be
|
||||||
|
individually verified, scoped, and reproducible before you open it. Please do
|
||||||
|
not post bulk machine-generated findings unfiltered.
|
||||||
|
|
||||||
|
These apply to all contributors and are about review bandwidth, not the value of
|
||||||
|
any single report. Maintainers may ask you to consolidate, split, or hold
|
||||||
|
submissions to keep the queue manageable.
|
||||||
|
|
||||||
## Pull Request Requirements
|
## Pull Request Requirements
|
||||||
|
|
||||||
Before opening a community pull request, make sure all of the following are true:
|
Before opening a community pull request, make sure all of the following are true:
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
|
||||||
# lint` before opening a PR" — these targets back those instructions.
|
# with `make` — these targets back those instructions.
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
|
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
|
||||||
|
GO_TOOLCHAIN = go$(GO_VERSION)
|
||||||
|
DEADCODE_VERSION := v0.46.0
|
||||||
|
GOLANGCI_LINT_VERSION := v2.12.2
|
||||||
|
GOVULNCHECK_VERSION := v1.3.0
|
||||||
|
|
||||||
|
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
|
||||||
|
|
||||||
# Build the main CLI binary into ./zero.
|
# Build the main CLI binary into ./zero.
|
||||||
build:
|
build:
|
||||||
@@ -33,6 +39,22 @@ fmt-check:
|
|||||||
# Lint = formatting check + vet (no extra tooling required).
|
# Lint = formatting check + vet (no extra tooling required).
|
||||||
lint: fmt-check vet
|
lint: fmt-check vet
|
||||||
|
|
||||||
|
# Versioned tools select the toolchain from their own modules when invoked with
|
||||||
|
# package@version. Read this module's go directive directly, without invoking
|
||||||
|
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
||||||
|
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
||||||
|
# The target-specific export is shell-independent.
|
||||||
|
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||||
|
|
||||||
|
lint-static:
|
||||||
|
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||||
|
|
||||||
|
deadcode:
|
||||||
|
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
|
||||||
|
|
||||||
|
vulncheck:
|
||||||
|
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
@@ -55,4 +77,4 @@ baseline: build
|
|||||||
--output internal/perfbench/reports/baseline.json
|
--output internal/perfbench/reports/baseline.json
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
|
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
<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">
|
<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>
|
<br>
|
||||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -75,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
|||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
Source builds require Go 1.26.5+.
|
Source builds require Go 1.26.6+.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Gitlawb/zero.git
|
git clone https://github.com/Gitlawb/zero.git
|
||||||
@@ -131,6 +132,7 @@ export ANTHROPIC_API_KEY=...
|
|||||||
export GEMINI_API_KEY=...
|
export GEMINI_API_KEY=...
|
||||||
export AIMLAPI_API_KEY=...
|
export AIMLAPI_API_KEY=...
|
||||||
export LONGCAT_API_KEY=...
|
export LONGCAT_API_KEY=...
|
||||||
|
export FIREWORKS_API_KEY=...
|
||||||
export MINIMAX_API_KEY=...
|
export MINIMAX_API_KEY=...
|
||||||
export MINIMAXI_API_KEY=...
|
export MINIMAXI_API_KEY=...
|
||||||
```
|
```
|
||||||
@@ -147,6 +149,12 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
|
|||||||
zero providers setup longcat --set-active
|
zero providers setup longcat --set-active
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To configure Fireworks AI directly, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero providers setup fireworks --set-active
|
||||||
|
```
|
||||||
|
|
||||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||||
regions:
|
regions:
|
||||||
|
|
||||||
@@ -195,7 +203,7 @@ Useful controls:
|
|||||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||||
| `Shift+Tab` | cycle permission mode |
|
| `Shift+Tab` | cycle permission mode |
|
||||||
| `Ctrl+B` | show/hide the sidebar |
|
| `Ctrl+B` | show/hide the sidebar |
|
||||||
| `Ctrl+C` | cancel or exit |
|
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
||||||
|
|
||||||
Common slash commands:
|
Common slash commands:
|
||||||
|
|
||||||
@@ -205,6 +213,8 @@ Common slash commands:
|
|||||||
| `/spec`, `/plan` | draft and review a plan before building |
|
| `/spec`, `/plan` | draft and review a plan before building |
|
||||||
| `/image` | attach an image for vision-capable models |
|
| `/image` | attach an image for vision-capable models |
|
||||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
| `/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 |
|
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||||
| `/compact`, `/context` | manage context usage |
|
| `/compact`, `/context` | manage context usage |
|
||||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||||
@@ -300,7 +310,8 @@ zero verify detect and run local verification checks
|
|||||||
zero changes inspect and commit local git changes
|
zero changes inspect and commit local git changes
|
||||||
zero usage token usage and estimated cost
|
zero usage token usage and estimated cost
|
||||||
zero cron scheduled agent jobs
|
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
|
## Extending Zero
|
||||||
@@ -360,27 +371,34 @@ go run ./cmd/zero-perf-bench
|
|||||||
|
|
||||||
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
||||||
session (background connection prewarm + request-prefix telemetry) for headless
|
session (background connection prewarm + request-prefix telemetry) for headless
|
||||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
|
`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
|
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
|
||||||
variable unset and set.
|
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
|
### Code Quality and Security Checks
|
||||||
|
|
||||||
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
|
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
|
||||||
|
|
||||||
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
|
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
|
||||||
2. **Vetting**: Run `go vet ./...` (or `make vet`).
|
2. **Vetting**: Run `make vet` (or `go vet ./...`).
|
||||||
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
3. **Linting**: Run `make lint-static`.
|
||||||
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
4. **Vulnerability Scan**: Run `make vulncheck`.
|
||||||
|
|
||||||
If you prefer to install these tools globally on your path, you can run:
|
Use the repository-managed targets rather than globally installed binaries:
|
||||||
|
the targets apply the module's required Go toolchain as well as the reviewed,
|
||||||
|
pinned tool versions.
|
||||||
|
|
||||||
|
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||||
|
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||||
|
run them directly. If it isn't, add it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install golangci-lint
|
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
||||||
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
||||||
|
|
||||||
# Install govulncheck
|
|
||||||
go install golang.org/x/vuln/cmd/[email protected]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cross-Compile Examples
|
### Cross-Compile Examples
|
||||||
@@ -404,6 +422,8 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
|||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
|
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
|
||||||
|
|
||||||
Questions, setup help, ideas, and sharing all live in
|
Questions, setup help, ideas, and sharing all live in
|
||||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
<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">
|
<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>
|
<br>
|
||||||
<a href="README.md">English</a> | <strong>中文</strong>
|
<a href="README.md">English</a> | <strong>中文</strong>
|
||||||
</p>
|
</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
|
```bash
|
||||||
git clone https://github.com/Gitlawb/zero.git
|
git clone https://github.com/Gitlawb/zero.git
|
||||||
@@ -141,6 +142,7 @@ zero
|
|||||||
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||||
| `/image` | 为视觉模型附加图片 |
|
| `/image` | 为视觉模型附加图片 |
|
||||||
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||||
|
| `/new` | 原地开始一个新会话(之前的会话仍保留在磁盘上) |
|
||||||
| `/compact`、`/context` | 管理上下文使用 |
|
| `/compact`、`/context` | 管理上下文使用 |
|
||||||
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||||
| `/add-dir` | 为此会话授予额外的写入目录 |
|
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||||
@@ -226,7 +228,8 @@ zero verify 检测和运行本地验证检查
|
|||||||
zero changes 检查和提交本地 Git 变更
|
zero changes 检查和提交本地 Git 变更
|
||||||
zero usage Token 使用量和估算成本
|
zero usage Token 使用量和估算成本
|
||||||
zero cron 定时智能体任务
|
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/PERFORMANCE.md)
|
||||||
- [智能体评估](docs/AGENT_EVALS.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),运行相关测试,然后提交一个聚焦的拉取请求。
|
欢迎贡献。阅读 [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
|
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||||
out of scope — but please report anything you are unsure about.
|
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
|
- 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`).
|
||||||
+3
-2
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
|
|||||||
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
||||||
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
||||||
|
|
||||||
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
|
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.local/share/zero/skills/ # primary (install/remove/lock target)
|
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||||
run-benchmarks/
|
run-benchmarks/
|
||||||
SKILL.md
|
SKILL.md
|
||||||
write-changelog/
|
write-changelog/
|
||||||
@@ -289,6 +289,7 @@ Install and manage:
|
|||||||
```bash
|
```bash
|
||||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||||
zero plugins list
|
zero plugins list
|
||||||
|
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
|
||||||
zero plugins remove github-pr-review # alias: rm
|
zero plugins remove github-pr-review # alias: rm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -749,7 +749,7 @@ flowchart TD
|
|||||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
||||||
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
||||||
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
||||||
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
|
`LoadFromRoots`; single-root `Load` remains for install/write.
|
||||||
- **Hooks** can observe or block tool lifecycle events.
|
- **Hooks** can observe or block tool lifecycle events.
|
||||||
|
|
||||||
## End-to-End Data Flow
|
## End-to-End Data Flow
|
||||||
|
|||||||
+86
-10
@@ -1,14 +1,30 @@
|
|||||||
# Installing Zero
|
# 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`
|
| Method | Command | Self-update |
|
||||||
- release archives on GitHub Releases
|
| --- | --- | --- |
|
||||||
- source builds with Go 1.26.5+
|
| 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
|
`zero upgrade` follows whatever owns the binary, with one exception it cannot
|
||||||
published GitHub Release for the requested version. The npm package is
|
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
|
||||||
self-contained: the platform binary installs from the npm registry.
|
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
|
## npm
|
||||||
|
|
||||||
@@ -111,6 +127,45 @@ Defaults:
|
|||||||
- Version: latest GitHub release
|
- Version: latest GitHub release
|
||||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
- 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
|
## From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -125,7 +180,7 @@ Build a local binary:
|
|||||||
go build -o zero ./cmd/zero
|
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
|
### Sandbox Helpers For Source Builds
|
||||||
|
|
||||||
@@ -234,10 +289,31 @@ directory.
|
|||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
Check for a newer release:
|
Check for a newer release, then download, verify, and install it in place:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
zero update --check
|
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
|
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
|
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.
|
`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
|
# Update Flow
|
||||||
|
|
||||||
`zero update --check` checks the latest GitHub release and compares it with the
|
`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
|
```bash
|
||||||
zero update --check
|
zero update --check
|
||||||
zero update --check --json
|
zero update --check --json
|
||||||
zero update --check --repo Gitlawb/zero
|
zero update --check --repo Gitlawb/zero
|
||||||
zero update --check --target windows-x64
|
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 does not replace the running binary.
|
||||||
- It exits with code `0` when the check succeeds, even when an update is
|
- 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
|
- `--json` prints the same result in a machine-readable format for scripts and
|
||||||
CI.
|
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:
|
Useful flags:
|
||||||
|
|
||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--repo <owner/repo>` | Check another GitHub repository. |
|
| `--repo <owner/repo>` | Use another GitHub repository for `--check` and `--apply`/`upgrade`. Ignored by an npm-managed apply. |
|
||||||
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
|
| `--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. |
|
| `--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`,
|
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
||||||
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
`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`
|
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||||
|
|
||||||
Installer scripts download the matching release asset for the local platform and
|
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`
|
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||||
before reinstalling.
|
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
|
module github.com/Gitlawb/zero
|
||||||
|
|
||||||
go 1.26.5
|
go 1.26.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.1.1
|
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
|
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/alecthomas/chroma/v2 v2.27.0
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1
|
github.com/aymanbagabas/go-udiff v0.4.1
|
||||||
github.com/charmbracelet/colorprofile v0.4.3
|
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/ansi v0.11.7
|
||||||
github.com/charmbracelet/x/term v0.2.2
|
github.com/charmbracelet/x/term v0.2.2
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||||
|
golang.org/x/image v0.45.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
mvdan.cc/sh/v3 v3.13.1
|
mvdan.cc/sh/v3 v3.13.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.2.2 // 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 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
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.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
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 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
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=
|
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=
|
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 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
|||||||
+207
-32
@@ -11,6 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
|
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||||
|
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||||
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
"github.com/Gitlawb/zero/internal/sessions"
|
"github.com/Gitlawb/zero/internal/sessions"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
@@ -23,9 +26,10 @@ import (
|
|||||||
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
|
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
|
||||||
// operation.
|
// operation.
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||||
|
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||||
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
|
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
|
||||||
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
|
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
|
||||||
// exactly like the exec surface — never run unconfined on the host.
|
// exactly like the exec surface — never run unconfined on the host.
|
||||||
@@ -60,13 +64,16 @@ type acpSession struct {
|
|||||||
// turnMu serializes prompt turns for one session: concurrent session/prompt
|
// turnMu serializes prompt turns for one session: concurrent session/prompt
|
||||||
// calls run one at a time so they can't interleave history or clobber the
|
// calls run one at a time so they can't interleave history or clobber the
|
||||||
// single cancel slot.
|
// single cancel slot.
|
||||||
turnMu sync.Mutex
|
turnMu sync.Mutex
|
||||||
|
modelMu sync.Mutex
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
mode agent.PermissionMode
|
mode agent.PermissionMode
|
||||||
model string // override; "" => config default
|
model string
|
||||||
cancel context.CancelFunc
|
models []SessionConfigOptionValue
|
||||||
history []turnRecord
|
restrictModels bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
history []turnRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||||
@@ -83,9 +90,6 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
|
||||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
|
||||||
|
|
||||||
// ---- initialize ----
|
// ---- initialize ----
|
||||||
|
|
||||||
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
||||||
@@ -120,7 +124,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
|||||||
|
|
||||||
// ---- session lifecycle ----
|
// ---- session lifecycle ----
|
||||||
|
|
||||||
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
|
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
|
||||||
var p NewSessionParams
|
var p NewSessionParams
|
||||||
if err := json.Unmarshal(params, &p); err != nil {
|
if err := json.Unmarshal(params, &p); err != nil {
|
||||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||||
@@ -129,18 +133,23 @@ func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, RPCError(codeInvalidParams, err.Error())
|
return nil, RPCError(codeInvalidParams, err.Error())
|
||||||
}
|
}
|
||||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
|
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||||
|
}
|
||||||
|
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||||
}
|
}
|
||||||
sess := a.registerSession(meta.SessionID, root, nil)
|
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||||
return NewSessionResult{
|
return NewSessionResult{
|
||||||
SessionID: sess.id,
|
SessionID: sess.id,
|
||||||
Modes: a.modeState(sess),
|
ConfigOptions: a.configOptions(sess),
|
||||||
|
Modes: a.modeState(sess),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
|
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
|
||||||
var p LoadSessionParams
|
var p LoadSessionParams
|
||||||
if err := json.Unmarshal(params, &p); err != nil {
|
if err := json.Unmarshal(params, &p); err != nil {
|
||||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||||
@@ -161,7 +170,17 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
|||||||
// a half-initialized session (registerSession sets history under the lock and
|
// a half-initialized session (registerSession sets history under the lock and
|
||||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||||
history, historyErr := a.loadHistory(meta.SessionID)
|
history, historyErr := a.loadHistory(meta.SessionID)
|
||||||
sess := a.registerSession(meta.SessionID, root, history)
|
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||||
|
}
|
||||||
|
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
|
||||||
|
model = persistedModel
|
||||||
|
if !modelChoiceExists(models, persistedModel) {
|
||||||
|
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||||
a.warnPersistence(
|
a.warnPersistence(
|
||||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||||
"load session history",
|
"load session history",
|
||||||
@@ -169,7 +188,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
|||||||
historyErr,
|
historyErr,
|
||||||
)
|
)
|
||||||
return LoadSessionResult{
|
return LoadSessionResult{
|
||||||
Modes: a.modeState(sess),
|
ConfigOptions: a.configOptions(sess),
|
||||||
|
Modes: a.modeState(sess),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +296,16 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
|
|||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
return StopCancelled, nil
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
if result.FinishReason == "length" {
|
if result.FinishReason == "length" {
|
||||||
@@ -291,10 +321,14 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
|
|||||||
// session/request_permission request and maps the outcome back. Failure to reach
|
// session/request_permission request and maps the outcome back. Failure to reach
|
||||||
// the client fails closed to deny.
|
// the client fails closed to deny.
|
||||||
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
|
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{
|
params := RequestPermissionParams{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
ToolCall: permissionToolCall(req),
|
ToolCall: permissionToolCall(req),
|
||||||
Options: buildPermissionOptions(req),
|
Options: options,
|
||||||
}
|
}
|
||||||
var result RequestPermissionResult
|
var result RequestPermissionResult
|
||||||
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
|
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
|
||||||
@@ -303,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 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) {
|
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
|
||||||
@@ -329,9 +363,11 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
|
|||||||
if sess == nil {
|
if sess == nil {
|
||||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||||
}
|
}
|
||||||
|
sess.turnMu.Lock()
|
||||||
|
defer sess.turnMu.Unlock()
|
||||||
mode := agent.PermissionMode(p.ModeID)
|
mode := agent.PermissionMode(p.ModeID)
|
||||||
switch mode {
|
switch mode {
|
||||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
|
||||||
sess.setMode(mode)
|
sess.setMode(mode)
|
||||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||||
return SetSessionModeResult{}, nil
|
return SetSessionModeResult{}, nil
|
||||||
@@ -354,11 +390,31 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
|
|||||||
if sess == nil {
|
if sess == nil {
|
||||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||||
}
|
}
|
||||||
if p.ConfigID != configIDModel {
|
switch p.ConfigID {
|
||||||
|
case configIDModel:
|
||||||
|
model := strings.TrimSpace(p.Value)
|
||||||
|
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case configIDMode:
|
||||||
|
// Same turnMu as handleSetMode so the two advertised mode doors (session
|
||||||
|
// set_mode and set_config_option) serialize mode flips consistently.
|
||||||
|
sess.turnMu.Lock()
|
||||||
|
defer sess.turnMu.Unlock()
|
||||||
|
mode := agent.PermissionMode(p.Value)
|
||||||
|
switch mode {
|
||||||
|
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
|
||||||
|
sess.setMode(mode)
|
||||||
|
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||||
|
case agent.PermissionModeUnsafe:
|
||||||
|
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
|
||||||
|
default:
|
||||||
|
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
|
||||||
|
}
|
||||||
|
default:
|
||||||
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
||||||
}
|
}
|
||||||
sess.setModel(p.Value)
|
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
|
||||||
return SetSessionConfigOptionResult{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
||||||
@@ -370,8 +426,24 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
|
|||||||
if sess == nil {
|
if sess == nil {
|
||||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||||
}
|
}
|
||||||
sess.setModel(p.Model)
|
model := strings.TrimSpace(p.Model)
|
||||||
return ZeroSetModelResult{Model: p.Model}, nil
|
if err := a.updateModel(sess, model, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ZeroSetModelResult{Model: model}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
|
||||||
|
sess.modelMu.Lock()
|
||||||
|
defer sess.modelMu.Unlock()
|
||||||
|
if restrictModels && !sess.hasModel(model) {
|
||||||
|
return RPCError(codeInvalidParams, "unknown model: "+model)
|
||||||
|
}
|
||||||
|
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
|
||||||
|
return RPCError(codeInternalError, "save model selection: "+err.Error())
|
||||||
|
}
|
||||||
|
sess.setModel(model)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||||
@@ -387,17 +459,91 @@ func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
|||||||
// ---- advertising helpers ----
|
// ---- advertising helpers ----
|
||||||
|
|
||||||
func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||||
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
|
// 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.
|
// 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{
|
return &SessionModeState{
|
||||||
CurrentModeID: string(s.currentMode()),
|
CurrentModeID: string(s.currentMode()),
|
||||||
AvailableModes: []SessionMode{
|
AvailableModes: []SessionMode{
|
||||||
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
{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.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||||
|
{ID: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveModelChoices advertises authenticated live models when discovery is
|
||||||
|
// available. The configured model is always retained as the sole fallback.
|
||||||
|
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
|
||||||
|
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, false, err
|
||||||
|
}
|
||||||
|
selected := strings.TrimSpace(resolved.Provider.Model)
|
||||||
|
options := make([]SessionConfigOptionValue, 0, 8)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
add := func(id, description string) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
|
||||||
|
}
|
||||||
|
add(selected, "")
|
||||||
|
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
|
||||||
|
restrictModels := knownProvider && !descriptor.Custom
|
||||||
|
if a.deps.DiscoverModels != nil {
|
||||||
|
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", nil, false, ctx.Err()
|
||||||
|
}
|
||||||
|
if discoverErr == nil && len(discovered) > 0 {
|
||||||
|
for _, model := range discovered {
|
||||||
|
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
|
||||||
|
add(model.ID, model.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected, options, restrictModels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
|
||||||
|
for _, option := range models {
|
||||||
|
if option.Value == model {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||||
|
model, models, mode := s.configState()
|
||||||
|
return []SessionConfigOption{{
|
||||||
|
ID: configIDModel,
|
||||||
|
Name: "Model",
|
||||||
|
Description: "Model used for this session.",
|
||||||
|
Category: configCategoryModel,
|
||||||
|
Type: configOptionTypeSelect,
|
||||||
|
CurrentValue: model,
|
||||||
|
Options: models,
|
||||||
|
}, {
|
||||||
|
ID: configIDMode,
|
||||||
|
Name: "Mode",
|
||||||
|
Description: "Permission mode used for this session.",
|
||||||
|
Category: configCategoryMode,
|
||||||
|
Type: configOptionTypeSelect,
|
||||||
|
CurrentValue: string(mode),
|
||||||
|
Options: []SessionConfigOptionValue{
|
||||||
|
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||||
|
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||||
|
{Value: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- persistence + continuity ----
|
// ---- persistence + continuity ----
|
||||||
|
|
||||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||||
@@ -525,13 +671,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
|||||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||||
// half-initialized session.
|
// half-initialized session.
|
||||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
|
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
if existing := a.sessions[id]; existing != nil {
|
if existing := a.sessions[id]; existing != nil {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
|
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||||
a.sessions[id] = sess
|
a.sessions[id] = sess
|
||||||
return sess
|
return sess
|
||||||
}
|
}
|
||||||
@@ -570,7 +716,18 @@ func (s *acpSession) currentMode() agent.PermissionMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *acpSession) setModel(model string) {
|
func (s *acpSession) setModel(model string) {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
found := false
|
||||||
|
for _, option := range s.models {
|
||||||
|
if option.Value == model {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found && model != "" {
|
||||||
|
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
|
||||||
|
}
|
||||||
s.model = model
|
s.model = model
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -581,6 +738,24 @@ func (s *acpSession) currentModel() string {
|
|||||||
return s.model
|
return s.model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *acpSession) hasModel(model string) bool {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, option := range s.models {
|
||||||
|
if option.Value == model {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
|
||||||
|
}
|
||||||
|
|
||||||
func (s *acpSession) appendHistory(rec turnRecord) {
|
func (s *acpSession) appendHistory(rec turnRecord) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.history = append(s.history, rec)
|
s.history = append(s.history, rec)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package acp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
"github.com/Gitlawb/zero/internal/sessions"
|
"github.com/Gitlawb/zero/internal/sessions"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
@@ -137,6 +139,12 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
|||||||
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
|
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
|
||||||
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
|
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
|
||||||
}
|
}
|
||||||
|
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
|
||||||
|
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
|
||||||
|
}
|
||||||
|
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||||
|
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
|
||||||
|
}
|
||||||
|
|
||||||
// session/prompt
|
// session/prompt
|
||||||
var promptRes PromptResult
|
var promptRes PromptResult
|
||||||
@@ -156,6 +164,201 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
|
||||||
|
deps := testDeps(t)
|
||||||
|
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
model := "gpt-5.5"
|
||||||
|
if o.Provider.Model != "" {
|
||||||
|
model = o.Provider.Model
|
||||||
|
}
|
||||||
|
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||||
|
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return []providermodeldiscovery.Model{
|
||||||
|
{ID: " gpt-5.4-mini ", Description: "Fast"},
|
||||||
|
{ID: "gpt-5.5", Description: "duplicate configured model"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
h := newHarness(t, deps)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var created NewSessionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||||
|
t.Fatalf("session/new: %v", err)
|
||||||
|
}
|
||||||
|
option := created.ConfigOptions[0]
|
||||||
|
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
|
||||||
|
t.Fatalf("new model option = %+v", option)
|
||||||
|
}
|
||||||
|
for _, choice := range option.Options {
|
||||||
|
if choice.Name != choice.Value {
|
||||||
|
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var selected SetSessionConfigOptionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||||
|
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
|
||||||
|
}, &selected); err != nil {
|
||||||
|
t.Fatalf("set_config_option: %v", err)
|
||||||
|
}
|
||||||
|
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
|
||||||
|
t.Fatalf("selected model = %q", got)
|
||||||
|
}
|
||||||
|
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||||
|
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
|
||||||
|
}, &SetSessionConfigOptionResult{}); err == nil {
|
||||||
|
t.Fatal("unknown standard model selection was accepted")
|
||||||
|
}
|
||||||
|
h.stop()
|
||||||
|
h = newHarness(t, deps)
|
||||||
|
defer h.stop()
|
||||||
|
var loaded LoadSessionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||||
|
t.Fatalf("session/load: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
|
||||||
|
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
|
||||||
|
}
|
||||||
|
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||||
|
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
|
||||||
|
deps := testDeps(t)
|
||||||
|
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||||
|
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return nil, fmt.Errorf("discovery unavailable")
|
||||||
|
}
|
||||||
|
h := newHarness(t, deps)
|
||||||
|
defer h.stop()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var created NewSessionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||||
|
t.Fatalf("session/new: %v", err)
|
||||||
|
}
|
||||||
|
option := created.ConfigOptions[0]
|
||||||
|
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
|
||||||
|
t.Fatalf("fallback model option = %+v", option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
|
||||||
|
deps := testDeps(t)
|
||||||
|
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
model := "configured-model"
|
||||||
|
if o.Provider.Model != "" {
|
||||||
|
model = o.Provider.Model
|
||||||
|
}
|
||||||
|
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||||
|
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
h := newHarness(t, deps)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var created NewSessionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||||
|
t.Fatalf("session/new: %v", err)
|
||||||
|
}
|
||||||
|
var selected SetSessionConfigOptionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||||
|
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
|
||||||
|
}, &selected); err != nil {
|
||||||
|
t.Fatalf("set custom model: %v", err)
|
||||||
|
}
|
||||||
|
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
|
||||||
|
t.Fatalf("custom model = %q", got)
|
||||||
|
}
|
||||||
|
h.stop()
|
||||||
|
h = newHarness(t, deps)
|
||||||
|
defer h.stop()
|
||||||
|
var loaded LoadSessionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||||
|
t.Fatalf("session/load: %v", err)
|
||||||
|
}
|
||||||
|
option := loaded.ConfigOptions[0]
|
||||||
|
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
|
||||||
|
t.Fatalf("loaded custom model option = %+v", option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
|
||||||
|
a := &Agent{deps: Deps{
|
||||||
|
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||||
|
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
|
||||||
|
}}, nil
|
||||||
|
},
|
||||||
|
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
|
||||||
|
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
a := &Agent{deps: Deps{
|
||||||
|
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
|
||||||
|
},
|
||||||
|
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("resolve error = %v, want context canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACPConfigOptionWireSchema(t *testing.T) {
|
||||||
|
b, err := json.Marshal(SessionConfigOption{
|
||||||
|
ID: "model", Name: "Model", Description: "desc", Category: "model",
|
||||||
|
Type: configOptionTypeSelect, CurrentValue: "m1",
|
||||||
|
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wire map[string]any
|
||||||
|
if err := json.Unmarshal(b, &wire); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"type", "category", "currentValue", "options"} {
|
||||||
|
if _, ok := wire[key]; !ok {
|
||||||
|
t.Errorf("wire field %q absent: %s", key, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := wire["value"]; ok {
|
||||||
|
t.Errorf("obsolete option value field present: %s", b)
|
||||||
|
}
|
||||||
|
if _, ok := wire["values"]; ok {
|
||||||
|
t.Errorf("obsolete values field present: %s", b)
|
||||||
|
}
|
||||||
|
choice := wire["options"].([]any)[0].(map[string]any)
|
||||||
|
if choice["value"] != "m1" {
|
||||||
|
t.Errorf("options[].value = %#v", choice["value"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestACPUnknownSessionPromptErrors(t *testing.T) {
|
func TestACPUnknownSessionPromptErrors(t *testing.T) {
|
||||||
h := newHarness(t, testDeps(t))
|
h := newHarness(t, testDeps(t))
|
||||||
defer h.stop()
|
defer h.stop()
|
||||||
@@ -181,6 +384,41 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
|
|||||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
|
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
|
||||||
t.Fatalf("set_mode ask: %v", err)
|
t.Fatalf("set_mode ask: %v", err)
|
||||||
}
|
}
|
||||||
|
var configured SetSessionConfigOptionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||||
|
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
|
||||||
|
}, &configured); err != nil {
|
||||||
|
t.Fatalf("set_config_option mode: %v", err)
|
||||||
|
}
|
||||||
|
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
|
||||||
|
t.Fatalf("configured mode = %q", got)
|
||||||
|
}
|
||||||
|
// Plan is accepted: it only narrows capability (read-only), so unlike Unsafe
|
||||||
|
// there is no elevation risk in letting a client select it.
|
||||||
|
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
|
||||||
|
t.Fatalf("set_mode plan: %v", err)
|
||||||
|
}
|
||||||
|
// Configuration path is a separate contract from MethodSessionSetMode: the
|
||||||
|
// mode option is advertised via configOptions and applied by handleSetConfigOption.
|
||||||
|
var planConfigured SetSessionConfigOptionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||||
|
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModePlan),
|
||||||
|
}, &planConfigured); err != nil {
|
||||||
|
t.Fatalf("set_config_option plan: %v", err)
|
||||||
|
}
|
||||||
|
if got := planConfigured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModePlan) {
|
||||||
|
t.Fatalf("configured mode after plan = %q, want plan", got)
|
||||||
|
}
|
||||||
|
hasPlanOption := false
|
||||||
|
for _, opt := range planConfigured.ConfigOptions[1].Options {
|
||||||
|
if opt.Value == string(agent.PermissionModePlan) {
|
||||||
|
hasPlanOption = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasPlanOption {
|
||||||
|
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
|
||||||
|
}
|
||||||
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
|
// 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 {
|
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")
|
t.Fatal("expected Unsafe mode to be rejected over ACP")
|
||||||
@@ -191,6 +429,37 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
|
||||||
|
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
|
||||||
|
// the same gap this test's TUI counterpart covers for /plan on.
|
||||||
|
func TestACPPlanModeWiresPermissionModeIntoAgentOptions(t *testing.T) {
|
||||||
|
deps := testDeps(t)
|
||||||
|
var captured agent.Options
|
||||||
|
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||||
|
captured = opts
|
||||||
|
return agent.Result{FinalAnswer: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h := newHarness(t, deps)
|
||||||
|
defer h.stop()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var newRes NewSessionResult
|
||||||
|
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||||
|
t.Fatalf("session/new: %v", err)
|
||||||
|
}
|
||||||
|
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
|
||||||
|
t.Fatalf("set_mode plan: %v", err)
|
||||||
|
}
|
||||||
|
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("plan it out")}}, &PromptResult{}); err != nil {
|
||||||
|
t.Fatalf("session/prompt: %v", err)
|
||||||
|
}
|
||||||
|
if captured.PermissionMode != agent.PermissionModePlan {
|
||||||
|
t.Fatalf("agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
|
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
|
||||||
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
|
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
|
||||||
// shell tools run confined, not unconfined on the host.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||||
|
package acp
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||||
|
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||||
|
|
||||||
|
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||||
|
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||||
|
}
|
||||||
+138
-6
@@ -17,6 +17,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSON-RPC 2.0 standard error codes.
|
// JSON-RPC 2.0 standard error codes.
|
||||||
@@ -80,8 +81,9 @@ type NotifyFunc func(ctx context.Context, params json.RawMessage)
|
|||||||
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
||||||
// the client for session/request_permission, fs/*, terminal/*).
|
// the client for session/request_permission, fs/*, terminal/*).
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
reader *bufio.Reader
|
rawReader io.Reader // wrapped lazily in Serve, once ctx is known — see interruptibleReader
|
||||||
w io.Writer
|
readerCloser io.Closer
|
||||||
|
w io.Writer
|
||||||
|
|
||||||
writeMu sync.Mutex // serializes all writes to w
|
writeMu sync.Mutex // serializes all writes to w
|
||||||
|
|
||||||
@@ -96,10 +98,15 @@ type Conn struct {
|
|||||||
wg sync.WaitGroup // tracks in-flight inbound handlers
|
wg sync.WaitGroup // tracks in-flight inbound handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w.
|
// NewConn builds a peer reading ndjson from r and writing ndjson to w. This
|
||||||
|
// Conn does not own r: cancelling Serve's context cannot forcibly close it, so
|
||||||
|
// a Read on r that's genuinely idle (never returns on its own) leaves Serve
|
||||||
|
// blocked until r itself produces something — e.g. a test that closes r's own
|
||||||
|
// write side to unblock it. Use NewOwnedConn when this Conn should own r's
|
||||||
|
// lifetime.
|
||||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||||
return &Conn{
|
return &Conn{
|
||||||
reader: bufio.NewReader(r),
|
rawReader: r,
|
||||||
w: w,
|
w: w,
|
||||||
handlers: make(map[string]HandlerFunc),
|
handlers: make(map[string]HandlerFunc),
|
||||||
notifiers: make(map[string]NotifyFunc),
|
notifiers: make(map[string]NotifyFunc),
|
||||||
@@ -107,6 +114,18 @@ func NewConn(r io.Reader, w io.Writer) *Conn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewOwnedConn is like NewConn but declares that this Conn exclusively owns r:
|
||||||
|
// if r implements io.Closer, Serve closes it on context cancellation to
|
||||||
|
// interrupt an otherwise-idle blocking Read. Only pass a reader whose lifetime
|
||||||
|
// this Conn should control — e.g. the process's own stdin — never a reader a
|
||||||
|
// caller retains or shares elsewhere, since cancellation tears it down
|
||||||
|
// permanently for every consumer, not just this Conn.
|
||||||
|
func NewOwnedConn(r io.Reader, w io.Writer) *Conn {
|
||||||
|
c := NewConn(r, w)
|
||||||
|
c.readerCloser, _ = r.(io.Closer)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// Handle registers a request handler for method.
|
// Handle registers a request handler for method.
|
||||||
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
||||||
|
|
||||||
@@ -128,17 +147,36 @@ func (c *Conn) Serve(ctx context.Context) error {
|
|||||||
c.wg.Wait()
|
c.wg.Wait()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
interruptible := newInterruptibleReader(ctx, c.rawReader, c.readerCloser)
|
||||||
|
reader := bufio.NewReader(interruptible)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// One ndjson value per line. Framing per line (rather than streaming the
|
// One ndjson value per line. Framing per line (rather than streaming the
|
||||||
// decoder) keeps a single malformed line from making the whole connection
|
// decoder) keeps a single malformed line from making the whole connection
|
||||||
// unrecoverable — we report -32700 and continue.
|
// unrecoverable — we report -32700 and continue.
|
||||||
line, err := c.reader.ReadBytes('\n')
|
//
|
||||||
|
// generation brackets this SPECIFIC ReadBytes call: bufio may invoke
|
||||||
|
// interruptible.Read zero or more times underneath it (zero when the
|
||||||
|
// answer is already sitting in bufio's own buffer — including a
|
||||||
|
// previously-buffered, not-yet-surfaced error: bufio can return a
|
||||||
|
// complete line with a nil error while quietly caching an error it
|
||||||
|
// received ALONGSIDE that line's bytes, which then surfaces on the VERY
|
||||||
|
// NEXT call with no further contact with interruptible.Read at all). The
|
||||||
|
// generation only advances inside interruptible.Read's own cancellation
|
||||||
|
// branch, so "unchanged across this call" is proof this call's outcome
|
||||||
|
// didn't pass through that branch — whether because it was answered from
|
||||||
|
// the buffer or because it raced ctx.Done() and won. Only that proof
|
||||||
|
// makes it safe to call the outcome genuine.
|
||||||
|
before := interruptible.generation()
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
interrupted := interruptible.generation() != before
|
||||||
|
|
||||||
if len(bytes.TrimSpace(line)) > 0 {
|
if len(bytes.TrimSpace(line)) > 0 {
|
||||||
c.handleLine(ctx, line)
|
c.handleLine(ctx, line)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.failAllPending(err)
|
c.failAllPending(err)
|
||||||
if errors.Is(err, io.EOF) || ctx.Err() != nil {
|
if errors.Is(err, io.EOF) || interrupted {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -146,6 +184,100 @@ func (c *Conn) Serve(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// interruptibleReader wraps a reader so a context cancellation can unblock a
|
||||||
|
// currently in-flight Read without ever discarding — or misattributing — the
|
||||||
|
// real outcome of a call that wasn't actually blocked.
|
||||||
|
//
|
||||||
|
// Each Read runs the underlying call on its own goroutine and races it against
|
||||||
|
// ctx.Done(). If the underlying call finishes first, its result is returned
|
||||||
|
// exactly as received; ctx being cancelled a moment later has no bearing on a
|
||||||
|
// call that already had its answer. If ctx.Done() fires first, closer (when
|
||||||
|
// non-nil) is closed to nudge a genuinely blocked read, the generation counter
|
||||||
|
// is bumped, and the call STILL waits for and returns the underlying call's own
|
||||||
|
// result — never a synthesized one — since closing a reader's outcome is
|
||||||
|
// whatever the reader itself reports for it, not ours to invent.
|
||||||
|
//
|
||||||
|
// The counter is what lets Serve tell a call that was actually raced against
|
||||||
|
// cancellation apart from one merely answered while cancellation happened to be
|
||||||
|
// pending — the distinction the read loop's own doc comment explains.
|
||||||
|
type interruptibleReader struct {
|
||||||
|
inner io.Reader
|
||||||
|
closer io.Closer
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
|
closeOnce sync.Once
|
||||||
|
gen atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInterruptibleReader(ctx context.Context, inner io.Reader, closer io.Closer) *interruptibleReader {
|
||||||
|
return &interruptibleReader{inner: inner, closer: closer, ctx: ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interruptibleReader) generation() int64 { return r.gen.Load() }
|
||||||
|
|
||||||
|
type interruptibleReadResult struct {
|
||||||
|
n int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// interruptibleReadDecision arbitrates whether an underlying Read completed or
|
||||||
|
// cancellation claimed it first. Completion is recorded before the result is
|
||||||
|
// published so cancellation cannot misclassify a finished read merely because
|
||||||
|
// its goroutine has not sent the result yet.
|
||||||
|
type interruptibleReadDecision struct {
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *interruptibleReadDecision) complete() { d.once.Do(func() {}) }
|
||||||
|
|
||||||
|
func (d *interruptibleReadDecision) interrupt() (claimed bool) {
|
||||||
|
d.once.Do(func() { claimed = true })
|
||||||
|
return claimed
|
||||||
|
}
|
||||||
|
|
||||||
|
// readInner records completion in the Read call's deferred epilogue. It also
|
||||||
|
// converts a reader panic into a terminal error so the helper goroutine always
|
||||||
|
// publishes a result for Read to join.
|
||||||
|
func (r *interruptibleReader) readInner(p []byte, decision *interruptibleReadDecision) (res interruptibleReadResult) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
res = interruptibleReadResult{err: fmt.Errorf("acp: reader panicked: %v", recovered)}
|
||||||
|
}
|
||||||
|
decision.complete()
|
||||||
|
}()
|
||||||
|
res.n, res.err = r.inner.Read(p)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *interruptibleReader) Read(p []byte) (int, error) {
|
||||||
|
resultCh := make(chan interruptibleReadResult, 1)
|
||||||
|
var decision interruptibleReadDecision
|
||||||
|
go func() {
|
||||||
|
resultCh <- r.readInner(p, &decision)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
return res.n, res.err
|
||||||
|
case <-r.ctx.Done():
|
||||||
|
// resultCh may not be readable yet even when inner.Read has returned: its
|
||||||
|
// goroutine can be descheduled between recording completion and publishing
|
||||||
|
// the result. Only interrupt a call that cancellation actually claims.
|
||||||
|
if decision.interrupt() {
|
||||||
|
r.gen.Add(1)
|
||||||
|
if r.closer != nil {
|
||||||
|
r.closeOnce.Do(func() { _ = r.closer.Close() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wait for the real result rather than returning synthetically: p is
|
||||||
|
// still being written by the goroutine above until it does, so returning
|
||||||
|
// early would race that write, and the actual error (whatever closing
|
||||||
|
// the reader produced, or whatever the reader was already about to
|
||||||
|
// report) is what the caller should see.
|
||||||
|
res := <-resultCh
|
||||||
|
return res.n, res.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
||||||
// with -32700 (id null) and keeps the connection alive.
|
// with -32700 (id null) and keeps the connection alive.
|
||||||
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||||
|
|||||||
@@ -3,11 +3,30 @@ package acp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type testReadCloser struct {
|
||||||
|
read func([]byte) (int, error)
|
||||||
|
close func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r testReadCloser) Read(p []byte) (int, error) { return r.read(p) }
|
||||||
|
func (r testReadCloser) Close() error { return r.close() }
|
||||||
|
|
||||||
|
type testReader func([]byte) (int, error)
|
||||||
|
|
||||||
|
func (r testReader) Read(p []byte) (int, error) { return r(p) }
|
||||||
|
|
||||||
|
type testWriter func([]byte) (int, error)
|
||||||
|
|
||||||
|
func (w testWriter) Write(p []byte) (int, error) { return w(p) }
|
||||||
|
|
||||||
// connPair wires two Conns together over in-memory pipes and serves both.
|
// connPair wires two Conns together over in-memory pipes and serves both.
|
||||||
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -25,6 +44,275 @@ func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConnServeCancellationInterruptsIdleRead(t *testing.T) {
|
||||||
|
pipeReader, writer := io.Pipe()
|
||||||
|
t.Cleanup(func() { _ = writer.Close() })
|
||||||
|
// Buffered + non-blocking send, not close: interruptibleReader retries each
|
||||||
|
// underlying Read on its own goroutine, so if bufio ever calls this more than
|
||||||
|
// once, a close() here would panic on the second call.
|
||||||
|
readStarted := make(chan struct{}, 1)
|
||||||
|
closeCalls := make(chan struct{}, 2)
|
||||||
|
reader := testReadCloser{
|
||||||
|
read: func(p []byte) (int, error) {
|
||||||
|
select {
|
||||||
|
case readStarted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return pipeReader.Read(p)
|
||||||
|
},
|
||||||
|
close: func() error {
|
||||||
|
closeCalls <- struct{}{}
|
||||||
|
return pipeReader.Close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conn := NewOwnedConn(reader, io.Discard)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- conn.Serve(ctx) }()
|
||||||
|
|
||||||
|
<-readStarted
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Serve returned %v after cancellation, want nil", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Serve did not return after cancelling an idle connection")
|
||||||
|
}
|
||||||
|
if got := len(closeCalls); got != 1 {
|
||||||
|
t.Fatalf("reader Close calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnServePreservesTerminalReadErrorDuringCancellation(t *testing.T) {
|
||||||
|
for _, closable := range []bool{false, true} {
|
||||||
|
name := "non-closable"
|
||||||
|
if closable {
|
||||||
|
name = "closable"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
wantErr := errors.New("read failed")
|
||||||
|
read := testReader(func(p []byte) (int, error) {
|
||||||
|
return copy(p, "not json"), wantErr
|
||||||
|
})
|
||||||
|
closeCalls := make(chan struct{}, 1)
|
||||||
|
var reader io.Reader = read
|
||||||
|
if closable {
|
||||||
|
reader = testReadCloser{
|
||||||
|
read: read,
|
||||||
|
close: func() error {
|
||||||
|
closeCalls <- struct{}{}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Buffered + non-blocking send, not close: a later change adding a
|
||||||
|
// second write (e.g. an additional error frame) must not panic on a
|
||||||
|
// repeated close of an already-closed channel.
|
||||||
|
writeStarted := make(chan struct{}, 1)
|
||||||
|
releaseWrite := make(chan struct{})
|
||||||
|
writer := testWriter(func(p []byte) (int, error) {
|
||||||
|
select {
|
||||||
|
case writeStarted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
<-releaseWrite
|
||||||
|
return len(p), nil
|
||||||
|
})
|
||||||
|
newConn := NewConn
|
||||||
|
if closable {
|
||||||
|
// Ownership matters here: this case exists to prove that even a
|
||||||
|
// Conn entitled to close its reader on cancellation still
|
||||||
|
// preserves a terminal error that was already decided.
|
||||||
|
newConn = NewOwnedConn
|
||||||
|
}
|
||||||
|
conn := newConn(reader, writer)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- conn.Serve(ctx) }()
|
||||||
|
|
||||||
|
<-writeStarted
|
||||||
|
cancel()
|
||||||
|
close(releaseWrite)
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("Serve returned %v, want terminal read error %v", err, wantErr)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Serve did not return after terminal read error")
|
||||||
|
}
|
||||||
|
if got := len(closeCalls); got != 0 {
|
||||||
|
t.Fatalf("reader Close calls = %d, want 0 after terminal read", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnServePreservesReadErrorBufferedAlongsideAPriorLine is the deterministic
|
||||||
|
// regression test for jatmn's #782 finding: a real, non-EOF ReadBytes failure can
|
||||||
|
// be DECIDED before cancellation ever happens, yet only SURFACE afterward, and
|
||||||
|
// must still be reported rather than swallowed as a clean shutdown.
|
||||||
|
//
|
||||||
|
// bufio.Reader can obtain data AND a terminal error from ONE underlying Read call
|
||||||
|
// (io.Reader explicitly permits returning (n>0, err) together). If a delimiter is
|
||||||
|
// found within that data, ReadBytes returns the line with a nil error for THIS
|
||||||
|
// call, but bufio caches the error internally and returns it — WITHOUT calling the
|
||||||
|
// underlying reader again — on the very next ReadBytes call. This test forces that
|
||||||
|
// exact sequence: the underlying reader hands back one complete, validly-framed
|
||||||
|
// line together with wantErr in a SINGLE call (asserted below to be its ONLY
|
||||||
|
// call), the loop dispatches that line through a synchronous path that blocks the
|
||||||
|
// read loop, cancellation is asserted to have happened before the loop attempts
|
||||||
|
// its next read, and only then is the block released. By the time the read loop
|
||||||
|
// asks for more input, cancellation is already in effect and the underlying
|
||||||
|
// reader is never touched again — so if Serve attributed the resulting error to
|
||||||
|
// cancellation merely because ctx was already done, it would incorrectly return
|
||||||
|
// nil. It must instead recognize that this read was answered entirely from
|
||||||
|
// bufio's own cache, never raced against ctx.Done(), and report wantErr.
|
||||||
|
func TestConnServePreservesReadErrorBufferedAlongsideAPriorLine(t *testing.T) {
|
||||||
|
wantErr := errors.New("read failed")
|
||||||
|
var readCalls atomic.Int32
|
||||||
|
read := testReader(func(p []byte) (int, error) {
|
||||||
|
readCalls.Add(1)
|
||||||
|
// An unsupported jsonrpc version with an id takes the SYNCHRONOUS
|
||||||
|
// writeError path in handleLine (not a dispatched goroutine), giving this
|
||||||
|
// test a reliable blocking point between processing this line and the
|
||||||
|
// loop's next ReadBytes call.
|
||||||
|
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
||||||
|
})
|
||||||
|
writeStarted := make(chan struct{}, 1)
|
||||||
|
releaseWrite := make(chan struct{})
|
||||||
|
var releaseWriteOnce sync.Once
|
||||||
|
release := func() { releaseWriteOnce.Do(func() { close(releaseWrite) }) }
|
||||||
|
t.Cleanup(release)
|
||||||
|
writer := testWriter(func(p []byte) (int, error) {
|
||||||
|
select {
|
||||||
|
case writeStarted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
<-releaseWrite
|
||||||
|
return len(p), nil
|
||||||
|
})
|
||||||
|
conn := NewConn(read, writer)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- conn.Serve(ctx) }()
|
||||||
|
|
||||||
|
// The synchronous write confirms the first (and, per the panic guard above,
|
||||||
|
// only) read has already returned and is being processed — cancelling now
|
||||||
|
// lands squarely in the gap between that read and the loop's next one, never
|
||||||
|
// racing an in-flight interruptibleReader.Read call.
|
||||||
|
select {
|
||||||
|
case <-writeStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Serve did not reach the synchronous write")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
release()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("Serve returned %v, want the buffered terminal read error %v", err, wantErr)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Serve did not return after the buffered terminal read error")
|
||||||
|
}
|
||||||
|
if got := readCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("underlying Read calls = %d, want exactly 1 (bufio must have served the second ReadBytes from its cache)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInterruptibleReaderPrefersDecidedResultOverCancellation is the regression
|
||||||
|
// test for jatmn's second #782 finding: interruptibleReader.Read's select
|
||||||
|
// between resultCh and ctx.Done() chooses pseudo-randomly when both are ready,
|
||||||
|
// so a real, already-decided outcome (e.g. a terminal transport error) could be
|
||||||
|
// misattributed to cancellation purely because ctx also happened to be done by
|
||||||
|
// the time the select ran. Each iteration lets the underlying Read return (so
|
||||||
|
// resultCh already holds the real outcome) before cancelling, maximizing the
|
||||||
|
// odds of landing exactly in that ambiguous window; the generation counter must
|
||||||
|
// never advance when the real, already-decided result is what gets returned.
|
||||||
|
func TestInterruptibleReaderPrefersDecidedResultOverCancellation(t *testing.T) {
|
||||||
|
wantErr := errors.New("read failed")
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
resultReady := make(chan struct{})
|
||||||
|
read := testReader(func(p []byte) (int, error) {
|
||||||
|
defer close(resultReady)
|
||||||
|
return copy(p, "x"), wantErr
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
r := newInterruptibleReader(ctx, read, nil)
|
||||||
|
|
||||||
|
readDone := make(chan interruptibleReadResult, 1)
|
||||||
|
go func() {
|
||||||
|
n, err := r.Read(make([]byte, 8))
|
||||||
|
readDone <- interruptibleReadResult{n, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-resultReady
|
||||||
|
time.Sleep(time.Millisecond) // bias resultCh's send ahead of cancel
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
res := <-readDone
|
||||||
|
if !errors.Is(res.err, wantErr) {
|
||||||
|
t.Fatalf("iteration %d: Read returned %v, want %v", i, res.err, wantErr)
|
||||||
|
}
|
||||||
|
if got := r.generation(); got != 0 {
|
||||||
|
t.Fatalf("iteration %d: generation = %d, want 0 (a decided result must never be attributed to cancellation)", i, got)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInterruptibleReadCompletionWinsBeforeResultPublication exercises the
|
||||||
|
// actual inner-read wrapper at the handoff between the underlying Read and the
|
||||||
|
// helper goroutine's result publication. Cancellation must not reclassify a
|
||||||
|
// result returned by this wrapper as interrupted.
|
||||||
|
func TestInterruptibleReadCompletionWinsBeforeResultPublication(t *testing.T) {
|
||||||
|
wantErr := errors.New("read failed")
|
||||||
|
read := testReader(func(p []byte) (int, error) {
|
||||||
|
return copy(p, "x"), wantErr
|
||||||
|
})
|
||||||
|
r := newInterruptibleReader(context.Background(), read, nil)
|
||||||
|
var decision interruptibleReadDecision
|
||||||
|
readComplete := make(chan struct{})
|
||||||
|
resultCh := make(chan interruptibleReadResult)
|
||||||
|
go func() {
|
||||||
|
res := r.readInner(make([]byte, 8), &decision)
|
||||||
|
close(readComplete)
|
||||||
|
resultCh <- res
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-readComplete
|
||||||
|
if decision.interrupt() {
|
||||||
|
t.Fatal("cancellation claimed a read that completed before result publication")
|
||||||
|
}
|
||||||
|
res := <-resultCh
|
||||||
|
if res.n != 1 || !errors.Is(res.err, wantErr) {
|
||||||
|
t.Fatalf("inner read result = (%d, %v), want (1, %v)", res.n, res.err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnServeReportsReaderPanicAsTerminalError(t *testing.T) {
|
||||||
|
read := testReader(func([]byte) (int, error) {
|
||||||
|
panic("boom")
|
||||||
|
})
|
||||||
|
conn := NewConn(read, io.Discard)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- conn.Serve(context.Background()) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
const want = "acp: reader panicked: boom"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("Serve returned %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Serve did not return after reader panic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConnRequestResponse(t *testing.T) {
|
func TestConnRequestResponse(t *testing.T) {
|
||||||
a, b, stop := connPair(t)
|
a, b, stop := connPair(t)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
+77
-22
@@ -15,17 +15,10 @@ import (
|
|||||||
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
||||||
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
||||||
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||||
actions := req.AvailableDecisions
|
actions := offeredDecisions(req)
|
||||||
if len(actions) == 0 {
|
|
||||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
|
||||||
actions = []agent.PermissionDecisionAction{
|
|
||||||
agent.PermissionDecisionAllow,
|
|
||||||
agent.PermissionDecisionDeny,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
options := make([]PermissionOption, 0, len(actions))
|
options := make([]PermissionOption, 0, len(actions))
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
kind, name := optionKindFor(action)
|
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
continue // skip actions that have no clean ACP option (e.g. cancel)
|
continue // skip actions that have no clean ACP option (e.g. cancel)
|
||||||
}
|
}
|
||||||
@@ -38,21 +31,61 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
|||||||
return options
|
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
|
// 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
|
// human label. Returns an empty kind for actions that ACP expresses through the
|
||||||
// outcome rather than an option (cancel).
|
// 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 {
|
switch action {
|
||||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
case agent.PermissionDecisionAllow:
|
||||||
return PermAllowOnce, "Allow"
|
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:
|
case agent.PermissionDecisionAllowForSession:
|
||||||
return PermAllowAlways, "Allow for this session"
|
return PermAllowAlways, "Allow for this session"
|
||||||
case agent.PermissionDecisionAllowPrefix:
|
case agent.PermissionDecisionAllowPrefix:
|
||||||
return PermAllowAlways, "Allow this command for the session"
|
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
|
||||||
case agent.PermissionDecisionAlwaysAllow:
|
case agent.PermissionDecisionAlwaysAllow:
|
||||||
return PermAllowAlways, "Always allow"
|
return PermAllowAlways, "Always allow"
|
||||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||||
return PermAllowAlways, "Always allow this command"
|
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
|
||||||
case agent.PermissionDecisionDeny:
|
case agent.PermissionDecisionDeny:
|
||||||
return PermRejectOnce, "Reject"
|
return PermRejectOnce, "Reject"
|
||||||
case agent.PermissionDecisionCancel:
|
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
|
// 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
|
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
|
||||||
// action verbatim (validated against what was offered); anything unrecognized
|
// action verbatim (validated against what was actually sent); anything
|
||||||
// fails closed to deny.
|
// unrecognized fails closed to deny.
|
||||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
|
//
|
||||||
|
// 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 {
|
switch outcome.Outcome {
|
||||||
case OutcomeCancelled:
|
case OutcomeCancelled:
|
||||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
||||||
case OutcomeSelected:
|
case OutcomeSelected:
|
||||||
action := agent.PermissionDecisionAction(outcome.OptionID)
|
|
||||||
// Bind to what was actually offered for THIS call: a client must not be able
|
// 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
|
// to return a broader grant (always_allow / allow_for_session) that wasn't
|
||||||
// presented. Anything not offered fails closed to deny.
|
// presented. Anything not offered fails closed to deny.
|
||||||
if actionOffered(action, offered) {
|
if actionOffered(outcome.OptionID, offered) {
|
||||||
return agent.PermissionDecision{Action: action}
|
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
|
||||||
}
|
}
|
||||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
||||||
default:
|
default:
|
||||||
@@ -84,9 +129,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.Permi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
|
func actionOffered(optionID string, offered []PermissionOption) bool {
|
||||||
for _, a := range offered {
|
for _, option := range offered {
|
||||||
if a == action {
|
if option.OptionID == optionID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,3 +168,13 @@ func rawInputBytes(data []byte) json.RawMessage {
|
|||||||
}
|
}
|
||||||
return json.RawMessage(data)
|
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) {
|
func TestDecisionFromOutcome(t *testing.T) {
|
||||||
offered := []agent.PermissionDecisionAction{
|
offered := buildPermissionOptions(agent.PermissionRequest{
|
||||||
agent.PermissionDecisionAllow,
|
ToolName: "bash",
|
||||||
agent.PermissionDecisionAlwaysAllow,
|
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||||
agent.PermissionDecisionDeny,
|
agent.PermissionDecisionAllow,
|
||||||
}
|
agent.PermissionDecisionAlwaysAllow,
|
||||||
|
agent.PermissionDecisionDeny,
|
||||||
|
},
|
||||||
|
})
|
||||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
||||||
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-19
@@ -103,10 +103,6 @@ type ContentBlock struct {
|
|||||||
|
|
||||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||||
|
|
||||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
|
||||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- sessions ----
|
// ---- sessions ----
|
||||||
|
|
||||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||||
@@ -233,10 +229,6 @@ func ToolContent(block ContentBlock) ToolCallContent {
|
|||||||
return ToolCallContent{Type: "content", Content: &block}
|
return ToolCallContent{Type: "content", Content: &block}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToolDiff(path, oldText, newText string) ToolCallContent {
|
|
||||||
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolCallLocation struct {
|
type ToolCallLocation struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Line *int `json:"line,omitempty"`
|
Line *int `json:"line,omitempty"`
|
||||||
@@ -337,19 +329,22 @@ type SetSessionModeParams struct {
|
|||||||
|
|
||||||
type SetSessionModeResult struct{}
|
type SetSessionModeResult struct{}
|
||||||
|
|
||||||
// ---- session config options (model selection) ----
|
// ---- session config options ----
|
||||||
|
|
||||||
type SessionConfigOptionValue struct {
|
type SessionConfigOptionValue struct {
|
||||||
ID string `json:"id"`
|
Value string `json:"value"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionConfigOption struct {
|
type SessionConfigOption struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Value string `json:"value"`
|
Category string `json:"category,omitempty"`
|
||||||
Values []SessionConfigOptionValue `json:"values,omitempty"`
|
Type string `json:"type"`
|
||||||
|
CurrentValue string `json:"currentValue"`
|
||||||
|
Options []SessionConfigOptionValue `json:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetSessionConfigOptionParams struct {
|
type SetSessionConfigOptionParams struct {
|
||||||
@@ -373,6 +368,11 @@ type ZeroSetModelResult struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
|
const (
|
||||||
// through the standard session/set_config_option method.
|
configIDModel = "model"
|
||||||
const configIDModel = "model"
|
configIDMode = "mode"
|
||||||
|
|
||||||
|
configOptionTypeSelect = "select"
|
||||||
|
configCategoryModel = "model"
|
||||||
|
configCategoryMode = "mode"
|
||||||
|
)
|
||||||
@@ -5,9 +5,29 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"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
|
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||||
// omits (or malforms) additional_permissions can never succeed: the same
|
// omits (or malforms) additional_permissions can never succeed: the same
|
||||||
// validation runs again at grant time regardless of the user's decision. It
|
// validation runs again at grant time regardless of the user's decision. It
|
||||||
@@ -16,7 +36,7 @@ import (
|
|||||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewBashTool(root))
|
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||||
|
|
||||||
promptCalls := 0
|
promptCalls := 0
|
||||||
result, err := executeToolCall(
|
result, err := executeToolCall(
|
||||||
@@ -51,7 +71,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
|
|||||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewBashTool(root))
|
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||||
|
|
||||||
promptCalls := 0
|
promptCalls := 0
|
||||||
result, err := executeToolCall(
|
result, err := executeToolCall(
|
||||||
@@ -88,7 +108,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
|
|||||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewBashTool(root))
|
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||||
|
|
||||||
result, err := executeToolCall(
|
result, err := executeToolCall(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -107,9 +127,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
|||||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
assertNamesOmitRemedy(t, "missing additional_permissions", result.Output)
|
||||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A well-formed additional_permissions payload must still reach the normal
|
// A well-formed additional_permissions payload must still reach the normal
|
||||||
@@ -117,7 +135,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
|||||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewBashTool(root))
|
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||||
|
|
||||||
promptCalls := 0
|
promptCalls := 0
|
||||||
_, err := executeToolCall(
|
_, err := executeToolCall(
|
||||||
@@ -144,3 +162,237 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
|
|||||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -501,15 +501,3 @@ func hasStringPrefix(values []string, prefix []string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func equalStringSlices(left []string, right []string) bool {
|
|
||||||
if len(left) != len(right) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for index := range left {
|
|
||||||
if left[index] != right[index] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -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) {
|
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"cat < in > out",
|
"cat < in > out",
|
||||||
|
|||||||
+107
-23
@@ -38,8 +38,9 @@ const compactionTriggerRatio = 0.7
|
|||||||
// transcript (and so tests can assert on it).
|
// transcript (and so tests can assert on it).
|
||||||
const summaryLabel = "[Summary of earlier conversation]"
|
const summaryLabel = "[Summary of earlier conversation]"
|
||||||
|
|
||||||
// summaryInstructions is the system prompt handed to the summarizer model.
|
// CompactionSummaryInstructions is the system prompt handed to both automatic
|
||||||
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
// 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; " +
|
"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 " +
|
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
|
||||||
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
||||||
@@ -56,6 +57,10 @@ type CompactionOptions struct {
|
|||||||
// injected so Compact stays pure and testable; the agent loop wires it to a
|
// injected so Compact stays pure and testable; the agent loop wires it to a
|
||||||
// real provider call.
|
// real provider call.
|
||||||
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
||||||
|
// taskState is a snapshot supplied by the running agent. Its immutable
|
||||||
|
// objective is always preserved; mutable fields are admitted only when its
|
||||||
|
// plan projection still matches the transcript.
|
||||||
|
taskState *taskStateSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
||||||
@@ -71,6 +76,12 @@ type CompactionResult struct {
|
|||||||
// summary message also includes summaryLabel and any preserved structured
|
// summary message also includes summaryLabel and any preserved structured
|
||||||
// state needed by later compactions.
|
// state needed by later compactions.
|
||||||
SummaryText string
|
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 reports whether Messages contains an injected summary.
|
||||||
Compacted bool
|
Compacted bool
|
||||||
}
|
}
|
||||||
@@ -133,11 +144,17 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
|||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
total += ApproxTextTokens(tool.Name)
|
total += ApproxTextTokens(tool.Name)
|
||||||
total += ApproxTextTokens(tool.Description)
|
total += ApproxTextTokens(tool.Description)
|
||||||
|
total += ApproxTextTokens(string(tool.Type))
|
||||||
if len(tool.Parameters) > 0 {
|
if len(tool.Parameters) > 0 {
|
||||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||||
total += ApproxTextTokens(string(encoded))
|
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
|
total += 4 // per-tool overhead
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
@@ -212,15 +229,15 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := opts.Summarize(middle)
|
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CompactionResult{}, err
|
return CompactionResult{}, err
|
||||||
}
|
}
|
||||||
summary = strings.TrimSpace(summary)
|
summary := summaryResult.SummaryText
|
||||||
|
|
||||||
// Preserve structured state (active plan + loaded skills) from the elided
|
// Preserve structured state (active plan + loaded skills) from the elided
|
||||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
|
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
||||||
|
|
||||||
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
||||||
compacted = append(compacted, messages[:systemEnd]...)
|
compacted = append(compacted, messages[:systemEnd]...)
|
||||||
@@ -234,10 +251,53 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
|||||||
RemovedCount: len(middle),
|
RemovedCount: len(middle),
|
||||||
PreservedCount: len(messages) - len(middle),
|
PreservedCount: len(messages) - len(middle),
|
||||||
SummaryText: summary,
|
SummaryText: summary,
|
||||||
|
ProjectedChars: summaryResult.ProjectedChars,
|
||||||
|
Truncated: summaryResult.Truncated,
|
||||||
Compacted: true,
|
Compacted: true,
|
||||||
}, nil
|
}, 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
|
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
|
||||||
// the preserved suffix begins on a user or assistant message rather than a
|
// 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
|
// tool/tool_result message. A tool result with no preceding assistant tool call
|
||||||
@@ -326,6 +386,9 @@ type compactionState struct {
|
|||||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||||
// but its token COST must still be counted so usage reports and budgets include it.
|
// but its token COST must still be counted so usage reports and budgets include it.
|
||||||
onUsage func(Usage)
|
onUsage func(Usage)
|
||||||
|
task *taskState
|
||||||
|
planner *contextPlanner
|
||||||
|
trace *trace.Recorder
|
||||||
|
|
||||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||||
@@ -365,13 +428,17 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
|||||||
return int(float64(raw) * state.calibrationRatio)
|
return int(float64(raw) * state.calibrationRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCompactionState(options Options) *compactionState {
|
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||||
return &compactionState{
|
state := &compactionState{
|
||||||
enabled: options.ContextWindow > 0,
|
enabled: options.ContextWindow > 0,
|
||||||
threshold: compactionThreshold(options.ContextWindow),
|
threshold: compactionThreshold(options.ContextWindow),
|
||||||
preserveLast: options.CompactionPreserveLast,
|
preserveLast: options.CompactionPreserveLast,
|
||||||
onUsage: options.OnUsage,
|
onUsage: options.OnUsage,
|
||||||
|
task: task,
|
||||||
|
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||||
|
trace: options.Trace,
|
||||||
}
|
}
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||||
@@ -406,6 +473,14 @@ func (state *compactionState) maybeCompact(
|
|||||||
// the bodies of old, large tool results (the model has already acted on
|
// 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
|
// them). If that brings us back under threshold, skip the paid summarizer
|
||||||
// entirely and preserve recent turns verbatim.
|
// 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 {
|
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||||
messages = pruned
|
messages = pruned
|
||||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||||
@@ -417,7 +492,8 @@ func (state *compactionState) maybeCompact(
|
|||||||
|
|
||||||
compacted, err := Compact(messages, CompactionOptions{
|
compacted, err := Compact(messages, CompactionOptions{
|
||||||
PreserveLast: state.preserveLast,
|
PreserveLast: state.preserveLast,
|
||||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
Summarize: state.summarizeClosure(ctx, provider),
|
||||||
|
taskState: state.task.snapshotForCompaction(messages),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Summarizer failed: keep the original history. The reactive path (or a
|
// Summarizer failed: keep the original history. The reactive path (or a
|
||||||
@@ -467,7 +543,8 @@ func (state *compactionState) recover(
|
|||||||
|
|
||||||
result, compactErr := Compact(messages, CompactionOptions{
|
result, compactErr := Compact(messages, CompactionOptions{
|
||||||
PreserveLast: state.preserveLast,
|
PreserveLast: state.preserveLast,
|
||||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
Summarize: state.summarizeClosure(ctx, provider),
|
||||||
|
taskState: state.task.snapshotForCompaction(messages),
|
||||||
})
|
})
|
||||||
if compactErr != nil {
|
if compactErr != nil {
|
||||||
// A genuine compaction attempt was made (and failed): the budget is spent
|
// A genuine compaction attempt was made (and failed): the budget is spent
|
||||||
@@ -500,9 +577,12 @@ func (state *compactionState) recover(
|
|||||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
// 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.
|
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||||
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||||
|
if state.planner == nil {
|
||||||
|
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||||
|
}
|
||||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||||
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +594,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
|
// Non-context-limit errors (and a single message that still won't fit) surface
|
||||||
// to the caller unchanged.
|
// to the caller unchanged.
|
||||||
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
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 {
|
if err == nil {
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
@@ -523,11 +607,11 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
|||||||
}
|
}
|
||||||
|
|
||||||
mid := len(messages) / 2
|
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 {
|
if leftErr != nil {
|
||||||
return "", leftErr
|
return "", leftErr
|
||||||
}
|
}
|
||||||
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
||||||
if rightErr != nil {
|
if rightErr != nil {
|
||||||
return "", rightErr
|
return "", rightErr
|
||||||
}
|
}
|
||||||
@@ -540,7 +624,7 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
|||||||
combined := strings.TrimSpace(left + "\n\n" + right)
|
combined := strings.TrimSpace(left + "\n\n" + right)
|
||||||
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
||||||
}, onUsage)
|
}, onUsage, planner, recorder)
|
||||||
if reduceErr != nil {
|
if reduceErr != nil {
|
||||||
if isContextLimitError(reduceErr.Error()) {
|
if isContextLimitError(reduceErr.Error()) {
|
||||||
// Even the two combined partials don't fit (extreme): fall back to the
|
// Even the two combined partials don't fit (extreme): fall back to the
|
||||||
@@ -556,15 +640,15 @@ func summarizeWithFallback(ctx context.Context, provider Provider, messages []ze
|
|||||||
}
|
}
|
||||||
|
|
||||||
// summarizeMessagesOnce performs a single tool-less summarization call.
|
// summarizeMessagesOnce performs a single tool-less summarization call.
|
||||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||||
request := zeroruntime.CompletionRequest{
|
requestMessages := []zeroruntime.Message{
|
||||||
Messages: []zeroruntime.Message{
|
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
|
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
|
||||||
},
|
|
||||||
// No tools: this is a plain text summarization call.
|
|
||||||
}
|
}
|
||||||
stream, err := provider.StreamCompletion(ctx, request)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,14 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
|||||||
if result.SummaryText != "manual summary" {
|
if result.SummaryText != "manual summary" {
|
||||||
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
||||||
}
|
}
|
||||||
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
|
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||||
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
|
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 {
|
if len(result.Messages) != 4 {
|
||||||
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
||||||
@@ -96,11 +102,46 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
|
|||||||
if result.SummaryText != "" {
|
if result.SummaryText != "" {
|
||||||
t.Fatalf("SummaryText = %q, want empty", 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) {
|
if !reflect.DeepEqual(result.Messages, messages) {
|
||||||
t.Fatalf("Messages changed on no-op: %#v", result.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) {
|
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
|
||||||
messages := []zeroruntime.Message{
|
messages := []zeroruntime.Message{
|
||||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const (
|
|||||||
const (
|
const (
|
||||||
maxRecentEdits = 20
|
maxRecentEdits = 20
|
||||||
maxEditNoteBytes = 160
|
maxEditNoteBytes = 160
|
||||||
|
// maxTaskObjectiveBytes keeps the original objective recognizable after
|
||||||
|
// compaction without allowing an unusually large prompt to recreate the
|
||||||
|
// context pressure the compaction just removed.
|
||||||
|
maxTaskObjectiveBytes = 512
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -76,32 +80,14 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
|
|||||||
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
||||||
// "" on malformed arguments or an empty plan.
|
// "" on malformed arguments or an empty plan.
|
||||||
func formatPlanArguments(arguments string) string {
|
func formatPlanArguments(arguments string) string {
|
||||||
var parsed struct {
|
plan, ok := parseTaskPlan(arguments)
|
||||||
Plan []struct {
|
if !ok {
|
||||||
Content string `json:"content"`
|
|
||||||
Step string `json:"step"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Notes string `json:"notes"`
|
|
||||||
} `json:"plan"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
lines := make([]string, 0, len(parsed.Plan))
|
lines := make([]string, 0, len(plan))
|
||||||
for _, item := range parsed.Plan {
|
for _, item := range plan {
|
||||||
content := strings.TrimSpace(item.Content)
|
line := "- [" + item.Status + "] " + item.Content
|
||||||
if content == "" {
|
if notes := item.Notes; notes != "" {
|
||||||
content = strings.TrimSpace(item.Step)
|
|
||||||
}
|
|
||||||
if content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status := strings.TrimSpace(item.Status)
|
|
||||||
if status == "" {
|
|
||||||
status = "pending"
|
|
||||||
}
|
|
||||||
line := "- [" + status + "] " + content
|
|
||||||
if notes := strings.TrimSpace(item.Notes); notes != "" {
|
|
||||||
line += "\n Notes: " + notes
|
line += "\n Notes: " + notes
|
||||||
}
|
}
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
@@ -116,7 +102,7 @@ type skillEntry struct {
|
|||||||
body string
|
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:
|
// — 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
|
// 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
|
// changed in each file (from the tool's result) so it need not re-read to
|
||||||
@@ -127,6 +113,7 @@ type skillEntry struct {
|
|||||||
// rather than an earlier, now-stale entry.
|
// rather than an earlier, now-stale entry.
|
||||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||||
pathByID := map[string]string{}
|
pathByID := map[string]string{}
|
||||||
|
noteByPath := map[string]string{}
|
||||||
sequence := make([]string, 0)
|
sequence := make([]string, 0)
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
for _, call := range message.ToolCalls {
|
for _, call := range message.ToolCalls {
|
||||||
@@ -143,12 +130,24 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
|||||||
sequence = append(sequence, path)
|
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)
|
order := lastSeenOrder(sequence)
|
||||||
if len(order) == 0 {
|
if len(order) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
noteByPath := map[string]string{}
|
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -431,12 +430,30 @@ func capBody(body string) string {
|
|||||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||||
type preservedState struct {
|
type preservedState struct {
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
|
Task *preservedTaskState `json:"task,omitempty"`
|
||||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||||
Tools []preservedTool `json:"tools,omitempty"`
|
Tools []preservedTool `json:"tools,omitempty"`
|
||||||
Skills []preservedSkill `json:"skills,omitempty"`
|
Skills []preservedSkill `json:"skills,omitempty"`
|
||||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type preservedTaskState struct {
|
||||||
|
Objective string `json:"objective"`
|
||||||
|
Status taskStatus `json:"status,omitempty"`
|
||||||
|
Pending int `json:"pending,omitempty"`
|
||||||
|
InProgress int `json:"in_progress,omitempty"`
|
||||||
|
Completed int `json:"completed,omitempty"`
|
||||||
|
Failed int `json:"failed,omitempty"`
|
||||||
|
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||||
|
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||||
|
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||||
|
Constraints []string `json:"constraints,omitempty"`
|
||||||
|
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||||
|
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||||
|
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||||
|
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type preservedEdit struct {
|
type preservedEdit struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
@@ -464,8 +481,39 @@ type preservedInstruction struct {
|
|||||||
// may live only inside the injected summary message, which on a later compaction
|
// may live only inside the injected summary message, which on a later compaction
|
||||||
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
||||||
// instruction blocks override the carried-forward copy by name/source.
|
// instruction blocks override the carried-forward copy by name/source.
|
||||||
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
|
||||||
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
||||||
|
task := priorState.Task
|
||||||
|
if taskSnapshot != nil {
|
||||||
|
task = &preservedTaskState{
|
||||||
|
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||||
|
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
|
||||||
|
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
|
||||||
|
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
|
||||||
|
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
|
||||||
|
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
|
||||||
|
}
|
||||||
|
// Plan parity corroborates only the mutable task projection. The objective
|
||||||
|
// comes directly from the run prompt and is immutable, so it must survive
|
||||||
|
// even after compaction removes the plan tool call needed for comparison.
|
||||||
|
if taskSnapshot.PlanParity == taskPlanParityMatch {
|
||||||
|
task.Status = taskSnapshot.Status
|
||||||
|
task.Pending = taskSnapshot.Plan.Pending
|
||||||
|
task.InProgress = taskSnapshot.Plan.InProgress
|
||||||
|
task.Completed = taskSnapshot.Plan.Completed
|
||||||
|
task.Failed = taskSnapshot.Plan.Failed
|
||||||
|
task.VerificationPassed = taskSnapshot.Verification.Passed
|
||||||
|
task.VerificationFailed = taskSnapshot.Verification.Failed
|
||||||
|
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freshConstraints := explicitConstraintsFromMessages(middle)
|
||||||
|
if len(freshConstraints) > 0 {
|
||||||
|
if task == nil {
|
||||||
|
task = &preservedTaskState{}
|
||||||
|
}
|
||||||
|
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
|
||||||
|
}
|
||||||
|
|
||||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||||
// plan preserved by an earlier compaction.
|
// plan preserved by an earlier compaction.
|
||||||
@@ -493,12 +541,114 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
|||||||
projectInstructionEntries(middle),
|
projectInstructionEntries(middle),
|
||||||
)
|
)
|
||||||
|
|
||||||
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
|
||||||
summary += "\n\n" + block
|
summary += "\n\n" + block
|
||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
|
||||||
|
var constraints []string
|
||||||
|
for _, message := range messages {
|
||||||
|
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, body := projectInstructionBlock(message.Content); body != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
|
||||||
|
}
|
||||||
|
return constraints
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorTaskConstraints(task *preservedTaskState) []string {
|
||||||
|
if task == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return task.Constraints
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorTaskChangedFiles(task *preservedTaskState) []string {
|
||||||
|
if task == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return task.ChangedFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
|
||||||
|
if task == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return task.UnresolvedFailures
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
|
||||||
|
if task == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return task.Approvals
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
|
||||||
|
if task == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return task.Artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
|
||||||
|
resolvedSet := make(map[string]struct{}, len(resolved))
|
||||||
|
for _, key := range resolved {
|
||||||
|
resolvedSet[key] = struct{}{}
|
||||||
|
}
|
||||||
|
merged := make([]taskFailureState, 0, len(older)+len(newer))
|
||||||
|
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
|
||||||
|
if _, ok := resolvedSet[failure.Key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
withoutSameKey := make([]taskFailureState, 0, len(merged))
|
||||||
|
for _, existing := range merged {
|
||||||
|
if existing.Key != failure.Key {
|
||||||
|
withoutSameKey = append(withoutSameKey, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged = append(withoutSameKey, failure)
|
||||||
|
}
|
||||||
|
if len(merged) > maxTaskEvidenceEntries {
|
||||||
|
merged = merged[len(merged)-maxTaskEvidenceEntries:]
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
|
||||||
|
merged := make([]T, 0, len(older)+len(newer))
|
||||||
|
seen := make(map[T]struct{}, len(older)+len(newer))
|
||||||
|
for _, value := range append(append([]T(nil), older...), newer...) {
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
merged = append(merged, value)
|
||||||
|
}
|
||||||
|
if len(merged) > limit {
|
||||||
|
merged = merged[len(merged)-limit:]
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func capTaskObjective(objective string) string {
|
||||||
|
objective = strings.TrimSpace(objective)
|
||||||
|
if len(objective) <= maxTaskObjectiveBytes {
|
||||||
|
return objective
|
||||||
|
}
|
||||||
|
const suffix = "…"
|
||||||
|
limit := maxTaskObjectiveBytes - len(suffix)
|
||||||
|
for limit > 0 && !utf8.RuneStart(objective[limit]) {
|
||||||
|
limit--
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(objective[:limit]) + suffix
|
||||||
|
}
|
||||||
|
|
||||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||||
@@ -555,11 +705,11 @@ func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
|||||||
|
|
||||||
// formatPreservedState renders state as the labelled, single-line
|
// formatPreservedState renders state as the labelled, single-line
|
||||||
// JSON block. Returns "" when there is nothing to preserve.
|
// JSON block. Returns "" when there is nothing to preserve.
|
||||||
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
|
||||||
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
state := preservedState{Plan: plan}
|
state := preservedState{Plan: plan, Task: task}
|
||||||
for _, e := range edits {
|
for _, e := range edits {
|
||||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||||
}
|
}
|
||||||
@@ -579,15 +729,6 @@ func formatPreservedState(plan string, edits, tools, skills, instructions []skil
|
|||||||
return preservedStateLabel + "\n" + string(encoded)
|
return preservedStateLabel + "\n" + string(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
|
||||||
// block. JSON escaping makes this lossless even when a skill body contains
|
|
||||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
|
||||||
// malformed.
|
|
||||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
|
||||||
state := parsePreservedStateBlock(summaryContent)
|
|
||||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePreservedStateBlock(summaryContent string) preservedState {
|
func parsePreservedStateBlock(summaryContent string) preservedState {
|
||||||
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +61,169 @@ func TestCompactPreservesActivePlan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||||
|
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||||
|
task := newTaskState(objective, nil)
|
||||||
|
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||||
|
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
|
||||||
|
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
|
||||||
|
}})
|
||||||
|
messages := stateConversation()
|
||||||
|
compacted, err := Compact(messages, CompactionOptions{
|
||||||
|
PreserveLast: 2,
|
||||||
|
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||||
|
taskState: task.snapshotForCompaction(messages),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compact: %v", err)
|
||||||
|
}
|
||||||
|
state := parsePreservedStateBlock(compacted[1].Content)
|
||||||
|
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
|
||||||
|
t.Fatalf("unexpected compact task state: %#v", state.Task)
|
||||||
|
}
|
||||||
|
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||||
|
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||||
|
}
|
||||||
|
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
|
||||||
|
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
|
||||||
|
task := newTaskState("Please keep the change focused.", nil)
|
||||||
|
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||||
|
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
|
||||||
|
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||||
|
}})
|
||||||
|
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||||
|
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
|
||||||
|
}})
|
||||||
|
messages := stateConversation()
|
||||||
|
|
||||||
|
first, err := Compact(messages, CompactionOptions{
|
||||||
|
PreserveLast: 2,
|
||||||
|
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||||
|
taskState: task.snapshotForCompaction(messages),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||||
|
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
|
||||||
|
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||||
|
}})
|
||||||
|
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||||
|
)
|
||||||
|
second, err := Compact(secondInput, CompactionOptions{
|
||||||
|
PreserveLast: 2,
|
||||||
|
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||||
|
taskState: task.snapshotForCompaction(secondInput),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := parsePreservedStateBlock(second[1].Content)
|
||||||
|
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
|
||||||
|
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
|
||||||
|
}
|
||||||
|
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
|
||||||
|
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
|
||||||
|
if !containsString(state.Task.Constraints, want) {
|
||||||
|
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, want string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||||
|
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
|
||||||
|
encoded, err := json.Marshal(prior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
messages := []zeroruntime.Message{
|
||||||
|
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||||
|
}
|
||||||
|
compacted, err := Compact(messages, CompactionOptions{
|
||||||
|
PreserveLast: 2,
|
||||||
|
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||||
|
taskState: &taskStateSnapshot{
|
||||||
|
Objective: "current objective",
|
||||||
|
PlanParity: taskPlanParityMismatch,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compact: %v", err)
|
||||||
|
}
|
||||||
|
state := parsePreservedStateBlock(compacted[1].Content)
|
||||||
|
if state.Task == nil || state.Task.Objective != "current objective" {
|
||||||
|
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
|
||||||
|
}
|
||||||
|
if state.Task.Status != "" || state.Task.Pending != 0 {
|
||||||
|
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
|
||||||
|
task := newTaskState("keep this objective", nil)
|
||||||
|
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||||
|
messages := stateConversation()
|
||||||
|
|
||||||
|
first, err := Compact(messages, CompactionOptions{
|
||||||
|
PreserveLast: 2,
|
||||||
|
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||||
|
taskState: task.snapshotForCompaction(messages),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Compact: %v", err)
|
||||||
|
}
|
||||||
|
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||||
|
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||||
|
)
|
||||||
|
snapshot := task.snapshotForCompaction(secondInput)
|
||||||
|
if snapshot.PlanParity != taskPlanParityMismatch {
|
||||||
|
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
|
||||||
|
}
|
||||||
|
second, err := Compact(secondInput, CompactionOptions{
|
||||||
|
PreserveLast: 2,
|
||||||
|
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||||
|
taskState: snapshot,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Compact: %v", err)
|
||||||
|
}
|
||||||
|
state := parsePreservedStateBlock(second[1].Content)
|
||||||
|
if state.Task == nil || state.Task.Objective != "keep this objective" {
|
||||||
|
t.Fatalf("objective lost after second compaction: %#v", state.Task)
|
||||||
|
}
|
||||||
|
if state.Task.Status != "" || state.Task.InProgress != 0 {
|
||||||
|
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
||||||
summary := compactStateConversation(t, stateConversation())
|
summary := compactStateConversation(t, stateConversation())
|
||||||
if !strings.Contains(summary, preservedStateLabel) {
|
if !strings.Contains(summary, preservedStateLabel) {
|
||||||
@@ -283,12 +448,10 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
|
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
|
||||||
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
||||||
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
|
if got != "" {
|
||||||
if !strings.Contains(got, want) {
|
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
|
||||||
t.Fatalf("expected %q in formatted plan, got %q", want, got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"}`},
|
{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.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)
|
edits := recentEdits(messages)
|
||||||
if len(edits) != 2 {
|
if len(edits) != 3 {
|
||||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
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") {
|
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])
|
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") {
|
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])
|
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
|
// After compaction elides the editing turns, the preserved-state block still
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
"github.com/Gitlawb/zero/internal/trace"
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,12 +59,15 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
|
|||||||
if last.Content != "most recent question" {
|
if last.Content != "most recent question" {
|
||||||
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
||||||
}
|
}
|
||||||
// The summarized middle excludes system and preserved suffix.
|
// The summarizer receives a compact semantic projection of the middle rather
|
||||||
if len(captured) != 3 {
|
// than reconstructible raw tool output.
|
||||||
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
|
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" {
|
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
|
||||||
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
|
if !strings.Contains(captured[0].Content, want) {
|
||||||
|
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Compaction must shrink the conversation.
|
// Compaction must shrink the conversation.
|
||||||
if estimateTokens(result) >= estimateTokens(messages) {
|
if estimateTokens(result) >= estimateTokens(messages) {
|
||||||
@@ -271,6 +275,27 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
|
|||||||
if estimateToolDefTokens(one) <= 0 {
|
if estimateToolDefTokens(one) <= 0 {
|
||||||
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
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{
|
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
||||||
Name: "write_file",
|
Name: "write_file",
|
||||||
Description: "Write contents to a file in the workspace.",
|
Description: "Write contents to a file in the workspace.",
|
||||||
@@ -336,7 +361,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||||
|
|
||||||
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
@@ -364,7 +389,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||||
|
|
||||||
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
@@ -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
|
// 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
|
// 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
|
// compaction can shrink it), then succeeds on the same-turn retry. Summary
|
||||||
@@ -388,6 +450,7 @@ type reactiveProvider struct {
|
|||||||
summarizeCalls int
|
summarizeCalls int
|
||||||
turnRequests int
|
turnRequests int
|
||||||
failedOnce bool
|
failedOnce bool
|
||||||
|
connectError bool
|
||||||
bigText string
|
bigText string
|
||||||
finalText 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
|
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
|
||||||
case provider.turnRequests == 2 && !provider.failedOnce:
|
case provider.turnRequests == 2 && !provider.failedOnce:
|
||||||
provider.failedOnce = true
|
provider.failedOnce = true
|
||||||
|
if provider.connectError {
|
||||||
|
return nil, errors.New("context_length_exceeded")
|
||||||
|
}
|
||||||
return streamEvents([]zeroruntime.StreamEvent{
|
return streamEvents([]zeroruntime.StreamEvent{
|
||||||
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
||||||
}), nil
|
}), 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) {
|
func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||||
provider := &reactiveProvider{
|
provider := &reactiveProvider{
|
||||||
bigText: strings.Repeat("b", 6000),
|
bigText: strings.Repeat("b", 6000),
|
||||||
@@ -427,7 +526,9 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
|||||||
// the compaction closure. read_file also returns a sizeable result that
|
// the compaction closure. read_file also returns a sizeable result that
|
||||||
// bloats the history.
|
// bloats the history.
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||||
|
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
|
||||||
|
var contextPlans []ContextBreakdown
|
||||||
|
|
||||||
// ContextWindow large enough that proactive compaction never triggers, so
|
// ContextWindow large enough that proactive compaction never triggers, so
|
||||||
// only the reactive path can save the run.
|
// only the reactive path can save the run.
|
||||||
@@ -436,6 +537,10 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
|||||||
PermissionMode: PermissionModeUnsafe,
|
PermissionMode: PermissionModeUnsafe,
|
||||||
ContextWindow: 10_000_000,
|
ContextWindow: 10_000_000,
|
||||||
CompactionPreserveLast: 2,
|
CompactionPreserveLast: 2,
|
||||||
|
Trace: recorder,
|
||||||
|
OnContext: func(breakdown ContextBreakdown) {
|
||||||
|
contextPlans = append(contextPlans, breakdown)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
|
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 {
|
if provider.summarizeCalls == 0 {
|
||||||
t.Fatal("expected reactive compaction to invoke the summarizer")
|
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
|
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
|
||||||
@@ -491,7 +615,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
|
|||||||
finalText: "recovered",
|
finalText: "recovered",
|
||||||
}
|
}
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||||
|
|
||||||
var deltas []string
|
var deltas []string
|
||||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||||
@@ -581,7 +705,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
|
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
||||||
}}}
|
}}}
|
||||||
@@ -628,7 +752,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRecoverDisabledIsNoop(t *testing.T) {
|
func TestRecoverDisabledIsNoop(t *testing.T) {
|
||||||
st := newCompactionState(Options{ContextWindow: 0})
|
st := newCompactionState(Options{ContextWindow: 0}, nil)
|
||||||
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
||||||
called := false
|
called := false
|
||||||
// recover must not invoke the provider/summarize when disabled, even on a
|
// recover must not invoke the provider/summarize when disabled, even on a
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
|
|||||||
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
|
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
|
||||||
// A direct admission is strong local evidence and takes precedence over all
|
// A direct admission is strong local evidence and takes precedence over all
|
||||||
// weaker signals, avoiding both continuation nudges and semantic checks.
|
// weaker signals, avoiding both continuation nudges and semantic checks.
|
||||||
if reason := selfReportedIncompletion(text); reason != "" {
|
if reason := selfReportedIncompletion(text); reason != "" {
|
||||||
@@ -50,7 +50,7 @@ func (policy *completionPolicy) evaluate(text string, planPending bool) completi
|
|||||||
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
||||||
// to continue, but only a persisted cue is ultimately incomplete.
|
// to continue, but only a persisted cue is ultimately incomplete.
|
||||||
cue := endsWithContinuationCue(text)
|
cue := endsWithContinuationCue(text)
|
||||||
if cue || planPending {
|
if cue || context.PlanPending {
|
||||||
if policy.continueNudges < maxContinueNudges {
|
if policy.continueNudges < maxContinueNudges {
|
||||||
policy.continueNudges++
|
policy.continueNudges++
|
||||||
reason := "your message ended mid-step"
|
reason := "your message ended mid-step"
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
||||||
policy := newCompletionPolicy(false)
|
policy := newCompletionPolicy(false)
|
||||||
|
|
||||||
complete := policy.evaluate("Done. All required checks pass.", false)
|
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
|
||||||
if complete.Decision != CompletionComplete {
|
if complete.Decision != CompletionComplete {
|
||||||
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
|
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
|
||||||
if incomplete.Decision != CompletionIncomplete {
|
if incomplete.Decision != CompletionIncomplete {
|
||||||
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
||||||
}
|
}
|
||||||
@@ -19,13 +22,13 @@ func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T)
|
|||||||
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||||
policy := newCompletionPolicy(false)
|
policy := newCompletionPolicy(false)
|
||||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||||
if got.Decision != CompletionIncomplete {
|
if got.Decision != CompletionIncomplete {
|
||||||
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
||||||
}
|
}
|
||||||
@@ -34,13 +37,13 @@ func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
|||||||
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||||
policy := newCompletionPolicy(false)
|
policy := newCompletionPolicy(false)
|
||||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||||
got := policy.evaluate("All set.", true)
|
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
got := policy.evaluate("All set.", true)
|
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||||
if got.Decision != CompletionComplete {
|
if got.Decision != CompletionComplete {
|
||||||
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
||||||
}
|
}
|
||||||
@@ -49,12 +52,12 @@ func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
|||||||
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||||
policy := newCompletionPolicy(true)
|
policy := newCompletionPolicy(true)
|
||||||
|
|
||||||
first := policy.evaluate("Implemented and tested.", false)
|
first := policy.evaluate("Implemented and tested.", completionContext{})
|
||||||
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
||||||
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
||||||
}
|
}
|
||||||
|
|
||||||
second := policy.evaluate("PASS. The result meets the task criterion.", false)
|
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
|
||||||
if second.Decision != CompletionComplete {
|
if second.Decision != CompletionComplete {
|
||||||
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
||||||
}
|
}
|
||||||
@@ -62,3 +65,11 @@ func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
|||||||
t.Fatal("semantic check was requested more than once")
|
t.Fatal("semantic check was requested more than once")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
|
||||||
|
objective := "make the requested behavior observable"
|
||||||
|
nudge := acceptanceVerificationNudge(objective)
|
||||||
|
if !strings.Contains(nudge, "Task objective: "+objective) {
|
||||||
|
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,24 @@ type ContextBreakdown struct {
|
|||||||
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
||||||
ContextWindow int // model context window; 0 when unknown
|
ContextWindow int // model context window; 0 when unknown
|
||||||
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window 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
|
// 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,
|
ContextWindow: contextWindow,
|
||||||
}
|
}
|
||||||
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
|
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 {
|
if contextWindow > 0 {
|
||||||
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
|
|||||||
if breakdown.UsedFraction != wantFraction {
|
if breakdown.UsedFraction != wantFraction {
|
||||||
t.Fatalf("UsedFraction = %v, want %v", 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) {
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
|
|||||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||||
registry.Register(fakeToolSearchTool{})
|
registry.Register(fakeToolSearchTool{})
|
||||||
|
|
||||||
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
|||||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
|
||||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||||
|
|
||||||
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
|||||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||||
}
|
}
|
||||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
if !strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
|
||||||
}
|
}
|
||||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
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 := tools.NewRegistry()
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||||
@@ -368,9 +368,8 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
|||||||
if !exposedNames["tool_search"] {
|
if !exposedNames["tool_search"] {
|
||||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||||
}
|
}
|
||||||
// No hidden tools means no dynamic discovery text is needed.
|
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
|
||||||
if reminder != "" {
|
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
|
||||||
t.Fatalf("expected empty discovery text when nothing is hidden, 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
|
|
||||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||||
DisabledTools: []string{"read_file"},
|
DisabledTools: []string{"read_file"},
|
||||||
|
|||||||
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
|||||||
var switchedTo string
|
var switchedTo string
|
||||||
switchCount := 0
|
switchCount := 0
|
||||||
result, err := Run(context.Background(), "go", firstProvider, Options{
|
result, err := Run(context.Background(), "go", firstProvider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Model: "claude-sonnet-4.5",
|
Model: "claude-sonnet-4.5",
|
||||||
MaxTurns: 4,
|
ServiceTier: "priority",
|
||||||
|
MaxTurns: 4,
|
||||||
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
||||||
switchCount++
|
switchCount++
|
||||||
switchedTo = modelID
|
switchedTo = modelID
|
||||||
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
|||||||
if len(firstProvider.requests) != 1 {
|
if len(firstProvider.requests) != 1 {
|
||||||
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
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 {
|
if len(secondProvider.requests) != 1 {
|
||||||
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
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" {
|
if result.FinalAnswer != "answered on the upgraded model" {
|
||||||
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
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")}
|
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
||||||
|
|
||||||
result, err := Run(context.Background(), "go", provider, Options{
|
result, err := Run(context.Background(), "go", provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Model: "claude-sonnet-4.5",
|
Model: "claude-sonnet-4.5",
|
||||||
MaxTurns: 4,
|
ServiceTier: "priority",
|
||||||
|
MaxTurns: 4,
|
||||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||||
return nil, errors.New("provider build blew up")
|
return nil, errors.New("provider build blew up")
|
||||||
},
|
},
|
||||||
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
|||||||
if len(provider.requests) != 2 {
|
if len(provider.requests) != 2 {
|
||||||
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
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" {
|
if result.FinalAnswer != "recovered" {
|
||||||
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func equalStringSlices(left []string, right []string) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range left {
|
||||||
|
if left[index] != right[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||||
|
// block. JSON escaping makes this lossless even when a skill body contains
|
||||||
|
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||||
|
// malformed.
|
||||||
|
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||||
|
state := parsePreservedStateBlock(summaryContent)
|
||||||
|
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitionTools builds the per-turn advertised tool list and optional
|
||||||
|
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||||
|
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||||
|
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||||
|
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||||
|
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||||
|
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||||
|
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||||
|
// so the inactive path is stable.
|
||||||
|
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||||
|
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -34,11 +33,11 @@ const (
|
|||||||
// knows before spending more tool turns.
|
// knows before spending more tool turns.
|
||||||
toolOnlyProgressReminderAt = 6
|
toolOnlyProgressReminderAt = 6
|
||||||
|
|
||||||
// planReminderTurn is the turn (1-based) by the end of which a multi-step
|
// planReminderTurn and planReminderToolThreshold keep bounded implementation
|
||||||
// task should have called update_plan; if it hasn't, a one-time reminder is
|
// work plan-free while still nudging a genuinely sustained run that has not
|
||||||
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
|
// established any structured progress. Both thresholds must be met.
|
||||||
// without a spurious planning nag.
|
planReminderTurn = 7
|
||||||
planReminderTurn = 3
|
planReminderToolThreshold = 7
|
||||||
|
|
||||||
// planToolName is the planning tool the loop watches for by name.
|
// planToolName is the planning tool the loop watches for by name.
|
||||||
planToolName = "update_plan"
|
planToolName = "update_plan"
|
||||||
@@ -127,9 +126,8 @@ func endsWithContinuationCue(text string) bool {
|
|||||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||||
// that tool's own status normalization.
|
// that tool's own status normalization.
|
||||||
func planStatusRemaining(status string) bool {
|
func planStatusRemaining(status string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
switch normalizeTaskPlanStatus(status) {
|
||||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
case "completed", "failed":
|
||||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -307,8 +305,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
|
|||||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||||
// passing treated as the objective being met; and a result that merely matches a
|
// passing treated as the objective being met; and a result that merely matches a
|
||||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||||
func acceptanceVerificationNudge() string {
|
func acceptanceVerificationNudge(objective string) string {
|
||||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||||
"result merely matches a baseline you were asked to beat or improve. " +
|
"result merely matches a baseline you were asked to beat or improve. " +
|
||||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||||
@@ -316,6 +314,10 @@ func acceptanceVerificationNudge() string {
|
|||||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||||
"If that check passes, reply PASS and cite the evidence. " +
|
"If that check passes, reply PASS and cite the evidence. " +
|
||||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||||
|
if objective = capTaskObjective(objective); objective != "" {
|
||||||
|
nudge += "\n\nTask objective: " + objective
|
||||||
|
}
|
||||||
|
return nudge
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolFailureHintMarker is a stable substring for tests.
|
// toolFailureHintMarker is a stable substring for tests.
|
||||||
@@ -553,16 +555,12 @@ func (state *guardState) pendingPlanItems() bool {
|
|||||||
// many items are still remaining. Malformed arguments leave the prior count
|
// many items are still remaining. Malformed arguments leave the prior count
|
||||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||||
func (state *guardState) observePlanUpdate(arguments string) {
|
func (state *guardState) observePlanUpdate(arguments string) {
|
||||||
var parsed struct {
|
plan, ok := parseTaskPlan(arguments)
|
||||||
Plan []struct {
|
if !ok {
|
||||||
Status string `json:"status"`
|
|
||||||
} `json:"plan"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pending := 0
|
pending := 0
|
||||||
for _, item := range parsed.Plan {
|
for _, item := range plan {
|
||||||
if planStatusRemaining(item.Status) {
|
if planStatusRemaining(item.Status) {
|
||||||
pending++
|
pending++
|
||||||
}
|
}
|
||||||
@@ -593,13 +591,12 @@ func (state *guardState) planReminder(turn int) string {
|
|||||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
|
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
|
||||||
// called update_plan if it's doing a multi-step task (>=1 other tool call).
|
// sustained work should have called update_plan. One-shot for the whole run.
|
||||||
// One-shot for the whole run.
|
|
||||||
if !state.notCalledReminderSent &&
|
if !state.notCalledReminderSent &&
|
||||||
!state.planEverCalled &&
|
!state.planEverCalled &&
|
||||||
turn >= planReminderTurn &&
|
turn >= planReminderTurn &&
|
||||||
state.totalToolCalls >= 1 {
|
state.totalToolCalls >= planReminderToolThreshold {
|
||||||
state.notCalledReminderSent = true
|
state.notCalledReminderSent = true
|
||||||
return planNotCalledReminder()
|
return planNotCalledReminder()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
|
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{
|
||||||
turns: [][]zeroruntime.StreamEvent{
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
@@ -169,6 +169,37 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The whole point of #702's fix: the unknown-session error's normalized
|
||||||
|
// signature must not vary with the session id, so a model probing ids 1, 2,
|
||||||
|
// 3, … produces one repeated signature the failure guard can count. If the id
|
||||||
|
// leaked into the first 80 normalized chars, each probe would reset the streak
|
||||||
|
// and the halt would never fire.
|
||||||
|
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
|
||||||
|
a := errorSignature(tools.UnknownExecSessionError(1))
|
||||||
|
b := errorSignature(tools.UnknownExecSessionError(999999))
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end: with an id-invariant signature, probing a different unknown id
|
||||||
|
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
|
||||||
|
// before the fix it never would.
|
||||||
|
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
|
||||||
|
var state guardState
|
||||||
|
var stoppedAt int
|
||||||
|
for i := 1; i <= toolFailureStopAt; i++ {
|
||||||
|
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
|
||||||
|
if out.Stop {
|
||||||
|
stoppedAt = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stoppedAt != toolFailureStopAt {
|
||||||
|
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||||
var state guardState
|
var state guardState
|
||||||
toolOnly := zeroruntime.CollectedStream{
|
toolOnly := zeroruntime.CollectedStream{
|
||||||
@@ -226,19 +257,16 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(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()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
|
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
|
||||||
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"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := Run(context.Background(), "go", provider, Options{
|
result, err := Run(context.Background(), "go", provider, Options{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
@@ -256,11 +284,49 @@ 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) {
|
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
|
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{
|
||||||
turns: [][]zeroruntime.StreamEvent{
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
@@ -288,7 +354,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
registry.Register(tools.NewUpdatePlanTool())
|
registry.Register(tools.NewUpdatePlanTool())
|
||||||
|
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{
|
||||||
@@ -318,7 +384,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
registry.Register(tools.NewUpdatePlanTool())
|
registry.Register(tools.NewUpdatePlanTool())
|
||||||
|
|
||||||
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
||||||
@@ -352,7 +418,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
registry.Register(tools.NewUpdatePlanTool())
|
registry.Register(tools.NewUpdatePlanTool())
|
||||||
|
|
||||||
turns := [][]zeroruntime.StreamEvent{
|
turns := [][]zeroruntime.StreamEvent{
|
||||||
@@ -387,7 +453,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root))
|
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||||
|
|
||||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||||
|
|||||||
+517
-205
File diff suppressed because it is too large.
Load diff
+879
-58
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -19,6 +20,16 @@ type propagationOutputTool struct {
|
|||||||
|
|
||||||
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||||
for index, arg := range os.Args {
|
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) {
|
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -30,25 +41,102 @@ func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
|
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{
|
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Hooks: []hooks.Definition{{
|
Hooks: []hooks.Definition{{
|
||||||
ID: "large-feedback",
|
ID: "large-feedback",
|
||||||
Event: hooks.EventAfterTool,
|
Event: hooks.EventAfterTool,
|
||||||
Matcher: "propagation_output",
|
Matcher: toolName,
|
||||||
Command: os.Args[0],
|
Command: os.Args[0],
|
||||||
Args: []string{
|
Args: hookArgs,
|
||||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
|
||||||
"--",
|
|
||||||
"--zero-output-budget-hook",
|
|
||||||
feedback,
|
|
||||||
},
|
|
||||||
Enabled: true,
|
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) Name() string { return "propagation_output" }
|
||||||
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
||||||
func (tool propagationOutputTool) Parameters() tools.Schema {
|
func (tool propagationOutputTool) Parameters() tools.Schema {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
|||||||
// "read_file".
|
// "read_file".
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
|
||||||
registry.Register(fakeToolSearchTool{})
|
registry.Register(fakeToolSearchTool{})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
|
|||||||
return tools.Result{Status: tools.StatusOK}
|
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
|
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||||
// output is identical to the uncached path.
|
// output is identical to the uncached path.
|
||||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||||
|
|||||||
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
|||||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
|
||||||
|
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
|
||||||
|
args := map[string]any{
|
||||||
|
"command": "npm install",
|
||||||
|
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||||
|
"additional_permissions": map[string]any{
|
||||||
|
"network": map[string]any{"enabled": true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||||
|
|
||||||
|
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
|
||||||
|
t.Fatal("an already-covered capability must not trigger another prompt")
|
||||||
|
}
|
||||||
|
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("covered capability should still produce an auditable allow event")
|
||||||
|
}
|
||||||
|
if event.Action != PermissionActionAllow {
|
||||||
|
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
|
||||||
|
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
|
||||||
|
args := map[string]any{"command": "make test"}
|
||||||
|
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
|
||||||
|
|
||||||
|
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("generic shell approval must produce an event")
|
||||||
|
}
|
||||||
|
if event.Reason != "" {
|
||||||
|
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
|
||||||
|
}
|
||||||
|
if event.DecisionReason != "runs a shell command" {
|
||||||
|
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
|
||||||
|
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
|
||||||
|
args := map[string]any{
|
||||||
|
"command": "curl https://example.test",
|
||||||
|
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
|
||||||
|
"justification": "Run without sandboxing.",
|
||||||
|
}
|
||||||
|
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
|
||||||
|
|
||||||
|
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("policy approval must produce an event")
|
||||||
|
}
|
||||||
|
if event.Reason != sandbox.ReasonNetworkBlocked {
|
||||||
|
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// escalatingEngine allows unsandboxed escalation: no denied reads, so
|
||||||
|
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
|
||||||
|
// call to require_escalated.
|
||||||
|
func escalatingEngine(t *testing.T) *sandbox.Engine {
|
||||||
|
t.Helper()
|
||||||
|
policy := sandbox.DefaultPolicy()
|
||||||
|
policy.DenyRead = nil
|
||||||
|
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonEscalatingEngine has a denied read, which is exactly the condition under
|
||||||
|
// which escalation is refused, so no disclosure should be made.
|
||||||
|
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
policy := sandbox.DefaultPolicy()
|
||||||
|
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
|
||||||
|
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
|
||||||
|
// agree on every input, because the defect being fixed is precisely that the
|
||||||
|
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
|
||||||
|
// only "allow command prefix for session".
|
||||||
|
//
|
||||||
|
// Asserting agreement rather than asserting each side's expected value is what
|
||||||
|
// makes this hold up. If someone later widens or narrows the rewrite's guards
|
||||||
|
// and forgets the flag, the two diverge here and this fails, which a pair of
|
||||||
|
// independent expected-value tests would not catch.
|
||||||
|
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
|
||||||
|
escalating := Options{Sandbox: escalatingEngine(t)}
|
||||||
|
denied := Options{Sandbox: nonEscalatingEngine(t)}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
toolName string
|
||||||
|
args map[string]any
|
||||||
|
options Options
|
||||||
|
}{
|
||||||
|
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
|
||||||
|
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
|
||||||
|
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
|
||||||
|
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
|
||||||
|
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
|
||||||
|
{
|
||||||
|
"call already asks for escalation",
|
||||||
|
"bash",
|
||||||
|
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
|
||||||
|
escalating,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call already asks for additional permissions",
|
||||||
|
"bash",
|
||||||
|
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
|
||||||
|
escalating,
|
||||||
|
},
|
||||||
|
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
|
||||||
|
// the disclosure has to fire. Included because the obvious reading of
|
||||||
|
// the guards says otherwise, and a case asserting the wrong thing here
|
||||||
|
// would have passed silently.
|
||||||
|
{
|
||||||
|
"unrecognized sandbox_permissions value",
|
||||||
|
"bash",
|
||||||
|
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
|
||||||
|
escalating,
|
||||||
|
},
|
||||||
|
{"empty args", "bash", map[string]any{}, escalating},
|
||||||
|
{"nil args", "bash", nil, escalating},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
|
||||||
|
|
||||||
|
// What the user's approval would actually be turned into.
|
||||||
|
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
|
||||||
|
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
|
||||||
|
|
||||||
|
if disclosed != escalates {
|
||||||
|
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two prefix decisions are the only ones that escalate, so a disclosure
|
||||||
|
// attached to a request offering neither would be a false alarm. Checked
|
||||||
|
// against the same predicate the rewrite consults.
|
||||||
|
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
|
||||||
|
escalating := map[PermissionDecisionAction]bool{
|
||||||
|
PermissionDecisionAllowPrefix: true,
|
||||||
|
PermissionDecisionAlwaysAllowPrefix: true,
|
||||||
|
}
|
||||||
|
for _, action := range []PermissionDecisionAction{
|
||||||
|
PermissionDecisionAllow,
|
||||||
|
PermissionDecisionAllowStrict,
|
||||||
|
PermissionDecisionAllowForSession,
|
||||||
|
PermissionDecisionAllowPrefix,
|
||||||
|
PermissionDecisionAlwaysAllowPrefix,
|
||||||
|
PermissionDecisionAlwaysAllow,
|
||||||
|
PermissionDecisionDeny,
|
||||||
|
PermissionDecisionCancel,
|
||||||
|
} {
|
||||||
|
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
|
||||||
|
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
|
|||||||
if c.policy == nil || c.escalated {
|
if c.policy == nil || c.escalated {
|
||||||
return PostureEscalation{}, false
|
return PostureEscalation{}, false
|
||||||
}
|
}
|
||||||
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
|
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
|
||||||
return PostureEscalation{}, false
|
return PostureEscalation{}, false
|
||||||
}
|
}
|
||||||
c.escalated = true
|
c.escalated = true
|
||||||
|
|||||||
@@ -14,13 +14,26 @@ import (
|
|||||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||||
//
|
//
|
||||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
|
||||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
// the auto-mode advertised set, including exec_command: auto mode must expose both
|
||||||
// only advertise in other modes are excluded, matching what a real turn actually
|
// process creation and write_stdin process interaction or the latter is unusable
|
||||||
// pays.
|
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
||||||
const (
|
const (
|
||||||
maxBaseSystemPromptTokens = 3500
|
maxBaseSystemPromptTokens = 3500
|
||||||
maxEagerToolSchemaTokens = 2700
|
// Raised deliberately from 3550 on 2026-08-08. view_image (#843) took the set
|
||||||
|
// to 3588, and #867 trimmed it back to 3578: still 28 tokens over.
|
||||||
|
//
|
||||||
|
// The overrun is a rounding error rather than a regression. view_image costs
|
||||||
|
// 82 tokens, the second cheapest tool in the set, against exec_command's 909
|
||||||
|
// and request_permissions' 397; it is simply the one that crossed the line.
|
||||||
|
// Raised rather than paid down because the alternative was trimming a
|
||||||
|
// description to claw back 28 tokens, degrading a tool's usability to satisfy
|
||||||
|
// a line drawn against a 2026-07 measurement that predates two tools.
|
||||||
|
//
|
||||||
|
// The ratchet did its job: it caught the creep and forced a decision instead
|
||||||
|
// of a drift. That is the point of it, so keep raising it deliberately rather
|
||||||
|
// than reflexively.
|
||||||
|
maxEagerToolSchemaTokens = 3650
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||||
@@ -46,6 +59,29 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
|||||||
got := estimateToolDefTokens(exposed)
|
got := estimateToolDefTokens(exposed)
|
||||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||||
if got > maxEagerToolSchemaTokens {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,11 @@ import (
|
|||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// promptSubstrings are the seven cacheable sub-components of the prompt a run
|
// promptSubstrings are the cacheable components of the prompt a run sends to a
|
||||||
// sends to a model. Each is the literal content the sub-component would emit
|
// model. systemPrompt is the exact joined system-message content. The remaining
|
||||||
// into the system prompt or tool list, hashed so a downstream observer can
|
// fields retain useful diagnostic boundaries within that prompt and tool list.
|
||||||
// detect drift turn-over-turn. The substrings are produced by
|
|
||||||
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
|
|
||||||
type promptSubstrings struct {
|
type promptSubstrings struct {
|
||||||
|
systemPrompt string
|
||||||
baseInstructions string
|
baseInstructions string
|
||||||
confirmationPolicy string
|
confirmationPolicy string
|
||||||
projectContext string
|
projectContext string
|
||||||
@@ -25,92 +24,61 @@ type promptSubstrings struct {
|
|||||||
schema string
|
schema string
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPromptSubstrings assembles the seven cacheable sub-components of the
|
// buildPromptSubstrings builds the prompt and retains both its exact joined
|
||||||
// prompt without joining them. The system prompt sections are produced by the
|
// form and narrower diagnostic components. Callers that already built the
|
||||||
// same private builders that buildSystemPrompt uses, so the substrings are
|
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
|
||||||
// byte-identical to what would have appeared in the joined prompt. The tools
|
// reads.
|
||||||
// and schema substrings are derived from the partitioned tool list (caller-
|
|
||||||
// provided) so this helper does not need to re-run the partition.
|
|
||||||
//
|
|
||||||
// This is the seam ComputePrefixFingerprint reads from. It exists as a
|
|
||||||
// separate function (rather than returning both prompt and substrings from
|
|
||||||
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
|
|
||||||
// and the substrings helper is independently testable.
|
|
||||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||||
core := strings.TrimSpace(options.SystemPrompt)
|
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
|
||||||
if core == "" {
|
}
|
||||||
core = strings.TrimSpace(coreSystemPrompt)
|
|
||||||
}
|
|
||||||
if core == "" {
|
|
||||||
core = fallbackSystemPrompt
|
|
||||||
}
|
|
||||||
|
|
||||||
// The confirmation policy is appended unconditionally in buildSystemPrompt;
|
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||||
// the substring here is the same constant.
|
|
||||||
policy := strings.TrimSpace(confirmationPolicy)
|
|
||||||
|
|
||||||
// projectContext is the joined output of workspaceContext, which itself
|
|
||||||
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
|
|
||||||
// joined string captures every guideline file's contribution and the
|
|
||||||
// ordering between them.
|
|
||||||
project := workspaceContext(options.Cwd)
|
|
||||||
|
|
||||||
// skills is the joined output of skillsContext, with each skill on its own
|
|
||||||
// line. The substring is the literal text the model would see.
|
|
||||||
skills := skillsContext(options)
|
|
||||||
|
|
||||||
// tools and schema are derived from the partitioned tool list. The list
|
|
||||||
// passed in is already in stable order (partitionToolsCached is the
|
|
||||||
// canonical stable partitioner) so concatenating in slice order is
|
|
||||||
// sufficient — we sort names again defensively in case a future caller
|
|
||||||
// hands us a list from a different partitioner.
|
|
||||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||||
|
|
||||||
return promptSubstrings{
|
return promptSubstrings{
|
||||||
baseInstructions: core,
|
systemPrompt: parts.prompt,
|
||||||
confirmationPolicy: policy,
|
baseInstructions: parts.baseInstructions,
|
||||||
projectContext: project,
|
confirmationPolicy: parts.confirmationPolicy,
|
||||||
skills: skills,
|
projectContext: parts.projectContext,
|
||||||
|
skills: parts.skills,
|
||||||
tools: toolsSubstr,
|
tools: toolsSubstr,
|
||||||
schema: schemaSubstr,
|
schema: schemaSubstr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
|
// toolSubstrings preserves the emitted tool order. Names and descriptions are
|
||||||
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
|
// fingerprinted separately from schemas so either kind of drift is identifiable.
|
||||||
// the partition produces a different hash. The schema substring is the
|
// Order is part of request identity: silently sorting here would hide a provider-
|
||||||
// concatenation of each tool's JSON schema in name order, so a schema change
|
// visible reordering and could report a false cache hit.
|
||||||
// is detected independently of the tool ordering.
|
|
||||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||||
if len(exposed) == 0 {
|
if len(exposed) == 0 {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
names := make([]string, 0, len(exposed))
|
|
||||||
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
|
|
||||||
for _, def := range exposed {
|
|
||||||
names = append(names, def.Name)
|
|
||||||
byName[def.Name] = def
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
var toolsSB, schemaSB strings.Builder
|
var toolsSB, schemaSB strings.Builder
|
||||||
for i, name := range names {
|
for _, def := range exposed {
|
||||||
if i > 0 {
|
writeLengthPrefixed(&toolsSB, def.Name)
|
||||||
toolsSB.WriteByte('\n')
|
writeLengthPrefixed(&toolsSB, def.Description)
|
||||||
}
|
writeLengthPrefixed(&toolsSB, string(def.Type))
|
||||||
toolsSB.WriteString(name)
|
|
||||||
def := byName[name]
|
|
||||||
// Parameters is rendered to a canonical JSON string per tool. The
|
// Parameters is rendered to a canonical JSON string per tool. The
|
||||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||||
// guarantees the same render is byte-identical across turns for the
|
// guarantees the same render is byte-identical across turns for the
|
||||||
// same tool, so this substring is a stable hash input.
|
// same tool, so this substring is a stable hash input.
|
||||||
schemaSB.WriteString(name)
|
writeLengthPrefixed(&schemaSB, def.Name)
|
||||||
schemaSB.WriteByte('\n')
|
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||||
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
|
if def.Format != nil {
|
||||||
schemaSB.WriteByte('\n')
|
writeLengthPrefixed(&schemaSB, def.Format.Type)
|
||||||
|
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
|
||||||
|
writeLengthPrefixed(&schemaSB, def.Format.Definition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return toolsSB.String(), schemaSB.String()
|
return toolsSB.String(), schemaSB.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeLengthPrefixed(sb *strings.Builder, value string) {
|
||||||
|
fmt.Fprintf(sb, "%d:", len(value))
|
||||||
|
sb.WriteString(value)
|
||||||
|
}
|
||||||
|
|
||||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||||
@@ -227,36 +195,16 @@ func writeStable(sb *strings.Builder, v any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
|
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
|
||||||
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
|
// not already have one. The agent loop uses computePrefixFingerprint with the
|
||||||
// independent SHA-256s of the corresponding sub-component; the complete-prefix
|
// prompt parts it built once at run start, ensuring tracing observes the exact
|
||||||
// hash is a SHA-256 of the canonical concatenation of the other six, so any
|
// request content without repeating workspace I/O.
|
||||||
// sub-component drift is observable both individually and in aggregate.
|
|
||||||
//
|
|
||||||
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
|
|
||||||
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
|
|
||||||
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
|
|
||||||
// context work is performed separately by buildSystemPrompt a few lines
|
|
||||||
// later in the request-build path, so on every traced turn the file and
|
|
||||||
// git reads happen twice. The duplication is intentional for this PR (a
|
|
||||||
// follow-up will pass substrings through) and is a known perf cost only on
|
|
||||||
// the opt-in trace path.
|
|
||||||
//
|
|
||||||
// A stable CompletePrefixHash across turns means the four captured
|
|
||||||
// sub-components (baseInstructions, confirmationPolicy, projectContext,
|
|
||||||
// skills) are byte-identical. It does NOT rule out drift in the seven
|
|
||||||
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
|
|
||||||
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
|
|
||||||
// userGuidelines, specialistDelegationContext, responseStyleContext). For
|
|
||||||
// default Options the four captured substrings are the full prompt; for
|
|
||||||
// non-default Options the seven uncaptured sections may contribute, and a
|
|
||||||
// consumer correlating CompletePrefix stability with cached_input_tokens
|
|
||||||
// must cross-check the model_switches counter (modelPromptAddendum changes
|
|
||||||
// on a model switch) to disambiguate "no drift" from "drift in an
|
|
||||||
// uncaptured section."
|
|
||||||
|
|
||||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||||
subs := buildPromptSubstrings(options, exposed)
|
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
||||||
|
systemPrompt := sha256hex(subs.systemPrompt)
|
||||||
base := sha256hex(subs.baseInstructions)
|
base := sha256hex(subs.baseInstructions)
|
||||||
policy := sha256hex(subs.confirmationPolicy)
|
policy := sha256hex(subs.confirmationPolicy)
|
||||||
project := sha256hex(subs.projectContext)
|
project := sha256hex(subs.projectContext)
|
||||||
@@ -264,9 +212,10 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
|
|||||||
toolsH := sha256hex(subs.tools)
|
toolsH := sha256hex(subs.tools)
|
||||||
schema := sha256hex(subs.schema)
|
schema := sha256hex(subs.schema)
|
||||||
complete := sha256hex(strings.Join([]string{
|
complete := sha256hex(strings.Join([]string{
|
||||||
base, policy, project, skills, toolsH, schema,
|
systemPrompt, toolsH, schema,
|
||||||
}, "|"))
|
}, "|"))
|
||||||
return prefixFingerprint{
|
return prefixFingerprint{
|
||||||
|
SystemPromptHash: systemPrompt,
|
||||||
BaseInstructionsHash: base,
|
BaseInstructionsHash: base,
|
||||||
ConfirmationPolicyHash: policy,
|
ConfirmationPolicyHash: policy,
|
||||||
ProjectContextHash: project,
|
ProjectContextHash: project,
|
||||||
@@ -282,6 +231,7 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
|
|||||||
// in loop.go) so the trace package does not need to import this one. The field
|
// in loop.go) so the trace package does not need to import this one. The field
|
||||||
// names match the trace.PrefixHash JSON tags 1:1.
|
// names match the trace.PrefixHash JSON tags 1:1.
|
||||||
type prefixFingerprint struct {
|
type prefixFingerprint struct {
|
||||||
|
SystemPromptHash string
|
||||||
BaseInstructionsHash string
|
BaseInstructionsHash string
|
||||||
ConfirmationPolicyHash string
|
ConfirmationPolicyHash string
|
||||||
ProjectContextHash string
|
ProjectContextHash string
|
||||||
|
|||||||
@@ -28,38 +28,28 @@ func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
|
// Tool order and descriptions are provider-visible and therefore part of the
|
||||||
// name-set sensitivity of the ToolsHash. The test name used to read as
|
// cacheable prefix identity.
|
||||||
// "reorder changes hash" but the assertion is the opposite: the hash is
|
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
|
||||||
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
|
|
||||||
// names before hashing). The partitioner is expected to produce a stable
|
|
||||||
// order, so a name-order change is not a hash signal — a name-set change
|
|
||||||
// is. The test asserts both: permutation of the same tools produces the
|
|
||||||
// same hash, and adding a tool produces a different hash.
|
|
||||||
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
|
|
||||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||||
a := []zeroruntime.ToolDefinition{
|
a := []zeroruntime.ToolDefinition{
|
||||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||||
}
|
}
|
||||||
b := []zeroruntime.ToolDefinition{
|
b := []zeroruntime.ToolDefinition{
|
||||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||||
}
|
}
|
||||||
// toolSubstrings sorts names internally, so the ToolsHash is identical
|
|
||||||
// for a permutation of the same tools. We assert that here to document
|
|
||||||
// the contract: name-order independence, name-set sensitivity.
|
|
||||||
fpA := ComputePrefixFingerprint(opts, a)
|
fpA := ComputePrefixFingerprint(opts, a)
|
||||||
fpB := ComputePrefixFingerprint(opts, b)
|
fpB := ComputePrefixFingerprint(opts, b)
|
||||||
if fpA.ToolsHash != fpB.ToolsHash {
|
if fpA.ToolsHash == fpB.ToolsHash {
|
||||||
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||||
}
|
}
|
||||||
// A different tool set must produce a different ToolsHash.
|
c := append([]zeroruntime.ToolDefinition(nil), a...)
|
||||||
c := append([]zeroruntime.ToolDefinition{}, a...)
|
c[0].Description = "read a file"
|
||||||
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
|
|
||||||
fpC := ComputePrefixFingerprint(opts, c)
|
fpC := ComputePrefixFingerprint(opts, c)
|
||||||
if fpA.ToolsHash == fpC.ToolsHash {
|
if fpA.ToolsHash == fpC.ToolsHash {
|
||||||
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,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
|
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
// 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
|
// model addendum change) must move the base-instructions hash, which moves
|
||||||
@@ -101,25 +123,50 @@ func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
|
// CompletePrefixHash represents the exact joined system prompt and the ordered
|
||||||
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
|
// tool definitions. Narrower system-section hashes remain diagnostic only.
|
||||||
// This is the regression catch for "did anyone reorder or drop a sub-hash
|
|
||||||
// from the canonical join without updating CompletePrefixHash."
|
|
||||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||||
fp := ComputePrefixFingerprint(opts, exposed)
|
fp := ComputePrefixFingerprint(opts, exposed)
|
||||||
// Manually compute what the canonical join should be.
|
// Manually compute what the canonical join should be.
|
||||||
expected := sha256hex(strings.Join([]string{
|
expected := sha256hex(strings.Join([]string{
|
||||||
fp.BaseInstructionsHash,
|
fp.SystemPromptHash,
|
||||||
fp.ConfirmationPolicyHash,
|
|
||||||
fp.ProjectContextHash,
|
|
||||||
fp.SkillsHash,
|
|
||||||
fp.ToolsHash,
|
fp.ToolsHash,
|
||||||
fp.SchemaHash,
|
fp.SchemaHash,
|
||||||
}, "|"))
|
}, "|"))
|
||||||
if fp.CompletePrefixHash != expected {
|
if fp.CompletePrefixHash != expected {
|
||||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||||
|
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
|
||||||
|
changed := base
|
||||||
|
changed.ResponseStyle = "concise"
|
||||||
|
|
||||||
|
fpBase := ComputePrefixFingerprint(base, nil)
|
||||||
|
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||||
|
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||||
|
t.Fatal("SystemPromptHash must change when any rendered system section changes")
|
||||||
|
}
|
||||||
|
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||||
|
t.Fatal("CompletePrefixHash must include the exact full system prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputePrefixFingerprintCoversTransientSystemPrompt(t *testing.T) {
|
||||||
|
base := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||||
|
changed := base
|
||||||
|
changed.TransientSystemPrompt = "turn-only guidance"
|
||||||
|
|
||||||
|
fpBase := ComputePrefixFingerprint(base, nil)
|
||||||
|
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||||
|
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||||
|
t.Fatal("SystemPromptHash must change for transient system guidance")
|
||||||
|
}
|
||||||
|
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||||
|
t.Fatal("CompletePrefixHash must cover transient system guidance")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +188,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
|||||||
if subs.baseInstructions != "test core" {
|
if subs.baseInstructions != "test core" {
|
||||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||||
}
|
}
|
||||||
|
if subs.systemPrompt != buildSystemPrompt(opts) {
|
||||||
|
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
|
||||||
|
}
|
||||||
if subs.confirmationPolicy == "" {
|
if subs.confirmationPolicy == "" {
|
||||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||||
}
|
}
|
||||||
@@ -151,6 +201,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
|||||||
// sha256hex of the substrings, with no truncation or reformatting
|
// sha256hex of the substrings, with no truncation or reformatting
|
||||||
// between the two layers.
|
// between the two layers.
|
||||||
fp := ComputePrefixFingerprint(opts, nil)
|
fp := ComputePrefixFingerprint(opts, nil)
|
||||||
|
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
|
||||||
|
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
|
||||||
|
}
|
||||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,3 +121,67 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
|
|||||||
func isPrunedPlaceholder(content string) bool {
|
func isPrunedPlaceholder(content string) bool {
|
||||||
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
|
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) {
|
func TestPruneSkipsSmallSessions(t *testing.T) {
|
||||||
// Total reclaimable is under the gate → no pruning.
|
// Total reclaimable is under the gate → no pruning.
|
||||||
msgs := []zeroruntime.Message{
|
msgs := []zeroruntime.Message{
|
||||||
@@ -120,3 +127,69 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
|
|||||||
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
|
||||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ func TestSpecialistDelegationContext(t *testing.T) {
|
|||||||
if strings.Contains(got, "nameless") {
|
if strings.Contains(got, "nameless") {
|
||||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
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) {
|
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
"github.com/Gitlawb/zero/internal/repomap"
|
"github.com/Gitlawb/zero/internal/repomap"
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,6 +71,22 @@ const (
|
|||||||
// guidelines), and the safety confirmation policy. It is built once per run so
|
// guidelines), and the safety confirmation policy. It is built once per run so
|
||||||
// every turn shares one (cacheable) system turn.
|
// every turn shares one (cacheable) system turn.
|
||||||
func buildSystemPrompt(options Options) string {
|
func buildSystemPrompt(options Options) string {
|
||||||
|
return buildSystemPromptParts(options).prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemPromptParts retains both the exact joined prompt and the diagnostic
|
||||||
|
// sections used by prompt-prefix tracing. Building these together prevents the
|
||||||
|
// tracing path from re-reading workspace state and guarantees that the hash is
|
||||||
|
// computed from the same prompt bytes placed in the request.
|
||||||
|
type systemPromptParts struct {
|
||||||
|
prompt string
|
||||||
|
baseInstructions string
|
||||||
|
confirmationPolicy string
|
||||||
|
projectContext string
|
||||||
|
skills string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSystemPromptParts(options Options) systemPromptParts {
|
||||||
core := strings.TrimSpace(options.SystemPrompt)
|
core := strings.TrimSpace(options.SystemPrompt)
|
||||||
if core == "" {
|
if core == "" {
|
||||||
core = strings.TrimSpace(coreSystemPrompt)
|
core = strings.TrimSpace(coreSystemPrompt)
|
||||||
@@ -78,6 +95,9 @@ func buildSystemPrompt(options Options) string {
|
|||||||
core = fallbackSystemPrompt
|
core = fallbackSystemPrompt
|
||||||
}
|
}
|
||||||
sections := []string{core}
|
sections := []string{core}
|
||||||
|
if transient := strings.TrimSpace(options.TransientSystemPrompt); transient != "" {
|
||||||
|
sections = append(sections, transient)
|
||||||
|
}
|
||||||
if addendum := modelPromptAddendum(options.Model); addendum != "" {
|
if addendum := modelPromptAddendum(options.Model); addendum != "" {
|
||||||
sections = append(sections, addendum)
|
sections = append(sections, addendum)
|
||||||
}
|
}
|
||||||
@@ -97,22 +117,34 @@ func buildSystemPrompt(options Options) string {
|
|||||||
if user := userGuidelines(); user != "" {
|
if user := userGuidelines(); user != "" {
|
||||||
sections = append(sections, user)
|
sections = append(sections, user)
|
||||||
}
|
}
|
||||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
project := workspaceContext(options.Cwd)
|
||||||
sections = append(sections, ws)
|
if project != "" {
|
||||||
|
sections = append(sections, project)
|
||||||
|
}
|
||||||
|
if modeCtx := permissionModeContext(options); modeCtx != "" {
|
||||||
|
sections = append(sections, modeCtx)
|
||||||
}
|
}
|
||||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||||
sections = append(sections, delegation)
|
sections = append(sections, delegation)
|
||||||
}
|
}
|
||||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
skillsBlock := skillsContext(options)
|
||||||
|
if skillsBlock != "" {
|
||||||
sections = append(sections, skillsBlock)
|
sections = append(sections, skillsBlock)
|
||||||
}
|
}
|
||||||
if style := responseStyleContext(options); style != "" {
|
if style := responseStyleContext(options); style != "" {
|
||||||
sections = append(sections, style)
|
sections = append(sections, style)
|
||||||
}
|
}
|
||||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
policy := strings.TrimSpace(confirmationPolicy)
|
||||||
|
if policy != "" {
|
||||||
sections = append(sections, policy)
|
sections = append(sections, policy)
|
||||||
}
|
}
|
||||||
return strings.Join(sections, "\n\n")
|
return systemPromptParts{
|
||||||
|
prompt: strings.Join(sections, "\n\n"),
|
||||||
|
baseInstructions: core,
|
||||||
|
confirmationPolicy: policy,
|
||||||
|
projectContext: project,
|
||||||
|
skills: skillsBlock,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
||||||
@@ -153,21 +185,19 @@ func approvedCommandPrefixContext(options Options) string {
|
|||||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
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
|
// specialistDelegationContext describes when a specialist earns its extra
|
||||||
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
// context and coordination cost. It renders only when specialists are known
|
||||||
// tool outputs out of the main context. It renders only when specialists are
|
// (which is only where the Task tool is actually registered), so a run with no
|
||||||
// known (which is only where the Task tool is actually registered), so a run with
|
// delegatable specialists produces the previous prompt unchanged.
|
||||||
// no delegatable specialists produces the previous prompt unchanged.
|
|
||||||
func specialistDelegationContext(options Options) string {
|
func specialistDelegationContext(options Options) string {
|
||||||
if len(options.Specialists) == 0 {
|
if len(options.Specialists) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("<specialists>\n")
|
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("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("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
|
||||||
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("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
|
||||||
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
|
||||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||||
for _, info := range options.Specialists {
|
for _, info := range options.Specialists {
|
||||||
name := strings.TrimSpace(info.Name)
|
name := strings.TrimSpace(info.Name)
|
||||||
@@ -285,11 +315,7 @@ func workspaceContext(cwd string) string {
|
|||||||
b.WriteString("<environment>\n")
|
b.WriteString("<environment>\n")
|
||||||
b.WriteString("Working directory: " + cwd + "\n")
|
b.WriteString("Working directory: " + cwd + "\n")
|
||||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||||
if runtime.GOOS == "windows" {
|
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
||||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
|
||||||
}
|
|
||||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||||
b.WriteString("Git branch: " + branch + "\n")
|
b.WriteString("Git branch: " + branch + "\n")
|
||||||
}
|
}
|
||||||
@@ -659,3 +685,12 @@ func gitBranchForPrompt(cwd string) string {
|
|||||||
}
|
}
|
||||||
return ref
|
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,
|
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
|
tests, or config before you modify behavior. Never edit a file you have not
|
||||||
read.
|
read.
|
||||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
|
||||||
keep it live. The plan bar is the user's progress signal — call update_plan
|
work. Skip it for bounded changes in one component. Update only when the
|
||||||
after EACH concrete unit of work (every file written, every command run), not
|
overall phase changes, keep at most one item in_progress, and never create a
|
||||||
just at coarse milestones: mark the finished item completed and the next one
|
plan after the work is complete.
|
||||||
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.
|
|
||||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||||
naming, and conventions. Prefer the smallest change that fully solves the
|
naming, and conventions. Prefer the smallest change that fully solves the
|
||||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||||
@@ -54,33 +48,49 @@ work.
|
|||||||
## Editing discipline
|
## Editing discipline
|
||||||
|
|
||||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||||
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
|
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||||
apply_patch - over shelling out to cat/sed/awk/python for file operations.
|
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.
|
They are safer, reviewable, and produce clean diffs.
|
||||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
- Use read_minified_file only to explore large or unfamiliar source. Use
|
||||||
shell or script invocation.
|
read_file for small or likely edit targets and exact text, comments, or line
|
||||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
numbers. Do not read both unless a new need for exact content appears.
|
||||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
- Keep edits focused and reviewable. A single patch may update several related
|
||||||
file's comment density: do not add explanatory comments unless the user asks or
|
files when they form one coherent change; do not hide unrelated edits in a
|
||||||
the code is already comment-dense.
|
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
|
- Solve the problem as posed, not a more general version of it. Add no
|
||||||
speculative abstraction, configurability, or handling for cases that cannot
|
speculative abstraction, configurability, or handling for cases that cannot
|
||||||
occur, and nothing the user did not ask for. A small diff can still be
|
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
|
- 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.
|
you did not author unless the task requires it; if you must, say so.
|
||||||
|
|
||||||
## Testing gate (mandatory)
|
## Testing gate (mandatory)
|
||||||
|
|
||||||
- After any change to code, verify after edits by running the project's
|
- After code changes, run the project's documented appropriate validators.
|
||||||
validators before you summarize or commit: tests, type-checks, linters, and/or
|
Scope iteration checks, combine compatible commands when diagnostics stay
|
||||||
the build, as appropriate. Scope them to the change while iterating; reserve
|
useful, and run the final set once after the last edit. Rerun only after a
|
||||||
full-suite runs for milestones.
|
change or failure.
|
||||||
- If you are unsure which validators apply, search the repo (Makefile, package
|
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
|
||||||
manifests, CI config) to find them.
|
do not invent a parallel flow.
|
||||||
- Never claim a task is done, and never commit, while validators are failing. If
|
- 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
|
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 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
|
## Tool use
|
||||||
|
|
||||||
@@ -100,6 +110,9 @@ work.
|
|||||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||||
you need to clean up a running foreground command yourself, use write_stdin.
|
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 edit_file/apply_patch/write_file for file changes.
|
||||||
- write_stdin with empty input polls an existing exec_command session, and
|
- write_stdin with empty input polls an existing exec_command session, and
|
||||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||||
it can drive the running process beyond the original command. Non-tty sessions
|
it can drive the running process beyond the original command. Non-tty sessions
|
||||||
@@ -135,6 +148,7 @@ work.
|
|||||||
- Default to concise, skimmable output. Lead with the answer or the result.
|
- Default to concise, skimmable output. Lead with the answer or the result.
|
||||||
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
||||||
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
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,
|
- 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.
|
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,
|
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||||
commands, and symbols.
|
commands, and symbols.
|
||||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
- 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
|
edit_file, apply_patch, write_file) 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.
|
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,
|
- Persist until the task is fully handled this turn: gather context, implement,
|
||||||
run the validators, and report — do not stop at a partial result.
|
run the validators, and report — do not stop at a partial result.
|
||||||
</model_guidance>`
|
</model_guidance>`
|
||||||
@@ -71,5 +74,6 @@ const geminiPromptAddendum = `<model_guidance>
|
|||||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||||
- Be concise and concrete. When you run a shell command with side effects, state
|
- Be concise and concrete. When you run a shell command with side effects, state
|
||||||
in one short clause why it is needed.
|
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>`
|
</model_guidance>`
|
||||||
@@ -29,6 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
|||||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
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
|
// 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.
|
// comment discipline is universal; it must not pick up another family's block.
|
||||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||||
|
|||||||
@@ -29,14 +29,17 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
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{
|
for _, want := range []string{
|
||||||
"read-before-edit",
|
"read-before-edit",
|
||||||
"inspect the target file",
|
"inspect the target file",
|
||||||
"plan then act",
|
"plan then act",
|
||||||
"choose the narrowest tool",
|
"choose the narrowest tool",
|
||||||
"prefer edit_file or apply_patch",
|
"for edits to existing files, use edit_file",
|
||||||
"verify after edits",
|
"verify after edits",
|
||||||
"honor the active permission mode",
|
"honor the active permission mode",
|
||||||
"avoid broad refactors",
|
"avoid broad refactors",
|
||||||
@@ -44,6 +47,13 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
|||||||
"do not recognize",
|
"do not recognize",
|
||||||
"scaled to the work",
|
"scaled to the work",
|
||||||
"comment density",
|
"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) {
|
if !strings.Contains(prompt, want) {
|
||||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
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) {
|
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
||||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
"github.com/Gitlawb/zero/internal/trace"
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
taskStatusActive taskStatus = "active"
|
||||||
|
taskStatusComplete taskStatus = "complete"
|
||||||
|
taskStatusIncomplete taskStatus = "incomplete"
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskPlanParity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
taskPlanParityUnknown taskPlanParity = "unknown"
|
||||||
|
taskPlanParityMatch taskPlanParity = "match"
|
||||||
|
taskPlanParityMismatch taskPlanParity = "mismatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskPlanItem struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskPlanState struct {
|
||||||
|
Items []taskPlanItem `json:"items,omitempty"`
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
InProgress int `json:"in_progress"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskToolState struct {
|
||||||
|
Succeeded int `json:"succeeded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskVerificationState struct {
|
||||||
|
Passed int `json:"passed"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskFailureState struct {
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskApprovalState struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Decision PermissionDecisionAction `json:"decision,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskArtifactState struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskStateSnapshot struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Objective string `json:"objective"`
|
||||||
|
Status taskStatus `json:"status"`
|
||||||
|
Plan taskPlanState `json:"plan"`
|
||||||
|
Tools taskToolState `json:"tools"`
|
||||||
|
Verification taskVerificationState `json:"verification"`
|
||||||
|
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||||
|
Constraints []string `json:"constraints,omitempty"`
|
||||||
|
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||||
|
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||||
|
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||||
|
ResolvedFailureKeys []string `json:"-"`
|
||||||
|
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||||
|
CompletionReason string `json:"completion_reason,omitempty"`
|
||||||
|
PlanParity taskPlanParity `json:"plan_parity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskStateEventKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
taskStateEventPlan taskStateEventKind = iota + 1
|
||||||
|
taskStateEventToolResult
|
||||||
|
taskStateEventVerification
|
||||||
|
taskStateEventCompletion
|
||||||
|
taskStateEventPermission
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskStateEvent struct {
|
||||||
|
kind taskStateEventKind
|
||||||
|
arguments string
|
||||||
|
toolResult ToolResult
|
||||||
|
verification Outcome
|
||||||
|
completion completionEvaluation
|
||||||
|
permission PermissionEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskState is a deterministic projection of facts the loop already observes.
|
||||||
|
// It does not initiate work or replace the transcript; consumers must check
|
||||||
|
// transcript parity before using its compact representation.
|
||||||
|
type taskState struct {
|
||||||
|
snapshotValue taskStateSnapshot
|
||||||
|
changedFiles map[string]struct{}
|
||||||
|
recorder *trace.Recorder
|
||||||
|
planObserved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||||
|
state := &taskState{
|
||||||
|
snapshotValue: taskStateSnapshot{
|
||||||
|
Objective: strings.TrimSpace(objective),
|
||||||
|
Status: taskStatusActive,
|
||||||
|
PlanParity: taskPlanParityUnknown,
|
||||||
|
},
|
||||||
|
changedFiles: map[string]struct{}{},
|
||||||
|
}
|
||||||
|
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
|
||||||
|
state.recorder = recorder
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *taskState) observe(event taskStateEvent) bool {
|
||||||
|
if state == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
switch event.kind {
|
||||||
|
case taskStateEventPlan:
|
||||||
|
plan, ok := parseTaskPlan(event.arguments)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
state.snapshotValue.Plan = summarizeTaskPlan(plan)
|
||||||
|
state.planObserved = true
|
||||||
|
state.markActive()
|
||||||
|
changed = true
|
||||||
|
case taskStateEventToolResult:
|
||||||
|
state.markActive()
|
||||||
|
if event.toolResult.Status == tools.StatusOK {
|
||||||
|
state.snapshotValue.Tools.Succeeded++
|
||||||
|
} else {
|
||||||
|
state.snapshotValue.Tools.Failed++
|
||||||
|
}
|
||||||
|
for _, path := range event.toolResult.ChangedFiles {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path != "" {
|
||||||
|
state.changedFiles[path] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||||
|
state.observeToolEvidence(event.arguments, event.toolResult)
|
||||||
|
changed = true
|
||||||
|
case taskStateEventVerification:
|
||||||
|
if event.verification == OutcomeDisabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
state.markActive()
|
||||||
|
state.snapshotValue.Verification.LastOutcome = event.verification
|
||||||
|
switch event.verification {
|
||||||
|
case OutcomePassed:
|
||||||
|
state.snapshotValue.Verification.Passed++
|
||||||
|
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||||
|
state.snapshotValue.Verification.Failed++
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
case taskStateEventCompletion:
|
||||||
|
state.snapshotValue.CompletionDecision = event.completion.Decision
|
||||||
|
state.snapshotValue.CompletionReason = event.completion.Reason
|
||||||
|
switch event.completion.Decision {
|
||||||
|
case CompletionComplete:
|
||||||
|
state.snapshotValue.Status = taskStatusComplete
|
||||||
|
case CompletionIncomplete:
|
||||||
|
state.snapshotValue.Status = taskStatusIncomplete
|
||||||
|
default:
|
||||||
|
state.snapshotValue.Status = taskStatusActive
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
case taskStateEventPermission:
|
||||||
|
state.markActive()
|
||||||
|
state.observePermission(event.permission)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
state.snapshotValue.Revision++
|
||||||
|
if event.kind != taskStateEventToolResult {
|
||||||
|
state.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *taskState) markActive() {
|
||||||
|
state.snapshotValue.Status = taskStatusActive
|
||||||
|
state.snapshotValue.CompletionDecision = ""
|
||||||
|
state.snapshotValue.CompletionReason = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *taskState) snapshot() taskStateSnapshot {
|
||||||
|
if state == nil {
|
||||||
|
return taskStateSnapshot{}
|
||||||
|
}
|
||||||
|
snapshot := state.snapshotValue
|
||||||
|
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||||
|
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||||
|
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
|
||||||
|
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
|
||||||
|
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
|
||||||
|
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
|
||||||
|
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxTaskEvidenceEntries = 8
|
||||||
|
maxTaskEvidenceBytes = 320
|
||||||
|
maxTaskConstraints = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
|
||||||
|
command := capTaskEvidence(commandFromArguments(arguments))
|
||||||
|
key := taskFailureKey(result.Name, command, arguments)
|
||||||
|
if result.Status == tools.StatusOK {
|
||||||
|
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
|
||||||
|
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||||
|
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
|
||||||
|
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||||
|
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
|
||||||
|
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
|
||||||
|
}, maxTaskEvidenceEntries)
|
||||||
|
}
|
||||||
|
for _, metaKey := range []string{"spill_path", "artifact_path"} {
|
||||||
|
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
|
||||||
|
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
|
||||||
|
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskFailureKey(toolName, command, arguments string) string {
|
||||||
|
identity := command
|
||||||
|
if identity == "" {
|
||||||
|
canonical := []byte(strings.TrimSpace(arguments))
|
||||||
|
var value any
|
||||||
|
if json.Unmarshal(canonical, &value) == nil {
|
||||||
|
if encoded, err := json.Marshal(value); err == nil {
|
||||||
|
canonical = encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(canonical)
|
||||||
|
identity = fmt.Sprintf("args:%x", digest[:8])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(toolName) + "\x00" + identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *taskState) observePermission(event PermissionEvent) {
|
||||||
|
decision := event.DecisionAction
|
||||||
|
if decision == "" {
|
||||||
|
decision = PermissionDecisionAction(event.Action)
|
||||||
|
}
|
||||||
|
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
|
||||||
|
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
|
||||||
|
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
|
||||||
|
}, maxTaskEvidenceEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandFromArguments(arguments string) string {
|
||||||
|
var object map[string]json.RawMessage
|
||||||
|
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{"cmd", "command", "script", "shell"} {
|
||||||
|
var command string
|
||||||
|
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
|
||||||
|
return strings.TrimSpace(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if index := strings.IndexByte(value, '\n'); index >= 0 {
|
||||||
|
value = value[:index]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func capTaskEvidence(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) <= maxTaskEvidenceBytes {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
limit := maxTaskEvidenceBytes - 3
|
||||||
|
for limit > 0 && !utf8.RuneStart(value[limit]) {
|
||||||
|
limit--
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value[:limit]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
var explicitConstraintPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
|
||||||
|
regexp.MustCompile(`(?i)\bdon'?t want\b`),
|
||||||
|
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
|
||||||
|
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
|
||||||
|
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
|
||||||
|
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractExplicitConstraints(text string) []string {
|
||||||
|
var constraints []string
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pattern := range explicitConstraintPatterns {
|
||||||
|
if pattern.MatchString(line) {
|
||||||
|
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(constraints) == maxTaskConstraints {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return constraints
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendBounded[T any](values []T, value T, limit int) []T {
|
||||||
|
values = append(values, value)
|
||||||
|
if len(values) > limit {
|
||||||
|
values = values[len(values)-limit:]
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
|
||||||
|
filtered := values[:0]
|
||||||
|
for _, existing := range values {
|
||||||
|
if existing != value {
|
||||||
|
filtered = append(filtered, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appendBounded(filtered, value, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTaskFailure(values []taskFailureState, key string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value.Key == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
|
||||||
|
out := values[:0]
|
||||||
|
for _, value := range values {
|
||||||
|
if value.Key != key {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeString(values []string, target string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if value != target {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// observePlanParity compares only the latest plan projection with the plan tool
|
||||||
|
// calls still present in messages. It mutates the snapshot and emits when the
|
||||||
|
// parity value changes; objective, tool, and verification fields are not part of
|
||||||
|
// this comparison.
|
||||||
|
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
|
||||||
|
if state == nil {
|
||||||
|
return taskPlanParityUnknown
|
||||||
|
}
|
||||||
|
transcriptPlan, found, valid := latestTaskPlan(messages)
|
||||||
|
tracked := state.snapshotValue.Plan.Items
|
||||||
|
parity := taskPlanParityMatch
|
||||||
|
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
|
||||||
|
parity = taskPlanParityMismatch
|
||||||
|
}
|
||||||
|
if state.snapshotValue.PlanParity != parity {
|
||||||
|
state.snapshotValue.PlanParity = parity
|
||||||
|
state.snapshotValue.Revision++
|
||||||
|
state.emit()
|
||||||
|
}
|
||||||
|
return parity
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionContext struct {
|
||||||
|
Objective string
|
||||||
|
PlanPending bool
|
||||||
|
PlanMatchesTranscript bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
|
||||||
|
context := completionContext{PlanPending: transcriptPlanPending}
|
||||||
|
if state == nil {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
context.Objective = state.snapshotValue.Objective
|
||||||
|
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
|
||||||
|
if context.PlanMatchesTranscript {
|
||||||
|
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
|
||||||
|
if state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state.observePlanParity(messages)
|
||||||
|
snapshot := state.snapshot()
|
||||||
|
return &snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *taskState) emit() {
|
||||||
|
if state == nil || state.recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot := state.snapshotValue
|
||||||
|
state.recorder.EmitTaskState(trace.TaskStateEvent{
|
||||||
|
Revision: snapshot.Revision,
|
||||||
|
Status: string(snapshot.Status),
|
||||||
|
PlanPending: snapshot.Plan.Pending,
|
||||||
|
PlanInProgress: snapshot.Plan.InProgress,
|
||||||
|
PlanCompleted: snapshot.Plan.Completed,
|
||||||
|
PlanFailed: snapshot.Plan.Failed,
|
||||||
|
ToolsSucceeded: snapshot.Tools.Succeeded,
|
||||||
|
ToolsFailed: snapshot.Tools.Failed,
|
||||||
|
VerificationPassed: snapshot.Verification.Passed,
|
||||||
|
VerificationFailed: snapshot.Verification.Failed,
|
||||||
|
VerificationOutcome: string(snapshot.Verification.LastOutcome),
|
||||||
|
ChangedFileCount: len(snapshot.ChangedFiles),
|
||||||
|
CompletionDecision: string(snapshot.CompletionDecision),
|
||||||
|
PlanParity: string(snapshot.PlanParity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
|
||||||
|
var object map[string]json.RawMessage
|
||||||
|
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rawPlan, ok := object["plan"]
|
||||||
|
if !ok || string(rawPlan) == "null" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var parsed []struct {
|
||||||
|
ID *string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(rawPlan, &parsed) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
plan := make([]taskPlanItem, 0, len(parsed))
|
||||||
|
for _, raw := range parsed {
|
||||||
|
if raw.Content == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
plan = append(plan, taskPlanItem{
|
||||||
|
Content: raw.Content,
|
||||||
|
Status: normalizeTaskPlanStatus(raw.Status),
|
||||||
|
Notes: raw.Notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lastInProgress := -1
|
||||||
|
for index := range plan {
|
||||||
|
if plan[index].Status == "in_progress" {
|
||||||
|
if lastInProgress >= 0 {
|
||||||
|
plan[lastInProgress].Status = "completed"
|
||||||
|
}
|
||||||
|
lastInProgress = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(plan) == 0 {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
return plan, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTaskPlanStatus(status string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||||
|
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
|
||||||
|
return "completed"
|
||||||
|
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
|
||||||
|
return "in_progress"
|
||||||
|
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
|
||||||
|
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
|
||||||
|
for _, item := range items {
|
||||||
|
switch item.Status {
|
||||||
|
case "completed":
|
||||||
|
plan.Completed++
|
||||||
|
case "in_progress":
|
||||||
|
plan.InProgress++
|
||||||
|
case "failed":
|
||||||
|
plan.Failed++
|
||||||
|
default:
|
||||||
|
plan.Pending++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
|
||||||
|
call := messages[i].ToolCalls[j]
|
||||||
|
if call.Name != planToolName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plan, ok := parseTaskPlan(call.Arguments)
|
||||||
|
return plan, true, ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(values map[string]struct{}) []string {
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for value := range values {
|
||||||
|
keys = append(keys, value)
|
||||||
|
}
|
||||||
|
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
|
||||||
|
// deterministic even if events originate from parallel read batches later.
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
"github.com/Gitlawb/zero/internal/trace"
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskStateReplayIsDeterministic(t *testing.T) {
|
||||||
|
events := []taskStateEvent{
|
||||||
|
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
|
||||||
|
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
|
||||||
|
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
|
||||||
|
{kind: taskStateEventVerification, verification: OutcomePassed},
|
||||||
|
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := newTaskState("ship the change", nil)
|
||||||
|
second := newTaskState("ship the change", nil)
|
||||||
|
for _, event := range events {
|
||||||
|
first.observe(event)
|
||||||
|
second.observe(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
|
||||||
|
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
|
||||||
|
}
|
||||||
|
got := first.snapshot()
|
||||||
|
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
|
||||||
|
t.Fatalf("unexpected plan snapshot: %#v", got)
|
||||||
|
}
|
||||||
|
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
|
||||||
|
t.Fatalf("unexpected evidence snapshot: %#v", got)
|
||||||
|
}
|
||||||
|
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
|
||||||
|
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
|
||||||
|
recorder := trace.NewRecorder("session", "run", "")
|
||||||
|
state := newTaskState("objective", recorder)
|
||||||
|
for range 20 {
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||||
|
}
|
||||||
|
if events := recorder.Finish().TaskStates; len(events) != 0 {
|
||||||
|
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = trace.NewRecorder("session", "run-2", "")
|
||||||
|
state = newTaskState("objective", recorder)
|
||||||
|
for range 20 {
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||||
|
}
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||||
|
events := recorder.Finish().TaskStates
|
||||||
|
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
|
||||||
|
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(tools.NewUpdatePlanTool())
|
||||||
|
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
recorder := trace.NewRecorder("session", "run", "")
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), "implement the change", provider, Options{
|
||||||
|
Registry: registry,
|
||||||
|
Trace: recorder,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if result.FinalAnswer != "done" {
|
||||||
|
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||||
|
}
|
||||||
|
events := recorder.Finish().TaskStates
|
||||||
|
if len(events) < 3 {
|
||||||
|
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
|
||||||
|
}
|
||||||
|
last := events[len(events)-1]
|
||||||
|
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
|
||||||
|
t.Fatalf("unexpected final task snapshot: %#v", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||||
|
state := newTaskState("objective", nil)
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
|
||||||
|
|
||||||
|
snapshot := state.snapshot()
|
||||||
|
snapshot.Plan.Items[0].Content = "mutated"
|
||||||
|
snapshot.ChangedFiles[0] = "mutated.go"
|
||||||
|
|
||||||
|
fresh := state.snapshot()
|
||||||
|
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
|
||||||
|
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
|
||||||
|
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
|
||||||
|
arguments := `{"cmd":"go test ./internal/agent"}`
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||||
|
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
|
||||||
|
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
|
||||||
|
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
|
||||||
|
}})
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||||
|
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
|
||||||
|
Scope: "/tmp", DecisionReason: "write a temporary fixture",
|
||||||
|
}})
|
||||||
|
|
||||||
|
snapshot := state.snapshot()
|
||||||
|
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
|
||||||
|
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
|
||||||
|
}
|
||||||
|
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
|
||||||
|
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
|
||||||
|
}
|
||||||
|
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
|
||||||
|
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
|
||||||
|
}
|
||||||
|
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
|
||||||
|
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
|
||||||
|
}
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||||
|
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
|
||||||
|
}})
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
|
||||||
|
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
|
||||||
|
}})
|
||||||
|
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
|
||||||
|
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||||
|
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
|
||||||
|
}})
|
||||||
|
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
|
||||||
|
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
|
||||||
|
}
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||||
|
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
|
||||||
|
}})
|
||||||
|
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
|
||||||
|
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
|
||||||
|
}
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||||
|
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
|
||||||
|
}})
|
||||||
|
snapshot = state.snapshot()
|
||||||
|
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
|
||||||
|
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
|
||||||
|
}
|
||||||
|
for _, key := range snapshot.ResolvedFailureKeys {
|
||||||
|
if key == snapshot.UnresolvedFailures[0].Key {
|
||||||
|
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||||
|
state := newTaskState("objective", nil)
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
|
||||||
|
|
||||||
|
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||||
|
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
|
||||||
|
}}}
|
||||||
|
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||||
|
t.Fatalf("parity = %q, want match", parity)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
|
||||||
|
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
|
||||||
|
t.Fatalf("parity = %q, want mismatch", parity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
|
||||||
|
state := newTaskState("objective", nil)
|
||||||
|
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
|
||||||
|
|
||||||
|
snapshot := state.snapshot()
|
||||||
|
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
|
||||||
|
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
|
||||||
|
}
|
||||||
|
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
|
||||||
|
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||||
|
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := newTaskState("objective", nil)
|
||||||
|
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
|
||||||
|
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
|
||||||
|
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
|
||||||
|
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
|
||||||
|
state := newTaskState("objective", nil)
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
|
||||||
|
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||||
|
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
context := state.completionContext(messages, true)
|
||||||
|
if !context.PlanPending || context.PlanMatchesTranscript {
|
||||||
|
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
|
||||||
|
}
|
||||||
|
if context.Objective != "objective" {
|
||||||
|
t.Fatalf("objective lost from completion context: %#v", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
|
||||||
|
state := newTaskState("objective", nil)
|
||||||
|
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
|
||||||
|
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||||
|
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
|
||||||
|
}}}
|
||||||
|
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
|
||||||
|
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
mismatching := append([]zeroruntime.Message(nil), matching...)
|
||||||
|
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
|
||||||
|
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
|
||||||
|
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
|
||||||
|
for _, arguments := range []string{
|
||||||
|
`{}`,
|
||||||
|
`{"plan":null}`,
|
||||||
|
`{"plan":[{"step":"alias is not accepted"}]}`,
|
||||||
|
`{"plan":[{"content":""}]}`,
|
||||||
|
`{"plan":[{"content":"valid"},{"content":""}]}`,
|
||||||
|
`{"plan":[{"id":4,"content":"valid"}]}`,
|
||||||
|
} {
|
||||||
|
if plan, ok := parseTaskPlan(arguments); ok {
|
||||||
|
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, " ") + "]"
|
||||||
|
}
|
||||||
+63
-13
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/execution"
|
||||||
"github.com/Gitlawb/zero/internal/hooks"
|
"github.com/Gitlawb/zero/internal/hooks"
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
"github.com/Gitlawb/zero/internal/streamjson"
|
"github.com/Gitlawb/zero/internal/streamjson"
|
||||||
@@ -25,6 +26,15 @@ const (
|
|||||||
PermissionModeAsk PermissionMode = "ask"
|
PermissionModeAsk PermissionMode = "ask"
|
||||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||||
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
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
|
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
||||||
// advertises the in-workspace mutators a member needs to build (write/edit +
|
// 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
|
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
||||||
@@ -67,11 +77,18 @@ type ToolResult struct {
|
|||||||
Output string
|
Output string
|
||||||
// Truncated reports that the tool's model-visible output omitted content.
|
// Truncated reports that the tool's model-visible output omitted content.
|
||||||
// The full result may be recoverable through Meta["spill_path"].
|
// The full result may be recoverable through Meta["spill_path"].
|
||||||
Truncated bool
|
Truncated bool
|
||||||
Meta map[string]string
|
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
|
Redacted bool
|
||||||
ChangedFiles []string
|
ChangedFiles []string
|
||||||
Display tools.Display
|
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||||
|
// command execution; callers must not schedule per-file work from them.
|
||||||
|
ChangeSummaries []execution.Change
|
||||||
|
Display tools.Display
|
||||||
|
Outcome tools.ToolOutcome
|
||||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||||
DenialReason DenialCategory
|
DenialReason DenialCategory
|
||||||
@@ -92,6 +109,24 @@ type ToolResult struct {
|
|||||||
RequestedModel string
|
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.
|
// DenialCategory classifies why a tool call was blocked before it executed.
|
||||||
type DenialCategory string
|
type DenialCategory string
|
||||||
|
|
||||||
@@ -169,6 +204,22 @@ type PermissionRequest struct {
|
|||||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||||
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,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 {
|
type PermissionDecision struct {
|
||||||
@@ -274,8 +325,12 @@ type Options struct {
|
|||||||
ProviderName string
|
ProviderName string
|
||||||
Model string
|
Model string
|
||||||
ReasoningEffort string
|
ReasoningEffort string
|
||||||
|
ServiceTier string
|
||||||
Cwd string
|
Cwd string
|
||||||
SystemPrompt 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
|
// ResponseStyle is the operator-selected reply style from the TUI /style
|
||||||
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
||||||
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
||||||
@@ -326,9 +381,11 @@ type Options struct {
|
|||||||
// specialist child process emits while running. The toolCallID identifies
|
// specialist child process emits while running. The toolCallID identifies
|
||||||
// which Task tool call the progress belongs to. nil is a no-op.
|
// which Task tool call the progress belongs to. nil is a no-op.
|
||||||
OnToolProgress func(toolCallID string, event streamjson.Event)
|
OnToolProgress func(toolCallID string, event streamjson.Event)
|
||||||
// OnContext, when set, is called once per turn with the per-category context
|
// OnContext, when set, is called for each main agent request with its
|
||||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
// per-category context budget, including a replacement request after
|
||||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
// 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)
|
OnContext func(ContextBreakdown)
|
||||||
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
// 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,
|
// mid-run: the loop calls it with the requested model id and, on success,
|
||||||
@@ -423,13 +480,6 @@ type Result struct {
|
|||||||
IncompleteReason string
|
IncompleteReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
|
||||||
// output token cap or withheld by a content filter) rather than completing
|
|
||||||
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
|
|
||||||
func (result Result) Truncated() bool {
|
|
||||||
return result.FinishReason != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// TruncationNotice returns a user-facing warning when the final response was
|
// TruncationNotice returns a user-facing warning when the final response was
|
||||||
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
||||||
// wording stays consistent.
|
// wording stays consistent.
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ type AgentRunner interface {
|
|||||||
|
|
||||||
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
||||||
|
|
||||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
|
||||||
return fn(ctx, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
||||||
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
||||||
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||||
|
package agenteval
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||||
|
return fn(ctx, input)
|
||||||
|
}
|
||||||
@@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report {
|
|||||||
if len(task.RequiredTraceEvents) > 0 {
|
if len(task.RequiredTraceEvents) > 0 {
|
||||||
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
||||||
}
|
}
|
||||||
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
|
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
|
||||||
report.Results = append(report.Results, result)
|
|
||||||
}
|
|
||||||
report.finishSummary()
|
report.finishSummary()
|
||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ func ParseTraceEventKeys(stdout string) []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func TraceEventKeys(stdout string) []string {
|
|
||||||
return ParseTraceEventKeys(stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MissingTraceEvents(required []string, stdout string) []string {
|
func MissingTraceEvents(required []string, stdout string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, key := range ParseTraceEventKeys(stdout) {
|
for _, key := range ParseTraceEventKeys(stdout) {
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
|
|||||||
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
||||||
|
|
||||||
if info.PrimaryLanguage != "" {
|
if info.PrimaryLanguage != "" {
|
||||||
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
|
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
|
||||||
}
|
}
|
||||||
if langs := topLanguages(info.Languages, 5); langs != "" {
|
if langs := topLanguages(info.Languages, 5); langs != "" {
|
||||||
b.WriteString("- Languages: " + langs + "\n")
|
b.WriteString("- Languages: " + langs + "\n")
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
|
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
|
||||||
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
||||||
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
|
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
|
||||||
}
|
}
|
||||||
if len(info.BuildTools) > 0 {
|
if len(info.BuildTools) > 0 {
|
||||||
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
||||||
|
|||||||
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
|
|||||||
}
|
}
|
||||||
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
||||||
if err != nil {
|
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 {
|
if outputFile != task.OutputFile {
|
||||||
changed = true
|
changed = true
|
||||||
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
|
|||||||
return path, nil
|
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 {
|
func validTaskID(taskID string) bool {
|
||||||
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package background
|
package background
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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) {
|
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
manager, err := NewManager(root)
|
manager, err := NewManager(root)
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
package background
|
package background
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/execution"
|
||||||
)
|
)
|
||||||
|
|
||||||
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
||||||
@@ -22,10 +21,7 @@ var (
|
|||||||
// the negative PID (the group), so any process the child forks dies with it
|
// the negative PID (the group), so any process the child forks dies with it
|
||||||
// instead of being orphaned. Must be called before cmd.Start.
|
// instead of being orphaned. Must be called before cmd.Start.
|
||||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||||
if cmd.SysProcAttr == nil {
|
execution.ConfigureProcessGroup(cmd)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
|
||||||
}
|
|
||||||
cmd.SysProcAttr.Setpgid = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// terminateProcess stops a background process. It first asks politely with
|
// terminateProcess stops a background process. It first asks politely with
|
||||||
@@ -41,71 +37,23 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
|||||||
// back to signalling only the individual PID, which also avoids reporting a false
|
// back to signalling only the individual PID, which also avoids reporting a false
|
||||||
// success when a non-leader group-signal returns ESRCH.
|
// success when a non-leader group-signal returns ESRCH.
|
||||||
func terminateProcess(pid int) error {
|
func terminateProcess(pid int) error {
|
||||||
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
|
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||||
// every process we can signal. A real child PID is always > 1, so never let a
|
|
||||||
// bogus 0/1 expand into either.
|
|
||||||
if pid <= 1 {
|
|
||||||
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal the whole group only when pid leads its own group; otherwise the
|
|
||||||
// individual process. See the doc comment for why a non-leader is signalled
|
|
||||||
// directly rather than via its (foreign) group.
|
|
||||||
target := pid
|
|
||||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
|
||||||
if pgid == pid {
|
|
||||||
target = -pid
|
|
||||||
}
|
|
||||||
} else if processGoneError(err) {
|
|
||||||
return nil // already gone
|
|
||||||
}
|
|
||||||
|
|
||||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
|
||||||
|
|
||||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
|
||||||
if processGoneError(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll liveness so we return promptly once it exits, rather than always
|
|
||||||
// waiting out the full grace period.
|
|
||||||
deadline := time.Now().Add(terminationGracePeriod)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if !alive() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
time.Sleep(terminationPollInterval)
|
|
||||||
}
|
|
||||||
if !alive() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still alive after the grace period: force-kill.
|
|
||||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
|
|
||||||
// so this helper only reports success once the target is actually gone —
|
|
||||||
// otherwise the caller gets nil while descendants are still racing.
|
|
||||||
deadline = time.Now().Add(terminationGracePeriod)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if !alive() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
time.Sleep(terminationPollInterval)
|
|
||||||
}
|
|
||||||
if alive() {
|
|
||||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// processGoneError reports whether an error means the process group has already
|
// terminateOwnedProcess terminates cmd's process group directly when its launch
|
||||||
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
|
// configuration proves it was made a group leader. This avoids fragile Getpgid
|
||||||
// no process in the target group remains.
|
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
||||||
func processGoneError(err error) bool {
|
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
||||||
return errors.Is(err, syscall.ESRCH)
|
//
|
||||||
|
// 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,14 +147,12 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
|||||||
t.Fatalf("terminateProcess: %v", err)
|
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)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for {
|
// processStopped treats only ESRCH or an observed zombie state as stopped;
|
||||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
// other errors (for example EPERM) remain live and keep polling.
|
||||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
for !processStopped(childPID) {
|
||||||
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
|
||||||
break // child no longer exists
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||||
@@ -162,3 +160,73 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
|||||||
time.Sleep(20 * time.Millisecond)
|
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,36 +3,50 @@
|
|||||||
package background
|
package background
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"github.com/Gitlawb/zero/internal/execution"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
|
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
|
||||||
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
|
// 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).
|
// setup is required (the POSIX build sets Setpgid here instead).
|
||||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
|
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
|
||||||
|
|
||||||
func terminateProcess(pid int) error {
|
func terminateProcess(pid int) error {
|
||||||
taskkill := taskkillPath()
|
return execution.TerminateProcessTree(pid, 0, 0)
|
||||||
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
process, err := os.FindProcess(pid)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return process.Kill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskkillPath() string {
|
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
|
||||||
systemRoot := os.Getenv("SystemRoot")
|
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
|
||||||
if systemRoot == "" {
|
// can discover descendants only while the root PID still exists; unlike a POSIX
|
||||||
systemRoot = os.Getenv("windir")
|
// 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)
|
||||||
}
|
}
|
||||||
if systemRoot == "" {
|
return alreadyExited, terminateErr
|
||||||
systemRoot = `C:\Windows`
|
|
||||||
}
|
|
||||||
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
|
||||||
}
|
}
|
||||||
@@ -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
|
package background
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const commandReapTimeout = 3 * time.Second
|
||||||
|
|
||||||
// TerminateProcess stops a background process by PID — on Windows its process
|
// 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
|
// tree; on POSIX its whole process group when the PID leads its own group (the
|
||||||
// invariant ConfigureChildProcessGroup establishes for processes started through
|
// invariant ConfigureChildProcessGroup establishes for processes started through
|
||||||
@@ -12,3 +21,69 @@ package background
|
|||||||
func TerminateProcess(pid int) error {
|
func TerminateProcess(pid int) error {
|
||||||
return terminateProcess(pid)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-3
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"github.com/Gitlawb/zero/internal/acp"
|
"github.com/Gitlawb/zero/internal/acp"
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,8 @@ Usage:
|
|||||||
Not meant to be run interactively — point your editor's ACP / external-agent
|
Not meant to be run interactively — point your editor's ACP / external-agent
|
||||||
setting at "zero acp".`
|
setting at "zero acp".`
|
||||||
|
|
||||||
|
var acpSignalContext = signalContext
|
||||||
|
|
||||||
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
||||||
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
||||||
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
||||||
@@ -40,9 +44,14 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn := acp.NewConn(deps.stdin, stdout)
|
// The process's own stdin belongs exclusively to this Conn for the life of
|
||||||
|
// the command, so cancellation may close it to interrupt an idle read.
|
||||||
|
conn := acp.NewOwnedConn(deps.stdin, stdout)
|
||||||
acp.NewAgent(conn, acp.Deps{
|
acp.NewAgent(conn, acp.Deps{
|
||||||
ResolveConfig: deps.resolveConfig,
|
ResolveConfig: deps.resolveConfig,
|
||||||
|
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||||
|
},
|
||||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||||
// surface — no ACP-specific credential handling needed.
|
// surface — no ACP-specific credential handling needed.
|
||||||
@@ -68,9 +77,9 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
|||||||
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, stop := signalContext()
|
ctx, stop := acpSignalContext()
|
||||||
defer stop()
|
defer stop()
|
||||||
if err := conn.Serve(ctx); err != nil && ctx.Err() == nil {
|
if err := conn.Serve(ctx); err != nil {
|
||||||
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
||||||
}
|
}
|
||||||
return exitSuccess
|
return exitSuccess
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type acpTestReader func([]byte) (int, error)
|
||||||
|
|
||||||
|
func (r acpTestReader) Read(p []byte) (int, error) { return r(p) }
|
||||||
|
|
||||||
|
type acpTestWriter func([]byte) (int, error)
|
||||||
|
|
||||||
|
func (w acpTestWriter) Write(p []byte) (int, error) { return w(p) }
|
||||||
|
|
||||||
|
func installACPTestContext(t *testing.T) context.CancelFunc {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
previous := acpSignalContext
|
||||||
|
acpSignalContext = func() (context.Context, context.CancelFunc) {
|
||||||
|
return ctx, cancel
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
acpSignalContext = previous
|
||||||
|
})
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunACPCancellationPreservesTerminalReadError(t *testing.T) {
|
||||||
|
cancel := installACPTestContext(t)
|
||||||
|
wantErr := errors.New("transport read failed")
|
||||||
|
reader := acpTestReader(func(p []byte) (int, error) {
|
||||||
|
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
||||||
|
})
|
||||||
|
writeStarted := make(chan struct{}, 1)
|
||||||
|
releaseWrite := make(chan struct{})
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
writer := acpTestWriter(func(p []byte) (int, error) {
|
||||||
|
writeStarted <- struct{}{}
|
||||||
|
<-releaseWrite
|
||||||
|
return stdout.Write(p)
|
||||||
|
})
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
done := make(chan int, 1)
|
||||||
|
go func() {
|
||||||
|
done <- runACP(nil, writer, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-writeStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ACP did not reach the response write")
|
||||||
|
}
|
||||||
|
// Keep Serve in the synchronous write until cancellation is observable. The
|
||||||
|
// regression requires the genuine read error to win over that cancellation.
|
||||||
|
cancel()
|
||||||
|
close(releaseWrite)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case code := <-done:
|
||||||
|
if code != exitCrash {
|
||||||
|
t.Fatalf("exit code = %d, want crash %d", code, exitCrash)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ACP did not exit after terminal read error")
|
||||||
|
}
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "acp: "+wantErr.Error()) {
|
||||||
|
t.Fatalf("stderr = %q, want terminal read error", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
|
||||||
|
cancel := installACPTestContext(t)
|
||||||
|
pipeReader, pipeWriter := io.Pipe()
|
||||||
|
t.Cleanup(func() { _ = pipeWriter.Close() })
|
||||||
|
readStarted := make(chan struct{}, 1)
|
||||||
|
reader := &acpNotifyingReadCloser{ReadCloser: pipeReader, readStarted: readStarted}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
done := make(chan int, 1)
|
||||||
|
go func() {
|
||||||
|
done <- runACP(nil, &stdout, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-readStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ACP did not begin reading")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case code := <-done:
|
||||||
|
if code != exitSuccess {
|
||||||
|
t.Fatalf("exit code = %d, want success %d; stderr: %s", code, exitSuccess, stderr.String())
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ACP did not exit after cancellation")
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type acpNotifyingReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
readStarted chan<- struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *acpNotifyingReadCloser) Read(p []byte) (int, error) {
|
||||||
|
r.once.Do(func() { r.readStarted <- struct{}{} })
|
||||||
|
return r.ReadCloser.Read(p)
|
||||||
|
}
|
||||||
@@ -515,9 +515,7 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
|
|||||||
if task.Agent.Truncated {
|
if task.Agent.Truncated {
|
||||||
converted.Truncated = true
|
converted.Truncated = true
|
||||||
}
|
}
|
||||||
for _, failure := range agentEvalFailuresFromTaskReport(task) {
|
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
|
||||||
converted.Failures = append(converted.Failures, failure)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return converted
|
return converted
|
||||||
}
|
}
|
||||||
|
|||||||
+250
-65
@@ -17,11 +17,13 @@ import (
|
|||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
|
"github.com/Gitlawb/zero/internal/execution"
|
||||||
"github.com/Gitlawb/zero/internal/hooks"
|
"github.com/Gitlawb/zero/internal/hooks"
|
||||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||||
"github.com/Gitlawb/zero/internal/mcp"
|
"github.com/Gitlawb/zero/internal/mcp"
|
||||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||||
"github.com/Gitlawb/zero/internal/observability"
|
"github.com/Gitlawb/zero/internal/observability"
|
||||||
|
"github.com/Gitlawb/zero/internal/peermsg"
|
||||||
"github.com/Gitlawb/zero/internal/plugins"
|
"github.com/Gitlawb/zero/internal/plugins"
|
||||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||||
@@ -58,37 +60,57 @@ type appDeps struct {
|
|||||||
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
||||||
// by fillAppDeps, so tests never mutate the process environment unless they
|
// by fillAppDeps, so tests never mutate the process environment unless they
|
||||||
// inject it). nil ⇒ no export.
|
// inject it). nil ⇒ no export.
|
||||||
exportActiveProvider func(providerName string)
|
exportActiveProvider func(providerName string)
|
||||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
// getenv reads a process environment variable (production: os.Getenv, set in
|
||||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
|
||||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
|
||||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
getenv func(string) string
|
||||||
newSessionStore func() *sessions.Store
|
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||||
skillsDir func() string
|
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||||
pluginsDir func() string
|
newSessionStore func() *sessions.Store
|
||||||
toolsDir func() string
|
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||||
newMCPStore func() (*mcp.PermissionStore, error)
|
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
skillsDir func() string
|
||||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
pluginsDir func() string
|
||||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
toolsDir func() string
|
||||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
newMCPStore func() (*mcp.PermissionStore, error)
|
||||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||||
detectVerifyPlan func(string) (verify.Plan, error)
|
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
releaseWorktree func(context.Context, worktrees.Options, string) error
|
||||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
detectVerifyPlan func(string) (verify.Plan, error)
|
||||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||||
runTUI func(context.Context, tui.Options) int
|
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||||
runEditor func(string) error
|
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||||
now func() time.Time
|
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 {
|
type mcpToolRuntime interface {
|
||||||
@@ -118,6 +140,7 @@ func defaultAppDeps() appDeps {
|
|||||||
stdin: os.Stdin,
|
stdin: os.Stdin,
|
||||||
userConfigPath: config.DefaultUserConfigPath,
|
userConfigPath: config.DefaultUserConfigPath,
|
||||||
exportActiveProvider: config.SetActiveProviderEnv,
|
exportActiveProvider: config.SetActiveProviderEnv,
|
||||||
|
getenv: os.Getenv,
|
||||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -181,6 +204,7 @@ func defaultAppDeps() appDeps {
|
|||||||
return mcp.RegisterTools(ctx, registry, cfg, options)
|
return mcp.RegisterTools(ctx, registry, cfg, options)
|
||||||
},
|
},
|
||||||
prepareWorktree: worktrees.Prepare,
|
prepareWorktree: worktrees.Prepare,
|
||||||
|
releaseWorktree: worktrees.Release,
|
||||||
detectVerifyPlan: verify.DetectPlan,
|
detectVerifyPlan: verify.DetectPlan,
|
||||||
runVerify: verify.Run,
|
runVerify: verify.Run,
|
||||||
runSelfVerify: selfverify.Run,
|
runSelfVerify: selfverify.Run,
|
||||||
@@ -189,11 +213,52 @@ func defaultAppDeps() appDeps {
|
|||||||
commitChanges: zerogit.Commit,
|
commitChanges: zerogit.Commit,
|
||||||
pushChanges: zerogit.Push,
|
pushChanges: zerogit.Push,
|
||||||
createPR: zerogit.CreatePR,
|
createPR: zerogit.CreatePR,
|
||||||
runTUI: tui.Run,
|
createBranch: zerogit.CreateBranch,
|
||||||
runEditor: openEditor,
|
isDefaultBranch: zerogit.IsDefaultBranch,
|
||||||
checkUpdate: update.Check,
|
currentGitUser: func(ctx context.Context, cwd string) string {
|
||||||
applyUpdate: update.Apply,
|
return zerogit.CurrentGitUser(ctx, cwd, nil)
|
||||||
now: time.Now,
|
},
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +433,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
|||||||
case "exec":
|
case "exec":
|
||||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||||
|
case "completions":
|
||||||
|
return runCompletions(args[1:], stdout, stderr)
|
||||||
case "daemon":
|
case "daemon":
|
||||||
return runDaemon(args[1:], stdout, stderr, deps)
|
return runDaemon(args[1:], stdout, stderr, deps)
|
||||||
case "config":
|
case "config":
|
||||||
@@ -524,6 +591,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
|||||||
if deps.prepareWorktree == nil {
|
if deps.prepareWorktree == nil {
|
||||||
deps.prepareWorktree = defaults.prepareWorktree
|
deps.prepareWorktree = defaults.prepareWorktree
|
||||||
}
|
}
|
||||||
|
if deps.releaseWorktree == nil {
|
||||||
|
deps.releaseWorktree = defaults.releaseWorktree
|
||||||
|
}
|
||||||
if deps.detectVerifyPlan == nil {
|
if deps.detectVerifyPlan == nil {
|
||||||
deps.detectVerifyPlan = defaults.detectVerifyPlan
|
deps.detectVerifyPlan = defaults.detectVerifyPlan
|
||||||
}
|
}
|
||||||
@@ -548,6 +618,45 @@ func fillAppDeps(deps appDeps) appDeps {
|
|||||||
if deps.createPR == nil {
|
if deps.createPR == nil {
|
||||||
deps.createPR = defaults.createPR
|
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 {
|
if deps.runTUI == nil {
|
||||||
deps.runTUI = defaults.runTUI
|
deps.runTUI = defaults.runTUI
|
||||||
}
|
}
|
||||||
@@ -577,6 +686,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
|||||||
baseProbeProviderHealth := deps.probeProviderHealth
|
baseProbeProviderHealth := deps.probeProviderHealth
|
||||||
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
|
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
|
||||||
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
|
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
|
||||||
|
if options.OAuthResolver == nil {
|
||||||
|
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
|
||||||
|
}
|
||||||
return baseProbeProviderHealth(ctx, options)
|
return baseProbeProviderHealth(ctx, options)
|
||||||
}
|
}
|
||||||
return deps
|
return deps
|
||||||
@@ -678,6 +790,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
|
|
||||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||||
|
executionRunner := execution.NewRunner(nil)
|
||||||
|
sandboxStore, err := deps.newSandboxStore()
|
||||||
|
if err != nil {
|
||||||
|
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||||
|
}
|
||||||
|
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
|
||||||
|
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
|
||||||
|
} else if notice != "" {
|
||||||
|
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||||
|
}
|
||||||
|
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||||
|
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||||
|
WorkspaceRoot: workspaceRoot,
|
||||||
|
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||||
|
Store: sandboxStore,
|
||||||
|
Backend: sandboxBackend,
|
||||||
|
Scope: scope,
|
||||||
|
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||||
|
})
|
||||||
|
executionRunner.SetPreparer(sandboxEngine)
|
||||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||||
@@ -707,22 +839,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
mcpTokenStore = nil
|
mcpTokenStore = nil
|
||||||
err = nil
|
err = nil
|
||||||
}
|
}
|
||||||
|
criticalMCPConfig, optionalMCPConfig := splitMCPStartupConfig(mcpConfig)
|
||||||
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
|
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
|
||||||
if len(mcpConfig.Servers) > 0 {
|
if len(criticalMCPConfig.Servers) > 0 {
|
||||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
runtime, registerErr := deps.registerMCPTools(context.Background(), registry, criticalMCPConfig, mcp.RegisterOptions{
|
||||||
PermissionStore: mcpPermissionStore,
|
PermissionStore: mcpPermissionStore,
|
||||||
Autonomy: mcp.AutonomyLow,
|
Autonomy: mcp.AutonomyLow,
|
||||||
|
Execution: executionRunner,
|
||||||
|
WorkspaceRoot: workspaceRoot,
|
||||||
})
|
})
|
||||||
}
|
if registerErr != nil {
|
||||||
if err != nil {
|
closeMCPRuntime(stderr, runtime)
|
||||||
closeMCPRuntime(stderr, mcpRuntime)
|
return writeAppError(stderr, redaction.ErrorMessage(registerErr, redaction.Options{}), 1)
|
||||||
return writeAppError(stderr, err.Error(), 1)
|
}
|
||||||
|
mcpRuntime = runtime
|
||||||
}
|
}
|
||||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||||
// A server that could not be reached or validated is skipped, not fatal (one
|
// 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
|
// 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
|
// 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
|
// was never asked for, so its failure is not worth a startup warning — only
|
||||||
// servers the user actually configured warn on failure (issue #552).
|
// servers the user actually configured warn on failure (issue #552).
|
||||||
for _, skipped := range mcpRuntime.Skipped() {
|
for _, skipped := range mcpRuntime.Skipped() {
|
||||||
@@ -738,7 +874,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
// The interactive TUI is not worktree-reassigned, so the trust root is the
|
// The interactive TUI is not worktree-reassigned, so the trust root is the
|
||||||
// launch directory itself.
|
// launch directory itself.
|
||||||
trustRoot := workspaceRoot
|
trustRoot := workspaceRoot
|
||||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||||
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
||||||
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
||||||
// apply_patch) would never be offered to the model — the TUI could neither edit
|
// apply_patch) would never be offered to the model — the TUI could neither edit
|
||||||
@@ -753,26 +889,57 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
if permissionMode == "" {
|
if permissionMode == "" {
|
||||||
permissionMode = agent.PermissionModeAsk
|
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
|
// Activate deferred MCP-tool loading for the interactive run only when the
|
||||||
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
||||||
// The registry is complete (core + specialist + MCP + plugins) here, so the
|
// This first pass covers every prompt-critical tool. Optional built-in MCP
|
||||||
// count is accurate; below threshold this is a no-op and the surface is
|
// defaults re-run the same gate after publishing their atomic batch, so a
|
||||||
// unchanged. The interactive surface applies no operator tool filters, so
|
// later catalog generation cannot miss its loader. The interactive surface
|
||||||
// enabled/disabled are nil — matching the AgentOptions below.
|
// applies no operator tool filters, so enabled/disabled are nil — matching the
|
||||||
|
// AgentOptions below.
|
||||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||||
sandboxStore, err := deps.newSandboxStore()
|
var optionalMCPRuntime *optionalMCPStartup
|
||||||
if err != nil {
|
var awaitToolReadiness func(context.Context)
|
||||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
|
||||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
||||||
WorkspaceRoot: workspaceRoot,
|
|
||||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
|
||||||
Store: sandboxStore,
|
|
||||||
Backend: sandboxBackend,
|
|
||||||
Scope: scope,
|
|
||||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
|
||||||
})
|
|
||||||
lastKnownMCPConfig := mcpConfig
|
lastKnownMCPConfig := mcpConfig
|
||||||
fileTracker := tools.NewFileTracker()
|
fileTracker := tools.NewFileTracker()
|
||||||
var scratchBaseline scratchFileBaseline
|
var scratchBaseline scratchFileBaseline
|
||||||
@@ -789,13 +956,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
|
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
|
||||||
// report can be combined with the plugin activation's, and emit at most one
|
// report can be combined with the plugin activation's, and emit at most one
|
||||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||||
return deps.runTUI(context.Background(), tui.Options{
|
return deps.runTUI(context.Background(), tui.Options{
|
||||||
Cwd: workspaceRoot,
|
Cwd: workspaceRoot,
|
||||||
Version: version,
|
Version: version,
|
||||||
Theme: theme,
|
Theme: theme,
|
||||||
SavedTheme: resolved.Preferences.Theme,
|
SavedTheme: resolved.Preferences.Theme,
|
||||||
|
SavedPet: resolved.Preferences.Pet,
|
||||||
UserConfigPath: userConfigPath,
|
UserConfigPath: userConfigPath,
|
||||||
DoctorUserConfigPath: doctorUserConfigPath,
|
DoctorUserConfigPath: doctorUserConfigPath,
|
||||||
ProjectConfigPath: projectConfigPath,
|
ProjectConfigPath: projectConfigPath,
|
||||||
@@ -808,8 +976,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
NewProvider: deps.newProvider,
|
NewProvider: deps.newProvider,
|
||||||
ProbeProviderHealth: deps.probeProviderHealth,
|
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
|
||||||
UserAgent: userAgent(),
|
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() {
|
PrepareRunCompletionWarning: func() {
|
||||||
scratchBaseline = scratchFileSnapshot(workspaceRoot)
|
scratchBaseline = scratchFileSnapshot(workspaceRoot)
|
||||||
},
|
},
|
||||||
@@ -817,7 +994,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
||||||
},
|
},
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
SessionStore: deps.newSessionStore(),
|
AwaitToolReadiness: awaitToolReadiness,
|
||||||
|
SessionStore: sessionStore,
|
||||||
|
PeerService: peerService,
|
||||||
SandboxStore: sandboxStore,
|
SandboxStore: sandboxStore,
|
||||||
MCPConfig: mcpConfig,
|
MCPConfig: mcpConfig,
|
||||||
MCPPermissionStore: mcpPermissionStore,
|
MCPPermissionStore: mcpPermissionStore,
|
||||||
@@ -1157,6 +1336,7 @@ Usage:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
exec Run a one-shot prompt through the Go agent runtime
|
exec Run a one-shot prompt through the Go agent runtime
|
||||||
|
completions Generate shell completion scripts
|
||||||
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
||||||
setup Guide first-run provider setup
|
setup Guide first-run provider setup
|
||||||
config Inspect resolved Go configuration without leaking secrets
|
config Inspect resolved Go configuration without leaking secrets
|
||||||
@@ -1178,7 +1358,8 @@ Commands:
|
|||||||
mcp Manage MCP backend settings
|
mcp Manage MCP backend settings
|
||||||
auth Log in to model providers via OAuth
|
auth Log in to model providers via OAuth
|
||||||
sandbox Inspect sandbox policy and persistent grants
|
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
|
worktrees Prepare isolated git worktrees
|
||||||
verify Detect and run local verification checks
|
verify Detect and run local verification checks
|
||||||
eval Validate offline agent eval suites
|
eval Validate offline agent eval suites
|
||||||
@@ -1340,6 +1521,10 @@ Flags:
|
|||||||
--spec-model <model> Override the draft model when --use-spec is set
|
--spec-model <model> Override the draft model when --use-spec is set
|
||||||
--spec-reasoning-effort <effort>
|
--spec-reasoning-effort <effort>
|
||||||
Override draft reasoning effort when --use-spec is set
|
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
|
--max-turns <number> Override the maximum agent loop turns
|
||||||
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
|
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
|
||||||
posture only (turn budget, effort, self-correction, escalation);
|
posture only (turn budget, effort, self-correction, escalation);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
"github.com/Gitlawb/zero/internal/mcp"
|
"github.com/Gitlawb/zero/internal/mcp"
|
||||||
|
"github.com/Gitlawb/zero/internal/redaction"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
"github.com/Gitlawb/zero/internal/tui"
|
"github.com/Gitlawb/zero/internal/tui"
|
||||||
"github.com/Gitlawb/zero/internal/update"
|
"github.com/Gitlawb/zero/internal/update"
|
||||||
@@ -211,12 +212,12 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
|||||||
},
|
},
|
||||||
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
||||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
"exa": config.DefaultMCPServers()["exa"],
|
||||||
}}, nil
|
}}, nil
|
||||||
},
|
},
|
||||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
|
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
|
}}, nil
|
||||||
},
|
},
|
||||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||||
@@ -227,7 +228,7 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
|||||||
if exitCode != 0 {
|
if exitCode != 0 {
|
||||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
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())
|
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) {
|
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -565,6 +634,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
|||||||
t.Fatalf("NewTokenStore() error = %v", err)
|
t.Fatalf("NewTokenStore() error = %v", err)
|
||||||
}
|
}
|
||||||
runtimeClosed := false
|
runtimeClosed := false
|
||||||
|
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||||
|
|
||||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||||
getwd: func() (string, error) {
|
getwd: func() (string, error) {
|
||||||
@@ -591,7 +661,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
|||||||
return closeFunc(func() error {
|
return closeFunc(func() error {
|
||||||
runtimeClosed = true
|
runtimeClosed = true
|
||||||
return nil
|
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 {
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||||
t.Fatal("TUI should not launch after MCP registration fails")
|
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") {
|
if !strings.Contains(stderr.String(), "register mcp tools failed") {
|
||||||
t.Fatalf("stderr missing registration error: %s", stderr.String())
|
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 {
|
if !runtimeClosed {
|
||||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
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) {
|
func TestRunUpdateReportsUpToDate(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -1660,6 +1750,7 @@ func assertHelpOutput(t *testing.T, args []string) {
|
|||||||
"mcp",
|
"mcp",
|
||||||
"sandbox",
|
"sandbox",
|
||||||
"update",
|
"update",
|
||||||
|
"upgrade",
|
||||||
"worktrees",
|
"worktrees",
|
||||||
"verify",
|
"verify",
|
||||||
"serve",
|
"serve",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
|
|
||||||
func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T) {
|
func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T) {
|
||||||
cwd := t.TempDir()
|
cwd := t.TempDir()
|
||||||
secret := "sk-proj-" + strings.Repeat("a", 24)
|
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||||
deps := appDeps{
|
deps := appDeps{
|
||||||
getwd: func() (string, error) { return cwd, nil },
|
getwd: func() (string, error) { return cwd, nil },
|
||||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||||
@@ -161,7 +161,7 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
|
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
|
||||||
cwd := t.TempDir()
|
cwd := t.TempDir()
|
||||||
secret := "sk-proj-" + strings.Repeat("b", 24)
|
secret := "sk-proj-" + strings.Repeat("b", 23) + "0"
|
||||||
deps := appDeps{
|
deps := appDeps{
|
||||||
getwd: func() (string, error) { return cwd, nil },
|
getwd: func() (string, error) { return cwd, nil },
|
||||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type completionNode struct {
|
||||||
|
names []string
|
||||||
|
flags []string
|
||||||
|
children []completionNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionContext struct {
|
||||||
|
path string
|
||||||
|
candidates []string
|
||||||
|
transitions map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var completionRoot = completionNode{
|
||||||
|
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
|
||||||
|
children: []completionNode{
|
||||||
|
{names: []string{"exec"}, flags: []string{
|
||||||
|
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
|
||||||
|
"--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",
|
||||||
|
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
|
||||||
|
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
|
||||||
|
"--notify", "--no-notify", "--no-completion-gate",
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
names: []string{"completions"},
|
||||||
|
flags: []string{"-h", "--help"},
|
||||||
|
children: leafNodes("bash", "zsh", "fish", "powershell", "elvish"),
|
||||||
|
},
|
||||||
|
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
|
||||||
|
{names: []string{"setup"}},
|
||||||
|
{names: []string{"config"}},
|
||||||
|
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
|
||||||
|
{names: []string{"providers"}, children: []completionNode{
|
||||||
|
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
|
||||||
|
{names: []string{"add"}}, {names: []string{"check"}}, {names: []string{"use"}},
|
||||||
|
{names: []string{"remove", "rm"}}, {names: []string{"rename"}}, {names: []string{"setup"}},
|
||||||
|
{names: []string{"detect"}}, {names: []string{"models"}},
|
||||||
|
}},
|
||||||
|
{names: []string{"doctor"}},
|
||||||
|
{names: []string{"context"}},
|
||||||
|
{names: []string{"repo-map", "repomap"}},
|
||||||
|
{names: []string{"search", "find"}},
|
||||||
|
{names: []string{"sessions", "session"}, children: leafNodes("list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan")},
|
||||||
|
{names: []string{"spec"}, children: leafNodes("show", "approve", "reject")},
|
||||||
|
{names: []string{"init"}},
|
||||||
|
{names: []string{"specialists", "specialist"}, children: []completionNode{
|
||||||
|
{names: []string{"list"}}, {names: []string{"show"}}, {names: []string{"create"}},
|
||||||
|
{names: []string{"delete", "rm"}}, {names: []string{"edit"}}, {names: []string{"path"}},
|
||||||
|
}},
|
||||||
|
{names: []string{"plugins", "plugin"}, children: []completionNode{
|
||||||
|
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
|
||||||
|
}},
|
||||||
|
{names: []string{"backends", "backend"}, children: leafNodes("doctor")},
|
||||||
|
{names: []string{"skills", "skill"}, children: []completionNode{
|
||||||
|
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
|
||||||
|
}},
|
||||||
|
{names: []string{"tools", "tool"}, children: leafNodes("make", "list")},
|
||||||
|
{names: []string{"hooks"}, children: []completionNode{
|
||||||
|
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
|
||||||
|
{names: []string{"enable"}}, {names: []string{"disable"}},
|
||||||
|
}},
|
||||||
|
{names: []string{"mcp"}, children: []completionNode{
|
||||||
|
{names: []string{"add"}}, {names: []string{"remove", "rm"}}, {names: []string{"enable"}},
|
||||||
|
{names: []string{"disable"}}, {names: []string{"check"}}, {names: []string{"list"}},
|
||||||
|
{names: []string{"permissions"}, children: leafNodes("list", "revoke", "clear")},
|
||||||
|
{names: []string{"tools"}, children: leafNodes("list")},
|
||||||
|
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
|
||||||
|
}},
|
||||||
|
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh")},
|
||||||
|
{names: []string{"sandbox"}, children: []completionNode{
|
||||||
|
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
|
||||||
|
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
|
||||||
|
}},
|
||||||
|
{names: []string{"update"}},
|
||||||
|
{names: []string{"upgrade"}},
|
||||||
|
{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")},
|
||||||
|
{names: []string{"changes", "change"}, children: []completionNode{
|
||||||
|
{names: []string{"inspect", "status"}}, {names: []string{"commit"}}, {names: []string{"push"}},
|
||||||
|
{names: []string{"pr", "pull-request"}},
|
||||||
|
}},
|
||||||
|
{names: []string{"usage"}, children: leafNodes("report")},
|
||||||
|
{names: []string{"cron"}, children: leafNodes("add", "list", "rm", "pause", "resume", "run")},
|
||||||
|
{names: []string{"repo-info", "repoinfo"}},
|
||||||
|
{names: []string{"serve"}},
|
||||||
|
{names: []string{"acp"}},
|
||||||
|
{names: []string{"help"}},
|
||||||
|
{names: []string{"version"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func leafNodes(names ...string) []completionNode {
|
||||||
|
nodes := make([]completionNode, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
nodes = append(nodes, completionNode{names: []string{name}})
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCompletions(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||||
|
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
|
||||||
|
if err := writeCompletionsHelp(stdout); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return writeExecUsageError(stderr, "shell required. Use `zero completions <bash|zsh|fish|powershell|elvish>`.")
|
||||||
|
}
|
||||||
|
if len(args) != 1 {
|
||||||
|
return writeExecUsageError(stderr, fmt.Sprintf("unexpected completions argument %q", args[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
contexts := completionContexts(completionRoot)
|
||||||
|
var err error
|
||||||
|
switch strings.ToLower(strings.TrimSpace(args[0])) {
|
||||||
|
case "bash":
|
||||||
|
err = writeBashCompletions(stdout, contexts)
|
||||||
|
case "zsh":
|
||||||
|
err = writeZshCompletions(stdout, contexts)
|
||||||
|
case "fish":
|
||||||
|
err = writeFishCompletions(stdout, contexts)
|
||||||
|
case "powershell":
|
||||||
|
err = writePowerShellCompletions(stdout, contexts)
|
||||||
|
case "elvish":
|
||||||
|
err = writeElvishCompletions(stdout, contexts)
|
||||||
|
default:
|
||||||
|
return writeExecUsageError(stderr, fmt.Sprintf("unsupported shell %q; expected bash, zsh, fish, powershell, or elvish", args[0]))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCompletionsHelp(w io.Writer) error {
|
||||||
|
_, err := fmt.Fprint(w, `Generate shell completion scripts.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
zero completions <shell>
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
<shell> Target shell: bash, zsh, fish, powershell, or elvish
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
source <(zero completions bash)
|
||||||
|
source <(zero completions zsh)
|
||||||
|
zero completions fish > ~/.config/fish/completions/zero.fish
|
||||||
|
zero completions powershell >> $PROFILE
|
||||||
|
eval (zero completions elvish | slurp)
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
-h, --help Show this help
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionContexts(root completionNode) []completionContext {
|
||||||
|
contexts := []completionContext{}
|
||||||
|
var visit func(completionNode, []string)
|
||||||
|
visit = func(node completionNode, paths []string) {
|
||||||
|
candidates := append([]string{}, node.flags...)
|
||||||
|
transitions := map[string]string{}
|
||||||
|
for _, child := range node.children {
|
||||||
|
candidates = append(candidates, child.names...)
|
||||||
|
for _, path := range paths {
|
||||||
|
for _, name := range child.names {
|
||||||
|
childPath := strings.TrimSpace(path + " " + name)
|
||||||
|
transitions[path+"|"+name] = childPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = uniqueStrings(candidates)
|
||||||
|
for _, path := range paths {
|
||||||
|
contexts = append(contexts, completionContext{path: path, candidates: candidates, transitions: transitions})
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
childPaths := make([]string, 0, len(paths)*len(child.names))
|
||||||
|
for _, path := range paths {
|
||||||
|
for _, name := range child.names {
|
||||||
|
childPaths = append(childPaths, strings.TrimSpace(path+" "+name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(child, childPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(root, []string{""})
|
||||||
|
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].path < contexts[j].path })
|
||||||
|
return contexts
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func allTransitions(contexts []completionContext) map[string]string {
|
||||||
|
transitions := map[string]string{}
|
||||||
|
for _, context := range contexts {
|
||||||
|
for key, value := range context.transitions {
|
||||||
|
transitions[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transitions
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedTransitionKeys(transitions map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(transitions))
|
||||||
|
for key := range transitions {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellWords(values []string) string {
|
||||||
|
return strings.Join(values, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBashCompletions(w io.Writer, contexts []completionContext) error {
|
||||||
|
if _, err := fmt.Fprintln(w, "# bash completion for zero\n_zero() {\n local cur context token i candidates\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n context=\"\""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writePOSIXContextLoop(w, contexts, " for ((i = 1; i < COMP_CWORD; i++)); do", " token=\"${COMP_WORDS[i]}\"", " done"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writePOSIXCandidateCase(w, contexts, " "); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(w, " COMPREPLY=( $(compgen -W \"$candidates\" -- \"$cur\") )\n}\ncomplete -F _zero zero")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeZshCompletions(w io.Writer, contexts []completionContext) error {
|
||||||
|
if _, err := fmt.Fprintln(w, "#compdef zero\n_zero() {\n local context token i\n local -a candidates\n context=\"\""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writePOSIXContextLoop(w, contexts, " for ((i = 2; i < CURRENT; i++)); do", " token=\"${words[i]}\"", " done"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, " case \"$context\" in"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, context := range contexts {
|
||||||
|
if _, err := fmt.Fprintf(w, " %q) candidates=(%s) ;;\n", context.path, quotedWords(context.candidates)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(w, " esac\n compadd -- \"${candidates[@]}\"\n}\ncompdef _zero zero")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePOSIXContextLoop(w io.Writer, contexts []completionContext, start string, token string, end string) error {
|
||||||
|
if _, err := fmt.Fprintln(w, start); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, " case \"$context|$token\" in"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
transitions := allTransitions(contexts)
|
||||||
|
for _, key := range sortedTransitionKeys(transitions) {
|
||||||
|
if _, err := fmt.Fprintf(w, " %q) context=%q ;;\n", key, transitions[key]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, " esac"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(w, end)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePOSIXCandidateCase(w io.Writer, contexts []completionContext, indent string) error {
|
||||||
|
if _, err := fmt.Fprintln(w, indent+"case \"$context\" in"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, context := range contexts {
|
||||||
|
if _, err := fmt.Fprintf(w, "%s %q) candidates=%q ;;\n", indent, context.path, shellWords(context.candidates)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(w, indent+"esac")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotedWords(values []string) string {
|
||||||
|
quoted := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
quoted = append(quoted, fmt.Sprintf("%q", value))
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFishCompletions(w io.Writer, contexts []completionContext) error {
|
||||||
|
if _, err := fmt.Fprintln(w, "# fish completion for zero\nfunction __zero_completion_context\n set -l context ''\n set -l tokens (commandline -opc)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, " for token in $tokens[2..-1]\n switch \"$context|$token\""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
transitions := allTransitions(contexts)
|
||||||
|
for _, key := range sortedTransitionKeys(transitions) {
|
||||||
|
if _, err := fmt.Fprintf(w, " case %q\n set context %q\n", key, transitions[key]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, " end\n end\n if test -z \"$context\"\n echo __root__\n else\n echo \"$context\"\n end\nend"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, context := range contexts {
|
||||||
|
path := context.path
|
||||||
|
if path == "" {
|
||||||
|
path = "__root__"
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "complete -c zero -f -n 'test (__zero_completion_context) = %q' -a %q\n", path, shellWords(context.candidates)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePowerShellCompletions(w io.Writer, contexts []completionContext) error {
|
||||||
|
if _, err := fmt.Fprintln(w, "# PowerShell completion for zero\nRegister-ArgumentCompleter -Native -CommandName zero -ScriptBlock {\n param($wordToComplete, $commandAst, $cursorPosition)\n $context = ''\n $elements = @($commandAst.CommandElements)\n $limit = $elements.Count\n if ($wordToComplete -ne '' -and $limit -gt 1) { $limit-- }\n for ($i = 1; $i -lt $limit; $i++) {\n $token = $elements[$i].Extent.Text\n switch (\"$context|$token\") {"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
transitions := allTransitions(contexts)
|
||||||
|
for _, key := range sortedTransitionKeys(transitions) {
|
||||||
|
if _, err := fmt.Fprintf(w, " '%s' { $context = '%s'; break }\n", key, transitions[key]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, " }\n }\n $candidates = switch ($context) {"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, context := range contexts {
|
||||||
|
if _, err := fmt.Fprintf(w, " '%s' { @(%s) }\n", context.path, powershellWords(context.candidates)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(w, " }\n $candidates | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {\n [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)\n }\n}")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func powershellWords(values []string) string {
|
||||||
|
quoted := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
quoted = append(quoted, "'"+strings.ReplaceAll(value, "'", "''")+"'")
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeElvishCompletions(w io.Writer, contexts []completionContext) error {
|
||||||
|
if _, err := fmt.Fprintln(w, "# Elvish completion for zero\nset edit:completion:arg-completer[zero] = {|@args|\n var context = ''\n for token $args[1..-1] {"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
transitions := allTransitions(contexts)
|
||||||
|
for index, key := range sortedTransitionKeys(transitions) {
|
||||||
|
keyword := "if"
|
||||||
|
if index > 0 {
|
||||||
|
keyword = "} elif"
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, " %s (eq $context'|'$token %q) {\n set context = %q\n", keyword, key, transitions[key]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, " }\n }"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for index, context := range contexts {
|
||||||
|
keyword := "if"
|
||||||
|
if index > 0 {
|
||||||
|
keyword = "} elif"
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, " %s (eq $context %q) {\n put %s\n", keyword, context.path, shellWords(context.candidates)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(w, " }\n}")
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompletionsHelpAndRootHelp(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := runWithDeps([]string{"completions", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||||
|
t.Fatalf("completion help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"zero completions <shell>",
|
||||||
|
"bash, zsh, fish, powershell, or elvish",
|
||||||
|
"source <(zero completions bash)",
|
||||||
|
"~/.config/fish/completions/zero.fish",
|
||||||
|
"eval (zero completions elvish | slurp)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(stdout.String(), want) {
|
||||||
|
t.Errorf("completion help missing %q:\n%s", want, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
if code := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||||
|
t.Fatalf("root help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "completions Generate shell completion scripts") {
|
||||||
|
t.Fatalf("root help does not list completions command:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionsRejectsMissingUnknownAndExtraShellArguments(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "missing", args: []string{"completions"}, want: "shell required"},
|
||||||
|
{name: "unknown", args: []string{"completions", "nu"}, want: "unsupported shell"},
|
||||||
|
{name: "extra", args: []string{"completions", "bash", "extra"}, want: "unexpected completions argument"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := runWithDeps(test.args, &stdout, &stderr, appDeps{}); code != exitUsage {
|
||||||
|
t.Fatalf("exit code = %d, want %d: %s", code, exitUsage, stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), test.want) {
|
||||||
|
t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionsGeneratesEverySupportedShell(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
shell string
|
||||||
|
marker string
|
||||||
|
syntaxShell string
|
||||||
|
}{
|
||||||
|
{shell: "bash", marker: "complete -F _zero zero", syntaxShell: "bash"},
|
||||||
|
{shell: "zsh", marker: "#compdef zero", syntaxShell: "zsh"},
|
||||||
|
{shell: "fish", marker: "complete -c zero"},
|
||||||
|
{shell: "powershell", marker: "Register-ArgumentCompleter -Native -CommandName zero"},
|
||||||
|
{shell: "elvish", marker: "edit:completion:arg-completer[zero]"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.shell, func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := runWithDeps([]string{"completions", test.shell}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||||
|
t.Fatalf("exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), test.marker) {
|
||||||
|
t.Fatalf("%s completion missing marker %q:\n%s", test.shell, test.marker, stdout.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"daemon", "mcp oauth", "sandbox grants", "--output-format"} {
|
||||||
|
if !strings.Contains(stdout.String(), want) {
|
||||||
|
t.Errorf("%s completion missing %q", test.shell, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
script := stdout.String()
|
||||||
|
switch test.shell {
|
||||||
|
case "fish":
|
||||||
|
assertBalancedFishBlocks(t, script)
|
||||||
|
case "powershell", "elvish":
|
||||||
|
assertBalancedBraces(t, script)
|
||||||
|
}
|
||||||
|
if test.syntaxShell != "" {
|
||||||
|
assertNativeShellSyntax(t, test.syntaxShell, script)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBalancedBraces(t *testing.T, script string) {
|
||||||
|
t.Helper()
|
||||||
|
if opens, closes := strings.Count(script, "{"), strings.Count(script, "}"); opens != closes {
|
||||||
|
t.Errorf("unbalanced braces: %d opening, %d closing", opens, closes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBalancedFishBlocks(t *testing.T, script string) {
|
||||||
|
t.Helper()
|
||||||
|
depth := 0
|
||||||
|
for _, line := range strings.Split(script, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch fields[0] {
|
||||||
|
case "function", "for", "switch", "if":
|
||||||
|
depth++
|
||||||
|
case "end":
|
||||||
|
depth--
|
||||||
|
if depth < 0 {
|
||||||
|
t.Fatal("fish completion closes a block before one is opened")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth != 0 {
|
||||||
|
t.Errorf("fish completion has %d unclosed blocks", depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNativeShellSyntax(t *testing.T, shell, script string) {
|
||||||
|
t.Helper()
|
||||||
|
path, err := exec.LookPath(shell)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("%s is not installed", shell)
|
||||||
|
}
|
||||||
|
command := exec.Command(path, "-n")
|
||||||
|
command.Stdin = strings.NewReader(script)
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("%s syntax check failed: %v\n%s", shell, err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||||
|
contexts := completionContexts(completionRoot)
|
||||||
|
byPath := make(map[string][]string, len(contexts))
|
||||||
|
for _, context := range contexts {
|
||||||
|
byPath[context.path] = context.candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
|
||||||
|
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
|
||||||
|
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||||
|
t.Helper()
|
||||||
|
set := make(map[string]bool, len(got))
|
||||||
|
for _, candidate := range got {
|
||||||
|
set[candidate] = true
|
||||||
|
}
|
||||||
|
for _, want := range wants {
|
||||||
|
if !set[want] {
|
||||||
|
t.Errorf("candidates %v do not contain %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,11 @@ func runContext(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
|||||||
return writeAppError(stderr, err.Error(), exitCrash)
|
return writeAppError(stderr, err.Error(), exitCrash)
|
||||||
}
|
}
|
||||||
report, err := contextreport.Build(contextreport.Options{
|
report, err := contextreport.Build(contextreport.Options{
|
||||||
WorkspaceRoot: workspaceRoot,
|
WorkspaceRoot: workspaceRoot,
|
||||||
Provider: resolved.Provider,
|
Provider: resolved.Provider,
|
||||||
Registry: newCoreRegistry(workspaceRoot),
|
Registry: newCoreRegistry(workspaceRoot),
|
||||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||||
|
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return writeAppError(stderr, err.Error(), exitCrash)
|
return writeAppError(stderr, err.Error(), exitCrash)
|
||||||
|
|||||||
+52
-10
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -160,20 +161,61 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
|||||||
cmd.Stdout = logFile
|
cmd.Stdout = logFile
|
||||||
cmd.Stderr = logFile
|
cmd.Stderr = logFile
|
||||||
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
|
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)
|
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)
|
var errDaemonStartTimeout = errors.New("daemon did not come up within timeout")
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if daemonReachable(paths) {
|
func startAndAwaitDaemonProcess(cmd *exec.Cmd, reachable func() bool, timeout time.Duration, pollInterval time.Duration) error {
|
||||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
if err := cmd.Start(); err != nil {
|
||||||
return exitSuccess
|
return err
|
||||||
}
|
|
||||||
time.Sleep(25 * time.Millisecond)
|
|
||||||
}
|
}
|
||||||
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 {
|
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/background"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
// 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())
|
registry := newCoreRegistry(t.TempDir())
|
||||||
// newCoreRegistry holds only built-ins; none implement Deferred().
|
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 2 {
|
||||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
|
t.Fatalf("deferredEligibleCount(core) = %d, want at least 2 optional built-ins", got)
|
||||||
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -138,6 +139,7 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
|
|||||||
}
|
}
|
||||||
if info.Hash != "" {
|
if info.Hash != "" {
|
||||||
lines = append(lines, " hash: "+info.Hash)
|
lines = append(lines, " hash: "+info.Hash)
|
||||||
|
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
||||||
}
|
}
|
||||||
lines = append(lines, " path: "+info.Skill.Path)
|
lines = append(lines, " path: "+info.Skill.Path)
|
||||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||||
@@ -252,6 +254,115 @@ func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writ
|
|||||||
return exitSuccess
|
return exitSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
|
id, options, help, err := parsePluginInfoArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
if help {
|
||||||
|
if err := writePluginInfoHelp(stdout); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
return writeExecUsageError(stderr, "usage: zero plugins info <id> [--user] [--json]")
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, err := deps.getwd()
|
||||||
|
if err != nil {
|
||||||
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
||||||
|
}
|
||||||
|
info, err := plugins.Info(plugins.InfoOptions{
|
||||||
|
LoadOptions: pluginLoadOptions(deps, cwd, options.user),
|
||||||
|
}, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, plugins.ErrNotInstalled) {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.json {
|
||||||
|
if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin := info.Plugin
|
||||||
|
lines := []string{fmt.Sprintf("%s (%s) %s", plugin.ID, plugin.Name, plugin.Version)}
|
||||||
|
state := "enabled"
|
||||||
|
if !plugin.Enabled {
|
||||||
|
state = "disabled"
|
||||||
|
}
|
||||||
|
lines = append(lines, " state: "+state)
|
||||||
|
lines = append(lines, " source: "+string(plugin.Source))
|
||||||
|
if len(plugin.Tools) > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf(" tools: %d", len(plugin.Tools)))
|
||||||
|
}
|
||||||
|
if len(plugin.Hooks) > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf(" hooks: %d", len(plugin.Hooks)))
|
||||||
|
}
|
||||||
|
if len(plugin.Skills) > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf(" skills: %d", len(plugin.Skills)))
|
||||||
|
}
|
||||||
|
if len(plugin.Prompts) > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf(" prompts: %d", len(plugin.Prompts)))
|
||||||
|
}
|
||||||
|
if info.LockSource != "" {
|
||||||
|
lines = append(lines, " lock source: "+info.LockSource)
|
||||||
|
}
|
||||||
|
if info.LockHash != "" {
|
||||||
|
lines = append(lines, " lock hash: "+info.LockHash)
|
||||||
|
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
||||||
|
}
|
||||||
|
lines = append(lines, " manifest: "+plugin.ManifestPath)
|
||||||
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
type pluginInfoOptions struct {
|
||||||
|
json bool
|
||||||
|
user bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePluginInfoArgs(args []string) (string, pluginInfoOptions, bool, error) {
|
||||||
|
options := pluginInfoOptions{}
|
||||||
|
id := ""
|
||||||
|
for _, arg := range args {
|
||||||
|
switch arg {
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
return "", options, true, nil
|
||||||
|
case "--json":
|
||||||
|
options.json = true
|
||||||
|
case "--user":
|
||||||
|
options.user = true
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(arg, "-") {
|
||||||
|
return "", options, false, execUsageError{fmt.Sprintf("unknown plugins info flag %q", arg)}
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
return "", options, false, execUsageError{"plugins info takes a single id"}
|
||||||
|
}
|
||||||
|
id = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id, options, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginLoadOptions(deps appDeps, cwd string, userOnly bool) plugins.LoadOptions {
|
||||||
|
opts := plugins.LoadOptions{Cwd: cwd, ExcludeProject: userOnly}
|
||||||
|
roots := []plugins.Root{{Source: plugins.SourceUser, Path: deps.pluginsDir()}}
|
||||||
|
if !userOnly {
|
||||||
|
roots = append(roots, plugins.Root{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")})
|
||||||
|
}
|
||||||
|
opts.Roots = roots
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
// --- tools make / list ---
|
// --- tools make / list ---
|
||||||
|
|
||||||
func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
@@ -567,6 +678,18 @@ Removes an installed plugin directory and its plugins.lock entry.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writePluginInfoHelp(w io.Writer) error {
|
||||||
|
_, err := fmt.Fprint(w, `Usage:
|
||||||
|
zero plugins info <id> [flags]
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--user Target only user plugins (ignore ./.zero/plugins)
|
||||||
|
--json Print plugin info as JSON
|
||||||
|
-h, --help Show this help
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func writeToolsHelp(w io.Writer) error {
|
func writeToolsHelp(w io.Writer) error {
|
||||||
_, err := fmt.Fprint(w, `Usage:
|
_, err := fmt.Fprint(w, `Usage:
|
||||||
zero tools <command>
|
zero tools <command>
|
||||||
|
|||||||
Loaded 100 of 695 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user