mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e07cd3ea | ||
|
|
7d16bda4da | ||
|
|
74c06a1358 | ||
|
|
2ddb134071 | ||
|
|
1c1cb94245 | ||
|
|
b4bea8c604 | ||
|
|
9aac8237e3 | ||
|
|
77462b0b31 | ||
|
|
a1a9503b62 | ||
|
|
6474b144ec | ||
|
|
689591d47b | ||
|
|
400e285249 | ||
|
|
6ce0931a63 | ||
|
|
68a60f14b4 | ||
|
|
d37de9214b | ||
|
|
588635487f | ||
|
|
845df6c7d2 | ||
|
|
15eb16f736 | ||
|
|
097c2652cb | ||
|
|
15b7a5475c | ||
|
|
bdbda89f3e | ||
|
|
8acd42a3bd | ||
|
|
1c38e8cba5 | ||
|
|
58daea7a48 | ||
|
|
3e7692d8f0 | ||
|
|
33c94ec7a3 | ||
|
|
c9f9dc02ef | ||
|
|
ec3b822505 | ||
|
|
81a5e6cb71 | ||
|
|
5d1869e7bc | ||
|
|
518d2e53ba | ||
|
|
d82b94defe | ||
|
|
88fb7b6192 | ||
|
|
0125e70d0f | ||
|
|
f5fa4ffc64 | ||
|
|
9fbdc34155 | ||
|
|
583e6530bd | ||
|
|
ac50a5a840 | ||
|
|
a4a805a6c8 | ||
|
|
9fe02ae52e | ||
|
|
9d5c82d441 | ||
|
|
97ef90d27a | ||
|
|
5c58256abc | ||
|
|
fa8734f179 | ||
|
|
5bb1bc11c3 | ||
|
|
d9b882eb0a | ||
|
|
a50574f673 | ||
|
|
c00a4734b5 | ||
|
|
2479884dea | ||
|
|
ab2f9fbd3e | ||
|
|
baa4be13ac | ||
|
|
ddfdf2878e | ||
|
|
96859c9bd1 | ||
|
|
3524f795e5 | ||
|
|
b1f41735a7 | ||
|
|
4945684fa2 | ||
|
|
5140d4bf7b | ||
|
|
3967d49d64 | ||
|
|
89bdc6719a | ||
|
|
722bb31216 | ||
|
|
a21a052a4a | ||
|
|
f079b90f82 | ||
|
|
b30c3971b4 | ||
|
|
da9fb50f54 | ||
|
|
684901165d | ||
|
|
e1975c1b39 | ||
|
|
8c6d3022bd | ||
|
|
974fc036c2 | ||
|
|
4cbd144d11 | ||
|
|
c0b6b9e5c9 | ||
|
|
d74ceb1127 | ||
|
|
fbf85984f6 | ||
|
|
9242b9e037 | ||
|
|
2e267bdbee | ||
|
|
739a47e3ea | ||
|
|
ce4a996ffa | ||
|
|
378d538e24 | ||
|
|
015452c1c9 | ||
|
|
af875df587 | ||
|
|
dbd9443014 | ||
|
|
0fae754460 | ||
|
|
2cc6f43bf7 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
*.go text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.sum text eol=lf
|
||||
@@ -1,5 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and setup help
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
|
||||
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
|
||||
- name: Feature ideas
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
|
||||
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Gitlawb/zero/security/advisories/new
|
||||
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
|
||||
@@ -1,12 +1,22 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or change
|
||||
name: Feature request (previously discussed)
|
||||
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please do not begin implementation until the issue has been reviewed and marked with the
|
||||
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
not here. This form is for the step after that: an idea that gained traction in Discussions,
|
||||
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
|
||||
Do not begin implementation until the issue has been reviewed and marked with the
|
||||
`issue-approved` label (see CONTRIBUTING.md).
|
||||
- type: input
|
||||
id: discussion
|
||||
attributes:
|
||||
label: Ideas discussion
|
||||
description: Link to the Discussions Ideas thread (or note which maintainer asked for this issue).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
|
||||
+33
-12
@@ -30,6 +30,36 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install Make on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: choco install make --no-progress -y
|
||||
|
||||
- name: Validate quality targets with stock macOS Make
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 1
|
||||
GIT_CONFIG_KEY_0: grep.patternType
|
||||
GIT_CONFIG_VALUE_0: fixed
|
||||
run: make -n vulncheck
|
||||
|
||||
- name: Check vulnerabilities through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: make vulncheck
|
||||
|
||||
- name: Check dead code through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make deadcode
|
||||
|
||||
- name: Run static lint through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make lint-static
|
||||
|
||||
- name: Check formatting
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
@@ -100,27 +130,18 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||
# their own modules, which can drop below the version go.mod requires and
|
||||
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||
# so all three run under it.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
|
||||
|
||||
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||
- name: govulncheck
|
||||
run: go run golang.org/x/vuln/cmd/[email protected] ./...
|
||||
run: make vulncheck
|
||||
|
||||
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
||||
# code is visible in CI. Non-blocking while the dormant subsystems are
|
||||
# still being wired or removed.
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||
run: make deadcode
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
@@ -130,4 +151,4 @@ jobs:
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||
run: make lint-static
|
||||
@@ -55,3 +55,6 @@ docs/superpowers/
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
zero-seccomp
|
||||
|
||||
# Generated benchmark reports are evidence for one configuration, never tree state
|
||||
internal/perfbench/reports/*.json
|
||||
@@ -1 +1 @@
|
||||
{".":"0.4.0"}
|
||||
{".":"0.6.0"}
|
||||
@@ -51,10 +51,8 @@ request, or completing an implementation task:
|
||||
or run focused tests with `-race`, when concurrency is affected.
|
||||
4. **Build**: `go run ./cmd/zero-release build`.
|
||||
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
||||
6. **Advisory lint**:
|
||||
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
7. **Security**:
|
||||
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
6. **Advisory lint**: `make lint-static`.
|
||||
7. **Security**: `make vulncheck`.
|
||||
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
||||
tracked changes).
|
||||
|
||||
|
||||
@@ -6,6 +6,84 @@ 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.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add persistent session goals ([#803](https://github.com/Gitlawb/zero/issues/803)) ([5d1869e](https://github.com/Gitlawb/zero/commit/5d1869e7bc193c97e07418b57b823905c1151d5d))
|
||||
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
|
||||
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
|
||||
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
|
||||
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
|
||||
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
|
||||
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
|
||||
* **cron:** reserve job IDs atomically ([#686](https://github.com/Gitlawb/zero/issues/686)) ([d9b882e](https://github.com/Gitlawb/zero/commit/d9b882eb0a94f38de8447cb54b57860ccd8ed053))
|
||||
* **lint:** resolve static analysis baseline ([#769](https://github.com/Gitlawb/zero/issues/769)) ([583e653](https://github.com/Gitlawb/zero/commit/583e6530bd849a8efcb2b88bbe8c297e8592e62c))
|
||||
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
|
||||
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
|
||||
* **sandbox:** clarify scoped permission prompts ([#798](https://github.com/Gitlawb/zero/issues/798)) ([9fe02ae](https://github.com/Gitlawb/zero/commit/9fe02ae52e3234cb9dae76e0c5594a287ccbb52a))
|
||||
* **sandbox:** deny reads of git's credential stores ([#815](https://github.com/Gitlawb/zero/issues/815)) ([#816](https://github.com/Gitlawb/zero/issues/816)) ([33c94ec](https://github.com/Gitlawb/zero/commit/33c94ec7a3e7987e6e36b5fd5e037ff4e6e84c26))
|
||||
* **sandbox:** preserve user config and retry denied writes ([#801](https://github.com/Gitlawb/zero/issues/801)) ([81a5e6c](https://github.com/Gitlawb/zero/commit/81a5e6cb716ba4b7ecf7ff3ff261dfa51fd01098))
|
||||
* **sandbox:** scrub the daemon token file pointer ([#677](https://github.com/Gitlawb/zero/issues/677)) ([#818](https://github.com/Gitlawb/zero/issues/818)) ([3e7692d](https://github.com/Gitlawb/zero/commit/3e7692d8f0885d346809869935c81a9cf055d89e))
|
||||
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
|
||||
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
|
||||
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
|
||||
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
|
||||
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
|
||||
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
|
||||
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
|
||||
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
|
||||
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
|
||||
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
|
||||
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
|
||||
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
|
||||
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
|
||||
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
|
||||
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
|
||||
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
|
||||
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
|
||||
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
|
||||
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
|
||||
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
|
||||
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
|
||||
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
|
||||
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
|
||||
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
|
||||
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
|
||||
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
|
||||
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
|
||||
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
|
||||
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
|
||||
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
|
||||
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
|
||||
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
|
||||
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
|
||||
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
|
||||
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||
|
||||
|
||||
|
||||
+48
-5
@@ -8,6 +8,13 @@ stabilizes. Community participation is still welcome, especially through bug
|
||||
reports, feature requests, reproduction details, logs, and discussion on existing
|
||||
issues.
|
||||
|
||||
Questions and setup help belong in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
|
||||
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
|
||||
an idea that gains traction there is the path to an approved issue and,
|
||||
eventually, an accepted PR.
|
||||
|
||||
Please read this policy before opening a pull request. Pull requests that do not
|
||||
follow it may be closed without review.
|
||||
|
||||
@@ -33,7 +40,11 @@ unless the issue has been explicitly approved for outside contribution.
|
||||
There are three primary ways to contribute:
|
||||
|
||||
1. Open a bug report with clear reproduction steps.
|
||||
2. Open a feature request describing the problem, use case, and expected behavior.
|
||||
2. Propose a feature in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
describing the problem, use case, and expected behavior. A feature-request
|
||||
issue is filed only after the idea gains traction there or a maintainer asks
|
||||
for one.
|
||||
3. Add useful context to an existing issue, such as logs, screenshots, examples,
|
||||
affected versions, or reproduction cases.
|
||||
|
||||
@@ -46,7 +57,8 @@ All community pull requests require an approved parent issue.
|
||||
|
||||
Before starting implementation work:
|
||||
|
||||
1. Open an issue using the appropriate issue template.
|
||||
1. For a bug, open an issue using the bug-report template. For a feature, start
|
||||
in Discussions Ideas; the feature-request issue comes after the discussion.
|
||||
2. Describe the bug, feature request, or functional change clearly.
|
||||
3. Wait for the core team to review the issue.
|
||||
4. Do not open a pull request unless the issue has the `issue-approved` label.
|
||||
@@ -55,6 +67,28 @@ An approved issue means only that the specific issue has been accepted for work.
|
||||
It does not approve unrelated future work, future pull requests, or future issues
|
||||
from the same author. Each issue is reviewed case by case.
|
||||
|
||||
## Contribution Volume and Batching
|
||||
|
||||
To keep triage and review sustainable while the project stabilizes, please keep
|
||||
your contribution flow to a size the core team can realistically work through:
|
||||
|
||||
- One issue per bug. File each defect as its own issue with its own
|
||||
reproduction. Please do not bundle several unrelated findings into a single
|
||||
"audit" issue; separate issues are far easier to triage, approve, and close
|
||||
individually.
|
||||
- Keep only a few items open at a time. If you already have several issues or
|
||||
pull requests awaiting review, let those move through the queue before opening
|
||||
more. A large simultaneous burst from one contributor slows review for
|
||||
everyone, even when each item is valid.
|
||||
- AI-assisted contributions are welcome, held to the same bar. Automated audits
|
||||
and generated patches are fine, but each issue and pull request must be
|
||||
individually verified, scoped, and reproducible before you open it. Please do
|
||||
not post bulk machine-generated findings unfiltered.
|
||||
|
||||
These apply to all contributors and are about review bandwidth, not the value of
|
||||
any single report. Maintainers may ask you to consolidate, split, or hold
|
||||
submissions to keep the queue manageable.
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
Before opening a community pull request, make sure all of the following are true:
|
||||
@@ -133,7 +167,12 @@ Clear bug reports are the most useful way to help the project during this phase.
|
||||
|
||||
## Submitting a Feature Request
|
||||
|
||||
When filing a feature request, please include:
|
||||
Feature requests start as a thread in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
not as an issue. The feature-request issue form exists for the step AFTER that:
|
||||
when an idea has gained traction in Discussions or a maintainer has asked for an
|
||||
issue to track it. Whether in the discussion or the eventual issue, please
|
||||
include:
|
||||
|
||||
- The problem you are trying to solve.
|
||||
- The behavior or functionality you are requesting.
|
||||
@@ -156,5 +195,9 @@ contribution phase.
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you are unsure whether a change is appropriate, open an issue first and ask.
|
||||
Please keep discussion focused, specific, and respectful of maintainer time.
|
||||
General questions, setup help, and "is this a bug or my config" uncertainty all
|
||||
go to
|
||||
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
|
||||
If you are unsure whether a proposed code change is appropriate, raise it in
|
||||
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
|
||||
focused, specific, and respectful of maintainer time.
|
||||
@@ -1,7 +1,13 @@
|
||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
|
||||
# with `make` — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
|
||||
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
|
||||
GO_TOOLCHAIN = go$(GO_VERSION)
|
||||
DEADCODE_VERSION := v0.46.0
|
||||
GOLANGCI_LINT_VERSION := v2.12.2
|
||||
GOVULNCHECK_VERSION := v1.3.0
|
||||
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -33,6 +39,22 @@ fmt-check:
|
||||
# Lint = formatting check + vet (no extra tooling required).
|
||||
lint: fmt-check vet
|
||||
|
||||
# Versioned tools select the toolchain from their own modules when invoked with
|
||||
# package@version. Read this module's go directive directly, without invoking
|
||||
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
||||
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
||||
# The target-specific export is shell-independent.
|
||||
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
|
||||
deadcode:
|
||||
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
|
||||
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -55,4 +77,4 @@ baseline: build
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||
@@ -131,6 +131,7 @@ export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -147,6 +148,12 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
To configure Fireworks AI directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup fireworks --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
@@ -195,7 +202,7 @@ Useful controls:
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
||||
|
||||
Common slash commands:
|
||||
|
||||
@@ -205,6 +212,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
@@ -366,21 +374,24 @@ variable unset and set.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
|
||||
|
||||
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
|
||||
2. **Vetting**: Run `go vet ./...` (or `make vet`).
|
||||
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
|
||||
2. **Vetting**: Run `make vet` (or `go vet ./...`).
|
||||
3. **Linting**: Run `make lint-static`.
|
||||
4. **Vulnerability Scan**: Run `make vulncheck`.
|
||||
|
||||
If you prefer to install these tools globally on your path, you can run:
|
||||
Use the repository-managed targets rather than globally installed binaries:
|
||||
the targets apply the module's required Go toolchain as well as the reviewed,
|
||||
pinned tool versions.
|
||||
|
||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||
run them directly. If it isn't, add it:
|
||||
|
||||
```bash
|
||||
# Install golangci-lint
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
|
||||
# Install govulncheck
|
||||
go install golang.org/x/vuln/cmd/[email protected]
|
||||
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
||||
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
||||
```
|
||||
|
||||
### Cross-Compile Examples
|
||||
@@ -402,6 +413,24 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
- [Performance](docs/PERFORMANCE.md)
|
||||
- [Agent evals](docs/AGENT_EVALS.md)
|
||||
|
||||
## Community
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
| Category | Use it for |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
|
||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
|
||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
|
||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
|
||||
|
||||
For a good Q&A answer fast, include `zero --version`, your OS and install
|
||||
method, the provider/model in use, and `zero doctor` output. See
|
||||
[SUPPORT.md](SUPPORT.md). Bugs belong in
|
||||
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
|
||||
follow [SECURITY.md](SECURITY.md), never a public thread.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Getting help with Zero
|
||||
|
||||
## Questions, setup help, configuration
|
||||
|
||||
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
|
||||
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
|
||||
be marked and found by the next person.
|
||||
|
||||
The fastest way to a useful answer is to include:
|
||||
|
||||
- `zero --version` output
|
||||
- your OS (Windows, macOS, Linux) and how you installed Zero (npm, install
|
||||
script, source)
|
||||
- the provider and model you are using (`zero providers list`)
|
||||
- `zero doctor` output for setup problems
|
||||
- the exact command you ran and the exact error text
|
||||
|
||||
## Bugs
|
||||
|
||||
If Zero does something wrong (crashes, wrong output, broken behavior), file a
|
||||
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
|
||||
sure whether it is a bug or a setup problem, start in Discussions and we will
|
||||
promote it to an issue if it is one.
|
||||
|
||||
## Feature ideas
|
||||
|
||||
Start a thread in
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
|
||||
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
|
||||
also the path to getting a PR accepted.
|
||||
|
||||
## Security issues
|
||||
|
||||
Never file a public issue or discussion. Report privately via
|
||||
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
|
||||
(see SECURITY.md).
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
|
||||
@@ -92,3 +94,18 @@ func TestHelpText(t *testing.T) {
|
||||
func emptyEnv(string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTurnExitCodeFailsWhenEveryTaskErrored(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
allErrored := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 3}
|
||||
if code := turnExitCode(allErrored, &stderr); code != 1 {
|
||||
t.Fatalf("exit=%d, want 1 when every task errored", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no accepted benchmark sample") {
|
||||
t.Fatalf("stderr missing explanation: %q", stderr.String())
|
||||
}
|
||||
partial := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 2}
|
||||
if code := turnExitCode(partial, &stderr); code != 0 {
|
||||
t.Fatalf("exit=%d, want 0 for partial errors (summary surfaces them)", code)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ type turnOptions struct {
|
||||
SuitePath string
|
||||
Model string
|
||||
Mode string
|
||||
ExecProfile string
|
||||
SelfCorrect bool
|
||||
Binary string
|
||||
Iterations int
|
||||
@@ -64,6 +66,7 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
|
||||
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
|
||||
Model: options.Model,
|
||||
Mode: options.Mode,
|
||||
ExecProfile: options.ExecProfile,
|
||||
SelfCorrect: options.SelfCorrect,
|
||||
Version: options.Version,
|
||||
Commit: options.Commit,
|
||||
@@ -86,9 +89,23 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
|
||||
// turnExitCode fails the command when every attempted task errored (no
|
||||
// iteration produced an accepted benchmark sample): such a report measures
|
||||
// nothing, and exiting 0 would let a broken configuration (missing binary, bad
|
||||
// path, failing harness step) pass as a clean baseline. Partial errors keep
|
||||
// exit 0 — the summary surfaces them loudly and the surviving samples are
|
||||
// still valid measurements.
|
||||
func turnExitCode(result perfbench.TurnBenchResult, stderr io.Writer) int {
|
||||
if result.TasksAttempted > 0 && result.TasksErrored == result.TasksAttempted {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -123,6 +140,13 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
|
||||
}
|
||||
options.Mode = value
|
||||
index = next
|
||||
case "--exec-profile":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.ExecProfile = value
|
||||
index = next
|
||||
case "--binary":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
@@ -195,6 +219,20 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
|
||||
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
|
||||
return options, fmt.Errorf("--model is required (or pass --dry-run)")
|
||||
}
|
||||
// Validate the profile here, before anything spawns: an unknown name would
|
||||
// otherwise make every child exit with a usage error in milliseconds, and
|
||||
// those near-zero walls would be recorded as valid latency samples in an
|
||||
// exit-0 report — exactly the misread-as-improvement trap the harness
|
||||
// closed for spawn failures. Normalizing to the catalog name also keeps
|
||||
// the stamped execProfile canonical (Lookup is case-insensitive), so two
|
||||
// captures of the same posture always compare equal.
|
||||
if raw := strings.TrimSpace(options.ExecProfile); raw != "" {
|
||||
profile, ok := execprofile.Lookup(raw)
|
||||
if !ok {
|
||||
return options, fmt.Errorf("unknown execution profile %q for --exec-profile. Valid profiles: %s", raw, strings.Join(execprofile.Names(), ", "))
|
||||
}
|
||||
options.ExecProfile = profile.Name
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
@@ -225,6 +263,9 @@ func turnHelpText() string {
|
||||
" --suite <path> Task set JSON file (required)",
|
||||
" --model <model> Model to run (required unless --dry-run)",
|
||||
" --mode <name> Exec mode preset to apply",
|
||||
" --exec-profile <name>",
|
||||
" Execution profile for every task (fast|balanced|thorough);",
|
||||
" forwarded to zero exec and stamped into the result",
|
||||
" --self-correct Enable the post-edit verify-and-correct loop",
|
||||
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
|
||||
" --iterations <n> Times to run each task (default: 1)",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTurnArgsExecProfile(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json",
|
||||
"--model", "m",
|
||||
"--exec-profile", "fast",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
// Inline form.
|
||||
options, err = parseTurnArgs([]string{
|
||||
"--suite=suite.json",
|
||||
"--model=m",
|
||||
"--exec-profile=thorough",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs inline: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "thorough" {
|
||||
t.Fatalf("ExecProfile = %q, want thorough", options.ExecProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// The bench validates the profile BEFORE spawning anything: an unknown name
|
||||
// would otherwise make every child exit with a usage error in milliseconds
|
||||
// and record those near-zero walls as valid latency samples in an exit-0
|
||||
// report. Lookup normalization also keeps the stamped name canonical so two
|
||||
// captures of the same posture compare equal.
|
||||
func TestParseTurnArgsExecProfileValidatesAndNormalizes(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "FAST",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want the canonical fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
_, err = parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "blanced",
|
||||
}, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown profile must fail parse, before anything spawns")
|
||||
}
|
||||
for _, name := range []string{"balanced", "fast", "thorough"} {
|
||||
if !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error must list %q, got %q", name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
|
||||
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
||||
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove/lock target)
|
||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
@@ -289,6 +289,7 @@ Install and manage:
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
|
||||
@@ -749,7 +749,7 @@ flowchart TD
|
||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
||||
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
||||
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
||||
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
|
||||
`LoadFromRoots`; single-root `Load` remains for install/write.
|
||||
- **Hooks** can observe or block tool lifecycle events.
|
||||
|
||||
## End-to-End Data Flow
|
||||
|
||||
+178
-27
@@ -11,6 +11,9 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -23,9 +26,10 @@ import (
|
||||
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
|
||||
// operation.
|
||||
type Deps struct {
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
|
||||
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
|
||||
// exactly like the exec surface — never run unconfined on the host.
|
||||
@@ -60,13 +64,16 @@ type acpSession struct {
|
||||
// turnMu serializes prompt turns for one session: concurrent session/prompt
|
||||
// calls run one at a time so they can't interleave history or clobber the
|
||||
// single cancel slot.
|
||||
turnMu sync.Mutex
|
||||
turnMu sync.Mutex
|
||||
modelMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string // override; "" => config default
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string
|
||||
models []SessionConfigOptionValue
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -83,9 +90,6 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
return a
|
||||
}
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -120,7 +124,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p NewSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||
@@ -129,18 +133,23 @@ func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil)
|
||||
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
Modes: a.modeState(sess),
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p LoadSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||
@@ -161,7 +170,17 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
|
||||
model = persistedModel
|
||||
if !modelChoiceExists(models, persistedModel) {
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
@@ -169,7 +188,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
historyErr,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
Modes: a.modeState(sess),
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -354,11 +374,27 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if p.ConfigID != configIDModel {
|
||||
switch p.ConfigID {
|
||||
case configIDModel:
|
||||
model := strings.TrimSpace(p.Value)
|
||||
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case configIDMode:
|
||||
mode := agent.PermissionMode(p.Value)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
case agent.PermissionModeUnsafe:
|
||||
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
|
||||
}
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
||||
}
|
||||
sess.setModel(p.Value)
|
||||
return SetSessionConfigOptionResult{}, nil
|
||||
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -370,8 +406,24 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
sess.setModel(p.Model)
|
||||
return ZeroSetModelResult{Model: p.Model}, nil
|
||||
model := strings.TrimSpace(p.Model)
|
||||
if err := a.updateModel(sess, model, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ZeroSetModelResult{Model: model}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
|
||||
sess.modelMu.Lock()
|
||||
defer sess.modelMu.Unlock()
|
||||
if restrictModels && !sess.hasModel(model) {
|
||||
return RPCError(codeInvalidParams, "unknown model: "+model)
|
||||
}
|
||||
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
|
||||
return RPCError(codeInternalError, "save model selection: "+err.Error())
|
||||
}
|
||||
sess.setModel(model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
@@ -398,6 +450,76 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveModelChoices advertises authenticated live models when discovery is
|
||||
// available. The configured model is always retained as the sole fallback.
|
||||
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
|
||||
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
selected := strings.TrimSpace(resolved.Provider.Model)
|
||||
options := make([]SessionConfigOptionValue, 0, 8)
|
||||
seen := make(map[string]bool)
|
||||
add := func(id, description string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
|
||||
}
|
||||
add(selected, "")
|
||||
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
|
||||
restrictModels := knownProvider && !descriptor.Custom
|
||||
if a.deps.DiscoverModels != nil {
|
||||
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
|
||||
if ctx.Err() != nil {
|
||||
return "", nil, false, ctx.Err()
|
||||
}
|
||||
if discoverErr == nil && len(discovered) > 0 {
|
||||
for _, model := range discovered {
|
||||
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
|
||||
add(model.ID, model.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected, options, restrictModels, nil
|
||||
}
|
||||
|
||||
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
|
||||
for _, option := range models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||
model, models, mode := s.configState()
|
||||
return []SessionConfigOption{{
|
||||
ID: configIDModel,
|
||||
Name: "Model",
|
||||
Description: "Model used for this session.",
|
||||
Category: configCategoryModel,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: model,
|
||||
Options: models,
|
||||
}, {
|
||||
ID: configIDMode,
|
||||
Name: "Mode",
|
||||
Description: "Permission mode used for this session.",
|
||||
Category: configCategoryMode,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: string(mode),
|
||||
Options: []SessionConfigOptionValue{
|
||||
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
@@ -525,13 +647,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||
// half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
}
|
||||
@@ -570,7 +692,18 @@ func (s *acpSession) currentMode() agent.PermissionMode {
|
||||
}
|
||||
|
||||
func (s *acpSession) setModel(model string) {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
found := false
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && model != "" {
|
||||
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
|
||||
}
|
||||
s.model = model
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -581,6 +714,24 @@ func (s *acpSession) currentModel() string {
|
||||
return s.model
|
||||
}
|
||||
|
||||
func (s *acpSession) hasModel(model string) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
|
||||
}
|
||||
|
||||
func (s *acpSession) appendHistory(rec turnRecord) {
|
||||
s.mu.Lock()
|
||||
s.history = append(s.history, rec)
|
||||
|
||||
@@ -3,6 +3,7 @@ package acp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -137,6 +139,12 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
||||
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
|
||||
}
|
||||
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
|
||||
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
|
||||
}
|
||||
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
|
||||
}
|
||||
|
||||
// session/prompt
|
||||
var promptRes PromptResult
|
||||
@@ -156,6 +164,201 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "gpt-5.5"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: " gpt-5.4-mini ", Description: "Fast"},
|
||||
{ID: "gpt-5.5", Description: "duplicate configured model"},
|
||||
}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
|
||||
t.Fatalf("new model option = %+v", option)
|
||||
}
|
||||
for _, choice := range option.Options {
|
||||
if choice.Name != choice.Value {
|
||||
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
|
||||
}
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set_config_option: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
|
||||
t.Fatalf("selected model = %q", got)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
|
||||
}, &SetSessionConfigOptionResult{}); err == nil {
|
||||
t.Fatal("unknown standard model selection was accepted")
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
|
||||
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
|
||||
}
|
||||
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, fmt.Errorf("discovery unavailable")
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
|
||||
t.Fatalf("fallback model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "configured-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set custom model: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
|
||||
t.Fatalf("custom model = %q", got)
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
option := loaded.ConfigOptions[0]
|
||||
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
|
||||
t.Fatalf("loaded custom model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
|
||||
}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
|
||||
},
|
||||
}}
|
||||
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
|
||||
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, context.Canceled
|
||||
},
|
||||
}}
|
||||
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("resolve error = %v, want context canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPConfigOptionWireSchema(t *testing.T) {
|
||||
b, err := json.Marshal(SessionConfigOption{
|
||||
ID: "model", Name: "Model", Description: "desc", Category: "model",
|
||||
Type: configOptionTypeSelect, CurrentValue: "m1",
|
||||
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(b, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"type", "category", "currentValue", "options"} {
|
||||
if _, ok := wire[key]; !ok {
|
||||
t.Errorf("wire field %q absent: %s", key, b)
|
||||
}
|
||||
}
|
||||
if _, ok := wire["value"]; ok {
|
||||
t.Errorf("obsolete option value field present: %s", b)
|
||||
}
|
||||
if _, ok := wire["values"]; ok {
|
||||
t.Errorf("obsolete values field present: %s", b)
|
||||
}
|
||||
choice := wire["options"].([]any)[0].(map[string]any)
|
||||
if choice["value"] != "m1" {
|
||||
t.Errorf("options[].value = %#v", choice["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPUnknownSessionPromptErrors(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
@@ -181,6 +384,15 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
|
||||
t.Fatalf("set_mode ask: %v", err)
|
||||
}
|
||||
var configured SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
|
||||
}, &configured); err != nil {
|
||||
t.Fatalf("set_config_option mode: %v", err)
|
||||
}
|
||||
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("configured mode = %q", got)
|
||||
}
|
||||
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected Unsafe mode to be rejected over ACP")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package acp
|
||||
|
||||
import "context"
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
+138
-6
@@ -17,6 +17,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// JSON-RPC 2.0 standard error codes.
|
||||
@@ -80,8 +81,9 @@ type NotifyFunc func(ctx context.Context, params json.RawMessage)
|
||||
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
||||
// the client for session/request_permission, fs/*, terminal/*).
|
||||
type Conn struct {
|
||||
reader *bufio.Reader
|
||||
w io.Writer
|
||||
rawReader io.Reader // wrapped lazily in Serve, once ctx is known — see interruptibleReader
|
||||
readerCloser io.Closer
|
||||
w io.Writer
|
||||
|
||||
writeMu sync.Mutex // serializes all writes to w
|
||||
|
||||
@@ -96,10 +98,15 @@ type Conn struct {
|
||||
wg sync.WaitGroup // tracks in-flight inbound handlers
|
||||
}
|
||||
|
||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w.
|
||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w. This
|
||||
// Conn does not own r: cancelling Serve's context cannot forcibly close it, so
|
||||
// a Read on r that's genuinely idle (never returns on its own) leaves Serve
|
||||
// blocked until r itself produces something — e.g. a test that closes r's own
|
||||
// write side to unblock it. Use NewOwnedConn when this Conn should own r's
|
||||
// lifetime.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
reader: bufio.NewReader(r),
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
@@ -107,6 +114,18 @@ func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
}
|
||||
}
|
||||
|
||||
// NewOwnedConn is like NewConn but declares that this Conn exclusively owns r:
|
||||
// if r implements io.Closer, Serve closes it on context cancellation to
|
||||
// interrupt an otherwise-idle blocking Read. Only pass a reader whose lifetime
|
||||
// this Conn should control — e.g. the process's own stdin — never a reader a
|
||||
// caller retains or shares elsewhere, since cancellation tears it down
|
||||
// permanently for every consumer, not just this Conn.
|
||||
func NewOwnedConn(r io.Reader, w io.Writer) *Conn {
|
||||
c := NewConn(r, w)
|
||||
c.readerCloser, _ = r.(io.Closer)
|
||||
return c
|
||||
}
|
||||
|
||||
// Handle registers a request handler for method.
|
||||
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
||||
|
||||
@@ -128,17 +147,36 @@ func (c *Conn) Serve(ctx context.Context) error {
|
||||
c.wg.Wait()
|
||||
}()
|
||||
|
||||
interruptible := newInterruptibleReader(ctx, c.rawReader, c.readerCloser)
|
||||
reader := bufio.NewReader(interruptible)
|
||||
|
||||
for {
|
||||
// One ndjson value per line. Framing per line (rather than streaming the
|
||||
// decoder) keeps a single malformed line from making the whole connection
|
||||
// unrecoverable — we report -32700 and continue.
|
||||
line, err := c.reader.ReadBytes('\n')
|
||||
//
|
||||
// generation brackets this SPECIFIC ReadBytes call: bufio may invoke
|
||||
// interruptible.Read zero or more times underneath it (zero when the
|
||||
// answer is already sitting in bufio's own buffer — including a
|
||||
// previously-buffered, not-yet-surfaced error: bufio can return a
|
||||
// complete line with a nil error while quietly caching an error it
|
||||
// received ALONGSIDE that line's bytes, which then surfaces on the VERY
|
||||
// NEXT call with no further contact with interruptible.Read at all). The
|
||||
// generation only advances inside interruptible.Read's own cancellation
|
||||
// branch, so "unchanged across this call" is proof this call's outcome
|
||||
// didn't pass through that branch — whether because it was answered from
|
||||
// the buffer or because it raced ctx.Done() and won. Only that proof
|
||||
// makes it safe to call the outcome genuine.
|
||||
before := interruptible.generation()
|
||||
line, err := reader.ReadBytes('\n')
|
||||
interrupted := interruptible.generation() != before
|
||||
|
||||
if len(bytes.TrimSpace(line)) > 0 {
|
||||
c.handleLine(ctx, line)
|
||||
}
|
||||
if err != nil {
|
||||
c.failAllPending(err)
|
||||
if errors.Is(err, io.EOF) || ctx.Err() != nil {
|
||||
if errors.Is(err, io.EOF) || interrupted {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
@@ -146,6 +184,100 @@ func (c *Conn) Serve(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// interruptibleReader wraps a reader so a context cancellation can unblock a
|
||||
// currently in-flight Read without ever discarding — or misattributing — the
|
||||
// real outcome of a call that wasn't actually blocked.
|
||||
//
|
||||
// Each Read runs the underlying call on its own goroutine and races it against
|
||||
// ctx.Done(). If the underlying call finishes first, its result is returned
|
||||
// exactly as received; ctx being cancelled a moment later has no bearing on a
|
||||
// call that already had its answer. If ctx.Done() fires first, closer (when
|
||||
// non-nil) is closed to nudge a genuinely blocked read, the generation counter
|
||||
// is bumped, and the call STILL waits for and returns the underlying call's own
|
||||
// result — never a synthesized one — since closing a reader's outcome is
|
||||
// whatever the reader itself reports for it, not ours to invent.
|
||||
//
|
||||
// The counter is what lets Serve tell a call that was actually raced against
|
||||
// cancellation apart from one merely answered while cancellation happened to be
|
||||
// pending — the distinction the read loop's own doc comment explains.
|
||||
type interruptibleReader struct {
|
||||
inner io.Reader
|
||||
closer io.Closer
|
||||
ctx context.Context
|
||||
|
||||
closeOnce sync.Once
|
||||
gen atomic.Int64
|
||||
}
|
||||
|
||||
func newInterruptibleReader(ctx context.Context, inner io.Reader, closer io.Closer) *interruptibleReader {
|
||||
return &interruptibleReader{inner: inner, closer: closer, ctx: ctx}
|
||||
}
|
||||
|
||||
func (r *interruptibleReader) generation() int64 { return r.gen.Load() }
|
||||
|
||||
type interruptibleReadResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
|
||||
// interruptibleReadDecision arbitrates whether an underlying Read completed or
|
||||
// cancellation claimed it first. Completion is recorded before the result is
|
||||
// published so cancellation cannot misclassify a finished read merely because
|
||||
// its goroutine has not sent the result yet.
|
||||
type interruptibleReadDecision struct {
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (d *interruptibleReadDecision) complete() { d.once.Do(func() {}) }
|
||||
|
||||
func (d *interruptibleReadDecision) interrupt() (claimed bool) {
|
||||
d.once.Do(func() { claimed = true })
|
||||
return claimed
|
||||
}
|
||||
|
||||
// readInner records completion in the Read call's deferred epilogue. It also
|
||||
// converts a reader panic into a terminal error so the helper goroutine always
|
||||
// publishes a result for Read to join.
|
||||
func (r *interruptibleReader) readInner(p []byte, decision *interruptibleReadDecision) (res interruptibleReadResult) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
res = interruptibleReadResult{err: fmt.Errorf("acp: reader panicked: %v", recovered)}
|
||||
}
|
||||
decision.complete()
|
||||
}()
|
||||
res.n, res.err = r.inner.Read(p)
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *interruptibleReader) Read(p []byte) (int, error) {
|
||||
resultCh := make(chan interruptibleReadResult, 1)
|
||||
var decision interruptibleReadDecision
|
||||
go func() {
|
||||
resultCh <- r.readInner(p, &decision)
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.n, res.err
|
||||
case <-r.ctx.Done():
|
||||
// resultCh may not be readable yet even when inner.Read has returned: its
|
||||
// goroutine can be descheduled between recording completion and publishing
|
||||
// the result. Only interrupt a call that cancellation actually claims.
|
||||
if decision.interrupt() {
|
||||
r.gen.Add(1)
|
||||
if r.closer != nil {
|
||||
r.closeOnce.Do(func() { _ = r.closer.Close() })
|
||||
}
|
||||
}
|
||||
// Wait for the real result rather than returning synthetically: p is
|
||||
// still being written by the goroutine above until it does, so returning
|
||||
// early would race that write, and the actual error (whatever closing
|
||||
// the reader produced, or whatever the reader was already about to
|
||||
// report) is what the caller should see.
|
||||
res := <-resultCh
|
||||
return res.n, res.err
|
||||
}
|
||||
}
|
||||
|
||||
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
||||
// with -32700 (id null) and keeps the connection alive.
|
||||
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
|
||||
@@ -3,11 +3,30 @@ package acp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testReadCloser struct {
|
||||
read func([]byte) (int, error)
|
||||
close func() error
|
||||
}
|
||||
|
||||
func (r testReadCloser) Read(p []byte) (int, error) { return r.read(p) }
|
||||
func (r testReadCloser) Close() error { return r.close() }
|
||||
|
||||
type testReader func([]byte) (int, error)
|
||||
|
||||
func (r testReader) Read(p []byte) (int, error) { return r(p) }
|
||||
|
||||
type testWriter func([]byte) (int, error)
|
||||
|
||||
func (w testWriter) Write(p []byte) (int, error) { return w(p) }
|
||||
|
||||
// connPair wires two Conns together over in-memory pipes and serves both.
|
||||
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||
t.Helper()
|
||||
@@ -25,6 +44,275 @@ func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServeCancellationInterruptsIdleRead(t *testing.T) {
|
||||
pipeReader, writer := io.Pipe()
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
// Buffered + non-blocking send, not close: interruptibleReader retries each
|
||||
// underlying Read on its own goroutine, so if bufio ever calls this more than
|
||||
// once, a close() here would panic on the second call.
|
||||
readStarted := make(chan struct{}, 1)
|
||||
closeCalls := make(chan struct{}, 2)
|
||||
reader := testReadCloser{
|
||||
read: func(p []byte) (int, error) {
|
||||
select {
|
||||
case readStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return pipeReader.Read(p)
|
||||
},
|
||||
close: func() error {
|
||||
closeCalls <- struct{}{}
|
||||
return pipeReader.Close()
|
||||
},
|
||||
}
|
||||
conn := NewOwnedConn(reader, io.Discard)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
<-readStarted
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned %v after cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after cancelling an idle connection")
|
||||
}
|
||||
if got := len(closeCalls); got != 1 {
|
||||
t.Fatalf("reader Close calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServePreservesTerminalReadErrorDuringCancellation(t *testing.T) {
|
||||
for _, closable := range []bool{false, true} {
|
||||
name := "non-closable"
|
||||
if closable {
|
||||
name = "closable"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
return copy(p, "not json"), wantErr
|
||||
})
|
||||
closeCalls := make(chan struct{}, 1)
|
||||
var reader io.Reader = read
|
||||
if closable {
|
||||
reader = testReadCloser{
|
||||
read: read,
|
||||
close: func() error {
|
||||
closeCalls <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
// Buffered + non-blocking send, not close: a later change adding a
|
||||
// second write (e.g. an additional error frame) must not panic on a
|
||||
// repeated close of an already-closed channel.
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
writer := testWriter(func(p []byte) (int, error) {
|
||||
select {
|
||||
case writeStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-releaseWrite
|
||||
return len(p), nil
|
||||
})
|
||||
newConn := NewConn
|
||||
if closable {
|
||||
// Ownership matters here: this case exists to prove that even a
|
||||
// Conn entitled to close its reader on cancellation still
|
||||
// preserves a terminal error that was already decided.
|
||||
newConn = NewOwnedConn
|
||||
}
|
||||
conn := newConn(reader, writer)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
<-writeStarted
|
||||
cancel()
|
||||
close(releaseWrite)
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Serve returned %v, want terminal read error %v", err, wantErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after terminal read error")
|
||||
}
|
||||
if got := len(closeCalls); got != 0 {
|
||||
t.Fatalf("reader Close calls = %d, want 0 after terminal read", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnServePreservesReadErrorBufferedAlongsideAPriorLine is the deterministic
|
||||
// regression test for jatmn's #782 finding: a real, non-EOF ReadBytes failure can
|
||||
// be DECIDED before cancellation ever happens, yet only SURFACE afterward, and
|
||||
// must still be reported rather than swallowed as a clean shutdown.
|
||||
//
|
||||
// bufio.Reader can obtain data AND a terminal error from ONE underlying Read call
|
||||
// (io.Reader explicitly permits returning (n>0, err) together). If a delimiter is
|
||||
// found within that data, ReadBytes returns the line with a nil error for THIS
|
||||
// call, but bufio caches the error internally and returns it — WITHOUT calling the
|
||||
// underlying reader again — on the very next ReadBytes call. This test forces that
|
||||
// exact sequence: the underlying reader hands back one complete, validly-framed
|
||||
// line together with wantErr in a SINGLE call (asserted below to be its ONLY
|
||||
// call), the loop dispatches that line through a synchronous path that blocks the
|
||||
// read loop, cancellation is asserted to have happened before the loop attempts
|
||||
// its next read, and only then is the block released. By the time the read loop
|
||||
// asks for more input, cancellation is already in effect and the underlying
|
||||
// reader is never touched again — so if Serve attributed the resulting error to
|
||||
// cancellation merely because ctx was already done, it would incorrectly return
|
||||
// nil. It must instead recognize that this read was answered entirely from
|
||||
// bufio's own cache, never raced against ctx.Done(), and report wantErr.
|
||||
func TestConnServePreservesReadErrorBufferedAlongsideAPriorLine(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
var readCalls atomic.Int32
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
readCalls.Add(1)
|
||||
// An unsupported jsonrpc version with an id takes the SYNCHRONOUS
|
||||
// writeError path in handleLine (not a dispatched goroutine), giving this
|
||||
// test a reliable blocking point between processing this line and the
|
||||
// loop's next ReadBytes call.
|
||||
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
||||
})
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
var releaseWriteOnce sync.Once
|
||||
release := func() { releaseWriteOnce.Do(func() { close(releaseWrite) }) }
|
||||
t.Cleanup(release)
|
||||
writer := testWriter(func(p []byte) (int, error) {
|
||||
select {
|
||||
case writeStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-releaseWrite
|
||||
return len(p), nil
|
||||
})
|
||||
conn := NewConn(read, writer)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
// The synchronous write confirms the first (and, per the panic guard above,
|
||||
// only) read has already returned and is being processed — cancelling now
|
||||
// lands squarely in the gap between that read and the loop's next one, never
|
||||
// racing an in-flight interruptibleReader.Read call.
|
||||
select {
|
||||
case <-writeStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not reach the synchronous write")
|
||||
}
|
||||
cancel()
|
||||
release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Serve returned %v, want the buffered terminal read error %v", err, wantErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after the buffered terminal read error")
|
||||
}
|
||||
if got := readCalls.Load(); got != 1 {
|
||||
t.Fatalf("underlying Read calls = %d, want exactly 1 (bufio must have served the second ReadBytes from its cache)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptibleReaderPrefersDecidedResultOverCancellation is the regression
|
||||
// test for jatmn's second #782 finding: interruptibleReader.Read's select
|
||||
// between resultCh and ctx.Done() chooses pseudo-randomly when both are ready,
|
||||
// so a real, already-decided outcome (e.g. a terminal transport error) could be
|
||||
// misattributed to cancellation purely because ctx also happened to be done by
|
||||
// the time the select ran. Each iteration lets the underlying Read return (so
|
||||
// resultCh already holds the real outcome) before cancelling, maximizing the
|
||||
// odds of landing exactly in that ambiguous window; the generation counter must
|
||||
// never advance when the real, already-decided result is what gets returned.
|
||||
func TestInterruptibleReaderPrefersDecidedResultOverCancellation(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
for i := 0; i < 100; i++ {
|
||||
resultReady := make(chan struct{})
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
defer close(resultReady)
|
||||
return copy(p, "x"), wantErr
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := newInterruptibleReader(ctx, read, nil)
|
||||
|
||||
readDone := make(chan interruptibleReadResult, 1)
|
||||
go func() {
|
||||
n, err := r.Read(make([]byte, 8))
|
||||
readDone <- interruptibleReadResult{n, err}
|
||||
}()
|
||||
|
||||
<-resultReady
|
||||
time.Sleep(time.Millisecond) // bias resultCh's send ahead of cancel
|
||||
cancel()
|
||||
|
||||
res := <-readDone
|
||||
if !errors.Is(res.err, wantErr) {
|
||||
t.Fatalf("iteration %d: Read returned %v, want %v", i, res.err, wantErr)
|
||||
}
|
||||
if got := r.generation(); got != 0 {
|
||||
t.Fatalf("iteration %d: generation = %d, want 0 (a decided result must never be attributed to cancellation)", i, got)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptibleReadCompletionWinsBeforeResultPublication exercises the
|
||||
// actual inner-read wrapper at the handoff between the underlying Read and the
|
||||
// helper goroutine's result publication. Cancellation must not reclassify a
|
||||
// result returned by this wrapper as interrupted.
|
||||
func TestInterruptibleReadCompletionWinsBeforeResultPublication(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
return copy(p, "x"), wantErr
|
||||
})
|
||||
r := newInterruptibleReader(context.Background(), read, nil)
|
||||
var decision interruptibleReadDecision
|
||||
readComplete := make(chan struct{})
|
||||
resultCh := make(chan interruptibleReadResult)
|
||||
go func() {
|
||||
res := r.readInner(make([]byte, 8), &decision)
|
||||
close(readComplete)
|
||||
resultCh <- res
|
||||
}()
|
||||
|
||||
<-readComplete
|
||||
if decision.interrupt() {
|
||||
t.Fatal("cancellation claimed a read that completed before result publication")
|
||||
}
|
||||
res := <-resultCh
|
||||
if res.n != 1 || !errors.Is(res.err, wantErr) {
|
||||
t.Fatalf("inner read result = (%d, %v), want (1, %v)", res.n, res.err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServeReportsReaderPanicAsTerminalError(t *testing.T) {
|
||||
read := testReader(func([]byte) (int, error) {
|
||||
panic("boom")
|
||||
})
|
||||
conn := NewConn(read, io.Discard)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(context.Background()) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
const want = "acp: reader panicked: boom"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("Serve returned %v, want %q", err, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after reader panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnRequestResponse(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
+19
-19
@@ -103,10 +103,6 @@ type ContentBlock struct {
|
||||
|
||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||
@@ -233,10 +229,6 @@ func ToolContent(block ContentBlock) ToolCallContent {
|
||||
return ToolCallContent{Type: "content", Content: &block}
|
||||
}
|
||||
|
||||
func ToolDiff(path, oldText, newText string) ToolCallContent {
|
||||
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
|
||||
}
|
||||
|
||||
type ToolCallLocation struct {
|
||||
Path string `json:"path"`
|
||||
Line *int `json:"line,omitempty"`
|
||||
@@ -337,19 +329,22 @@ type SetSessionModeParams struct {
|
||||
|
||||
type SetSessionModeResult struct{}
|
||||
|
||||
// ---- session config options (model selection) ----
|
||||
// ---- session config options ----
|
||||
|
||||
type SessionConfigOptionValue struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type SessionConfigOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Values []SessionConfigOptionValue `json:"values,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Type string `json:"type"`
|
||||
CurrentValue string `json:"currentValue"`
|
||||
Options []SessionConfigOptionValue `json:"options"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionParams struct {
|
||||
@@ -373,6 +368,11 @@ type ZeroSetModelResult struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
|
||||
// through the standard session/set_config_option method.
|
||||
const configIDModel = "model"
|
||||
const (
|
||||
configIDModel = "model"
|
||||
configIDMode = "mode"
|
||||
|
||||
configOptionTypeSelect = "select"
|
||||
configCategoryModel = "model"
|
||||
configCategoryMode = "mode"
|
||||
)
|
||||
@@ -5,9 +5,29 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type normalizationRecordingShellTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *normalizationRecordingShellTool) Name() string { return "bash" }
|
||||
func (tool *normalizationRecordingShellTool) Description() string {
|
||||
return "record normalized shell arguments"
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: true}
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (tool *normalizationRecordingShellTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
@@ -16,7 +36,7 @@ import (
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -51,7 +71,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -88,7 +108,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
@@ -117,7 +137,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
@@ -144,3 +164,120 @@ func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T)
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
||||
for _, mode := range []string{
|
||||
string(tools.SandboxPermissionsUseDefault),
|
||||
string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
} {
|
||||
args := map[string]any{
|
||||
"command": "./zero --version",
|
||||
"sandbox_permissions": mode,
|
||||
"additional_permissions": map[string]any{
|
||||
"file_system": map[string]any{
|
||||
"deny_read": []any{},
|
||||
"entries": []any{},
|
||||
"read": []any{},
|
||||
"write": []any{},
|
||||
},
|
||||
"network": map[string]any{"enabled": false},
|
||||
},
|
||||
}
|
||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("mode %q retained empty additional_permissions: %#v", mode, args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("mode %q normalized to %q, want use_default", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallNormalizesProviderExpandedEmptyAdditionalPermissions(t *testing.T) {
|
||||
tool := &normalizationRecordingShellTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tool)
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"./zero --version","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"file_system":{"deny_read":[],"entries":[],"read":[],"write":[]},"network":{"enabled":false}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{Cwd: t.TempDir()},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("provider-expanded empty permissions were rejected: %s", result.Output)
|
||||
}
|
||||
if len(tool.calls) != 1 {
|
||||
t.Fatalf("shell tool calls = %d, want 1", len(tool.calls))
|
||||
}
|
||||
args := tool.calls[0]
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("executeToolCall retained empty additional_permissions: %#v", args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("sandbox mode = %q, want use_default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdditionalPermissionsPreservesRealRequest(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
normalizeNoopAdditionalPermissions(args, t.TempDir(), nil)
|
||||
if _, exists := args["additional_permissions"]; !exists {
|
||||
t.Fatal("non-empty additional_permissions was removed")
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsWithAdditionalPermissions) {
|
||||
t.Fatalf("sandbox mode changed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdditionalPermissionsRemovesWorkspaceReadAlreadyCoveredBySandbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
})
|
||||
args := map[string]any{
|
||||
"command": "./zero --version",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"file_system": map[string]any{
|
||||
"entries": []any{
|
||||
map[string]any{
|
||||
"access": "read",
|
||||
"path": map[string]any{
|
||||
"type": "path",
|
||||
"path": ".",
|
||||
},
|
||||
},
|
||||
},
|
||||
"deny_read": []any{},
|
||||
"read": []any{},
|
||||
"write": []any{},
|
||||
},
|
||||
"network": map[string]any{"enabled": false},
|
||||
},
|
||||
}
|
||||
|
||||
normalizeNoopAdditionalPermissions(args, root, engine)
|
||||
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
t.Fatalf("already-covered additional_permissions was retained: %#v", args)
|
||||
}
|
||||
if got := args["sandbox_permissions"]; got != string(tools.SandboxPermissionsUseDefault) {
|
||||
t.Fatalf("sandbox mode changed to %q", got)
|
||||
}
|
||||
}
|
||||
@@ -501,15 +501,3 @@ func hasStringPrefix(values []string, prefix []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -56,6 +56,10 @@ type CompactionOptions struct {
|
||||
// injected so Compact stays pure and testable; the agent loop wires it to a
|
||||
// real provider call.
|
||||
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
||||
// taskState is a snapshot supplied by the running agent. Its immutable
|
||||
// objective is always preserved; mutable fields are admitted only when its
|
||||
// plan projection still matches the transcript.
|
||||
taskState *taskStateSnapshot
|
||||
}
|
||||
|
||||
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
||||
@@ -220,7 +224,7 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
||||
|
||||
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
||||
compacted = append(compacted, messages[:systemEnd]...)
|
||||
@@ -326,6 +330,7 @@ type compactionState struct {
|
||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
@@ -365,13 +370,15 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
return int(float64(raw) * state.calibrationRatio)
|
||||
}
|
||||
|
||||
func newCompactionState(options Options) *compactionState {
|
||||
return &compactionState{
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
@@ -406,6 +413,14 @@ func (state *compactionState) maybeCompact(
|
||||
// the bodies of old, large tool results (the model has already acted on
|
||||
// them). If that brings us back under threshold, skip the paid summarizer
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
}
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
@@ -418,6 +433,7 @@ func (state *compactionState) maybeCompact(
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
// Summarizer failed: keep the original history. The reactive path (or a
|
||||
@@ -468,6 +484,7 @@ func (state *compactionState) recover(
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
// A genuine compaction attempt was made (and failed): the budget is spent
|
||||
|
||||
@@ -30,6 +30,10 @@ const (
|
||||
const (
|
||||
maxRecentEdits = 20
|
||||
maxEditNoteBytes = 160
|
||||
// maxTaskObjectiveBytes keeps the original objective recognizable after
|
||||
// compaction without allowing an unusually large prompt to recreate the
|
||||
// context pressure the compaction just removed.
|
||||
maxTaskObjectiveBytes = 512
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -76,32 +80,14 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
|
||||
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
||||
// "" on malformed arguments or an empty plan.
|
||||
func formatPlanArguments(arguments string) string {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Content string `json:"content"`
|
||||
Step string `json:"step"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(parsed.Plan))
|
||||
for _, item := range parsed.Plan {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(item.Step)
|
||||
}
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
status := strings.TrimSpace(item.Status)
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
line := "- [" + status + "] " + content
|
||||
if notes := strings.TrimSpace(item.Notes); notes != "" {
|
||||
lines := make([]string, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
line := "- [" + item.Status + "] " + item.Content
|
||||
if notes := item.Notes; notes != "" {
|
||||
line += "\n Notes: " + notes
|
||||
}
|
||||
lines = append(lines, line)
|
||||
@@ -431,12 +417,25 @@ func capBody(body string) string {
|
||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||
type preservedState struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Task *preservedTaskState `json:"task,omitempty"`
|
||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||
Tools []preservedTool `json:"tools,omitempty"`
|
||||
Skills []preservedSkill `json:"skills,omitempty"`
|
||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
Path string `json:"path"`
|
||||
Note string `json:"note,omitempty"`
|
||||
@@ -464,8 +463,27 @@ type preservedInstruction struct {
|
||||
// may live only inside the injected summary message, which on a later compaction
|
||||
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
||||
// instruction blocks override the carried-forward copy by name/source.
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
|
||||
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
// even after compaction removes the plan tool call needed for comparison.
|
||||
if taskSnapshot.PlanParity == taskPlanParityMatch {
|
||||
task.Status = taskSnapshot.Status
|
||||
task.Pending = taskSnapshot.Plan.Pending
|
||||
task.InProgress = taskSnapshot.Plan.InProgress
|
||||
task.Completed = taskSnapshot.Plan.Completed
|
||||
task.Failed = taskSnapshot.Plan.Failed
|
||||
task.VerificationPassed = taskSnapshot.Verification.Passed
|
||||
task.VerificationFailed = taskSnapshot.Verification.Failed
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -493,12 +511,25 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
|
||||
summary += "\n\n" + block
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
return objective
|
||||
}
|
||||
const suffix = "…"
|
||||
limit := maxTaskObjectiveBytes - len(suffix)
|
||||
for limit > 0 && !utf8.RuneStart(objective[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(objective[:limit]) + suffix
|
||||
}
|
||||
|
||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||
@@ -555,11 +586,11 @@ func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||
|
||||
// formatPreservedState renders state as the labelled, single-line
|
||||
// JSON block. Returns "" when there is nothing to preserve.
|
||||
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
return ""
|
||||
}
|
||||
state := preservedState{Plan: plan}
|
||||
state := preservedState{Plan: plan, Task: task}
|
||||
for _, e := range edits {
|
||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||
}
|
||||
@@ -579,15 +610,6 @@ func formatPreservedState(plan string, edits, tools, skills, instructions []skil
|
||||
return preservedStateLabel + "\n" + string(encoded)
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
func parsePreservedStateBlock(summaryContent string) preservedState {
|
||||
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
@@ -59,6 +60,103 @@ func TestCompactPreservesActivePlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
|
||||
t.Fatalf("unexpected compact task state: %#v", state.Task)
|
||||
}
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
|
||||
encoded, err := json.Marshal(prior)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
}
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: &taskStateSnapshot{
|
||||
Objective: "current objective",
|
||||
PlanParity: taskPlanParityMismatch,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "current objective" {
|
||||
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.Pending != 0 {
|
||||
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
|
||||
task := newTaskState("keep this objective", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Compact: %v", err)
|
||||
}
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
)
|
||||
snapshot := task.snapshotForCompaction(secondInput)
|
||||
if snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
|
||||
}
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "keep this objective" {
|
||||
t.Fatalf("objective lost after second compaction: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.InProgress != 0 {
|
||||
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
||||
summary := compactStateConversation(t, stateConversation())
|
||||
if !strings.Contains(summary, preservedStateLabel) {
|
||||
@@ -283,12 +381,10 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
|
||||
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
|
||||
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
||||
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q in formatted plan, got %q", want, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -364,7 +364,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
},
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -379,6 +379,43 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeCompactStopsAfterSupersededReadsReachThreshold(t *testing.T) {
|
||||
body := strings.Repeat("same source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
||||
}
|
||||
pruned, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed <= 0 {
|
||||
t.Fatal("test setup did not reclaim superseded read output")
|
||||
}
|
||||
prunedSize := estimateTokens(pruned)
|
||||
if estimateTokens(messages) <= prunedSize {
|
||||
t.Fatal("test setup does not cross the compaction threshold")
|
||||
}
|
||||
|
||||
provider := &summarizeRecordingProvider{}
|
||||
state := &compactionState{
|
||||
enabled: true,
|
||||
threshold: prunedSize,
|
||||
preserveLast: 2,
|
||||
}
|
||||
got := state.maybeCompact(context.Background(), provider, messages, nil)
|
||||
|
||||
if provider.summarizeCalls != 0 {
|
||||
t.Fatalf("cheap pruning should avoid the summarizer, got %d calls", provider.summarizeCalls)
|
||||
}
|
||||
if state.lowWaterMark != prunedSize {
|
||||
t.Fatalf("lowWaterMark = %d, want %d", state.lowWaterMark, prunedSize)
|
||||
}
|
||||
if !strings.Contains(got[2].Content, "superseded identical") || got[4].Content != body {
|
||||
t.Fatalf("unexpected pruned messages: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// reactiveProvider builds up history with a tool call on turn 1, then errors
|
||||
// with a context-limit message on turn 2 (once the history is large enough that
|
||||
// compaction can shrink it), then succeeds on the same-turn retry. Summary
|
||||
@@ -427,7 +464,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// the compaction closure. read_file also returns a sizeable result that
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -491,7 +528,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
var deltas []string
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
@@ -581,7 +618,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -628,7 +665,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverDisabledIsNoop(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 0})
|
||||
st := newCompactionState(Options{ContextWindow: 0}, nil)
|
||||
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
||||
called := false
|
||||
// recover must not invoke the provider/summarize when disabled, even on a
|
||||
|
||||
@@ -39,7 +39,7 @@ func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
|
||||
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
||||
}
|
||||
|
||||
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
|
||||
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
|
||||
// A direct admission is strong local evidence and takes precedence over all
|
||||
// weaker signals, avoiding both continuation nudges and semantic checks.
|
||||
if reason := selfReportedIncompletion(text); reason != "" {
|
||||
@@ -50,7 +50,7 @@ func (policy *completionPolicy) evaluate(text string, planPending bool) completi
|
||||
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
||||
// to continue, but only a persisted cue is ultimately incomplete.
|
||||
cue := endsWithContinuationCue(text)
|
||||
if cue || planPending {
|
||||
if cue || context.PlanPending {
|
||||
if policy.continueNudges < maxContinueNudges {
|
||||
policy.continueNudges++
|
||||
reason := "your message ended mid-step"
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
|
||||
complete := policy.evaluate("Done. All required checks pass.", false)
|
||||
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
|
||||
if complete.Decision != CompletionComplete {
|
||||
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
||||
}
|
||||
|
||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
|
||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
|
||||
if incomplete.Decision != CompletionIncomplete {
|
||||
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
||||
}
|
||||
@@ -19,13 +22,13 @@ func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T)
|
||||
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", true)
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionIncomplete {
|
||||
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
||||
}
|
||||
@@ -34,13 +37,13 @@ func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("All set.", true)
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("All set.", true)
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionComplete {
|
||||
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
||||
}
|
||||
@@ -49,12 +52,12 @@ func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(true)
|
||||
|
||||
first := policy.evaluate("Implemented and tested.", false)
|
||||
first := policy.evaluate("Implemented and tested.", completionContext{})
|
||||
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
||||
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
||||
}
|
||||
|
||||
second := policy.evaluate("PASS. The result meets the task criterion.", false)
|
||||
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
|
||||
if second.Decision != CompletionComplete {
|
||||
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
||||
}
|
||||
@@ -62,3 +65,11 @@ func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||
t.Fatal("semantic check was requested more than once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
|
||||
objective := "make the requested behavior observable"
|
||||
nudge := acceptanceVerificationNudge(objective)
|
||||
if !strings.Contains(nudge, "Task objective: "+objective) {
|
||||
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
|
||||
@@ -341,15 +341,15 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||
}
|
||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||
if !strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("stable tool_search catalog must retain already-loaded tool names, got %q", search.Description)
|
||||
}
|
||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
func TestPartitionToolsActiveNothingHiddenKeepsStableCatalog(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -368,9 +368,8 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// No hidden tools means no dynamic discovery text is needed.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("stable catalog must retain all deferred names after loading, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||
DisabledTools: []string{"read_file"},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -127,9 +126,8 @@ func endsWithContinuationCue(text string) bool {
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
switch normalizeTaskPlanStatus(status) {
|
||||
case "completed", "failed":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -307,8 +305,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge() string {
|
||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
func acceptanceVerificationNudge(objective string) string {
|
||||
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
@@ -316,6 +314,10 @@ func acceptanceVerificationNudge() string {
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
if objective = capTaskObjective(objective); objective != "" {
|
||||
nudge += "\n\nTask objective: " + objective
|
||||
}
|
||||
return nudge
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
@@ -553,16 +555,12 @@ func (state *guardState) pendingPlanItems() bool {
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range parsed.Plan {
|
||||
for _, item := range plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -169,6 +169,37 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of #702's fix: the unknown-session error's normalized
|
||||
// signature must not vary with the session id, so a model probing ids 1, 2,
|
||||
// 3, … produces one repeated signature the failure guard can count. If the id
|
||||
// leaked into the first 80 normalized chars, each probe would reset the streak
|
||||
// and the halt would never fire.
|
||||
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
|
||||
a := errorSignature(tools.UnknownExecSessionError(1))
|
||||
b := errorSignature(tools.UnknownExecSessionError(999999))
|
||||
if a != b {
|
||||
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: with an id-invariant signature, probing a different unknown id
|
||||
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
|
||||
// before the fix it never would.
|
||||
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
|
||||
var state guardState
|
||||
var stoppedAt int
|
||||
for i := 1; i <= toolFailureStopAt; i++ {
|
||||
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
|
||||
if out.Stop {
|
||||
stoppedAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if stoppedAt != toolFailureStopAt {
|
||||
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||
var state guardState
|
||||
toolOnly := zeroruntime.CollectedStream{
|
||||
@@ -229,7 +260,7 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -260,7 +291,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -288,7 +319,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{
|
||||
@@ -318,7 +349,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
||||
@@ -352,7 +383,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
turns := [][]zeroruntime.StreamEvent{
|
||||
@@ -387,7 +418,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||
|
||||
+260
-109
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -181,10 +182,46 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.runPermissions = runPermissions
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
|
||||
promptParts := buildSystemPromptParts(options)
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
|
||||
guards := newGuardState()
|
||||
compactor := newCompactionState(options)
|
||||
task := newTaskState(prompt, options.Trace)
|
||||
compactor := newCompactionState(options, task)
|
||||
defer func() {
|
||||
// A final transcript comparison is observational. It records drift but
|
||||
// never rewrites the result or changes execution after the fact.
|
||||
task.observePlanParity(result.Messages)
|
||||
// Tool-result observations are intentionally coalesced; this final event
|
||||
// guarantees their aggregate counts are present even on an early return.
|
||||
task.emit()
|
||||
}()
|
||||
// The execution-profile posture controller. nil Options.Profile (every
|
||||
// existing caller) makes every observe/decide call a no-op, keeping the
|
||||
// loop byte-identical for unprofiled runs.
|
||||
posture := newProfileController(options.Profile)
|
||||
// applyPosture applies at most one posture escalation when an armed trigger
|
||||
// has fired: raise the hoisted turn ceiling, restore effort and the
|
||||
// completion gate, stamp the counter. Shared by the end-of-turn tail and
|
||||
// the completion-gate uncertain path (which continues the loop before the
|
||||
// tail runs). No-op forever after the first escalation and for unprofiled
|
||||
// runs.
|
||||
applyPosture := func() {
|
||||
if target, fired := posture.maybeEscalate(); fired {
|
||||
if target.MaxTurns > maxTurns {
|
||||
maxTurns = target.MaxTurns
|
||||
}
|
||||
if target.ReasoningEffort != "" {
|
||||
options.ReasoningEffort = target.ReasoningEffort
|
||||
} else if target.RestoreDefaultEffort {
|
||||
options.ReasoningEffort = ""
|
||||
}
|
||||
if target.RestoreCompletionGate {
|
||||
options.RequireCompletionSignal = true
|
||||
}
|
||||
options.Trace.Counter(trace.CounterPostureEscalations, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Background post-edit diagnostics: files changed by mutating tools are
|
||||
// checked off the tool-call critical path and any errors are appended as a
|
||||
@@ -230,10 +267,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// the messages, so computing it before compaction is safe.
|
||||
// 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
|
||||
@@ -242,16 +275,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Prompt-prefix fingerprint: hash the seven cacheable sub-components
|
||||
// of this turn's request (system prompt sections, project context,
|
||||
// skills, partitioned tool list, tool schemas) and emit one trace
|
||||
// event. Stable across turns => cacheable. Drift in any sub-hash
|
||||
// names the sub-component that broke the cache for this turn. The
|
||||
// computation is pure and cheap (seven SHA-256s of small strings)
|
||||
// and is a no-op when tracing is off.
|
||||
// Fingerprint the exact system prompt built at run start plus this turn's
|
||||
// provider-visible tool definitions in their emitted order. Reusing the
|
||||
// retained prompt parts avoids rebuilding workspace context while ensuring
|
||||
// the trace describes the same bytes carried by the request.
|
||||
if options.Trace != nil {
|
||||
fp := ComputePrefixFingerprint(options, exposed)
|
||||
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fp.SystemPromptHash,
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
@@ -545,7 +576,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// model's final answer ONLY when the work is actually done. Default off
|
||||
// (RequireCompletionSignal), so interactive runs stay byte-identical.
|
||||
if options.RequireCompletionSignal {
|
||||
evaluation := completionPolicy.evaluate(collected.Text, guards.pendingPlanItems())
|
||||
completionContext := task.completionContext(messages, guards.pendingPlanItems())
|
||||
evaluation := completionPolicy.evaluate(collected.Text, completionContext)
|
||||
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: evaluation})
|
||||
switch evaluation.Decision {
|
||||
case CompletionIncomplete:
|
||||
result.Incomplete = true
|
||||
@@ -554,6 +587,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
case CompletionUncertain:
|
||||
// Observe the uncertainty signal and apply any escalation NOW:
|
||||
// this branch continues the turn loop before the end-of-turn
|
||||
// act point, so deferring would skip escalation entirely.
|
||||
posture.observeUncertain()
|
||||
applyPosture()
|
||||
switch evaluation.Action {
|
||||
case completionActionContinue:
|
||||
options.Trace.Counter(trace.CounterCompletionNudges, 1)
|
||||
@@ -565,13 +603,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: acceptanceVerificationNudge(),
|
||||
Content: acceptanceVerificationNudge(completionContext.Objective),
|
||||
})
|
||||
}
|
||||
continue
|
||||
case CompletionComplete:
|
||||
// Local evidence is sufficient; proceed to final diagnostics.
|
||||
}
|
||||
} else {
|
||||
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||
}
|
||||
// Finalization diagnostics gate: edits from this run may still have
|
||||
// checks in flight — the per-turn drain waits only briefly and defers
|
||||
@@ -595,6 +635,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// executing so the empty-turn counter resets and plan-tracking signals
|
||||
// stay current.
|
||||
guards.observeTurn(collected)
|
||||
for _, call := range collected.ToolCalls {
|
||||
if call.Name == planToolName {
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: call.Arguments})
|
||||
}
|
||||
}
|
||||
|
||||
failureHint := ""
|
||||
// turnRequestedModel records the FIRST mid-run escalation target requested
|
||||
@@ -646,6 +691,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -692,6 +738,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// would misdirect the model toward JSON shape or blocked behavior.
|
||||
retriableFailure := isRetriableToolError(toolResult)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
|
||||
posture.observeToolOutcome(outcome, toolResult)
|
||||
if outcome.Stop {
|
||||
// The assistant message advertised EVERY collected tool call, but
|
||||
// the guard halts mid-turn so the calls after this one never run.
|
||||
@@ -724,7 +771,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// the assistant's tool_results stay contiguous (a user message between
|
||||
// tool_results breaks strict provider replay). nil SelfCorrect is a no-op.
|
||||
if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 {
|
||||
if feedback, _ := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch)); feedback != "" {
|
||||
feedback, selfCorrectOutcome := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch))
|
||||
posture.observeSelfCorrect(selfCorrectOutcome)
|
||||
task.observe(taskStateEvent{kind: taskStateEventVerification, verification: selfCorrectOutcome})
|
||||
if feedback != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: feedback,
|
||||
@@ -817,6 +867,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
|
||||
// One-shot posture escalation: when an armed profile trigger fired this
|
||||
// turn, restore the stricter knob values the profile displaced. Mutates
|
||||
// only per-turn-read policy (the hoisted turn ceiling, reasoning effort,
|
||||
// completion gate); never messages, model, or session. No-op forever
|
||||
// after the first escalation and for every unprofiled run.
|
||||
applyPosture()
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
@@ -1019,6 +1076,9 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if isShellCommandTool(call.Name) {
|
||||
normalizeNoopAdditionalPermissions(args, options.Cwd, options.Sandbox)
|
||||
}
|
||||
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
|
||||
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
|
||||
// discovery tool exposed to the model rejects at dispatch time (an inescapable
|
||||
@@ -1110,6 +1170,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
decisionCommandPrefix = grant.Prefix
|
||||
}
|
||||
}
|
||||
// An explicit additional-permissions request is an elevation only when it
|
||||
// expands the current effective policy. If the same capability was already
|
||||
// approved for this session, reuse that grant across equivalent commands
|
||||
// instead of prompting again merely because their command prefixes differ.
|
||||
if toolFound && !permissionGranted && isShellCommandTool(call.Name) && options.Sandbox != nil {
|
||||
if profile, requested, profileErr := inlineAdditionalPermissionsProfile(args, options.Cwd); profileErr == nil && requested && options.Sandbox.CoversRequestPermissions(profile) {
|
||||
permissionGranted = true
|
||||
decisionAction = PermissionDecisionAllowForSession
|
||||
decisionReason = "requested sandbox capabilities already granted for this session"
|
||||
}
|
||||
}
|
||||
|
||||
var preflightDecision *sandbox.Decision
|
||||
if toolFound && options.Sandbox != nil {
|
||||
@@ -1286,7 +1357,8 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Cwd: options.Cwd,
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// a file that changed on disk outside Zero since it was last read.
|
||||
FileTracker: options.FileTracker,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
|
||||
// synchronously inside edit_file/write_file and block every mutating tool
|
||||
// call on the language server (≥300ms debounce floor, 10s cap). The loop
|
||||
@@ -1338,20 +1410,37 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
result = registry.RebudgetAfterHook(call.Name, args, result)
|
||||
}
|
||||
}
|
||||
result = registry.CommitFileObservation(result, options.FileTracker)
|
||||
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
|
||||
// observers can see the risk of an allowed mutation. Prefer the preflight
|
||||
// decision's classification when the sandbox evaluated the call; otherwise
|
||||
// run the same pure classifier the permission path uses. Denied/canceled
|
||||
// results returned earlier keep the zero value.
|
||||
executedRisk := sandbox.Risk{}
|
||||
if preflightDecision != nil {
|
||||
executedRisk = preflightDecision.Risk
|
||||
} else if toolFound {
|
||||
// Unknown-tool results fall through here with a nil tool; they carry a
|
||||
// denial and keep the zero risk value.
|
||||
executedRisk = sandbox.Classify(sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
|
||||
}
|
||||
|
||||
// Secret scrubbing happens at the registry boundary (the single point both
|
||||
// the agent loop and the MCP server pass through), so result.Output is
|
||||
// already redacted here and result.Redacted reflects whether it changed.
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
// the Run turn loop performs the actual provider switch. Empty for every
|
||||
@@ -1471,8 +1560,15 @@ func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, resu
|
||||
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
|
||||
return false
|
||||
}
|
||||
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
||||
return false
|
||||
if result.ExecutionOutcome != nil {
|
||||
denial := result.ExecutionOutcome.Denial
|
||||
if denial == nil || denial.Capability.Kind != execution.CapabilityExternalNetwork {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return false
|
||||
@@ -1525,6 +1621,17 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
|
||||
}
|
||||
|
||||
func sandboxDeniedShellResult(result tools.Result) bool {
|
||||
if result.ExecutionOutcome != nil {
|
||||
// Only a typed platform denial that explicitly requests unrestricted
|
||||
// recovery may enter the legacy retry path. Narrow capability denials
|
||||
// remain on their exact capability path.
|
||||
denial := result.ExecutionOutcome.Denial
|
||||
return denial != nil &&
|
||||
denial.Source == execution.DenialSourcePlatformSandbox &&
|
||||
denial.Capability.Kind == execution.CapabilityUnrestricted &&
|
||||
denial.Recoverable &&
|
||||
denial.NextAction == execution.DenialNextActionRequestApproval
|
||||
}
|
||||
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
||||
}
|
||||
|
||||
@@ -1588,20 +1695,21 @@ func sandboxRestrictionRetryReason(result tools.Result) string {
|
||||
|
||||
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1616,20 +1724,21 @@ func unsandboxedRetryArgs(args map[string]any) map[string]any {
|
||||
|
||||
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
||||
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: string(permissionMode),
|
||||
Autonomy: options.Autonomy,
|
||||
Sandbox: options.Sandbox,
|
||||
ToolCallID: toolCallID,
|
||||
SessionID: options.SessionID,
|
||||
Model: options.Model,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
FileTracker: options.FileTracker,
|
||||
DeferFileObservationCommit: true,
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
Progress: progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1653,17 +1762,18 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
}
|
||||
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
RequestedModel: meta["escalate_to_model"],
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
RequestedModel: meta["escalate_to_model"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1923,15 +2033,16 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
Cwd: options.Cwd,
|
||||
})
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: result.Display,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
}
|
||||
}
|
||||
return ToolResult{
|
||||
@@ -2495,7 +2606,8 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
|
||||
scopeText = requestPermissionsScope(profile)
|
||||
}
|
||||
reason = userFacingPermissionReason(call.Name, args, reason)
|
||||
decisionReason := strings.TrimSpace(reason)
|
||||
reason = userFacingPermissionReason(call.Name, args, reason, safety.Reason)
|
||||
|
||||
return PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
@@ -2508,6 +2620,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
Autonomy: options.Autonomy,
|
||||
SideEffect: string(safety.SideEffect),
|
||||
Reason: reason,
|
||||
DecisionReason: decisionReason,
|
||||
Scope: scopeText,
|
||||
Risk: risk,
|
||||
Block: block,
|
||||
@@ -2517,18 +2630,31 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}, true
|
||||
}
|
||||
|
||||
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
|
||||
func userFacingPermissionReason(toolName string, args map[string]any, reason string, fallback string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return reason
|
||||
}
|
||||
if justification, ok := firstStringArg(args, "justification"); ok {
|
||||
return justification
|
||||
|
||||
// Policy and sandbox explanations are authoritative. The tool's static
|
||||
// safety copy is only an internal fallback; presenting it as the reason for
|
||||
// every ordinary shell approval makes prompts generic and obscures the cases
|
||||
// where policy has an actionable explanation.
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
if reason != "" && reason != fallback && reason != "tool requires approval before execution" && reason != sandbox.ReasonEscalatedSandboxRequired {
|
||||
return reason
|
||||
}
|
||||
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
|
||||
|
||||
if shellCommandRequiresEscalated(args) {
|
||||
if justification, ok := firstStringArg(args, "justification"); ok {
|
||||
return justification
|
||||
}
|
||||
return "This command needs to run outside the sandbox."
|
||||
}
|
||||
return reason
|
||||
|
||||
// Ordinary shell approvals need no invented explanation. The raw fallback is
|
||||
// retained separately as DecisionReason for traces and policy auditing.
|
||||
return ""
|
||||
}
|
||||
|
||||
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
|
||||
@@ -2629,6 +2755,45 @@ func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
|
||||
// normalizeNoopAdditionalPermissions treats a provider-expanded empty or
|
||||
// already-covered additional_permissions object as if it had been omitted.
|
||||
// Some strict-schema providers materialize every optional property and may add
|
||||
// a workspace read that the default sandbox already grants; rejecting either
|
||||
// no-op makes the model retry an otherwise valid command. Requests that broaden
|
||||
// access and malformed payloads are left untouched so validation stays closed.
|
||||
func normalizeNoopAdditionalPermissions(args map[string]any, basePath string, engine *sandbox.Engine) {
|
||||
raw, exists := args["additional_permissions"]
|
||||
if !exists || raw == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var profile sandbox.RequestPermissionProfile
|
||||
if err := json.Unmarshal(data, &profile); err != nil {
|
||||
return
|
||||
}
|
||||
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
noop := normalized.Empty()
|
||||
if !noop && engine != nil {
|
||||
if grantProfile, grantErr := sandbox.RequestPermissionGrantProfile(normalized); grantErr == nil {
|
||||
noop = engine.CoversRequestPermissions(grantProfile)
|
||||
}
|
||||
}
|
||||
if !noop {
|
||||
return
|
||||
}
|
||||
delete(args, "additional_permissions")
|
||||
if rawMode, ok := args["sandbox_permissions"].(string); ok &&
|
||||
strings.TrimSpace(rawMode) == string(tools.SandboxPermissionsWithAdditionalPermissions) {
|
||||
args["sandbox_permissions"] = string(tools.SandboxPermissionsUseDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
|
||||
if !shellCommandAdditionalPermissionsRequested(args) {
|
||||
if _, exists := args["additional_permissions"]; exists {
|
||||
@@ -2749,19 +2914,6 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
||||
}
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
|
||||
// partitionToolsCached is partitionTools with an optional per-tool definition
|
||||
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
|
||||
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
|
||||
@@ -2832,22 +2984,20 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// 2. tool_search — always present, right after the stable block;
|
||||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||
// transition grows the tail instead of inserting into the eager block.
|
||||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||
// tool_search's compact catalog also stays byte-stable as tools load, so only
|
||||
// the appended full-schema tail changes.
|
||||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||
var hiddenTools []tools.Tool
|
||||
var deferredCatalog []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue // added explicitly between the eager block and the loaded tail
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
deferredCatalog = append(deferredCatalog, tool)
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -2860,15 +3010,16 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
return loadedTail[left].Name < loadedTail[right].Name
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
// Keep the catalog byte-stable as tools load. tool_search already redirects
|
||||
// requests for an exposed tool back to the current tool list, so retaining
|
||||
// loaded names costs only compact catalog lines and avoids invalidating the
|
||||
// provider cache at the loader definition on every load.
|
||||
discovery := tools.BuildToolSearchDescription(deferredCatalog)
|
||||
|
||||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||
// eager block and carries the discovery list for still-hidden tools.
|
||||
// eager block and carries the stable deferred-tool catalog.
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
|
||||
+198
-56
@@ -8,10 +8,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
@@ -25,6 +27,75 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
options := Options{Sandbox: engine}
|
||||
legacyMeta := map[string]string{
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
|
||||
}
|
||||
|
||||
applicationFailure := tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Meta: legacyMeta,
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateFailed,
|
||||
Kind: execution.OutcomeApplicationFailure,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
},
|
||||
}
|
||||
if sandboxDeniedNetworkRetryCandidate(call, nil, applicationFailure, options) || sandboxRestrictedShellRetryCandidate(call, nil, applicationFailure, options) {
|
||||
t.Fatal("typed application failure must not be retried from legacy sandbox-like text")
|
||||
}
|
||||
|
||||
protectedDenial := applicationFailure
|
||||
protectedDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
|
||||
Source: execution.DenialSourceConfiguredPolicy,
|
||||
Reason: "protected metadata",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, protectedDenial, options) {
|
||||
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
nonRecoverableDenial := protectedDenial
|
||||
nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox enforcement failed closed",
|
||||
Recoverable: false,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) {
|
||||
t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
networkDenial := protectedDenial
|
||||
networkDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityExternalNetwork},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "external network denied",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if !sandboxDeniedNetworkRetryCandidate(call, nil, networkDenial, options) {
|
||||
t.Fatal("typed external-network denial should use the narrow network approval path")
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests = append(provider.requests, request)
|
||||
|
||||
@@ -233,6 +304,18 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any)
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox blocked command execution",
|
||||
Recoverable: true,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
Meta: map[string]string{
|
||||
"exit_code": "1",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
@@ -249,7 +332,8 @@ type sandboxDeniedExecCommandRetryTool struct {
|
||||
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
|
||||
|
||||
type sandboxNetworkDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
calls []map[string]any
|
||||
deferFileObservationCommit []bool
|
||||
}
|
||||
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
|
||||
@@ -272,6 +356,7 @@ func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[str
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
|
||||
if shellNetworkAllowed(ctx, options.Sandbox, args) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "server started"}
|
||||
}
|
||||
@@ -314,7 +399,8 @@ func agentNativeBackendStub() sandbox.Backend {
|
||||
}
|
||||
|
||||
type sandboxNamespaceLimitedRetryTool struct {
|
||||
calls []map[string]any
|
||||
calls []map[string]any
|
||||
deferFileObservationCommit []bool
|
||||
}
|
||||
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
|
||||
@@ -333,8 +419,12 @@ func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) RunWithOptions(_ context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
tool.deferFileObservationCommit = append(tool.deferFileObservationCommit, options.DeferFileObservationCommit)
|
||||
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
|
||||
}
|
||||
@@ -400,6 +490,9 @@ func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing
|
||||
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
|
||||
}
|
||||
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
|
||||
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
|
||||
}
|
||||
@@ -551,6 +644,9 @@ func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
|
||||
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
|
||||
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
|
||||
}
|
||||
if len(retryTool.deferFileObservationCommit) != 2 || !retryTool.deferFileObservationCommit[1] {
|
||||
t.Fatalf("retry defer flags = %#v, want retry observation deferred", retryTool.deferFileObservationCommit)
|
||||
}
|
||||
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("permission requests = %#v, want one network approval", requests)
|
||||
}
|
||||
@@ -808,9 +904,6 @@ func TestRunReportsTruncationFinishReason(t *testing.T) {
|
||||
if result.FinishReason != zeroruntime.FinishReasonLength {
|
||||
t.Fatalf("FinishReason = %q, want %q", result.FinishReason, zeroruntime.FinishReasonLength)
|
||||
}
|
||||
if !result.Truncated() {
|
||||
t.Fatal("Truncated() = false, want true for a length-capped response")
|
||||
}
|
||||
if result.TruncationNotice() == "" {
|
||||
t.Fatal("TruncationNotice() empty for a truncated response")
|
||||
}
|
||||
@@ -828,7 +921,7 @@ func TestRunNormalCompletionIsNotTruncated(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Truncated() || result.TruncationNotice() != "" {
|
||||
if result.TruncationNotice() != "" {
|
||||
t.Fatalf("normal completion reported as truncated: reason=%q", result.FinishReason)
|
||||
}
|
||||
}
|
||||
@@ -905,7 +998,7 @@ func TestRunEmitsUsageEvents(t *testing.T) {
|
||||
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1112,9 +1205,9 @@ func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewGrepTool(root))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedGrepTool(root, nil))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1142,7 +1235,7 @@ func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1179,7 +1272,7 @@ func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
func TestRunRejectsToolCallsOutsideEnabledList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1217,7 +1310,7 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1258,10 +1351,50 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesRequestPrefixAcrossTurns(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.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "read notes", provider, Options{
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
SessionID: "session-stable-prefix",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("provider requests = %d, want 2", len(provider.requests))
|
||||
}
|
||||
first, second := provider.requests[0], provider.requests[1]
|
||||
if len(second.Messages) < len(first.Messages) || !reflect.DeepEqual(second.Messages[:len(first.Messages)], first.Messages) {
|
||||
t.Fatalf("first request messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", first.Messages, second.Messages)
|
||||
}
|
||||
if !reflect.DeepEqual(first.Tools, second.Tools) {
|
||||
t.Fatalf("tool definitions drifted between turns:\nfirst=%#v\nsecond=%#v", first.Tools, second.Tools)
|
||||
}
|
||||
if first.PromptCacheKey != "session-stable-prefix" || second.PromptCacheKey != first.PromptCacheKey {
|
||||
t.Fatalf("prompt cache key must remain stable: first=%q second=%q", first.PromptCacheKey, second.PromptCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1329,7 +1462,7 @@ func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1376,8 +1509,8 @@ func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1452,7 +1585,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
// after a later call in the same turn supersedes it.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1509,7 +1642,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1552,7 +1685,7 @@ func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1608,7 +1741,7 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1657,7 +1790,7 @@ func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing
|
||||
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1705,7 +1838,7 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write should not continue")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1801,7 +1934,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
@@ -1873,7 +2006,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1954,7 +2087,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2156,7 +2289,7 @@ func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
|
||||
@@ -2263,7 +2396,7 @@ func TestRunPersistentCommandPrefixStillPromptsForNetwork(t *testing.T) {
|
||||
t.Fatalf("seed command prefix: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2312,7 +2445,11 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "PATH=.:$PATH curl https://example.com"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
if windowsTestUsesPowerShell() {
|
||||
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
|
||||
} else {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
}
|
||||
fakeCurl := filepath.Join(root, "curl.cmd")
|
||||
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2324,7 +2461,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2391,7 +2528,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2433,13 +2570,17 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
|
||||
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "echo rm -rf /"
|
||||
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
|
||||
command = `Write-Output 'rm -rf /'`
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2503,7 +2644,7 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
// prior code denied it because persistPermissionGrant errors when Sandbox==nil.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2535,6 +2676,12 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func windowsTestUsesPowerShell() bool {
|
||||
executable, _ := tools.HostShellCommand("")
|
||||
name := strings.ToLower(filepath.Base(executable))
|
||||
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
|
||||
}
|
||||
|
||||
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
||||
for _, decision := range decisions {
|
||||
if decision == want {
|
||||
@@ -2567,7 +2714,7 @@ func TestRunCancellationPreservesContextCanceledIdentity(t *testing.T) {
|
||||
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2619,7 +2766,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2798,7 +2945,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2847,7 +2994,7 @@ func TestRunStopsAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
@@ -2877,7 +3024,7 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3068,22 +3215,17 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
wants := []string{"workdir/cwd", "MSYS binaries"}
|
||||
if windowsTestUsesPowerShell() {
|
||||
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
|
||||
} else {
|
||||
wants = append(wants, "cmd.exe", "double quotes")
|
||||
}
|
||||
// The examples must themselves use the safe (double-quoted) form; a
|
||||
// single-quoted example here would teach the model the exact syntax
|
||||
// that fails under cmd.exe.
|
||||
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
|
||||
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
|
||||
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
}
|
||||
@@ -3136,7 +3278,7 @@ func TestBuildSystemPromptAllowsSpecModeOverride(t *testing.T) {
|
||||
func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
specmode.RegisterDraftTools(registry, root, nil)
|
||||
@@ -3173,7 +3315,7 @@ func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("done")
|
||||
|
||||
result, err := Run(context.Background(), "draft", provider, Options{
|
||||
@@ -3206,7 +3348,7 @@ func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3347,7 +3489,7 @@ func TestRunSurfacesDroppedToolCallAlongsideValidCall(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,6 +20,16 @@ type propagationOutputTool struct {
|
||||
|
||||
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||
for index, arg := range os.Args {
|
||||
if arg == "--zero-output-budget-hook-repeat" && index+1 < len(os.Args) {
|
||||
repeats, err := strconv.Atoi(os.Args[index+1])
|
||||
if err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
if _, err := os.Stdout.WriteString(strings.Repeat("hook feedback ", repeats)); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
|
||||
continue
|
||||
}
|
||||
@@ -30,25 +41,102 @@ func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
|
||||
feedback := strings.Repeat("hook feedback ", 200)
|
||||
return outputBudgetHookDispatcherFor("propagation_output", 200)
|
||||
}
|
||||
|
||||
func outputBudgetHookDispatcherFor(toolName string, repeats int) *hooks.Dispatcher {
|
||||
feedback := strings.Repeat("hook feedback ", repeats)
|
||||
hookArgs := []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook",
|
||||
feedback,
|
||||
}
|
||||
if repeats > 1_000 {
|
||||
hookArgs = []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook-repeat",
|
||||
strconv.Itoa(repeats),
|
||||
}
|
||||
}
|
||||
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{{
|
||||
ID: "large-feedback",
|
||||
Event: hooks.EventAfterTool,
|
||||
Matcher: "propagation_output",
|
||||
Matcher: toolName,
|
||||
Command: os.Args[0],
|
||||
Args: []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook",
|
||||
feedback,
|
||||
},
|
||||
Args: hookArgs,
|
||||
Enabled: true,
|
||||
}},
|
||||
}})
|
||||
}
|
||||
|
||||
func TestExecuteToolCallCommitsReadObservationAfterFinalBoundary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkPath := filepath.Join(root, "small.txt")
|
||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
tracker := tools.NewFileTracker()
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "read-small", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
||||
}, PermissionModeAuto, Options{Cwd: root, FileTracker: tracker})
|
||||
if abortErr != nil || result.Status != tools.StatusOK {
|
||||
t.Fatalf("executeToolCall: result=%#v err=%v", result, abortErr)
|
||||
}
|
||||
if !tracker.SeenWhole(resolvedPath) {
|
||||
t.Fatal("unmodified final read output was not committed as observed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallDoesNotCommitReadObservationBeforeHookBudget(t *testing.T) {
|
||||
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
if err := os.WriteFile(targetPath, []byte("known content\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkPath := filepath.Join(root, "small.txt")
|
||||
if err := os.Symlink(targetPath, linkPath); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
resolvedPath, err := filepath.EvalSymlinks(linkPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
tracker := tools.NewFileTracker()
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "read-hook", Name: "read_file", Arguments: `{"path":"small.txt"}`,
|
||||
}, PermissionModeAuto, Options{
|
||||
Cwd: root, FileTracker: tracker, Hooks: outputBudgetHookDispatcherFor("read_file", 20_000),
|
||||
})
|
||||
if abortErr != nil {
|
||||
t.Fatalf("executeToolCall: %v", abortErr)
|
||||
}
|
||||
if !result.Truncated {
|
||||
t.Fatalf("precondition: hook feedback was not truncated: %#v", result)
|
||||
}
|
||||
if tracker.SeenWhole(resolvedPath) {
|
||||
t.Fatal("read observation committed before hook output was budgeted")
|
||||
}
|
||||
}
|
||||
|
||||
func (tool propagationOutputTool) Name() string { return "propagation_output" }
|
||||
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
||||
func (tool propagationOutputTool) Parameters() tools.Schema {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "npm install",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
|
||||
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
|
||||
t.Fatal("an already-covered capability must not trigger another prompt")
|
||||
}
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("covered capability should still produce an auditable allow event")
|
||||
}
|
||||
if event.Action != PermissionActionAllow {
|
||||
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
|
||||
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "make test"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("generic shell approval must produce an event")
|
||||
}
|
||||
if event.Reason != "" {
|
||||
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
|
||||
}
|
||||
if event.DecisionReason != "runs a shell command" {
|
||||
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
|
||||
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "curl https://example.test",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
|
||||
"justification": "Run without sandboxing.",
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("policy approval must produce an event")
|
||||
}
|
||||
if event.Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// profileController observes per-turn run signals and decides at most one
|
||||
// posture escalation per run for the armed execution profile. A nil policy (or
|
||||
// a policy without Escalate) makes every method a no-op, so runs without a
|
||||
// profile are byte-identical to before — the same opt-in convention as Trace
|
||||
// and SelfCorrect.
|
||||
//
|
||||
// The controller only ever CHANGES loop policy knobs (turn ceiling, reasoning
|
||||
// effort, completion gate) through the values the loop applies at its act
|
||||
// point; it never injects messages, never touches the model or session, and
|
||||
// never interacts with the model-initiated escalate_model path.
|
||||
type profileController struct {
|
||||
policy *PostureEscalation
|
||||
|
||||
escalated bool
|
||||
uncertainSeen int
|
||||
|
||||
failureTripped bool
|
||||
riskTripped bool
|
||||
scTripped bool
|
||||
uncertainTrip bool
|
||||
}
|
||||
|
||||
// newProfileController is nil-safe: a nil ProfilePolicy (the default for every
|
||||
// existing caller) or a nil Escalate yields a controller that observes and
|
||||
// decides nothing.
|
||||
func newProfileController(policy *ProfilePolicy) *profileController {
|
||||
if policy == nil {
|
||||
return &profileController{}
|
||||
}
|
||||
return &profileController{policy: policy.Escalate}
|
||||
}
|
||||
|
||||
// observeToolOutcome watches two signals from an executed tool call: the
|
||||
// repeated-failure guard's streak (reusing the guard's own counting, not a
|
||||
// second failure model) and the sandbox risk classification of an executed
|
||||
// result.
|
||||
func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, result ToolResult) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return
|
||||
}
|
||||
if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak {
|
||||
c.failureTripped = true
|
||||
}
|
||||
if threshold := riskRank(c.policy.OnRiskyMutation); threshold > 0 &&
|
||||
result.DenialReason == DenialNone &&
|
||||
riskRank(result.Risk.Level) >= threshold {
|
||||
// The call EXECUTED (was not denied): partial failures count too, since
|
||||
// the mutation ran. An unrecognized threshold ranks 0 and disables the
|
||||
// signal instead of matching everything.
|
||||
c.riskTripped = true
|
||||
}
|
||||
}
|
||||
|
||||
// observeUncertain counts uncertain completion evaluations (continue nudges and
|
||||
// semantic checks). Headless-only by construction: the completion gate never
|
||||
// runs interactively.
|
||||
func (c *profileController) observeUncertain() {
|
||||
if c.policy == nil || c.escalated || c.policy.OnCompletionUncertain <= 0 {
|
||||
return
|
||||
}
|
||||
c.uncertainSeen++
|
||||
if c.uncertainSeen >= c.policy.OnCompletionUncertain {
|
||||
c.uncertainTrip = true
|
||||
}
|
||||
}
|
||||
|
||||
// observeSelfCorrect watches the post-edit verification outcome. Any failing
|
||||
// outcome (correcting, reported, aborted) is a signal; passed and disabled are
|
||||
// not.
|
||||
func (c *profileController) observeSelfCorrect(outcome Outcome) {
|
||||
if c.policy == nil || c.escalated || !c.policy.OnSelfCorrectFailure {
|
||||
return
|
||||
}
|
||||
switch outcome {
|
||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||
c.scTripped = true
|
||||
}
|
||||
}
|
||||
|
||||
// maybeEscalate reports the escalation target exactly once, the first time any
|
||||
// armed trigger has fired. Subsequent calls return false for the rest of the
|
||||
// run: escalation is one-shot and never de-escalates, so no cooldown state is
|
||||
// needed.
|
||||
func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
c.escalated = true
|
||||
return *c.policy, true
|
||||
}
|
||||
|
||||
// riskRank orders sandbox risk levels for threshold comparison. Unknown levels
|
||||
// rank 0: an unrecognized RESULT level can never meet a valid threshold, and an
|
||||
// unrecognized THRESHOLD is rejected before comparison (rank 0 disables the
|
||||
// signal) so a typo in a profile can never make every result match.
|
||||
func riskRank(level sandbox.RiskLevel) int {
|
||||
switch level {
|
||||
case sandbox.RiskLow:
|
||||
return 1
|
||||
case sandbox.RiskMedium:
|
||||
return 2
|
||||
case sandbox.RiskHigh:
|
||||
return 3
|
||||
case sandbox.RiskCritical:
|
||||
return 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
|
||||
for _, controller := range []*profileController{
|
||||
newProfileController(nil),
|
||||
newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil
|
||||
} {
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
controller.observeUncertain()
|
||||
controller.observeSelfCorrect(OutcomeAborted)
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("nil policy must never escalate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("streak below threshold must not escalate")
|
||||
}
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError})
|
||||
target, fired := controller.maybeEscalate()
|
||||
if !fired || target.MaxTurns != 80 {
|
||||
t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}})
|
||||
// Medium risk on an executed result: below threshold.
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("medium risk must not trip a high threshold")
|
||||
}
|
||||
// Critical risk on a DENIED result: never counts (the mutation never ran).
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("a denied result must not trip the risky-mutation signal")
|
||||
}
|
||||
// Critical risk on an executed PARTIAL FAILURE (error status, no denial):
|
||||
// the mutation ran, so it counts.
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("an executed partial failure at critical risk must trip a high threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("an unrecognized threshold must disable the signal, not match every result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}})
|
||||
controller.observeSelfCorrect(OutcomePassed)
|
||||
controller.observeSelfCorrect(OutcomeDisabled)
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("passing/disabled outcomes must not escalate")
|
||||
}
|
||||
controller.observeSelfCorrect(OutcomeReported)
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("a failing verification outcome must escalate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}})
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("first uncertain evaluation must not trip a threshold of 2")
|
||||
}
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("second uncertain evaluation must escalate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesAtMostOnce(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("expected first escalation")
|
||||
}
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("escalation must be one-shot per run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act
|
||||
// point: the completion gate's continue branch escapes the end-of-turn tail,
|
||||
// so escalation must apply before the continue. A cue turn under the gate
|
||||
// counts as uncertain, escalates, and the very next request carries the target
|
||||
// effort.
|
||||
func TestPostureEscalationOnUncertainCompletion(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
recorder := trace.NewRecorder("posture-session", "run-2", "fast")
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
Trace: recorder,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
ReasoningEffort: "high",
|
||||
OnCompletionUncertain: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "Done. All set." {
|
||||
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[1].ReasoningEffort != "high" {
|
||||
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort)
|
||||
}
|
||||
tr := recorder.Finish()
|
||||
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
||||
t.Fatalf("posture_escalations = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that filled the run's effort displaced the provider default ("");
|
||||
// RestoreDefaultEffort lets escalation restore that default, which a plain ""
|
||||
// ReasoningEffort target cannot express (it means "leave untouched").
|
||||
func TestPostureEscalationRestoresDefaultEffort(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
ReasoningEffort: "low",
|
||||
RequireCompletionSignal: true,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
RestoreDefaultEffort: true,
|
||||
OnCompletionUncertain: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "Done. All set." {
|
||||
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[0].ReasoningEffort != "low" {
|
||||
t.Fatalf("pre-escalation request effort = %q, want the profile's low", provider.requests[0].ReasoningEffort)
|
||||
}
|
||||
if provider.requests[1].ReasoningEffort != "" {
|
||||
t.Fatalf("post-escalation request effort = %q, want the restored provider default (empty)", provider.requests[1].ReasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// failingProfileTool always errors with the same signature so the repeated-
|
||||
// failure guard builds a streak.
|
||||
type failingProfileTool struct{}
|
||||
|
||||
func (failingProfileTool) Name() string { return "flaky_probe" }
|
||||
func (failingProfileTool) Description() string { return "always fails identically (test)" }
|
||||
func (failingProfileTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (failingProfileTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (failingProfileTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"}
|
||||
}
|
||||
|
||||
// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path:
|
||||
// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and
|
||||
// finishes on a turn the original ceiling would have cut off, with the effort
|
||||
// target applied to the post-escalation request and the counter emitted.
|
||||
func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
toolTurn := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn,
|
||||
toolTurn,
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "recovered after escalation"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
recorder := trace.NewRecorder("posture-session", "run-1", "fast")
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Trace: recorder,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
MaxTurns: 4,
|
||||
ReasoningEffort: "high",
|
||||
OnToolFailureStreak: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "recovered after escalation" {
|
||||
t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[2].ReasoningEffort != "high" {
|
||||
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort)
|
||||
}
|
||||
tr := recorder.Finish()
|
||||
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
||||
t.Fatalf("posture_escalations = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at
|
||||
// the loop level: the identical failing script without a profile ends at the
|
||||
// original ceiling with the max-turns answer.
|
||||
func TestPostureEscalationAbsentWithoutProfile(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
toolTurn := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) > 3 {
|
||||
t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests))
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "maximum number of turns") {
|
||||
t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk
|
||||
// stamp on the unsandboxed path: a read-only tool classifies low.
|
||||
func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "flaky_probe"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.Risk.Level == "" {
|
||||
t.Fatal("executed result must carry a risk classification")
|
||||
}
|
||||
if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) {
|
||||
t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a
|
||||
// tool (unknown name, nil tool) keeps the zero risk value instead of panicking
|
||||
// or inventing a classification.
|
||||
func TestUnknownToolResultCarriesZeroRisk(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "no_such_tool"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.Status == tools.StatusOK {
|
||||
t.Fatal("expected an error result for an unknown tool")
|
||||
}
|
||||
if result.Risk.Level != "" {
|
||||
t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level)
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
|
||||
// the auto-mode advertised set, including exec_command: auto mode must expose both
|
||||
// process creation and write_stdin process interaction or the latter is unusable
|
||||
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
maxEagerToolSchemaTokens = 3550
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
|
||||
@@ -11,12 +11,11 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// promptSubstrings are the seven cacheable sub-components of the prompt a run
|
||||
// sends to a model. Each is the literal content the sub-component would emit
|
||||
// into the system prompt or tool list, hashed so a downstream observer can
|
||||
// detect drift turn-over-turn. The substrings are produced by
|
||||
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
|
||||
// promptSubstrings are the cacheable components of the prompt a run sends to a
|
||||
// model. systemPrompt is the exact joined system-message content. The remaining
|
||||
// fields retain useful diagnostic boundaries within that prompt and tool list.
|
||||
type promptSubstrings struct {
|
||||
systemPrompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
@@ -25,92 +24,55 @@ type promptSubstrings struct {
|
||||
schema string
|
||||
}
|
||||
|
||||
// buildPromptSubstrings assembles the seven cacheable sub-components of the
|
||||
// prompt without joining them. The system prompt sections are produced by the
|
||||
// same private builders that buildSystemPrompt uses, so the substrings are
|
||||
// byte-identical to what would have appeared in the joined prompt. The tools
|
||||
// and schema substrings are derived from the partitioned tool list (caller-
|
||||
// provided) so this helper does not need to re-run the partition.
|
||||
//
|
||||
// This is the seam ComputePrefixFingerprint reads from. It exists as a
|
||||
// separate function (rather than returning both prompt and substrings from
|
||||
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
|
||||
// and the substrings helper is independently testable.
|
||||
// buildPromptSubstrings builds the prompt and retains both its exact joined
|
||||
// form and narrower diagnostic components. Callers that already built the
|
||||
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
|
||||
// reads.
|
||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
}
|
||||
if core == "" {
|
||||
core = fallbackSystemPrompt
|
||||
}
|
||||
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
|
||||
}
|
||||
|
||||
// The confirmation policy is appended unconditionally in buildSystemPrompt;
|
||||
// the substring here is the same constant.
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
|
||||
// projectContext is the joined output of workspaceContext, which itself
|
||||
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
|
||||
// joined string captures every guideline file's contribution and the
|
||||
// ordering between them.
|
||||
project := workspaceContext(options.Cwd)
|
||||
|
||||
// skills is the joined output of skillsContext, with each skill on its own
|
||||
// line. The substring is the literal text the model would see.
|
||||
skills := skillsContext(options)
|
||||
|
||||
// tools and schema are derived from the partitioned tool list. The list
|
||||
// passed in is already in stable order (partitionToolsCached is the
|
||||
// canonical stable partitioner) so concatenating in slice order is
|
||||
// sufficient — we sort names again defensively in case a future caller
|
||||
// hands us a list from a different partitioner.
|
||||
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||
|
||||
return promptSubstrings{
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skills,
|
||||
systemPrompt: parts.prompt,
|
||||
baseInstructions: parts.baseInstructions,
|
||||
confirmationPolicy: parts.confirmationPolicy,
|
||||
projectContext: parts.projectContext,
|
||||
skills: parts.skills,
|
||||
tools: toolsSubstr,
|
||||
schema: schemaSubstr,
|
||||
}
|
||||
}
|
||||
|
||||
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
|
||||
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
|
||||
// the partition produces a different hash. The schema substring is the
|
||||
// concatenation of each tool's JSON schema in name order, so a schema change
|
||||
// is detected independently of the tool ordering.
|
||||
// toolSubstrings preserves the emitted tool order. Names and descriptions are
|
||||
// fingerprinted separately from schemas so either kind of drift is identifiable.
|
||||
// Order is part of request identity: silently sorting here would hide a provider-
|
||||
// visible reordering and could report a false cache hit.
|
||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||
if len(exposed) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
names := make([]string, 0, len(exposed))
|
||||
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
|
||||
for _, def := range exposed {
|
||||
names = append(names, def.Name)
|
||||
byName[def.Name] = def
|
||||
}
|
||||
sort.Strings(names)
|
||||
var toolsSB, schemaSB strings.Builder
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
toolsSB.WriteByte('\n')
|
||||
}
|
||||
toolsSB.WriteString(name)
|
||||
def := byName[name]
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
schemaSB.WriteString(name)
|
||||
schemaSB.WriteByte('\n')
|
||||
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
|
||||
schemaSB.WriteByte('\n')
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
func writeLengthPrefixed(sb *strings.Builder, value string) {
|
||||
fmt.Fprintf(sb, "%d:", len(value))
|
||||
sb.WriteString(value)
|
||||
}
|
||||
|
||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||
@@ -227,36 +189,16 @@ func writeStable(sb *strings.Builder, v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
|
||||
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
|
||||
// independent SHA-256s of the corresponding sub-component; the complete-prefix
|
||||
// hash is a SHA-256 of the canonical concatenation of the other six, so any
|
||||
// sub-component drift is observable both individually and in aggregate.
|
||||
//
|
||||
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
|
||||
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
|
||||
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
|
||||
// context work is performed separately by buildSystemPrompt a few lines
|
||||
// later in the request-build path, so on every traced turn the file and
|
||||
// git reads happen twice. The duplication is intentional for this PR (a
|
||||
// follow-up will pass substrings through) and is a known perf cost only on
|
||||
// the opt-in trace path.
|
||||
//
|
||||
// A stable CompletePrefixHash across turns means the four captured
|
||||
// sub-components (baseInstructions, confirmationPolicy, projectContext,
|
||||
// skills) are byte-identical. It does NOT rule out drift in the seven
|
||||
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
|
||||
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
|
||||
// userGuidelines, specialistDelegationContext, responseStyleContext). For
|
||||
// default Options the four captured substrings are the full prompt; for
|
||||
// non-default Options the seven uncaptured sections may contribute, and a
|
||||
// consumer correlating CompletePrefix stability with cached_input_tokens
|
||||
// must cross-check the model_switches counter (modelPromptAddendum changes
|
||||
// on a model switch) to disambiguate "no drift" from "drift in an
|
||||
// uncaptured section."
|
||||
|
||||
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
|
||||
// not already have one. The agent loop uses computePrefixFingerprint with the
|
||||
// prompt parts it built once at run start, ensuring tracing observes the exact
|
||||
// request content without repeating workspace I/O.
|
||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||
subs := buildPromptSubstrings(options, exposed)
|
||||
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
|
||||
}
|
||||
|
||||
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
||||
systemPrompt := sha256hex(subs.systemPrompt)
|
||||
base := sha256hex(subs.baseInstructions)
|
||||
policy := sha256hex(subs.confirmationPolicy)
|
||||
project := sha256hex(subs.projectContext)
|
||||
@@ -264,9 +206,10 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
|
||||
toolsH := sha256hex(subs.tools)
|
||||
schema := sha256hex(subs.schema)
|
||||
complete := sha256hex(strings.Join([]string{
|
||||
base, policy, project, skills, toolsH, schema,
|
||||
systemPrompt, toolsH, schema,
|
||||
}, "|"))
|
||||
return prefixFingerprint{
|
||||
SystemPromptHash: systemPrompt,
|
||||
BaseInstructionsHash: base,
|
||||
ConfirmationPolicyHash: policy,
|
||||
ProjectContextHash: project,
|
||||
@@ -282,6 +225,7 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
|
||||
// in loop.go) so the trace package does not need to import this one. The field
|
||||
// names match the trace.PrefixHash JSON tags 1:1.
|
||||
type prefixFingerprint struct {
|
||||
SystemPromptHash string
|
||||
BaseInstructionsHash string
|
||||
ConfirmationPolicyHash string
|
||||
ProjectContextHash string
|
||||
|
||||
@@ -28,38 +28,28 @@ func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
|
||||
// name-set sensitivity of the ToolsHash. The test name used to read as
|
||||
// "reorder changes hash" but the assertion is the opposite: the hash is
|
||||
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
|
||||
// names before hashing). The partitioner is expected to produce a stable
|
||||
// order, so a name-order change is not a hash signal — a name-set change
|
||||
// is. The test asserts both: permutation of the same tools produces the
|
||||
// same hash, and adding a tool produces a different hash.
|
||||
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
|
||||
// Tool order and descriptions are provider-visible and therefore part of the
|
||||
// cacheable prefix identity.
|
||||
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
}
|
||||
// toolSubstrings sorts names internally, so the ToolsHash is identical
|
||||
// for a permutation of the same tools. We assert that here to document
|
||||
// the contract: name-order independence, name-set sensitivity.
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.ToolsHash != fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
if fpA.ToolsHash == fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
}
|
||||
// A different tool set must produce a different ToolsHash.
|
||||
c := append([]zeroruntime.ToolDefinition{}, a...)
|
||||
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
|
||||
c := append([]zeroruntime.ToolDefinition(nil), a...)
|
||||
c[0].Description = "read a file"
|
||||
fpC := ComputePrefixFingerprint(opts, c)
|
||||
if fpA.ToolsHash == fpC.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,25 +91,35 @@ func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
|
||||
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
|
||||
// This is the regression catch for "did anyone reorder or drop a sub-hash
|
||||
// from the canonical join without updating CompletePrefixHash."
|
||||
// CompletePrefixHash represents the exact joined system prompt and the ordered
|
||||
// tool definitions. Narrower system-section hashes remain diagnostic only.
|
||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||
fp := ComputePrefixFingerprint(opts, exposed)
|
||||
// Manually compute what the canonical join should be.
|
||||
expected := sha256hex(strings.Join([]string{
|
||||
fp.BaseInstructionsHash,
|
||||
fp.ConfirmationPolicyHash,
|
||||
fp.ProjectContextHash,
|
||||
fp.SkillsHash,
|
||||
fp.SystemPromptHash,
|
||||
fp.ToolsHash,
|
||||
fp.SchemaHash,
|
||||
}, "|"))
|
||||
if fp.CompletePrefixHash != expected {
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
|
||||
changed := base
|
||||
changed.ResponseStyle = "concise"
|
||||
|
||||
fpBase := ComputePrefixFingerprint(base, nil)
|
||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||
t.Fatal("SystemPromptHash must change when any rendered system section changes")
|
||||
}
|
||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||
t.Fatal("CompletePrefixHash must include the exact full system prompt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
if subs.baseInstructions != "test core" {
|
||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||
}
|
||||
if subs.systemPrompt != buildSystemPrompt(opts) {
|
||||
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
|
||||
}
|
||||
if subs.confirmationPolicy == "" {
|
||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||
}
|
||||
@@ -151,6 +154,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
// sha256hex of the substrings, with no truncation or reformatting
|
||||
// between the two layers.
|
||||
fp := ComputePrefixFingerprint(opts, nil)
|
||||
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
|
||||
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
|
||||
}
|
||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
|
||||
@@ -121,3 +121,67 @@ func toolNameForResult(messages []zeroruntime.Message, index int) string {
|
||||
func isPrunedPlaceholder(content string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
|
||||
}
|
||||
|
||||
var supersedableReadTools = map[string]bool{
|
||||
"read_file": true,
|
||||
"read_minified_file": true,
|
||||
"grep": true,
|
||||
"glob": true,
|
||||
"list_directory": true,
|
||||
}
|
||||
|
||||
// pruneSupersededReadResults removes only older byte-identical results of the
|
||||
// same read call. It runs when context pressure already requires a rewrite, so
|
||||
// it does not trade a warm provider-cache prefix for a small apparent saving.
|
||||
func pruneSupersededReadResults(messages []zeroruntime.Message) ([]zeroruntime.Message, int) {
|
||||
type seenResultKey struct {
|
||||
call string
|
||||
content string
|
||||
}
|
||||
seen := map[seenResultKey]struct{}{}
|
||||
out := messages
|
||||
reclaimed := 0
|
||||
copied := false
|
||||
|
||||
for index := len(messages) - 1; index >= 0; index-- {
|
||||
message := messages[index]
|
||||
if message.Role != zeroruntime.MessageRoleTool || isPrunedPlaceholder(message.Content) {
|
||||
continue
|
||||
}
|
||||
call, ok := toolCallForResult(messages, index)
|
||||
if !ok || !supersedableReadTools[call.Name] {
|
||||
continue
|
||||
}
|
||||
key := seenResultKey{
|
||||
call: call.Name + "\x00" + strings.TrimSpace(call.Arguments),
|
||||
content: message.Content,
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
placeholder := fmt.Sprintf("[superseded identical %s result — the newer result remains in context]", call.Name)
|
||||
if !copied {
|
||||
out = append([]zeroruntime.Message(nil), messages...)
|
||||
copied = true
|
||||
}
|
||||
out[index].Content = placeholder
|
||||
reclaimed += max(0, ApproxTextTokens(message.Content)-ApproxTextTokens(placeholder))
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return out, reclaimed
|
||||
}
|
||||
|
||||
func toolCallForResult(messages []zeroruntime.Message, resultIndex int) (zeroruntime.ToolCall, bool) {
|
||||
id := messages[resultIndex].ToolCallID
|
||||
if id == "" {
|
||||
return zeroruntime.ToolCall{}, false
|
||||
}
|
||||
for index := resultIndex - 1; index >= 0; index-- {
|
||||
for _, call := range messages[index].ToolCalls {
|
||||
if call.ID == id {
|
||||
return call, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return zeroruntime.ToolCall{}, false
|
||||
}
|
||||
@@ -25,6 +25,13 @@ func toolCallMsg(id, name string) zeroruntime.Message {
|
||||
}
|
||||
}
|
||||
|
||||
func toolCallWithArgs(id, name, args string) zeroruntime.Message {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name, Arguments: args}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSkipsSmallSessions(t *testing.T) {
|
||||
// Total reclaimable is under the gate → no pruning.
|
||||
msgs := []zeroruntime.Message{
|
||||
@@ -120,3 +127,69 @@ func TestPruneDoesNotMutateInput(t *testing.T) {
|
||||
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsOnlyDropsIdenticalOlderReads(t *testing.T) {
|
||||
body := strings.Repeat("same source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("old", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old", Content: body},
|
||||
toolCallWithArgs("different", "read_file", `{"path":"b.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "different", Content: body},
|
||||
toolCallWithArgs("new", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new", Content: body},
|
||||
}
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
||||
t.Fatalf("older duplicate was not pruned: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
||||
}
|
||||
if out[3].Content != body || out[5].Content != body {
|
||||
t.Fatal("different call and newest result must remain exact")
|
||||
}
|
||||
if messages[1].Content != body {
|
||||
t.Fatal("input slice was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsKeepsChangedAndMutatingResults(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("r1", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r1", Content: "version one"},
|
||||
toolCallWithArgs("r2", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "r2", Content: "version two"},
|
||||
toolCallWithArgs("e1", "edit_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "edited"},
|
||||
toolCallWithArgs("e2", "edit_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "edited"},
|
||||
}
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
if reclaimed != 0 {
|
||||
t.Fatalf("unsafe results were pruned: %d", reclaimed)
|
||||
}
|
||||
for index := range messages {
|
||||
if out[index].Content != messages[index].Content {
|
||||
t.Fatalf("message %d changed unexpectedly", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSupersededReadResultsFindsMatchingBodyAcrossChangedRead(t *testing.T) {
|
||||
bodyA := strings.Repeat("version a\n", 100)
|
||||
bodyB := strings.Repeat("version b\n", 100)
|
||||
messages := []zeroruntime.Message{
|
||||
toolCallWithArgs("old-a", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "old-a", Content: bodyA},
|
||||
toolCallWithArgs("middle-b", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "middle-b", Content: bodyB},
|
||||
toolCallWithArgs("new-a", "read_file", `{"path":"a.go"}`),
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "new-a", Content: bodyA},
|
||||
}
|
||||
|
||||
out, reclaimed := pruneSupersededReadResults(messages)
|
||||
|
||||
if reclaimed <= 0 || !strings.Contains(out[1].Content, "superseded identical") {
|
||||
t.Fatalf("older matching body was not pruned across a changed result: reclaimed=%d output=%q", reclaimed, out[1].Content)
|
||||
}
|
||||
if out[3].Content != bodyB || out[5].Content != bodyA {
|
||||
t.Fatal("changed middle result and newest matching result must remain exact")
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,16 @@ func TestSpecialistDelegationContext(t *testing.T) {
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
for _, want := range []string{"reduce total context", "Do not delegate small direct work", "bounded assignment"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing evidence-driven delegation guidance %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"delegate to it proactively", "launch several specialists in parallel"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("retained broad delegation guidance %q: %q", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,22 @@ const (
|
||||
// guidelines), and the safety confirmation policy. It is built once per run so
|
||||
// every turn shares one (cacheable) system turn.
|
||||
func buildSystemPrompt(options Options) string {
|
||||
return buildSystemPromptParts(options).prompt
|
||||
}
|
||||
|
||||
// systemPromptParts retains both the exact joined prompt and the diagnostic
|
||||
// sections used by prompt-prefix tracing. Building these together prevents the
|
||||
// tracing path from re-reading workspace state and guarantees that the hash is
|
||||
// computed from the same prompt bytes placed in the request.
|
||||
type systemPromptParts struct {
|
||||
prompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
skills string
|
||||
}
|
||||
|
||||
func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
@@ -97,22 +114,31 @@ func buildSystemPrompt(options Options) string {
|
||||
if user := userGuidelines(); user != "" {
|
||||
sections = append(sections, user)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
project := workspaceContext(options.Cwd)
|
||||
if project != "" {
|
||||
sections = append(sections, project)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
skillsBlock := skillsContext(options)
|
||||
if skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
if policy != "" {
|
||||
sections = append(sections, policy)
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
return systemPromptParts{
|
||||
prompt: strings.Join(sections, "\n\n"),
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skillsBlock,
|
||||
}
|
||||
}
|
||||
|
||||
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
||||
@@ -153,21 +179,19 @@ func approvedCommandPrefixContext(options Options) string {
|
||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
|
||||
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
||||
// tool outputs out of the main context. It renders only when specialists are
|
||||
// known (which is only where the Task tool is actually registered), so a run with
|
||||
// no delegatable specialists produces the previous prompt unchanged.
|
||||
// specialistDelegationContext describes when a specialist earns its extra
|
||||
// context and coordination cost. It renders only when specialists are known
|
||||
// (which is only where the Task tool is actually registered), so a run with no
|
||||
// delegatable specialists produces the previous prompt unchanged.
|
||||
func specialistDelegationContext(options Options) string {
|
||||
if len(options.Specialists) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<specialists>\n")
|
||||
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
|
||||
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
||||
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
|
||||
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
||||
b.WriteString("Use the Task tool when a specialist's focused context, expertise, or an independent parallel subtask is likely to improve quality or reduce total context. ")
|
||||
b.WriteString("Do not delegate small direct work, duplicate exploration, or split sequential steps merely because a specialist is available. ")
|
||||
b.WriteString("When delegating, give a bounded assignment and consume its concise evidence instead of importing a long transcript.\n")
|
||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||
for _, info := range options.Specialists {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -285,11 +309,7 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("<environment>\n")
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||
b.WriteString("Git branch: " + branch + "\n")
|
||||
}
|
||||
|
||||
@@ -31,15 +31,10 @@ work.
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it live. The plan bar is the user's progress signal — call update_plan
|
||||
after EACH concrete unit of work (every file written, every command run), not
|
||||
just at coarse milestones: mark the finished item completed and the next one
|
||||
in_progress before you start it. A plan stuck at 0/N while files are landing is
|
||||
a bug, not economy — these calls are cheap, expected, and the update_plan cards
|
||||
are hidden from the transcript, so frequent updates cost the user nothing and
|
||||
never clutter the conversation. Keep at most one item in_progress, and never
|
||||
batch the updates to the end of the turn. Skip the plan for trivial one-step
|
||||
tasks.
|
||||
keep it current at meaningful milestones. Mark completed work and the next
|
||||
in-progress step together when practical; do not spend separate model turns
|
||||
updating the plan after every file or command. Keep at most one item
|
||||
in_progress. Skip the plan for trivial one-step tasks.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
@@ -57,8 +52,9 @@ work.
|
||||
file tools - read_file, list_directory, glob, grep, write_file, edit_file,
|
||||
apply_patch - over shelling out to cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
@@ -100,6 +96,9 @@ work.
|
||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type taskStatus string
|
||||
|
||||
const (
|
||||
taskStatusActive taskStatus = "active"
|
||||
taskStatusComplete taskStatus = "complete"
|
||||
taskStatusIncomplete taskStatus = "incomplete"
|
||||
)
|
||||
|
||||
type taskPlanParity string
|
||||
|
||||
const (
|
||||
taskPlanParityUnknown taskPlanParity = "unknown"
|
||||
taskPlanParityMatch taskPlanParity = "match"
|
||||
taskPlanParityMismatch taskPlanParity = "mismatch"
|
||||
)
|
||||
|
||||
type taskPlanItem struct {
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type taskPlanState struct {
|
||||
Items []taskPlanItem `json:"items,omitempty"`
|
||||
Pending int `json:"pending"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskToolState struct {
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskVerificationState struct {
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type taskStateSnapshot struct {
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
|
||||
const (
|
||||
taskStateEventPlan taskStateEventKind = iota + 1
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
kind taskStateEventKind
|
||||
arguments string
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
// It does not initiate work or replace the transcript; consumers must check
|
||||
// transcript parity before using its compact representation.
|
||||
type taskState struct {
|
||||
snapshotValue taskStateSnapshot
|
||||
changedFiles map[string]struct{}
|
||||
recorder *trace.Recorder
|
||||
planObserved bool
|
||||
}
|
||||
|
||||
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
state := &taskState{
|
||||
snapshotValue: taskStateSnapshot{
|
||||
Objective: strings.TrimSpace(objective),
|
||||
Status: taskStatusActive,
|
||||
PlanParity: taskPlanParityUnknown,
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
|
||||
func (state *taskState) observe(event taskStateEvent) bool {
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
switch event.kind {
|
||||
case taskStateEventPlan:
|
||||
plan, ok := parseTaskPlan(event.arguments)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
state.snapshotValue.Plan = summarizeTaskPlan(plan)
|
||||
state.planObserved = true
|
||||
state.markActive()
|
||||
changed = true
|
||||
case taskStateEventToolResult:
|
||||
state.markActive()
|
||||
if event.toolResult.Status == tools.StatusOK {
|
||||
state.snapshotValue.Tools.Succeeded++
|
||||
} else {
|
||||
state.snapshotValue.Tools.Failed++
|
||||
}
|
||||
for _, path := range event.toolResult.ChangedFiles {
|
||||
path = strings.TrimSpace(path)
|
||||
if path != "" {
|
||||
state.changedFiles[path] = struct{}{}
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
return false
|
||||
}
|
||||
state.markActive()
|
||||
state.snapshotValue.Verification.LastOutcome = event.verification
|
||||
switch event.verification {
|
||||
case OutcomePassed:
|
||||
state.snapshotValue.Verification.Passed++
|
||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||
state.snapshotValue.Verification.Failed++
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventCompletion:
|
||||
state.snapshotValue.CompletionDecision = event.completion.Decision
|
||||
state.snapshotValue.CompletionReason = event.completion.Reason
|
||||
switch event.completion.Decision {
|
||||
case CompletionComplete:
|
||||
state.snapshotValue.Status = taskStatusComplete
|
||||
case CompletionIncomplete:
|
||||
state.snapshotValue.Status = taskStatusIncomplete
|
||||
default:
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
if event.kind != taskStateEventToolResult {
|
||||
state.emit()
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (state *taskState) markActive() {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
state.snapshotValue.CompletionDecision = ""
|
||||
state.snapshotValue.CompletionReason = ""
|
||||
}
|
||||
|
||||
func (state *taskState) snapshot() taskStateSnapshot {
|
||||
if state == nil {
|
||||
return taskStateSnapshot{}
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// observePlanParity compares only the latest plan projection with the plan tool
|
||||
// calls still present in messages. It mutates the snapshot and emits when the
|
||||
// parity value changes; objective, tool, and verification fields are not part of
|
||||
// this comparison.
|
||||
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
|
||||
if state == nil {
|
||||
return taskPlanParityUnknown
|
||||
}
|
||||
transcriptPlan, found, valid := latestTaskPlan(messages)
|
||||
tracked := state.snapshotValue.Plan.Items
|
||||
parity := taskPlanParityMatch
|
||||
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
|
||||
parity = taskPlanParityMismatch
|
||||
}
|
||||
if state.snapshotValue.PlanParity != parity {
|
||||
state.snapshotValue.PlanParity = parity
|
||||
state.snapshotValue.Revision++
|
||||
state.emit()
|
||||
}
|
||||
return parity
|
||||
}
|
||||
|
||||
type completionContext struct {
|
||||
Objective string
|
||||
PlanPending bool
|
||||
PlanMatchesTranscript bool
|
||||
}
|
||||
|
||||
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
|
||||
context := completionContext{PlanPending: transcriptPlanPending}
|
||||
if state == nil {
|
||||
return context
|
||||
}
|
||||
context.Objective = state.snapshotValue.Objective
|
||||
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
|
||||
if context.PlanMatchesTranscript {
|
||||
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
state.observePlanParity(messages)
|
||||
snapshot := state.snapshot()
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
func (state *taskState) emit() {
|
||||
if state == nil || state.recorder == nil {
|
||||
return
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
state.recorder.EmitTaskState(trace.TaskStateEvent{
|
||||
Revision: snapshot.Revision,
|
||||
Status: string(snapshot.Status),
|
||||
PlanPending: snapshot.Plan.Pending,
|
||||
PlanInProgress: snapshot.Plan.InProgress,
|
||||
PlanCompleted: snapshot.Plan.Completed,
|
||||
PlanFailed: snapshot.Plan.Failed,
|
||||
ToolsSucceeded: snapshot.Tools.Succeeded,
|
||||
ToolsFailed: snapshot.Tools.Failed,
|
||||
VerificationPassed: snapshot.Verification.Passed,
|
||||
VerificationFailed: snapshot.Verification.Failed,
|
||||
VerificationOutcome: string(snapshot.Verification.LastOutcome),
|
||||
ChangedFileCount: len(snapshot.ChangedFiles),
|
||||
CompletionDecision: string(snapshot.CompletionDecision),
|
||||
PlanParity: string(snapshot.PlanParity),
|
||||
})
|
||||
}
|
||||
|
||||
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||
return nil, false
|
||||
}
|
||||
rawPlan, ok := object["plan"]
|
||||
if !ok || string(rawPlan) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var parsed []struct {
|
||||
ID *string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if json.Unmarshal(rawPlan, &parsed) != nil {
|
||||
return nil, false
|
||||
}
|
||||
plan := make([]taskPlanItem, 0, len(parsed))
|
||||
for _, raw := range parsed {
|
||||
if raw.Content == "" {
|
||||
return nil, false
|
||||
}
|
||||
plan = append(plan, taskPlanItem{
|
||||
Content: raw.Content,
|
||||
Status: normalizeTaskPlanStatus(raw.Status),
|
||||
Notes: raw.Notes,
|
||||
})
|
||||
}
|
||||
lastInProgress := -1
|
||||
for index := range plan {
|
||||
if plan[index].Status == "in_progress" {
|
||||
if lastInProgress >= 0 {
|
||||
plan[lastInProgress].Status = "completed"
|
||||
}
|
||||
lastInProgress = index
|
||||
}
|
||||
}
|
||||
if len(plan) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
return plan, true
|
||||
}
|
||||
|
||||
func normalizeTaskPlanStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
|
||||
return "completed"
|
||||
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
|
||||
return "in_progress"
|
||||
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return "failed"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
|
||||
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "completed":
|
||||
plan.Completed++
|
||||
case "in_progress":
|
||||
plan.InProgress++
|
||||
case "failed":
|
||||
plan.Failed++
|
||||
default:
|
||||
plan.Pending++
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
|
||||
call := messages[i].ToolCalls[j]
|
||||
if call.Name != planToolName {
|
||||
continue
|
||||
}
|
||||
plan, ok := parseTaskPlan(call.Arguments)
|
||||
return plan, true, ok
|
||||
}
|
||||
}
|
||||
return nil, false, true
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
keys = append(keys, value)
|
||||
}
|
||||
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
|
||||
// deterministic even if events originate from parallel read batches later.
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestTaskStateReplayIsDeterministic(t *testing.T) {
|
||||
events := []taskStateEvent{
|
||||
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
|
||||
{kind: taskStateEventVerification, verification: OutcomePassed},
|
||||
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
|
||||
}
|
||||
|
||||
first := newTaskState("ship the change", nil)
|
||||
second := newTaskState("ship the change", nil)
|
||||
for _, event := range events {
|
||||
first.observe(event)
|
||||
second.observe(event)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
|
||||
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
|
||||
}
|
||||
got := first.snapshot()
|
||||
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
|
||||
t.Fatalf("unexpected plan snapshot: %#v", got)
|
||||
}
|
||||
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
|
||||
t.Fatalf("unexpected evidence snapshot: %#v", got)
|
||||
}
|
||||
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
|
||||
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
state := newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
if events := recorder.Finish().TaskStates; len(events) != 0 {
|
||||
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
|
||||
}
|
||||
|
||||
recorder = trace.NewRecorder("session", "run-2", "")
|
||||
state = newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
|
||||
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
|
||||
result, err := Run(context.Background(), "implement the change", provider, Options{
|
||||
Registry: registry,
|
||||
Trace: recorder,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) < 3 {
|
||||
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
|
||||
}
|
||||
last := events[len(events)-1]
|
||||
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
|
||||
t.Fatalf("unexpected final task snapshot: %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
snapshot.Plan.Items[0].Content = "mutated"
|
||||
snapshot.ChangedFiles[0] = "mutated.go"
|
||||
|
||||
fresh := state.snapshot()
|
||||
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
|
||||
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
|
||||
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
|
||||
}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("parity = %q, want match", parity)
|
||||
}
|
||||
|
||||
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
|
||||
t.Fatalf("parity = %q, want mismatch", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
|
||||
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
|
||||
}
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
|
||||
}
|
||||
|
||||
empty := newTaskState("objective", nil)
|
||||
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
|
||||
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
|
||||
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
|
||||
}}}
|
||||
|
||||
context := state.completionContext(messages, true)
|
||||
if !context.PlanPending || context.PlanMatchesTranscript {
|
||||
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
|
||||
}
|
||||
if context.Objective != "objective" {
|
||||
t.Fatalf("objective lost from completion context: %#v", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
|
||||
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
|
||||
}}}
|
||||
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
|
||||
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
|
||||
}
|
||||
|
||||
mismatching := append([]zeroruntime.Message(nil), matching...)
|
||||
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
|
||||
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
|
||||
for _, arguments := range []string{
|
||||
`{}`,
|
||||
`{"plan":null}`,
|
||||
`{"plan":[{"step":"alias is not accepted"}]}`,
|
||||
`{"plan":[{"content":""}]}`,
|
||||
`{"plan":[{"content":"valid"},{"content":""}]}`,
|
||||
`{"plan":[{"id":4,"content":"valid"}]}`,
|
||||
} {
|
||||
if plan, ok := parseTaskPlan(arguments); ok {
|
||||
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-8
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -71,10 +72,19 @@ type ToolResult struct {
|
||||
Meta map[string]string
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
Display tools.Display
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
Display tools.Display
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
// Risk is the sandbox risk classification of this call, stamped for EXECUTED
|
||||
// results so run-policy observers (the execution-profile controller) can see
|
||||
// the risk level of an allowed mutation. It mirrors the classification the
|
||||
// permission path already computes; denied or canceled results keep the zero
|
||||
// value. Pure observation: nothing about permissions or sandboxing changes.
|
||||
Risk sandbox.Risk
|
||||
// LoadedTools carries the deferred-tool names a tool_search call asked the
|
||||
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
|
||||
// ordinary tool result; only tool_search populates it.
|
||||
@@ -98,6 +108,54 @@ const (
|
||||
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
||||
)
|
||||
|
||||
// ProfilePolicy is the loop-facing slice of a selected execution profile.
|
||||
// The surface (exec flag, TUI command) resolves a named profile into this
|
||||
// policy; the loop only ever sees the policy, never the catalog.
|
||||
type ProfilePolicy struct {
|
||||
// Name labels trace counters and diagnostics (e.g. "fast"). The trace
|
||||
// recorder's profile label is set by the caller at recorder construction.
|
||||
Name string
|
||||
// Escalate, when non-nil, arms one-shot in-run posture escalation.
|
||||
Escalate *PostureEscalation
|
||||
}
|
||||
|
||||
// PostureEscalation describes a one-shot escalation to stricter knob values,
|
||||
// applied mid-run when an armed trigger fires. Targets are the values the
|
||||
// selected profile DISPLACED at run start (i.e. "restore the balanced
|
||||
// posture"), so escalation can never introduce a value that was not already
|
||||
// valid for this run and model. Zero-valued targets leave that knob untouched.
|
||||
type PostureEscalation struct {
|
||||
// MaxTurns raises the turn ceiling to this value when greater than the
|
||||
// ceiling in effect. 0 leaves the ceiling untouched.
|
||||
MaxTurns int
|
||||
// ReasoningEffort replaces the run's effort when non-empty.
|
||||
ReasoningEffort string
|
||||
// RestoreDefaultEffort clears the run's effort override (back to the
|
||||
// provider/model default) when true. It exists because the displaced value
|
||||
// of a profile-filled effort is "" — which as a ReasoningEffort target
|
||||
// means "leave untouched" — so restoring the default needs its own signal.
|
||||
// Ignored when ReasoningEffort is non-empty.
|
||||
RestoreDefaultEffort bool
|
||||
// RestoreCompletionGate re-enables RequireCompletionSignal (headless
|
||||
// completion semantics) when true.
|
||||
RestoreCompletionGate bool
|
||||
|
||||
// Triggers. A zero value disables that signal entirely.
|
||||
// OnToolFailureStreak fires when the repeated-failure guard observes a
|
||||
// same-tool retriable-failure streak of at least this length.
|
||||
OnToolFailureStreak int
|
||||
// OnCompletionUncertain fires on the Nth uncertain completion evaluation
|
||||
// (continue nudge or semantic check). Headless only: the completion gate
|
||||
// never runs interactively.
|
||||
OnCompletionUncertain int
|
||||
// OnSelfCorrectFailure fires when a post-edit verification cycle reports a
|
||||
// failing outcome (correcting, reported, or aborted).
|
||||
OnSelfCorrectFailure bool
|
||||
// OnRiskyMutation fires when an EXECUTED tool result carries a sandbox risk
|
||||
// level at or above this threshold. Empty disables the signal.
|
||||
OnRiskyMutation sandbox.RiskLevel
|
||||
}
|
||||
|
||||
type PermissionRequest struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
@@ -298,6 +356,12 @@ type Options struct {
|
||||
// contract as ModelSwitcher: a returned error records a note and the run
|
||||
// continues on the current model and session.
|
||||
ModelSessionSwitcher func(ctx context.Context, modelID string) (zeroruntime.TurnSessionProvider, error)
|
||||
// Profile, when set, arms the execution-profile posture controller for this
|
||||
// run (auto-escalation to stricter knob values on failure/uncertainty/risky
|
||||
// mutation signals). nil — the default everywhere today — leaves the loop
|
||||
// byte-identical: no observation, no escalation, no counters. Same opt-in
|
||||
// convention as Trace and SelfCorrect.
|
||||
Profile *ProfilePolicy
|
||||
// Trace, when set, records per-turn timing for the run: the loop stamps
|
||||
// spans (prompt build, generation, tool execution, permission wait,
|
||||
// compaction, provider connect) and counters (model requests, tool calls,
|
||||
@@ -363,13 +427,6 @@ type Result struct {
|
||||
IncompleteReason string
|
||||
}
|
||||
|
||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||
// output token cap or withheld by a content filter) rather than completing
|
||||
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
|
||||
func (result Result) Truncated() bool {
|
||||
return result.FinishReason != ""
|
||||
}
|
||||
|
||||
// TruncationNotice returns a user-facing warning when the final response was
|
||||
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
||||
// wording stays consistent.
|
||||
|
||||
@@ -31,10 +31,6 @@ type AgentRunner interface {
|
||||
|
||||
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
||||
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
||||
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agenteval
|
||||
|
||||
import "context"
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
@@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report {
|
||||
if len(task.RequiredTraceEvents) > 0 {
|
||||
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
||||
}
|
||||
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
|
||||
report.Results = append(report.Results, result)
|
||||
}
|
||||
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
|
||||
report.finishSummary()
|
||||
return report
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
|
||||
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
||||
|
||||
if info.PrimaryLanguage != "" {
|
||||
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
|
||||
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
|
||||
}
|
||||
if langs := topLanguages(info.Languages, 5); langs != "" {
|
||||
b.WriteString("- Languages: " + langs + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
|
||||
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
|
||||
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
||||
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
|
||||
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
|
||||
}
|
||||
if len(info.BuildTools) > 0 {
|
||||
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
||||
@@ -22,10 +21,7 @@ var (
|
||||
// the negative PID (the group), so any process the child forks dies with it
|
||||
// instead of being orphaned. Must be called before cmd.Start.
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
execution.ConfigureProcessGroup(cmd)
|
||||
}
|
||||
|
||||
// terminateProcess stops a background process. It first asks politely with
|
||||
@@ -41,71 +37,5 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
// back to signalling only the individual PID, which also avoids reporting a false
|
||||
// success when a non-leader group-signal returns ESRCH.
|
||||
func terminateProcess(pid int) error {
|
||||
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
|
||||
// every process we can signal. A real child PID is always > 1, so never let a
|
||||
// bogus 0/1 expand into either.
|
||||
if pid <= 1 {
|
||||
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
|
||||
}
|
||||
|
||||
// Signal the whole group only when pid leads its own group; otherwise the
|
||||
// individual process. See the doc comment for why a non-leader is signalled
|
||||
// directly rather than via its (foreign) group.
|
||||
target := pid
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if processGoneError(err) {
|
||||
return nil // already gone
|
||||
}
|
||||
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if processGoneError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Poll liveness so we return promptly once it exits, rather than always
|
||||
// waiting out the full grace period.
|
||||
deadline := time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Still alive after the grace period: force-kill.
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
|
||||
// so this helper only reports success once the target is actually gone —
|
||||
// otherwise the caller gets nil while descendants are still racing.
|
||||
deadline = time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processGoneError reports whether an error means the process group has already
|
||||
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
|
||||
// no process in the target group remains.
|
||||
func processGoneError(err error) bool {
|
||||
return errors.Is(err, syscall.ESRCH)
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
@@ -149,12 +149,9 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
|
||||
// The forked child must be gone too (poll until reaped by init).
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
break // child no longer exists
|
||||
}
|
||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||
|
||||
@@ -3,36 +3,16 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
|
||||
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
||||
// setup is required (the POSIX build sets Setpgid here instead).
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
|
||||
|
||||
func terminateProcess(pid int) error {
|
||||
taskkill := taskkillPath()
|
||||
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = os.Getenv("windir")
|
||||
}
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
||||
return execution.TerminateProcessTree(pid, 0, 0)
|
||||
}
|
||||
+12
-3
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/acp"
|
||||
"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/tools"
|
||||
)
|
||||
@@ -25,6 +27,8 @@ Usage:
|
||||
Not meant to be run interactively — point your editor's ACP / external-agent
|
||||
setting at "zero acp".`
|
||||
|
||||
var acpSignalContext = signalContext
|
||||
|
||||
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
||||
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
||||
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
||||
@@ -40,9 +44,14 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
}
|
||||
}
|
||||
|
||||
conn := acp.NewConn(deps.stdin, stdout)
|
||||
// The process's own stdin belongs exclusively to this Conn for the life of
|
||||
// the command, so cancellation may close it to interrupt an idle read.
|
||||
conn := acp.NewOwnedConn(deps.stdin, stdout)
|
||||
acp.NewAgent(conn, acp.Deps{
|
||||
ResolveConfig: deps.resolveConfig,
|
||||
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||
},
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
@@ -68,9 +77,9 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
||||
})
|
||||
|
||||
ctx, stop := signalContext()
|
||||
ctx, stop := acpSignalContext()
|
||||
defer stop()
|
||||
if err := conn.Serve(ctx); err != nil && ctx.Err() == nil {
|
||||
if err := conn.Serve(ctx); err != nil {
|
||||
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type acpTestReader func([]byte) (int, error)
|
||||
|
||||
func (r acpTestReader) Read(p []byte) (int, error) { return r(p) }
|
||||
|
||||
type acpTestWriter func([]byte) (int, error)
|
||||
|
||||
func (w acpTestWriter) Write(p []byte) (int, error) { return w(p) }
|
||||
|
||||
func installACPTestContext(t *testing.T) context.CancelFunc {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
previous := acpSignalContext
|
||||
acpSignalContext = func() (context.Context, context.CancelFunc) {
|
||||
return ctx, cancel
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
acpSignalContext = previous
|
||||
})
|
||||
return cancel
|
||||
}
|
||||
|
||||
func TestRunACPCancellationPreservesTerminalReadError(t *testing.T) {
|
||||
cancel := installACPTestContext(t)
|
||||
wantErr := errors.New("transport read failed")
|
||||
reader := acpTestReader(func(p []byte) (int, error) {
|
||||
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
||||
})
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
var stdout bytes.Buffer
|
||||
writer := acpTestWriter(func(p []byte) (int, error) {
|
||||
writeStarted <- struct{}{}
|
||||
<-releaseWrite
|
||||
return stdout.Write(p)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runACP(nil, writer, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-writeStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not reach the response write")
|
||||
}
|
||||
// Keep Serve in the synchronous write until cancellation is observable. The
|
||||
// regression requires the genuine read error to win over that cancellation.
|
||||
cancel()
|
||||
close(releaseWrite)
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != exitCrash {
|
||||
t.Fatalf("exit code = %d, want crash %d", code, exitCrash)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not exit after terminal read error")
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "acp: "+wantErr.Error()) {
|
||||
t.Fatalf("stderr = %q, want terminal read error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
|
||||
cancel := installACPTestContext(t)
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
t.Cleanup(func() { _ = pipeWriter.Close() })
|
||||
readStarted := make(chan struct{}, 1)
|
||||
reader := &acpNotifyingReadCloser{ReadCloser: pipeReader, readStarted: readStarted}
|
||||
var stdout, stderr bytes.Buffer
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runACP(nil, &stdout, &stderr, fillAppDeps(appDeps{stdin: reader}))
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-readStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not begin reading")
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want success %d; stderr: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ACP did not exit after cancellation")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
type acpNotifyingReadCloser struct {
|
||||
io.ReadCloser
|
||||
readStarted chan<- struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *acpNotifyingReadCloser) Read(p []byte) (int, error) {
|
||||
r.once.Do(func() { r.readStarted <- struct{}{} })
|
||||
return r.ReadCloser.Read(p)
|
||||
}
|
||||
@@ -515,9 +515,7 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
|
||||
if task.Agent.Truncated {
|
||||
converted.Truncated = true
|
||||
}
|
||||
for _, failure := range agentEvalFailuresFromTaskReport(task) {
|
||||
converted.Failures = append(converted.Failures, failure)
|
||||
}
|
||||
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
+40
-16
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
@@ -58,7 +59,11 @@ type appDeps struct {
|
||||
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
||||
// by fillAppDeps, so tests never mutate the process environment unless they
|
||||
// inject it). nil ⇒ no export.
|
||||
exportActiveProvider func(providerName string)
|
||||
exportActiveProvider func(providerName string)
|
||||
// getenv reads a process environment variable (production: os.Getenv, set in
|
||||
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
|
||||
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
@@ -118,6 +123,7 @@ func defaultAppDeps() appDeps {
|
||||
stdin: os.Stdin,
|
||||
userConfigPath: config.DefaultUserConfigPath,
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
getenv: os.Getenv,
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
@@ -368,6 +374,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
case "exec":
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||
case "completions":
|
||||
return runCompletions(args[1:], stdout, stderr)
|
||||
case "daemon":
|
||||
return runDaemon(args[1:], stdout, stderr, deps)
|
||||
case "config":
|
||||
@@ -577,6 +585,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
baseProbeProviderHealth := deps.probeProviderHealth
|
||||
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
|
||||
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
|
||||
if options.OAuthResolver == nil {
|
||||
options.OAuthResolver, _ = oauthLoginForProfile(options.Profile)
|
||||
}
|
||||
return baseProbeProviderHealth(ctx, options)
|
||||
}
|
||||
return deps
|
||||
@@ -678,6 +689,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
|
||||
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
@@ -712,6 +743,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
@@ -738,7 +771,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// The interactive TUI is not worktree-reassigned, so the trust root is the
|
||||
// launch directory itself.
|
||||
trustRoot := workspaceRoot
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
||||
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
||||
// apply_patch) would never be offered to the model — the TUI could neither edit
|
||||
@@ -760,19 +793,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// unchanged. The interactive surface applies no operator tool filters, so
|
||||
// enabled/disabled are nil — matching the AgentOptions below.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
@@ -789,7 +809,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
|
||||
// report can be combined with the plugin activation's, and emit at most one
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
@@ -1157,6 +1177,7 @@ Usage:
|
||||
|
||||
Commands:
|
||||
exec Run a one-shot prompt through the Go agent runtime
|
||||
completions Generate shell completion scripts
|
||||
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
||||
setup Guide first-run provider setup
|
||||
config Inspect resolved Go configuration without leaking secrets
|
||||
@@ -1341,6 +1362,9 @@ Flags:
|
||||
--spec-reasoning-effort <effort>
|
||||
Override draft reasoning effort when --use-spec is set
|
||||
--max-turns <number> Override the maximum agent loop turns
|
||||
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
|
||||
posture only (turn budget, effort, self-correction, escalation);
|
||||
composes with --mode (which picks the model) and explicit flags win
|
||||
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
|
||||
--enabled-tools <tools> Only expose these comma or space separated tools
|
||||
--disabled-tools <tools> Hide these comma or space separated tools
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type completionNode struct {
|
||||
names []string
|
||||
flags []string
|
||||
children []completionNode
|
||||
}
|
||||
|
||||
type completionContext struct {
|
||||
path string
|
||||
candidates []string
|
||||
transitions map[string]string
|
||||
}
|
||||
|
||||
var completionRoot = completionNode{
|
||||
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
|
||||
children: []completionNode{
|
||||
{names: []string{"exec"}, flags: []string{
|
||||
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
|
||||
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--max-turns", "--exec-profile",
|
||||
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
|
||||
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
|
||||
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
|
||||
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
|
||||
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
|
||||
"--notify", "--no-notify", "--no-completion-gate",
|
||||
}},
|
||||
{
|
||||
names: []string{"completions"},
|
||||
flags: []string{"-h", "--help"},
|
||||
children: leafNodes("bash", "zsh", "fish", "powershell", "elvish"),
|
||||
},
|
||||
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
|
||||
{names: []string{"setup"}},
|
||||
{names: []string{"config"}},
|
||||
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
|
||||
{names: []string{"providers"}, children: []completionNode{
|
||||
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
|
||||
{names: []string{"add"}}, {names: []string{"check"}}, {names: []string{"use"}},
|
||||
{names: []string{"remove", "rm"}}, {names: []string{"rename"}}, {names: []string{"setup"}},
|
||||
{names: []string{"detect"}}, {names: []string{"models"}},
|
||||
}},
|
||||
{names: []string{"doctor"}},
|
||||
{names: []string{"context"}},
|
||||
{names: []string{"repo-map", "repomap"}},
|
||||
{names: []string{"search", "find"}},
|
||||
{names: []string{"sessions", "session"}, children: leafNodes("list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan")},
|
||||
{names: []string{"spec"}, children: leafNodes("show", "approve", "reject")},
|
||||
{names: []string{"init"}},
|
||||
{names: []string{"specialists", "specialist"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"show"}}, {names: []string{"create"}},
|
||||
{names: []string{"delete", "rm"}}, {names: []string{"edit"}}, {names: []string{"path"}},
|
||||
}},
|
||||
{names: []string{"plugins", "plugin"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
|
||||
}},
|
||||
{names: []string{"backends", "backend"}, children: leafNodes("doctor")},
|
||||
{names: []string{"skills", "skill"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
|
||||
}},
|
||||
{names: []string{"tools", "tool"}, children: leafNodes("make", "list")},
|
||||
{names: []string{"hooks"}, children: []completionNode{
|
||||
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
|
||||
{names: []string{"enable"}}, {names: []string{"disable"}},
|
||||
}},
|
||||
{names: []string{"mcp"}, children: []completionNode{
|
||||
{names: []string{"add"}}, {names: []string{"remove", "rm"}}, {names: []string{"enable"}},
|
||||
{names: []string{"disable"}}, {names: []string{"check"}}, {names: []string{"list"}},
|
||||
{names: []string{"permissions"}, children: leafNodes("list", "revoke", "clear")},
|
||||
{names: []string{"tools"}, children: leafNodes("list")},
|
||||
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
|
||||
}},
|
||||
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh")},
|
||||
{names: []string{"sandbox"}, children: []completionNode{
|
||||
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
|
||||
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
|
||||
}},
|
||||
{names: []string{"update"}},
|
||||
{names: []string{"upgrade"}},
|
||||
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")},
|
||||
{names: []string{"verify"}},
|
||||
{names: []string{"trust"}, children: leafNodes("list", "remove")},
|
||||
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
|
||||
{names: []string{"changes", "change"}, children: []completionNode{
|
||||
{names: []string{"inspect", "status"}}, {names: []string{"commit"}}, {names: []string{"push"}},
|
||||
{names: []string{"pr", "pull-request"}},
|
||||
}},
|
||||
{names: []string{"usage"}, children: leafNodes("report")},
|
||||
{names: []string{"cron"}, children: leafNodes("add", "list", "rm", "pause", "resume", "run")},
|
||||
{names: []string{"repo-info", "repoinfo"}},
|
||||
{names: []string{"serve"}},
|
||||
{names: []string{"acp"}},
|
||||
{names: []string{"help"}},
|
||||
{names: []string{"version"}},
|
||||
},
|
||||
}
|
||||
|
||||
func leafNodes(names ...string) []completionNode {
|
||||
nodes := make([]completionNode, 0, len(names))
|
||||
for _, name := range names {
|
||||
nodes = append(nodes, completionNode{names: []string{name}})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func runCompletions(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
|
||||
if err := writeCompletionsHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "shell required. Use `zero completions <bash|zsh|fish|powershell|elvish>`.")
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unexpected completions argument %q", args[1]))
|
||||
}
|
||||
|
||||
contexts := completionContexts(completionRoot)
|
||||
var err error
|
||||
switch strings.ToLower(strings.TrimSpace(args[0])) {
|
||||
case "bash":
|
||||
err = writeBashCompletions(stdout, contexts)
|
||||
case "zsh":
|
||||
err = writeZshCompletions(stdout, contexts)
|
||||
case "fish":
|
||||
err = writeFishCompletions(stdout, contexts)
|
||||
case "powershell":
|
||||
err = writePowerShellCompletions(stdout, contexts)
|
||||
case "elvish":
|
||||
err = writeElvishCompletions(stdout, contexts)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unsupported shell %q; expected bash, zsh, fish, powershell, or elvish", args[0]))
|
||||
}
|
||||
if err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func writeCompletionsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Generate shell completion scripts.
|
||||
|
||||
Usage:
|
||||
zero completions <shell>
|
||||
|
||||
Arguments:
|
||||
<shell> Target shell: bash, zsh, fish, powershell, or elvish
|
||||
|
||||
Examples:
|
||||
source <(zero completions bash)
|
||||
source <(zero completions zsh)
|
||||
zero completions fish > ~/.config/fish/completions/zero.fish
|
||||
zero completions powershell >> $PROFILE
|
||||
eval (zero completions elvish | slurp)
|
||||
|
||||
Flags:
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func completionContexts(root completionNode) []completionContext {
|
||||
contexts := []completionContext{}
|
||||
var visit func(completionNode, []string)
|
||||
visit = func(node completionNode, paths []string) {
|
||||
candidates := append([]string{}, node.flags...)
|
||||
transitions := map[string]string{}
|
||||
for _, child := range node.children {
|
||||
candidates = append(candidates, child.names...)
|
||||
for _, path := range paths {
|
||||
for _, name := range child.names {
|
||||
childPath := strings.TrimSpace(path + " " + name)
|
||||
transitions[path+"|"+name] = childPath
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates = uniqueStrings(candidates)
|
||||
for _, path := range paths {
|
||||
contexts = append(contexts, completionContext{path: path, candidates: candidates, transitions: transitions})
|
||||
}
|
||||
for _, child := range node.children {
|
||||
childPaths := make([]string, 0, len(paths)*len(child.names))
|
||||
for _, path := range paths {
|
||||
for _, name := range child.names {
|
||||
childPaths = append(childPaths, strings.TrimSpace(path+" "+name))
|
||||
}
|
||||
}
|
||||
visit(child, childPaths)
|
||||
}
|
||||
}
|
||||
visit(root, []string{""})
|
||||
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].path < contexts[j].path })
|
||||
return contexts
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func allTransitions(contexts []completionContext) map[string]string {
|
||||
transitions := map[string]string{}
|
||||
for _, context := range contexts {
|
||||
for key, value := range context.transitions {
|
||||
transitions[key] = value
|
||||
}
|
||||
}
|
||||
return transitions
|
||||
}
|
||||
|
||||
func sortedTransitionKeys(transitions map[string]string) []string {
|
||||
keys := make([]string, 0, len(transitions))
|
||||
for key := range transitions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func shellWords(values []string) string {
|
||||
return strings.Join(values, " ")
|
||||
}
|
||||
|
||||
func writeBashCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# bash completion for zero\n_zero() {\n local cur context token i candidates\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n context=\"\""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePOSIXContextLoop(w, contexts, " for ((i = 1; i < COMP_CWORD; i++)); do", " token=\"${COMP_WORDS[i]}\"", " done"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePOSIXCandidateCase(w, contexts, " "); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " COMPREPLY=( $(compgen -W \"$candidates\" -- \"$cur\") )\n}\ncomplete -F _zero zero")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeZshCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "#compdef zero\n_zero() {\n local context token i\n local -a candidates\n context=\"\""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePOSIXContextLoop(w, contexts, " for ((i = 2; i < CURRENT; i++)); do", " token=\"${words[i]}\"", " done"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " case \"$context\" in"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
if _, err := fmt.Fprintf(w, " %q) candidates=(%s) ;;\n", context.path, quotedWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " esac\n compadd -- \"${candidates[@]}\"\n}\ncompdef _zero zero")
|
||||
return err
|
||||
}
|
||||
|
||||
func writePOSIXContextLoop(w io.Writer, contexts []completionContext, start string, token string, end string) error {
|
||||
if _, err := fmt.Fprintln(w, start); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, token); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " case \"$context|$token\" in"); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for _, key := range sortedTransitionKeys(transitions) {
|
||||
if _, err := fmt.Fprintf(w, " %q) context=%q ;;\n", key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " esac"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, end)
|
||||
return err
|
||||
}
|
||||
|
||||
func writePOSIXCandidateCase(w io.Writer, contexts []completionContext, indent string) error {
|
||||
if _, err := fmt.Fprintln(w, indent+"case \"$context\" in"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
if _, err := fmt.Fprintf(w, "%s %q) candidates=%q ;;\n", indent, context.path, shellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, indent+"esac")
|
||||
return err
|
||||
}
|
||||
|
||||
func quotedWords(values []string) string {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", value))
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
|
||||
func writeFishCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# fish completion for zero\nfunction __zero_completion_context\n set -l context ''\n set -l tokens (commandline -opc)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " for token in $tokens[2..-1]\n switch \"$context|$token\""); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for _, key := range sortedTransitionKeys(transitions) {
|
||||
if _, err := fmt.Fprintf(w, " case %q\n set context %q\n", key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " end\n end\n if test -z \"$context\"\n echo __root__\n else\n echo \"$context\"\n end\nend"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
path := context.path
|
||||
if path == "" {
|
||||
path = "__root__"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "complete -c zero -f -n 'test (__zero_completion_context) = %q' -a %q\n", path, shellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePowerShellCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# PowerShell completion for zero\nRegister-ArgumentCompleter -Native -CommandName zero -ScriptBlock {\n param($wordToComplete, $commandAst, $cursorPosition)\n $context = ''\n $elements = @($commandAst.CommandElements)\n $limit = $elements.Count\n if ($wordToComplete -ne '' -and $limit -gt 1) { $limit-- }\n for ($i = 1; $i -lt $limit; $i++) {\n $token = $elements[$i].Extent.Text\n switch (\"$context|$token\") {"); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for _, key := range sortedTransitionKeys(transitions) {
|
||||
if _, err := fmt.Fprintf(w, " '%s' { $context = '%s'; break }\n", key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " }\n }\n $candidates = switch ($context) {"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, context := range contexts {
|
||||
if _, err := fmt.Fprintf(w, " '%s' { @(%s) }\n", context.path, powershellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " }\n $candidates | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {\n [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)\n }\n}")
|
||||
return err
|
||||
}
|
||||
|
||||
func powershellWords(values []string) string {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(value, "'", "''")+"'")
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func writeElvishCompletions(w io.Writer, contexts []completionContext) error {
|
||||
if _, err := fmt.Fprintln(w, "# Elvish completion for zero\nset edit:completion:arg-completer[zero] = {|@args|\n var context = ''\n for token $args[1..-1] {"); err != nil {
|
||||
return err
|
||||
}
|
||||
transitions := allTransitions(contexts)
|
||||
for index, key := range sortedTransitionKeys(transitions) {
|
||||
keyword := "if"
|
||||
if index > 0 {
|
||||
keyword = "} elif"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, " %s (eq $context'|'$token %q) {\n set context = %q\n", keyword, key, transitions[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, " }\n }"); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, context := range contexts {
|
||||
keyword := "if"
|
||||
if index > 0 {
|
||||
keyword = "} elif"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, " %s (eq $context %q) {\n put %s\n", keyword, context.path, shellWords(context.candidates)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w, " }\n}")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionsHelpAndRootHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"completions", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("completion help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"zero completions <shell>",
|
||||
"bash, zsh, fish, powershell, or elvish",
|
||||
"source <(zero completions bash)",
|
||||
"~/.config/fish/completions/zero.fish",
|
||||
"eval (zero completions elvish | slurp)",
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Errorf("completion help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("root help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "completions Generate shell completion scripts") {
|
||||
t.Fatalf("root help does not list completions command:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionsRejectsMissingUnknownAndExtraShellArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "missing", args: []string{"completions"}, want: "shell required"},
|
||||
{name: "unknown", args: []string{"completions", "nu"}, want: "unsupported shell"},
|
||||
{name: "extra", args: []string{"completions", "bash", "extra"}, want: "unexpected completions argument"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runWithDeps(test.args, &stdout, &stderr, appDeps{}); code != exitUsage {
|
||||
t.Fatalf("exit code = %d, want %d: %s", code, exitUsage, stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionsGeneratesEverySupportedShell(t *testing.T) {
|
||||
tests := []struct {
|
||||
shell string
|
||||
marker string
|
||||
syntaxShell string
|
||||
}{
|
||||
{shell: "bash", marker: "complete -F _zero zero", syntaxShell: "bash"},
|
||||
{shell: "zsh", marker: "#compdef zero", syntaxShell: "zsh"},
|
||||
{shell: "fish", marker: "complete -c zero"},
|
||||
{shell: "powershell", marker: "Register-ArgumentCompleter -Native -CommandName zero"},
|
||||
{shell: "elvish", marker: "edit:completion:arg-completer[zero]"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.shell, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"completions", test.shell}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), test.marker) {
|
||||
t.Fatalf("%s completion missing marker %q:\n%s", test.shell, test.marker, stdout.String())
|
||||
}
|
||||
for _, want := range []string{"daemon", "mcp oauth", "sandbox grants", "--output-format"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Errorf("%s completion missing %q", test.shell, want)
|
||||
}
|
||||
}
|
||||
script := stdout.String()
|
||||
switch test.shell {
|
||||
case "fish":
|
||||
assertBalancedFishBlocks(t, script)
|
||||
case "powershell", "elvish":
|
||||
assertBalancedBraces(t, script)
|
||||
}
|
||||
if test.syntaxShell != "" {
|
||||
assertNativeShellSyntax(t, test.syntaxShell, script)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertBalancedBraces(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
if opens, closes := strings.Count(script, "{"), strings.Count(script, "}"); opens != closes {
|
||||
t.Errorf("unbalanced braces: %d opening, %d closing", opens, closes)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBalancedFishBlocks(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
depth := 0
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "function", "for", "switch", "if":
|
||||
depth++
|
||||
case "end":
|
||||
depth--
|
||||
if depth < 0 {
|
||||
t.Fatal("fish completion closes a block before one is opened")
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth != 0 {
|
||||
t.Errorf("fish completion has %d unclosed blocks", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNativeShellSyntax(t *testing.T, shell, script string) {
|
||||
t.Helper()
|
||||
path, err := exec.LookPath(shell)
|
||||
if err != nil {
|
||||
t.Skipf("%s is not installed", shell)
|
||||
}
|
||||
command := exec.Command(path, "-n")
|
||||
command.Stdin = strings.NewReader(script)
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s syntax check failed: %v\n%s", shell, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
contexts := completionContexts(completionRoot)
|
||||
byPath := make(map[string][]string, len(contexts))
|
||||
for _, context := range contexts {
|
||||
byPath[context.path] = context.candidates
|
||||
}
|
||||
|
||||
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
|
||||
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
|
||||
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
|
||||
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
|
||||
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
|
||||
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
|
||||
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
|
||||
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
|
||||
}
|
||||
|
||||
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||
t.Helper()
|
||||
set := make(map[string]bool, len(got))
|
||||
for _, candidate := range got {
|
||||
set[candidate] = true
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !set[want] {
|
||||
t.Errorf("candidates %v do not contain %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,11 @@ func runContext(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
report, err := contextreport.Build(contextreport.Options{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Provider: resolved.Provider,
|
||||
Registry: newCoreRegistry(workspaceRoot),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Provider: resolved.Provider,
|
||||
Registry: newCoreRegistry(workspaceRoot),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
|
||||
@@ -77,11 +77,10 @@ func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredEligibleCountIgnoresCoreTools(t *testing.T) {
|
||||
func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
// newCoreRegistry holds only built-ins; none implement Deferred().
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 3 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want at least 3 optional built-ins", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -138,6 +139,7 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
|
||||
}
|
||||
if info.Hash != "" {
|
||||
lines = append(lines, " hash: "+info.Hash)
|
||||
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
||||
}
|
||||
lines = append(lines, " path: "+info.Skill.Path)
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||
@@ -252,6 +254,115 @@ func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writ
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
id, options, help, err := parsePluginInfoArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writePluginInfoHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if id == "" {
|
||||
return writeExecUsageError(stderr, "usage: zero plugins info <id> [--user] [--json]")
|
||||
}
|
||||
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
||||
}
|
||||
info, err := plugins.Info(plugins.InfoOptions{
|
||||
LoadOptions: pluginLoadOptions(deps, cwd, options.user),
|
||||
}, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, plugins.ErrNotInstalled) {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
plugin := info.Plugin
|
||||
lines := []string{fmt.Sprintf("%s (%s) %s", plugin.ID, plugin.Name, plugin.Version)}
|
||||
state := "enabled"
|
||||
if !plugin.Enabled {
|
||||
state = "disabled"
|
||||
}
|
||||
lines = append(lines, " state: "+state)
|
||||
lines = append(lines, " source: "+string(plugin.Source))
|
||||
if len(plugin.Tools) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" tools: %d", len(plugin.Tools)))
|
||||
}
|
||||
if len(plugin.Hooks) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" hooks: %d", len(plugin.Hooks)))
|
||||
}
|
||||
if len(plugin.Skills) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" skills: %d", len(plugin.Skills)))
|
||||
}
|
||||
if len(plugin.Prompts) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" prompts: %d", len(plugin.Prompts)))
|
||||
}
|
||||
if info.LockSource != "" {
|
||||
lines = append(lines, " lock source: "+info.LockSource)
|
||||
}
|
||||
if info.LockHash != "" {
|
||||
lines = append(lines, " lock hash: "+info.LockHash)
|
||||
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
||||
}
|
||||
lines = append(lines, " manifest: "+plugin.ManifestPath)
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type pluginInfoOptions struct {
|
||||
json bool
|
||||
user bool
|
||||
}
|
||||
|
||||
func parsePluginInfoArgs(args []string) (string, pluginInfoOptions, bool, error) {
|
||||
options := pluginInfoOptions{}
|
||||
id := ""
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return "", options, true, nil
|
||||
case "--json":
|
||||
options.json = true
|
||||
case "--user":
|
||||
options.user = true
|
||||
default:
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return "", options, false, execUsageError{fmt.Sprintf("unknown plugins info flag %q", arg)}
|
||||
}
|
||||
if id != "" {
|
||||
return "", options, false, execUsageError{"plugins info takes a single id"}
|
||||
}
|
||||
id = arg
|
||||
}
|
||||
}
|
||||
return id, options, false, nil
|
||||
}
|
||||
|
||||
func pluginLoadOptions(deps appDeps, cwd string, userOnly bool) plugins.LoadOptions {
|
||||
opts := plugins.LoadOptions{Cwd: cwd, ExcludeProject: userOnly}
|
||||
roots := []plugins.Root{{Source: plugins.SourceUser, Path: deps.pluginsDir()}}
|
||||
if !userOnly {
|
||||
roots = append(roots, plugins.Root{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")})
|
||||
}
|
||||
opts.Roots = roots
|
||||
return opts
|
||||
}
|
||||
|
||||
// --- tools make / list ---
|
||||
|
||||
func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -567,6 +678,18 @@ Removes an installed plugin directory and its plugins.lock entry.
|
||||
return err
|
||||
}
|
||||
|
||||
func writePluginInfoHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero plugins info <id> [flags]
|
||||
|
||||
Flags:
|
||||
--user Target only user plugins (ignore ./.zero/plugins)
|
||||
--json Print plugin info as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeToolsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero tools <command>
|
||||
|
||||
@@ -83,6 +83,48 @@ func TestRunSkillAddInfoRemove(t *testing.T) {
|
||||
if !strings.Contains(info, src) || !strings.Contains(info, "sha256:") {
|
||||
t.Fatalf("info missing source/hash:\n%s", info)
|
||||
}
|
||||
if !strings.Contains(info, "hash drift: false") {
|
||||
t.Fatalf("info missing hash drift status:\n%s", info)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info --json exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
var beforeJSON map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &beforeJSON); err != nil {
|
||||
t.Fatalf("parse skill info json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if beforeJSON["hashDrift"] != false {
|
||||
t.Fatalf("hashDrift before edit = %#v, want false", beforeJSON["hashDrift"])
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(skillsDir, "confirmation-policy", "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte("---\ndescription: edited\n---\nchanged\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info after edit exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "hash drift: true") {
|
||||
t.Fatalf("info missing hash drift after edit:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info --json after edit exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
var afterJSON map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &afterJSON); err != nil {
|
||||
t.Fatalf("parse skill info json after edit: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if afterJSON["hashDrift"] != true {
|
||||
t.Fatalf("hashDrift after edit = %#v, want true", afterJSON["hashDrift"])
|
||||
}
|
||||
|
||||
// remove deletes it
|
||||
stdout.Reset()
|
||||
@@ -177,6 +219,15 @@ func TestRunPluginAddListRemove(t *testing.T) {
|
||||
t.Fatalf("add output missing id:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"plugin", "info", "zero.demo"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("plugin info exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "zero.demo") || !strings.Contains(stdout.String(), "manifest:") {
|
||||
t.Fatalf("info output missing plugin details:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
// remove
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
|
||||
+124
-31
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -69,7 +71,14 @@ type execOptions struct {
|
||||
// intentionally inert: nothing consumes it. Model selection is driven by
|
||||
// --model / --mode instead. See writeExecHelp ("Accept legacy model profile
|
||||
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
|
||||
modelProfile string
|
||||
modelProfile string
|
||||
// execProfile selects a named execution profile (balanced, fast, thorough):
|
||||
// a loop-posture bundle (turn budget, reasoning effort, self-correction,
|
||||
// escalation triggers) applied AFTER --mode with the same
|
||||
// fill-only-if-unset rule, so precedence is explicit flag > mode > profile.
|
||||
// Distinct from the mode preset also named "fast" (which picks a model) and
|
||||
// from the legacy inert --profile above. See internal/execprofile.
|
||||
execProfile string
|
||||
reasoningEffort string
|
||||
useSpec bool
|
||||
specModel string
|
||||
@@ -167,6 +176,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err := applyExecMode(&options); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
// A profile tunes loop posture the way a mode tunes model selection, and
|
||||
// runs after it so the mode's fills count as "set" and win. MaxTurns is
|
||||
// deferred to config resolution below, where the displaced resolved budget
|
||||
// is known and becomes the escalation restore target.
|
||||
execProfile, execProfileFilledEffort, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
@@ -190,6 +207,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
// Register the escalate_model tool only when the run opted into mid-run
|
||||
// escalation. It is registered before --list-tools formatting and
|
||||
// validateExecToolFilters so the tool is both listable and filter-validatable.
|
||||
@@ -222,16 +240,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
permissionMode = agent.PermissionModeSpecDraft
|
||||
}
|
||||
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
var mcpRuntime mcpToolRuntime
|
||||
var mcpSkip trustSkip
|
||||
// Make local plugins live for this run: register their declared tools into the
|
||||
// registry and collect their hooks + skill roots for the dispatcher and skill
|
||||
// tool below. Done before --list-tools and filter validation so plugin tools
|
||||
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
var pluginActivation pluginActivation
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
@@ -272,6 +287,31 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
if notice, err := sandboxEngine.ConsumeGrantMigrationNotice(); err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", "migrate sandbox grants: "+err.Error())
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
@@ -339,28 +379,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
images = nil
|
||||
}
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
// Re-register the core tools with the run scope, OVERWRITING the nil-scope
|
||||
// instances registered before config resolve (the registry must exist that
|
||||
// early for --list-tools and tool-filter validation, which run without a
|
||||
// provider). This is safe only while two invariants hold:
|
||||
// 1. Registry.Register replaces by NAME, so every path-confining core
|
||||
// tool is swapped wholesale; and
|
||||
// 2. nothing between the initial registration and this point captures a
|
||||
// core-tool INSTANCE (tool_search holds the *Registry* and resolves
|
||||
// names lazily; --list-tools and filter validation use names only).
|
||||
// A new wrapper that snapshots a core tool before this line would silently
|
||||
// ship nil-scope enforcement — add it below this re-registration instead.
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
runReasoningEffort := options.reasoningEffort
|
||||
if options.useSpec && options.specReasoningEffort != "" {
|
||||
runReasoningEffort = options.specReasoningEffort
|
||||
@@ -486,6 +504,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
reasoningEffort: forwardEffort,
|
||||
specPermissionMode: permissionMode,
|
||||
notifier: notifier,
|
||||
// The profile displaced resolved.MaxTurns above, so the spec-draft
|
||||
// run arms the same escalation policy as the main run (in practice
|
||||
// only the failure-streak and risky-mutation triggers can fire
|
||||
// here: the draft runs without the completion gate or a
|
||||
// self-corrector).
|
||||
profilePolicy: execProfile.Policy(displacedMaxTurns,
|
||||
specProfileEffortFilled(execProfileFilledEffort, options.specReasoningEffort)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -528,7 +553,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
var traceRecorder *trace.Recorder
|
||||
var traceSnapshot *trace.TurnTrace
|
||||
if tracePath != "" {
|
||||
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, "")
|
||||
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, execProfile.Name)
|
||||
defer func() {
|
||||
if err := writeTraceSnapshot(traceSnapshot, tracePath, stderr); err != nil {
|
||||
fmt.Fprintf(stderr, "[zero] failed to write trace: %s\n", err)
|
||||
@@ -599,7 +624,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Build the hooks dispatcher out of the struct literal so its trust skip report
|
||||
// can be combined with the plugin activation's, and emit at most one notice when
|
||||
// project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
@@ -627,6 +652,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -913,6 +939,12 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
|
||||
// request is allowed and commands run unwrapped. Only reachable from global
|
||||
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
|
||||
if cfg.Enabled != nil && !*cfg.Enabled {
|
||||
policy.Mode = sandbox.ModeDisabled
|
||||
}
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
@@ -1133,6 +1165,67 @@ func applyExecMode(options *execOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyExecProfile expands an --exec-profile selection onto the exec options.
|
||||
// Like applyExecMode it only fills fields the caller left unset, and it runs
|
||||
// AFTER the mode so a mode-filled field counts as set: precedence is explicit
|
||||
// flag > --mode > --exec-profile. Only the options-level knobs are applied here
|
||||
// (reasoning effort still flows through the model-supported gating downstream;
|
||||
// self-correct is presence-only so a profile can only turn it on). MaxTurns is
|
||||
// intentionally NOT applied here — the profile displaces the resolved budget
|
||||
// after config resolution, where the displaced value is known and becomes the
|
||||
// escalation restore target. An unknown profile is a usage error listing the
|
||||
// valid names. Not to be confused with the legacy inert --profile flag.
|
||||
// The second return reports whether the profile actually filled the reasoning
|
||||
// effort (false when the user set one explicitly), so the escalation policy
|
||||
// knows the displaced effort was the provider default and can restore it.
|
||||
func applyExecProfile(options *execOptions) (execprofile.Profile, bool, error) {
|
||||
name := strings.TrimSpace(options.execProfile)
|
||||
if name == "" {
|
||||
return execprofile.Profile{}, false, nil
|
||||
}
|
||||
profile, ok := execprofile.Lookup(name)
|
||||
if !ok {
|
||||
return execprofile.Profile{}, false, execUsageError{fmt.Sprintf("unknown execution profile %q. Valid profiles: %s.", options.execProfile, strings.Join(execprofile.Names(), ", "))}
|
||||
}
|
||||
effortFilled := false
|
||||
if options.reasoningEffort == "" && profile.ReasoningEffort != "" {
|
||||
options.reasoningEffort = profile.ReasoningEffort
|
||||
effortFilled = true
|
||||
}
|
||||
// Spec-safe projection: the spec-draft path wires no self-corrector (an
|
||||
// explicit --self-correct --use-spec is rejected at parse time, before
|
||||
// profiles apply), so a profile's self-correct knob is meaningless there.
|
||||
// Project it away rather than erroring: the user asked for a thorough
|
||||
// DRAFT, and the profile's other knobs (budget, effort) apply to it fine.
|
||||
if profile.SelfCorrect && !options.useSpec {
|
||||
options.selfCorrect = true
|
||||
}
|
||||
return profile, effortFilled, nil
|
||||
}
|
||||
|
||||
// specProfileEffortFilled reports whether the profile's effort fill actually
|
||||
// governs the spec-draft run. An explicit --spec-reasoning-effort replaces the
|
||||
// filled effort for the draft, so the escalation's effort restore must not arm
|
||||
// there: escalation must never clear an effort the user pinned by hand.
|
||||
func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool {
|
||||
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
|
||||
}
|
||||
|
||||
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
|
||||
// The profile displaces the RESOLVED value, not the flag: a pinned budget
|
||||
// always wins and the profile backs off entirely, while an env/config budget
|
||||
// is the "balanced posture" a mid-run escalation can restore. pinnedMaxTurns
|
||||
// is any caller-pinned budget — an explicit --max-turns flag OR a mode
|
||||
// preset's fill (both land in options.maxTurns, and a mode outranks a profile
|
||||
// just like the flag does). displaced is 0 when nothing was displaced, so an
|
||||
// escalation leaves the ceiling untouched.
|
||||
func applyProfileTurnBudget(profile execprofile.Profile, pinnedMaxTurns int, resolvedMaxTurns int) (effective int, displaced int) {
|
||||
if profile.MaxTurns > 0 && pinnedMaxTurns == 0 {
|
||||
return profile.MaxTurns, resolvedMaxTurns
|
||||
}
|
||||
return resolvedMaxTurns, 0
|
||||
}
|
||||
|
||||
// resolveSelectedModel routes a user-supplied --model value through the model
|
||||
// registry so that fuzzy aliases (e.g. "sonnet 4.5") resolve to canonical ids
|
||||
// and deprecated models auto-redirect to their fallback. It returns the model id
|
||||
|
||||
@@ -147,6 +147,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
options.modelProfile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
case arg == "--exec-profile":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.execProfile = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--exec-profile="):
|
||||
value, err := requiredInlineFlagValue(arg, "--exec-profile")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.execProfile = value
|
||||
case arg == "-r" || arg == "--reasoning-effort":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
)
|
||||
|
||||
func TestApplyExecProfileFillsOnlyUnset(t *testing.T) {
|
||||
// Unset effort: the profile fills it and reports the fill, so the
|
||||
// escalation policy knows the displaced effort was the provider default.
|
||||
options := execOptions{execProfile: "fast"}
|
||||
profile, effortFilled, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if profile.Name != "fast" {
|
||||
t.Fatalf("profile.Name = %q, want fast", profile.Name)
|
||||
}
|
||||
if options.reasoningEffort != "low" {
|
||||
t.Fatalf("reasoningEffort = %q, want the profile's low", options.reasoningEffort)
|
||||
}
|
||||
if !effortFilled {
|
||||
t.Fatal("the profile filled the effort, so effortFilled must report it")
|
||||
}
|
||||
|
||||
// Set effort (explicit flag or a mode's fill): the profile backs off.
|
||||
options = execOptions{execProfile: "fast", reasoningEffort: "high"}
|
||||
_, effortFilled, err = applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, an explicit value must win over the profile", options.reasoningEffort)
|
||||
}
|
||||
if effortFilled {
|
||||
t.Fatal("the profile backed off, so effortFilled must be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecProfileThoroughArmsSelfCorrect(t *testing.T) {
|
||||
options := execOptions{execProfile: "thorough"}
|
||||
if _, _, err := applyExecProfile(&options); err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if !options.selfCorrect {
|
||||
t.Fatal("thorough must arm the post-edit self-correct loop")
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, want high", options.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec-draft path wires no self-corrector (explicit --self-correct
|
||||
// --use-spec is rejected at parse time, before profiles apply), so a profile's
|
||||
// self-correct knob is projected away under --use-spec instead of silently
|
||||
// arming a knob the draft runner ignores. The other knobs still apply.
|
||||
func TestApplyExecProfileSpecProjectsAwaySelfCorrect(t *testing.T) {
|
||||
options := execOptions{execProfile: "thorough", useSpec: true}
|
||||
if _, _, err := applyExecProfile(&options); err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.selfCorrect {
|
||||
t.Fatal("thorough under --use-spec must not arm self-correct (the draft runner has none)")
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, the profile's other knobs must still apply", options.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecProfileUnknownIsUsageError(t *testing.T) {
|
||||
options := execOptions{execProfile: "turbo"}
|
||||
_, _, err := applyExecProfile(&options)
|
||||
if err == nil {
|
||||
t.Fatal("expected a usage error for an unknown profile")
|
||||
}
|
||||
if _, ok := err.(execUsageError); !ok {
|
||||
t.Fatalf("expected execUsageError, got %T: %v", err, err)
|
||||
}
|
||||
for _, name := range execprofile.Names() {
|
||||
if !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("usage error must list %q, got %q", name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: explicit flag > --mode > --exec-profile. The deep mode fills
|
||||
// effort=high and max-turns=160; the fast profile must not override either,
|
||||
// because a mode-filled field counts as set by the time the profile runs.
|
||||
func TestExecProfilePrecedenceFlagOverModeOverProfile(t *testing.T) {
|
||||
options := execOptions{mode: "deep", execProfile: "fast"}
|
||||
if err := applyExecMode(&options); err != nil {
|
||||
t.Fatalf("applyExecMode: %v", err)
|
||||
}
|
||||
profile, _, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, the mode's high must win over the profile's low", options.reasoningEffort)
|
||||
}
|
||||
// The mode filled options.maxTurns, so the turn budget must not be displaced.
|
||||
effective, displaced := applyProfileTurnBudget(profile, options.maxTurns, options.maxTurns)
|
||||
if effective != 160 || displaced != 0 {
|
||||
t.Fatalf("turn budget = (%d, displaced %d), the mode's 160 must win with nothing displaced", effective, displaced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProfileTurnBudget(t *testing.T) {
|
||||
fast, _ := execprofile.Lookup("fast")
|
||||
balanced, _ := execprofile.Lookup("balanced")
|
||||
|
||||
// No explicit flag: the profile displaces the resolved budget.
|
||||
if effective, displaced := applyProfileTurnBudget(fast, 0, 80); effective != 30 || displaced != 80 {
|
||||
t.Fatalf("fast over resolved 80 = (%d, %d), want (30, 80)", effective, displaced)
|
||||
}
|
||||
// Explicit --max-turns pins the budget; the profile backs off entirely.
|
||||
if effective, displaced := applyProfileTurnBudget(fast, 50, 50); effective != 50 || displaced != 0 {
|
||||
t.Fatalf("fast with explicit 50 = (%d, %d), want (50, 0)", effective, displaced)
|
||||
}
|
||||
// Balanced never displaces anything.
|
||||
if effective, displaced := applyProfileTurnBudget(balanced, 0, 80); effective != 80 || displaced != 0 {
|
||||
t.Fatalf("balanced over resolved 80 = (%d, %d), want (80, 0)", effective, displaced)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-regression invariant at the options level: selecting balanced leaves
|
||||
// the options byte-identical to not selecting a profile at all, and produces
|
||||
// no loop policy.
|
||||
func TestExecProfileBalancedLeavesOptionsUntouched(t *testing.T) {
|
||||
options := execOptions{execProfile: "balanced"}
|
||||
reference := options
|
||||
profile, _, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(options, reference) {
|
||||
t.Fatalf("balanced changed the options: %+v vs %+v", options, reference)
|
||||
}
|
||||
if policy := profile.Policy(80, false); policy != nil {
|
||||
t.Fatalf("balanced must produce a nil policy, got %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// --exec-profile= with an inline empty value must error like its two-token
|
||||
// form and the sibling --mode=, not silently run the default posture (the
|
||||
// classic shell hazard: an unset variable expanding into the inline form).
|
||||
func TestParseExecProfileInlineEmptyIsError(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--exec-profile=", "hello"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--exec-profile requires a value") {
|
||||
t.Fatalf("expected a required-value error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Full echo-provider run: the selected profile must be stamped into the
|
||||
// per-turn trace so benchmark attribution can group runs by posture.
|
||||
func TestRunExecTraceRecordsSelectedProfile(t *testing.T) {
|
||||
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "fast", "--trace", tracePath, "hello",
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
raw, err := os.ReadFile(tracePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read trace: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"profile":"fast"`) {
|
||||
t.Fatalf("trace must record the selected profile, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit --spec-reasoning-effort governs the spec draft's effort, so the
|
||||
// escalation effort-restore must not arm there even when the profile filled
|
||||
// the (unused) main effort.
|
||||
func TestSpecProfileEffortFilled(t *testing.T) {
|
||||
if !specProfileEffortFilled(true, "") {
|
||||
t.Fatal("no explicit spec effort: the profile's fill governs, restore must arm")
|
||||
}
|
||||
if specProfileEffortFilled(true, "high") {
|
||||
t.Fatal("an explicit spec effort must disarm the effort restore")
|
||||
}
|
||||
if specProfileEffortFilled(false, "") {
|
||||
t.Fatal("nothing filled, nothing to restore")
|
||||
}
|
||||
}
|
||||
|
||||
// The trace label deliberately records the SELECTED profile, balanced
|
||||
// included, so captures are self-describing. Run behavior stays identical
|
||||
// (balanced fills nothing and arms no policy); the label is the one intended
|
||||
// difference in the opt-in trace artifact.
|
||||
func TestRunExecTraceRecordsBalancedSelection(t *testing.T) {
|
||||
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "balanced", "--trace", tracePath, "hello",
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
raw, err := os.ReadFile(tracePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read trace: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"profile":"balanced"`) {
|
||||
t.Fatalf("trace must record the balanced selection, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUnknownExecProfileIsUsageError(t *testing.T) {
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "turbo", "hello",
|
||||
})
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr, "unknown execution profile") {
|
||||
t.Fatalf("expected an unknown-profile usage error, got %q", stderr)
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,11 @@ func preflightExecSession(options execOptions) error {
|
||||
if session == nil {
|
||||
return execUsageError{"Zero session not found: " + options.resume}
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
return execUsageError{"Zero session is not resumable: " + options.resume}
|
||||
}
|
||||
case options.resumeLatest:
|
||||
latest, err := store.Latest()
|
||||
latest, err := store.LatestResumable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestPreflightRejectsSideSessions(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide}); err != nil {
|
||||
t.Fatalf("create side session: %v", err)
|
||||
}
|
||||
|
||||
err := preflightExecSession(execOptions{resumeLatest: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "No Zero sessions available to resume") {
|
||||
t.Fatalf("preflightExecSession error = %v, want no resumable sessions", err)
|
||||
}
|
||||
|
||||
err = preflightExecSession(execOptions{resume: "side"})
|
||||
if err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: side") {
|
||||
t.Fatalf("explicit side-session preflight error = %v, want non-resumable rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -46,6 +47,11 @@ type execSpecDraftRun struct {
|
||||
reasoningEffort string
|
||||
specPermissionMode agent.PermissionMode
|
||||
notifier *notify.Notifier
|
||||
// profilePolicy carries the selected execution profile's escalation policy
|
||||
// (nil when none): the profile displaces resolved.MaxTurns before this
|
||||
// path branches off, so the spec-draft run needs the same safety net as
|
||||
// the main run.
|
||||
profilePolicy *agent.ProfilePolicy
|
||||
}
|
||||
|
||||
type execSpecDraftInfo struct {
|
||||
@@ -109,7 +115,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
|
||||
// repo trusted. Emit at most one notice when project hooks or the project MCP
|
||||
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
||||
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
@@ -119,6 +125,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Profile: run.profilePolicy,
|
||||
Cwd: run.workspaceRoot,
|
||||
SystemPrompt: specmode.DraftSystemPrompt,
|
||||
Images: run.images,
|
||||
|
||||
@@ -549,10 +549,14 @@ func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
// --profile (legacy, inert model profile) and --exec-profile (execution
|
||||
// profile) are distinct flags and must coexist on one invocation.
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{
|
||||
"exec",
|
||||
"--profile",
|
||||
"fast",
|
||||
"--exec-profile",
|
||||
"balanced",
|
||||
"--reasoning-effort",
|
||||
"low",
|
||||
"hello",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,12 +25,47 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
|
||||
var heads, posts atomic.Int64
|
||||
// Closed when the prewarm probe lands, so the turn can be held open until it
|
||||
// does. See the POST handler for why that ordering has to be forced.
|
||||
headSeen := make(chan struct{})
|
||||
var headOnce sync.Once
|
||||
var prewarmWaitTimedOut atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodHead:
|
||||
heads.Add(1)
|
||||
headOnce.Do(func() { close(headSeen) })
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
|
||||
// Hold the turn open until the prewarm probe arrives. The probe is
|
||||
// issued on the run's context, and runExec cancels that context the
|
||||
// moment it returns, because the stop function signalContext hands
|
||||
// back is a CancelFunc. A probe that has not reached the wire by the
|
||||
// end of the run therefore never reaches it at all. That is why the
|
||||
// poll this replaced could not work at any deadline: once the run is
|
||||
// over the probe is cancelled, not merely slow.
|
||||
//
|
||||
// This widens the window, it does not close it. Once the goroutine is
|
||||
// scheduled it still has only prewarmTimeout to dial loopback and
|
||||
// write the request, and that timeout is armed inside the goroutine
|
||||
// itself, so holding the turn here cannot extend it. If this test
|
||||
// ever fails again, prewarmTimeout is where to look, not this hold.
|
||||
//
|
||||
// No self-deadlock: the pinned transport leaves MaxConnsPerHost at 0,
|
||||
// so the HEAD dials its own connection instead of queueing behind
|
||||
// this held POST. Do not set a per-host connection limit there.
|
||||
//
|
||||
// Bounded, so a probe that genuinely never fires fails the assertion
|
||||
// below with a useful message instead of hanging the test.
|
||||
select {
|
||||
case <-headSeen:
|
||||
case <-time.After(10 * time.Second):
|
||||
// Record it. A probe that shows up after this gave up would still
|
||||
// leave heads at 1, so the count alone proves the probe was
|
||||
// eventually received, not that it was ordered before the turn,
|
||||
// which is the whole claim of this test.
|
||||
prewarmWaitTimedOut.Store(true)
|
||||
}
|
||||
posts.Add(1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n"))
|
||||
@@ -75,14 +111,13 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
if got := posts.Load(); got < 1 {
|
||||
t.Fatalf("server saw %d chat-completions POSTs, want >= 1", got)
|
||||
}
|
||||
// The prewarm probe is asynchronous; it fires well before the model turn
|
||||
// finishes, but poll rather than assuming scheduling order. The deadline
|
||||
// exceeds the production prewarmTimeout (3s) so a slow probe cannot flake
|
||||
// this assertion.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for heads.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Checked before the count, because the count cannot tell the two apart: a
|
||||
// probe that arrived only after the guard expired still leaves heads at 1.
|
||||
if prewarmWaitTimedOut.Load() {
|
||||
t.Fatal("the turn was not held until the prewarm probe arrived; the probe did not precede it")
|
||||
}
|
||||
// No polling: the turn above could not complete until the probe landed, so
|
||||
// the count is settled by this point.
|
||||
if got := heads.Load(); got != 1 {
|
||||
t.Fatalf("server saw %d prewarm HEAD probes, want exactly 1", got)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitSuccess
|
||||
case "add":
|
||||
return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr)
|
||||
case "info":
|
||||
return runPluginInfo(args[1:], stdout, stderr, deps)
|
||||
case "remove", "rm":
|
||||
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
|
||||
default:
|
||||
@@ -561,6 +563,7 @@ func writePluginsHelp(w io.Writer) error {
|
||||
|
||||
Commands:
|
||||
list List local Zero plugins
|
||||
info <id> Show plugin details and lockfile metadata
|
||||
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
|
||||
remove <id> Remove an installed plugin and its lockfile entry
|
||||
`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
@@ -56,8 +57,8 @@ func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool
|
||||
//
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment); the project layer loads only when that root is trusted.
|
||||
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
|
||||
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
|
||||
}
|
||||
|
||||
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
|
||||
@@ -72,7 +73,7 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
|
||||
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
|
||||
// returned trustSkip lets the caller emit one combined notice; the notice itself
|
||||
// is NOT emitted here.
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
|
||||
@@ -106,10 +107,15 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
|
||||
}
|
||||
config.Hooks = merged
|
||||
}
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
return hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: config,
|
||||
Audit: audit,
|
||||
Cwd: workspaceRoot,
|
||||
Config: config,
|
||||
Audit: audit,
|
||||
Cwd: workspaceRoot,
|
||||
Execution: runner,
|
||||
}), skip
|
||||
}
|
||||
|
||||
|
||||
@@ -734,14 +734,12 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
|
||||
switch {
|
||||
case legacyFound:
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
case config.IsDefaultMCPServer(name):
|
||||
// A built-in default server isn't written to the file until the user
|
||||
// overrides it. Treat it as present with an empty base so disabling it
|
||||
// writes a minimal {"disabled":true} entry that merges over the default —
|
||||
// letting `zero mcp disable <default>` work even though it lives in code.
|
||||
raw = nil
|
||||
found = true
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
@@ -29,7 +30,7 @@ type mcpToolListItem struct {
|
||||
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
|
||||
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
|
||||
// otherwise a workspace whose only project config is MCP would be gated silently.
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
|
||||
@@ -46,9 +47,15 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
|
||||
if err != nil {
|
||||
return nil, skip, err
|
||||
}
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
||||
PermissionStore: store,
|
||||
Autonomy: autonomy,
|
||||
Execution: runner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
return runtime, skip, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
@@ -109,6 +112,65 @@ func TestRunDoctorConnectivityProbesProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityUsesOAuthLogin(t *testing.T) {
|
||||
tokenPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: tokenPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{
|
||||
AccessToken: "oauth-doctor-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed OAuth token: %v", err)
|
||||
}
|
||||
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profile := config.ProviderProfile{
|
||||
Name: "xai",
|
||||
CatalogID: "xai",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/v1",
|
||||
Model: "grok-4",
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"doctor", "--connectivity"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Provider: profile,
|
||||
Providers: []config.ProviderProfile{profile},
|
||||
ActiveProvider: profile.Name,
|
||||
}, nil
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
now: fixedCLITime("2026-07-29T20:00:00Z"),
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("doctor exit = %d, want success; stderr=%q\n%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
if gotAuth != "Bearer oauth-doctor-token" {
|
||||
t.Fatalf("Authorization = %q, want OAuth bearer", gotAuth)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "[pass] provider.connectivity") {
|
||||
t.Fatalf("doctor did not report passing connectivity:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorConnectivityReportsProviderHealthFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -42,7 +43,7 @@ type pluginActivation struct {
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment). The returned activation carries the skip report so the caller can
|
||||
// emit one combined notice; the notice itself is NOT emitted here.
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string, runners ...*execution.Runner) pluginActivation {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
|
||||
@@ -65,7 +66,11 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
writePluginActivationWarning(stderr, formatLoadDiagnostic(diagnostic))
|
||||
}
|
||||
|
||||
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot})
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot, Execution: runner})
|
||||
for _, warning := range result.Warnings {
|
||||
writePluginActivationWarning(stderr, warning)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
|
||||
@@ -83,10 +83,10 @@ func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
|
||||
options := providerDetectOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
case "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
)
|
||||
|
||||
type providerModelsOptions struct {
|
||||
@@ -137,7 +138,12 @@ func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderP
|
||||
// the provider's model-listing endpoint with no curated-catalog merge or
|
||||
// coding-model filtering, so a custom provider's full model list is returned.
|
||||
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
|
||||
resolver, loginKey := oauthLoginForProfile(profile)
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{
|
||||
OAuthResolver: resolver,
|
||||
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
|
||||
UserAgent: userAgent(),
|
||||
})
|
||||
}
|
||||
|
||||
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
|
||||
|
||||
@@ -53,16 +53,39 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// SetActiveProvider only ever matches profiles persisted in config.json
|
||||
// (see config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, switching to
|
||||
// that provider by name always fails with a confusing "not found" even
|
||||
// though it is genuinely usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderUse(stdout, stderr, deps, options, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.SetActiveProvider(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
override := activeProviderEnvOverride(deps.getenv, cfg.ActiveProvider)
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
payload := map[string]any{
|
||||
"activeProvider": cfg.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
}); err != nil {
|
||||
}
|
||||
if override != "" {
|
||||
// A JSON consumer must not read this as an effective switch either.
|
||||
payload["effectiveProvider"] = override
|
||||
payload["overriddenByEnv"] = config.ActiveProviderEnv
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -70,9 +93,32 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if _, err := fmt.Fprintf(stdout, "Active provider set to %s\nnext: %s\n", cfg.ActiveProvider, providerCheckCommand(cfg.ActiveProvider, false)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if override != "" {
|
||||
if _, err := fmt.Fprintf(stderr, "Note: %s=%s is set and overrides config.json, so %s stays the active provider until you unset %s.\n", config.ActiveProviderEnv, override, override, config.ActiveProviderEnv); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// activeProviderEnvOverride returns the ZERO_PROVIDER value when it is set and
|
||||
// names a DIFFERENT provider than the one just selected, meaning the saved
|
||||
// `providers use` selection will NOT be the effective active provider until the
|
||||
// env var is unset. applyEnv (resolver.go) makes ZERO_PROVIDER win over
|
||||
// config.json unconditionally, so reporting the write as a plain success reads as
|
||||
// a switch that silently has no effect (issue #721). Empty when nothing overrides
|
||||
// (including when getenv is nil, e.g. a test that did not inject the environment).
|
||||
func activeProviderEnvOverride(getenv func(string) string, selected string) string {
|
||||
if getenv == nil {
|
||||
return ""
|
||||
}
|
||||
override := strings.TrimSpace(getenv(config.ActiveProviderEnv))
|
||||
if override == "" || strings.EqualFold(override, strings.TrimSpace(selected)) {
|
||||
return ""
|
||||
}
|
||||
return override
|
||||
}
|
||||
|
||||
func runProvidersSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderSetupArgs(args)
|
||||
if err != nil {
|
||||
@@ -352,6 +398,22 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
name := options.names[0]
|
||||
// RemoveProvider only ever matches profiles persisted in config.json (see
|
||||
// config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, deleting that
|
||||
// provider by name always fails with a confusing "not found" even though
|
||||
// it is genuinely visible/usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRemove(stdout, stderr, deps, name, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RemoveProvider(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -428,6 +490,16 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
oldName := options.names[0]
|
||||
persisted, err := config.ProviderPersisted(configPath, oldName)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRename(stdout, stderr, deps, oldName, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -447,3 +519,120 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// providerResolvedByName reports whether name matches a provider in a
|
||||
// resolved provider list — used to tell a genuinely unknown name (a typo)
|
||||
// apart from a real, env-derived provider that just has no config.json row.
|
||||
func providerResolvedByName(providers []config.ProviderProfile, name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, provider := range providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderUse handles `zero providers use <name>` for a
|
||||
// provider that is not persisted in config.json. If it's not resolvable at
|
||||
// all (an unknown/misspelled name), it returns handled=false so the caller
|
||||
// falls through to SetActiveProvider's real "not found" error. If it IS
|
||||
// resolvable — an env-derived profile (e.g. ambient OPENAI_API_KEY) —
|
||||
// SetActiveProvider would only ever fail "not found" against it, so this
|
||||
// reports the situation plainly instead of that confusing error (issue
|
||||
// #707).
|
||||
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
// resolveCommandCenterConfig already wrote its own error to stderr;
|
||||
// stop here instead of letting the caller try SetActiveProvider too.
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, options.name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to switch to.\nIt is available whenever its environment variable is set, but is only active when selected (for example via ZERO_PROVIDER); unset its environment variable to stop Zero from detecting it automatically.",
|
||||
options.name,
|
||||
)
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderRemove handles `zero providers remove <name>` for
|
||||
// a provider that is not persisted in config.json, mirroring the TUI
|
||||
// provider manager's delete handling (internal/tui/provider_manager.go). If
|
||||
// name isn't resolvable at all, it returns handled=false so the caller falls
|
||||
// through to RemoveProvider's real "not found" error.
|
||||
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable) — nothing to remove there.\nUnset its environment variable to stop Zero from detecting it automatically.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"removed": "",
|
||||
"keyRemoved": false,
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to rename.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"renamed": nil,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
@@ -75,6 +75,207 @@ func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func providersUseOverrideConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{
|
||||
ActiveProvider: "work",
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, Model: "gpt-4.1"},
|
||||
{Name: "fast", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", Model: "llama-3.3-70b-versatile"},
|
||||
},
|
||||
})
|
||||
return configPath
|
||||
}
|
||||
|
||||
// The write to config.json still succeeds, but when ZERO_PROVIDER names a
|
||||
// different provider the saved selection is NOT effective, so the command must
|
||||
// warn instead of reporting a silent success (issue #721).
|
||||
func TestRunProvidersUseWarnsWhenEnvOverrides(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := providersUseOverrideConfig(t)
|
||||
deps := providerSetupDeps(configPath)
|
||||
deps.getenv = func(key string) string {
|
||||
if key == config.ActiveProviderEnv {
|
||||
return "work" // ZERO_PROVIDER=work overrides the switch to fast
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
if cfg := readFileConfig(t, configPath); cfg.ActiveProvider != "fast" {
|
||||
t.Fatalf("ActiveProvider = %q, want fast (the write still lands)", cfg.ActiveProvider)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Active provider set to fast") {
|
||||
t.Fatalf("stdout missing the success line: %q", stdout.String())
|
||||
}
|
||||
note := stderr.String()
|
||||
for _, want := range []string{config.ActiveProviderEnv, "overrides config.json", "work"} {
|
||||
if !strings.Contains(note, want) {
|
||||
t.Fatalf("override note missing %q, got %q", want, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseJSONFlagsEnvOverride(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := providerSetupDeps(providersUseOverrideConfig(t))
|
||||
deps.getenv = func(key string) string {
|
||||
if key == config.ActiveProviderEnv {
|
||||
return "work"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if code := runWithDeps([]string{"providers", "use", "fast", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
EffectiveProvider string `json:"effectiveProvider"`
|
||||
OverriddenByEnv string `json:"overriddenByEnv"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.ActiveProvider != "fast" || payload.EffectiveProvider != "work" || payload.OverriddenByEnv != config.ActiveProviderEnv {
|
||||
t.Fatalf("JSON must flag the env override, got %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// No override note when ZERO_PROVIDER is unset or already names the selection.
|
||||
func TestRunProvidersUseNoWarnWithoutEnvOverride(t *testing.T) {
|
||||
cases := map[string]func(string) string{
|
||||
"env unset": func(string) string { return "" },
|
||||
"env matches fast": func(key string) string {
|
||||
if key == config.ActiveProviderEnv {
|
||||
return "fast"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}
|
||||
for name, getenv := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := providerSetupDeps(providersUseOverrideConfig(t))
|
||||
deps.getenv = getenv
|
||||
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("exit = %d: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected no override note, got %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte("{"), 0o600); err != nil {
|
||||
t.Fatalf("write malformed config: %v", err)
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("exit code = %d, want %d", exitCode, exitCrash)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid config JSON") {
|
||||
t.Fatalf("stderr = %q, want malformed-config error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Removed string `json:"removed"`
|
||||
KeyRemoved bool `json:"keyRemoved"`
|
||||
Persisted bool `json:"persisted"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Removed != "" || payload.KeyRemoved || payload.Persisted || payload.ConfigPath != configPath {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no saved profile to rename") {
|
||||
t.Fatalf("stdout = %q, want unpersisted explanation", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Renamed *struct{} `json:"renamed"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Renamed != nil || payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1001,3 +1001,18 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
|
||||
disabled := false
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
|
||||
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
|
||||
}
|
||||
// Omitted (nil) and explicit true keep the default enforcing mode.
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("omitted enabled must not disable the sandbox")
|
||||
}
|
||||
enabled := true
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("enabled:true must not disable the sandbox")
|
||||
}
|
||||
}
|
||||
@@ -216,10 +216,10 @@ func parseNonEmptySessionsFlag(flag string, value string) (string, error) {
|
||||
func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
|
||||
kind := sessions.SessionKind(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch kind {
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSide, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
|
||||
return kind, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, spec-draft, or spec-impl.", value)}
|
||||
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, side, spec-draft, or spec-impl.", value)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ Commands:
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
--kind <kind> Filter list by fork, child, spec-draft, or spec-impl
|
||||
--kind <kind> Filter list by fork, child, side, spec-draft, or spec-impl
|
||||
--sequence <n> Rewind target sequence (rewind-plan, rewind)
|
||||
--event <id> Rewind target event id (rewind-plan, rewind)
|
||||
--exclude-target Drop the target event (rewind-plan, rewind)
|
||||
|
||||
@@ -233,6 +233,9 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide, Title: "BTW side"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -249,6 +252,17 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
|
||||
t.Fatalf("filtered sessions output = %q", output)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "list", "--kind", "side"}, &stdout, &stderr, appDeps{
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return store
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess || !strings.Contains(stdout.String(), "side") || strings.Contains(stdout.String(), "regular") {
|
||||
t.Fatalf("sessions list --kind side exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sessions", "tree", "draft", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
|
||||
|
||||
@@ -244,19 +244,19 @@ func parseUsageArgs(args []string) (usageOptions, bool, error) {
|
||||
func FormatReport(report usage.Report, insertions int, deletions int) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n")
|
||||
builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost"))
|
||||
fmt.Fprintf(&builder, "%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost")
|
||||
for _, bucket := range report.Buckets {
|
||||
builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n",
|
||||
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)))
|
||||
fmt.Fprintf(&builder, "%-12s %10d %14s %14s\n",
|
||||
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost))
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n",
|
||||
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)))
|
||||
fmt.Fprintf(&builder, "\n%-12s %10d %14s %14s\n",
|
||||
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost))
|
||||
|
||||
builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n",
|
||||
insertions, deletions, report.NetLOC))
|
||||
fmt.Fprintf(&builder, "\nnet LOC (estimate): +%d / -%d = %d\n",
|
||||
insertions, deletions, report.NetLOC)
|
||||
if report.NetLOCPositive {
|
||||
builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC))
|
||||
builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)))
|
||||
fmt.Fprintf(&builder, "tokens per net LOC: %.1f\n", report.TokensPerNetLOC)
|
||||
fmt.Fprintf(&builder, "est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC))
|
||||
} else {
|
||||
builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n")
|
||||
builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n")
|
||||
|
||||
+137
-16
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,11 @@ func LoadProviderCommand(command string) (FileConfig, error) {
|
||||
stdout, stderr, err := runProviderCommand(command, providerCommandTimeout)
|
||||
if err != nil {
|
||||
if errors.Is(err, errProviderCommandTimeout) {
|
||||
return FileConfig{}, fmt.Errorf("provider command timed out after 5s")
|
||||
// Report which phase ran out of time, and anything the command
|
||||
// managed to say. This used to be a bare string with the underlying
|
||||
// error and stderr both dropped, which left a user whose command was
|
||||
// slow on a cold machine with nothing to act on.
|
||||
return FileConfig{}, fmt.Errorf("provider command timed out after %s: %w%s", providerCommandTimeout, err, commandOutput(stderr))
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider command failed: %w%s", err, commandOutput(stderr))
|
||||
}
|
||||
@@ -40,34 +45,150 @@ func LoadProviderCommand(command string) (FileConfig, error) {
|
||||
|
||||
var errProviderCommandTimeout = errors.New("provider command timeout")
|
||||
|
||||
// providerCommandDrainGrace bounds how long the timeout path waits for cmd.Wait
|
||||
// to return after the process tree has been terminated. Terminate normally makes
|
||||
// that immediate; this exists so a descendant that refuses to die cannot extend
|
||||
// a call whose deadline has already expired.
|
||||
const providerCommandDrainGrace = 2 * time.Second
|
||||
|
||||
// syncBuffer serialises the command's I/O pump against reads from this package.
|
||||
// On the timeout path runProviderCommand can return while cmd.Wait is still
|
||||
// draining, so the buffers outlive the call and a plain bytes.Buffer would be
|
||||
// read and written concurrently.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
// snapshot copies out what has been written so far. A copy rather than the
|
||||
// buffer's own slice, which the pump may still append to.
|
||||
func (b *syncBuffer) snapshot() []byte {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]byte(nil), b.buf.Bytes()...)
|
||||
}
|
||||
|
||||
// awaitWithGrace waits for done and gives up after grace, reporting whether the
|
||||
// wait completed rather than expired.
|
||||
//
|
||||
// Split out from the timeout path so the bound can be asserted on its own. In
|
||||
// place it is effectively untestable: Terminate kills the tree, so Wait returns
|
||||
// promptly and an unbounded receive would behave identically in every test that
|
||||
// is not pathological, which is exactly how the old unbounded receive survived.
|
||||
func awaitWithGrace(done <-chan error, grace time.Duration) bool {
|
||||
timer := time.NewTimer(grace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// runProviderCommand runs a provider command under a real deadline.
|
||||
//
|
||||
// "Real" is the point: timeout used to bound only the wait, so the call could
|
||||
// take arbitrarily longer than it. Two phases sat outside it. Starting the
|
||||
// process ran before the clock began, and on Windows that is CREATE_SUSPENDED,
|
||||
// job-object creation and assignment, and a system-wide thread snapshot, none of
|
||||
// which is fast on a cold or contended machine. Draining after termination was
|
||||
// unbounded as well. Both are inside the budget now, so the deadline is an upper
|
||||
// bound rather than a floor.
|
||||
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
|
||||
cmd := shellCommand(command)
|
||||
configureCommandProcess(cmd)
|
||||
// Bound the time Wait spends draining I/O pipes after the process exits,
|
||||
// in case an orphaned descendant still holds the write ends open.
|
||||
cmd.WaitDelay = time.Second
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
stdout := &syncBuffer{}
|
||||
stderr := &syncBuffer{}
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, err
|
||||
// Armed before the process is started, so process creation is inside the
|
||||
// budget rather than ahead of it.
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
|
||||
type startResult struct {
|
||||
proc *commandProcess
|
||||
err error
|
||||
}
|
||||
// Buffered, so the goroutine can finish and exit even when nobody is left
|
||||
// waiting on it because the deadline already fired.
|
||||
started := make(chan startResult, 1)
|
||||
go func() {
|
||||
proc, err := startCommandProcess(cmd)
|
||||
started <- startResult{proc: proc, err: err}
|
||||
}()
|
||||
|
||||
var proc *commandProcess
|
||||
select {
|
||||
case result := <-started:
|
||||
if result.err != nil {
|
||||
return stdout.snapshot(), stderr.snapshot(), result.err
|
||||
}
|
||||
proc = result.proc
|
||||
case <-deadline.C:
|
||||
// The start is still in flight and will eventually hand back whatever it
|
||||
// created, so cleanup is handed off rather than abandoned. Wait reaps the
|
||||
// process; without it a started-but-orphaned command becomes a zombie.
|
||||
go func() {
|
||||
result := <-started
|
||||
if result.err != nil {
|
||||
return
|
||||
}
|
||||
result.proc.Terminate()
|
||||
_ = cmd.Wait()
|
||||
result.proc.Close()
|
||||
}()
|
||||
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: starting the command exceeded %s", errProviderCommandTimeout, timeout)
|
||||
}
|
||||
defer proc.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
case <-timer.C:
|
||||
terminateCommandProcess(cmd)
|
||||
<-done
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
if err != nil {
|
||||
// The shell exited (whether via ErrWaitDelay because a
|
||||
// background descendant kept the inherited stdout/stderr pipes
|
||||
// open, or via a nonzero exit status) but a leftover descendant
|
||||
// may still be running. Terminate is a no-op against an
|
||||
// already-dead tree, so always call it rather than gating on
|
||||
// the specific error to avoid leaking that descendant.
|
||||
proc.Terminate()
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
// Wrap rather than replace. A descendant holding the inherited pipes
|
||||
// open and the command never finishing at all are different failures
|
||||
// that both collapsed into the same bare sentinel, which left callers
|
||||
// and tests unable to tell which one happened. Callers that only ask
|
||||
// errors.Is(err, errProviderCommandTimeout) are unaffected, so the
|
||||
// message LoadProviderCommand reports is unchanged.
|
||||
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: %w", errProviderCommandTimeout, err)
|
||||
}
|
||||
return stdout.snapshot(), stderr.snapshot(), err
|
||||
case <-deadline.C:
|
||||
proc.Terminate()
|
||||
// Bounded, unlike the bare receive this replaces. Terminate kills the
|
||||
// tree so Wait almost always returns at once, but "almost always" was
|
||||
// doing real work here: a tree that resists termination used to hold the
|
||||
// call open indefinitely, long past the deadline that had just expired.
|
||||
// Returning early leaves cmd.Wait running, which is why the buffers are
|
||||
// synchronised.
|
||||
awaitWithGrace(done, providerCommandDrainGrace)
|
||||
return stdout.snapshot(), stderr.snapshot(), fmt.Errorf("%w: the command did not finish within %s", errProviderCommandTimeout, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -67,7 +70,8 @@ func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10})
|
||||
pidFile := filepath.Join(t.TempDir(), "sleep.pid")
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10, PidFile: pidFile})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
@@ -85,6 +89,175 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
if elapsed > maxElapsed {
|
||||
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
|
||||
}
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// providerCommandTestBudget bounds the two background-child tests below, and is
|
||||
// deliberately NOT providerCommandTimeout. Those tests assert which error each
|
||||
// cmd.Wait path yields and that the leftover descendant is killed; neither is a
|
||||
// claim about how fast Windows can start a process. Driving them through
|
||||
// LoadProviderCommand billed the fixture's own startup to the production 5s
|
||||
// budget, so on a loaded runner the outer timer won the select in
|
||||
// runProviderCommand: the nonzero-exit test saw a timeout instead of the exit
|
||||
// status it asserts, and the timer's Terminate killed the background child
|
||||
// before it recorded its PID, putting the PID file permanently out of reach no
|
||||
// matter how long the assertion polled for it.
|
||||
//
|
||||
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
|
||||
// it, since both tests return about one WaitDelay after the fixture exits.
|
||||
const providerCommandTestBudget = 60 * time.Second
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
// returned yet trips the budget first. That is what lets both tests drop their
|
||||
// old elapsed-versus-lifetime bounds. "Still alive when the call returned" is
|
||||
// now true by construction, so a child found dead afterwards can only have been
|
||||
// killed by proc.Terminate.
|
||||
const backgroundChildLifetime = 120 * time.Second
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
|
||||
// exits immediately but leaves a detached child holding the inherited
|
||||
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
|
||||
// unblocks once WaitDelay elapses, and that path must still terminate the
|
||||
// leftover child instead of just returning and leaking it.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg.pid")
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
if err == nil {
|
||||
t.Fatal("runProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
}
|
||||
// Name the exact path. The outer-timer path reports the bare sentinel, so
|
||||
// matching only that, or the "timed out after 5s" text this test used to
|
||||
// match, would also be satisfied by a slow runner taking precisely the path
|
||||
// this test exists to rule out.
|
||||
if !errors.Is(err, errProviderCommandTimeout) || !errors.Is(err, exec.ErrWaitDelay) {
|
||||
t.Fatalf("error = %v, want errProviderCommandTimeout wrapping exec.ErrWaitDelay", err)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChildOnFailure covers the same
|
||||
// leaked-descendant scenario as TestLoadProviderCommandTerminatesBackgroundChild,
|
||||
// but with a shell that exits nonzero. Go's exec.Cmd.Wait only returns
|
||||
// exec.ErrWaitDelay when the command itself exited successfully; a nonzero
|
||||
// exit yields the bare *ExitError instead, so the leftover child must still
|
||||
// be terminated on that path.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "boom",
|
||||
ExitCode: 7,
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
// The shell's own exit status has to survive. A timeout here means the
|
||||
// budget swallowed it, which is exactly what this test kept reporting on CI.
|
||||
if errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the shell's exit status rather than a timeout", err)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error = %v, want *exec.ExitError from the nonzero exit", err)
|
||||
}
|
||||
if exitErr.ExitCode() != 7 {
|
||||
t.Fatalf("exit code = %d, want 7", exitErr.ExitCode())
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func TestAssertProcessTerminatedWaitsForDelayedPIDFile(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "delayed.pid")
|
||||
firstMissing := make(chan struct{})
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
<-firstMissing
|
||||
tempFile := pidFile + ".tmp"
|
||||
if err := os.WriteFile(tempFile, []byte("2147483647\n"), 0o600); err != nil {
|
||||
writeDone <- err
|
||||
return
|
||||
}
|
||||
writeDone <- os.Rename(tempFile, pidFile)
|
||||
}()
|
||||
|
||||
missingObserved := false
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, func() {
|
||||
missingObserved = true
|
||||
close(firstMissing)
|
||||
})
|
||||
if !missingObserved {
|
||||
t.Fatal("PID file existed before the missing-file polling path was exercised")
|
||||
}
|
||||
if err := <-writeDone; err != nil {
|
||||
t.Fatalf("write delayed pid file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
// The basic timeout fixture is intentionally unsynchronized. On a loaded
|
||||
// Windows runner, the cold PowerShell child can be terminated before it
|
||||
// records its PID; the synchronized background-child tests below retain
|
||||
// the strict PID and liveness checks.
|
||||
if _, err := os.Stat(pidFile); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func assertProcessTerminated(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, nil)
|
||||
}
|
||||
|
||||
func assertProcessTerminatedAfterMissing(t *testing.T, pidFile string, onMissing func()) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var data []byte
|
||||
var err error
|
||||
missingObserved := false
|
||||
for time.Now().Before(deadline) {
|
||||
data, err = os.ReadFile(pidFile)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
}
|
||||
if !missingObserved {
|
||||
missingObserved = true
|
||||
if onMissing != nil {
|
||||
onMissing()
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read sleeper pid file before timeout: %v", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sleeper pid %q: %v", data, err)
|
||||
}
|
||||
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("sleeper process %d still alive after timeout", pid)
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandInvalidJSON(t *testing.T) {
|
||||
@@ -118,6 +291,14 @@ type commandScript struct {
|
||||
Stderr string
|
||||
ExitCode int
|
||||
SleepSeconds int
|
||||
PidFile string
|
||||
|
||||
// BackgroundSleepSeconds, if set, spawns a detached child that keeps the
|
||||
// inherited stdout/stderr handles open well after the script itself
|
||||
// exits, simulating a `sleep 600 & exit` style command. BackgroundPidFile
|
||||
// records the detached child's PID.
|
||||
BackgroundSleepSeconds int
|
||||
BackgroundPidFile string
|
||||
}
|
||||
|
||||
func writeCommand(t *testing.T, script commandScript) string {
|
||||
@@ -128,7 +309,11 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
path := filepath.Join(dir, "provider.cmd")
|
||||
lines := []string{"@echo off"}
|
||||
if script.SleepSeconds > 0 {
|
||||
lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"")
|
||||
sleep := "Start-Sleep -Seconds " + itoa(script.SleepSeconds)
|
||||
if script.PidFile != "" {
|
||||
sleep = "Set-Content -Path '" + psSingleQuote(script.PidFile) + "' -Value $PID -Encoding Ascii; " + sleep
|
||||
}
|
||||
lines = append(lines, "powershell -NoProfile -Command \""+sleep+"\"")
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
lines = append(lines, "echo "+script.Stdout)
|
||||
@@ -136,6 +321,40 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "echo "+script.Stderr+" 1>&2")
|
||||
}
|
||||
if script.BackgroundSleepSeconds > 0 {
|
||||
readyFile := script.BackgroundPidFile + ".ready"
|
||||
bgSleep := "Set-Content -LiteralPath '" + psSingleQuote(script.BackgroundPidFile) + "' -Value $PID -Encoding Ascii; " +
|
||||
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
|
||||
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
|
||||
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
|
||||
// Wait for the child to record its PID before exiting, so the PID is
|
||||
// on disk while the tree is still alive: runProviderCommand kills the
|
||||
// tree one WaitDelay after the script exits, and a child that has not
|
||||
// written by then never will.
|
||||
//
|
||||
// This waits in cmd itself. Spawning a second PowerShell just to poll
|
||||
// cost another interpreter cold start, and that start is pure overhead
|
||||
// added AFTER the PID is already written. On a loaded runner it pushed
|
||||
// the script's exit past the 5s provider-command timeout, so the
|
||||
// nonzero-exit test saw a timeout instead of the exit status it
|
||||
// asserts. The wait is also bounded now: giving up and letting the PID
|
||||
// assertion fail is far more diagnosable than blocking until the
|
||||
// timeout fires and reporting an unrelated error. Each iteration is
|
||||
// about a second, since `ping -n 2` sleeps between its two echoes,
|
||||
// so the ceiling is roughly 15s: far longer than any cold PowerShell
|
||||
// start seen on a runner, and far shorter than
|
||||
// providerCommandTestBudget, so giving up surfaces a legible
|
||||
// PID-file failure rather than a timeout that hides the real cause.
|
||||
lines = append(lines,
|
||||
":zeroWaitReady",
|
||||
"if exist \""+readyFile+"\" goto zeroReady",
|
||||
"set /a ZERO_READY_TRIES+=1",
|
||||
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
)
|
||||
}
|
||||
lines = append(lines, "exit /b "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
|
||||
t.Fatalf("write command: %v", err)
|
||||
@@ -146,6 +365,9 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
path := filepath.Join(dir, "provider.sh")
|
||||
lines := []string{"#!/bin/sh"}
|
||||
if script.SleepSeconds > 0 {
|
||||
if script.PidFile != "" {
|
||||
lines = append(lines, "echo $$ > '"+shSingleQuote(script.PidFile)+"'")
|
||||
}
|
||||
lines = append(lines, "sleep "+itoa(script.SleepSeconds))
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
@@ -154,6 +376,10 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
if script.Stderr != "" {
|
||||
lines = append(lines, "printf '%s\\n' '"+script.Stderr+"' >&2")
|
||||
}
|
||||
if script.BackgroundSleepSeconds > 0 {
|
||||
lines = append(lines, "sleep "+itoa(script.BackgroundSleepSeconds)+" &")
|
||||
lines = append(lines, "echo $! > '"+shSingleQuote(script.BackgroundPidFile)+"'")
|
||||
}
|
||||
lines = append(lines, "exit "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o700); err != nil {
|
||||
t.Fatalf("write command: %v", err)
|
||||
@@ -161,6 +387,19 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// psSingleQuote escapes a value for interpolation inside a PowerShell
|
||||
// single-quoted string literal, where a literal quote is doubled.
|
||||
func psSingleQuote(value string) string {
|
||||
return strings.ReplaceAll(value, "'", "''")
|
||||
}
|
||||
|
||||
// shSingleQuote escapes a value for interpolation inside a POSIX shell
|
||||
// single-quoted string literal, where a literal quote must close the
|
||||
// quoted section, emit an escaped quote, then reopen it.
|
||||
func shSingleQuote(value string) string {
|
||||
return strings.ReplaceAll(value, "'", `'\''`)
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
@@ -175,3 +414,133 @@ func itoa(value int) string {
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
|
||||
// The whole point of #811: providerCommandTimeout has to be an upper bound on
|
||||
// how long the call takes, not a floor. Process creation used to run before the
|
||||
// clock started and the post-termination drain was unbounded, so a slow or
|
||||
// contended machine could block startup for far longer than the error text
|
||||
// promised.
|
||||
//
|
||||
// This one guards the end-to-end contract against gross regressions. It is NOT
|
||||
// the assertion that pins the drain bound: Terminate kills the tree, so Wait
|
||||
// returns promptly and this passes even with the drain unbounded. That property
|
||||
// is pinned by TestAwaitWithGraceGivesUp, and the start-phase bound by
|
||||
// TestRunProviderCommandCountsProcessStartAgainstTheDeadline.
|
||||
func TestRunProviderCommandBoundsTotalDuration(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bound.pid")
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 60, PidFile: pidFile})
|
||||
|
||||
const budget = 2 * time.Second
|
||||
start := time.Now()
|
||||
_, _, err := runProviderCommand(command, budget)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the timeout sentinel", err)
|
||||
}
|
||||
// Budget, plus the bounded drain, plus room for a loaded runner. A failure
|
||||
// here means some phase is outside the deadline again, which is the bug.
|
||||
ceiling := budget + providerCommandDrainGrace + 8*time.Second
|
||||
if elapsed > ceiling {
|
||||
t.Fatalf("returned after %s with a %s budget; the timeout is not bounding the call (ceiling %s)", elapsed, budget, ceiling)
|
||||
}
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// The timeout error has to carry something an operator can act on. It used to be
|
||||
// a bare string with the underlying error and the command's stderr both dropped,
|
||||
// so a user whose provider command was slow got no clue which phase ran out or
|
||||
// what the command had said.
|
||||
func TestLoadProviderCommandTimeoutReportsDiagnostics(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "provider is warming up",
|
||||
SleepSeconds: 30,
|
||||
PidFile: filepath.Join(t.TempDir(), "diag.pid"),
|
||||
})
|
||||
|
||||
_, err := LoadProviderCommand(command)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out") {
|
||||
t.Fatalf("error = %q, want a timeout message", err.Error())
|
||||
}
|
||||
// Wrapped, so callers can still classify it.
|
||||
if !errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want errors.Is against the timeout sentinel", err)
|
||||
}
|
||||
// And it must say which phase, rather than only that time ran out.
|
||||
if !strings.Contains(err.Error(), "did not finish") && !strings.Contains(err.Error(), "starting the command") {
|
||||
t.Fatalf("error = %q, want it to name the phase that exceeded the deadline", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// The output buffers are written by cmd.Wait's I/O pump, which keeps running
|
||||
// after the timeout path has returned them to the caller, so a plain
|
||||
// bytes.Buffer would be read and written concurrently. Asserted on the buffer
|
||||
// directly: driving it end to end does not reliably produce the overlap, because
|
||||
// a terminated command usually stops writing before the snapshot is taken, so
|
||||
// such a test passes with the locking removed and proves nothing.
|
||||
//
|
||||
// Only meaningful under -race, which CI runs.
|
||||
func TestSyncBufferAllowsConcurrentWriteAndSnapshot(t *testing.T) {
|
||||
buffer := &syncBuffer{}
|
||||
writing := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writing)
|
||||
for i := 0; i < 2000; i++ {
|
||||
_, _ = buffer.Write([]byte("x"))
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2000; i++ {
|
||||
_ = buffer.snapshot()
|
||||
}
|
||||
<-writing
|
||||
if len(buffer.snapshot()) != 2000 {
|
||||
t.Fatalf("buffer holds %d bytes, want 2000", len(buffer.snapshot()))
|
||||
}
|
||||
}
|
||||
|
||||
// The drain after termination must be bounded. Asserted directly on the helper,
|
||||
// because in place it is untestable without a tree that refuses to die: Wait
|
||||
// normally returns at once, which is precisely why an unbounded receive sat
|
||||
// there unnoticed. A channel that never fires is the pathological case made
|
||||
// cheap.
|
||||
func TestAwaitWithGraceGivesUp(t *testing.T) {
|
||||
never := make(chan error)
|
||||
start := time.Now()
|
||||
if awaitWithGrace(never, 50*time.Millisecond) {
|
||||
t.Fatal("reported completion for a wait that never finished")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("gave up after %s, want roughly the grace period", elapsed)
|
||||
}
|
||||
// Still reports completion when the wait does finish, or the timeout path
|
||||
// would always claim the tree hung.
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
if !awaitWithGrace(done, time.Second) {
|
||||
t.Fatal("reported expiry for a wait that had already finished")
|
||||
}
|
||||
}
|
||||
|
||||
// Process creation has to be inside the budget. It used to run before the clock
|
||||
// started, and on Windows that phase is CREATE_SUSPENDED plus job-object setup
|
||||
// plus a system-wide thread snapshot, none of it fast on a contended machine.
|
||||
//
|
||||
// A budget that is already spent when the call begins can only be exceeded
|
||||
// during the start, so the phase named in the error is what distinguishes the
|
||||
// two orderings: with the clock started afterwards, the start would always
|
||||
// complete first and the failure would come from the wait instead.
|
||||
func TestRunProviderCommandCountsProcessStartAgainstTheDeadline(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-x","model":"m"}`})
|
||||
|
||||
_, _, err := runProviderCommand(command, time.Nanosecond)
|
||||
if !errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the timeout sentinel", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "starting the command") {
|
||||
t.Fatalf("error = %q, want the start phase to be the one that exceeded the deadline; "+
|
||||
"anything else means process creation is outside the budget again", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
// "unset" and will not override.
|
||||
func ToolsOverride(deferThreshold int) ToolsConfig {
|
||||
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
|
||||
}
|
||||
|
||||
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
|
||||
// semantic provider/model rules used during resolution. It returns the parsed
|
||||
// config (zero value on parse failure) plus any structured issues. A parse
|
||||
// failure yields a single issue whose Message is the underlying JSON error's
|
||||
// text (already flattened via Error(), not chained — callers cannot recover
|
||||
// the original *json.SyntaxError / *json.UnmarshalTypeError via errors.As).
|
||||
func ValidateFile(path string) (FileConfig, []Issue) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, []Issue{{Message: fmt.Sprintf("invalid config JSON %s: %v", path, err)}}
|
||||
}
|
||||
|
||||
issues := validateSemantics(cfg)
|
||||
issues = append(issues, unknownFieldIssues(data)...)
|
||||
return cfg, issues
|
||||
}
|
||||
|
||||
// SetProviderDescription sets a provider's description VERBATIM — including to
|
||||
// empty. The generic UpsertProvider merge treats empty fields as "leave
|
||||
// unchanged", so clearing a description needs this dedicated setter.
|
||||
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
|
||||
cfg.Providers[index].Description = strings.TrimSpace(description)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
|
||||
// server the user explicitly enabled. The only layer allowed to
|
||||
// override an explicit higher-scope decision is the CLI override scope
|
||||
// (canReenable=true).
|
||||
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
|
||||
if canReenable || !baseDisabledSet || baseDisabled == next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
type commandProcess struct {
|
||||
groupID int
|
||||
anchor *exec.Cmd
|
||||
anchorInput io.WriteCloser
|
||||
}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
// startCommandProcess retains a live process-group leader until cleanup. The
|
||||
// provider shell may be reaped before Wait finishes draining descendant-held
|
||||
// pipes, so its PID cannot safely identify the group after Wait returns.
|
||||
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
|
||||
anchor := exec.Command("sh", "-c", "read _")
|
||||
anchor.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
anchorInput, err := anchor.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = cmd.Process.Kill()
|
||||
if err := anchor.Start(); err != nil {
|
||||
_ = anchorInput.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proc := &commandProcess{groupID: anchor.Process.Pid, anchor: anchor, anchorInput: anchorInput}
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
cmd.SysProcAttr.Pgid = proc.groupID
|
||||
if err := cmd.Start(); err != nil {
|
||||
proc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
func (p *commandProcess) Terminate() {
|
||||
if p.groupID != 0 {
|
||||
_ = syscall.Kill(-p.groupID, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *commandProcess) Close() {
|
||||
if p.anchorInput != nil {
|
||||
_ = p.anchorInput.Close()
|
||||
p.anchorInput = nil
|
||||
}
|
||||
if p.anchor != nil {
|
||||
_ = p.anchor.Wait()
|
||||
p.anchor = nil
|
||||
}
|
||||
p.groupID = 0
|
||||
}
|
||||
Loaded 100 of 431 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user