Compare commits

..
Author SHA1 Message Date
Kevin Codex b549f5928f refactor(config): drop the unreachable SetNotify wrapper
SetNotify was kept in #1001 as a thin replace-form wrapper over
UpdateNotify for existing tests, but every product surface (the CLI
`zero config notify` and the TUI /notify picker) calls UpdateNotify's
merge form directly, so the advisory deadcode job reports it as
unreachable. Move the replace helper into writer_test.go as the
unexported setNotify, keep every test it served unchanged in behaviour,
and point the two CLI doc comments at UpdateNotify.

`make deadcode` no longer lists internal/config; the other advisory
findings in that job are pre-existing and untouched.
2026-10-14 15:58:35 +08:00
996 changed files with 3079 additions and 86505 deletions

No files matched your search

+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Twigpine/zero/discussions/categories/q-a
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Twigpine/zero/discussions/categories/ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Twigpine/zero/security/advisories/new
url: https://github.com/Gitlawb/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
+55 -23
View File
@@ -100,21 +100,19 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm package named in package.json (@twigpine/zero): first
# the five platform payloads (versions of the SAME package at suffixed
# versions like X.Y.Z-linux-x64, carrying the native binary + vendored
# helpers), then the wrapper at X.Y.Z whose optionalDependencies alias those
# platform versions. Platform versions MUST publish first — the wrapper pins
# them exactly, so the reverse order would open a window where installs
# resolve aliases that 404. See docs/NPM_PACKAGING.md for the full contract.
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file + the npm-publish
# environment as a trusted publisher, and provenance is then generated
# automatically. npm checks provenance against package.json `repository`,
# so that must name this repository. Note npm only allows configuring a
# trusted publisher on an EXISTING package, so a new package name starts
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
@@ -127,7 +125,7 @@ jobs:
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@twigpine/zero
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
@@ -203,13 +201,47 @@ jobs:
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform payloads first under the `platform` dist-tag, then the wrapper,
# with the `latest` dist-tag checked before and after (platform versions
# are semver PRERELEASES of the wrapper version and must never become
# `latest`). npm applies publishes asynchronously, so the script waits
# until npm shows every platform version before the wrapper goes out,
# and until `latest` moves after it. Versions already on the registry
# are skipped, so "Re-run failed jobs" is safe after a partial publish.
- name: Publish npm packages
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
shell: bash
run: scripts/npm/publish-packages.sh dist/npm
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
+1 -1
View File
@@ -1 +1 @@
{".":"0.12.0"}
{".":"0.9.0"}
-88
View File
@@ -6,94 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
### Features
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
### Bug Fixes
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
### Features
* **providers:** add Concentrate provider preset ([#1147](https://github.com/Twigpine/zero/issues/1147)) ([61b0936](https://github.com/Twigpine/zero/commit/61b09364f0f45b0fa8aebaf0a6c3d1c55dbe01c3))
### Bug Fixes
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
### Performance Improvements
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
## [0.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
### Features
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
### Bug Fixes
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
+5 -5
View File
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
@@ -41,7 +41,7 @@ There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Propose a feature in
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
@@ -193,7 +193,7 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
@@ -222,7 +222,7 @@ contribution phase.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a).
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+19 -119
View File
@@ -12,10 +12,9 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -88,18 +87,9 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
@@ -126,7 +116,7 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -140,38 +130,26 @@ matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
0.10.0 and the old name gets no further releases. Both packages provide the
`zero` command, so quit any running `zero`, then remove the old package first:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
Your config, sessions, and logins are kept; they live outside the package. For
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
### Install scripts
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### From source
Source builds require Go 1.26.10+.
Source builds require Go 1.26.9+.
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -183,18 +161,17 @@ If you are testing before the first public release, build from source:
go build -o zero ./cmd/zero
```
On Linux, native sandboxing needs Bubblewrap. The main `zero` binary re-executes
itself as the helper when `zero-linux-sandbox` is absent. A separate helper next
to `zero` or on `PATH` still takes precedence:
On Linux, build the sandbox helper too if you want native sandboxing:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` on `PATH` (`~/.local/bin` is a good default). macOS does not need an
extra helper binary. Windows source builds can use the main `zero.exe` as their
sandbox helper; release archives still ship standalone Windows helper executables.
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
Windows source builds can use the main `zero.exe` as their sandbox helper; release
archives still ship standalone Windows helper executables.
More install details: [docs/INSTALL.md](docs/INSTALL.md).
@@ -227,7 +204,6 @@ export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -256,12 +232,6 @@ To configure GMI Cloud directly, run:
zero providers setup gmicloud --set-active
```
To configure Concentrate directly, run:
```bash
zero providers setup concentrate --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -397,76 +367,6 @@ and can rewrite every request through it, so this is the trust you accept by
configuring one. Leave the variables unset for the checks to be enforced end to
end.
The built-in `web_search` tool is registered only when a search backend is
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
base URL, or the AI/ML API Toolbox:
```bash
export ZERO_WEBSEARCH_PROVIDER=aimlapi
```
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
billed to your AI/ML API balance: about $0.0013 per result, with a documented
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
which the Toolbox enforces. It is used only when
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
enables it. The key is read from your user configuration only; provider
profiles in a project `.zero` config are not used. It is the aimlapi profile's
key (the active profile when it is aimlapi, otherwise the first aimlapi
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
aimlapi profile exists. It is resolved again on every search;
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
that reads `AIMLAPI_API_KEY`, points at a base URL other than
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
may belong to a proxy, so it is not sent and the search fails without a request.
Requests go only to
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
`include_domains`, and results are still filtered locally.
### AI/ML API Toolbox backend for `web_fetch`
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
```bash
export ZERO_WEBFETCH_PROVIDER=aimlapi
```
Having an AI/ML API key never switches it on by itself, and any other value (or
none) keeps the direct fetch. The variable is read on every fetch.
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
priced above that is refused before it starts and not charged. Rate-limited
or gateway-failed requests are retried with the same idempotency key, so a
retry is never billed twice.
- **Key:** the key comes from your `aimlapi` provider profile (including a key
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
the key may then belong to a proxy.
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
endpoint, with `Authorization: Bearer <key>` and the attribution headers
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
aimlapi.com calls.
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
returns an error and does not fetch the page directly. Unset the variable to
go back to direct fetches.
- **Remote fetch:** the page is fetched by a remote service, which may serve a
cached copy, and the full URL (query string included) is sent to it. The URL
checks above still run first, so loopback, private and metadata URLs are
never handed to it. The result is Markdown with a
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
and `format: "raw"` is refused because the original response is not
available. The complete Toolbox run response is limited to 8 MiB before JSON
decoding; larger responses return an error even if `max_bytes` is smaller.
The remote run may already have been billed when this limit is detected.
For local dev servers, use shell commands such as `curl` through `exec_command`
so the normal sandbox and permission policy applies. Long-running commands stay
attached to a background terminal session and can be listed or stopped from the
@@ -626,19 +526,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Twigpine/zero/issues/new/choose); security reports
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
+15 -41
View File
@@ -12,10 +12,9 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -86,18 +85,9 @@ zero exec --output-format stream-json < turns.jsonl
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
@@ -115,41 +105,32 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.26.10+。
源码构建需要 Go 1.26.9+。
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -160,14 +141,14 @@ go run ./cmd/zero
go build -o zero ./cmd/zero
```
在 Linux 上,原生沙箱需要 Bubblewrap。主程序 `zero` 在找不到 `zero-linux-sandbox` 时会以自身作为辅助进程重新执行。同目录或 PATH 上的独立 helper 仍优先:
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # 可选
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 放在 `PATH` 上(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
@@ -196,7 +177,6 @@ export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
@@ -211,12 +191,6 @@ zero providers setup longcat --set-active
zero providers setup gmicloud --set-active
```
要直接配置 Concentrate,运行:
```bash
zero providers setup concentrate --set-active
```
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
@@ -384,14 +358,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
| [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) | 维护者发布的版本和项目动态 |
## 贡献
+1 -13
View File
@@ -33,16 +33,4 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
non-empty user Authorization header is configured, OAuth is selected, project
config has touched the server, or the key is missing or not a single token
(including Unicode whitespace). See
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
+5 -5
View File
@@ -2,8 +2,8 @@
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Twigpine/zero/discussions). Use
[Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) so answers can
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
@@ -18,19 +18,19 @@ The fastest way to a useful answer is to include:
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Twigpine/zero/issues/new/choose). If you are not
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas). Zero is
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Twigpine/zero/security/advisories/new)
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
(see SECURITY.md).
+2 -2
View File
@@ -2,7 +2,7 @@ name: ZERO
description: >-
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
model- and provider-agnostic: you supply the provider and API key as inputs.
author: Twigpine
author: Gitlawb
branding:
icon: terminal
@@ -110,7 +110,7 @@ inputs:
zero-repo:
description: Repository to install the ZERO release from (owner/repo).
required: false
default: Twigpine/zero
default: Gitlawb/zero
outputs:
exit-code:
+13 -63
View File
@@ -1,63 +1,11 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
// release was also published once under the old name, so 0.9.0's `zero
// upgrade` and anyone still installing the old name land on a working build
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
// published under the old name after that.
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
// The npm name this wrapper was published under, read from its own
// package.json so the name lives in one place. The platform payload alias and
// every reinstall hint below derive from it.
function readPackageName(root) {
try {
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
if (typeof name === 'string' && name !== '') return name;
} catch {
// An unreadable manifest must not stop the CLI from launching.
}
return CURRENT_PACKAGE_NAME;
}
const packageName = readPackageName(packageRoot);
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
// Uninstalling first is the only safe order: both packages own the `zero` bin,
// so npm refuses to install the new one over the old (EEXIST), and forcing it
// lets a later uninstall of the old package delete the shared bin.
function legacyPackageNotice() {
return (
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
'To keep getting updates, quit zero and run:\n' +
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
);
}
// Printed when the wrapper exits rather than before zero starts, because the
// TUI takes over the screen and would hide anything printed first. Only on a
// terminal, so scripts that capture stderr see nothing new. `zero update` and
// `zero upgrade` print the same note themselves.
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
process.on('exit', () => {
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
// Synchronous: an exit handler cannot wait for an asynchronous write.
writeSync(2, legacyPackageNotice());
}
});
}
function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
@@ -133,10 +81,10 @@ function bunRecoveryParagraph() {
return (
'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
` bun pm trust ${packageName} (project install)\n` +
` bun pm -g trust ${packageName} (global install)\n` +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
` "trustedDependencies": ["${packageName}"]\n` +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
);
@@ -145,14 +93,14 @@ function bunRecoveryParagraph() {
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
`Normally npm installs it as an optional dependency of ${packageName}\n` +
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
@@ -165,7 +113,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
` npm install -g ${packageName}\n` +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
@@ -234,12 +182,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
);
}
// The platform payload is a version of this same package, installed under an
// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `${packageName}-${process.platform}-${process.arch}`;
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
@@ -279,6 +227,8 @@ function downloadMissingBinary(packageRoot) {
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
// taskOptions configures the `zero-perf-bench tasks` subcommand: the reproducible
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
)
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/Twigpine/zero/internal/review"
"github.com/Gitlawb/zero/internal/review"
)
func main() {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/release"
"github.com/Gitlawb/zero/internal/release"
)
func main() {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"os/exec"
"syscall"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -5
View File
@@ -3,13 +3,9 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/cli"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/cli"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == sandbox.LinuxSandboxHelperSubcommand {
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
}
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+2 -65
View File
@@ -110,8 +110,6 @@ Discovery roots (earlier wins on name collisions):
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.
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
```text
~/.local/share/zero/skills/ # primary (install/remove target)
run-benchmarks/
@@ -142,15 +140,6 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
```text
pdf/
SKILL.md
scripts/fill.py
references/form-fields.md
```
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
@@ -203,11 +192,7 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
@@ -261,52 +246,6 @@ or use a new server name. OAuth tokens are bound to the resolved server identity
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
```bash
@@ -351,12 +290,10 @@ Install and manage:
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the `enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
with:
prompt: ${{ inputs.task }}
provider: openai
@@ -66,7 +66,7 @@ jobs:
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
## Outputs
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
with:
prompt: |
Investigate this issue and propose a fix.
@@ -134,7 +134,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
id: zero
with:
prompt-file: .github/zero/upgrade-deps.md
@@ -176,7 +176,7 @@ jobs:
- **Least-privilege tokens.** Grant only the permissions the workflow needs
(`contents: write` to edit files, `pull-requests: write` to comment). The
default `GITHUB_TOKEN` is scoped to the repository.
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
workflow run uses a known ZERO version. `zero-version` lets you pin the
installed binary independently of the action ref.
- **Linux and macOS runners** are supported; Windows runners are rejected with a
-4
View File
@@ -634,10 +634,6 @@ Core tool groups include:
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
target itself, so destination enforcement ends at the proxy; see the note in
the README.
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
it), the same URL checks run and the page is then fetched as Markdown by the
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
direct fetch; see the README.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
+30 -78
View File
@@ -5,11 +5,11 @@ blessed one.
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
| 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 |
@@ -29,7 +29,7 @@ Release and verify its checksum.
## npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -56,47 +56,15 @@ cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
```
### Moving from @gitlawb/zero
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
`@twigpine/zero`, and the old name gets no further releases.
Both packages provide the `zero` command, so npm refuses to install the new
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
uninstall of the old package removes the shared `zero` command. Quit any
running `zero` (on Windows a running `zero.exe` cannot be removed), then
uninstall the old package before installing the new one:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
With another package manager, the same two steps:
```bash
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
```
Config, sessions, logins, and plugins live in your user config and home
directories, not in the package, so they carry over unchanged.
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
updates lands on a working build. That build prints these steps when `zero`
exits in a terminal, and `zero upgrade` and `zero update --check` print them
too.
## Linux And macOS Script
Install the latest release:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -121,7 +89,7 @@ scripts/install.sh --install-dir "$HOME/bin"
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
@@ -132,7 +100,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -155,7 +123,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
@@ -166,13 +134,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Twigpine/zero
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Twigpine/[email protected]
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
@@ -185,19 +153,13 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
go install github.com/Gitlawb/zero/cmd/zero@latest
```
Every release so far declares the module path `github.com/Gitlawb/zero`, so
`go install` has to use that path; `github.com/Twigpine/zero/cmd/zero@latest`
fails with "module declares its path as" until a release is tagged with the new
module path.
This builds from source, so it needs Go 1.26.10+ and it does not go through the
This builds from source, so it needs Go 1.26.9+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
the helper in this order: an executable `zero-linux-sandbox` next to `zero`,
then `PATH`, then self-exec of the main binary. Standalone helpers remain
optional and preferred. See
- 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.
@@ -207,7 +169,7 @@ macOS and Windows need no extra helper.
## From Source
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -218,32 +180,25 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.10+.
Source builds require Go 1.26.9+.
### Sandbox Helpers For Source Builds
Release archives include the platform sandbox helpers. A source build of the
main `zero` binary is enough for Linux native sandboxing. Zero resolves the
helper in this order:
Release archives include the platform sandbox helpers. If you build directly
from source, build the helpers you need:
1. an executable `zero-linux-sandbox` next to `zero`
2. then `PATH`
3. then self-exec of the main binary
Bubblewrap must still be installed. Standalone helpers remain optional and
preferred.
A separate helper remains supported if it sits next to `zero` or on `PATH`:
Linux:
```bash
go build -o zero ./cmd/zero
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp
```
Put `zero` on `PATH`, for example `~/.local/bin`. `zero-seccomp` is kept as a
compatibility wrapper; the sandbox helper applies the Unix-socket filter itself
when that sandbox option is enabled.
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
helper applies the Unix-socket filter itself when that sandbox option is enabled.
Linux native sandboxing also requires Bubblewrap to be installed.
macOS uses the system sandbox and does not need an extra helper binary.
@@ -258,7 +213,7 @@ seccomp filter on Android:
pkg install golang
# Build Zero for Android
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
@@ -347,10 +302,7 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
the old `@gitlawb/zero` package refuses instead and prints the steps in
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
further releases, so npm would only reinstall the same version.
- **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`.
+18 -77
View File
@@ -1,6 +1,6 @@
# npm packaging
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
## Goals
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
@@ -25,15 +25,15 @@ The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@twigpine/zero` package**,
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
### Binary resolution in `bin/zero.js`
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
@@ -103,22 +103,10 @@ dependencies of the wrapper:
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
### One package name
The npm name lives in `package.json` and nothing else repeats it:
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
versions, the alias names, and the alias targets from it, and `bin/zero.js`
reads its own `package.json` to derive the alias it resolves. The Go side
needs the name as a constant (`npmPackageName` in
`internal/update/installmethod.go`), and a test holds it equal to
`package.json`.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
registry.
one is user-visible immediately.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
@@ -130,67 +118,20 @@ registry.
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions are on the registry before the wrapper.** The
wrapper's `optionalDependencies` pin exact suffixed versions; publishing
the wrapper first would create a window where installs resolve aliases
that 404. Ordering the uploads is not enough: npm applies a publish
asynchronously, and in 0.10.0 a platform version appeared ten minutes
after its upload returned, after the wrapper. The publish script waits
until npm shows every platform version before it publishes the wrapper,
and waits the same way for `latest` afterwards (up to 30 minutes in all).
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew. The publish script refuses to start when
the wrapper's aliases do not match the platform payloads beside it.
and its binaries can never skew.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration. npm checks provenance against
`package.json` `repository`, so it must name the repository the workflow
runs in.
5. **Re-runs are safe.** A version already on the registry is skipped rather
than republished, so re-running a job that failed between the platform and
wrapper publishes finishes the release. Only npm's E404 counts as "not
published"; a lookup that fails any other way is retried and then stops the
job before anything is published.
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@twigpine/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
## The move from `@gitlawb/zero`
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
from 0.10.0. There is no in-place migration: both packages own the `zero`
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
lets a later uninstall of the old package delete the shared bin. The user
has to uninstall the old package and install the new one, in that order.
Deprecating the old package alone would not have reached existing users.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name, npm reinstalls nothing, prints no deprecation
warning (it only warns when it installs something), and Zero reports the
update as done. So 0.10.0 was published once more under the old name, as a
bridge:
- The release job assembled the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and published
them with the same script, after the `@twigpine/zero` publish. The steps
that did this ran once and have been removed from `release-artifacts.yml`.
- The bridge build knows its name from the `package.json` beside it. The Go
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
with the switch steps instead of reinstalling it, and `zero update --check`
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
terminal only. This handling stays in the code, since only a build
installed under the old name ever triggers it.
- Updating from 0.9.0 installs that bridge build, so npm also shows the
deprecation message.
`@gitlawb/zero` is deprecated on every version and no longer has a trusted
publisher, so this release workflow cannot publish it again; another version
would take a maintainer publishing by hand. Its versions stay on the
registry: existing installs resolve their platform payload through them, so
they must never be unpublished.
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
## Runbook: bumping the vendored helpers
+9 -10
View File
@@ -2,8 +2,7 @@
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
`bin` wrapper.
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -38,24 +37,24 @@ path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@twigpine
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@twigpine/zero`), version,
- `package.json` has the expected package name (`@gitlawb/zero`), version,
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@twigpine/zero@<version>-<platform>-<arch>` with matching
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
+4 -8
View File
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Twigpine/zero
zero update --check --repo Gitlawb/zero
zero update --check --target windows-x64
zero upgrade
@@ -28,11 +28,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
of the old `@gitlawb/zero` package refuses, without a release check, and
prints the steps to switch to `@twigpine/zero` (see
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
too.
- 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.
@@ -42,7 +38,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--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 @twigpine/zero@latest` and
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
@@ -69,7 +65,7 @@ Endpoint resolution order:
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Twigpine/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
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/Twigpine/zero
module github.com/Gitlawb/zero
go 1.26.10
go 1.26.9
require (
charm.land/bubbles/v2 v2.1.1
+26 -84
View File
@@ -13,18 +13,17 @@ import (
"strings"
"sync"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/providercatalog"
"github.com/Twigpine/zero/internal/providermodelcatalog"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/usage"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Deps are the ZERO capabilities the ACP Agent drives. The CLI fills these with
@@ -214,7 +213,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false, agent.PermissionModeAuto)
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
@@ -330,7 +329,7 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
// Resume has already refused a non-resumable kind above, so only load can
// reach here with one, and it publishes that session for viewing only.
readOnly := !sessions.IsResumableKind(meta.SessionKind)
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly, restoredPermissionMode(meta.PermissionMode))
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
if existed {
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
if operation == persistedSessionResume && historyErr != nil {
@@ -528,6 +527,8 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
queue := func(input sessions.AppendEventInput) {
pendingEvents = append(pendingEvents, input)
}
queue(messageEvent("user", userText))
visionCache := make(map[string]bool)
var visionCacheMu sync.Mutex
supportsVision := func(modelID string) bool {
@@ -551,15 +552,11 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
images = nil
}
// Match the TUI: only images actually sent to the model enter history.
attachments := sessions.MessageAttachments{Images: len(images)}
queue(messageEvent("user", userText, attachments))
opts := agent.Options{
Cwd: sess.cwd,
SessionID: sess.id,
ProviderName: resolved.Provider.Name,
ModelFamily: providercatalog.ModelFamilyFor(resolved.Provider.CatalogID),
Model: resolved.Provider.Model,
Registry: registry,
Sandbox: sandboxEngine,
@@ -570,12 +567,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
SupportsVision: supportsVision,
OnText: note.text,
OnReasoning: note.thought,
// Persist token usage the same way `zero exec` does, so `zero usage report`
// sees ACP sessions too. It joins the turn's buffered batch: a hard run
// failure that aborts the turn drops its usage together with its history.
OnUsage: func(u agent.Usage) {
queue(usageEvent(u))
},
OnToolCall: func(call agent.ToolCall) {
queue(toolCallEvent(call))
note.toolCall(call)
@@ -595,7 +586,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
if result.FinalAnswer != "" {
queue(messageEvent("assistant", result.FinalAnswer, sessions.MessageAttachments{}))
queue(messageEvent("assistant", result.FinalAnswer))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
@@ -616,7 +607,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
// one file write, which is the contiguity restored here — not a disk
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
persistenceErr := a.persistTurn(sess.id, pendingEvents)
sess.appendHistory(turnRecord{user: attachments.WithContent(userText), assistant: result.FinalAnswer})
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
a.warnPersistence(
note,
"save session history",
@@ -704,9 +695,7 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
@@ -742,9 +731,7 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
@@ -786,35 +773,6 @@ func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool)
return nil
}
// updateMode persists the mode BEFORE applying it, so a failed write fails the
// request instead of leaving disk behind memory; the same order updateModel
// uses. The caller holds sess.turnMu (both mode doors take it and it is not
// reentrant, so this must not lock it again); the write under turnMu briefly
// blocks that session's prompts, which is what makes the store authoritative.
func (a *Agent) updateMode(sess *acpSession, mode agent.PermissionMode) error {
if _, err := a.deps.Store.UpdateMode(sess.id, string(mode)); err != nil {
return RPCError(codeInternalError, "save mode selection: "+err.Error())
}
sess.setMode(mode)
return nil
}
// restoredPermissionMode maps a persisted mode onto the modes ACP offers. A
// session written before modes were persisted has none and keeps the historical
// auto default. A stored value outside the three is never allowed to come back
// weaker than ask, and unsafe can never be reached because ACP refuses to set
// it in the first place.
func restoredPermissionMode(saved string) agent.PermissionMode {
switch mode := agent.PermissionMode(strings.TrimSpace(saved)); mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
return mode
case "":
return agent.PermissionModeAuto
default:
return agent.PermissionModeAsk
}
}
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
var p CancelParams
if err := json.Unmarshal(params, &p); err != nil {
@@ -932,23 +890,10 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput
return err
}
func messageEvent(role, content string, attachments sessions.MessageAttachments) sessions.AppendEventInput {
payload := map[string]any{"role": role, "content": content}
if role == "user" && (attachments.Images > 0 || attachments.Documents > 0) {
payload["attachments"] = attachments
}
func messageEvent(role, content string) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: payload,
}
}
// usageEvent records one provider usage report in the same persisted shape the
// exec runtime writes (usage.EventUsagePayload is the single writer).
func usageEvent(u agent.Usage) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventUsage,
Payload: usage.EventUsagePayload(u),
Payload: map[string]any{"role": role, "content": content},
}
}
@@ -1100,16 +1045,14 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
continue
}
var msg struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments sessions.MessageAttachments `json:"attachments"`
Role string `json:"role"`
Content string `json:"content"`
}
if json.Unmarshal(raw, &msg) != nil {
continue
}
switch msg.Role {
case "user":
msg.Content = msg.Attachments.WithContent(msg.Content)
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
if havePending {
records = append(records, turnRecord{user: pendingUser})
@@ -1267,15 +1210,14 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// session is returned rather than orphaning its turn or resetting its mode/model.
// The bool reports that case so persisted activation can re-read and apply disk
// history under turnMu. history is set BEFORE first publication so no concurrent
// prompt can read a half-initialized session. mode is the session's initial
// permission mode: auto for a new session, the restored value for load/resume.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool, mode agent.PermissionMode) (*acpSession, bool) {
// prompt can read a half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing, true
}
sess := &acpSession{id: id, cwd: cwd, mode: mode, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
a.sessions[id] = sess
return sess, false
}
+8 -12
View File
@@ -19,13 +19,13 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// fakeProvider streams a canned assistant message and ends the turn — enough to
@@ -960,11 +960,7 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID,
Prompt: []ContentBlock{
TextBlock("failed question"),
ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"),
},
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"fmt"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
-181
View File
@@ -1,181 +0,0 @@
package acp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestACPImageAttachmentHistory(t *testing.T) {
for _, tc := range []struct {
name string
text string
images int
withoutVision bool
want string
}{
{name: "image only", images: 1, want: "[1 image]"},
{name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"},
{name: "ignored by model", images: 1, withoutVision: true, want: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
modalities := []string{"text", "image"}
if tc.withoutVision {
modalities = []string{"text"}
}
return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil
}
type observedTurn struct {
prompt string
images int
}
turns := make(chan observedTurn, 8)
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
turns <- observedTurn{prompt: prompt, images: len(opts.Images)}
return agent.Result{FinalAnswer: "received"}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
h := newHarness(t, deps)
defer h.stop()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
const imageData = "image-history-bytes-not-for-storage"
encoded := base64.StdEncoding.EncodeToString([]byte(imageData))
var blocks []ContentBlock
if tc.text != "" {
blocks = append(blocks, TextBlock(tc.text))
}
for range tc.images {
blocks = append(blocks, ImageBlock(encoded, "image/png"))
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil {
t.Fatal(err)
}
live := <-turns
wantLiveImages := tc.images
if tc.withoutVision {
wantLiveImages = 0
}
if live.images != wantLiveImages {
t.Fatalf("live images = %d, want %d", live.images, wantLiveImages)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil || len(events) < 2 {
t.Fatalf("read completed turn: events=%+v err=%v", events, err)
}
var user struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments struct {
Images int `json:"images"`
} `json:"attachments"`
}
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
t.Fatal(err)
}
if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages {
t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages)
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) {
t.Errorf("image data leaked into session event: %s", event.Payload)
}
}
followUp := func(t *testing.T, client *clientHarness) {
t.Helper()
if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")},
}, &PromptResult{}); err != nil {
t.Fatal(err)
}
got := <-turns
if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) {
t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt)
}
if got.images != 0 {
t.Errorf("historical images reattached: %d", got.images)
}
}
followUp(t, h)
h.stop()
// Fresh agents and stores reproduce a process restart, with no shared
// in-memory history to hide a missing attachment in the event log.
for _, method := range []string{MethodSessionLoad, MethodSessionResume} {
t.Run(method, func(t *testing.T) {
restoredDeps := deps
restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir})
loader := newHarness(t, restoredDeps)
defer loader.stop()
if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil {
t.Fatal(err)
}
if method == MethodSessionLoad {
select {
case update := <-loader.notifications:
if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want {
t.Errorf("replayed user = %+v, want %q", update, tc.want)
}
case <-ctx.Done():
t.Fatal("user turn was not replayed")
}
} else {
select {
case update := <-loader.notifications:
t.Errorf("resume replayed transcript: %+v", update)
default:
}
}
followUp(t, loader)
})
}
})
}
}
func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()})
if err != nil {
t.Fatal(err)
}
for i, payload := range []string{
`{"role":"user","content":"old text"}`,
`{"role":"user","content":"","attachments":{"images":1,"documents":2}}`,
`{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`,
`{"role":"assistant","content":"answer","attachments":{"images":1}}`,
} {
if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil {
t.Fatalf("append message %d: %v", i, err)
}
}
a := &Agent{deps: deps}
_, messages, _, err := a.loadHistory(meta.SessionID, false)
if err != nil {
t.Fatal(err)
}
want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"}
if len(messages) != len(want) {
t.Fatalf("messages = %+v", messages)
}
for i, text := range want {
if messages[i].content != text {
t.Errorf("message %d = %q, want %q", i, messages[i].content, text)
}
}
}
-213
View File
@@ -1,213 +0,0 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// captureAgentOptions makes every turn record the agent.Options it was built
// with, so a test can assert the permission mode the next prompt actually runs
// with rather than only what the wire reports.
func captureAgentOptions(deps *Deps) *agent.Options {
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
}
return &captured
}
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
t.Helper()
h := newHarness(t, deps)
t.Cleanup(h.stop)
return h
}
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
t.Helper()
if err := h.client.Call(ctx, method, params, result); err != nil {
t.Fatalf("%s: %v", method, err)
}
}
// restartHarness replaces the live agent with a fresh one over the same store,
// which is the process restart the persisted mode has to survive.
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
t.Helper()
h.stop()
return newHarnessT(t, deps)
}
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
t.Helper()
for _, option := range options {
if option.ID == configIDMode {
return option.CurrentValue
}
}
t.Fatalf("no mode config option in %+v", options)
return ""
}
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
t.Helper()
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
}
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
t.Fatalf("mode config option = %q, want %q", got, want)
}
}
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModePlan) {
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
}
// LOAD after a restart: the response must report the stored mode, and the
// next prompt must actually run with it.
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModePlan)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
// RESUME after another restart reports the same.
h = restartHarness(t, h, deps)
var resumed ResumeSessionResult
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
assertModeReported(t, resumed, agent.PermissionModePlan)
}
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
deps := testDeps(t)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
var configured SetSessionConfigOptionResult
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured)
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
t.Fatalf("configured mode = %q, want ask", got)
}
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModeAsk) {
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModeAsk)
}
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
// the historical auto default, and a stored value outside the three offered
// modes must never come back weaker than ask (and never unsafe).
func TestACPModeRestoreFallback(t *testing.T) {
cases := []struct {
name string
seed string // "" means metadata written before modes were persisted
want agent.PermissionMode
}{
{name: "unset keeps auto", want: agent.PermissionModeAuto},
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
if tc.seed != "" {
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
t.Fatalf("seed mode: %v", err)
}
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, tc.want)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
if captured.PermissionMode != tc.want {
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
}
})
}
}
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
// mode cannot be saved the request fails and the live session is untouched.
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
// Remove the metadata file so UpdateMode fails but the live session stays
// promptable (turn persistence only warns when the file is gone).
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
var rpcErr *rpcError
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
}
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModeAuto {
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"encoding/json"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
// permission.go maps ZERO's permission prompt model onto ACP's
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
func TestBuildPermissionOptions(t *testing.T) {
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
@@ -83,7 +83,7 @@ func browserToolDetails(name string) (*BrowserToolDetails, bool) {
}
}
const zeroBrowserMetaKey = "github.com/Twigpine/zero/browser"
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
// extension channel. Keeping this in one helper prevents start, result, and
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
-309
View File
@@ -1,309 +0,0 @@
package acp
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// `zero exec` persists every provider usage report as a session usage event, so
// `zero usage report --session <id>` can total a run. The ACP agent did not set
// OnUsage, which dropped the token counts of every ACP session. A turn with two
// provider requests (a tool round trip) must leave two usage events, in the shape
// the usage report reads, next to the turn's messages.
func TestACPPromptPersistsProviderUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: "done"}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
return agent.Result{FinalAnswer: "done"}, nil
}
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.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
}, &result); err != nil {
t.Fatalf("session/prompt: %v", err)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
type usagePayload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CachedInputTokens int `json:"cachedInputTokens"`
}
var got []usagePayload
messages := 0
for _, event := range events {
switch event.Type {
case sessions.EventUsage:
var payload usagePayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("usage payload %s: %v", event.Payload, err)
}
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
got = append(got, payload)
case sessions.EventMessage:
messages++
}
}
want := []usagePayload{
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
}
if len(got) != len(want) {
t.Fatalf("usage events = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
}
}
if messages != 2 {
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
}
}
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
// turn leaves nothing durable, exactly as it does for the turn's messages.
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, errors.New("injected hard run failure")
}
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
return agent.Result{}, errors.New("injected hard run failure")
}
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.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Fatalf("aborted turn left durable events: %+v", events)
}
}
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
// provider already reported were spent, so they stay in the session log even
// though the client cancelled. A hard error (the test above) is the path that
// drops them.
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, context.Canceled
}
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
return agent.Result{FinalAnswer: "partial"}, context.Canceled
}
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.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
}, &result); err != nil {
t.Fatalf("cancelled turn returned an RPC error: %v", err)
}
if result.StopReason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
usageEvents := 0
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
usageEvents++
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
var payload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
}
}
if usageEvents != 1 {
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
}
}
// Usage events sit in the same log session/load and session/resume read, but
// history reconstruction skips them. The wire replay stays the conversation,
// and the next turn's prompt stays the conversation: token counts never become
// context and never cross the ACP wire.
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
const userLine = "unique-acp-user-line"
const answer = "unique-acp-answer"
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: answer}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
return agent.Result{FinalAnswer: answer}, nil
}
workspace := t.TempDir()
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: workspace}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
h.stop()
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
sawUsage := false
for _, event := range events {
if event.Type == sessions.EventUsage {
sawUsage = true
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
}
}
if !sawUsage {
t.Fatal("session log has no usage event to ignore")
}
loader := newHarness(t, deps)
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
var replayed []string
for {
select {
case update := <-loader.notifications:
replayed = append(replayed, update.Content.Text)
case <-time.After(150 * time.Millisecond):
goto drained
}
}
drained:
loader.stop()
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
}
for _, text := range replayed {
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
t.Fatalf("session/load replayed usage onto the wire: %q", text)
}
}
var gotPrompt string
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
gotPrompt = prompt
return agent.Result{FinalAnswer: "continued"}, nil
}
resumer := newHarness(t, deps)
defer resumer.stop()
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &ResumeSessionResult{}); err != nil {
t.Fatalf("session/resume: %v", err)
}
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err != nil {
t.Fatalf("resumed session/prompt: %v", err)
}
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
}
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
}
}
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
// has no model escalation, and the writer must not grow a prompt, tool
// argument, or other raw field.
var usagePayloadAllowedKeys = map[string]bool{
"promptTokens": true,
"completionTokens": true,
"totalTokens": true,
"cachedInputTokens": true,
"cacheWriteTokens": true,
"reasoningTokens": true,
}
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
t.Helper()
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
t.Fatalf("usage payload %s: %v", payload, err)
}
if len(raw) == 0 {
t.Fatalf("usage payload is empty: %s", payload)
}
for key := range raw {
if !usagePayloadAllowedKeys[key] {
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
}
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// providerCallingAskUserThenAnswer returns a mock provider whose first turn calls
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/testutil"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
-34
View File
@@ -1,34 +0,0 @@
package agent
import "github.com/Twigpine/zero/internal/tools"
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
// input is normalized through the registered built-in tool, so absolute headers
// are rewritten relative to the selected write root and cwd is set: checkpoint
// capture and execution therefore agree on the files at stake.
//
// Best-effort: nil when the call has no capture targets or its arguments cannot
// be resolved. Checkpoint capture never fails a run.
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
if registry == nil {
return nil
}
args := map[string]any{}
if call.Freeform {
tool, ok := registry.Get(call.Name)
if !ok || !tools.IsBuiltInApplyPatch(tool) {
return nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil
}
}
return tools.MutationTargets(workspaceRoot, call.Name, args)
}
-99
View File
@@ -1,99 +0,0 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
func TestCheckpointTargets(t *testing.T) {
root := t.TempDir()
// Absolute headers must be compared against the physical root: t.TempDir
// differs from it on macOS (/var -> /private/var) and via Windows short names.
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
unbuiltinRegistry := tools.NewRegistry()
unbuiltinRegistry.Register(&customApplyPatchTool{})
for _, tc := range []struct {
name string
registry *tools.Registry
call ToolCall
want []string
}{
{
name: "freeform relative header",
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform absolute header stays workspace-relative",
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform multi-file patch",
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
want: []string{"main.txt", "notes.txt"},
},
{
name: "json arguments",
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
want: []string{"main.txt"},
},
{
name: "freeform traversal is rejected",
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
want: nil,
},
{
name: "freeform custom apply_patch is not prepared",
registry: unbuiltinRegistry,
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: nil,
},
{
name: "unknown freeform tool",
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
want: nil,
},
{
name: "malformed json arguments",
call: ToolCall{Name: "apply_patch", Arguments: "{"},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reg := registry
if tc.registry != nil {
reg = tc.registry
}
got := CheckpointTargets(root, reg, tc.call)
if !slices.Equal(got, tc.want) {
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
}
})
}
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
}
}
-135
View File
@@ -1,135 +0,0 @@
package agent
import (
"context"
"testing"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
)
// progressProbeTool records whether the loop handed it a progress callback.
// streams mirrors what a real tool declares via tools.ChildProgressStreamer.
type progressProbeTool struct {
name string
streams bool
got *bool
}
func (t *progressProbeTool) Name() string { return t.name }
func (t *progressProbeTool) Description() string { return "probe" }
func (t *progressProbeTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (t *progressProbeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t *progressProbeTool) Run(context.Context, map[string]any) tools.Result {
*t.got = false
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t *progressProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
*t.got = options.Progress != nil
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
// StreamsChildProgress makes this type implement tools.ChildProgressStreamer;
// the flag drives the answer. A tool that never declares at all is modelled by
// silentProbeTool below, which genuinely does not implement the interface.
func (t *progressProbeTool) StreamsChildProgress() bool { return t.streams }
// silentProbeTool does NOT implement tools.ChildProgressStreamer at all — the
// state every tool in the tree is in today except Task and orchestrate.
type silentProbeTool struct {
name string
got *bool
}
func (t *silentProbeTool) Name() string { return t.name }
func (t *silentProbeTool) Description() string { return "probe" }
func (t *silentProbeTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (t *silentProbeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t *silentProbeTool) Run(context.Context, map[string]any) tools.Result {
*t.got = false
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func (t *silentProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
*t.got = options.Progress != nil
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func runProbe(t *testing.T, tool tools.Tool, got *bool, withSink bool) bool {
t.Helper()
registry := tools.NewRegistry()
registry.Register(tool)
options := Options{}
if withSink {
options.OnToolProgress = func(string, streamjson.Event) {}
}
result, err := executeToolCall(context.Background(),
registry,
ToolCall{ID: "call_1", Name: tool.Name(), Arguments: "{}"},
PermissionModeUnsafe,
options)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status == "" {
t.Fatalf("probe produced no result")
}
return *got
}
// THE PARITY OBLIGATION for un-gating the progress path.
//
// The relationship asserted here is EQUALITY, per RULES.md §3: a tool's
// progress wiring before and after this change must be the same for every tool
// that already had it and every tool that already lacked it. Only a tool that
// newly DECLARES the interface may change.
//
// The name gate is gone, so the guarantee cannot be "Task still works" — it has
// to be stated in terms of the declaration, which is what these cases do.
func TestProgressCallbackFollowsTheDeclarationNotTheName(t *testing.T) {
t.Run("a declaring tool receives it (Task's behaviour, preserved)", func(t *testing.T) {
var got bool
// Named something other than "Task" ON PURPOSE: under the old name gate
// this case failed, which is the whole point of the change.
if !runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, true) {
t.Fatal("a tool declaring StreamsChildProgress must receive the callback")
}
})
t.Run("a tool named Task that does NOT declare gets nothing", func(t *testing.T) {
var got bool
// The inverse of the old behaviour, and the reason a name is the wrong
// key: identity is not capability.
if runProbe(t, &silentProbeTool{name: "Task", got: &got}, &got, true) {
t.Fatal("the callback must follow the declaration, not the name")
}
})
t.Run("a non-declaring tool receives nothing (every other tool, unchanged)", func(t *testing.T) {
var got bool
if runProbe(t, &silentProbeTool{name: "read_file", got: &got}, &got, true) {
t.Fatal("un-gating must not start handing a callback to tools that never had one")
}
})
t.Run("a declaring tool that answers false gets nothing", func(t *testing.T) {
var got bool
if runProbe(t, &progressProbeTool{name: "spawner", streams: false, got: &got}, &got, true) {
t.Fatal("StreamsChildProgress() == false must be honoured")
}
})
t.Run("no sink means no callback, whatever the tool declares", func(t *testing.T) {
var got bool
if runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, false) {
t.Fatal("without OnToolProgress there is nothing to forward to")
}
})
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"runtime"
"strings"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"runtime"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"unicode/utf8"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Session compaction.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction preservation.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"unicode/utf8"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stateConversation is a long enough conversation that Compact elides a middle
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"regexp"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sync/atomic"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var markerPattern = regexp.MustCompile(`msg-\d+`)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// --- Pure Compact() tests -------------------------------------------------
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// someRequestContains reports whether any message sent to the provider across all
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"encoding/json"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Context budget.
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMeasureContextSplitsByCategory(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
+2 -15
View File
@@ -2,9 +2,8 @@
package agent
import (
"github.com/Twigpine/zero/internal/specialist"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
@@ -40,15 +39,3 @@ func parsePreservedState(summaryContent string) (string, []skillEntry) {
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
// Phase 2 additivity-proof seam. The identity test uses the REAL orchestrate
// tool rather than a stub, so it exercises the actual Deferred() contract that
// enforces the posture-off constraint. internal/specialist does not import
// internal/agent, so this direction creates no cycle.
const phase2ToolName = specialist.OrchestrateToolName
// registerPhase2ToolForTest registers the real tool with the posture OFF, which
// is the condition the identity test is about.
func registerPhase2ToolForTest(registry *tools.Registry) {
registry.Register(&specialist.OrchestrateTool{PostureActive: func() bool { return false }})
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/Twigpine/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
+4 -436
View File
@@ -1,11 +1,10 @@
package agent
import (
"regexp"
"strconv"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Guardrail thresholds for the agent loop. These keep a runaway model from
@@ -169,24 +168,7 @@ var selfReportPhrases = []string{
var inabilityStems = []string{
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
"i was not able to", "i do not have", "i don't have",
"we are unable to", "we were unable to",
// THE SUBJECTLESS STEM IS KEPT, and the heading it fired on is handled where
// the heading is, not by deleting the stem.
//
// It was removed once because a completed audit's own section heading —
// "**Unable to verify (1):** - MCP #3 claim was truncated" — reads as an
// admission. But it is the ONLY stem that catches an admission with no
// first-person subject, and removing it lost every one of those:
//
// "Unable to complete the task; the build never succeeded."
// "The agent was unable to finish the migration."
// "Unable to verify the fix, so the change is unverified."
//
// None name "i" or "we", so no other stem sees them. That traded one false
// positive for three false negatives, in the direction this guard exists to
// prevent. countedLabelSentence drops the heading shape instead.
"unable to ",
"i was not able to", "i do not have", "i don't have", "unable to ",
"without being able to",
}
@@ -197,19 +179,6 @@ var inabilityStems = []string{
var successNegationTails = []string{
"find any", "found any", "find a ", "see any", "detect any", "identify any",
"reproduce", "spot any", "locate any",
// A NEGATIVE SEARCH RESULT IS THE ANSWER, not a failure to produce one.
//
// The list above already encodes this — "I could not find any remaining
// issues" is success — but only for the "any" phrasings. A finder reporting
// "I could NOT find where AllowManifestToolAutoApproval is set to true in
// production code" was marked INCOMPLETE after 53 tool calls and a 19,145
// character audit, for doing precisely the job it was given: establishing
// that something is not there.
"find where", "find the", "find it", "find that", "find this",
"found where", "found the",
"locate where", "locate the", "locate it",
"determine where", "identify where", "see where",
"reproduce ", "confirm any", "observe any",
}
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
@@ -278,401 +247,11 @@ func admissionSentences(lower string) []string {
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
// must be the model's own report about the CURRENT objective, not general
// language that merely resembles one.
// toolGrantMarkers flag a sentence as reporting WHICH TOOLS this run was given,
// not whether the work was done.
//
// A read-only plan task is SUPPOSED to say this. One wrote "I don't have an
// update_plan tool available in this specialist context (only read-only
// exploration tools were provided)" and then delivered the complete answer —
// helper name, file, line 214, full source — and was marked INCOMPLETE on the
// "i don't have" stem. The prompt asks tasks to name their limits plainly; a
// detector that punishes exactly that teaches the opposite.
//
// NARROW ON PURPOSE: the sentence must name a TOOL or a GRANT. "I do not have
// enough evidence" is still an admission and still fires.
// NARROWED AFTER AN AUDIT OF THIS VERY FIX. The first version listed a bare
// " tool", which exempts any inability sentence that merely mentions one —
// measured at 5/5 on ordinary phrasings:
//
// "I cannot run the build tool, so the change is unverified"
// "I could not use the migration tool and the data is untouched"
// "I was unable to invoke the formatting tool on the output"
//
// Those are genuine admissions, and silently exempting them is the WORSE
// direction: a false positive costs a re-run, a false negative reports
// unfinished work as done. The markers now have to be about what the run WAS
// GIVEN, not about a tool being mentioned at all.
var toolGrantMarkers = []string{
"tool available", "tools available", "no such tool", "not available in this",
"read-only tools", "read only tools", "only read-only", "only read only",
"tools were provided", "tools were given", "toolset provided",
"in this specialist context", "in this context only",
"is not in my toolset", "not in my toolset", "not in this toolset",
}
// objectiveFailureMarkers name the OBJECTIVE rather than a capability. A
// sentence carrying one is about whether the job got done, so the tool-grant
// exemption above does not apply to it however many tools it mentions.
// VERB-ANCHORED, not bare nouns. "this task" alone was too crude: a task that
// finished wrote "so i could not record a plan; the task is a single
// read-and-report step and is now complete" — it names the task in order to
// report SUCCESS, and a bare-noun override read that as failure. The marker has
// to be the objective NOT BEING DONE, which needs the verb.
var objectiveFailureMarkers = []string{
"complete this task", "complete the task", "completing this task", "completing the task",
"finish this task", "finish the task", "finishing this task",
"complete it", "completing it", "finish it", "finishing it",
// The objective and the assignment need the verb too, for the same reason
// "this task" did. "the objective is met" and "the assignment is complete"
// name the objective in order to report SUCCESS, and a bare noun read both
// as failure — so a finished answer carrying a tool caveat was told it had
// not finished, which is the worst thing this detector can do.
"complete the objective", "completing the objective",
"finish the objective", "finishing the objective",
"meet the objective", "meeting the objective", "achieve the objective",
"complete the assignment", "completing the assignment",
"finish the assignment", "finishing the assignment",
"as requested", "what was asked",
"do this task", "perform this task", "carry out this task",
}
// blockedWorkMarkers are what turns an absence-establishing sentence back into
// an admission of failure.
//
// MEASURED, NOT ARGUED. The successNegationTails list above exists so a finder
// reporting "I could not find where X is set" is not marked incomplete for doing
// its job. But the stems it added — "find the", "reproduce ", "confirm any",
// "observe any" — also head the most ordinary way of admitting defeat, and the
// allowance fired on the whole sentence regardless of how it ended. Measured
// against eleven genuine admissions, TEN passed the detector undetected:
//
// "I could not reproduce the crash, so the fix is unverified."
// "I could not find the root cause; someone else will need to pick this up."
// "I could not locate the source of the regression and have run out of ideas."
//
// That is the guard's entire purpose defeated — it is the last thing between a
// stalled run and a report that reads like success.
//
// So the allowance now yields when the sentence ALSO says the work is blocked.
// "I could not find where X is set in production code" still passes, because
// nothing in it claims the objective was left undone; the three above do not.
// strongAbsenceTails are the allowance tails carrying an explicit "any": the
// model asserting it looked and found NOTHING. That is a finding whatever the
// rest of the sentence says, so blockedWorkMarkers does not override them.
var strongAbsenceTails = []string{
"find any", "found any", "see any", "detect any", "identify any",
"spot any", "locate any", "confirm any", "observe any",
// The OBSERVATION family. Looking for a failure and not producing one is the
// same kind of result as looking for an issue and not finding one, and
// leaving these out meant "I could not reproduce any failure in the parser"
// was not a strong absence — so an unrelated blocked statement in the next
// sentence flipped a clean negative result into an admission.
"reproduce any", "trigger any", "produce any", "hit any",
"encounter any", "provoke any", "surface any", "measure any",
}
// strongAbsenceObjects are the things whose ABSENCE IS THE RESULT: you go
// looking for them precisely so you can report there are none, and finding none
// is the work succeeding.
//
// WHAT FOLLOWS "any" DECIDES, and treating every "find any" as success was too
// broad:
//
// "I could not find any remaining issues" -> a finding, the search succeeded
// "I could not find any solution" -> an admission, the work did not
//
// Both carry the explicit "any". Only the object separates them, so only the
// object can classify them.
//
// AN ALLOW-LIST, because this grants the exemption. A deny-list of deliverables
// would have to anticipate every noun a model might reach for, and everything
// forgotten would be waved through as success — the failure direction this
// detector exists to prevent. An unrecognised object is simply not strong, which
// leaves the sentence to the ordinary blocked-work handling rather than
// flagging it outright.
var strongAbsenceObjects = []string{
"issue", "issues", "problem", "problems", "bug", "bugs", "defect", "defects",
"error", "errors", "failure", "failures", "regression", "regressions",
"evidence", "example", "examples", "occurrence", "occurrences",
"instance", "instances", "reference", "references", "match", "matches",
"caller", "callers", "usage", "usages", "use", "uses", "case", "cases",
"vulnerability", "vulnerabilities", "leak", "leaks", "race", "races",
"sign", "signs", "trace", "traces", "mention", "mentions", "difference", "differences",
"blocker", "blockers", "gap", "gaps", "omission", "omissions", "discrepancy", "discrepancies",
"conflict", "conflicts", "violation", "violations", "warning", "warnings",
"call", "calls", "site", "sites", "callsite", "callsites", "consumer", "consumers",
"dependency", "dependencies", "user", "users", "path", "paths",
}
// absenceQualifiers sit between "any" and the object without changing it.
var absenceQualifiers = []string{
"remaining", "other", "further", "more", "additional", "obvious", "such",
"outstanding", "new", "existing", "actual", "real", "clear", "direct",
}
// strongAbsence reports whether tail is an "any"-family absence whose OBJECT
// makes finding nothing the result rather than the shortfall.
func strongAbsence(tail string) bool {
for _, prefix := range strongAbsenceTails {
if !strings.HasPrefix(tail, prefix) {
continue
}
rest := strings.TrimSpace(tail[len(prefix):])
for trimmed := true; trimmed; {
trimmed = false
for _, qualifier := range absenceQualifiers {
if word, remainder, ok := cutFirstWord(rest); ok && word == qualifier {
rest, trimmed = remainder, true
break
}
}
}
word, _, ok := cutFirstWord(rest)
if !ok {
continue
}
for _, object := range strongAbsenceObjects {
if word == object {
return true
}
}
}
return false
}
// cutFirstWord returns the first bare word of text, lowercased by the caller's
// own normalisation, with surrounding punctuation removed.
func cutFirstWord(text string) (word string, rest string, ok bool) {
text = strings.TrimLeft(text, " \t")
end := 0
for end < len(text) {
c := text[end]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' {
end++
continue
}
break
}
if end == 0 {
return "", text, false
}
return text[:end], text[end:], true
}
var blockedWorkMarkers = []string{
"unverified", "not verified", "cannot verify", "could not verify",
"someone else", "will need to", "needs someone", "handed off", "hand off",
// "someone else" and "will need to" are the two that also appear in
// SUCCESSFUL reports, describing somebody else's future work — "I could not
// find any remaining issues, though a follow-up will need to cover the
// Windows path" is a finding. They are kept because they do carry a genuine
// handoff-because-blocked ("someone else will need to pick this up"), and the
// strongAbsenceTails exemption above is what stops them flipping a finding.
"ran out of", "run out of", "out of time", "in the time available",
"stopped there", "stopping here", "gave up", "giving up",
"nothing was modified", "no changes were made", "left unchanged", "left undone",
"may be inert", "is unresolved", "remains unresolved", "still broken",
"so i cannot", "so i could not",
// Saying the work is BLOCKED, in as many words. "I could not find the root
// cause, so the work is blocked" carries the statement in the same sentence
// and still passed, because every marker above names a symptom of being
// blocked and none named the thing itself.
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
"cannot proceed", "could not proceed", "can not proceed", "unable to proceed",
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
"is incomplete", "remains incomplete",
"still unresolved", "still unverified",
}
// bareInabilityStems are the two entries above that are STEMS rather than
// descriptions of a blocked state. The stem loop below already scans them with
// its own tail logic, and a sentence can carry one on its way to reporting
// success: "so i could not record a plan; the task is a single read-and-report
// step and is now complete" is a finished task that did not need the plan.
var bareInabilityStems = []string{"so i cannot", "so i could not"}
// unambiguousFailureStates say the work is in a bad state, with no reading on
// which it is a result.
//
// AN OBJECT CANNOT OUTRANK AN EXPLICIT STATE. strongAbsence returns true for
// "evidence", and that suppressed every blocked-work marker in the sentence — so
// "I could not find any evidence supporting the fix, so it remains unverified"
// reported success while saying in as many words that the work is unverified.
// The absence protection exists for ownership and follow-up wording, where "I
// could not find any remaining issues, though a follow-up will need to cover the
// Windows path" really is a finding. It was never meant to cover a sentence that
// states the outcome.
//
// So this is the SHORT list: "unverified" and "still broken" have one reading,
// while "someone else", "will need to" and "nothing was modified" have two and
// stay ambiguous. Same-sentence only — a state in the NEXT sentence may belong
// to another subject, which is what the lookahead's topic-shift guard is for.
var unambiguousFailureStates = []string{
"unverified", "not verified",
"is unresolved", "remains unresolved",
"still broken",
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
"still unresolved", "still unverified",
"is incomplete", "remains incomplete",
"cannot proceed", "could not proceed", "unable to proceed",
"gave up", "giving up", "ran out of", "run out of",
"left undone",
}
// consequenceBoundaries separate what was looked for from what followed.
//
// THE STATE HAS TO BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED. Matching
// "is unresolved" anywhere in the sentence read the negated PROPOSITION as the
// reported result:
//
// "I could not find any evidence that the issue is unresolved."
//
// That is a successful negative finding — there is no evidence the issue remains
// unresolved — and it was marked incomplete, which is the opposite polarity of
// the case the override was added for. What separates them is position: after a
// consequence boundary the state is being asserted, inside a "that…" clause it is
// the thing being denied.
var consequenceBoundaries = []string{", so ", "; ", ", but ", ", therefore", ", leaving ", ", which "}
// reportedConsequence returns the part of a sentence that states the OUTCOME,
// or "" when the sentence never turns to one. Everything before the first
// boundary is what was searched for.
func reportedConsequence(sentence string) string {
earliest := -1
width := 0
for _, boundary := range consequenceBoundaries {
if index := strings.Index(sentence, boundary); index >= 0 && (earliest < 0 || index < earliest) {
earliest, width = index, len(boundary)
}
}
if earliest < 0 {
return ""
}
return sentence[earliest+width:]
}
// blockedStateMarkers describe WORK LEFT BLOCKED — unverified, unresolved,
// handed off, abandoned for time. Only these break the tool-grant exemption:
// they say something about the state the work is in, which a tool caveat cannot
// excuse, whereas a bare stem says only that one step did not happen.
//
// DERIVED, not copied. Two hand-maintained lists that must stay in step drift,
// and the drift here would be silent in both directions.
var blockedStateMarkers = func() []string {
bare := make(map[string]bool, len(bareInabilityStems))
for _, stem := range bareInabilityStems {
bare[stem] = true
}
out := make([]string, 0, len(blockedWorkMarkers))
for _, marker := range blockedWorkMarkers {
if !bare[marker] {
out = append(out, marker)
}
}
return out
}()
// carriesTheConsequence reports whether the sentence after an allowance should
// be read as that allowance's consequence.
//
// The lookahead exists because the consequence usually IS the next sentence, but
// "usually" is not "always" — a message can turn to something else, and reading
// a blocked statement about a different subject as this one's consequence is the
// cost of the lookahead. A sentence that announces the change of subject, or
// disclaims the thing as out of scope, is taken at its word.
//
// This does not catch every unrelated follow-on, and deliberately errs toward
// reading the next sentence: an admission reported as success is the failure this
// guard exists to prevent, and a message that says something is unverified has
// said it whether or not it is the same something.
func carriesTheConsequence(next string) bool {
return !containsAny(next, topicShiftMarkers)
}
// topicShiftMarkers say the message has moved on to something else.
var topicShiftMarkers = []string{
"separately", "unrelatedly", "unrelated", "as an aside", "aside from",
"out of scope", "outside the scope", "not in scope", "for a different",
"in a different", "on another", "elsewhere in", "in other news",
}
// countedLabelSentence reports whether a sentence is a markdown LABEL that
// introduces and counts a bucket of findings, rather than a claim about the
// objective.
//
// "**Unable to verify (1):** - MCP #3 claim was truncated" is a section heading
// in a COMPLETED audit. Read as a sentence it is indistinguishable from an
// admission, which is why the subjectless stem was once deleted to silence it.
//
// NARROW ON PURPOSE: the sentence must BEGIN with the inability phrase, after
// markdown emphasis, AND carry a parenthesised count. "Unable to complete the
// task; the build never succeeded" begins the same way and has no count, so it
// still fires — which is the whole reason this is preferable to dropping the
// stem.
func countedLabelSentence(sentence string) bool {
trimmed := strings.TrimLeft(strings.TrimSpace(sentence), "-*#> \t")
if !strings.HasPrefix(trimmed, "unable to ") {
return false
}
return countedLabelSuffix.MatchString(sentence)
}
var countedLabelSuffix = regexp.MustCompile(`\(\s*\d+\s*\)`)
func selfReportedIncompletion(text string) string {
sentences := admissionSentences(strings.ToLower(stripQuoted(text)))
for index, sentence := range sentences {
// THE CONSEQUENCE IS OFTEN THE NEXT SENTENCE. The blocked-work override
// only ever saw the sentence the allowance fired in, so the same
// admission escaped or was caught purely on its punctuation:
//
// "I could not reproduce the crash, so the fix is unverified." caught
// "I could not reproduce the crash. The fix is unverified." missed
//
// A full stop is not a claim that the work finished, and writing the
// consequence as its own sentence is how most people write. The
// blocked-work question is asked of this sentence AND the one after it;
// everything else is still decided on the sentence alone, so a stem in
// one sentence cannot be paired with an allowance tail in another.
blockedContext := sentence
if index+1 < len(sentences) && carriesTheConsequence(sentences[index+1]) {
blockedContext += " " + sentences[index+1]
}
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
if containsAny(sentence, narrativeMarkers) {
continue
}
// A counted label is a heading, not a claim about the objective.
if countedLabelSentence(sentence) {
continue
}
// A sentence about the tool grant is about CAPABILITY, not about the
// objective — UNLESS it also says the task itself could not be done.
//
// THE OVERRIDE IS NOT OPTIONAL. Without it the exemption swallowed a
// genuine failure: "I am unable to complete this task with the current
// tool set … Only write_file is enabled … so I cannot inspect the
// codebase." That task really did fail, and it mentions tools, so a bare
// tool-marker check waved it through. Naming the task is what separates
// "I lack a tool I did not need" from "I lack the tools this needed".
// BLOCKED WORK BREAKS THE EXEMPTION TOO, not just a named objective. The
// override above asks whether the sentence names the task, which a whole
// class of real admissions never does:
//
// "No write tool is available in this context, so the fix is unverified."
// "There is no edit tool available here, so the change remains unapplied."
// "Write tools are not available in this setup, so the tests were never run."
//
// Every one mentions tools, none names the objective, and all four were
// waved through. The tool caveat is the REASON the work is blocked, not a
// reason to stop reading — so a sentence that also says something is
// unverified, unapplied or untested goes on to the blocked-work handling
// below instead of being exempted here.
if containsAny(sentence, toolGrantMarkers) &&
!containsAny(sentence, objectiveFailureMarkers) &&
!containsAny(sentence, blockedStateMarkers) {
continue
}
for _, phrase := range selfReportPhrases {
if strings.Contains(sentence, phrase) {
return selfReportReason(phrase)
@@ -689,18 +268,7 @@ func selfReportedIncompletion(text string) string {
}
abs := start + rel
tail := strings.TrimSpace(sentence[abs+len(stem):])
// The allowance yields to a sentence that also says the work is
// blocked: "could not reproduce the crash" is a finding, "could not
// reproduce the crash, so the fix is unverified" is an admission,
// and the tail prefix alone cannot tell them apart.
strong := strongAbsence(tail)
// The object decides whether finding nothing is a result; an
// explicit failure state in the SAME sentence decides that it is
// not, whatever the object.
if strong && containsAny(reportedConsequence(sentence), unambiguousFailureStates) {
strong = false
}
if !hasAnyPrefix(tail, successNegationTails) || (!strong && containsAny(blockedContext, blockedWorkMarkers)) {
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
@@ -1,458 +0,0 @@
package agent
import "testing"
// FOUR COMPLETED TASKS WERE MARKED INCOMPLETE, and the sentences below are
// verbatim from those sessions.
//
// The detector reads message text — the one place in this feature that does —
// and it is supposed to catch a model admitting it did not do the job. Instead
// it caught four tasks that HAD done the job and were honestly naming a limit,
// which is exactly what the plan-task prompt asks them to do. Two of them had
// made 53 and 60 tool calls; one had written files.
//
// The cost of a false positive here is not cosmetic: the task is reported
// failed, retried on another model, and rendered red in the plan panel.
func TestAnHonestCaveatInsideDeliveredWorkIsNotAnAdmission(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
// 53 tool calls, a 19,145-character audit. Establishing that
// something is NOT there is the finding, not a failure to find one.
name: "a negative search result is the answer",
text: "I traced every code path where repo-committed files influence tool approval. " +
"I could NOT find where `AllowManifestToolAutoApproval` is set to true in production code paths.",
},
{
// A completed verification task categorising its own findings. The
// bare "unable to" stem had no first-person subject and fired on a
// markdown heading.
name: "a report section heading",
text: "## Verification Table\n\n**Verified (4):** all confirmed against source.\n" +
"**Unable to verify (1):** - MCP #3 claim was truncated in the input.",
},
{
// Delivered helper name, file, line 214 and full source. The task was
// read-only by design and said so.
name: "a statement about the tool grant",
text: "I don't have an `update_plan` tool available in this specialist context " +
"(only read-only exploration tools were provided). " +
"The task is a single read-only assessment and I've already gathered all the needed evidence.",
},
{
name: "read-only tools named plainly",
text: "I did not run the tests because my tools are read-only, " +
"so I can report only what the code and tests say statically.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason != "" {
t.Fatalf("a completed task was marked incomplete: %s", reason)
}
})
}
}
// AND THE DETECTOR MUST STILL BITE. Widening it to stop punishing honesty must
// not turn it off — these are the admissions it exists for, several of them
// verbatim from the same set of sessions.
func TestGenuineAdmissionsAreStillCaught(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
name: "the measured workspace failure",
text: "I cannot complete this task. The target directory `/Users/kratos/zm-lab/pkg/execprofile` " +
"is outside the workspace boundary.",
},
{
name: "an empty worktree",
text: "This task cannot be completed in the current workspace because the relevant source tree is absent. " +
"I cannot find BuildFinalResult anywhere.",
},
{
name: "guessing",
text: "I guessed at the retry semantics because the code was not reachable.",
},
{
name: "fabrication",
text: "I fabricated the line numbers to fill the table.",
},
{
name: "stated doubt about the result",
text: "The patch may not be correct — I did not run it.",
},
{
name: "not enough evidence is still an admission",
text: "I do not have enough evidence to answer the question.",
},
{
name: "first-person unable, with a subject",
text: "I was unable to determine which branch actually runs.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason == "" {
t.Fatalf("a real admission was missed: %q", tc.text)
}
})
}
}
// The tool-grant exemption is NARROW. A sentence has to be about tools or the
// grant; "not enough evidence" is not, and must keep firing.
func TestTheToolGrantExemptionDoesNotSwallowRealAdmissions(t *testing.T) {
if reason := selfReportedIncompletion("I could not verify the claim and I do not have enough evidence."); reason == "" {
t.Fatal("the tool-grant exemption swallowed an evidence admission")
}
if reason := selfReportedIncompletion("I don't have the file contents, so the answer is a guess."); reason == "" {
t.Fatal("an admission about missing content was exempted as a tool statement")
}
}
// THE EXEMPTION MUST NOT SWALLOW A TASK THAT REALLY COULD NOT BE DONE.
//
// Verbatim from a session the first version of this fix regressed: the task
// mentions tools, so a bare tool-marker check waved it through — but it names
// the TASK as the thing that failed, which is the whole distinction.
func TestATaskThatCouldNotBeDoneForLackOfToolsStillFails(t *testing.T) {
for _, tc := range []struct {
name string
text string
}{
{
name: "the measured regression",
text: "I am unable to complete this task with the current tool set. " +
"Only `write_file` is enabled, so I cannot inspect the codebase to collect scan findings.",
},
{
name: "named differently",
text: "I could not finish the objective because no read tools were provided.",
},
{
name: "cannot complete it",
text: "I don't have the tools to complete it as requested.",
},
} {
t.Run(tc.name, func(t *testing.T) {
if reason := selfReportedIncompletion(tc.text); reason == "" {
t.Fatalf("a task that genuinely could not be done was passed as complete: %q", tc.text)
}
})
}
// And the exemption must still work for the case it exists for: a tool that
// was not needed, alongside delivered work.
exempt := "I don't have an `update_plan` tool available in this specialist context " +
"(only read-only exploration tools were provided)."
if reason := selfReportedIncompletion(exempt); reason != "" {
t.Fatalf("the override broke the exemption it guards: %s", reason)
}
}
// NAMING THE TASK TO REPORT SUCCESS IS NOT NAMING IT TO REPORT FAILURE.
//
// Verbatim from the session that the first override regressed. A task that had
// finished wrote a footnote about a tool it did not need, in the same sentence
// as its own completion statement — and a bare-noun override read "the task" as
// an admission. The marker has to be the objective NOT being done.
func TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
done := "- **note:** the update_plan tool is not available in my current toolset " +
"(only read-only file tools), so i could not record a plan; " +
"the task is a single read-and-report step and is now complete"
if reason := selfReportedIncompletion(done); reason != "" {
t.Fatalf("a finished task was marked incomplete for saying so: %s", reason)
}
// The failure form, which must still fire, differs only in the verb.
failed := "the update_plan tool is not available, so i could not complete this task"
if reason := selfReportedIncompletion(failed); reason == "" {
t.Fatal("a task that could not be completed was passed as complete")
}
}
// AN INABILITY THAT MERELY MENTIONS A TOOL IS STILL AN ADMISSION.
//
// THE AUDIT FINDING THIS PINS. The first version of the exemption listed a bare
// " tool", so any inability sentence mentioning one escaped — measured at 5/5 on
// ordinary phrasings. That is the worse direction: a false positive costs a
// re-run, a false negative reports unfinished work as done.
func TestMentioningAToolDoesNotExemptAnAdmission(t *testing.T) {
for _, text := range []string{
"I cannot run the build tool, so the change is unverified",
"I could not use the migration tool and the data is untouched",
"I was unable to invoke the formatting tool on the output",
"I don't have a working compiler tool here",
"I cannot finish because the deploy tools are broken",
} {
if reason := selfReportedIncompletion(text); reason == "" {
t.Errorf("a genuine admission was silently exempted: %q", text)
}
}
}
// And the exemption still covers what it was built for: a run stating which
// tools it was GIVEN, alongside delivered work.
func TestTheGrantExemptionStillCoversWhatItWasBuiltFor(t *testing.T) {
for _, text := range []string{
"I don't have an `update_plan` tool available in this specialist context (only read-only exploration tools were provided).",
"I did not run the tests because only read-only tools were provided, so I report what the code says statically.",
"update_plan is not in my toolset, so I could not record a plan.",
} {
if reason := selfReportedIncompletion(text); reason != "" {
t.Errorf("a run naming its own grant was marked incomplete: %q -> %s", text, reason)
}
}
}
// NAMING THE OBJECTIVE TO REPORT SUCCESS, same trap as naming the task.
//
// "the objective" and "the assignment" were bare nouns in objectiveFailureMarkers
// while "this task" had already been verb-anchored for exactly this reason. A
// message that mentions a tool it lacked and then says the objective IS met was
// read as saying the objective was not met — a finished answer told it had not
// finished, which is the worst outcome this detector has.
func TestNamingTheObjectiveWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
for _, done := range []string{
"I do not have write tools available, but the objective is met: the config already sets the flag.",
"No write tool is available to me, so the objective was achieved by reading alone.",
"The assignment is complete; no shell was needed.",
"I have no browser tool available here, yet the assignment is complete.",
} {
if reason := selfReportedIncompletion(done); reason != "" {
t.Errorf("a finished answer was marked incomplete for naming its objective: %q -> %s", done, reason)
}
}
// The failure forms differ only in the verb, and must still fire.
for _, failed := range []string{
"I could not complete the objective because the build never succeeded.",
"I was unable to finish the assignment.",
} {
if reason := selfReportedIncompletion(failed); reason == "" {
t.Errorf("an objective that was not met was passed as complete: %q", failed)
}
}
}
// A TOOL CAVEAT IS THE REASON WORK IS BLOCKED, NOT A REASON TO STOP READING.
//
// The tool-grant exemption asked only whether the sentence named the objective,
// so an admission that named none was waved through on the strength of
// mentioning tools:
//
// "No write tools available, so I could not verify the change."
//
// It breaks now on a blocked STATE — unverified, unresolved, handed off — but
// deliberately not on the two bare inability stems in that list, because a
// sentence carrying one can still be on its way to reporting success (see
// TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission, which this broke when
// the whole list was used).
func TestAToolCaveatDoesNotExcuseBlockedWork(t *testing.T) {
for _, admission := range []string{
"No write tools available, so I could not verify the change.",
"There is no edit tool available here, so I could not verify the fix.",
} {
if reason := selfReportedIncompletion(admission); reason == "" {
t.Errorf("a tool caveat excused blocked work: %q", admission)
}
}
// The exemption still covers what it exists for: a tool that was not needed.
for _, exempt := range []string{
"No update_plan tool available in this specialist context, so I proceeded directly.",
"There is no update_plan tool available in this specialist context (only read-only tools), so I have written the plan into this answer instead.",
} {
if reason := selfReportedIncompletion(exempt); reason != "" {
t.Errorf("the blocked-state check broke the exemption it guards: %q -> %s", exempt, reason)
}
}
}
// WHAT FOLLOWS "any" DECIDES WHETHER FINDING NOTHING IS THE RESULT.
//
// The "any"-family was treated as a finding whatever the object, so an admission
// wearing the same words walked through:
//
// "I could not find any remaining issues" -> a finding, the search succeeded
// "I could not find any solution" -> an admission, the work did not
//
// Both carry the explicit "any"; only the object separates them. The object list
// is an ALLOW-LIST because it grants the exemption — a deny-list of deliverables
// would wave through every noun nobody thought of, which is the wrong direction
// for this detector to fail in.
func TestAnAbsenceIsAFindingOnlyWhenTheObjectMakesItOne(t *testing.T) {
for _, admission := range []string{
"I could not find any solution, so the migration remains unresolved.",
"I could not find any fix, so the build is still broken.",
"I could not find any workaround; someone else will need to pick this up.",
"I could not identify any approach, so this is unverified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an admission wearing the words of a finding passed as complete: %q", admission)
}
}
// The findings the allowance exists for are untouched, including the ones
// carrying a blocked-work marker about somebody else's future work.
for _, finding := range []string{
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
"I could not find any evidence of a leak, so nothing was modified.",
"I did not see any further regressions, so someone else can ship it.",
"I could not detect any races, and nothing was modified.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a finding was reported as an admission: %q -> %s", finding, reason)
}
}
}
// A FULL STOP IS NOT A CLAIM THAT THE WORK FINISHED.
//
// The blocked-work override only ever saw the sentence the allowance fired in,
// so the SAME admission was caught or missed on its punctuation alone:
//
// "I could not reproduce the crash, so the fix is unverified." caught
// "I could not reproduce the crash. The fix is unverified." missed
//
// Writing the consequence as its own sentence is how most people write, and half
// of a reviewer's corpus of ordinary admissions escaped through it.
//
// Only the blocked-work question looks ahead. A stem in one sentence is still
// never paired with an allowance tail in another.
func TestTheConsequenceMayBeTheNextSentence(t *testing.T) {
for _, admission := range []string{
"I could not reproduce the crash, so the fix is unverified.",
"I could not reproduce the crash. The fix is unverified.",
"I could not locate the source of the regression and have run out of ideas.",
"I could not locate the source of the regression. I have run out of ideas.",
// "the work is blocked" says it in as many words, and every marker was a
// SYMPTOM of being blocked rather than the thing itself.
"I could not find the root cause, so the work is blocked.",
"I could not find the root cause. The work is blocked.",
"I could not get the integration test to run. The behaviour is therefore unverified.",
"I could not apply the patch cleanly. Nothing was modified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an admission escaped by putting its consequence in a second sentence: %q", admission)
}
}
// THE LOOKAHEAD'S OWN COST, guarded. A message that turns to other work must
// not have that work's blocked state read as this result's consequence.
for _, finding := range []string{
"I searched the tree and could not find any other call sites. The rename is complete.",
"I could not find any remaining usages of the deprecated helper. A future cleanup is blocked until the API freeze lifts.",
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
"I could not reproduce the crash. Separately, the deploy step is unverified because it needs prod access.",
"I could not find any other call sites. The follow-up work is blocked on a design decision, which is out of scope here.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("the lookahead read another subject's blocked state as this result's: %q -> %s", finding, reason)
}
}
}
// AN UNAMBIGUOUS STATE THAT IS NOT ALSO A MARKER IS DEAD.
//
// unambiguousFailureStates only decides whether a recognised absence object
// stops protecting the sentence; something still has to FIRE, and that is
// blockedWorkMarkers. Adding "still blocked" to the first list and not the
// second left it doing nothing at all, and the case looked handled because the
// phrase appeared in the code.
//
// Two hand-maintained lists that must agree is the shape that drifts, so the
// agreement is asserted rather than remembered.
func TestEveryUnambiguousStateIsAlsoABlockedWorkMarker(t *testing.T) {
for _, state := range unambiguousFailureStates {
found := false
for _, marker := range blockedWorkMarkers {
if state == marker {
found = true
break
}
}
if !found {
t.Errorf("%q outranks a strong absence but is not a blockedWorkMarker, so nothing fires on it", state)
}
}
}
// EXPLICIT FAILURE STATES OUTRANK A RECOGNISED ABSENCE OBJECT.
//
// strongAbsence returns true for objects like "evidence", and that suppressed
// every blocked-work marker in the sentence — so a message saying in as many
// words that the work is unverified reported success. The absence protection is
// for ownership and follow-up wording; it was never meant to cover a sentence
// that states the outcome.
func TestAnExplicitFailureStateOutranksTheAbsenceObject(t *testing.T) {
for _, admission := range []string{
"I could not find any evidence supporting the fix, so it remains unverified.",
"I could not find any evidence for the cause, so the bug is unresolved.",
"I could not find any way to make it work, so I gave up.",
"I could not find any working approach; it is still broken.",
"I could not find any issues, but the migration is still blocked.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an explicit failure state was overruled by the absence object: %q", admission)
}
}
// AMBIGUOUS wording still yields to the absence, which is the whole reason
// the protection exists: ownership and follow-up read two ways, an explicit
// state reads one.
for _, finding := range []string{
"I could not find any evidence that the flag is read in production.",
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
"I could not find any evidence of a leak, so nothing was modified.",
"I could not detect any races, and nothing was modified.",
// A state in the NEXT sentence may belong to another subject, so
// same-sentence only.
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a finding was flagged by ambiguous or another subject's wording: %q -> %s", finding, reason)
}
}
}
// THE STATE MUST BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED.
//
// The override matched a failure state anywhere in the sentence, which read the
// negated PROPOSITION as the reported result:
//
// "I could not find any evidence that the issue is unresolved."
//
// That is a successful negative finding — there is no evidence the issue remains
// unresolved — and it was marked incomplete. Exactly the opposite polarity of the
// case the override was added for, and the two have to be tested together or
// fixing one just moves the error.
func TestAStateInsideTheNegatedPropositionIsNotTheOutcome(t *testing.T) {
for _, finding := range []string{
"I could not find any evidence that the issue is unresolved.",
"I could not find any evidence that the build is still broken.",
"I could not find any evidence that the migration is incomplete.",
"I could not find any sign that the deploy is blocked.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("a negated proposition was read as the reported outcome: %q -> %s", finding, reason)
}
}
// Both polarities together: after a consequence boundary the state IS the
// outcome and must still fire.
for _, admission := range []string{
"I could not find any evidence supporting the fix, so it remains unverified.",
"I could not find any evidence for the cause, so the bug is unresolved.",
"I could not find any way to make it work, so I gave up.",
"I could not find any working approach; it is still broken.",
"I could not find any issues, but the migration is still blocked.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an outcome stated after a consequence boundary was missed: %q", admission)
}
}
}
+2 -154
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
@@ -80,9 +80,6 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
if result.FinalAnswer == maxTurnsAnswer {
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
@@ -516,22 +513,6 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
return turns
}
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
// to put a productive tool turn before an empty-turn streak.
type alwaysSucceedingTool struct{}
func (alwaysSucceedingTool) Name() string { return "ok" }
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
func (alwaysSucceedingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysSucceedingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
@@ -548,51 +529,6 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
if len(provider.requests) != toolFailureStopAt {
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
// Under the headless completion gate both guardrail stops are unfinished runs,
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
// Thresholds and stop answers stay pinned by the two tests above.
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
for _, tc := range []struct {
name string
turns [][]zeroruntime.StreamEvent
wantTurns int
wantReason string
}{
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
toolTurn("ok-1", "ok", "{}"),
emptyTurn(), emptyTurn(), emptyTurn(),
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
registry.Register(alwaysSucceedingTool{})
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
Registry: registry,
MaxTurns: 12,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Turns != tc.wantTurns {
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
}
if !result.Incomplete {
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
}
})
}
}
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
@@ -618,91 +554,3 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
}
}
// An absence-establishing sentence is a finding; the same sentence that also
// says the work is blocked is an admission. The allowance stems ("find the",
// "reproduce ", "confirm any" …) head both, so the tail prefix alone cannot
// separate them — measured against real phrasings, TEN of eleven genuine
// admissions passed the detector before blockedWorkMarkers existed.
func TestIncompletionAllowanceYieldsToBlockedWork(t *testing.T) {
// Must FIRE: the inability is reported as leaving work undone.
for _, admission := range []string{
"I could not reproduce the crash, so the fix is unverified.",
"I was unable to reproduce the reported behaviour and stopped there.",
"I could not find the root cause; someone else will need to pick this up.",
"I could not find the bug in the time available.",
"I did not manage to reproduce the failure, so I cannot confirm the patch works.",
"I could not locate the source of the regression and have run out of ideas.",
"I could not find where to apply the change, so nothing was modified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("admission passed the detector: %q", admission)
}
}
// Must NOT fire: establishing that something is absent is the job, and the
// motivating case is the audit that spent 53 tool calls proving a negative.
for _, finding := range []string{
"I could not find any remaining issues.",
"I could NOT find where AllowManifestToolAutoApproval is set to true in production code.",
"I could not find the flag being set anywhere outside tests, so the concern does not apply.",
"I could not reproduce the reported exploit, which confirms the guard holds.",
"I could not observe any regression across the suite.",
"I could not confirm any leak; every path is bounded.",
// Next steps and ownership belong in successful reports too. Both of
// these were flagged when blockedWorkMarkers could override an explicit
// "any", which is the model asserting exhaustive absence.
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
"I could not find any blockers; someone else can take the release from here.",
} {
if reason := selfReportedIncompletion(finding); reason != "" {
t.Errorf("finding wrongly flagged as incomplete: %q -> %q", finding, reason)
}
}
// NOT CAUGHT, and recorded rather than hidden. An explicit "any" is treated
// as exhaustive absence, so an admission phrased that way passes:
//
// "I could not observe any effect, so the change may be inert."
//
// Measured across eleven admissions, four still pass, all of them either
// "any"-phrased or single-clause with no blocked-work signal ("I failed to
// reproduce it locally"). Catching those needs a different signal than
// substring matching; tightening this list to reach them re-broke the
// findings above every way I tried.
}
// An admission with NO first-person subject must still fire. The subjectless
// "unable to " stem was once deleted to silence a completed audit's section
// heading, which also lost every impersonal admission — none of these names "i"
// or "we", so no other stem sees them.
func TestImpersonalAdmissionsAreStillCaught(t *testing.T) {
for _, admission := range []string{
"Unable to complete the task; the build never succeeded.",
"The agent was unable to finish the migration.",
"Unable to verify the fix, so the change is unverified.",
} {
if selfReportedIncompletion(admission) == "" {
t.Errorf("an impersonal admission passed the detector: %q", admission)
}
}
}
// The heading that motivated deleting the stem must stay exempt. It is a label
// counting a bucket of findings in a COMPLETED audit, not a claim about the
// objective — which is why it is recognised by shape rather than by removing a
// stem that catches real admissions.
func TestACountedHeadingIsNotAnAdmission(t *testing.T) {
for _, heading := range []string{
"**Unable to verify (1):** - MCP #3 claim was truncated",
"Unable to reproduce (3):",
} {
if reason := selfReportedIncompletion(heading); reason != "" {
t.Errorf("a counted heading was read as an admission: %q -> %q", heading, reason)
}
}
// But the same opening WITHOUT a count is a real admission.
if selfReportedIncompletion("Unable to verify the deployment; it never started.") == "" {
t.Error("a subjectless admission with no count was exempted as a heading")
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
)
func TestAppendHookFeedbackFormatsOutput(t *testing.T) {
+20 -157
View File
@@ -11,29 +11,20 @@ import (
"strings"
"sync"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/measurements"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const maxTokensAnswer = "Agent reached its token budget without a final answer."
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxTokensFinalAnswerPrompt is the same ending for a different reason, and it
// says which. A run stopped for spend and a run stopped for round trips call for
// the same next action from the model and a different explanation to the reader,
// who otherwise reaches for the wrong lever — raising a turn count that was never
// what ran out.
const maxTokensFinalAnswerPrompt = "You have reached this run's token budget. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
// on a fresh connection before giving up. Incomplete tool-call previews are
@@ -187,10 +178,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// this local, so no signatures change anywhere downstream.
provider = sessionProvider{session: session}
// The turn's raw user text, carried to tools so a gate can tell "the user
// asked for this" from "the model read a sentence that said to".
options.userMessage = prompt
maxTurns := options.MaxTurns
if maxTurns <= 0 {
maxTurns = 12
@@ -269,18 +256,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// FileDiagnostics callback is wired; every method no-ops on nil.
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
// THE RUN'S OWN TIMINGS, so a final answer cannot report a number no command
// here produced. Every tool result is read for `go test` durations; the final
// answer is checked against them once before it is returned.
//
// POSTURE-GATED, and nil when the posture is off — every method no-ops on
// nil, so a run that never heard of zeromaxing takes exactly the path it took
// before this existed. See internal/measurements for the failure it is for.
var measured *measurements.Ledger
if options.Zeromaxing != ZeromaxingOff {
measured = measurements.NewLedger()
}
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
@@ -296,12 +271,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// doesn't re-run the recursive schema→map conversion for every tool every turn.
toolDefCache := map[string]zeroruntime.ToolDefinition{}
// SPEND, accumulated from what the provider actually reported rather than
// estimated. Only read when options.MaxTokens > 0, so an unbounded run adds
// one integer and changes nothing else.
spentTokens := 0
stoppedOnTokens := false
result = Result{Messages: copyMessages(messages)}
dispatchSessionStart(ctx, options)
defer func() {
@@ -314,27 +283,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
// CHECKED HERE, at the top of a turn, for the same reason the turn count
// is: the previous turn's tool calls have run and their results are in
// hand. Stopping mid-turn would discard work already paid for.
if options.MaxTokens > 0 && spentTokens >= options.MaxTokens {
stoppedOnTokens = true
break
}
// The zeromaxing posture's reminders. Appended to the CONVERSATION tail
// as user-role messages — the same channel as the diagnostics nudge
// below and the failure/plan hints later in the turn — and never into
// the system prompt, which is built once per run and must stay
// byte-stable so the provider's cached prefix survives. See
// internal/agent/zeromaxing.go.
for _, reminder := range zeromaxingReminders(options.Zeromaxing, result.Turns, options.OrchestrateAvailable) {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Deliver background post-edit diagnostics from the previous turn's edits
// BEFORE compaction so the nudge is part of the request being budgeted.
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
@@ -346,19 +294,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
}
// A BACKGROUND PLAN that finished since the last turn. Same channel and
// the same point in the turn as the diagnostics nudge above: the model
// was told the plan was not finished and must not report it as done, so
// this is the message that makes that promise good.
if options.PlanCompletions != nil {
if finished := options.PlanCompletions(); finished != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: finished,
})
}
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
@@ -620,11 +555,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// assistant reply is appended below, after this, so `messages` is still the
// sent request.
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
// Accumulated at the same point the estimator is calibrated: past error
// recovery, and past any reactive compaction that re-sent the request, so
// this counts the exchange that actually happened rather than one that was
// abandoned and replaced.
spentTokens += collected.Usage.TotalTokens()
// Carry the turn's terminal stop reason so a final answer cut off at the
// output token cap (or by a content filter) is reported as truncated. A
@@ -657,14 +587,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
result.FinalAnswer = noOutputStopAnswer(result.Turns)
// Under the headless completion gate this stop is unfinished work,
// not a successful final answer: the guard exists precisely because
// the model made no progress. Interactive runs keep the gate off, so
// their behavior is unchanged.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -732,23 +654,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
continue
}
// THE ANSWER IS CHECKED AGAINST THE TRANSCRIPT before it is returned.
// Same channel and the same gate as the diagnostics drain above, and
// for the same reason: a final answer means there is no later turn, so
// a number that contradicts what actually ran has to be raised now or
// never. Each name is raised at most once by the ledger, so an answer
// that comes back unchanged is returned rather than asked again.
// ACROSS RUNS, because a final answer may summarise several commands
// and this caller cannot say which one any given number came from.
// Holding it to a single run would accuse the model of inventing a
// number one of the other commands really printed.
if nudge := measurements.Nudge(measured.ConflictsAcrossRuns(collected.Text)); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
continue
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -837,14 +742,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
options.Trace.Counter(trace.CounterToolCalls, 1)
recordOutputBudgetTrace(options.Trace, toolResult)
// Read before anything can truncate or summarize it: this is the only
// place the command's own words are in hand — and the only place the
// CALL that produced them is in hand too. Recording which tool and
// which arguments printed a timing is what stops a claim about one
// command being answered by a number a different one produced: a
// `-race` run is routinely several times slower, which is the size of
// discrepancy this check exists to catch.
measured.Record(measurements.Run{Command: call.Name, Args: []string{call.Arguments}}, toolResult.Output)
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
@@ -911,13 +808,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, buildToolImageMessages()...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
// Same headless contract as the empty-turn guard: halting on a
// repeated failure is an unfinished run, not success. Gate-off
// (interactive / --no-completion-gate) keeps the old behavior.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "the " + call.Name + " tool failed " + strconv.Itoa(outcome.Count) + " times in a row with the same error"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -1087,31 +977,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
// WHICH BOUND FIRED reaches the reader, because the two call for different
// responses: a turn-count stop is a round-trip problem, a token stop is a
// spend one, and reporting the first for the second sends someone to raise a
// limit that was never what ran out.
finalPrompt, exhaustedAnswer, limitName := maxTurnsFinalAnswerPrompt, maxTurnsAnswer, "max-turns limit"
if stoppedOnTokens {
finalPrompt, exhaustedAnswer, limitName = maxTokensFinalAnswerPrompt, maxTokensAnswer, "token budget"
}
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options, finalPrompt); strings.TrimSpace(answer) != "" {
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the " + limitName + " without completing"
result.IncompleteReason = "reached the max-turns limit without completing"
}
return result, nil
}
result.FinalAnswer = exhaustedAnswer
result.FinalAnswer = maxTurnsAnswer
result.Messages = copyMessages(messages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the " + limitName + " without a final answer"
result.IncompleteReason = "reached the max-turns limit without a final answer"
}
return result, nil
}
@@ -1178,11 +1059,11 @@ func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
})
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options, prompt string) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: prompt,
Content: maxTurnsFinalAnswerPrompt,
})
// The max-turns final-answer call is a pre-content connect, often after a long
// autonomous/cron run — route it through the reconnect helper so a single
@@ -1593,17 +1474,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
// Wire the progress callback so the TUI sees live tool-call events from a
// child agent process.
//
// Keyed on the TOOL'S OWN DECLARATION (tools.ChildProgressStreamer), not on
// its name. This was `call.Name == "Task"`, which made the second
// sub-agent-spawning tool run invisibly; `|| call.Name == "orchestrate"`
// would have been the same defect one name later. A tool that spawns
// children declares it, and every tool that does not keeps the nil callback
// it has today.
// Task tool: wire progress callback so the TUI sees live tool-call events
// from the specialist child process.
var progressCallback func(streamjson.Event)
if options.OnToolProgress != nil && tools.StreamsChildProgress(tool) {
if call.Name == "Task" && options.OnToolProgress != nil {
toolCallID := call.ID
onProgress := options.OnToolProgress
progressCallback = func(event streamjson.Event) {
@@ -1622,7 +1496,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
UserMessage: options.userMessage,
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
@@ -3051,7 +2924,7 @@ func availablePermissionDecisions(event PermissionEvent, args map[string]any, op
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
}
}
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName, options) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
decisions = append(decisions, PermissionDecisionAlwaysAllow)
}
}
@@ -3253,17 +3126,7 @@ func grantFilesystemForSandboxPrompt(event PermissionEvent, scope sandbox.Permis
}, scope)
}
func permissionSupportsPersistentDecision(toolName string, options Options) bool {
// THE TOOL'S OWN DECLARATION FIRST. A name list cannot describe a tool whose
// reach depends on its arguments, and it cannot cover a tool that RUNS the
// ones already refused below — see tools.PersistentPermissionRefuser.
if options.Registry != nil {
if tool, found := options.Registry.Get(toolName); found {
if refuser, ok := tool.(tools.PersistentPermissionRefuser); ok && refuser.RefusesPersistentPermission() {
return false
}
}
}
func permissionSupportsPersistentDecision(toolName string) bool {
switch toolName {
case "bash", "exec_command", "write_stdin", "apply_patch":
return false
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// imageEchoProvider records the messages of the first request it receives, then
+7 -143
View File
@@ -13,13 +13,13 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/specmode"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type mockProvider struct {
@@ -4361,142 +4361,6 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
}
}
// (h) The zeromaxing variant of TestRunPreservesRequestPrefixAcrossTurns.
//
// This is the test the whole reminder design exists to satisfy. The system
// prompt and tool definitions are the provider's CACHED PREFIX; #760 made them
// build once per run so they stay byte-identical across turns. The posture adds
// per-turn text, so it is exactly the kind of change that silently destroys the
// cache — roughly doubling input cost with nothing detecting it.
//
// The reminders are appended to the conversation TAIL as user-role messages, so
// each turn's request must still be an exact prefix-extension of the previous
// one, with identical tools and cache key.
func TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\n")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
if _, err := Run(context.Background(), "read notes", provider, Options{
Cwd: root,
Registry: registry,
SessionID: "session-stable-prefix-zeromaxing",
Zeromaxing: ZeromaxingEntering,
}); err != nil {
t.Fatal(err)
}
if len(provider.requests) != 3 {
t.Fatalf("provider requests = %d, want 3", len(provider.requests))
}
// Every request must extend the previous one exactly — nothing rewritten
// above the append point, which is what keeps the cached prefix valid.
for i := 1; i < len(provider.requests); i++ {
prev, cur := provider.requests[i-1], provider.requests[i]
if len(cur.Messages) < len(prev.Messages) ||
!reflect.DeepEqual(cur.Messages[:len(prev.Messages)], prev.Messages) {
t.Fatalf("request %d is not an exact prefix-extension of request %d:\nprev=%#v\ncur=%#v",
i, i-1, prev.Messages, cur.Messages)
}
if !reflect.DeepEqual(prev.Tools, cur.Tools) {
t.Fatalf("tool definitions drifted between turns %d and %d", i-1, i)
}
if cur.PromptCacheKey != prev.PromptCacheKey {
t.Fatalf("prompt cache key drifted: %q -> %q", prev.PromptCacheKey, cur.PromptCacheKey)
}
}
// The system prompt — the cached prefix itself — must be byte-identical
// across turns, and must contain NONE of the posture text.
system := provider.requests[0].Messages[0]
for i, request := range provider.requests {
if !reflect.DeepEqual(request.Messages[0], system) {
t.Fatalf("system message changed on turn %d — the cached prefix is broken", i+1)
}
}
for _, forbidden := range []string{
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice,
} {
if strings.Contains(system.Content, forbidden) {
t.Fatalf("posture reminder leaked into the SYSTEM PROMPT (above the cache breakpoint): %q", forbidden)
}
}
// ...and the reminders really did arrive, on schedule. Without this the
// assertions above would pass just as happily if the feature did nothing.
firstTurn := renderZeromaxingMessages(provider.requests[0].Messages)
if !strings.Contains(firstTurn, ZeromaxingEnterNotice) || !strings.Contains(firstTurn, ZeromaxingBudgetNotice) {
t.Fatalf("turn 1 must carry the enter + budget notices:\n%s", firstTurn)
}
if strings.Contains(firstTurn, ZeromaxingStillOnNotice) {
t.Fatalf("turn 1 must NOT carry the still-on notice:\n%s", firstTurn)
}
secondOnly := renderZeromaxingMessages(provider.requests[1].Messages[len(provider.requests[0].Messages):])
if !strings.Contains(secondOnly, ZeromaxingStillOnNotice) {
t.Fatalf("turn 2 must carry the still-on notice:\n%s", secondOnly)
}
if strings.Contains(secondOnly, ZeromaxingEnterNotice) {
t.Fatalf("turn 2 must NOT re-announce entry:\n%s", secondOnly)
}
}
// A run with the posture OFF must be byte-identical to one that never heard of
// it — the no-regression half of the feature.
func TestRunWithoutZeromaxingCarriesNoPostureText(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
if _, err := Run(context.Background(), "hello", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-no-zeromaxing",
}); err != nil {
t.Fatal(err)
}
rendered := renderZeromaxingMessages(provider.requests[0].Messages)
for _, forbidden := range []string{
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice,
} {
if strings.Contains(rendered, forbidden) {
t.Fatalf("a posture-free run must carry no posture text, found %q", forbidden)
}
}
}
// renderZeromaxingMessages flattens messages to one searchable string.
func renderZeromaxingMessages(messages []zeroruntime.Message) string {
var b strings.Builder
for _, message := range messages {
b.WriteString(string(message.Role))
b.WriteString(": ")
b.WriteString(message.Content)
b.WriteString("\n")
}
return b.String()
}
// A cancelled permission on either sandbox-retry path must abort the run with a
// recognisable cancellation, and must not run the escalated retry.
//
-149
View File
@@ -1,149 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// goTestTool stands in for a shell that ran `go test` and printed its result.
type goTestTool struct{ output string }
func (goTestTool) Name() string { return "run_tests" }
func (goTestTool) Description() string { return "run the test suite" }
func (goTestTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (goTestTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true}
}
func (t goTestTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: t.output}
}
// callThenAnswer is a provider that calls run_tests, then gives finalAnswer, then
// gives corrected — so a run that is sent back for a number gets somewhere to go.
func callThenAnswer(finalAnswer, corrected string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "run_tests"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: finalAnswer}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: corrected}, {Type: zeroruntime.StreamEventDone}},
}}
}
const suiteOutput = `{"Action":"pass","Package":"github.com/x/y","Test":"TestChattyChild","Elapsed":0.86}
{"Action":"pass","Package":"github.com/x/y","Elapsed":0.86}
`
// A FINAL ANSWER THAT REPORTS A NUMBER THE RUN NEVER PRODUCED IS SENT BACK.
//
// The measured failure: a benchmark table where the same test read 0.86s in one
// paste and 4.20s in the next, with nothing said about the difference. A prompt
// rule cannot catch this — a model willing to write a number it did not measure
// is equally willing to say it re-ran it. The transcript can.
func TestAFinalAnswerContradictingTheTranscriptIsSentBack(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
provider := callThenAnswer("github.com/x/y took 4.20s.", "Corrected: github.com/x/y took 0.86s.")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-measured",
Zeromaxing: ZeromaxingActive,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) < 3 {
t.Fatalf("the run ended after %d requests: the contradicted answer was accepted", len(provider.requests))
}
sentBack := renderZeromaxingMessages(provider.requests[2].Messages)
for _, required := range []string{"github.com/x/y", "4.2s", "0.86s", "Re-run the command"} {
if !strings.Contains(sentBack, required) {
t.Errorf("the correction does not mention %q:\n%s", required, sentBack)
}
}
if !strings.Contains(result.FinalAnswer, "0.86s") {
t.Errorf("the corrected answer was not the one returned: %q", result.FinalAnswer)
}
}
// AN HONEST ANSWER IS RETURNED UNTOUCHED. A tripwire that fires on a correct
// report gets switched off, and then it catches nothing.
func TestAnAnswerThatMatchesTheTranscriptIsReturnedAsIs(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
provider := callThenAnswer("github.com/x/y took 0.86s.", "should never be reached")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-honest",
Zeromaxing: ZeromaxingActive,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("an honest answer cost %d requests, want 2: the check fired on a correct report", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "0.86s") {
t.Errorf("final answer = %q", result.FinalAnswer)
}
}
// THE CHECK IS PART OF THE POSTURE. With zeromaxing off the run must take
// exactly the path it took before this existed — the same rule every other
// posture behaviour follows.
func TestThePostureOffRunIsNeverSentBackForANumber(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
provider := callThenAnswer("github.com/x/y took 4.20s.", "should never be reached")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-posture-off",
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("a posture-off run cost %d requests, want 2: the check reached a path it must not", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "4.20s") {
t.Errorf("a posture-off run's answer was altered: %q", result.FinalAnswer)
}
}
// AN UNCORRECTED ANSWER IS RETURNED RATHER THAN ASKED AGAIN. The ledger raises
// each name once, so a model that repeats the number ends the run instead of
// looping until the turn budget is gone.
func TestARepeatedNumberEndsTheRunRatherThanLooping(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(goTestTool{output: suiteOutput})
// Says the same wrong number both times.
provider := callThenAnswer("github.com/x/y took 4.20s.", "github.com/x/y took 4.20s.")
result, err := Run(context.Background(), "run the suite", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-stubborn",
Zeromaxing: ZeromaxingActive,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 3 {
t.Fatalf("the run cost %d requests, want exactly 3: one call, one answer, one correction", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "4.20s") {
t.Errorf("final answer = %q", result.FinalAnswer)
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
type safetyTool struct {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// midStreamAbortProvider connects successfully but emits a transport-abort
-69
View File
@@ -1,69 +0,0 @@
package agent
import (
"strings"
"testing"
)
// THE PROVIDER OUTRANKS THE MODEL ID, because the provider knows and the id only
// hints.
//
// A ChatGPT OAuth session was classified correctly for one reason: "gpt-5.5"
// happens to begin with "gpt". Nothing obliges the next id to — OpenAI has
// already shipped o1, o3 and o4 — and the prefix list has to be amended each
// time to keep a first-party provider working. Asking the provider does not.
func TestTheProviderFamilyWinsOverTheModelId(t *testing.T) {
// An id no prefix arm matches, from a provider that declares OpenAI.
if got := modelPromptAddendum("openai", "some-unreleased-id"); got != openAIPromptAddendum {
t.Error("a provider that declares openai did not get the openai addendum for an unrecognised id")
}
if got := modelPromptAddendum("gemini", "some-unreleased-id"); got != geminiPromptAddendum {
t.Error("a provider that declares gemini did not get the gemini addendum")
}
// Anthropic is aligned with the core prompt and takes no addendum, declared
// or guessed. Asserted so "no addendum" stays a decision, not an accident.
if got := modelPromptAddendum("anthropic", "some-unreleased-id"); got != "" {
t.Errorf("anthropic gained an addendum: %q", got)
}
}
// A GATEWAY DECLARES NOTHING, and must fall back to the id — it is the only
// thing that distinguishes openai/gpt-4.1 from z-ai/glm-4.6 on one endpoint.
func TestAGatewayFallsBackToTheModelId(t *testing.T) {
for _, testCase := range []struct {
model string
want string
}{
{"openai/gpt-4.1", openAIPromptAddendum},
{"google/gemini-2.5-pro", geminiPromptAddendum},
{"anthropic/claude-sonnet-4.5", ""},
// Unclassified, and honestly so: no family addendum exists for it.
{"z-ai/glm-4.6", ""},
{"qwen3-coder:480b", ""},
} {
if got := modelPromptAddendum("", testCase.model); got != testCase.want {
t.Errorf("%s: addendum = %q, want %q", testCase.model, truncateForTest(got), truncateForTest(testCase.want))
}
}
}
// The wiring is not decoration: a family that reaches Options must reach the
// prompt. Asserted through the real assembly rather than the helper, because
// asserting the helper is how a value gets threaded to a layer that drops it.
func TestTheDeclaredFamilyReachesTheAssembledPrompt(t *testing.T) {
withProvider := BuildSystemPromptPreview(Options{ModelFamily: "openai", Model: "some-unreleased-id"})
if !strings.Contains(withProvider, "Persist until the task is fully handled this turn") {
t.Error("the openai guidance did not reach the assembled prompt when the provider declared it")
}
withoutProvider := BuildSystemPromptPreview(Options{Model: "some-unreleased-id"})
if strings.Contains(withoutProvider, "Persist until the task is fully handled this turn") {
t.Error("an unclassified model gained openai guidance from nowhere")
}
}
func truncateForTest(text string) string {
if len(text) > 40 {
return text[:40] + "..."
}
return text
}
@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type propagationOutputTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"sync"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"reflect"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// countingSchemaTool records how many times its Parameters() schema is read, so a
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestPermissionScope(t *testing.T) {
@@ -1,104 +0,0 @@
package agent
import (
"context"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
// refusingTool declares that its approval must never be remembered.
type refusingTool struct{ name string }
func (t refusingTool) Name() string { return t.name }
func (refusingTool) Description() string { return "" }
func (refusingTool) Parameters() tools.Schema { return tools.Schema{} }
func (refusingTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
func (refusingTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
func (refusingTool) RefusesPersistentPermission() bool { return true }
// ordinaryTool declares nothing, so the name list decides for it.
type ordinaryTool struct{ name string }
func (t ordinaryTool) Name() string { return t.name }
func (ordinaryTool) Description() string { return "" }
func (ordinaryTool) Parameters() tools.Schema { return tools.Schema{} }
func (ordinaryTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
func (ordinaryTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
// A TOOL'S OWN REFUSAL WINS over the name list.
//
// The list refuses bash, exec_command, write_stdin and apply_patch because
// "always allow" is too broad a standing grant for them. It cannot describe a
// tool that RUNS those — and orchestrate can be given bash by a plan, so
// remembering it would be strictly broader than the refusal the list enforces.
func TestAToolCanRefuseToBeRemembered(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(refusingTool{name: "refuses"})
registry.Register(ordinaryTool{name: "ordinary"})
options := Options{Registry: registry}
if permissionSupportsPersistentDecision("refuses", options) {
t.Fatal("a tool that refuses persistence was offered it")
}
if !permissionSupportsPersistentDecision("ordinary", options) {
t.Fatal("an ordinary tool lost its persistent approval")
}
}
// The NAME LIST still applies to tools that declare nothing, so this adds a
// rule rather than replacing one.
func TestTheExistingNameRefusalsAreUnchanged(t *testing.T) {
options := Options{Registry: tools.NewRegistry()}
for _, name := range []string{"bash", "exec_command", "write_stdin", "apply_patch"} {
if permissionSupportsPersistentDecision(name, options) {
t.Errorf("%q must not be persistently approvable", name)
}
}
for _, name := range []string{"read_file", "write_file", "edit_file"} {
if !permissionSupportsPersistentDecision(name, options) {
t.Errorf("%q lost its persistent approval", name)
}
}
}
// A nil registry must not crash and must not start refusing everything — the
// name list is the fallback, not an empty answer.
func TestPersistentDecisionsSurviveANilRegistry(t *testing.T) {
if permissionSupportsPersistentDecision("bash", Options{}) {
t.Fatal("bash became persistable with no registry")
}
if !permissionSupportsPersistentDecision("read_file", Options{}) {
t.Fatal("read_file lost persistence with no registry")
}
}
// THE CONSEQUENCE, at the decision list a prompt actually offers. Asserting the
// predicate alone would pass against a caller that ignored it.
func TestARefusingToolIsNeverOfferedAlwaysAllow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(refusingTool{name: "refuses"})
registry.Register(ordinaryTool{name: "ordinary"})
engine := sandbox.NewEngine(sandbox.EngineOptions{})
options := Options{Registry: registry, Sandbox: engine}
offered := func(name string) bool {
event := PermissionEvent{ToolName: name, Action: PermissionActionPrompt}
for _, decision := range availablePermissionDecisions(event, nil, options) {
if decision == PermissionDecisionAlwaysAllow {
return true
}
}
return false
}
if offered("refuses") {
t.Fatal("a refusing tool was offered always-allow in the prompt")
}
if !engine.CanPersistGrants() {
t.Skip("this engine cannot persist grants, so the positive case is unreachable")
}
if !offered("ordinary") {
t.Fatal("an ordinary tool lost always-allow; the refusal is being applied to everything")
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func planUpdateTurn(status string) zeroruntime.CollectedStream {
-215
View File
@@ -1,215 +0,0 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// THE ADDITIVITY PROOF.
//
// ZeroMaxing Phase 2 adds an `orchestrate` tool. The constraint proved here is
// that REGISTERING THAT TOOL changes nothing while the posture is OFF: same
// advertised tool set, same tool-definition bytes, same assembled system
// prompt, therefore the same token count and the same provider request.
//
// Stated narrowly on purpose. "With the posture off, Zero is byte-identical to
// a build without the feature" is the tempting phrasing and it is not true:
// system_prompt.go drops the ~5 KB confirmation policy for any run that cannot
// mutate, and runCanMutate is evaluated regardless of posture. An all-read-only
// run — `zero exec --enabled-tools read_file,grep`, or a read-only specialist
// child — therefore gets a smaller prompt than a build without the feature,
// posture off. That drop is deliberate and fails closed; it is simply not part
// of what this test proves, and the comparison below could not catch it anyway
// since both registries take the same branch.
//
// The proof is a CONTROLLED COMPARISON rather than a frozen hash of the whole
// prefix. Two registries are built that differ in exactly one thing — whether
// the orchestrate tool is registered — and the posture-off output of both must
// be byte-identical. That states the real property ("registering this tool
// changes nothing while the posture is off"), it exercises the Deferred()
// mechanism that enforces it, and it cannot rot: there is no constant to
// regenerate and nothing to keep in step by hand.
//
// A frozen whole-prefix hash was tried first and is the wrong instrument here:
// the assembled prompt embeds the working directory and the operating system,
// so the constant would be machine- and platform-specific and every CI runner
// would "fail" honestly-unchanged code. The definition-bytes hash below keeps
// the frozen-golden idea where it IS portable.
// fixtureCwd is a fixed synthetic path. The prompt embeds the working
// directory, so a real t.TempDir() would make any hash unstable across runs.
const fixtureCwd = "/zero/fixture/workspace"
// baseFixtureRegistry is the control: a fixed, representative tool set with NO
// orchestrate tool. Deliberately not the full core set — that would make the
// golden hash churn on every unrelated tool change and train the reader to
// regenerate it.
func baseFixtureRegistry() *tools.Registry {
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(fixtureCwd, nil))
registry.Register(tools.NewScopedGrepTool(fixtureCwd, nil))
registry.Register(tools.NewScopedGlobTool(fixtureCwd, nil))
registry.Register(tools.NewScopedListDirectoryTool(fixtureCwd, nil))
return registry
}
// postureOffPrefix returns the three things a user pays for, for a posture-OFF
// run against the given registry: the advertised tool names in emitted order,
// the full tool-definition bytes, and the assembled system prompt.
func postureOffPrefix(t *testing.T, registry *tools.Registry, mode PermissionMode) (names []string, definitions string, prompt string) {
t.Helper()
options := Options{
Cwd: fixtureCwd,
Registry: registry,
SessionID: "session-posture-off-identity",
Zeromaxing: ZeromaxingOff, // THE POINT: the posture is off.
// Deferral ACTIVE. Without this the partition never consults Deferred()
// at all, and this test would pass on the permission gate alone — which
// it originally did, so a Deferred() regression went undetected by it.
DeferThreshold: 1,
}
exposed, _ := partitionTools(registry, mode, options, nil)
for _, definition := range exposed {
names = append(names, definition.Name)
}
encoded, err := json.Marshal(exposed)
if err != nil {
t.Fatalf("marshal tool definitions: %v", err)
}
return names, string(encoded), buildSystemPromptParts(options).prompt
}
// (a) THE PROOF. Registering the orchestrate tool must change NOTHING that a
// posture-off run sends: not the advertised set, not the definition bytes, not
// the prompt. If this fails, the feature has stopped being additive and the
// diff is what is wrong.
func TestPostureOffPrefixUnchangedByRegisteringTheTool(t *testing.T) {
// BOTH permission modes. Under auto the permission gate would hide the tool
// even if Deferred() were broken, so auto alone proves nothing about the
// mechanism; unsafe is where Deferred() is the ONLY thing standing between
// the tool and the advertised set.
for _, mode := range []PermissionMode{PermissionModeAuto, PermissionModeUnsafe} {
t.Run(string(mode), func(t *testing.T) { assertPostureOffPrefixUnchanged(t, mode) })
}
}
func assertPostureOffPrefixUnchanged(t *testing.T, mode PermissionMode) {
t.Helper()
withoutNames, withoutDefs, withoutPrompt := postureOffPrefix(t, baseFixtureRegistry(), mode)
withTool := baseFixtureRegistry()
registerPhase2ToolForTest(withTool)
withNames, withDefs, withPrompt := postureOffPrefix(t, withTool, mode)
if !reflect.DeepEqual(withoutNames, withNames) {
t.Fatalf("ADVERTISED TOOL SET CHANGED with the posture off:\n without: %v\n with: %v",
withoutNames, withNames)
}
if withoutDefs != withDefs {
t.Fatalf("TOOL DEFINITION BYTES CHANGED with the posture off (%d -> %d bytes)",
len(withoutDefs), len(withDefs))
}
if withoutPrompt != withPrompt {
t.Fatalf("ASSEMBLED PROMPT CHANGED with the posture off (%d -> %d bytes)",
len(withoutPrompt), len(withPrompt))
}
// Guard against the test passing because the tool was never registered at
// all: it must be present in the registry, just not advertised.
if _, ok := withTool.Get(phase2ToolName); !ok {
t.Fatalf("fixture did not register %q, so this test proves nothing", phase2ToolName)
}
}
// The same claim in terms a reader can check without diffing: with the posture
// off the tool is registered but not advertised, and its name appears nowhere
// in what the provider receives.
func TestPostureOffDoesNotAdvertiseThePhase2Tool(t *testing.T) {
registry := baseFixtureRegistry()
registerPhase2ToolForTest(registry)
names, definitions, prompt := postureOffPrefix(t, registry, PermissionModeUnsafe)
for _, name := range names {
if name == phase2ToolName {
t.Fatalf("posture off must not advertise %q, got %v", phase2ToolName, names)
}
}
if strings.Contains(definitions, phase2ToolName) {
t.Fatalf("posture off must not send %q in the tool definitions", phase2ToolName)
}
if strings.Contains(prompt, phase2ToolName) {
t.Fatalf("posture off must not name %q in the system prompt", phase2ToolName)
}
}
// ...and the run-level counterpart: a posture-off conversation carries no
// posture or orchestration text at all.
func TestPostureOffRunCarriesNoPostureText(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registerPhase2ToolForTest(registry)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
if _, err := Run(context.Background(), "hello", provider, Options{
Cwd: root, Registry: registry, SessionID: "session-posture-off-run",
}); err != nil {
t.Fatal(err)
}
var rendered strings.Builder
for _, message := range provider.requests[0].Messages {
rendered.WriteString(message.Content)
}
if strings.Contains(rendered.String(), phase2ToolName) {
t.Fatalf("a posture-off run must never name %q:\n%s", phase2ToolName, rendered.String())
}
for _, forbidden := range []string{
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
} {
if strings.Contains(rendered.String(), forbidden) {
t.Fatalf("a posture-off run must carry no posture text, found %q", forbidden)
}
}
}
// A frozen golden over the TOOL DEFINITION BYTES only. Definitions carry no
// environment (no cwd, no OS), so unlike the whole prefix this IS portable and
// a moved hash means a real schema change in the fixture's tools.
//
// MOVED AGAIN AFTER THE CURRENT MAIN REFRESH. Main's tool descriptions now
// distinguish exact reads from minified exploratory reads while keeping the
// same posture-off exposure contract. Before that, #867 reshaped read_file's
// schema (the canonical
// path/offset/limit contract) and reworded its description; before that, #838
// reworded glob, grep, list_directory and read_file. Both are schema changes on
// main's side rather than posture leaks from this branch, and the distinction was
// proved rather than assumed: the first request body of a posture-off run was
// compared byte for byte against a binary built from that same main, across
// --auto low/medium/high/member and --use-spec, and all five were identical.
// The absolute byte counts moved with main's new wording (33549 -> 31851 on
// --auto low); the DIFFERENCE between the two binaries stayed zero, which is
// the thing this guard exists to hold.
const postureOffDefinitionsFingerprint = "35a12ffe0079ffb18a3342df5e0c3499836d2f2811b94707ae6c337cf5087197"
func TestPostureOffToolDefinitionsMatchGolden(t *testing.T) {
registry := baseFixtureRegistry()
registerPhase2ToolForTest(registry)
_, definitions, _ := postureOffPrefix(t, registry, PermissionModeUnsafe)
sum := sha256.Sum256([]byte(definitions))
got := hex.EncodeToString(sum[:])
if postureOffDefinitionsFingerprint == "PLACEHOLDER" {
t.Fatalf("golden not captured yet; observed %s", got)
}
if got != postureOffDefinitionsFingerprint {
t.Fatalf("posture-off tool definitions changed:\n got %s\n want %s\n%s",
got, postureOffDefinitionsFingerprint, definitions)
}
}
@@ -4,8 +4,8 @@ import (
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
+1 -1
View File
@@ -1,7 +1,7 @@
package agent
import (
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
// profileController observes per-turn run signals and decides at most one
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
Loaded 100 of 996 files, more files were not shown because too many files have changed in this diff. Show more