Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
856919bcaa | ||
|
|
883e5bec24 | ||
|
|
530563a0bb | ||
|
|
c56a4c4e6e | ||
|
|
300cf6275f | ||
|
|
106c0b77f9 | ||
|
|
846d30f57f | ||
|
|
bb96206c27 | ||
|
|
e0fb65f003 | ||
|
|
a3d4edef8f | ||
|
|
a195e8848c | ||
|
|
a0979fb756 | ||
|
|
4dc603f622 | ||
|
|
4d2e9f0d08 | ||
|
|
1335ca6671 | ||
|
|
ddc45f2c81 | ||
|
|
eebb384676 | ||
|
|
39d6560137 | ||
|
|
bb3346a828 | ||
|
|
ac5fe0d218 | ||
|
|
0c6f179a84 | ||
|
|
feb992757c | ||
|
|
23c27e0778 | ||
|
|
2c418fc8ff | ||
|
|
571af701bd | ||
|
|
2eb9d94fac | ||
|
|
eefa978f61 | ||
|
|
bea2230633 | ||
|
|
df67edec13 | ||
|
|
42eb6ab5fb | ||
|
|
8a68660161 | ||
|
|
ab018b3098 | ||
|
|
1697b7a5ce | ||
|
|
6b5852dc18 | ||
|
|
aa52040498 | ||
|
|
547330197b | ||
|
|
7f11491078 | ||
|
|
0324c1d410 | ||
|
|
b9ef1d271a | ||
|
|
9520f2bad7 | ||
|
|
e2d18c50f0 | ||
|
|
e20b762426 | ||
|
|
7f8e5f08bb | ||
|
|
99721c762f | ||
|
|
dcf229fecd | ||
|
|
3fae0de2ea | ||
|
|
4ba112a0eb | ||
|
|
b76c764b77 | ||
|
|
d73ec81e2d | ||
|
|
2479d946c1 | ||
|
|
d76f7f6931 | ||
|
|
37d69c96ce | ||
|
|
176f5a242b | ||
|
|
9e609c5242 | ||
|
|
2f531178f6 | ||
|
|
3b43c43eca | ||
|
|
faec8ee201 | ||
|
|
c1937dfac7 | ||
|
|
90014210d2 | ||
|
|
6937a309cf | ||
|
|
da244bcbfc | ||
|
|
e34c8d2252 | ||
|
|
91d921f4c5 | ||
|
|
6ae8396f02 | ||
|
|
2837f5efb3 | ||
|
|
08fbea7a32 | ||
|
|
82ae3a8809 | ||
|
|
488def68dc | ||
|
|
b04fe96e9e | ||
|
|
79fa1422ef | ||
|
|
1aacf38b72 | ||
|
|
3063593129 | ||
|
|
d6b25fc303 | ||
|
|
7c50a9fb98 | ||
|
|
275e248669 | ||
|
|
0207f2b269 | ||
|
|
8b3d1b1597 | ||
|
|
a3852bac59 | ||
|
|
a3e7446681 | ||
|
|
0021475d4c | ||
|
|
deba3adfca | ||
|
|
501f32160f | ||
|
|
1e4db7c664 | ||
|
|
f9092668a8 | ||
|
|
7f5e5af7bb | ||
|
|
f30f550e60 | ||
|
|
aadb4a27e9 | ||
|
|
a6ec0435e3 | ||
|
|
1b5db17656 | ||
|
|
eeea3308fe | ||
|
|
27b319ca88 | ||
|
|
1fde941803 | ||
|
|
7acac7acee | ||
|
|
1649008506 | ||
|
|
6fe0d1edcd | ||
|
|
e59bf78cab | ||
|
|
6645f9744b | ||
|
|
2749447cdc |
No files matched your search
@@ -29,6 +29,9 @@ reviews:
|
||||
- path: "tests/**/*.ts"
|
||||
instructions: |
|
||||
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
|
||||
- path: "**/*.go"
|
||||
instructions: |
|
||||
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
|
||||
tools:
|
||||
github-checks:
|
||||
timeout_ms: 300000
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and setup help
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
|
||||
url: https://github.com/Twigpine/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
|
||||
url: https://github.com/Twigpine/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
|
||||
url: https://github.com/Twigpine/zero/security/advisories/new
|
||||
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
|
||||
@@ -5,7 +5,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/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
|
||||
|
||||
@@ -6,10 +6,109 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Code Quality & Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Check formatting
|
||||
run: make fmt-check
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: make deadcode
|
||||
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
|
||||
test:
|
||||
name: Unit Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test with coverage
|
||||
run: |
|
||||
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: Publish coverage summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f coverage.out ]; then
|
||||
go tool cover -func=coverage.out | awk '
|
||||
BEGIN {
|
||||
total = "unknown"
|
||||
rows = ""
|
||||
}
|
||||
$1 == "total:" {
|
||||
total = $3
|
||||
next
|
||||
}
|
||||
{
|
||||
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
|
||||
}
|
||||
END {
|
||||
print "### 📊 Test Coverage Summary: **" total "**"
|
||||
print "<details><summary>Coverage per function</summary>\n"
|
||||
print "| File | Function | Coverage |"
|
||||
print "| :--- | :--- | :--- |"
|
||||
printf "%s", rows
|
||||
print "</details>"
|
||||
}' >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-coverage-report
|
||||
path: coverage.out
|
||||
if-no-files-found: warn
|
||||
|
||||
smoke:
|
||||
name: Smoke (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -18,64 +117,22 @@ jobs:
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:" >&2
|
||||
echo "$unformatted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
- name: Test (Fast Non-Race)
|
||||
run: go test ./...
|
||||
|
||||
- name: Build binary
|
||||
@@ -84,9 +141,15 @@ jobs:
|
||||
- name: Smoke binary
|
||||
run: go run ./cmd/zero-release smoke
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
race:
|
||||
name: Race Detector
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# A race the detector newly reports can present as a deadlock rather than a
|
||||
# failure, and the default job timeout is six hours. This job is expected to
|
||||
# be the longest in the workflow, so the ceiling is generous but finite.
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -100,55 +163,70 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Invoke the Makefile target rather than repeating its flags. `make test`
|
||||
# is already `go test ./... -race -count=1`, so running it here is what
|
||||
# stops the project's declared test command and what CI actually runs
|
||||
# from drifting apart again. The race detector needs cgo, which the
|
||||
# hosted ubuntu runner supplies without an extra setup step.
|
||||
- name: Test with the race detector
|
||||
run: make test
|
||||
|
||||
performance:
|
||||
name: Performance Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binary
|
||||
run: go run ./cmd/zero-release build
|
||||
|
||||
- name: Performance smoke
|
||||
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
run: |
|
||||
mkdir -p dist/perf
|
||||
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
|
||||
|
||||
- name: Upload performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: zero-performance-smoke
|
||||
path: dist/perf/perf-bench.json
|
||||
if-no-files-found: warn
|
||||
|
||||
security:
|
||||
name: Security & code health
|
||||
name: Security & Code Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# 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
|
||||
- name: govulncheck (linux)
|
||||
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: make deadcode
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
# package but never actually called, plus staticcheck-style correctness
|
||||
# and readability issues. Scoped to a few linters rather than the full
|
||||
# default battery, and non-blocking, while the existing findings across
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: make lint-static
|
||||
- name: govulncheck (windows)
|
||||
run: make vulncheck-windows
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
|
||||
@@ -34,12 +34,15 @@ jobs:
|
||||
- name: Checkout
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout requested ref
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
@@ -97,19 +100,21 @@ jobs:
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
|
||||
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
|
||||
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
|
||||
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
|
||||
# optionalDependencies alias those platform versions. Platform versions MUST
|
||||
# publish first — the wrapper pins them exactly, so the reverse order would
|
||||
# open a window where installs resolve aliases that 404. See
|
||||
# docs/NPM_PACKAGING.md for the full contract.
|
||||
# Publishes the npm package named in package.json (@twigpine/zero): first
|
||||
# the five platform payloads (versions of the SAME package at suffixed
|
||||
# versions like X.Y.Z-linux-x64, carrying the native binary + vendored
|
||||
# helpers), then the wrapper at X.Y.Z whose optionalDependencies alias those
|
||||
# platform versions. Platform versions MUST publish first — the wrapper pins
|
||||
# them exactly, so the reverse order would open a window where installs
|
||||
# resolve aliases that 404. See docs/NPM_PACKAGING.md for the full contract.
|
||||
#
|
||||
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
|
||||
# npmjs.com settings must list this repo + workflow file as a trusted
|
||||
# publisher, and provenance is then generated automatically. Note npm only
|
||||
# allows configuring a trusted publisher on an EXISTING package, so the very
|
||||
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
|
||||
# npmjs.com settings must list this repo + workflow file + the npm-publish
|
||||
# environment as a trusted publisher, and provenance is then generated
|
||||
# automatically. npm checks provenance against package.json `repository`,
|
||||
# so that must name this repository. Note npm only allows configuring a
|
||||
# trusted publisher on an EXISTING package, so a new package name starts
|
||||
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
|
||||
# Because the platform payloads are versions of the same package, they reuse
|
||||
# that single trusted-publisher configuration.
|
||||
publish-npm:
|
||||
@@ -122,13 +127,15 @@ jobs:
|
||||
# maintainer approves it in the Actions UI before anything reaches npm.
|
||||
environment:
|
||||
name: npm-publish
|
||||
url: https://www.npmjs.com/package/@gitlawb/zero
|
||||
url: https://www.npmjs.com/package/@twigpine/zero
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token exchange for npm trusted publishing + provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# No registry-url here on purpose: setup-node's registry-url writes an
|
||||
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
|
||||
@@ -196,47 +203,58 @@ jobs:
|
||||
- name: Assemble npm packages
|
||||
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
|
||||
|
||||
# Platform versions are semver PRERELEASES of the wrapper version
|
||||
# (X.Y.Z-linux-x64), so they must never publish under the default
|
||||
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
|
||||
# deliver a platform payload as the CLI. Publish them under a dedicated
|
||||
# tag and assert `latest` survived before the wrapper goes out.
|
||||
- name: Publish platform packages to npm
|
||||
# Platform payloads first under the `platform` dist-tag, then the wrapper,
|
||||
# with the `latest` dist-tag checked before and after (platform versions
|
||||
# are semver PRERELEASES of the wrapper version and must never become
|
||||
# `latest`). Versions already on the registry are skipped, so "Re-run
|
||||
# failed jobs" is safe after a partial publish.
|
||||
- name: Publish npm packages
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
for dir in dist/npm/platforms/*/; do
|
||||
echo "publishing ${dir}"
|
||||
(cd "$dir" && npm publish --access public --tag platform)
|
||||
done
|
||||
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
|
||||
# bootstrap publish): under set -e a bare failure here would abort
|
||||
# AFTER the platform payloads went out, leaving no wrapper and a
|
||||
# re-run that fails on republishing existing versions. An empty
|
||||
# value falls through the guard below.
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
case "$latest" in
|
||||
"${version}-"*)
|
||||
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
|
||||
run: scripts/npm/publish-packages.sh dist/npm
|
||||
|
||||
- name: Publish wrapper to npm
|
||||
# One final release under the pre-rename name @gitlawb/zero. Zero 0.9.0's
|
||||
# `zero upgrade` runs `npm install -g @gitlawb/zero@latest`, and people
|
||||
# reinstall the old name from shell history and old tutorials, so this
|
||||
# release is published once more under it: the same verified artifacts,
|
||||
# assembled as @gitlawb/zero, which the binary and wrapper recognise and
|
||||
# answer with the switch steps (docs/NPM_PACKAGING.md). Runs after the
|
||||
# @twigpine/zero publish, so the package it points at already exists.
|
||||
#
|
||||
# The gate keeps this to a single release: it is owed only while the old
|
||||
# name's latest is still the last pre-rename version, or is this version
|
||||
# (a re-run after it went out). After this release, `npm deprecate` the
|
||||
# old package and delete these two steps.
|
||||
- name: Check whether the final @gitlawb/zero release is owed
|
||||
id: legacy-name
|
||||
shell: bash
|
||||
env:
|
||||
LAST_PRE_RENAME_VERSION: 0.9.0
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
(cd dist/npm/wrapper && npm publish --access public)
|
||||
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
|
||||
# a publish by a few seconds).
|
||||
for attempt in 1 2 3 4 5; do
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
if [ "$latest" = "$version" ]; then
|
||||
echo "dist-tag latest is ${version}."
|
||||
exit 0
|
||||
latest=""
|
||||
for _ in 1 2 3; do
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$latest" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
|
||||
exit 1
|
||||
if [ -z "$latest" ]; then
|
||||
echo "::error::could not read the latest dist-tag of @gitlawb/zero from the registry" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$latest" = "$LAST_PRE_RENAME_VERSION" ] || [ "$latest" = "$version" ]; then
|
||||
owed=true
|
||||
else
|
||||
owed=false
|
||||
fi
|
||||
echo "@gitlawb/zero latest is ${latest}; final release under the old name owed: ${owed}"
|
||||
echo "owed=${owed}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish the final @gitlawb/zero release
|
||||
if: ${{ steps.legacy-name.outputs.owed == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
node scripts/npm/build-platform-packages.mjs \
|
||||
--artifacts-dir dist/artifacts --out-dir dist/npm-legacy --package-name @gitlawb/zero
|
||||
scripts/npm/publish-packages.sh dist/npm-legacy
|
||||
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
@@ -12,8 +14,13 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate action.yml
|
||||
@@ -24,6 +31,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run action-summary test suite
|
||||
run: node --test scripts/action-summary.test.mjs
|
||||
|
||||
- name: Parse action.yml and assert documented inputs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.8.0"}
|
||||
{".":"0.10.0"}
|
||||
@@ -81,6 +81,13 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
|
||||
paths on Windows). Prefer one cross-platform function with small `GOOS`
|
||||
checks over duplicated helpers that drift.
|
||||
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
|
||||
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
|
||||
`windows-x64`) and the additional supported Android/Termux source-build and
|
||||
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
|
||||
`plan9`, are unsupported and are not a review criterion. Do not block a PR
|
||||
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
|
||||
`plan9` as a fixture for "unsupported".
|
||||
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
|
||||
success and error paths (including stderr). Fail closed on ownership, lease,
|
||||
and permission checks. Do not rely on pre-open path resolution
|
||||
|
||||
@@ -6,6 +6,105 @@ 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.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
|
||||
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
|
||||
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
|
||||
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
|
||||
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
|
||||
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
|
||||
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
|
||||
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
|
||||
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
|
||||
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
|
||||
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
|
||||
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
|
||||
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
|
||||
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
|
||||
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
|
||||
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
|
||||
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
|
||||
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
|
||||
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
|
||||
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
|
||||
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
|
||||
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
|
||||
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
|
||||
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
|
||||
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
|
||||
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
|
||||
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
|
||||
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
|
||||
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
|
||||
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
|
||||
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
|
||||
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
|
||||
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
|
||||
|
||||
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
|
||||
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
|
||||
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
|
||||
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
|
||||
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
|
||||
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
|
||||
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
|
||||
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
|
||||
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
|
||||
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
|
||||
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
|
||||
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
|
||||
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
|
||||
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
|
||||
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
|
||||
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
|
||||
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
|
||||
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
|
||||
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
|
||||
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
|
||||
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
|
||||
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
|
||||
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
|
||||
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
|
||||
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
|
||||
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
|
||||
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
|
||||
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
|
||||
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
|
||||
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
|
||||
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
|
||||
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
|
||||
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
|
||||
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
|
||||
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
|
||||
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
|
||||
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
|
||||
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
|
||||
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
|
||||
|
||||
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
|
||||
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
|
||||
issues.
|
||||
|
||||
Questions and setup help belong in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
|
||||
[GitHub Discussions](https://github.com/Twigpine/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;
|
||||
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
|
||||
an idea that gains traction there is the path to an approved issue and,
|
||||
eventually, an accepted PR.
|
||||
|
||||
@@ -41,7 +41,7 @@ There are three primary ways to contribute:
|
||||
|
||||
1. Open a bug report with clear reproduction steps.
|
||||
2. Propose a feature in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
[Discussions Ideas](https://github.com/Twigpine/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.
|
||||
@@ -126,6 +126,31 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
|
||||
specific bug, addressing a security issue, improving compatibility, or supporting
|
||||
an approved feature.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
Zero publishes five native prebuilt artifacts from
|
||||
`.github/workflows/release-artifacts.yml`:
|
||||
|
||||
- `linux-x64` (`linux/amd64`)
|
||||
- `linux-arm64` (`linux/arm64`)
|
||||
- `macos-arm64` (`darwin/arm64`)
|
||||
- `macos-x64` (`darwin/amd64`)
|
||||
- `windows-x64` (`windows/amd64`)
|
||||
|
||||
The shell installer and npm wrapper resolve the applicable artifacts from that
|
||||
list. The PowerShell installer supports the published `windows-x64` artifact;
|
||||
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
|
||||
build under emulation or build from source.
|
||||
|
||||
Android/Termux is also supported even though it has no distinct release
|
||||
artifact. The npm wrapper maps Android to the Linux artifact, and
|
||||
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
|
||||
devices whose seccomp policy blocks the Linux build's syscall path.
|
||||
|
||||
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
|
||||
review criterion. Do not block a pull request for failing to compile on an
|
||||
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
|
||||
|
||||
## What We Prioritize
|
||||
|
||||
The core team will prioritize:
|
||||
@@ -168,7 +193,7 @@ Clear bug reports are the most useful way to help the project during this phase.
|
||||
## Submitting a Feature Request
|
||||
|
||||
Feature requests start as a thread in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
[Discussions Ideas](https://github.com/Twigpine/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
|
||||
@@ -197,7 +222,7 @@ contribution phase.
|
||||
|
||||
General questions, setup help, and "is this a bug or my config" uncertainty all
|
||||
go to
|
||||
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
|
||||
[Discussions Q&A](https://github.com/Twigpine/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.
|
||||
@@ -7,7 +7,7 @@ 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
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -44,7 +44,8 @@ lint: fmt-check vet
|
||||
# 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 deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
@@ -55,6 +56,11 @@ deadcode:
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
vulncheck-windows:
|
||||
mkdir -p "$(CURDIR)/.cache/gobin"
|
||||
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
||||
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -77,4 +83,4 @@ baseline: build
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
<p align="center"><strong>A terminal coding agent you own.</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -23,10 +29,74 @@ zero exec "fix the failing test in ./pkg"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## Partners
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://twigpine.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
|
||||
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://mimo.mi.com">
|
||||
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://novita.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
|
||||
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://concentrate.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
|
||||
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://exa.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
|
||||
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
|
||||
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
|
||||
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Why Zero
|
||||
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
|
||||
OpenAI-/Anthropic-compatible endpoint.
|
||||
- **Stay in control.** File writes, shell commands, network access, and
|
||||
out-of-workspace writes go through Zero's permission and sandbox policy.
|
||||
@@ -46,7 +116,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
zero
|
||||
```
|
||||
|
||||
@@ -60,26 +130,38 @@ matching GitHub Release whenever it is missing. Windows on ARM runs the x64
|
||||
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
|
||||
how the package is put together.
|
||||
|
||||
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
|
||||
0.10.0 and the old name gets no further releases. Both packages provide the
|
||||
`zero` command, so quit any running `zero`, then remove the old package first:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
```
|
||||
|
||||
Your config, sessions, and logins are kept; they live outside the package. For
|
||||
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
|
||||
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
@@ -133,6 +215,7 @@ export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -155,6 +238,12 @@ To configure Fireworks AI directly, run:
|
||||
zero providers setup fireworks --set-active
|
||||
```
|
||||
|
||||
To configure GMI Cloud directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
@@ -182,8 +271,12 @@ zero providers add custom-openai-compatible \
|
||||
--set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
|
||||
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
|
||||
load a model and enable its local OpenAI-compatible API (default
|
||||
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
|
||||
loaded model ID in the add command. If no usable ID is discovered, load a model
|
||||
and retry. Model IDs requiring shell-specific quoting use interactive setup.
|
||||
|
||||
## Daily Use
|
||||
|
||||
@@ -273,6 +366,19 @@ zero sandbox grants list
|
||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||
and MCP support for additional tools.
|
||||
|
||||
`web_fetch` refuses loopback, private and other special-use addresses: it checks
|
||||
the URL before asking permission, resolves the host, and dials the address it
|
||||
validated so a name cannot resolve to something else in between.
|
||||
|
||||
If `HTTP_PROXY`/`HTTPS_PROXY` is set, `web_fetch` and the provider connectivity
|
||||
probe use it, and that last step changes: the proxy is dialed and the target
|
||||
hostname is sent to it, so the proxy decides which address the request actually
|
||||
reaches. The URL is still checked and resolved locally first, but a proxy that
|
||||
answers differently can reach a private service. A forward proxy already sees
|
||||
and can rewrite every request through it, so this is the trust you accept by
|
||||
configuring one. Leave the variables unset for the checks to be enforced end to
|
||||
end.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
@@ -296,7 +402,7 @@ zero context context-budget report
|
||||
zero repo-map deterministic repository map
|
||||
zero repo-info local repository summary
|
||||
zero search | find search local session history
|
||||
zero sessions inspect, resume, fork, and rewind sessions
|
||||
zero sessions inspect, resume, fork, rewind, and prune sessions
|
||||
zero spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
@@ -314,6 +420,13 @@ zero update --check check for newer releases
|
||||
zero upgrade download, verify, and install the latest release
|
||||
```
|
||||
|
||||
Cron keeps the newest 1,000 run outcomes per job in `runs.jsonl`. Each new run
|
||||
atomically replaces the history with the retained tail; existing larger histories
|
||||
are trimmed on their next run. Archive the file before that run if you need older
|
||||
outcomes. This retention does not delete session directories or reset fire counts.
|
||||
Malformed records and legacy lines of 1 MiB or more are skipped when reading or
|
||||
compacting history; new records of that size are rejected.
|
||||
|
||||
## Extending Zero
|
||||
|
||||
### Project and personal instructions
|
||||
@@ -371,9 +484,13 @@ go run ./cmd/zero-perf-bench
|
||||
|
||||
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
|
||||
session (background connection prewarm + request-prefix telemetry) for headless
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
|
||||
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
|
||||
variable unset and set.
|
||||
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
|
||||
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
|
||||
with the variable unset and set.
|
||||
|
||||
Native ChatGPT Responses sessions are enabled by default. Set
|
||||
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
|
||||
transport.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
@@ -421,19 +538,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
[GitHub Discussions](https://github.com/Twigpine/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 |
|
||||
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
|
||||
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
|
||||
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
|
||||
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
|
||||
|
||||
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
|
||||
[issues](https://github.com/Twigpine/zero/issues/new/choose); security reports
|
||||
follow [SECURITY.md](SECURITY.md), never a public thread.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -21,9 +27,73 @@ zero exec "修复 ./pkg 中失败的测试"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## 合作伙伴
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://twigpine.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
|
||||
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://mimo.mi.com">
|
||||
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://novita.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
|
||||
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://concentrate.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
|
||||
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://exa.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
|
||||
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
|
||||
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
|
||||
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
@@ -35,32 +105,41 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
zero
|
||||
```
|
||||
|
||||
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
|
||||
|
||||
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
```
|
||||
|
||||
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.6+。
|
||||
源码构建需要 Go 1.26.9+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
@@ -106,6 +185,7 @@ export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
@@ -114,7 +194,17 @@ export LONGCAT_API_KEY=...
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||
要直接配置 GMI Cloud,运行:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
|
||||
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
|
||||
|
||||
## 日常使用
|
||||
|
||||
@@ -214,7 +304,7 @@ zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero sessions 检查、恢复、分叉、回滚和清理会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
@@ -277,14 +367,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
|
||||
|
||||
提问、安装帮助、想法和分享都在
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
|
||||
|
||||
| 分类 | 用途 |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
|
||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
|
||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
|
||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
|
||||
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
|
||||
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
|
||||
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
|
||||
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: ZERO
|
||||
description: >-
|
||||
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
|
||||
model- and provider-agnostic: you supply the provider and API key as inputs.
|
||||
author: Gitlawb
|
||||
author: Twigpine
|
||||
|
||||
branding:
|
||||
icon: terminal
|
||||
@@ -110,7 +110,7 @@ inputs:
|
||||
zero-repo:
|
||||
description: Repository to install the ZERO release from (owner/repo).
|
||||
required: false
|
||||
default: Gitlawb/zero
|
||||
default: Twigpine/zero
|
||||
|
||||
outputs:
|
||||
exit-code:
|
||||
|
||||
@@ -1,11 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
|
||||
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
|
||||
// release was also published once under the old name, so 0.9.0's `zero
|
||||
// upgrade` and anyone still installing the old name land on a working build
|
||||
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
|
||||
// published under the old name after that.
|
||||
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
|
||||
|
||||
// The npm name this wrapper was published under, read from its own
|
||||
// package.json so the name lives in one place. The platform payload alias and
|
||||
// every reinstall hint below derive from it.
|
||||
function readPackageName(root) {
|
||||
try {
|
||||
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
if (typeof name === 'string' && name !== '') return name;
|
||||
} catch {
|
||||
// An unreadable manifest must not stop the CLI from launching.
|
||||
}
|
||||
return CURRENT_PACKAGE_NAME;
|
||||
}
|
||||
|
||||
const packageName = readPackageName(packageRoot);
|
||||
|
||||
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
|
||||
// Uninstalling first is the only safe order: both packages own the `zero` bin,
|
||||
// so npm refuses to install the new one over the old (EEXIST), and forcing it
|
||||
// lets a later uninstall of the old package delete the shared bin.
|
||||
function legacyPackageNotice() {
|
||||
return (
|
||||
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
|
||||
'To keep getting updates, quit zero and run:\n' +
|
||||
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
|
||||
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
|
||||
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
|
||||
);
|
||||
}
|
||||
|
||||
// Printed when the wrapper exits rather than before zero starts, because the
|
||||
// TUI takes over the screen and would hide anything printed first. Only on a
|
||||
// terminal, so scripts that capture stderr see nothing new. `zero update` and
|
||||
// `zero upgrade` print the same note themselves.
|
||||
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
|
||||
process.on('exit', () => {
|
||||
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
|
||||
// Synchronous: an exit handler cannot wait for an asynchronous write.
|
||||
writeSync(2, legacyPackageNotice());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function zeroBinaryName(platform = process.platform) {
|
||||
return platform === 'win32' ? 'zero.exe' : 'zero';
|
||||
}
|
||||
@@ -81,10 +133,10 @@ function bunRecoveryParagraph() {
|
||||
return (
|
||||
'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||
` bun pm trust ${packageName} (project install)\n` +
|
||||
` bun pm -g trust ${packageName} (global install)\n` +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
` "trustedDependencies": ["${packageName}"]\n` +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
);
|
||||
@@ -93,14 +145,14 @@ function bunRecoveryParagraph() {
|
||||
function buildFromSourceParagraph() {
|
||||
return (
|
||||
'If this platform has no prebuilt binary, build from source:\n' +
|
||||
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
|
||||
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
|
||||
);
|
||||
}
|
||||
|
||||
function missingBinaryContextParagraph() {
|
||||
return (
|
||||
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
|
||||
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
|
||||
`Normally npm installs it as an optional dependency of ${packageName}\n` +
|
||||
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
|
||||
'also download it from the GitHub Release when it is missing.'
|
||||
);
|
||||
}
|
||||
@@ -113,7 +165,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
|
||||
'\n' +
|
||||
'Things to try:\n' +
|
||||
' - reinstall without omitting optional dependencies:\n' +
|
||||
' npm install -g @gitlawb/zero\n' +
|
||||
` npm install -g ${packageName}\n` +
|
||||
' - run the downloader manually (needs write access to the package dir):\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
@@ -182,12 +234,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
|
||||
);
|
||||
}
|
||||
|
||||
// The platform payload is a version of @gitlawb/zero itself, installed under
|
||||
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
|
||||
// The platform payload is a version of this same package, installed under an
|
||||
// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
|
||||
// process.platform/process.arch, so an unsupported platform simply fails to
|
||||
// resolve and we fall through to the downloader.
|
||||
function platformPackageBinary() {
|
||||
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
|
||||
const aliasedName = `${packageName}-${process.platform}-${process.arch}`;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
|
||||
@@ -227,8 +279,6 @@ function downloadMissingBinary(packageRoot) {
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
function resolveNativeBinary() {
|
||||
const fromPlatformPackage = platformPackageBinary();
|
||||
if (fromPlatformPackage) return fromPlatformPackage;
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Gitlawb/zero@v1
|
||||
- uses: Twigpine/zero@v1
|
||||
with:
|
||||
prompt: ${{ inputs.task }}
|
||||
provider: openai
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
|
||||
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
|
||||
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
|
||||
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
|
||||
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Gitlawb/zero@v1
|
||||
- uses: Twigpine/zero@v1
|
||||
with:
|
||||
prompt: |
|
||||
Investigate this issue and propose a fix.
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Gitlawb/zero@v1
|
||||
- uses: Twigpine/zero@v1
|
||||
id: zero
|
||||
with:
|
||||
prompt-file: .github/zero/upgrade-deps.md
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
- **Least-privilege tokens.** Grant only the permissions the workflow needs
|
||||
(`contents: write` to edit files, `pull-requests: write` to comment). The
|
||||
default `GITHUB_TOKEN` is scoped to the repository.
|
||||
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
|
||||
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
|
||||
workflow run uses a known ZERO version. `zero-version` lets you pin the
|
||||
installed binary independently of the action ref.
|
||||
- **Linux and macOS runners** are supported; Windows runners are rejected with a
|
||||
|
||||
@@ -302,8 +302,9 @@ Conceptually, one requested tool goes through these phases:
|
||||
the model can inspect it and decide what to do next.
|
||||
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
|
||||
writer, or ACP client. TUI and exec record rich tool/session events for
|
||||
resume/history; ACP currently persists conversational messages and streams
|
||||
tool/permission updates to the client.
|
||||
resume/history; ACP persists conversational messages and completed tool-call
|
||||
starts/results for load replay. Live streaming details and permission
|
||||
exchanges remain client notifications rather than durable history.
|
||||
|
||||
Tool success and failure are both informative context. A successful `read_file`
|
||||
result gives the model file contents; a failed `edit_file` result gives the model
|
||||
@@ -627,7 +628,12 @@ Core tool groups include:
|
||||
skills, `ask_user`, permission requests.
|
||||
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
|
||||
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
|
||||
- **Network tools**: `web_fetch` and optional web search backend.
|
||||
- **Network tools**: `web_fetch` and optional web search backend. `web_fetch`
|
||||
validates the URL before the permission prompt, refuses loopback/private/
|
||||
special-use addresses, and dials the address it resolved. With
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
|
||||
target itself, so destination enforcement ends at the proxy; see the note in
|
||||
the README.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
|
||||
@@ -5,11 +5,11 @@ blessed one.
|
||||
|
||||
| Method | Command | Self-update |
|
||||
| --- | --- | --- |
|
||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
||||
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
|
||||
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
|
||||
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
|
||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
||||
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
|
||||
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
|
||||
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
|
||||
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
|
||||
| Source | `go build -o zero ./cmd/zero` | rebuild |
|
||||
|
||||
@@ -29,7 +29,7 @@ Release and verify its checksum.
|
||||
## npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
zero
|
||||
```
|
||||
|
||||
@@ -56,15 +56,47 @@ cannot write to keeps failing — rerun the install or the command below with
|
||||
sufficient permissions). To trigger the fetch manually:
|
||||
|
||||
```bash
|
||||
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
|
||||
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
|
||||
```
|
||||
|
||||
### Moving from @gitlawb/zero
|
||||
|
||||
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
|
||||
`@twigpine/zero`, and the old name gets no further releases.
|
||||
|
||||
Both packages provide the `zero` command, so npm refuses to install the new
|
||||
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
|
||||
uninstall of the old package removes the shared `zero` command. Quit any
|
||||
running `zero` (on Windows a running `zero.exe` cannot be removed), then
|
||||
uninstall the old package before installing the new one:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
```
|
||||
|
||||
With another package manager, the same two steps:
|
||||
|
||||
```bash
|
||||
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
|
||||
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
|
||||
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
|
||||
```
|
||||
|
||||
Config, sessions, logins, and plugins live in your user config and home
|
||||
directories, not in the package, so they carry over unchanged.
|
||||
|
||||
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
|
||||
updates lands on a working build. That build prints these steps when `zero`
|
||||
exits in a terminal, and `zero upgrade` and `zero update --check` print them
|
||||
too.
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
From a checkout:
|
||||
@@ -89,7 +121,7 @@ scripts/install.sh --install-dir "$HOME/bin"
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Repository: `Twigpine/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `~/.local/bin/zero`
|
||||
|
||||
@@ -100,7 +132,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
Install the latest release:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
From a checkout:
|
||||
@@ -123,7 +155,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Repository: `Twigpine/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
@@ -134,13 +166,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
|
||||
registry in the middle:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Gitlawb/zero
|
||||
mise use -g ubi:Twigpine/zero
|
||||
```
|
||||
|
||||
Pin a version the same way you would any other tool:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Gitlawb/[email protected]
|
||||
mise use -g ubi:Twigpine/[email protected]
|
||||
```
|
||||
|
||||
Updates come from `mise upgrade`. `zero upgrade` also works, because a
|
||||
@@ -153,7 +185,7 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
|
||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
||||
```
|
||||
|
||||
This builds from source, so it needs Go 1.26.6+ and it does not go through the
|
||||
This builds from source, so it needs Go 1.26.9+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
||||
@@ -169,7 +201,7 @@ macOS and Windows need no extra helper.
|
||||
## From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
@@ -180,7 +212,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
@@ -213,7 +245,7 @@ seccomp filter on Android:
|
||||
pkg install golang
|
||||
|
||||
# Build Zero for Android
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
cd zero
|
||||
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
|
||||
|
||||
@@ -302,7 +334,10 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
|
||||
overwriting a binary a package manager owns leaves that manager describing a
|
||||
version that is no longer there:
|
||||
|
||||
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
|
||||
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
|
||||
the old `@gitlawb/zero` package refuses instead and prints the steps in
|
||||
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
|
||||
further releases, so npm would only reinstall the same version.
|
||||
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
|
||||
keg binary directly would be reverted by the next `brew upgrade` or
|
||||
`brew reinstall`.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Local per-model completion limits
|
||||
|
||||
Add `modelRPM` to the existing Zero config file:
|
||||
|
||||
```json
|
||||
{"modelRPM": {"gpt-4.1": 30, "claude-sonnet-4.5": 15}}
|
||||
```
|
||||
|
||||
Keys resolve through the model registry; custom IDs match exactly after trimming
|
||||
whitespace. Missing keys and zero mean unlimited. Negative limits and conflicting
|
||||
aliases are configuration errors. Project config may add or tighten a user cap,
|
||||
but cannot relax it. Changes take effect on the next Zero process start.
|
||||
|
||||
For `zero exec` and interactive agent turns, admission happens immediately before
|
||||
the wrapped turn session's `Stream`. N admissions fit in any sliding 60-second
|
||||
window; N+1 returns a local rate-limit error before entering that method. The
|
||||
error reports when a slot expires. Exec uses its existing provider-error exit
|
||||
code plus a local-limit hint. There is no sleep or hidden retry. Failed admitted
|
||||
requests still consume a slot.
|
||||
|
||||
Sessions and model switches share a limiter in one process. Separate processes
|
||||
have independent windows; restarting clears them. This is not an account-wide
|
||||
cap. It counts Stream admissions, not all HTTP requests: setup/prewarm,
|
||||
compaction, provider-internal retries, discovery and direct calls outside that
|
||||
boundary are not counted. No TPM, cost budgets, persistence, cross-process
|
||||
coordination or new runtime/Python dependency is introduced.
|
||||
@@ -1,6 +1,6 @@
|
||||
# npm packaging
|
||||
|
||||
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
|
||||
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
|
||||
rules the release pipeline must follow. Read this before touching
|
||||
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
|
||||
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
|
||||
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
|
||||
|
||||
## Goals
|
||||
|
||||
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
|
||||
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
|
||||
|
||||
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
|
||||
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
|
||||
@@ -25,15 +25,15 @@ The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
|
||||
plus per-platform payloads carrying the native binaries.
|
||||
|
||||
```
|
||||
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
|
||||
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
|
||||
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
|
||||
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
|
||||
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
|
||||
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
|
||||
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
|
||||
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
|
||||
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
|
||||
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
|
||||
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
|
||||
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
|
||||
```
|
||||
|
||||
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
|
||||
- The platform "packages" are **versions of the same `@twigpine/zero` package**,
|
||||
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
|
||||
`npm:` aliases in `optionalDependencies`. One package name means one npm
|
||||
trusted-publisher configuration — no new publish credentials per platform.
|
||||
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
|
||||
|
||||
### Binary resolution in `bin/zero.js`
|
||||
|
||||
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
|
||||
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
|
||||
it. This wins over any previously downloaded copy — the platform version is
|
||||
pinned to the wrapper release.
|
||||
2. Fall back to a binary previously downloaded next to the wrapper.
|
||||
@@ -103,10 +103,22 @@ dependencies of the wrapper:
|
||||
platform binary (plus the musl variant on linux, which its launcher detects
|
||||
at runtime) — about 65 MB saved per platform package.
|
||||
|
||||
### One package name
|
||||
|
||||
The npm name lives in `package.json` and nothing else repeats it:
|
||||
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
|
||||
versions, the alias names, and the alias targets from it, and `bin/zero.js`
|
||||
reads its own `package.json` to derive the alias it resolves. The Go side
|
||||
needs the name as a constant (`npmPackageName` in
|
||||
`internal/update/installmethod.go`), and a test holds it equal to
|
||||
`package.json`.
|
||||
|
||||
## Publishing rules (release pipeline)
|
||||
|
||||
These are the invariants `release-artifacts.yml` must hold. Breaking the first
|
||||
one is user-visible immediately.
|
||||
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
|
||||
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
|
||||
registry.
|
||||
|
||||
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
|
||||
semver *prerelease* of `0.4.0`; publishing it without an explicit
|
||||
@@ -123,15 +135,80 @@ one is user-visible immediately.
|
||||
first would create a window where installs resolve aliases that 404.
|
||||
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
|
||||
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
|
||||
and its binaries can never skew.
|
||||
and its binaries can never skew. The publish script refuses to start when
|
||||
the wrapper's aliases do not match the platform payloads beside it.
|
||||
4. All publishes go through the existing npm OIDC trusted-publishing flow and
|
||||
the `npm-publish` environment gate; no tokens. Because the platform
|
||||
payloads are versions of the same package, they reuse the single
|
||||
trusted-publisher configuration.
|
||||
trusted-publisher configuration. npm checks provenance against
|
||||
`package.json` `repository`, so it must name the repository the workflow
|
||||
runs in.
|
||||
5. **Re-runs are safe.** A version already on the registry is skipped rather
|
||||
than republished, so re-running a job that failed between the platform and
|
||||
wrapper publishes finishes the release. Only npm's E404 counts as "not
|
||||
published"; a lookup that fails any other way is retried and then stops the
|
||||
job before anything is published.
|
||||
|
||||
`zero update` keeps working unchanged: it detects an npm install by finding a
|
||||
`package.json` named `@gitlawb/zero` next to the running binary — true inside
|
||||
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
|
||||
`package.json` named `@twigpine/zero` next to the running binary — true inside
|
||||
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
|
||||
|
||||
## The move from `@gitlawb/zero`
|
||||
|
||||
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
|
||||
from 0.10.0. There is no in-place migration: both packages own the `zero`
|
||||
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
|
||||
lets a later uninstall of the old package delete the shared bin. The user
|
||||
has to uninstall the old package and install the new one, in that order.
|
||||
|
||||
Deprecating the old package alone does not reach existing users. 0.9.0's
|
||||
`zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with nothing newer
|
||||
under that name, npm reinstalls nothing, prints no deprecation warning (it
|
||||
only warns when it installs something), and Zero reports the update as done.
|
||||
So 0.10.0 is published once more under the old name, as a bridge:
|
||||
|
||||
- The publish job assembles the same verified artifacts a second time with
|
||||
`build-platform-packages.mjs --package-name @gitlawb/zero` and publishes
|
||||
them with the same script, after the `@twigpine/zero` publish. A gate runs
|
||||
it only while the old name's `latest` is still 0.9.0 (or is this version,
|
||||
on a re-run), so it cannot happen twice.
|
||||
- The bridge build knows its name from the `package.json` beside it. The Go
|
||||
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
|
||||
with the switch steps instead of reinstalling it, and `zero update --check`
|
||||
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
|
||||
terminal only.
|
||||
- Updating from 0.9.0 now installs a real version, so npm also shows the
|
||||
deprecation message.
|
||||
|
||||
### Runbook
|
||||
|
||||
Before the first release under the new name:
|
||||
|
||||
1. Create the package, since npm only attaches a trusted publisher to a
|
||||
package that exists: publish an empty `@twigpine/[email protected]` by hand with
|
||||
`npm publish --access public --tag bootstrap`, then
|
||||
`npm deprecate @twigpine/[email protected] "Placeholder; install @twigpine/zero@latest"`.
|
||||
2. Trust this workflow for it:
|
||||
`npm trust github @twigpine/zero --repo Twigpine/zero --file release-artifacts.yml --env npm-publish --allow-publish`.
|
||||
3. Point the old package's trusted publisher at this repository too, for the
|
||||
bridge publish: `npm trust list @gitlawb/zero`, revoke the existing entry
|
||||
with `npm trust revoke @gitlawb/zero --id <id>`, then run the same
|
||||
`npm trust github` command for `@gitlawb/zero`.
|
||||
4. Confirm Settings → Environments → `npm-publish` has a required reviewer.
|
||||
The environment exists either way; without a reviewer the job does not
|
||||
wait for approval.
|
||||
|
||||
After the release, once both packages show 0.10.0 as `latest`:
|
||||
|
||||
1. `npm deprecate @gitlawb/zero "Zero moved to @twigpine/zero. Switch with: npm uninstall -g @gitlawb/zero && npm install -g @twigpine/zero"`
|
||||
(no version range, so every version, including the platform payloads,
|
||||
carries it). Never unpublish: existing installs resolve their platform
|
||||
payload through these versions.
|
||||
2. Revoke the old package's trusted publisher (`npm trust revoke`), so
|
||||
nothing can publish under that name again.
|
||||
3. Delete the two `@gitlawb/zero` steps from `release-artifacts.yml`. The Go
|
||||
and wrapper handling of the old name can stay; it only activates in a
|
||||
build installed under that name.
|
||||
|
||||
## Runbook: bumping the vendored helpers
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Run this checklist when a PR changes npm distribution files such as
|
||||
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
|
||||
Go build or release tooling, or the npm `bin` wrapper.
|
||||
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
|
||||
`bin` wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -37,24 +38,24 @@ path:
|
||||
|
||||
```bash
|
||||
# platform-package path
|
||||
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
|
||||
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
|
||||
mkdir -p /tmp/zero-sim/node_modules/@twigpine
|
||||
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
|
||||
cp -R dist/npm/platforms/zero-<platform>-<arch> \
|
||||
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
"/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
|
||||
|
||||
# first-run download fallback (delete the platform package first)
|
||||
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name (`@gitlawb/zero`), version,
|
||||
- `package.json` has the expected package name (`@twigpine/zero`), version,
|
||||
`bin.zero` entry, and NO `scripts` entries — the published package must be
|
||||
free of lifecycle scripts (see NPM_PACKAGING.md).
|
||||
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
|
||||
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
|
||||
`package.json` is `@twigpine/zero@<version>-<platform>-<arch>` with matching
|
||||
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
|
||||
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
|
||||
scripts, no dependencies, and the full five-alias `optionalDependencies`
|
||||
|
||||
@@ -6,6 +6,7 @@ Zero.
|
||||
## User Docs
|
||||
|
||||
- [Install](INSTALL.md)
|
||||
- [Local per-model completion limits](MODEL_RPM.md)
|
||||
- [Update flow](UPDATE.md)
|
||||
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
|
||||
```bash
|
||||
zero update --check
|
||||
zero update --check --json
|
||||
zero update --check --repo Gitlawb/zero
|
||||
zero update --check --repo Twigpine/zero
|
||||
zero update --check --target windows-x64
|
||||
|
||||
zero upgrade
|
||||
@@ -28,7 +28,11 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
||||
|
||||
`--apply` installs the update in place:
|
||||
|
||||
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
|
||||
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
|
||||
of the old `@gitlawb/zero` package refuses, without a release check, and
|
||||
prints the steps to switch to `@twigpine/zero` (see
|
||||
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
|
||||
too.
|
||||
- Standalone installs download the release archive, verify its checksum,
|
||||
extract it, and atomically replace the running binary plus any installed
|
||||
optional sandbox helpers.
|
||||
@@ -38,7 +42,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
||||
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
|
||||
since applying always installs onto the current machine.
|
||||
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
|
||||
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
|
||||
install: that path delegates to `npm install -g @twigpine/zero@latest` and
|
||||
takes its release from the npm registry, not from GitHub. They still apply to
|
||||
`--check` there.
|
||||
- `--json` serializes Zero's final result. For npm-managed installs, npm may
|
||||
@@ -65,7 +69,7 @@ Endpoint resolution order:
|
||||
1. `--endpoint`
|
||||
2. `ZERO_UPDATE_RELEASE_URL`
|
||||
3. `--repo`
|
||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||
@@ -93,14 +97,24 @@ binary:
|
||||
|
||||
| State on disk | Meaning |
|
||||
|---|---|
|
||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
|
||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker, or a per-user update-recovery record naming that copy | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. The `.keep` marker is in the installation directory and can be deleted; the identity-bound record is not, so deleting the marker alone does not end the refusal. |
|
||||
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
|
||||
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
|
||||
|
||||
The refusal names the paths involved and the two moves that end the state:
|
||||
either move the recovery binary back over the executable path, or — if the
|
||||
installed binary is the one you want — delete the `.keep` marker (or the
|
||||
`.recovery` copy, once you have verified the installed binary) and update again.
|
||||
installed binary is the one you want — delete the recovery copy itself (not
|
||||
only the `.keep` marker) after verifying the installed binary, then update
|
||||
again. Deleting the marker alone is not enough: a failed restore also records
|
||||
that copy in per-user state outside the installation directory, and the next
|
||||
update consults that record even if the marker is gone.
|
||||
|
||||
That trusted record lives under `%AppData%\zero\update-recovery` (per-user).
|
||||
The recovery copy and its `.keep` marker sit in the installation directory
|
||||
(per-machine). A second Windows account on the same machine does not see the
|
||||
first account's unresolved record; the sibling marker is the machine-visible
|
||||
signal for every account. There is no separate machine-wide store: writing one
|
||||
would need a shared writable location and ACL rules the updater does not own.
|
||||
|
||||
This is deliberately fail-closed and differs from older releases, which deleted
|
||||
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
||||
<circle cx="6.4" cy="6.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#FFFFFF"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#FFFFFF"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#FFFFFF"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#FFFFFF"/><circle cx="6.4" cy="16" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#FFFFFF"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#FFFFFF"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#FFFFFF"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#FFFFFF"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#FFFFFF"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#FFFFFF"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#FFFFFF"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#FFFFFF"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#FFFFFF"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#FFFFFF"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#FFFFFF"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#FFFFFF"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#FFFFFF"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#FFFFFF"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#FFFFFF"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#FFFFFF"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
||||
<circle cx="6.4" cy="6.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#0D0E10"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#0D0E10"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#0D0E10"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#0D0E10"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#0D0E10"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#0D0E10"/><circle cx="6.4" cy="16" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#0D0E10"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#0D0E10"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#0D0E10"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#0D0E10"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#0D0E10"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#0D0E10"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#0D0E10"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#0D0E10"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#0D0E10"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#0D0E10"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#0D0E10"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#0D0E10"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#0D0E10"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#0D0E10"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#0D0E10"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#0D0E10"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#0D0E10"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#0D0E10"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#0D0E10"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#0D0E10"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#0D0E10"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#0D0E10"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#0D0E10"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#0D0E10"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#0D0E10"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#0D0E10"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#0D0E10"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#0D0E10"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#0D0E10"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#0D0E10"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#0D0E10"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#0D0E10"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#0D0E10"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#0D0E10"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#0D0E10"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#0D0E10"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#0D0E10"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#0D0E10"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#0D0E10"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#0D0E10"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#0D0E10"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="white"/>
|
||||
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="white"/>
|
||||
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="white"/>
|
||||
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="#0143D9"/>
|
||||
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="black"/>
|
||||
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="black"/>
|
||||
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#fff">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#0D0E10">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_novita_dark)">
|
||||
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="white"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_novita_dark">
|
||||
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_125_11573)">
|
||||
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="black"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="black"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_125_11573">
|
||||
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,10 +1,10 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.6
|
||||
go 1.26.9
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/bubbletea/v2 v2.0.9
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sys v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
||||
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -64,13 +64,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
@@ -89,6 +89,13 @@ type Conn struct {
|
||||
|
||||
handlers map[string]HandlerFunc
|
||||
notifiers map[string]NotifyFunc
|
||||
// Notifications for one method are an ordered stream: session/update in
|
||||
// particular is stateful, so dispatching every frame in an unrelated
|
||||
// goroutine can make a later chunk overtake an earlier one. Different
|
||||
// methods retain independent tails, keeping session/cancel responsive while
|
||||
// an update handler is busy.
|
||||
notifyMu sync.Mutex
|
||||
notifyTails map[string]chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
@@ -106,11 +113,12 @@ type Conn struct {
|
||||
// lifetime.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
notifyTails: make(map[string]chan struct{}),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,11 +311,7 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}(msg)
|
||||
case msg.isNotify():
|
||||
if fn := c.notifiers[msg.Method]; fn != nil {
|
||||
c.wg.Add(1)
|
||||
go func(m rpcMessage) {
|
||||
defer c.wg.Done()
|
||||
fn(ctx, m.Params)
|
||||
}(msg)
|
||||
c.dispatchNotification(ctx, msg, fn)
|
||||
}
|
||||
default:
|
||||
// Malformed frame; reply only if we can identify a request id.
|
||||
@@ -317,6 +321,35 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchNotification preserves wire order within one method without making
|
||||
// unrelated notification methods wait for each other. The read loop installs
|
||||
// each tail before starting its goroutine, so goroutine scheduling cannot
|
||||
// reorder the chain it observes.
|
||||
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
|
||||
c.notifyMu.Lock()
|
||||
previous := c.notifyTails[msg.Method]
|
||||
done := make(chan struct{})
|
||||
c.notifyTails[msg.Method] = done
|
||||
c.notifyMu.Unlock()
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
defer func() {
|
||||
close(done)
|
||||
c.notifyMu.Lock()
|
||||
if c.notifyTails[msg.Method] == done {
|
||||
delete(c.notifyTails, msg.Method)
|
||||
}
|
||||
c.notifyMu.Unlock()
|
||||
}()
|
||||
if previous != nil {
|
||||
<-previous
|
||||
}
|
||||
fn(ctx, msg.Params)
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
||||
fn := c.handlers[msg.Method]
|
||||
if fn == nil {
|
||||
|
||||
@@ -360,6 +360,86 @@ func TestConnNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
firstStarted := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
delivered := make(chan int, 2)
|
||||
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
|
||||
var in struct{ Sequence int }
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if in.Sequence == 1 {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
}
|
||||
delivered <- in.Sequence
|
||||
})
|
||||
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
|
||||
t.Fatalf("first notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-firstStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first notification did not start")
|
||||
}
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
|
||||
t.Fatalf("second notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case sequence := <-delivered:
|
||||
t.Fatalf("notification %d overtook the blocked first notification", sequence)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
close(releaseFirst)
|
||||
for want := 1; want <= 2; want++ {
|
||||
select {
|
||||
case got := <-delivered:
|
||||
if got != want {
|
||||
t.Fatalf("notification order = ...,%d; want %d", got, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("notification %d was not delivered", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
updateStarted := make(chan struct{})
|
||||
releaseUpdate := make(chan struct{})
|
||||
cancelDelivered := make(chan struct{})
|
||||
b.HandleNotify("update", func(context.Context, json.RawMessage) {
|
||||
close(updateStarted)
|
||||
<-releaseUpdate
|
||||
})
|
||||
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
|
||||
close(cancelDelivered)
|
||||
})
|
||||
|
||||
if err := a.Notify("update", nil); err != nil {
|
||||
t.Fatalf("update notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("update notification did not start")
|
||||
}
|
||||
if err := a.Notify("cancel", nil); err != nil {
|
||||
t.Fatalf("cancel notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-cancelDelivered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("different notification method was blocked behind update")
|
||||
}
|
||||
close(releaseUpdate)
|
||||
}
|
||||
|
||||
func TestConnMethodNotFound(t *testing.T) {
|
||||
a, _, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// captureAgentOptions makes every turn record the agent.Options it was built
|
||||
// with, so a test can assert the permission mode the next prompt actually runs
|
||||
// with rather than only what the wire reports.
|
||||
func captureAgentOptions(deps *Deps) *agent.Options {
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
captured = opts
|
||||
return agent.Result{FinalAnswer: "ok"}, nil
|
||||
}
|
||||
return &captured
|
||||
}
|
||||
|
||||
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
|
||||
t.Helper()
|
||||
h := newHarness(t, deps)
|
||||
t.Cleanup(h.stop)
|
||||
return h
|
||||
}
|
||||
|
||||
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
|
||||
t.Helper()
|
||||
if err := h.client.Call(ctx, method, params, result); err != nil {
|
||||
t.Fatalf("%s: %v", method, err)
|
||||
}
|
||||
}
|
||||
|
||||
// restartHarness replaces the live agent with a fresh one over the same store,
|
||||
// which is the process restart the persisted mode has to survive.
|
||||
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
|
||||
t.Helper()
|
||||
h.stop()
|
||||
return newHarnessT(t, deps)
|
||||
}
|
||||
|
||||
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
|
||||
t.Helper()
|
||||
for _, option := range options {
|
||||
if option.ID == configIDMode {
|
||||
return option.CurrentValue
|
||||
}
|
||||
}
|
||||
t.Fatalf("no mode config option in %+v", options)
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
|
||||
t.Helper()
|
||||
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
|
||||
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
|
||||
}
|
||||
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
|
||||
t.Fatalf("mode config option = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
captured := captureAgentOptions(&deps)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
|
||||
|
||||
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
|
||||
meta, err := deps.Store.Get(created.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
t.Fatalf("store.Get: %v", err)
|
||||
}
|
||||
if meta.PermissionMode != string(agent.PermissionModePlan) {
|
||||
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
|
||||
}
|
||||
|
||||
// LOAD after a restart: the response must report the stored mode, and the
|
||||
// next prompt must actually run with it.
|
||||
h = restartHarness(t, h, deps)
|
||||
var loaded LoadSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
|
||||
assertModeReported(t, loaded, agent.PermissionModePlan)
|
||||
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
|
||||
if captured.PermissionMode != agent.PermissionModePlan {
|
||||
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
|
||||
}
|
||||
|
||||
// RESUME after another restart reports the same.
|
||||
h = restartHarness(t, h, deps)
|
||||
var resumed ResumeSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
|
||||
assertModeReported(t, resumed, agent.PermissionModePlan)
|
||||
}
|
||||
|
||||
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
|
||||
|
||||
var configured SetSessionConfigOptionResult
|
||||
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
|
||||
}, &configured)
|
||||
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
|
||||
t.Fatalf("configured mode = %q, want ask", got)
|
||||
}
|
||||
meta, err := deps.Store.Get(created.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
t.Fatalf("store.Get: %v", err)
|
||||
}
|
||||
if meta.PermissionMode != string(agent.PermissionModeAsk) {
|
||||
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
|
||||
}
|
||||
|
||||
h = restartHarness(t, h, deps)
|
||||
var loaded LoadSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
|
||||
assertModeReported(t, loaded, agent.PermissionModeAsk)
|
||||
}
|
||||
|
||||
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
|
||||
// the historical auto default, and a stored value outside the three offered
|
||||
// modes must never come back weaker than ask (and never unsafe).
|
||||
func TestACPModeRestoreFallback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed string // "" means metadata written before modes were persisted
|
||||
want agent.PermissionMode
|
||||
}{
|
||||
{name: "unset keeps auto", want: agent.PermissionModeAuto},
|
||||
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
|
||||
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
|
||||
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
captured := captureAgentOptions(&deps)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
|
||||
if tc.seed != "" {
|
||||
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
|
||||
t.Fatalf("seed mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h = restartHarness(t, h, deps)
|
||||
var loaded LoadSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
|
||||
assertModeReported(t, loaded, tc.want)
|
||||
|
||||
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
|
||||
if captured.PermissionMode != tc.want {
|
||||
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
|
||||
// mode cannot be saved the request fails and the live session is untouched.
|
||||
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
captured := captureAgentOptions(&deps)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
|
||||
|
||||
// Remove the metadata file so UpdateMode fails but the live session stays
|
||||
// promptable (turn persistence only warns when the file is gone).
|
||||
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
|
||||
if err := os.Remove(metadataPath); err != nil {
|
||||
t.Fatalf("remove metadata: %v", err)
|
||||
}
|
||||
|
||||
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
|
||||
var rpcErr *rpcError
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
|
||||
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
|
||||
}
|
||||
|
||||
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
|
||||
if captured.PermissionMode != agent.PermissionModeAuto {
|
||||
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
|
||||
}
|
||||
}
|
||||
@@ -142,13 +142,15 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
|
||||
// session/request_permission request from a ZERO permission request.
|
||||
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
||||
args := marshalArgs(req.Args)
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
ToolCallID: req.ToolCallID,
|
||||
Title: toolTitle(req.ToolName, string(args)),
|
||||
Kind: toolKindFor(req.ToolName),
|
||||
Status: ToolStatusPending,
|
||||
RawInput: rawInputBytes(args),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, req.ToolName)
|
||||
return upd
|
||||
}
|
||||
|
||||
func marshalArgs(args map[string]any) []byte {
|
||||
|
||||
@@ -82,3 +82,17 @@ func TestPermissionToolCall(t *testing.T) {
|
||||
t.Error("expected rawInput from args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
|
||||
call := permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "browser-1",
|
||||
ToolName: "browser_connect",
|
||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
||||
})
|
||||
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
|
||||
t.Fatalf("browser descriptor = %#v", got)
|
||||
}
|
||||
if call.Title != "browser connect" {
|
||||
t.Fatalf("title = %q", call.Title)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -17,6 +20,14 @@ func agentMessageChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
func replayMessageChunk(role, messageID, text string) ContentChunk {
|
||||
update := UpdateAgentMessageChunk
|
||||
if role == "user" {
|
||||
update = UpdateUserMessageChunk
|
||||
}
|
||||
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
|
||||
}
|
||||
|
||||
func agentThoughtChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
@@ -44,12 +55,125 @@ func toolKindFor(name string) string {
|
||||
|
||||
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
||||
func toolTitle(name, rawArgs string) string {
|
||||
if browser, ok := browserToolDetails(name); ok {
|
||||
return browserToolTitle(browser.Command, rawArgs)
|
||||
}
|
||||
if hint := primaryArgHint(rawArgs); hint != "" {
|
||||
return name + " " + hint
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// browserToolDetails identifies ZERO's local browser helpers without treating
|
||||
// similarly named MCP tools as browser automation. The descriptor intentionally
|
||||
// contains no request data: ACP tool input is already protocol-visible, but a
|
||||
// durable UI must not need to retain text, local CDP targets, or full URLs just
|
||||
// to recognise the browser operation.
|
||||
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
|
||||
const prefix = "browser_"
|
||||
command, ok := strings.CutPrefix(name, prefix)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch command {
|
||||
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
|
||||
return &BrowserToolDetails{Version: 1, Command: command}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
|
||||
|
||||
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
|
||||
// extension channel. Keeping this in one helper prevents start, result, and
|
||||
// permission payloads from drifting onto different wire shapes.
|
||||
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
|
||||
browser, ok := browserToolDetails(name)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(browser)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
|
||||
}
|
||||
|
||||
// browserToolTitle avoids putting browser_type text, an attached DevTools
|
||||
// endpoint, or a URL query/fragment in a tool-card title. Those values can
|
||||
// carry credentials or session data; the UI only needs the operation and, for
|
||||
// navigation, a human-recognisable origin.
|
||||
func browserToolTitle(command, rawArgs string) string {
|
||||
switch command {
|
||||
case "action":
|
||||
action, ok := exactJSONStringArg(rawArgs, "command")
|
||||
if !ok {
|
||||
return "browser action"
|
||||
}
|
||||
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
|
||||
return "browser action " + action
|
||||
}
|
||||
return "browser action"
|
||||
case "open":
|
||||
rawURL, ok := exactJSONStringArg(rawArgs, "url")
|
||||
if !ok {
|
||||
return "browser open"
|
||||
}
|
||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
||||
if err != nil {
|
||||
return "browser open"
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "browser open"
|
||||
}
|
||||
origin := u.Scheme + "://" + u.Host
|
||||
if !browserTitleTextSafe(origin) {
|
||||
return "browser open"
|
||||
}
|
||||
return "browser open " + truncateHint(origin)
|
||||
default:
|
||||
return "browser " + command
|
||||
}
|
||||
}
|
||||
|
||||
// browserTitleTextSafe validates text after URL parsing has decoded escaped
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: whitespace, control,
|
||||
// format/bidi, and line/paragraph separator runes can reorder or split the
|
||||
// permission label shown to a user. The execution URL remains unchanged.
|
||||
func browserTitleTextSafe(text string) bool {
|
||||
if !utf8.ValidString(text) {
|
||||
return false
|
||||
}
|
||||
for _, r := range text {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
|
||||
// exact JSON key is considered, and a non-string value is invalid. In
|
||||
// particular, an incidental "URL" key must not change a permission title when
|
||||
// browser_open will only read "url".
|
||||
func exactJSONStringArg(rawArgs, key string) (string, bool) {
|
||||
var args map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(rawArgs), &args) != nil {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := args[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
|
||||
// raw JSON arguments. Best-effort; returns "" when it can't parse.
|
||||
func primaryArgHint(rawArgs string) string {
|
||||
@@ -89,7 +213,7 @@ func rawInput(args string) json.RawMessage {
|
||||
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
|
||||
// update (status in_progress — ZERO executes immediately after advertising).
|
||||
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
SessionUpdate: UpdateToolCall,
|
||||
ToolCallID: call.ID,
|
||||
Title: toolTitle(call.Name, call.Arguments),
|
||||
@@ -97,6 +221,8 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
Status: ToolStatusInProgress,
|
||||
RawInput: rawInput(call.Arguments),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, call.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
||||
@@ -116,26 +242,64 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
||||
if locs := toolResultLocations(result); len(locs) > 0 {
|
||||
upd.Locations = locs
|
||||
}
|
||||
attachBrowserToolDetails(&upd, result.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
||||
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
|
||||
text := strings.TrimRight(result.Output, "\n")
|
||||
if text == "" {
|
||||
text = result.Display.Summary
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
return []ToolCallContent{ToolContent(TextBlock(text))}
|
||||
content = append(content, ToolContent(TextBlock(text)))
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
|
||||
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
|
||||
for _, diff := range diffs {
|
||||
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
|
||||
continue
|
||||
}
|
||||
var oldText *string
|
||||
if diff.OldExists {
|
||||
old := diff.OldText
|
||||
oldText = &old
|
||||
}
|
||||
var newText *string
|
||||
if diff.NewExists {
|
||||
updated := diff.NewText
|
||||
newText = &updated
|
||||
}
|
||||
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
||||
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
|
||||
for _, f := range result.ChangedFiles {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
for _, diff := range result.FileDiffs {
|
||||
path := diff.Path
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
locs = append(locs, ToolCallLocation{Path: path})
|
||||
}
|
||||
// FileDiff.Path is canonical absolute path data while ChangedFiles is
|
||||
// normally workspace-relative. Without the trusted workspace root these
|
||||
// coordinate systems cannot be correlated safely: a suffix match would let
|
||||
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
|
||||
// shared seen set deduplicates only identities already exactly comparable.
|
||||
for _, f := range result.ChangedFiles {
|
||||
if f == "" || seen[f] {
|
||||
continue
|
||||
}
|
||||
seen[f] = true
|
||||
locs = append(locs, ToolCallLocation{Path: f})
|
||||
}
|
||||
return locs
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
|
||||
t.Helper()
|
||||
raw, ok := update.Meta[zeroBrowserMetaKey]
|
||||
if !ok {
|
||||
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
|
||||
}
|
||||
var details BrowserToolDetails
|
||||
if err := json.Unmarshal(raw, &details); err != nil {
|
||||
t.Fatalf("decode browser metadata: %v", err)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
||||
m := agentMessageChunk("hello")
|
||||
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
||||
@@ -56,6 +72,223 @@ func TestToolTitleAndHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
|
||||
start := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-1",
|
||||
Name: "browser_open",
|
||||
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
|
||||
})
|
||||
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
|
||||
t.Fatalf("browser descriptor = %#v, want open", got)
|
||||
}
|
||||
if start.Title != "browser open https://example.com" {
|
||||
t.Fatalf("browser title = %q", start.Title)
|
||||
}
|
||||
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
|
||||
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
|
||||
}
|
||||
encoded, err := json.Marshal(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire struct {
|
||||
Meta map[string]json.RawMessage `json:"_meta"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
|
||||
t.Fatalf("browser wire metadata = %#v", wire.Meta)
|
||||
}
|
||||
|
||||
typed := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-2",
|
||||
Name: "browser_type",
|
||||
Arguments: `{"ref":"email","text":"[email protected]"}`,
|
||||
})
|
||||
if got := browserDescriptor(t, typed); got.Command != "type" {
|
||||
t.Fatalf("browser type descriptor = %#v", got)
|
||||
}
|
||||
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
|
||||
t.Fatalf("browser type title = %q", typed.Title)
|
||||
}
|
||||
|
||||
action := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-3",
|
||||
Name: "browser_action",
|
||||
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
|
||||
})
|
||||
if action.Title != "browser action keyboard_insert_text" {
|
||||
t.Fatalf("browser action title = %q", action.Title)
|
||||
}
|
||||
|
||||
result := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "browser-2",
|
||||
Name: "browser_type",
|
||||
Status: tools.StatusOK,
|
||||
})
|
||||
if got := browserDescriptor(t, result); got.Command != "type" {
|
||||
t.Fatalf("browser result descriptor = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{
|
||||
ID: "start",
|
||||
Name: "browser_open",
|
||||
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
|
||||
}),
|
||||
toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "result",
|
||||
Name: "browser_type",
|
||||
Status: tools.StatusOK,
|
||||
}),
|
||||
permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "permission",
|
||||
ToolName: "browser_connect",
|
||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
||||
}),
|
||||
}
|
||||
|
||||
type protocolToolCallUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
encoded, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(encoded, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := root["browser"]; ok {
|
||||
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
|
||||
}
|
||||
|
||||
var protocol protocolToolCallUpdate
|
||||
if err := json.Unmarshal(encoded, &protocol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarded, err := json.Marshal(protocol)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roundTripped ToolCallUpdate
|
||||
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
details := browserDescriptor(t, roundTripped)
|
||||
if details.Version != 1 || details.Command == "" {
|
||||
t.Fatalf("round-tripped browser descriptor = %#v", details)
|
||||
}
|
||||
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
|
||||
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
|
||||
if strings.Contains(descriptorJSON, secret) {
|
||||
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
|
||||
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
|
||||
t.Fatalf("bare-host title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
|
||||
t.Fatalf("case-variant URL title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`); got != "browser open https://actual.example.test" {
|
||||
t.Fatalf("exact URL key title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
|
||||
t.Fatalf("unknown browser action title = %q", got)
|
||||
}
|
||||
|
||||
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
|
||||
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
|
||||
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
|
||||
t.Fatalf("bounded browser origin title = %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
|
||||
for _, rawURL := range []string{
|
||||
"https://safe.example%C2%A0evil.test/path",
|
||||
"https://safe.example%E2%80%83evil.test/path",
|
||||
"https://safe.example%E3%80%80evil.test/path",
|
||||
"https://safe.example%E2%80%AEevil.test/path",
|
||||
"https://safe.example%E2%81%A6evil.test/path",
|
||||
"https://safe.example%C2%85evil.test/path",
|
||||
"https://safe.example%E2%80%A8evil.test/path",
|
||||
"https://safe.example%E2%80%A9evil.test/path",
|
||||
} {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("execution URL rejected: %v", err)
|
||||
}
|
||||
if normalized != rawURL {
|
||||
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
|
||||
}
|
||||
|
||||
args, err := json.Marshal(map[string]any{"url": rawURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title := browserToolTitle("open", string(args)); title != "browser open" {
|
||||
t.Errorf("unsafe origin accepted before permission translation: %q", title)
|
||||
}
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
|
||||
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
|
||||
}
|
||||
for _, update := range updates {
|
||||
encoded, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded ToolCallUpdate
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Title != "browser open" {
|
||||
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
|
||||
}
|
||||
for _, r := range decoded.Title {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
|
||||
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
|
||||
if len(start.Meta) != 0 {
|
||||
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
|
||||
}
|
||||
encoded, err := json.Marshal(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), `"browser"`) {
|
||||
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStart(t *testing.T) {
|
||||
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
||||
if upd.SessionUpdate != UpdateToolCall {
|
||||
@@ -74,21 +307,26 @@ func TestToolCallStart(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToolCallResult(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
ok := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "tc1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "applied\n",
|
||||
ChangedFiles: []string{"a.go", ""},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
|
||||
})
|
||||
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
|
||||
t.Fatalf("unexpected ok result: %+v", ok)
|
||||
}
|
||||
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
t.Fatalf("unexpected content: %+v", ok.Content)
|
||||
}
|
||||
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
|
||||
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
|
||||
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
|
||||
t.Fatalf("unexpected diff content: %+v", diff)
|
||||
}
|
||||
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
|
||||
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
|
||||
}
|
||||
|
||||
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
||||
@@ -97,6 +335,190 @@ func TestToolCallResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty.txt")
|
||||
content := appendToolResultDiffs(nil, []tools.FileDiff{
|
||||
{Path: path, OldExists: false, NewExists: true, NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
|
||||
})
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("diff content = %#v", content)
|
||||
}
|
||||
for index, diff := range content {
|
||||
encoded, err := json.Marshal(diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wire["path"] != path {
|
||||
t.Fatalf("wire diff %d = %s", index, encoded)
|
||||
}
|
||||
switch index {
|
||||
case 0:
|
||||
oldText, present := wire["oldText"]
|
||||
if !present || oldText != nil || wire["newText"] != "" {
|
||||
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
|
||||
}
|
||||
case 1:
|
||||
if wire["oldText"] != "before" || wire["newText"] != "" {
|
||||
t.Fatalf("empty replacement diff = %s", encoded)
|
||||
}
|
||||
case 2:
|
||||
newText, present := wire["newText"]
|
||||
if wire["oldText"] != "before" || !present || newText != nil {
|
||||
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
rootPath := filepath.Join(root, "a.go")
|
||||
nestedPath := filepath.Join(root, "sub", "a.go")
|
||||
diff := func(path string) tools.FileDiff {
|
||||
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
diffs []tools.FileDiff
|
||||
want []string
|
||||
}{
|
||||
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
|
||||
FileDiffs: tc.diffs,
|
||||
})
|
||||
if len(locations) != len(tc.want) {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
for index := range tc.want {
|
||||
if locations[index].Path != tc.want[index] {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
|
||||
relativePath := " report.txt "
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
|
||||
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{path, path},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
|
||||
})
|
||||
if len(locations) != 1 || locations[0].Path != path {
|
||||
t.Fatalf("exact duplicate locations = %#v", locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
|
||||
relativePath := "deleted.go"
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
|
||||
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("deleted file locations = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
path := filepath.Join(t.TempDir(), "secret.txt")
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
|
||||
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
|
||||
}}})
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
|
||||
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
|
||||
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{
|
||||
ChangedFiles: []string{"credentials.txt"},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
|
||||
}},
|
||||
})
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "call", Status: tools.StatusOK,
|
||||
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
|
||||
})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
|
||||
t.Fatalf("ACP path fallback = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
|
||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
||||
for name, separator := range map[string]string{
|
||||
"combining grapheme joiner": "\u034f",
|
||||
"variation selector": "\ufe0f",
|
||||
} {
|
||||
for _, side := range []string{"old", "new"} {
|
||||
t.Run(name+" "+side, func(t *testing.T) {
|
||||
obfuscated := secret[:20] + separator + secret[20:]
|
||||
diff := tools.FileDiff{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "safe old", NewText: "safe new",
|
||||
}
|
||||
if side == "old" {
|
||||
diff.OldText = obfuscated
|
||||
} else {
|
||||
diff.NewText = obfuscated
|
||||
}
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
|
||||
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
|
||||
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
|
||||
}
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanUpdateAndStatus(t *testing.T) {
|
||||
upd := planUpdate([]tools.PlanItem{
|
||||
{Content: "step a", Status: "completed"},
|
||||
|
||||
@@ -12,6 +12,8 @@ const (
|
||||
MethodAuthenticate = "authenticate"
|
||||
MethodSessionNew = "session/new"
|
||||
MethodSessionLoad = "session/load"
|
||||
MethodSessionList = "session/list"
|
||||
MethodSessionResume = "session/resume"
|
||||
MethodSessionPrompt = "session/prompt"
|
||||
MethodSessionCancel = "session/cancel" // notification
|
||||
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
||||
@@ -62,8 +64,16 @@ type PromptCapabilities struct {
|
||||
}
|
||||
|
||||
type AgentCapabilities struct {
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
|
||||
}
|
||||
|
||||
// Empty capability objects are presence flags in ACP v1. Pointers preserve
|
||||
// the wire distinction between an advertised `{}` and an omitted capability.
|
||||
type SessionCapabilities struct {
|
||||
List *struct{} `json:"list,omitempty"`
|
||||
Resume *struct{} `json:"resume,omitempty"`
|
||||
}
|
||||
|
||||
type AuthMethod struct {
|
||||
@@ -117,9 +127,14 @@ type McpServer struct {
|
||||
}
|
||||
|
||||
type NewSessionParams struct {
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
// AdditionalDirectories is declared by the protocol and consumed nowhere in
|
||||
// this repo yet. When it is wired up it must go through requestedWorkspace
|
||||
// like Cwd does: these are client-supplied paths with no absoluteness rule of
|
||||
// their own, which is the same shape as the resume-cwd hole that made
|
||||
// {"sessionId":"known"} activate a session against this process's directory.
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionResult struct {
|
||||
@@ -140,6 +155,39 @@ type LoadSessionResult struct {
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
// ListSessionsParams and the following types implement ACP v1 session/list.
|
||||
// Transcript contents stay behind session/load; this method returns metadata
|
||||
// only and leaves the optional pagination cursor opaque.
|
||||
type ListSessionsParams struct {
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfoMeta struct {
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
Meta *SessionInfoMeta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
type ListSessionsResult struct {
|
||||
Sessions []SessionInfo `json:"sessions"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// session/resume takes session/load's wire shape, so its cwd is a plain string
|
||||
// too and an omitted one decodes exactly like an empty one. Sharing the type is
|
||||
// safe only because activatePersistedSession requires an absolute cwd from the
|
||||
// request itself — nothing downstream may supply a default for either method.
|
||||
type ResumeSessionParams = LoadSessionParams
|
||||
type ResumeSessionResult = LoadSessionResult
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
type PromptParams struct {
|
||||
@@ -174,6 +222,7 @@ type SessionNotification struct {
|
||||
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
||||
type ContentChunk struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Content ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
@@ -210,6 +259,21 @@ type ToolCallUpdate struct {
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
|
||||
// namespaced key so protocol-shaped clients can preserve them while decoding
|
||||
// and re-encoding a tool call.
|
||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserToolDetails identifies the browser helper operation behind a tool
|
||||
// call. Version is the schema version for this optional ZERO extension;
|
||||
// Command is one of install, launch, connect, open, snapshot, click, type,
|
||||
// press, or action. Future fields must remain display-safe and must not
|
||||
// include browser profile data, cookies, typed text, URL paths/queries, or
|
||||
// DevTools endpoints.
|
||||
type BrowserToolDetails struct {
|
||||
Version int `json:"version"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
||||
@@ -220,9 +284,30 @@ type ToolCallContent struct {
|
||||
// type == "content"
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
// type == "diff"
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText string `json:"oldText,omitempty"`
|
||||
NewText string `json:"newText,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText *string `json:"oldText,omitempty"`
|
||||
NewText *string `json:"newText,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON preserves ACP's discriminated content union. A diff always has
|
||||
// path, while nullable oldText/newText distinguish creation, deletion, and an
|
||||
// existing file replaced with empty content. Other content variants omit all
|
||||
// diff fields rather than serializing irrelevant nulls.
|
||||
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
|
||||
if content.Type == "diff" {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
OldText *string `json:"oldText"`
|
||||
NewText *string `json:"newText"`
|
||||
}{
|
||||
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
|
||||
})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
}{Type: content.Type, Content: content.Content})
|
||||
}
|
||||
|
||||
func ToolContent(block ContentBlock) ToolCallContent {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// `zero exec` persists every provider usage report as a session usage event, so
|
||||
// `zero usage report --session <id>` can total a run. The ACP agent did not set
|
||||
// OnUsage, which dropped the token counts of every ACP session. A turn with two
|
||||
// provider requests (a tool round trip) must leave two usage events, in the shape
|
||||
// the usage report reads, next to the turn's messages.
|
||||
func TestACPPromptPersistsProviderUsage(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{FinalAnswer: "done"}, nil
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
|
||||
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
|
||||
return agent.Result{FinalAnswer: "done"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
|
||||
}, &result); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type usagePayload struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
CachedInputTokens int `json:"cachedInputTokens"`
|
||||
}
|
||||
var got []usagePayload
|
||||
messages := 0
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case sessions.EventUsage:
|
||||
var payload usagePayload
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("usage payload %s: %v", event.Payload, err)
|
||||
}
|
||||
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
|
||||
got = append(got, payload)
|
||||
case sessions.EventMessage:
|
||||
messages++
|
||||
}
|
||||
}
|
||||
want := []usagePayload{
|
||||
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
|
||||
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("usage events = %+v, want %+v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if messages != 2 {
|
||||
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
|
||||
// turn leaves nothing durable, exactly as it does for the turn's messages.
|
||||
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{}, errors.New("injected hard run failure")
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
|
||||
return agent.Result{}, errors.New("injected hard run failure")
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
|
||||
}, &PromptResult{}); err == nil {
|
||||
t.Fatal("hard RunAgent failure was reported as a successful turn")
|
||||
}
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("aborted turn left durable events: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
|
||||
// provider already reported were spent, so they stay in the session log even
|
||||
// though the client cancelled. A hard error (the test above) is the path that
|
||||
// drops them.
|
||||
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{}, context.Canceled
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
|
||||
return agent.Result{FinalAnswer: "partial"}, context.Canceled
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
|
||||
}, &result); err != nil {
|
||||
t.Fatalf("cancelled turn returned an RPC error: %v", err)
|
||||
}
|
||||
if result.StopReason != StopCancelled {
|
||||
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
|
||||
}
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usageEvents := 0
|
||||
for _, event := range events {
|
||||
if event.Type != sessions.EventUsage {
|
||||
continue
|
||||
}
|
||||
usageEvents++
|
||||
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
|
||||
var payload struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
}
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
|
||||
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
|
||||
}
|
||||
}
|
||||
if usageEvents != 1 {
|
||||
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage events sit in the same log session/load and session/resume read, but
|
||||
// history reconstruction skips them. The wire replay stays the conversation,
|
||||
// and the next turn's prompt stays the conversation: token counts never become
|
||||
// context and never cross the ACP wire.
|
||||
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
|
||||
const userLine = "unique-acp-user-line"
|
||||
const answer = "unique-acp-answer"
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{FinalAnswer: answer}, nil
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
|
||||
return agent.Result{FinalAnswer: answer}, nil
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
|
||||
}, &PromptResult{}); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
h.stop()
|
||||
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sawUsage := false
|
||||
for _, event := range events {
|
||||
if event.Type == sessions.EventUsage {
|
||||
sawUsage = true
|
||||
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
|
||||
}
|
||||
}
|
||||
if !sawUsage {
|
||||
t.Fatal("session log has no usage event to ignore")
|
||||
}
|
||||
|
||||
loader := newHarness(t, deps)
|
||||
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
|
||||
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
var replayed []string
|
||||
for {
|
||||
select {
|
||||
case update := <-loader.notifications:
|
||||
replayed = append(replayed, update.Content.Text)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
drained:
|
||||
loader.stop()
|
||||
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
|
||||
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
|
||||
}
|
||||
for _, text := range replayed {
|
||||
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
|
||||
t.Fatalf("session/load replayed usage onto the wire: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
var gotPrompt string
|
||||
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
|
||||
gotPrompt = prompt
|
||||
return agent.Result{FinalAnswer: "continued"}, nil
|
||||
}
|
||||
resumer := newHarness(t, deps)
|
||||
defer resumer.stop()
|
||||
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
|
||||
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
|
||||
}, &ResumeSessionResult{}); err != nil {
|
||||
t.Fatalf("session/resume: %v", err)
|
||||
}
|
||||
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
|
||||
}, &PromptResult{}); err != nil {
|
||||
t.Fatalf("resumed session/prompt: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
|
||||
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
|
||||
}
|
||||
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
|
||||
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
|
||||
// has no model escalation, and the writer must not grow a prompt, tool
|
||||
// argument, or other raw field.
|
||||
var usagePayloadAllowedKeys = map[string]bool{
|
||||
"promptTokens": true,
|
||||
"completionTokens": true,
|
||||
"totalTokens": true,
|
||||
"cachedInputTokens": true,
|
||||
"cacheWriteTokens": true,
|
||||
"reasoningTokens": true,
|
||||
}
|
||||
|
||||
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
|
||||
t.Helper()
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||
t.Fatalf("usage payload %s: %v", payload, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
t.Fatalf("usage payload is empty: %s", payload)
|
||||
}
|
||||
for key := range raw {
|
||||
if !usagePayloadAllowedKeys[key] {
|
||||
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -111,7 +112,11 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
testutil.WaitFor(t, "worker idle", func() bool {
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
return diagnostics.working == nil
|
||||
})
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
@@ -123,23 +128,6 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -144,11 +146,17 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
for _, tool := range tools {
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
total += ApproxTextTokens(string(tool.Type))
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
if tool.Format != nil {
|
||||
total += ApproxTextTokens(tool.Format.Type)
|
||||
total += ApproxTextTokens(tool.Format.Syntax)
|
||||
total += ApproxTextTokens(tool.Format.Definition)
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
}
|
||||
return total
|
||||
@@ -390,6 +398,27 @@ type compactionState struct {
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
|
||||
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
||||
// (Options.Summarizer) for the current main model. summarizerProvider
|
||||
// memoizes the built provider and summarizerResolved records that the
|
||||
// factory ran (it may legitimately return no provider: the main model is
|
||||
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
||||
// build or call fails, the run stays on the main provider until the next
|
||||
// model switch — a misconfigured cheap model must never break (or
|
||||
// repeatedly delay) compaction.
|
||||
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
summarizerProvider Provider
|
||||
summarizerResolved bool
|
||||
summarizerBroken bool
|
||||
// window is the context window the threshold was derived from. The
|
||||
// reactive free-prune stage compares against it (the limit the provider
|
||||
// enforces), not against the 70% proactive threshold — a pruned history
|
||||
// between the two would still succeed on retry.
|
||||
window int
|
||||
// mainModel is the model the run currently streams on; the summarizer
|
||||
// choice is made against it and remade after a mid-run switch.
|
||||
mainModel string
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
@@ -424,17 +453,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
window: options.ContextWindow,
|
||||
mainModel: options.Model,
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
summarizerFactory: options.Summarizer,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// updateWindow re-derives the compaction threshold after a mid-run model
|
||||
// switch. The low-water mark is reset: it was measured against the old
|
||||
// threshold, and keeping it could suppress a compaction the smaller window
|
||||
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
||||
func (state *compactionState) updateWindow(window int) {
|
||||
if window <= 0 {
|
||||
return
|
||||
}
|
||||
state.enabled = true
|
||||
state.window = window
|
||||
state.threshold = compactionThreshold(window)
|
||||
state.lowWaterMark = 0
|
||||
if state.planner != nil {
|
||||
state.planner.config.contextWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// switchModel records a mid-run model switch: the window is re-derived
|
||||
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
||||
// the next compaction re-resolves it against the new main model. A run that
|
||||
// started on the cheap model (no dedicated summarizer) and escalated to an
|
||||
// expensive one therefore regains cheap summaries — the exact workflow a
|
||||
// dedicated summarizer exists for.
|
||||
func (state *compactionState) switchModel(modelID string, window int) {
|
||||
state.mainModel = modelID
|
||||
state.updateWindow(window)
|
||||
state.summarizerProvider = nil
|
||||
state.summarizerResolved = false
|
||||
state.summarizerBroken = false
|
||||
}
|
||||
|
||||
// summarizeProvider returns the provider summarization calls should use and
|
||||
// whether it is the dedicated summarizer: that one when it applies to the
|
||||
// current main model and is healthy, else the main provider. The factory runs
|
||||
// at most once per main model; a build error marks the summarizer broken for
|
||||
// that model. Callers branch on the boolean rather than comparing Provider
|
||||
// values — interface comparison panics on a non-comparable dynamic type.
|
||||
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
||||
if state.summarizerFactory == nil || state.summarizerBroken {
|
||||
return main, false
|
||||
}
|
||||
if !state.summarizerResolved {
|
||||
state.summarizerResolved = true
|
||||
built, err := state.summarizerFactory(ctx, state.mainModel)
|
||||
if err != nil {
|
||||
state.summarizerBroken = true
|
||||
return main, false
|
||||
}
|
||||
state.summarizerProvider = built
|
||||
}
|
||||
if state.summarizerProvider == nil {
|
||||
return main, false
|
||||
}
|
||||
return state.summarizerProvider, true
|
||||
}
|
||||
|
||||
// fitsWindow reports whether a calibrated size is safely under the limit the
|
||||
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
||||
// for the paid summarizer, not the hard limit; the reactive path compares
|
||||
// against the window itself, with a small margin for estimate error, so a
|
||||
// history pruned to, say, 85% of the window takes the free retry.
|
||||
func (state *compactionState) fitsWindow(size int) bool {
|
||||
if state.window <= 0 {
|
||||
return size <= state.threshold
|
||||
}
|
||||
return size <= state.window-state.window/20
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
|
||||
// when the history is under threshold, or when the history has not grown past
|
||||
@@ -528,10 +628,34 @@ func (state *compactionState) recover(
|
||||
// context-limit error never triggers an unexpected summarization call.
|
||||
return messages, false, nil
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
if !isContextLimitError(errorMessage) {
|
||||
return messages, false, nil
|
||||
}
|
||||
if !isContextLimitError(errorMessage) {
|
||||
|
||||
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
||||
// often be fixed for free by dropping superseded reads and pruning old
|
||||
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
||||
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
||||
// prune). The free retry is taken only when the pruned history fits the
|
||||
// window the provider enforces — otherwise a retry would fail the same
|
||||
// way, so fall through to the paid summarizer in this same call.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
prunedAny := false
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if prunedAny {
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if state.fitsWindow(size) {
|
||||
state.lowWaterMark = size
|
||||
return messages, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if state.reactiveAttempted {
|
||||
return messages, false, nil
|
||||
}
|
||||
|
||||
@@ -571,11 +695,21 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
||||
// call fails the summarizer is marked broken for the current model and the
|
||||
// same slice is retried once on the main provider, so the caller sees at most
|
||||
// one (main-provider-quality) error.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
||||
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
if err == nil || !dedicated {
|
||||
return summary, err
|
||||
}
|
||||
state.summarizerBroken = true
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
}
|
||||
}
|
||||
@@ -659,8 +793,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
||||
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
||||
// search results), yet the summary instructions only need their gist — the
|
||||
// model that ACTED on the full output already turned it into decisions the
|
||||
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
||||
// head+tail of a result while cutting the summarizer input by 5-10x on
|
||||
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
||||
const summaryToolResultClamp = 2000
|
||||
|
||||
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
||||
// a write_file call carries the whole file body in its arguments.
|
||||
const summaryToolArgsClamp = 500
|
||||
|
||||
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
||||
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
||||
func clampForSummary(text string, max int) string {
|
||||
if max <= 0 || len(text) <= max {
|
||||
return text
|
||||
}
|
||||
head := max * 3 / 4
|
||||
tail := max - head
|
||||
for head > 0 && !utf8.RuneStart(text[head]) {
|
||||
head--
|
||||
}
|
||||
start := len(text) - tail
|
||||
for start < len(text) && !utf8.RuneStart(text[start]) {
|
||||
start++
|
||||
}
|
||||
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
||||
}
|
||||
|
||||
// renderTranscript flattens messages into a plain-text transcript for the
|
||||
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
||||
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
||||
// above): the summary needs what was done and decided, not the raw payloads.
|
||||
func renderTranscript(messages []zeroruntime.Message) string {
|
||||
lines := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
@@ -670,13 +837,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
|
||||
if len(message.ToolCalls) > 0 {
|
||||
calls := make([]string, 0, len(message.ToolCalls))
|
||||
for _, call := range message.ToolCalls {
|
||||
calls = append(calls, call.Name+"("+call.Arguments+")")
|
||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
||||
}
|
||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case zeroruntime.MessageRoleTool:
|
||||
lines = append(lines, "tool result: "+message.Content)
|
||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
||||
default:
|
||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// compactableHistory returns a history whose middle is large enough to trip
|
||||
// the proactive threshold for a 1000-token window.
|
||||
func compactableHistory() []zeroruntime.Message {
|
||||
return []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
|
||||
}
|
||||
}
|
||||
|
||||
// Summarization must run on the dedicated (cheap) summarizer when one is
|
||||
// configured — the main provider sees no summarize traffic at all.
|
||||
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
|
||||
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
main := &mockProvider{}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
|
||||
|
||||
if len(cheap.requests) == 0 {
|
||||
t.Fatal("dedicated summarizer never received the summarize call")
|
||||
}
|
||||
if len(main.requests) != 0 {
|
||||
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
|
||||
t.Fatal("compaction did not shrink the history")
|
||||
}
|
||||
found := false
|
||||
for _, message := range compacted {
|
||||
if strings.Contains(message.Content, "CHEAP SUMMARY") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cheap summarizer's summary missing from the compacted history")
|
||||
}
|
||||
}
|
||||
|
||||
// A failing summarizer falls back to the main provider for the SAME slice and
|
||||
// stays broken for the rest of the run — no repeated failed cheap calls.
|
||||
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
|
||||
|
||||
summary, err := summarize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback must succeed via the main provider: %v", err)
|
||||
}
|
||||
if summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, want the main provider's", summary)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
|
||||
}
|
||||
if !state.summarizerBroken {
|
||||
t.Fatal("a failed summarizer must be marked broken for the run")
|
||||
}
|
||||
|
||||
// Second summarization: straight to main, no cheap retry.
|
||||
if _, err := summarize(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
|
||||
}
|
||||
if len(main.requests) != 2 {
|
||||
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A factory that errors (misconfigured model) is equivalent to no summarizer:
|
||||
// main provider used, factory not retried.
|
||||
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return nil, errors.New("unknown model")
|
||||
},
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
|
||||
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("after a factory failure the run must stay on the main provider")
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool payloads are clamped in the summarizer transcript: results to
|
||||
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
|
||||
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
|
||||
bigResult := strings.Repeat("r", 50_000)
|
||||
bigArgs := strings.Repeat("w", 20_000)
|
||||
transcript := renderTranscript([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "c1", Name: "write_file", Arguments: bigArgs},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
|
||||
})
|
||||
|
||||
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
|
||||
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
|
||||
}
|
||||
if !strings.Contains(transcript, "chars omitted for summarization") {
|
||||
t.Fatal("clamped transcript must carry the omission marker")
|
||||
}
|
||||
if !strings.Contains(transcript, "keep going") {
|
||||
t.Fatal("non-tool content must survive unclamped")
|
||||
}
|
||||
// Head AND tail of the clamped payloads survive.
|
||||
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
|
||||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
|
||||
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// bigToolHistory returns a history with n large tool results (~40k estimated
|
||||
// tokens each): the trailing protection window keeps the newest verbatim,
|
||||
// the older ones are prunable.
|
||||
func bigToolHistory(n int) []zeroruntime.Message {
|
||||
bigBody := strings.Repeat("x", 160_000)
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
|
||||
for i := 0; i < n; i++ {
|
||||
id := string(rune('a' + i))
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
|
||||
)
|
||||
}
|
||||
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
|
||||
}
|
||||
|
||||
func summaryProvider(text string) *mockProvider {
|
||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
}
|
||||
|
||||
// A context-limit error is first answered with the free prune stage; when the
|
||||
// pruned history fits the window the retry is free (no summarizer call) and
|
||||
// the one-shot reactive budget is untouched. The paid summarizer only runs
|
||||
// when a later error finds nothing left to prune.
|
||||
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// The protected trailing tool output (~40k tokens) must fit the window.
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("prunable history that fits must produce a free retry")
|
||||
}
|
||||
if len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
t.Fatal("a free prune retry must not consume the reactive budget")
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
|
||||
// Second context-limit error on the pruned history: nothing meaningfully
|
||||
// prunable remains, so the paid summarizer fires and consumes the budget.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("second recover must compact and retry")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("second recover must reach the summarizer")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("a paid recover must consume the reactive budget")
|
||||
}
|
||||
}
|
||||
|
||||
// When pruning reclaims something but the pruned history still exceeds the
|
||||
// window, recover falls through to the paid summarizer in the SAME call
|
||||
// instead of spending a retry that would fail the same way.
|
||||
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// A 1,000-token window: even the protected trailing output overflows it.
|
||||
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("insufficient prune must still end in a retry via summarization")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("the paid summarizer consumes the reactive budget")
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(messages) {
|
||||
t.Fatal("recover did not shrink the history")
|
||||
}
|
||||
}
|
||||
|
||||
// The free prune stage is not gated behind the one-shot budget: after a paid
|
||||
// reactive compaction, a later context-limit error still gets a free retry
|
||||
// when pruning makes the history fit.
|
||||
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
state.reactiveAttempted = true
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
// With nothing left to prune and the budget spent, the caller keeps its
|
||||
// original error — recover neither loops nor summarizes.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil || retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
|
||||
}
|
||||
}
|
||||
|
||||
// The free-retry gate compares against the window the provider enforces, not
|
||||
// the 70% proactive threshold: a history pruned to ~80% of the window still
|
||||
// takes the free retry.
|
||||
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
messages := bigToolHistory(5)
|
||||
pruned, _ := pruneStaleToolOutput(messages, 2)
|
||||
prunedTokens := estimateTokens(pruned)
|
||||
// Choose a window so the pruned size sits between 70% and 95% of it.
|
||||
window := prunedTokens * 100 / 80
|
||||
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
|
||||
if prunedTokens <= state.threshold {
|
||||
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
|
||||
}
|
||||
|
||||
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// After a mid-run model switch the summarizer is re-resolved against the new
|
||||
// main model: a run that started on the cheap model (no dedicated summarizer)
|
||||
// and escalated gets a cheap summarizer, and a previously broken one is
|
||||
// retried for the new model.
|
||||
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
|
||||
cheap := summaryProvider("CHEAP SUMMARY")
|
||||
main := summaryProvider("MAIN SUMMARY")
|
||||
var factoryModels []string
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Model: "cheap-model",
|
||||
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
|
||||
factoryModels = append(factoryModels, mainModelID)
|
||||
if mainModelID == "cheap-model" {
|
||||
return nil, nil // main already is the cheap model
|
||||
}
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("on the cheap model the main provider must summarize")
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
|
||||
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
state.switchModel("big-model", 400_000)
|
||||
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
|
||||
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
|
||||
}
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
|
||||
}
|
||||
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
|
||||
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
// A broken summarizer is retried after the next switch.
|
||||
state.summarizerBroken = true
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("a broken summarizer must fall back to main")
|
||||
}
|
||||
state.switchModel("other-model", 0)
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionUpdateWindow(t *testing.T) {
|
||||
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
|
||||
state.lowWaterMark = 50_000
|
||||
before := state.threshold
|
||||
|
||||
state.updateWindow(0) // unknown model: unchanged
|
||||
if state.threshold != before || state.lowWaterMark != 50_000 {
|
||||
t.Fatal("updateWindow(0) must be a no-op")
|
||||
}
|
||||
|
||||
state.updateWindow(10_000)
|
||||
if state.threshold >= before {
|
||||
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
|
||||
}
|
||||
if state.lowWaterMark != 0 {
|
||||
t.Fatal("low-water mark must reset on a window change")
|
||||
}
|
||||
if !state.enabled {
|
||||
t.Fatal("a positive window must keep compaction enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: after escalate_model switches providers, the loop re-derives the
|
||||
// context window via ContextWindowFor, visible in the OnContext report.
|
||||
func TestRunEscalationRederivesContextWindow(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "big-model"})
|
||||
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
var windows []int
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
ContextWindow: 100_000,
|
||||
Model: "small-model",
|
||||
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
if modelID == "big-model" {
|
||||
return 400_000
|
||||
}
|
||||
return 0
|
||||
},
|
||||
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(windows) < 2 {
|
||||
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
|
||||
}
|
||||
if windows[0] != 100_000 {
|
||||
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
|
||||
}
|
||||
if windows[len(windows)-1] != 400_000 {
|
||||
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// nonComparableProvider has a slice field, so comparing two Provider interface
|
||||
// values holding it would panic; the summarizer path must never compare them.
|
||||
type nonComparableProvider struct {
|
||||
inner *mockProvider
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
return p.inner.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
|
||||
}, nil)
|
||||
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
|
||||
}
|
||||
// And with no dedicated summarizer at all the closure still works.
|
||||
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -126,8 +126,11 @@ func capCompactionBrief(brief string) (string, bool) {
|
||||
|
||||
func compactionToolCallLine(call zeroruntime.ToolCall) string {
|
||||
detail := ""
|
||||
if call.Freeform && call.Name == "apply_patch" {
|
||||
detail = strings.Join(patchPaths(call.Arguments), ", ")
|
||||
}
|
||||
var arguments map[string]any
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
if detail == "" && json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
|
||||
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
detail = value
|
||||
|
||||
@@ -73,6 +73,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
|
||||
{ID: "raw-patch", Name: "apply_patch", Freeform: true, Arguments: "*** Begin Patch\n*** Update File: internal/api.go\n*** Add File: internal/config.go\n*** End Patch\n"},
|
||||
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
|
||||
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
|
||||
}},
|
||||
@@ -80,7 +81,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
|
||||
}
|
||||
|
||||
content := projectCompactionInput(messages).messages[0].Content
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "internal/api.go", "internal/config.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
|
||||
@@ -275,6 +275,27 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
|
||||
if estimateToolDefTokens(one) <= 0 {
|
||||
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
|
||||
}
|
||||
typed := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
typed[0].Type = zeroruntime.ToolDefinitionFreeform
|
||||
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
|
||||
t.Fatal("a non-empty tool type must increase the estimated request cost")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
|
||||
} {
|
||||
t.Run("format "+test.name, func(t *testing.T) {
|
||||
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
|
||||
formatted[0].Format = &test.format
|
||||
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
|
||||
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
|
||||
Name: "write_file",
|
||||
Description: "Write contents to a file in the workspace.",
|
||||
|
||||
@@ -102,6 +102,10 @@ func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.To
|
||||
for index, definition := range toolDefs {
|
||||
copied[index] = definition
|
||||
copied[index].Parameters = copySchemaMap(definition.Parameters)
|
||||
if definition.Format != nil {
|
||||
format := *definition.Format
|
||||
copied[index].Format = &format
|
||||
}
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
}
|
||||
toolDefs := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file", Description: "Read a file",
|
||||
Format: &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: "start: PATH"},
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"path"},
|
||||
@@ -53,10 +54,12 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
toolDefs[0].Parameters["required"].([]string)[0] = "other"
|
||||
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
|
||||
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
|
||||
toolDefs[0].Format.Definition = "changed"
|
||||
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
|
||||
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" ||
|
||||
plan.Request.Tools[0].Format == nil || plan.Request.Tools[0].Format.Definition != "start: PATH" ||
|
||||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
|
||||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
|
||||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type customApplyPatchTool struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
type fixedPathScope struct {
|
||||
roots []string
|
||||
}
|
||||
|
||||
func (scope fixedPathScope) Roots() []string {
|
||||
return append([]string(nil), scope.roots...)
|
||||
}
|
||||
|
||||
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
|
||||
t.Helper()
|
||||
resolved := make([]string, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
absolute, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", root, err)
|
||||
}
|
||||
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
absolute = physical
|
||||
}
|
||||
resolved = append(resolved, absolute)
|
||||
}
|
||||
return fixedPathScope{roots: resolved}
|
||||
}
|
||||
|
||||
func (*customApplyPatchTool) Name() string { return "apply_patch" }
|
||||
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
|
||||
func (*customApplyPatchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
|
||||
}
|
||||
func (*customApplyPatchTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
|
||||
}
|
||||
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
|
||||
tool.calls++
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(root, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(extra, "hello.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read extra-root patched file: %v", err)
|
||||
}
|
||||
if string(content) != "hello\n" {
|
||||
t.Fatalf("patched content = %q, want hello newline", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
|
||||
for _, extraHasMatchingPath := range []bool{false, true} {
|
||||
name := "extra target missing"
|
||||
if extraHasMatchingPath {
|
||||
name = "extra target exists"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspaceTarget := filepath.Join(workspace, "src", "a.go")
|
||||
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extraTarget := filepath.Join(extra, "src", "a.go")
|
||||
if extraHasMatchingPath {
|
||||
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
extraCreated := filepath.Join(extra, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: src/a.go",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + extraCreated,
|
||||
"+extra",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
|
||||
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
if extraHasMatchingPath {
|
||||
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
|
||||
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
|
||||
t.Fatalf("mixed-root patch created extra file: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
useExtraRoot bool
|
||||
}{
|
||||
{name: "workspace"},
|
||||
{name: "extra root", useExtraRoot: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
scope, err := sandbox.NewScope(workspace, []string{extra})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := workspace
|
||||
if test.useExtraRoot {
|
||||
root = extra
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "src", "a.go")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatePath := target
|
||||
if !test.useExtraRoot {
|
||||
updatePath = filepath.Join("src", "a.go")
|
||||
}
|
||||
created := filepath.Join(root, "b.go")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: " + updatePath,
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: " + created,
|
||||
"+created",
|
||||
"*** End Patch",
|
||||
}, "\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
|
||||
t.Fatalf("updated content=%q err=%v", content, err)
|
||||
}
|
||||
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
|
||||
t.Fatalf("created content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
granted := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
scope := newFixedPathScope(t, root, granted)
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
|
||||
target := filepath.Join(outside, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{Cwd: root})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("ungranted absolute patch created target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating directory symlinks requires privileges on Windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
intermediate := filepath.Join(extra, "nested")
|
||||
if err := os.Mkdir(intermediate, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
scope := newFixedPathScope(t, root, extra)
|
||||
tool := tools.NewScopedApplyPatchTool(root, scope)
|
||||
target := filepath.Join(intermediate, "blocked.txt")
|
||||
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
|
||||
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(intermediate); err != nil {
|
||||
t.Fatalf("Remove intermediate: %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, intermediate); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
|
||||
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("unknown freeform status = %s, want error", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
|
||||
custom := &customApplyPatchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(custom)
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
|
||||
}, PermissionModeUnsafe, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
|
||||
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
|
||||
}
|
||||
if custom.calls != 0 {
|
||||
t.Fatalf("custom apply_patch executed %d times", custom.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
|
||||
|
||||
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "apply_patch",
|
||||
Arguments: patch,
|
||||
Freeform: true,
|
||||
}, PermissionModeAsk, Options{
|
||||
Cwd: root,
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("denied freeform status = %s, want error", result.Status)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("denied freeform patch changed the workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
|
||||
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
|
||||
safe := historySafeToolCalls([]ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
|
||||
}})
|
||||
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
|
||||
t.Fatalf("freeform history changed raw call: %#v", safe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
|
||||
messages := appendAbortedToolResults(nil, []ToolCall{{
|
||||
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
|
||||
}})
|
||||
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
|
||||
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ const (
|
||||
// knows before spending more tool turns.
|
||||
toolOnlyProgressReminderAt = 6
|
||||
|
||||
// planReminderTurn is the turn (1-based) by the end of which a multi-step
|
||||
// task should have called update_plan; if it hasn't, a one-time reminder is
|
||||
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
|
||||
// without a spurious planning nag.
|
||||
planReminderTurn = 3
|
||||
// planReminderTurn and planReminderToolThreshold keep bounded implementation
|
||||
// work plan-free while still nudging a genuinely sustained run that has not
|
||||
// established any structured progress. Both thresholds must be met.
|
||||
planReminderTurn = 7
|
||||
planReminderToolThreshold = 7
|
||||
|
||||
// planToolName is the planning tool the loop watches for by name.
|
||||
planToolName = "update_plan"
|
||||
@@ -591,13 +591,12 @@ func (state *guardState) planReminder(turn int) string {
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
|
||||
// called update_plan if it's doing a multi-step task (>=1 other tool call).
|
||||
// One-shot for the whole run.
|
||||
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
|
||||
// sustained work should have called update_plan. One-shot for the whole run.
|
||||
if !state.notCalledReminderSent &&
|
||||
!state.planEverCalled &&
|
||||
turn >= planReminderTurn &&
|
||||
state.totalToolCalls >= 1 {
|
||||
state.totalToolCalls >= planReminderToolThreshold {
|
||||
state.notCalledReminderSent = true
|
||||
return planNotCalledReminder()
|
||||
}
|
||||
|
||||
@@ -257,19 +257,16 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
if planReminderToolThreshold != expectedThreshold {
|
||||
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
|
||||
}
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
|
||||
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
|
||||
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
@@ -287,6 +284,44 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
|
||||
const expectedThreshold = 7
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
|
||||
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
|
||||
state := guardState{totalToolCalls: 7}
|
||||
if got := state.planReminder(6); got != "" {
|
||||
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
|
||||
}
|
||||
if state.notCalledReminderSent {
|
||||
t.Fatal("early turn marked the not-called reminder as sent")
|
||||
}
|
||||
}
|
||||
|
||||
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, count)
|
||||
for range count {
|
||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -25,14 +26,18 @@ const maxTurnsAnswer = "Agent reached maximum number of turns without a final an
|
||||
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
|
||||
|
||||
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
|
||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||
// safe recovery for a stalled/dead pooled connection.
|
||||
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
|
||||
// on a fresh connection before giving up. Incomplete tool-call previews are
|
||||
// allowed through the gate (they are never dispatched before the error return).
|
||||
// The safety claim is only that visible answer prose is not duplicated on retry —
|
||||
// not that every partial stream shape is excluded. Covers a stalled/dead pooled
|
||||
// connection or a socket abort before tool dispatch.
|
||||
//
|
||||
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||||
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||||
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||||
// while bounding the worst case to ~2× the idle timeout.
|
||||
// while bounding the worst case to ~2× the idle timeout. Mid-stream transport
|
||||
// aborts share this bound (do not raise it for #973).
|
||||
const maxStreamStallRetries = 1
|
||||
|
||||
const (
|
||||
@@ -141,6 +146,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
|
||||
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
|
||||
options.Trace.Counter(trace.CounterCacheWriteTokens, int64(usage.CacheWriteTokens))
|
||||
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
|
||||
}
|
||||
}
|
||||
@@ -373,7 +379,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
rec.StampFirstToken()
|
||||
}
|
||||
if onText != nil {
|
||||
forwardedVisibleText = true
|
||||
// Zero-length chunks are not committed answer prose; counting
|
||||
// them would block an eligible mid-stream abort retry.
|
||||
if s != "" {
|
||||
forwardedVisibleText = true
|
||||
}
|
||||
onText(s)
|
||||
}
|
||||
}
|
||||
@@ -449,9 +459,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
// A stream idle/stall timeout is safely re-issued when the turn committed NO
|
||||
// answer text — no forwarded visible prose (forwardedVisibleText) and no
|
||||
// collected final text (collected.Text). This covers two cases:
|
||||
// A stream idle/stall timeout OR mid-stream transport abort (#973 — wsarecv /
|
||||
// connection reset / unexpected EOF / connection closed / broken pipe) is
|
||||
// safely re-issued when the turn committed NO answer text — no forwarded
|
||||
// visible prose (forwardedVisibleText) and no collected final text
|
||||
// (collected.Text). The abort gate matches midStreamAbortNeedles only, not
|
||||
// the full shouldReconnect list: connect-phase timeouts and connection
|
||||
// refused are NOT retried here (a slow healthy server must not cost a
|
||||
// second prefill). This covers three cases:
|
||||
// 1. Nothing streamed at all before the connection died (the original
|
||||
// macOS stale-pooled-connection hang past the response-header timeout).
|
||||
// 2. The model streamed transient reasoning and began a tool call (e.g. a
|
||||
@@ -462,13 +477,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// re-render is transient previews, not duplicated answer text. This is
|
||||
// why the gate no longer excludes collected.ToolCalls: an incomplete
|
||||
// tool call from a timed-out stream is discard-and-retry, not output.
|
||||
// 3. Mid-stream socket abort after connect succeeded (Windows WSAECONNABORTED /
|
||||
// wsarecv, connection reset by peer). Same no-tool-executed safety: the
|
||||
// error returns before tool dispatch, so a bounded retry is safe.
|
||||
// A turn that forwarded real prose is NOT retried (it would duplicate visible
|
||||
// answer text) and falls through to the error return below. Capped +
|
||||
// exponential backoff, with a user-visible notice per attempt.
|
||||
for attempt := 1; attempt <= maxStreamStallRetries &&
|
||||
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
|
||||
(isStreamTimeoutError(collected.Error) || isMidStreamTransportAbort(collected.Error)) &&
|
||||
!forwardedVisibleText &&
|
||||
collected.Text == ""; attempt++ {
|
||||
if notify := stallRetryNoticeFor(options); notify != nil {
|
||||
var notify reconnectNotifier
|
||||
if isStreamTimeoutError(collected.Error) {
|
||||
notify = stallRetryNoticeFor(options)
|
||||
} else {
|
||||
notify = reconnectNoticeFor(options)
|
||||
// Count this post-connect reissue even when the replacement
|
||||
// connect succeeds immediately. streamWithReconnect only
|
||||
// increments reconnect_count after a failed connect attempt.
|
||||
options.Trace.Counter(trace.CounterReconnectCount, 1)
|
||||
}
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamStallRetries)
|
||||
}
|
||||
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
|
||||
@@ -481,12 +510,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
if ctx.Err() != nil {
|
||||
return result, ctx.Err()
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
stallGenSpan := options.Trace.Span(trace.SpanGeneration)
|
||||
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
|
||||
stallGenSpan.End()
|
||||
}
|
||||
// Recheck ctx after a retried CollectStream: helpers.go stores
|
||||
// ctx.Err().Error() in collected.Error, and errors.New of that string
|
||||
// drops the context.Canceled sentinel ACP branches on.
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
// Route a reissued stream's non-stall error through the SAME recovery as
|
||||
// the initial stream (image-rejection wrapping / context-limit compaction)
|
||||
@@ -497,6 +536,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, stop
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, errors.New(collected.Error)
|
||||
@@ -645,9 +688,25 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// recorded. They travel as user messages, and a user message between two
|
||||
// tool_results breaks strict provider replay — Anthropic coalesces them
|
||||
// into one user block list and requires the tool_result blocks first, so
|
||||
// interleaving yields [tool_result, text, image, tool_result] and a 400.
|
||||
// Same reason the self-correction feedback below is deferred.
|
||||
var toolImageMessages []zeroruntime.Message
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate. Vision-gate evaluation is deferred until
|
||||
// after any model switch this turn resolves, so images can reach an
|
||||
// escalated vision-capable model.
|
||||
var toolResultsWithImages []ToolResult
|
||||
buildToolImageMessages := func() []zeroruntime.Message {
|
||||
var out []zeroruntime.Message
|
||||
for _, tr := range toolResultsWithImages {
|
||||
if imageMessage, ok := toolResultImageMessage(tr, options); ok {
|
||||
out = append(out, imageMessage)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
@@ -697,21 +756,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate.
|
||||
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
|
||||
toolImageMessages = append(toolImageMessages, imageMessage)
|
||||
if len(toolResult.Images) > 0 {
|
||||
toolResultsWithImages = append(toolResultsWithImages, toolResult)
|
||||
}
|
||||
|
||||
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
|
||||
@@ -723,13 +776,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
if abortErr != nil {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, abortErr
|
||||
}
|
||||
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolResult.ModelOutput()
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -753,7 +806,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// messages stay valid for a strict provider replay (Anthropic
|
||||
// rejects a tool_use with no answering tool_result).
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -772,10 +825,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
// Every tool_result for this turn is now recorded, including aborted
|
||||
// placeholders, so the images can follow without splitting them.
|
||||
messages = append(messages, toolImageMessages...)
|
||||
toolImageMessages = nil
|
||||
|
||||
// Run post-edit self-correction once over the union of files this turn
|
||||
// changed, then append any feedback after every tool_result is recorded so
|
||||
@@ -844,15 +893,31 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
// here, so a switch to a model with a different window keeps compacting
|
||||
// against the original budget. Fixing it needs the switcher to also
|
||||
// report the new window — out of scope for this change.
|
||||
// Re-derive the context window for the new model so compaction and
|
||||
// the OnContext budget report track the model actually in force —
|
||||
// without this a switch to a smaller-window model can overflow the
|
||||
// target, and a switch to a larger one over-compacts. An unknown
|
||||
// model (<= 0) keeps the original window. The compactor also
|
||||
// re-resolves its dedicated summarizer against the new model.
|
||||
window := 0
|
||||
if options.ContextWindowFor != nil {
|
||||
window = options.ContextWindowFor(turnRequestedModel)
|
||||
}
|
||||
if window > 0 {
|
||||
options.ContextWindow = window
|
||||
planner.config.contextWindow = window
|
||||
}
|
||||
compactor.switchModel(turnRequestedModel, window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_result, self-correct notice, and model-switch update for this
|
||||
// turn is now in place; evaluate and append image messages against the
|
||||
// effective (potentially escalated) model.
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
toolResultsWithImages = nil
|
||||
|
||||
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
|
||||
// calls executed above; surface the dropped call too so it is never
|
||||
// silently ignored just because the turn also did real work. This is
|
||||
@@ -1035,6 +1100,9 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
|
||||
safe := make([]ToolCall, len(calls))
|
||||
for i, call := range calls {
|
||||
safe[i] = call
|
||||
if call.Freeform {
|
||||
continue
|
||||
}
|
||||
args := strings.TrimSpace(call.Arguments)
|
||||
switch {
|
||||
case args == "":
|
||||
@@ -1118,8 +1186,28 @@ func decodeToolArguments(arguments string, v any) error {
|
||||
}
|
||||
|
||||
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if call.Freeform {
|
||||
if !toolFound || !tools.IsBuiltInApplyPatch(tool) {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Unsupported freeform tool call for " + call.Name + ".",
|
||||
}, nil
|
||||
}
|
||||
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
|
||||
if err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: Invalid freeform apply_patch input: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
args = prepared
|
||||
} else if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
@@ -1147,7 +1235,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
DenialReason: DenialFiltered,
|
||||
}, nil
|
||||
}
|
||||
tool, toolFound := registry.Get(call.Name)
|
||||
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
|
||||
modeName := string(permissionMode)
|
||||
return ToolResult{
|
||||
@@ -1493,6 +1580,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
@@ -1799,6 +1887,10 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
|
||||
}
|
||||
|
||||
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
|
||||
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
|
||||
// explicitly cross the same transcript/redaction boundary before its result
|
||||
// can be forwarded through ACP.
|
||||
result = tools.ScrubResultSecrets(result)
|
||||
output, outputRedacted := scrubInterceptedOutput(result.Output)
|
||||
display := result.Display
|
||||
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
|
||||
@@ -1826,6 +1918,7 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
@@ -2119,6 +2212,7 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
@@ -3210,11 +3304,15 @@ func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition,
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
definition := zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
if tools.IsBuiltInApplyPatch(tool) {
|
||||
definition.Type = zeroruntime.ToolDefinitionFreeform
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
@@ -3369,10 +3467,11 @@ func loadedToolsFromResult(meta map[string]string) []string {
|
||||
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
|
||||
for _, call := range remaining {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
IsError: true,
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
ToolCallProviderID: call.ProviderCallID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
@@ -3418,8 +3517,10 @@ func copyMessages(messages []Message) []Message {
|
||||
//
|
||||
// The text names the tool so the model can tell which call an image came from
|
||||
// when several ran in one turn — the images arrive detached from their tool
|
||||
// result, so nothing else associates them.
|
||||
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
// result, so nothing else associates them. If the effective model cannot
|
||||
// accept images, the tool's text result is left unchanged and this follow-up
|
||||
// is a notice with no image parts, matching the CLI/TUI vision gate.
|
||||
func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Message, bool) {
|
||||
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
|
||||
for _, image := range result.Images {
|
||||
if len(image.Data) == 0 {
|
||||
@@ -3440,9 +3541,26 @@ func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
if label == "" {
|
||||
label = "tool"
|
||||
}
|
||||
if !modelAcceptsToolImages(options) {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + " was not sent because the current model does not support image input. Switch to a vision-capable model before retrying image delivery, including any images skipped by the tool's image budget.",
|
||||
}, true
|
||||
}
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + ":",
|
||||
Images: images,
|
||||
}, true
|
||||
}
|
||||
|
||||
func modelAcceptsToolImages(options Options) bool {
|
||||
if options.SupportsVision != nil {
|
||||
return options.SupportsVision(options.Model)
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return modelregistry.VisionCapableByName(options.Model)
|
||||
}
|
||||
return modelregistry.SupportsVision(registry, options.Model)
|
||||
}
|
||||
@@ -27,6 +27,23 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
|
||||
Status: tools.StatusError,
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"),
|
||||
OldExists: true,
|
||||
NewExists: true,
|
||||
OldText: "token=" + secret,
|
||||
NewText: "safe",
|
||||
}},
|
||||
})
|
||||
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
|
||||
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
@@ -4096,7 +4113,7 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
|
||||
// caller's callback AND stamps token counters, and the run completes.
|
||||
func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 10, CompletionTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -4135,6 +4152,9 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
|
||||
t.Fatalf("cached input token counter = %d, want 20", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterCacheWriteTokens); got != 10 {
|
||||
t.Fatalf("cache-write token counter = %d, want 10", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
|
||||
t.Fatalf("output token counter = %d, want 40", got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// midStreamAbortProvider connects successfully but emits a transport-abort
|
||||
// StreamEventError on the first abortBefore calls, then succeeds with "done".
|
||||
// hangOnCall, if > 0, makes that 1-based call block until ctx is done so a
|
||||
// cancel during the retried CollectStream can be reproduced.
|
||||
// connectFailFrom, if > 0, makes that 1-based call and later return a
|
||||
// reconnectable connect error so streamWithReconnect enters its backoff.
|
||||
type midStreamAbortProvider struct {
|
||||
calls int32
|
||||
abortBefore int32
|
||||
abortError string
|
||||
partialText string
|
||||
emptyTextEvent bool
|
||||
partialToolCall string
|
||||
hangOnCall int32
|
||||
connectFailFrom int32
|
||||
connectErr string
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (p *midStreamAbortProvider) StreamCompletion(ctx context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
if p.connectFailFrom > 0 && n >= p.connectFailFrom {
|
||||
if p.started != nil {
|
||||
select {
|
||||
case <-p.started:
|
||||
default:
|
||||
close(p.started)
|
||||
}
|
||||
}
|
||||
errMsg := p.connectErr
|
||||
if errMsg == "" {
|
||||
errMsg = "connection reset by peer"
|
||||
}
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
if p.hangOnCall > 0 && n == p.hangOnCall {
|
||||
if p.started != nil {
|
||||
close(p.started)
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
if n <= p.abortBefore {
|
||||
if p.emptyTextEvent {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: ""}
|
||||
}
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
}
|
||||
if p.partialToolCall != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
|
||||
}
|
||||
errMsg := p.abortError
|
||||
if errMsg == "" {
|
||||
errMsg = "provider stream error: read: connection reset by peer"
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: errMsg,
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamConnectionReset(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream connection reset should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamWindowsAbort(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a Windows mid-stream abort should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// wsarecv alone (no "connection was aborted" substring) must still retry, so
|
||||
// deleting the wsarecv needle cannot go green.
|
||||
func TestRunRetriesMidStreamWsarecvNeedle(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: 10053",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("wsarecv without 'connection was aborted' should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryMidStreamAbortAfterPartialOutput(t *testing.T) {
|
||||
sawText := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialText: "partial"}
|
||||
_, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) { sawText++ },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a mid-stream abort after partial output must NOT be retried; want an error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("partial-then-abort must not retry, got %d calls", got)
|
||||
}
|
||||
if sawText == 0 {
|
||||
t.Fatal("OnText must have fired so forwardedVisibleText is the named guard")
|
||||
}
|
||||
}
|
||||
|
||||
// A zero-length OnText chunk is not committed answer prose, so an eligible
|
||||
// transport abort still retries.
|
||||
func TestRunRetriesMidStreamAbortAfterEmptyTextEvent(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1, emptyTextEvent: true}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty OnText must not block mid-stream abort retry, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamAbortAfterIncompleteToolCall(t *testing.T) {
|
||||
starts := 0
|
||||
dispatched := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialToolCall: "write_file"}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnToolCallStart: func(string, string) { starts++ },
|
||||
OnToolCallDelta: func(string, string) {},
|
||||
OnToolCall: func(ToolCall) { dispatched++ },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream abort mid-incomplete-tool-call should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
if starts != 1 {
|
||||
t.Fatalf("the incomplete tool call should have been forwarded once before the abort, got %d starts", starts)
|
||||
}
|
||||
if dispatched != 0 {
|
||||
t.Fatalf("OnToolCall must never fire for an incomplete aborted tool call (no dispatch), got %d", dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGivesUpAfterMaxMidStreamAbortRetries(t *testing.T) {
|
||||
if maxStreamStallRetries != 1 {
|
||||
t.Fatalf("maxStreamStallRetries = %d, want 1 (#973 must not raise this bound)", maxStreamStallRetries)
|
||||
}
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{abortBefore: 99}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a persistent mid-stream abort must surface an error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry); pin the bound at 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortUsesReconnectNotice(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
var notices string
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("transport abort must use reconnect wording, notices = %q", notices)
|
||||
}
|
||||
if strings.Contains(lower, "model stalled") {
|
||||
t.Fatalf("transport abort must not use stall wording, notices = %q", notices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringMidStreamRetryPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, hangOnCall: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for retried stream")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during retried stream")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during retried stream must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringReplacementConnectBackoffPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = 200 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, connectFailFrom: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for replacement connect failure")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during replacement connect backoff")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during replacement connect backoff must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortRecordsReconnectCountOnImmediateReplacement(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
rec := trace.NewRecorder("midstream-abort", "run-1", "test")
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
Trace: rec,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 immediate replacement), got %d", got)
|
||||
}
|
||||
gotTrace := rec.Finish()
|
||||
if got := gotTrace.Counter(trace.CounterReconnectCount); got != 1 {
|
||||
t.Fatalf("reconnect_count = %d, want 1 for a successful replacement connection", got)
|
||||
}
|
||||
if got := gotTrace.Counter(trace.CounterModelRequests); got != 2 {
|
||||
t.Fatalf("model_requests = %d, want 2 (initial + replacement)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryResponseHeaderTimeout(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 99,
|
||||
abortError: "net/http: timeout awaiting response headers",
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a response-header timeout must not be retried as a mid-stream abort")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("header timeout must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMidStreamTransportAbort(t *testing.T) {
|
||||
aborts := []string{
|
||||
"provider stream error: read: connection reset by peer",
|
||||
"provider stream error: unexpected EOF",
|
||||
"provider stream error: read: wsarecv: 10053",
|
||||
"provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
"provider stream error: read: connection closed",
|
||||
"write: broken pipe",
|
||||
"provider stream error: server closed the connection",
|
||||
"provider stream error: software caused connection abort",
|
||||
}
|
||||
for _, m := range aborts {
|
||||
if !isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("want mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
notAborts := []string{
|
||||
"",
|
||||
"context length exceeded",
|
||||
"rate limit error: slow down",
|
||||
"model not found",
|
||||
"net/http: timeout awaiting response headers",
|
||||
"dial tcp 10.0.0.1:443: connect: connection refused",
|
||||
"i/o timeout",
|
||||
"500 Internal Server Error",
|
||||
"502 Bad Gateway",
|
||||
"503 Service Unavailable",
|
||||
"504 Gateway Timeout",
|
||||
"provider request error: request does not satisfy oneOf schema",
|
||||
"provider request error: connection closed is not a supported finish reason",
|
||||
"provider error: connection closed is not a supported finish reason",
|
||||
"provider error: upstream connection reset by peer",
|
||||
"provider error: unexpected EOF while reading upstream response",
|
||||
"provider error: 502 Bad Gateway",
|
||||
"auth error: connection reset by peer",
|
||||
"rate limit error: server closed",
|
||||
"schema error: property oneOf is invalid",
|
||||
}
|
||||
for _, m := range notAborts {
|
||||
if isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("must NOT classify as mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryClassifiedProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: connection closed is not a supported finish reason",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("classified provider error with 'connection closed' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection closed") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider error: unexpected EOF while reading upstream response",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("provider error with 'unexpected EOF' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if got := err.Error(); got != p.abortError {
|
||||
t.Fatalf("error = %q, want original provider error %q", got, p.abortError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryApplicationErrorContainingEOFSubstring(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: request does not satisfy oneOf schema",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("application error with 'oneOf' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "oneOf") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamUnexpectedEOF(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: unexpected EOF",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected EOF should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("expected reconnect notice, got %q", notices)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,19 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
|
||||
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
|
||||
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
|
||||
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
|
||||
if custom.Type != "" {
|
||||
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
@@ -33,7 +35,24 @@ const (
|
||||
// The ratchet did its job: it caught the creep and forced a decision instead
|
||||
// of a drift. That is the point of it, so keep raising it deliberately rather
|
||||
// than reflexively.
|
||||
//
|
||||
// THIS COUNTS THE SCHEMAS ONLY, NOT THE HOST'S SHELL GUIDANCE. Until
|
||||
// 2026-09-07 it counted both, and that made it a different measurement on
|
||||
// every machine: exec_command appends shell guidance whose size depends on
|
||||
// which shell was detected, so the same tree measured 3498 on Linux, ~3600 on
|
||||
// a Windows host with pwsh 7, and 3653 on Windows PowerShell 5.1, which is
|
||||
// stock Windows. The 5.1 case has been over this ceiling since view_image
|
||||
// landed, so `go test ./...` failed for anyone on a stock Windows box while
|
||||
// CI stayed green, because the runners have pwsh 7 and take the cheaper
|
||||
// branch. A budget that passes or fails on which PowerShell happens to be
|
||||
// installed is not ratcheting anything.
|
||||
maxEagerToolSchemaTokens = 3650
|
||||
// The host-specific half, ratcheted on its own so it cannot creep either.
|
||||
// Measured 2026-09-07: Windows PowerShell 5.1 is the most expensive host at
|
||||
// 155 tokens (722 chars), pwsh 7 is 110, cmd.exe is 87, and every POSIX host
|
||||
// is 0 because no guidance is appended there. Set above the worst case, not
|
||||
// above whatever this machine happens to be.
|
||||
maxExecCommandShellGuidanceTokens = 200
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
@@ -56,8 +75,35 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
total := estimateToolDefTokens(exposed)
|
||||
// exec_command's description carries this host's shell guidance, and that
|
||||
// guidance is the one part of the eager set that is not the same everywhere.
|
||||
// Charged separately below rather than folded in here, or this ceiling would
|
||||
// mean a different thing on every machine. See the constants above.
|
||||
//
|
||||
// Removed from the description BEFORE estimating rather than subtracted from
|
||||
// the total afterwards. ApproxTextTokens is an integer division, so
|
||||
// floor((schema+guidance)/4) - floor(guidance/4) comes out one token high
|
||||
// whenever the two remainders add past four, and which side of that line a
|
||||
// host lands on depends on the length of its guidance: exactly the
|
||||
// host-dependence this number is supposed to be rid of.
|
||||
guidance := tools.HostExecCommandShellGuidance()
|
||||
schemas := make([]zeroruntime.ToolDefinition, len(exposed))
|
||||
copy(schemas, exposed)
|
||||
stripped := 0
|
||||
for index := range schemas {
|
||||
without := strings.TrimSuffix(schemas[index].Description, guidance)
|
||||
if without != schemas[index].Description {
|
||||
stripped++
|
||||
}
|
||||
schemas[index].Description = without
|
||||
}
|
||||
if guidance != "" && stripped != 1 {
|
||||
t.Fatalf("SETUP INVALID: this host's shell guidance came off %d descriptions, want exactly exec_command's; internal/tools pins it as that description's tail", stripped)
|
||||
}
|
||||
got := estimateToolDefTokens(schemas)
|
||||
guidanceTokens := ApproxTextTokens(guidance)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools (%d total, %d host shell guidance)", got, len(exposed), total, guidanceTokens)
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
// NOT "defer a tool": this test pins DeferThreshold at 0, so marking a
|
||||
// tool deferred leaves it exposed here and changes nothing. Deferral is
|
||||
@@ -66,9 +112,12 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
// deliberate raise.
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
if guidanceTokens > maxExecCommandShellGuidanceTokens {
|
||||
t.Fatalf("this host's exec_command shell guidance is %d tokens, over the %d ceiling — trim the guidance in internal/tools/shell_runtime.go or raise the ceiling deliberately", guidanceTokens, maxExecCommandShellGuidanceTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
|
||||
func TestAgentAdvertisesBothEditTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
@@ -79,9 +128,9 @@ func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
if !names["apply_patch"] {
|
||||
t.Fatal("agent must retain apply_patch for existing-file changes")
|
||||
t.Fatal("agent must retain apply_patch for multi-hunk changes")
|
||||
}
|
||||
if names["edit_file"] {
|
||||
t.Fatal("agent must not receive the ambiguous string-replacement tool")
|
||||
if !names["edit_file"] {
|
||||
t.Fatal("agent must receive edit_file for targeted exact replacements")
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,18 @@ func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSu
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
writeLengthPrefixed(&toolsSB, string(def.Type))
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
if def.Format != nil {
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Type)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
|
||||
writeLengthPrefixed(&schemaSB, def.Format.Definition)
|
||||
}
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,38 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
|
||||
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
|
||||
|
||||
typed := base
|
||||
typed.Type = zeroruntime.ToolDefinitionFreeform
|
||||
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
|
||||
t.Fatal("ToolsHash must change when tool Type changes")
|
||||
}
|
||||
|
||||
formatCases := []struct {
|
||||
name string
|
||||
format zeroruntime.ToolDefinitionFormat
|
||||
}{
|
||||
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
|
||||
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
|
||||
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
|
||||
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
|
||||
}
|
||||
for _, test := range formatCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withFormat := base
|
||||
withFormat.Format = &test.format
|
||||
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
|
||||
if got.SchemaHash == baseFingerprint.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change for %s format input", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
|
||||
@@ -144,6 +144,15 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"i/o timeout",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
// Windows mid-stream aborts (WSAECONNABORTED / wsarecv) — host or AV
|
||||
// forcibly closes an established socket during CollectStream.
|
||||
// "software caused connection abort" is the Unix/macOS ECONNABORTED
|
||||
// text; it also appears in midStreamAbortNeedles, which must stay a
|
||||
// subset of this list.
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
} {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
@@ -152,6 +161,86 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// midStreamAbortNeedles are the transport-abort classes retried AFTER connect
|
||||
// succeeded and the stream body has started. This is a subset of shouldReconnect:
|
||||
// connect-phase signals (timeout, connection refused, "temporarily unavailable")
|
||||
// stay on the connect retry path, because matching them here would re-prefill a
|
||||
// healthy-but-slow server (ollama cloud header timeouts) and tell the user the
|
||||
// connection was lost. Issue #973 names WSAECONNABORTED / connection reset /
|
||||
// unexpected stream EOF.
|
||||
var midStreamAbortNeedles = []string{
|
||||
"unexpected end",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"server closed",
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
}
|
||||
|
||||
var classifiedNonTransportPrefixes = []string{
|
||||
"provider request error:",
|
||||
"provider error:",
|
||||
"auth error:",
|
||||
"rate limit error:",
|
||||
}
|
||||
|
||||
// isMidStreamTransportAbort reports whether a collected stream error string is a
|
||||
// retryable mid-stream transport abort. It does NOT delegate to shouldReconnect:
|
||||
// that list is a connect-phase argument ("no response was received"). The stream
|
||||
// body has already started here, so only abort/reset/EOF/close classes retry.
|
||||
//
|
||||
// Used for failures DURING the stream body AFTER a successful connect and BEFORE
|
||||
// tool dispatch — the incomplete turn committed no answer text and executed no
|
||||
// tools, so a bounded re-issue is safe (same safety rules as the stall path).
|
||||
func isMidStreamTransportAbort(message string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowered == "" || isContextLimitError(lowered) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range classifiedNonTransportPrefixes {
|
||||
if strings.Contains(lowered, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if errhint.HasStatusCode(lowered, "500", "502", "503", "504") {
|
||||
return false
|
||||
}
|
||||
if containsWordBoundary(lowered, "eof") {
|
||||
return true
|
||||
}
|
||||
for _, needle := range midStreamAbortNeedles {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsWordBoundary(text, word string) bool {
|
||||
start := 0
|
||||
for {
|
||||
idx := strings.Index(text[start:], word)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
pos := start + idx
|
||||
endPos := pos + len(word)
|
||||
leftBoundary := pos == 0 || !isAlphaNumByte(text[pos-1])
|
||||
rightBoundary := endPos == len(text) || !isAlphaNumByte(text[endPos])
|
||||
if leftBoundary && rightBoundary {
|
||||
return true
|
||||
}
|
||||
start = pos + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isAlphaNumByte(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
}
|
||||
|
||||
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
|
||||
@@ -92,6 +92,12 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
// Windows socket aborts also remain reconnectable at connect time.
|
||||
"wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"read: connection was aborted",
|
||||
// Unix/macOS ECONNABORTED text.
|
||||
"read: software caused connection abort",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
|
||||
@@ -30,13 +30,10 @@ work.
|
||||
read-before-edit discipline: inspect the target file and nearby callers,
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** Use update_plan for implementation or investigation that has at
|
||||
least three meaningful dependent steps, then keep it current at milestones.
|
||||
Mark completed work and the next in-progress step together when practical;
|
||||
do not spend separate turns updating it after every file or command. Keep at
|
||||
most one item in_progress. Skip plans for simple lookups, explanations, code
|
||||
navigation, and other short tasks. Never create a plan after the work is
|
||||
already complete merely to describe what you did.
|
||||
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
|
||||
work. Skip it for bounded changes in one component. Update only when the
|
||||
overall phase changes, keep at most one item in_progress, and never create a
|
||||
plan after the work is complete.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
@@ -52,38 +49,48 @@ work.
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
write_file, apply_patch - over shelling out to
|
||||
edit_file, apply_patch, write_file - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Prefer read_minified_file when initially exploring source code; it preserves
|
||||
code while removing comments and redundant whitespace. Use read_file when
|
||||
exact text, comments, or line numbers are needed.
|
||||
- Use read_minified_file only to explore large or unfamiliar source. Use
|
||||
read_file for small or likely edit targets and exact text, comments, or line
|
||||
numbers. Do not read both unless a new need for exact content appears.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
|
||||
and enough unchanged context to identify the intended location. Match the
|
||||
existing indentation, imports, and idioms. Match the file's comment density:
|
||||
do not add explanatory comments unless the user asks or the code is already
|
||||
- For edits to existing files, use edit_file for a targeted change (old_string
|
||||
must match the file exactly and be unique, so include a few surrounding
|
||||
lines) and apply_patch when one coherent change spans several hunks or
|
||||
files. Use write_file only to create a file or when most of it changes; do
|
||||
not rewrite a whole file to change a few lines. Match the existing
|
||||
indentation, imports, and idioms. Match the file's comment density: do not
|
||||
add explanatory comments unless the user asks or the code is already
|
||||
comment-dense.
|
||||
- A successful edit result already confirms the change; do not re-read a file
|
||||
just to verify an edit that succeeded. If an edit fails, read the error, fix
|
||||
the old_string or hunk, and retry the same tool rather than switching to a
|
||||
full rewrite.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
over-built; if a 200-line solution could be 50, rewrite it. Once the required
|
||||
behavior passes validation, stop instead of adding optional cleanup.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
## Testing gate (mandatory)
|
||||
|
||||
- After any change to code, verify after edits by running the project's
|
||||
validators before you summarize or commit: tests, type-checks, linters, and/or
|
||||
the build, as appropriate. Scope them to the change while iterating; reserve
|
||||
full-suite runs for milestones.
|
||||
- If you are unsure which validators apply, search the repo (Makefile, package
|
||||
manifests, CI config) to find them.
|
||||
- After code changes, run the project's documented appropriate validators.
|
||||
Scope iteration checks, combine compatible commands when diagnostics stay
|
||||
useful, and run the final set once after the last edit. Rerun only after a
|
||||
change or failure.
|
||||
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
|
||||
do not invent a parallel flow.
|
||||
- Never claim a task is done, and never commit, while validators are failing. If
|
||||
they fail, fix the cause and rerun; do not paper over it. If you could not run
|
||||
a validator, say so explicitly rather than implying success.
|
||||
- A successful native patch confirms the edit. Reread only when the next change
|
||||
needs exact content or the result was ambiguous.
|
||||
|
||||
## Tool use
|
||||
|
||||
@@ -105,7 +112,7 @@ work.
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/apply_patch for file changes.
|
||||
with exec_command, or use edit_file/apply_patch/write_file for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
@@ -141,6 +148,7 @@ work.
|
||||
- Default to concise, skimmable output. Lead with the answer or the result.
|
||||
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
|
||||
code blocks for code, and `inline code` for file paths, commands, symbols, and
|
||||
short snippets. Reference code as `file:line` so it is clickable.
|
||||
short snippets. Reference code with file paths and include line numbers when
|
||||
already known; do not look them up solely for the final summary.
|
||||
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
|
||||
say so; when something is done and verified, state it plainly without hedging.
|
||||
@@ -60,14 +60,17 @@ const openAIPromptAddendum = `<model_guidance>
|
||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||
commands, and symbols.
|
||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||
write_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
file work. Make one tool call per file; do not batch file writes into a script.
|
||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
|
||||
file work. Use them directly; never run apply_patch through exec_command.
|
||||
read_file, edit_file and write_file take one file per call, while one coherent
|
||||
apply_patch may span several files. Keep independent native calls in the same
|
||||
turn, and do not batch file writes into a script.
|
||||
- Persist until the task is fully handled this turn: gather context, implement,
|
||||
run the validators, and report — do not stop at a partial result.
|
||||
</model_guidance>`
|
||||
|
||||
const geminiPromptAddendum = `<model_guidance>
|
||||
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
|
||||
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
|
||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||
- Be concise and concrete. When you run a shell command with side effects, state
|
||||
in one short clause why it is needed.
|
||||
|
||||
@@ -29,6 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
|
||||
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
|
||||
@@ -29,14 +29,17 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
|
||||
if strings.Contains(prompt, "clickable file paths") {
|
||||
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"read-before-edit",
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"for edits to existing files, prefer apply_patch",
|
||||
"for edits to existing files, use edit_file",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
@@ -44,6 +47,13 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"do not recognize",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
"skip it for bounded changes in one component",
|
||||
"do not read both unless a new need for exact content appears",
|
||||
"combine compatible commands when diagnostics stay useful",
|
||||
"do not invent a parallel flow",
|
||||
"a successful native patch confirms the edit",
|
||||
"once the required behavior passes validation, stop",
|
||||
"do not look them up solely for the final summary",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
|
||||
@@ -10,6 +10,24 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestNonVisionImageNoticeQualifiesBudgetRecovery(t *testing.T) {
|
||||
result := ToolResult{
|
||||
Name: "mcp_screenshot",
|
||||
Output: "One image exceeded this result's remaining image budget. Retrying with fewer images can recover this payload.",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("image")}},
|
||||
}
|
||||
notice, ok := toolResultImageMessage(result, Options{
|
||||
SupportsVision: func(string) bool { return false },
|
||||
})
|
||||
if !ok || len(notice.Images) != 0 {
|
||||
t.Fatal("non-vision delivery must produce a text-only notice")
|
||||
}
|
||||
if !strings.Contains(notice.Content, "Switch to a vision-capable model before retrying") ||
|
||||
!strings.Contains(notice.Content, "budget") {
|
||||
t.Fatalf("notice does not qualify budget recovery: %s", notice.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// imageTool returns a tool result carrying an image, the way a screenshot tool
|
||||
// should be able to.
|
||||
type imageTool struct {
|
||||
@@ -62,6 +80,7 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -86,11 +105,16 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
|
||||
// The tool-result pairing must be untouched: one tool result per tool call.
|
||||
toolResults := 0
|
||||
var toolText string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults++
|
||||
toolText = message.Content
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved", toolText)
|
||||
}
|
||||
if toolResults != 1 {
|
||||
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
|
||||
}
|
||||
@@ -136,7 +160,7 @@ func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2, Model: "gpt-4o"})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -205,3 +229,176 @@ func messageShape(messages []zeroruntime.Message) string {
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
|
||||
func TestRunDropsToolResultImagesForANonVisionModel(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "ok"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "totally-made-up-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var toolText string
|
||||
var noticed bool
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolText = message.Content
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
t.Fatalf("non-vision model received image bytes on %q: %s", message.Role, messageShape(result.Messages))
|
||||
}
|
||||
if strings.Contains(message.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved on the non-vision path", toolText)
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatalf("non-vision model was not told the image was dropped; recorded %s", messageShape(result.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
type switchAndCaptureTool struct {
|
||||
targetModel string
|
||||
}
|
||||
|
||||
func (switchAndCaptureTool) Name() string { return "switch_and_capture" }
|
||||
func (switchAndCaptureTool) Description() string { return "Switches model and captures image" }
|
||||
func (switchAndCaptureTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (switchAndCaptureTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t switchAndCaptureTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "[image returned by tool]",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("\x89PNG\r\n\x1a\nfake")}},
|
||||
Meta: map[string]string{"escalate_to_model": t.targetModel},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunToolImagesRespectsModelSwitch(t *testing.T) {
|
||||
t.Run("Non-vision to vision model escalation forwards images", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "gpt-4o"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "I see it on gpt-4o."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switched := false
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
switched = true
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "non-vision-initial",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !switched {
|
||||
t.Fatal("expected model switch to occur")
|
||||
}
|
||||
|
||||
var carrier *zeroruntime.Message
|
||||
for index := range result.Messages {
|
||||
if len(result.Messages[index].Images) > 0 {
|
||||
carrier = &result.Messages[index]
|
||||
}
|
||||
if strings.Contains(result.Messages[index].Content, "does not support image input") {
|
||||
t.Fatalf("images were unexpectedly dropped after switch to vision model: %v", result.Messages[index])
|
||||
}
|
||||
}
|
||||
if carrier == nil {
|
||||
t.Fatal("expected image carrier message after escalation to vision model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vision to non-vision model switch drops images with notice", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "text-only-dest"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "ok."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
var noticed bool
|
||||
var toolOutputSeen bool
|
||||
for _, m := range result.Messages {
|
||||
if len(m.Images) > 0 {
|
||||
t.Fatalf("image delivered to non-vision destination model: %v", m)
|
||||
}
|
||||
if strings.Contains(m.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
if strings.Contains(m.Content, "[image returned by tool]") {
|
||||
toolOutputSeen = true
|
||||
}
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatal("expected drop notice when switching to non-vision model")
|
||||
}
|
||||
if !toolOutputSeen {
|
||||
t.Fatal("expected tool output preserved in message history")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -84,6 +84,7 @@ type ToolResult struct {
|
||||
Images []zeroruntime.ImageBlock
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
FileDiffs []tools.FileDiff
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
@@ -340,6 +341,11 @@ type Options struct {
|
||||
// nil for text-only runs (the seeded message then carries no images, exactly
|
||||
// as before).
|
||||
Images []zeroruntime.ImageBlock
|
||||
// SupportsVision, when set, reports whether the effective model accepts
|
||||
// image input. Tool-produced images are dropped at the shared delivery
|
||||
// boundary when this is false. nil uses the curated catalog plus the
|
||||
// name heuristic via modelregistry.SupportsVision.
|
||||
SupportsVision func(modelID string) bool
|
||||
// ContextWindow is the model's maximum input token budget. When > 0 the agent
|
||||
// loop compacts long conversations once the estimated size crosses a fraction
|
||||
// of this window. 0 DISABLES compaction entirely (every existing caller/test
|
||||
@@ -348,10 +354,26 @@ type Options struct {
|
||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||
CompactionPreserveLast int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// Summarizer, when set, lazily builds the provider used for compaction
|
||||
// summarization calls — typically a cheap/fast model, since summaries at
|
||||
// main-model prices are the single most expensive recurring event in a
|
||||
// long run. It receives the main model currently in force and may return
|
||||
// (nil, nil) when no dedicated summarizer applies to it (the main model is
|
||||
// already the cheap one). Built on the first paid compaction and again
|
||||
// after a mid-run model switch. Any failure (build or call) falls back to
|
||||
// the run's main provider until the next switch, so a misconfigured
|
||||
// summarizer can never break compaction. nil keeps today's behavior.
|
||||
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
// ContextWindowFor, when set, resolves a model ID to its context window so
|
||||
// the compactor can re-derive its threshold after a mid-run model switch
|
||||
// (escalate_model). Without it a switch keeps compacting against the
|
||||
// original model's window — overflowing a smaller target or over-compacting
|
||||
// a larger one. Return <= 0 when the model is unknown (window unchanged).
|
||||
ContextWindowFor func(modelID string) int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// FileTracker records per-session file read/write versions so the write tools
|
||||
// can detect a file changed on disk outside Zero since it was last read. nil
|
||||
// disables the check. Created once per session and threaded into every tool run.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -45,6 +47,16 @@ func terminateProcess(pid int) error {
|
||||
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
||||
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
||||
//
|
||||
// The bool result is whether the leader had already exited before termination
|
||||
// was attempted — the POSIX analogue of Windows GetExitCodeProcess returning a
|
||||
// value other than STILL_ACTIVE. A waitable zombie is already-exited: Wait can
|
||||
// collect it, but kill(0) still succeeds, and when ps is unavailable
|
||||
// signalTargetRunning conservatively treats that as "still running".
|
||||
// TerminateCommand uses the flag to discard the resulting spurious
|
||||
// SIGKILL-timeout once the reap has independently succeeded. The flag is about
|
||||
// the leader only; group/tree termination is still invoked so live descendants
|
||||
// are signalled.
|
||||
//
|
||||
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
|
||||
// own convention. A command made its own session leader via Setsid (as opposed
|
||||
// to Setpgid) is also its own process-group leader in practice, but takes the
|
||||
@@ -52,8 +64,20 @@ func terminateProcess(pid int) error {
|
||||
// because TerminateCommand has exactly one caller in this codebase; worth
|
||||
// covering explicitly if Setsid-configured commands start using this path too.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
alreadyExited := leaderWaitableExited(cmd.Process.Pid)
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
||||
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
||||
return alreadyExited, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
return false, terminateProcess(cmd.Process.Pid)
|
||||
return alreadyExited, terminateProcess(cmd.Process.Pid)
|
||||
}
|
||||
|
||||
// terminationTargetGoneAfterReap independently checks the exact signal target
|
||||
// used for an owned command. In particular, reaping a zombie group leader does
|
||||
// not make this return true while any descendant still occupies that group.
|
||||
func terminationTargetGoneAfterReap(cmd *exec.Cmd) bool {
|
||||
target := cmd.Process.Pid
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
||||
target = -target
|
||||
}
|
||||
return errors.Is(syscall.Kill(target, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -220,6 +221,109 @@ func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandZombieLeaderWithoutPS(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
|
||||
}
|
||||
|
||||
grace, poll := terminationGracePeriod, terminationPollInterval
|
||||
terminationGracePeriod, terminationPollInterval = 150*time.Millisecond, 10*time.Millisecond
|
||||
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
|
||||
|
||||
// Issue #862: a zombie group leader with ps unresolvable used to burn both
|
||||
// grace periods (kill(0) succeeds against a zombie) and return
|
||||
// "did not exit after SIGKILL" even though cmd.Wait reaped exit status 0.
|
||||
// The already-exited probe must answer without ps so TerminateCommand can
|
||||
// discard that spurious timeout after a successful reap.
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
t.Cleanup(func() {
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !leaderWaitableExited(pid) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("leader %d did not become waitable-exited within 5s", pid)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", "")
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v, want nil (zombie leader with ps unavailable must not report SIGKILL timeout)", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("zombie leader was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandPreservesGroupFailureWithExitedLeaderAndLiveChild(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("waitable-exited leader probe is implemented on linux and darwin")
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read forked child pid: %v", err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse forked child pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !leaderWaitableExited(cmd.Process.Pid) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("leader %d did not become waitable-exited within 5s", cmd.Process.Pid)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
treeErr := errors.New("process group could not be terminated")
|
||||
originalTerminateOwnedProcess := terminateOwnedProcessForTest
|
||||
terminateOwnedProcessForTest = func(got *exec.Cmd) (bool, error) {
|
||||
if got != cmd {
|
||||
t.Fatalf("terminate called with %p, want %p", got, cmd)
|
||||
}
|
||||
return true, treeErr
|
||||
}
|
||||
t.Cleanup(func() { terminateOwnedProcessForTest = originalTerminateOwnedProcess })
|
||||
|
||||
err = TerminateCommand(cmd)
|
||||
if !errors.Is(err, treeErr) {
|
||||
t.Fatalf("TerminateCommand error = %v, want live-group termination failure", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("exited leader was not reaped")
|
||||
}
|
||||
if processStopped(childPID) {
|
||||
t.Fatalf("child %d unexpectedly stopped; failure boundary needs a live group member", childPID)
|
||||
}
|
||||
}
|
||||
|
||||
func processStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build darwin
|
||||
|
||||
package background
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// szomb is sys/proc.h SZOMB: the process has exited and is waiting to be reaped.
|
||||
const szomb int8 = 5
|
||||
|
||||
// leaderWaitableExited reports whether pid has already exited and is waiting
|
||||
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
|
||||
// returning a value other than STILL_ACTIVE: the leader is a zombie, waitable
|
||||
// as exited, without collecting it. A true result does not mean the process
|
||||
// group is empty — descendants may still be running.
|
||||
//
|
||||
// Darwin has no /proc and x/sys/unix does not wrap waitid here, so the probe
|
||||
// is kern.proc.pid via sysctl. It does not use ps, which is the #862 failure
|
||||
// mode when PATH cannot resolve ps.
|
||||
func leaderWaitableExited(pid int) bool {
|
||||
if pid <= 1 {
|
||||
return false
|
||||
}
|
||||
kinfo, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return kinfo.Proc.P_stat == szomb
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build linux
|
||||
|
||||
package background
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// POSIX waitid si_code values for an already-exited child (bits/waitflags.h).
|
||||
const (
|
||||
cldExited int32 = 1
|
||||
cldKilled int32 = 2
|
||||
cldDumped int32 = 3
|
||||
)
|
||||
|
||||
// leaderWaitableExited reports whether pid has already exited and is waiting
|
||||
// to be reaped. This is the POSIX analogue of Windows GetExitCodeProcess
|
||||
// returning a value other than STILL_ACTIVE: the leader is waitable as exited
|
||||
// (typically a zombie) without collecting it. A true result does not mean the
|
||||
// process group is empty — descendants may still be running.
|
||||
//
|
||||
// Detection does not use ps. waitid(WNOHANG|WNOWAIT) asks the kernel whether
|
||||
// an exit status is already available. /proc/<pid>/stat state Z is the
|
||||
// fallback when waitid cannot answer (for example if procfs is the only
|
||||
// usable source). Either path is independent of PATH, which is the #862
|
||||
// failure mode: empty PATH makes ps unresolvable and kill(0) still succeeds
|
||||
// against a zombie.
|
||||
func leaderWaitableExited(pid int) bool {
|
||||
if pid <= 1 {
|
||||
return false
|
||||
}
|
||||
if waitidAlreadyExited(pid) {
|
||||
return true
|
||||
}
|
||||
return procIsZombie(pid)
|
||||
}
|
||||
|
||||
func waitidAlreadyExited(pid int) bool {
|
||||
var info unix.Siginfo
|
||||
err := unix.Waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOHANG|unix.WNOWAIT, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch info.Code {
|
||||
case cldExited, cldKilled, cldDumped:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func procIsZombie(pid int) bool {
|
||||
data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// /proc/<pid>/stat: pid (comm) state ... — comm may contain spaces or
|
||||
// parentheses, so the state is the first field after the last ')'.
|
||||
i := bytes.LastIndexByte(data, ')')
|
||||
if i < 0 || i+1 >= len(data) {
|
||||
return false
|
||||
}
|
||||
rest := bytes.TrimSpace(data[i+1:])
|
||||
return len(rest) > 0 && rest[0] == 'Z'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package background
|
||||
|
||||
// leaderWaitableExited cannot positively identify a waitable-exited leader on
|
||||
// this platform without ps. Returning false keeps TerminateCommand's existing
|
||||
// conservative behaviour: a termination error is not discarded after reap.
|
||||
func leaderWaitableExited(pid int) bool {
|
||||
return false
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package background
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -46,7 +47,18 @@ func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
||||
// WithHandle refuses only a process that has already been waited or
|
||||
// released, so it has finished. Say so in the form exec.Cmd understands:
|
||||
// a Cancel error wrapping os.ErrProcessDone is "nothing to cancel", where
|
||||
// the unexported "already released" error it would otherwise carry made
|
||||
// Wait report a spurious cancel failure. A dead root has no tree left to
|
||||
// find either way.
|
||||
return true, fmt.Errorf("pin process %d for termination: %w: %w", cmd.Process.Pid, os.ErrProcessDone, err)
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
|
||||
// Windows has no persistent process-group identity to query after a dead root
|
||||
// is reaped. Preserve TerminateCommand's existing, documented dead-root
|
||||
// behavior; the stronger independent target check is available on POSIX.
|
||||
func terminationTargetGoneAfterReap(*exec.Cmd) bool { return true }
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
|
||||
const commandReapTimeout = 3 * time.Second
|
||||
|
||||
// terminateOwnedProcessForTest is a seam for the failure-boundary tests. Tests
|
||||
// replace it only while no other test in this package is running in parallel.
|
||||
var terminateOwnedProcessForTest = terminateOwnedProcess
|
||||
|
||||
// TerminateProcess stops a background process by PID — on Windows its process
|
||||
// tree; on POSIX its whole process group when the PID leads its own group (the
|
||||
// invariant ConfigureChildProcessGroup establishes for processes started through
|
||||
@@ -26,11 +30,15 @@ func TerminateProcess(pid int) error {
|
||||
// have exclusive ownership of cmd: it must not have previously called Wait or
|
||||
// Process.Release, and no goroutine may call either concurrently. On POSIX it
|
||||
// stops the whole group when the command was configured as its leader; ordinary
|
||||
// commands safely fall back to PID/tree discovery. On Windows, success for a
|
||||
// leader that was already dead confirms only that the leader was reaped;
|
||||
// descendants may survive because Windows cannot rediscover a tree from a dead
|
||||
// root. `zero daemon start` needs this operation when readiness times out: it
|
||||
// launched the child, so it must both stop the tree and collect the leader.
|
||||
// commands safely fall back to PID/tree discovery. When POSIX observes a
|
||||
// waitable-zombie leader before signalling, a termination error is discarded
|
||||
// after reap only if the launch-time signal target is independently gone; the
|
||||
// leader observation alone says nothing about descendants. Windows retains its
|
||||
// existing behavior of ignoring the expected kill-attempt error when
|
||||
// GetExitCodeProcess shows that the root was already dead. Windows cannot
|
||||
// rediscover a tree from a dead root, so descendants may survive there. `zero
|
||||
// daemon start` needs this operation when readiness times out: it launched the
|
||||
// child, so it must both stop the tree and collect the leader.
|
||||
//
|
||||
// The order matters: the tree is signalled first, because Wait releases the
|
||||
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
|
||||
@@ -44,7 +52,7 @@ func TerminateCommand(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return errors.New("terminate command: process was never started")
|
||||
}
|
||||
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
|
||||
leaderAlreadyExited, terminateErr := terminateOwnedProcessForTest(cmd)
|
||||
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
|
||||
if reapErr != nil {
|
||||
if terminateErr != nil {
|
||||
@@ -52,15 +60,42 @@ func TerminateCommand(cmd *exec.Cmd) error {
|
||||
}
|
||||
return reapErr
|
||||
}
|
||||
if terminateErr != nil && !leaderAlreadyExited {
|
||||
return terminateErr
|
||||
if terminateErr != nil {
|
||||
if !leaderAlreadyExited || !terminationTargetGoneAfterReap(cmd) {
|
||||
return terminateErr
|
||||
}
|
||||
}
|
||||
// Only discard a termination error when the platform demonstrated before
|
||||
// attempting termination that the leader had already exited. A successful
|
||||
// reap alone says nothing about whether a live descendant tree was stopped.
|
||||
// On POSIX, discard a termination error only when the leader was already
|
||||
// exited and the launch-time signal target is independently gone after reap.
|
||||
// A successful leader reap alone says nothing about live descendants.
|
||||
// Windows retains its existing dead-root semantics because it has no
|
||||
// persistent group identity to query after the root exits.
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminateCommandGroup stops a started command's process group on POSIX, or its
|
||||
// process tree on Windows, WITHOUT reaping it.
|
||||
//
|
||||
// It is TerminateCommand without the Wait, for a caller whose Wait already runs
|
||||
// somewhere else: a worker pool waiting on the command in its own goroutine, or
|
||||
// the command's own Cancel hook, which exec.Cmd invokes while its Wait is in
|
||||
// progress. TerminateCommand would be a second Wait there. Both used to pass
|
||||
// cmd.Process.Pid to TerminateProcess, which rediscovers the group at signal time
|
||||
// with Getpgid, and once the leader has exited that lookup fails, so only the dead
|
||||
// leader was signalled and its descendants kept running. When cmd was configured
|
||||
// by ConfigureChildProcessGroup the group now comes from that launch-time
|
||||
// configuration instead. On Windows it is the tree kill TerminateProcess performs,
|
||||
// with the process handle pinned while it runs so the PID cannot be recycled
|
||||
// underneath it; once the command has been waited that pin fails with an error
|
||||
// wrapping os.ErrProcessDone.
|
||||
func TerminateCommandGroup(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := terminateOwnedProcess(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
||||
// terminated is expected to report one (or a signal), so the only interesting
|
||||
// failure is Wait itself not working.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TerminateCommandGroup LEAVES THE WAIT TO ITS CALLER. Its callers already have a
|
||||
// Wait running or about to run: the daemon pool waits on its workers, and
|
||||
// exec.Cmd calls the Cancel hook from inside Wait. Reaping here, the way
|
||||
// TerminateCommand does, would make their Wait fail with "Wait was already
|
||||
// called" instead of reporting how the command ended.
|
||||
func TestTerminateCommandGroupLeavesTheWaitToTheCaller(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30")
|
||||
}
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
t.Fatalf("TerminateCommandGroup: %v", err)
|
||||
}
|
||||
|
||||
waited := make(chan error, 1)
|
||||
go func() { waited <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-waited:
|
||||
var exitErr *exec.ExitError
|
||||
if err != nil && !errors.As(err, &exitErr) {
|
||||
t.Fatalf("the caller's own Wait failed after TerminateCommandGroup: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatal("the command was still running 10s after TerminateCommandGroup")
|
||||
}
|
||||
}
|
||||
|
||||
// A command that was never started, or a nil one, is nothing to stop.
|
||||
func TestTerminateCommandGroupWithNoProcessIsANoOp(t *testing.T) {
|
||||
if err := TerminateCommandGroup(nil); err != nil {
|
||||
t.Fatalf("nil command: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(exec.Command("sleep", "1")); err != nil {
|
||||
t.Fatalf("unstarted command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// After the caller has waited, Windows cannot pin the process any more. The error
|
||||
// must still read as "already finished", which is what exec.Cmd's Cancel contract
|
||||
// and the pool both treat as nothing wrong.
|
||||
func TestTerminateCommandGroupAfterWaitReportsProcessDone(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("POSIX signals the group, which is simply gone after the wait")
|
||||
}
|
||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
t.Fatalf("after Wait the error is %v, want nil or one wrapping os.ErrProcessDone", err)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/acp"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
|
||||
@@ -57,20 +59,8 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
NewProvider: deps.newProvider,
|
||||
RunAgent: agent.Run,
|
||||
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
|
||||
// exec surface, so ACP shell/file tools are confined — never run unconfined.
|
||||
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
return registry, engine, nil
|
||||
BuildWorkspace: func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*acp.Workspace, error) {
|
||||
return buildACPWorkspace(ctx, workspaceRoot, resolved, mode, deps)
|
||||
},
|
||||
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
||||
Store: deps.newSessionStore(),
|
||||
@@ -85,6 +75,76 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// buildACPWorkspace matches exec's registry construction for one ACP turn. MCP
|
||||
// servers use the same sandbox-prepared execution runner, project configuration is
|
||||
// gated by the validated workspace's trust state, and their runtime is released
|
||||
// when acp.Agent completes the turn. ACP deliberately stays at low autonomy: an
|
||||
// editor connection never upgrades MCP permissions beyond an interactive prompt.
|
||||
func buildACPWorkspace(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode, deps appDeps) (*acp.Workspace, error) {
|
||||
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
|
||||
workspace := &acp.Workspace{
|
||||
Registry: registry, Sandbox: engine,
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
}
|
||||
if mode != agent.PermissionModePlan {
|
||||
// MCP stdio servers are subprocesses. Passing the engine to their runner
|
||||
// keeps them inside the exact sandbox / lifecycle path used by zero exec.
|
||||
runtime, trustSkip, err := registerMCPToolsForWorkspaceWithOptions(
|
||||
ctx, workspaceRoot, registry, deps, mcp.AutonomyLow, workspaceRoot,
|
||||
mcp.RegisterOptions{Execution: execution.NewRunner(engine), AdvertiseInAuto: true},
|
||||
)
|
||||
if err != nil {
|
||||
// RegisterTools may have connected an earlier server before reporting a
|
||||
// later failure, so do not orphan a partial runtime on this error path.
|
||||
if runtime != nil {
|
||||
_ = runtime.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workspace.Cleanup = runtime.Close
|
||||
workspace.Notices = acpMCPSetupNotices(trustSkip, runtime)
|
||||
}
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
// MCP tools are deferred-eligible. Register their loader only after every
|
||||
// ACP-visible tool is present, using the same mode the agent receives.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, mode, nil, nil)
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func acpMCPSetupNotices(skip trustSkip, runtime mcpToolRuntime) []string {
|
||||
var notices []string
|
||||
if skip.excludedProjectConfig {
|
||||
if skip.trustCheckErrored {
|
||||
notices = append(notices, "The workspace-trust store could not be read; project MCP servers were ignored (fail-closed). Run 'zero trust' to enable them.")
|
||||
} else {
|
||||
notices = append(notices, "Project MCP servers were ignored in this untrusted workspace. Run 'zero trust' to enable them.")
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
return notices
|
||||
}
|
||||
for _, skipped := range runtime.Skipped() {
|
||||
if skipped.UnconfiguredDefault {
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("MCP server %s unavailable, skipped", skipped.Name)
|
||||
if skipped.Err != nil {
|
||||
message += ": " + redaction.ErrorMessage(skipped.Err, redaction.Options{})
|
||||
}
|
||||
notices = append(notices, redaction.RedactString(message, redaction.Options{}))
|
||||
}
|
||||
return notices
|
||||
}
|
||||
|
||||
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
|
||||
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
|
||||
// dir) and additionally rejects the filesystem root and the home directory — an
|
||||
|
||||
@@ -5,10 +5,19 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
type acpTestReader func([]byte) (int, error)
|
||||
@@ -108,6 +117,198 @@ func TestRunACPIdleCancellationExitsCleanly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspaceRegistersSandboxedMCPToolsAndClosesThem(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
if err := workspacetrust.Trust(workspaceRoot); err != nil {
|
||||
t.Fatalf("trust workspace: %v", err)
|
||||
}
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("new grant store: %v", err)
|
||||
}
|
||||
|
||||
var gotExclude, registered, closed bool
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(root string, excludeProject bool) (config.MCPConfig, error) {
|
||||
if root != workspaceRoot {
|
||||
t.Fatalf("MCP workspace root = %q, want %q", root, workspaceRoot)
|
||||
}
|
||||
gotExclude = excludeProject
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "fake-docs"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
if ctx == nil {
|
||||
t.Fatal("MCP registration received nil context")
|
||||
}
|
||||
if len(cfg.Servers) != 1 || cfg.Servers["docs"].Command != "fake-docs" {
|
||||
t.Fatalf("MCP config = %+v", cfg)
|
||||
}
|
||||
if options.Autonomy != mcp.AutonomyLow {
|
||||
t.Fatalf("MCP autonomy = %q, want low", options.Autonomy)
|
||||
}
|
||||
if options.Execution == nil {
|
||||
t.Fatal("MCP stdio server was not given the sandbox execution runner")
|
||||
}
|
||||
if options.WorkspaceRoot != workspaceRoot {
|
||||
t.Fatalf("MCP execution workspace = %q, want %q", options.WorkspaceRoot, workspaceRoot)
|
||||
}
|
||||
if !options.AdvertiseInAuto {
|
||||
t.Fatal("ACP MCP tools must be advertised in auto mode without being pre-approved")
|
||||
}
|
||||
registered = true
|
||||
registry.Register(cliFakeDeferredTool{
|
||||
name: "mcp_docs_search", permission: tools.PermissionPrompt,
|
||||
advertiseInAuto: options.AdvertiseInAuto,
|
||||
})
|
||||
return closeFunc(func() error {
|
||||
closed = true
|
||||
return nil
|
||||
}), nil
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{
|
||||
Tools: config.ToolsConfig{DeferThreshold: 1},
|
||||
}, agent.PermissionModeAuto, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("buildACPWorkspace: %v", err)
|
||||
}
|
||||
if gotExclude {
|
||||
t.Fatal("trusted ACP workspace excluded project MCP configuration")
|
||||
}
|
||||
if !registered {
|
||||
t.Fatal("ACP workspace did not register configured MCP tools")
|
||||
}
|
||||
if _, ok := workspace.Registry.Get("mcp_docs_search"); !ok {
|
||||
t.Fatal("MCP tool is absent from ACP agent registry")
|
||||
}
|
||||
if _, ok := workspace.Registry.Get(tools.ToolSearchToolName); !ok {
|
||||
t.Fatal("ACP registry is missing tool_search for deferred MCP tools")
|
||||
}
|
||||
if workspace.DeferThreshold != 1 {
|
||||
t.Fatalf("workspace defer threshold = %d, want 1", workspace.DeferThreshold)
|
||||
}
|
||||
if err := workspace.Close(); err != nil {
|
||||
t.Fatalf("close ACP workspace: %v", err)
|
||||
}
|
||||
if !closed {
|
||||
t.Fatal("ACP workspace did not close its MCP runtime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspacePreservesRedactedMCPSetupNotices(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
workspaceRoot := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspaceRoot, ".zero"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectConfig := `{"mcp":{"servers":{"project-docs":{"type":"stdio","command":"project-docs"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(workspaceRoot, ".zero", "config.json"), []byte(projectConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "fake-docs"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{{
|
||||
Name: "docs", Err: errors.New("token=" + secret),
|
||||
}}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), workspaceRoot, config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
joined := strings.Join(workspace.Notices, "\n")
|
||||
if !strings.Contains(joined, "untrusted workspace") || !strings.Contains(joined, "MCP server docs unavailable") {
|
||||
t.Fatalf("workspace notices = %#v", workspace.Notices)
|
||||
}
|
||||
if strings.Contains(joined, secret) || !strings.Contains(joined, "[REDACTED]") {
|
||||
t.Fatalf("workspace notices leaked secret: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspaceSkipsMCPInPlanMode(t *testing.T) {
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("new grant store: %v", err)
|
||||
}
|
||||
called := false
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
called = true
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModePlan, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("buildACPWorkspace: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("plan-mode ACP workspace must not resolve or start MCP servers")
|
||||
}
|
||||
if err := workspace.Close(); err != nil {
|
||||
t.Fatalf("close plan workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildACPWorkspaceClosesPartialMCPRuntimeOnRegistrationError(t *testing.T) {
|
||||
grantStore, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: t.TempDir() + "/grants.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("new grant store: %v", err)
|
||||
}
|
||||
closed := false
|
||||
deps := fillAppDeps(appDeps{
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"partial": {Type: "stdio", Command: "fake-partial"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return grantStore, nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return closeFunc(func() error {
|
||||
closed = true
|
||||
return nil
|
||||
}), errors.New("second MCP server failed")
|
||||
},
|
||||
})
|
||||
|
||||
workspace, err := buildACPWorkspace(context.Background(), t.TempDir(), config.ResolvedConfig{}, agent.PermissionModeAuto, deps)
|
||||
if err == nil {
|
||||
t.Fatal("buildACPWorkspace succeeded after MCP registration error")
|
||||
}
|
||||
if workspace != nil {
|
||||
t.Fatal("buildACPWorkspace returned a workspace after MCP registration error")
|
||||
}
|
||||
if !closed {
|
||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
||||
}
|
||||
}
|
||||
|
||||
type acpNotifyingReadCloser struct {
|
||||
io.ReadCloser
|
||||
readStarted chan<- struct{}
|
||||
|
||||
@@ -311,40 +311,35 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
|
||||
modelregistry.EnableModelsDevOverlay()
|
||||
|
||||
addDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
// --add-dir, --theme and --allow-escalation may be written in any order;
|
||||
// see splitLeadingRootFlags for why they are split together.
|
||||
root, args, err := splitLeadingRootFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme <name> selects the TUI palette non-interactively (auto or any registered
|
||||
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
|
||||
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
|
||||
theme, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
moreDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
addDirs = append(addDirs, moreDirs...)
|
||||
addDirs, theme, allowEscalation := root.addDirs, root.theme, root.allowEscalation
|
||||
|
||||
if len(args) == 0 {
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme)
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme, allowEscalation)
|
||||
}
|
||||
|
||||
// --add-dir grants an extra write root, and only the interactive TUI and
|
||||
// exec dispatch paths consume one. Fail loud everywhere else rather than
|
||||
// silently discarding an explicit grant — including help/version, which
|
||||
// run no agent and could only ignore it. The allowlist names exactly the
|
||||
// cases below that forward addDirs; a future subcommand is rejected by
|
||||
// default until it opts in here.
|
||||
if len(addDirs) > 0 {
|
||||
switch args[0] {
|
||||
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
|
||||
// Forwarded by the matching case below.
|
||||
default:
|
||||
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
|
||||
}
|
||||
// --add-dir grants an extra write root and --allow-escalation opts a run
|
||||
// into mid-run model escalation; only the interactive TUI and exec consume
|
||||
// either. Both are forwarded to exec below and rejected loudly everywhere
|
||||
// else rather than silently discarded, including help/version, which run
|
||||
// no agent and could only ignore them. The allowlist names exactly the
|
||||
// cases below that forward; a future subcommand is rejected by default
|
||||
// until it opts in here.
|
||||
forwardsRootFlags := false
|
||||
switch args[0] {
|
||||
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
|
||||
forwardsRootFlags = true
|
||||
}
|
||||
if len(addDirs) > 0 && !forwardsRootFlags {
|
||||
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
|
||||
}
|
||||
if allowEscalation && !forwardsRootFlags {
|
||||
return writeAppError(stderr, "--allow-escalation is only supported for the interactive TUI and exec", 1)
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
@@ -354,26 +349,24 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
// reach unsafe mode in the shell — and the "!" shell escape (which is
|
||||
// gated behind unsafe) was therefore unreachable.
|
||||
//
|
||||
// --add-dir may legally appear on either side of the flag, so re-split
|
||||
// the remaining args and merge with the dirs already collected. Any
|
||||
// trailing non-flag args were ignored on this path before --add-dir
|
||||
// existed and still are — but an --add-dir hidden BEHIND one would be
|
||||
// silently dropped with them, so reject that misplacement loudly.
|
||||
moreDirs, rest, err := splitLeadingAddDirFlags(args[1:])
|
||||
// The root flags may legally appear on either side of this flag, so
|
||||
// split them again from what follows it and merge with what the root
|
||||
// already took. Any trailing non-flag args were ignored on this path
|
||||
// before --add-dir existed and still are, but an --add-dir hidden
|
||||
// BEHIND one would be silently dropped with them, so reject that
|
||||
// misplacement loudly below.
|
||||
more, rest, err := splitLeadingRootFlags(args[1:])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme may appear here too; extract it before the stray-arg checks so it is
|
||||
// not rejected as an unexpected positional, then re-split --add-dir after it.
|
||||
skipTheme, rest, err := splitLeadingThemeFlag(rest)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
moreDirs := more.addDirs
|
||||
// A --theme written before the flag was taken at the root and used to be
|
||||
// dropped here; one written after it wins, as the last occurrence does.
|
||||
skipTheme := theme
|
||||
if more.theme != "" {
|
||||
skipTheme = more.theme
|
||||
}
|
||||
evenMoreDirs, rest, err := splitLeadingAddDirFlags(rest)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
moreDirs = append(moreDirs, evenMoreDirs...)
|
||||
skipAllowEscalation := more.allowEscalation
|
||||
// A misplaced --add-dir anywhere in the remainder is the more specific error,
|
||||
// so check for it across all of rest before rejecting stray args.
|
||||
for _, arg := range rest {
|
||||
@@ -390,7 +383,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return writeAppError(stderr, "--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`", 1)
|
||||
}
|
||||
}
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme)
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme, allowEscalation || skipAllowEscalation)
|
||||
case "-h", "--help", "help":
|
||||
if err := writeHelp(stdout); err != nil {
|
||||
return 1
|
||||
@@ -423,16 +416,16 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if len(args) < 2 {
|
||||
return writePromptRequired(stderr)
|
||||
}
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
// Forward the root flags exec consumes so its own parser collects them.
|
||||
// Use the inline --prompt=<value> form so a prompt whose first character is a
|
||||
// dash (e.g. `zero -p "-foo"`) is taken verbatim instead of being mistaken for
|
||||
// a flag and rejected with "--prompt requires a value" (matches the cron path).
|
||||
execArgs := append(addDirFlagArgs(addDirs), "--prompt="+args[1])
|
||||
execArgs := append(rootFlagArgs(addDirs, allowEscalation), "--prompt="+args[1])
|
||||
execArgs = append(execArgs, args[2:]...)
|
||||
return runExec(execArgs, stdout, stderr, deps)
|
||||
case "exec":
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||
// Forward the root flags exec consumes so its own parser collects them.
|
||||
return runExec(append(rootFlagArgs(addDirs, allowEscalation), args[1:]...), stdout, stderr, deps)
|
||||
case "completions":
|
||||
return runCompletions(args[1:], stdout, stderr)
|
||||
case "daemon":
|
||||
@@ -694,15 +687,15 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
return deps
|
||||
}
|
||||
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false)
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, allowEscalation bool) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false, allowEscalation)
|
||||
}
|
||||
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool, allowEscalation bool) int {
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
@@ -734,7 +727,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
// Fresh-onboarding reset, but the user's notification preference is
|
||||
// NOT config-to-redo: clearing it here would surface as an empty
|
||||
// Options.Notify, and the TUI's unconfigured default (both/unfocused)
|
||||
// would then resurrect alerts a user explicitly turned off while the
|
||||
// setup wizard runs (maintainer review, PR #1001). Carry the stored
|
||||
// block through; other wizard-visible state starts clean as before.
|
||||
resolved = config.ResolvedConfig{Notify: resolved.Notify}
|
||||
forceSetup = true
|
||||
}
|
||||
}
|
||||
@@ -790,6 +789,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
// Mid-run model escalation is opt-in on this surface too. The tool is present
|
||||
// only when the operator asked for it with --allow-escalation, and the
|
||||
// switchers that make it do anything ride on the same flag through
|
||||
// Options.AllowEscalation below. Registering one without the other ships a
|
||||
// tool the loop will never act on, which looks like a feature and is not.
|
||||
if allowEscalation {
|
||||
registry.Register(tools.NewEscalateModelTool())
|
||||
}
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
@@ -839,18 +846,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
mcpTokenStore = nil
|
||||
err = nil
|
||||
}
|
||||
criticalMCPConfig, optionalMCPConfig := splitMCPStartupConfig(mcpConfig)
|
||||
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
|
||||
if len(mcpConfig.Servers) > 0 {
|
||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
||||
if len(criticalMCPConfig.Servers) > 0 {
|
||||
runtime, registerErr := deps.registerMCPTools(context.Background(), registry, criticalMCPConfig, mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
closeMCPRuntime(stderr, mcpRuntime)
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
if registerErr != nil {
|
||||
closeMCPRuntime(stderr, runtime)
|
||||
return writeAppError(stderr, redaction.ErrorMessage(registerErr, redaction.Options{}), 1)
|
||||
}
|
||||
mcpRuntime = runtime
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
@@ -909,11 +918,35 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
// Activate deferred MCP-tool loading for the interactive run only when the
|
||||
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
||||
// The registry is complete (core + specialist + MCP + plugins) here, so the
|
||||
// count is accurate; below threshold this is a no-op and the surface is
|
||||
// unchanged. The interactive surface applies no operator tool filters, so
|
||||
// enabled/disabled are nil — matching the AgentOptions below.
|
||||
// This first pass covers every prompt-critical tool. Optional built-in MCP
|
||||
// defaults re-run the same gate after publishing their atomic batch, so a
|
||||
// later catalog generation cannot miss its loader. The interactive surface
|
||||
// applies no operator tool filters, so enabled/disabled are nil — matching the
|
||||
// AgentOptions below.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
var optionalMCPRuntime *optionalMCPStartup
|
||||
var awaitToolReadiness func(context.Context)
|
||||
if len(optionalMCPConfig.Servers) > 0 {
|
||||
optionalMCPRuntime = startOptionalMCP(
|
||||
context.Background(),
|
||||
registry,
|
||||
optionalMCPConfig,
|
||||
mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
},
|
||||
deps.registerMCPTools,
|
||||
func() {
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
},
|
||||
)
|
||||
defer closeMCPRuntime(stderr, optionalMCPRuntime)
|
||||
awaitToolReadiness = func(ctx context.Context) {
|
||||
optionalMCPRuntime.Await(ctx, optionalMCPPromptGrace)
|
||||
}
|
||||
}
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
@@ -932,7 +965,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
// From the engine the session's commands run through, so the notice
|
||||
// describes the sandbox they actually get.
|
||||
startupNotices := []string{sandboxEngine.DegradedNotice()}
|
||||
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
ModelRPM: modelRPM,
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
@@ -948,10 +986,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecentModels: resolved.Preferences.RecentModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
CompactionModel: resolved.Preferences.CompactionModel,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.ConfiguredTurnSessions(profile, provider, providers.Options{ModelRPM: modelRPM}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
},
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
PrepareRunCompletionWarning: func() {
|
||||
scratchBaseline = scratchFileSnapshot(workspaceRoot)
|
||||
},
|
||||
@@ -959,6 +1007,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
||||
},
|
||||
Registry: registry,
|
||||
AwaitToolReadiness: awaitToolReadiness,
|
||||
SessionStore: sessionStore,
|
||||
PeerService: peerService,
|
||||
SandboxStore: sandboxStore,
|
||||
@@ -996,6 +1045,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
AllowEscalation: allowEscalation,
|
||||
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
|
||||
// It resolves against the same merged set (default dir + plugin skill
|
||||
// roots) as the skill tool and the system-prompt list, re-read per use so
|
||||
@@ -1007,6 +1057,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
StartupNotices: startupNotices,
|
||||
STT: resolved.STT,
|
||||
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
|
||||
ShutdownDictationServer: sttServerManager.Shutdown,
|
||||
@@ -1341,6 +1392,7 @@ Flags:
|
||||
-v, --version Print version
|
||||
-p, --prompt Run a one-shot prompt
|
||||
--add-dir <path> Allow writes in an extra directory (repeatable)
|
||||
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
|
||||
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
|
||||
`)
|
||||
return err
|
||||
@@ -1357,6 +1409,17 @@ func addDirFlagArgs(addDirs []string) []string {
|
||||
return flags
|
||||
}
|
||||
|
||||
// rootFlagArgs re-synthesises the root flags exec consumes, in the spelling
|
||||
// its own parser accepts, so a flag written before the subcommand reaches
|
||||
// the run exactly as one written after it would.
|
||||
func rootFlagArgs(addDirs []string, allowEscalation bool) []string {
|
||||
flags := addDirFlagArgs(addDirs)
|
||||
if allowEscalation {
|
||||
flags = append(flags, "--allow-escalation")
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// splitLeadingAddDirFlags strips leading --add-dir flags from the root
|
||||
// argument list (zero --add-dir <path> [--add-dir <path>] [subcommand …]).
|
||||
// Subcommands like exec parse their own --add-dir occurrences.
|
||||
@@ -1397,6 +1460,83 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
||||
return addDirs, args, nil
|
||||
}
|
||||
|
||||
// rootFlags is what the leading root flags amount to once every one of them
|
||||
// has been stripped from the front of the argument list.
|
||||
type rootFlags struct {
|
||||
addDirs []string
|
||||
theme string
|
||||
allowEscalation bool
|
||||
}
|
||||
|
||||
// splitLeadingRootFlags strips --add-dir, --theme and --allow-escalation from
|
||||
// the front of args in whatever order they were written, stopping at the
|
||||
// first token none of them claims.
|
||||
//
|
||||
// EACH SPLITTER STOPS AT THE FIRST TOKEN IT DOES NOT OWN, so running them once
|
||||
// in a fixed sequence made the order the operator wrote them in load-bearing:
|
||||
// a flag handled late in the sequence stranded every flag written after it as
|
||||
// an unknown command, and `zero --allow-escalation --theme auto` exited with
|
||||
// an argument error instead of launching. Running the sequence until it makes
|
||||
// no progress accepts every ordering, including a flag repeated on both sides
|
||||
// of another.
|
||||
func splitLeadingRootFlags(args []string) (rootFlags, []string, error) {
|
||||
var flags rootFlags
|
||||
for {
|
||||
before := len(args)
|
||||
addDirs, rest, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return rootFlags{}, nil, err
|
||||
}
|
||||
flags.addDirs = append(flags.addDirs, addDirs...)
|
||||
theme, rest, err := splitLeadingThemeFlag(rest)
|
||||
if err != nil {
|
||||
return rootFlags{}, nil, err
|
||||
}
|
||||
if theme != "" {
|
||||
// The last occurrence wins across passes, as it does within one.
|
||||
flags.theme = theme
|
||||
}
|
||||
allowEscalation, rest, err := splitLeadingAllowEscalationFlag(rest)
|
||||
if err != nil {
|
||||
return rootFlags{}, nil, err
|
||||
}
|
||||
flags.allowEscalation = flags.allowEscalation || allowEscalation
|
||||
args = rest
|
||||
if len(args) == before {
|
||||
return flags, args, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitLeadingAllowEscalationFlag strips a leading --allow-escalation from the
|
||||
// root argument list, opting the interactive session into mid-run model
|
||||
// escalation.
|
||||
//
|
||||
// OPT-IN, THE SAME WAY exec IS. Escalation moves a run onto a different model,
|
||||
// which changes what the run costs and which provider sees the conversation, so
|
||||
// it is a decision the operator makes rather than a default. The exec flag
|
||||
// already answers this conservatively and the interactive surface should not
|
||||
// answer it differently.
|
||||
//
|
||||
// Bare flag only: repeating it is harmless, and an =value form is rejected so a
|
||||
// mistyped --allow-escalation=false is a loud error instead of silently enabling
|
||||
// the thing it was trying to turn off.
|
||||
func splitLeadingAllowEscalationFlag(args []string) (bool, []string, error) {
|
||||
allow := false
|
||||
for len(args) > 0 {
|
||||
switch {
|
||||
case args[0] == "--allow-escalation":
|
||||
allow = true
|
||||
args = args[1:]
|
||||
case strings.HasPrefix(args[0], "--allow-escalation="):
|
||||
return false, nil, errors.New("--allow-escalation takes no value; pass it bare to enable mid-run model escalation, or omit it")
|
||||
default:
|
||||
return allow, args, nil
|
||||
}
|
||||
}
|
||||
return allow, args, nil
|
||||
}
|
||||
|
||||
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
|
||||
// from the root argument list and validates it against the registered themes. The
|
||||
// last occurrence wins. A value outside the allowed set is a loud error rather than
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -342,8 +343,10 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
ModelRPM: map[string]int{"gpt-test": 1},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
@@ -373,6 +376,77 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
|
||||
}
|
||||
if launchedOptions.ModelRPM == nil {
|
||||
t.Fatal("ModelRPM = nil, want limiter preserved through provider fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a stored explicit notify opt-out must
|
||||
// survive the provider-recovery startup path. With a stale activeProvider and
|
||||
// another usable saved provider, Resolve returns the partial config alongside
|
||||
// ErrNoActiveProvider and the recovery branch forwards it — dropping the
|
||||
// notify policy there would surface an empty Options.Notify, and the TUI's
|
||||
// unconfigured default (both) would resurrect alerts the user explicitly
|
||||
// turned off. The same recovery branch that clears the resolved config for
|
||||
// the setup wizard carries the stored notify block through.
|
||||
func TestRunNoArgsPreservesNotifyOptOutThroughProviderRecovery(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Stale activeProvider + usable saved provider, and the user's
|
||||
// config.json explicitly opts out of notifications. The fixed
|
||||
// resolver carries the parsed notify block through the error path.
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
Notify: config.NotifyConfig{Mode: "off", FocusMode: "always"},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &cliFakeProvider{}, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
// The explicit opt-out reaches the TUI intact — NOT the both/unfocused
|
||||
// unconfigured default.
|
||||
if launchedOptions.Notify.Mode != "off" || launchedOptions.Notify.FocusMode != "always" {
|
||||
t.Fatalf("Options.Notify = %+v, want the stored off/always opt-out preserved through recovery", launchedOptions.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +560,74 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsPaintsBeforeOptionalDefaultMCPIsReady(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPermissionStore() error = %v", err)
|
||||
}
|
||||
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
configResult := make(chan error, 1)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
userConfigPath: func() (string, error) { return userConfigPath, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: config.DefaultMCPServers()}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return permissionStore, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return tokenStore, nil },
|
||||
registerMCPTools: func(_ context.Context, registry *tools.Registry, cfg config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
var configErr error
|
||||
if _, ok := cfg.Servers["exa"]; !ok || len(cfg.Servers) != 1 {
|
||||
configErr = fmt.Errorf("optional MCP config = %#v, want only exa", cfg.Servers)
|
||||
}
|
||||
configResult <- configErr
|
||||
close(started)
|
||||
<-release
|
||||
registry.Register(cliFakeMCPRegistryTool{})
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("optional MCP initialization did not start")
|
||||
}
|
||||
if options.AwaitToolReadiness == nil {
|
||||
t.Fatal("TUI did not receive optional tool readiness barrier")
|
||||
}
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); ok {
|
||||
t.Fatal("optional MCP tool was published before registration completed")
|
||||
}
|
||||
close(release)
|
||||
options.AwaitToolReadiness(ctx)
|
||||
if _, ok := options.Registry.Get("mcp_docs_lookup"); !ok {
|
||||
t.Fatal("optional MCP tool was not visible after readiness completed")
|
||||
}
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if configErr := <-configResult; configErr != nil {
|
||||
t.Fatal(configErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -565,6 +707,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
runtimeClosed := false
|
||||
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
@@ -591,7 +734,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
return closeFunc(func() error {
|
||||
runtimeClosed = true
|
||||
return nil
|
||||
}), errors.New("register mcp tools failed")
|
||||
}), fmt.Errorf("register mcp tools failed: %s", secret)
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
t.Fatal("TUI should not launch after MCP registration fails")
|
||||
@@ -605,6 +748,12 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "register mcp tools failed") {
|
||||
t.Fatalf("stderr missing registration error: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), secret) {
|
||||
t.Fatalf("stderr leaked MCP registration secret: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), redaction.RedactedSecret) {
|
||||
t.Fatalf("stderr missing redaction marker: %s", stderr.String())
|
||||
}
|
||||
if !runtimeClosed {
|
||||
t.Fatal("partial MCP runtime was not closed after registration error")
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return runAuthStatus(args[1:], stdout, stderr, deps)
|
||||
case "refresh":
|
||||
return runAuthRefresh(args[1:], stdout, stderr, deps)
|
||||
case "reset":
|
||||
return runAuthReset(args[1:], stdout, stderr, deps)
|
||||
case "openrouter":
|
||||
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
|
||||
case "chatgpt":
|
||||
@@ -261,6 +263,7 @@ type authArgs struct {
|
||||
json bool
|
||||
device bool
|
||||
watch bool
|
||||
confirm bool
|
||||
scopes []string
|
||||
help bool
|
||||
}
|
||||
@@ -286,6 +289,8 @@ func parseAuthArgs(sub string, args []string) (authArgs, error) {
|
||||
parsed.device = true
|
||||
case arg == "--watch":
|
||||
parsed.watch = true
|
||||
case arg == "--confirm":
|
||||
parsed.confirm = true
|
||||
case arg == "--scope":
|
||||
if i+1 >= len(args) {
|
||||
return authArgs{}, fmt.Errorf("--scope requires a value")
|
||||
@@ -321,6 +326,7 @@ func validateAuthFlags(sub string, a authArgs) error {
|
||||
"logout": {"json": true},
|
||||
"status": {"json": true},
|
||||
"refresh": {"watch": true},
|
||||
"reset": {"json": true, "confirm": true},
|
||||
}[sub]
|
||||
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
|
||||
if a.json && !allowed["json"] {
|
||||
@@ -332,6 +338,9 @@ func validateAuthFlags(sub string, a authArgs) error {
|
||||
if a.watch && !allowed["watch"] {
|
||||
return bad("--watch")
|
||||
}
|
||||
if a.confirm && !allowed["confirm"] {
|
||||
return bad("--confirm")
|
||||
}
|
||||
if len(a.scopes) > 0 && !allowed["scope"] {
|
||||
return bad("--scope")
|
||||
}
|
||||
@@ -569,6 +578,43 @@ func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthReset(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("reset", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) > 0 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("zero auth reset takes no arguments (got %q)", parsed.positional[0]))
|
||||
}
|
||||
if !parsed.confirm {
|
||||
return writeExecUsageError(stderr, "zero auth reset clears all stored OAuth logins and shared MCP tokens; rerun with --confirm to reset")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := manager.Reset(); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Reset bool `json:"reset"`
|
||||
}{Reset: true}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, "Reset OAuth token store."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
|
||||
want := oauth.ProviderKey(provider)
|
||||
filtered := make([]oauth.Status, 0, 1)
|
||||
@@ -589,6 +635,7 @@ Commands:
|
||||
logout <provider> Delete a provider's stored login
|
||||
status [provider] Show login presence/expiry (never the token)
|
||||
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
|
||||
reset --confirm [--json] Clear all OAuth logins and shared MCP tokens (also repairs corrupted stores)
|
||||
openrouter Log in to OpenRouter in the browser; mints an API key
|
||||
chatgpt Log in to ChatGPT in the browser (Codex backend, ChatGPT Plus/Pro)
|
||||
|
||||
@@ -615,7 +662,7 @@ Flags:
|
||||
--device Use the device-code flow (headless/SSH; no browser)
|
||||
--scope Add an OAuth scope (repeatable)
|
||||
--watch Keep the token fresh in the foreground (refresh only)
|
||||
--json Print result as JSON (status/logout)
|
||||
--json Print result as JSON (status/logout/reset)
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
|
||||
@@ -129,6 +129,8 @@ func TestRunAuthRejectsWrongFlags(t *testing.T) {
|
||||
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
|
||||
{"auth", "refresh", "demo", "--json"}, // json not for refresh
|
||||
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
|
||||
{"auth", "status", "--confirm"}, // confirm is reset-only
|
||||
{"auth", "reset", "--confirm", "--watch"}, // confirmation does not allow unrelated flags
|
||||
}
|
||||
for _, args := range cases {
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -196,13 +198,102 @@ func TestRunAuthHelp(t *testing.T) {
|
||||
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d", code)
|
||||
}
|
||||
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
|
||||
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "reset --confirm", "--device"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthResetRequiresConfirmation(t *testing.T) {
|
||||
for _, args := range [][]string{{}, {"--json"}} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runAuthReset(args, &stdout, &stderr, appDeps{})
|
||||
after, readErr := os.ReadFile(path)
|
||||
if readErr != nil || !bytes.Equal(before, after) {
|
||||
t.Fatalf("unconfirmed reset changed the store: %v", readErr)
|
||||
}
|
||||
if code == exitSuccess || !strings.Contains(stderr.String(), "--confirm") || stdout.Len() != 0 {
|
||||
t.Fatalf("unconfirmed reset: exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthResetJSON(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "synthetic-reset-test-token"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "reset", "--confirm", "--json"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v (stdout=%q)", err, stdout.String())
|
||||
}
|
||||
if !payload.Reset {
|
||||
t.Fatalf("payload = %+v, want reset=true", payload)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("status exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
||||
t.Fatalf("expected empty store after JSON reset, got: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthReset(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "secret"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "reset", "--confirm"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Reset OAuth token store") {
|
||||
t.Fatalf("stdout = %q", stdout.String())
|
||||
}
|
||||
|
||||
// Verify store is now empty.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("status exit = %d", code)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
||||
t.Fatalf("expected empty store after reset, got: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||
// authorize params), not the generic manager path. The generic login accepts
|
||||
|
||||
@@ -26,6 +26,30 @@ type modelSummary = zerocommands.ModelSnapshot
|
||||
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot
|
||||
|
||||
func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
// The first non-flag argument is a subcommand. With no positional argument
|
||||
// (or only flag arguments), the read-only summary path runs — this keeps
|
||||
// `zero config` and `zero config --json` working unchanged.
|
||||
command := "summary"
|
||||
rest := args
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
command = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
rest = args[1:]
|
||||
}
|
||||
switch command {
|
||||
case "summary":
|
||||
return runConfigSummary(rest, stdout, stderr, deps)
|
||||
case "notify":
|
||||
return runConfigNotify(rest, stdout, stderr, deps)
|
||||
case "help":
|
||||
if err := writeConfigHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown config command %q", command))
|
||||
}
|
||||
|
||||
func runConfigSummary(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseCommandCenterArgs(args, false, false)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -407,7 +431,11 @@ func formatProviderCatalogLine(provider providerCatalogSummary) string {
|
||||
provider.RuntimeSupported,
|
||||
))
|
||||
if provider.RuntimeSupported {
|
||||
lines = append(lines, " setup: zero providers setup "+displayCLIValue(provider.ID, "unknown")+" --set-active")
|
||||
setup := " setup: zero providers setup " + displayCLIValue(provider.ID, "unknown") + " --set-active"
|
||||
if provider.ID == "atomic-chat-local" {
|
||||
setup = " setup: run zero setup to select a loaded model, or zero providers detect to get an add command"
|
||||
}
|
||||
lines = append(lines, setup)
|
||||
} else {
|
||||
lines = append(lines, " unsupported: "+displayCLIValue(provider.RuntimeUnsupportedReason, "unknown"))
|
||||
}
|
||||
@@ -462,8 +490,12 @@ func formatProviderCatalogValue(value string, fallback string) string {
|
||||
func writeConfigHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero config [flags]
|
||||
zero config notify [flags]
|
||||
|
||||
Inspects resolved Go configuration without printing secrets.
|
||||
Inspects resolved Go configuration without printing secrets. The notify
|
||||
subcommand reads or updates the stored global notification preference,
|
||||
which controls both the completion and needs-input alerts —
|
||||
run "zero config notify --help" for details.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
@@ -502,7 +534,7 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers models [name] [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio, Atomic Chat) and prints adopt commands plus per-provider next steps.
|
||||
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
||||
|
||||
Flags:
|
||||
|
||||
@@ -967,3 +967,10 @@ func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
|
||||
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicCatalogSetupRequiresLoadedModel(t *testing.T) {
|
||||
out := formatProviderCatalogLine(providerCatalogSummary{ID: "atomic-chat-local", RuntimeSupported: true})
|
||||
if strings.Contains(out, "zero providers setup atomic-chat-local --set-active") || !strings.Contains(out, "zero setup") {
|
||||
t.Fatalf("catalog advertises a failing setup command: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type completionContext struct {
|
||||
}
|
||||
|
||||
var completionRoot = completionNode{
|
||||
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
|
||||
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--skip-permissions-unsafe"},
|
||||
children: []completionNode{
|
||||
{names: []string{"exec"}, flags: []string{
|
||||
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
|
||||
@@ -40,7 +40,11 @@ var completionRoot = completionNode{
|
||||
},
|
||||
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
|
||||
{names: []string{"setup"}},
|
||||
{names: []string{"config"}},
|
||||
{names: []string{"config"}, flags: []string{"-h", "--help", "--json"}, children: []completionNode{
|
||||
{names: []string{"notify"}, flags: []string{
|
||||
"-h", "--help", "--mode", "--focus", "--reset", "--json",
|
||||
}},
|
||||
}},
|
||||
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
|
||||
{names: []string{"providers"}, children: []completionNode{
|
||||
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
|
||||
@@ -78,7 +82,8 @@ var completionRoot = completionNode{
|
||||
{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{"auth"}, children: append(leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh"),
|
||||
completionNode{names: []string{"reset"}, flags: []string{"--confirm", "--json", "--help"}})},
|
||||
{names: []string{"sandbox"}, children: []completionNode{
|
||||
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
|
||||
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
|
||||
|
||||
@@ -159,10 +159,17 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
assertCandidates(t, byPath["worktree"], "prepare", "release")
|
||||
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
|
||||
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
|
||||
assertCandidates(t, byPath["auth"], "openrouter", "chatgpt", "login", "logout", "status", "refresh", "reset")
|
||||
assertCandidates(t, byPath["auth reset"], "--confirm", "--json", "--help")
|
||||
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")
|
||||
// `config` gained the notify subcommand; the completion tree must expose
|
||||
// it (and its flags) so shell completion cannot go stale for new CLI
|
||||
// surfaces (maintainer review, PR #1001).
|
||||
assertCandidates(t, byPath["config"], "notify", "--json", "--help")
|
||||
assertCandidates(t, byPath["config notify"], "--mode", "--focus", "--reset", "--json", "--help")
|
||||
}
|
||||
|
||||
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// runConfigNotify implements `zero config notify`: with no flags it prints the
|
||||
// stored mode/focusMode; --mode/--focus update them via the same
|
||||
// config.UpdateNotify writer the TUI /notify command uses, so all surfaces stay
|
||||
// in lockstep; --reset blanks both fields so the TUI's effective default
|
||||
// applies again (an unconfigured headless run stays silent).
|
||||
//
|
||||
// The command manages a user preference, so it talks ONLY to the user's own
|
||||
// config file (config.UserNotify / config.UpdateNotify) and never runs the full
|
||||
// config resolution: resolving providers would fail with ErrNoActiveProvider
|
||||
// for a brand-new user, locking them out of setting notifications before they
|
||||
// have even configured a provider (CodeRabbit review, PR #1001). The display
|
||||
// therefore reports the USER'S stored values — a project config that
|
||||
// overrides notify for one repo is not shown here, by the same logic the
|
||||
// maintainer applied to the write path.
|
||||
func runConfigNotify(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseConfigNotifyArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeConfigNotifyHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
if options.mode != "" || options.focus != "" || options.reset {
|
||||
// One serialized read-merge-write transaction: the lock covers
|
||||
// reading the stored block, applying only the explicit fields, and
|
||||
// replacing the file, so two concurrent partial updates (e.g.
|
||||
// --mode off and --focus always from two terminals) cannot lose
|
||||
// each other's change (maintainer review, PR #1001). Omitted
|
||||
// fields preserve the values stored in the user's OWN file — blank
|
||||
// stays blank; --reset is the only path that clears both fields.
|
||||
_, err := config.UpdateNotify(configPath, func(current config.NotifyConfig) config.NotifyConfig {
|
||||
if options.reset {
|
||||
return config.NotifyConfig{}
|
||||
}
|
||||
if options.mode != "" {
|
||||
current.Mode = options.mode
|
||||
}
|
||||
if options.focus != "" {
|
||||
current.FocusMode = options.focus
|
||||
}
|
||||
return current
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// Report the stored values (blank renders as "(default)").
|
||||
current, err := config.UserNotify(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"mode": current.Mode,
|
||||
"focusMode": current.FocusMode,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
lines := []string{
|
||||
"Notify",
|
||||
"mode: " + displayCLIValue(current.Mode, "(default)"),
|
||||
"focusMode: " + displayCLIValue(current.FocusMode, "(default)"),
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type configNotifyOptions struct {
|
||||
mode string
|
||||
focus string
|
||||
reset bool
|
||||
json bool
|
||||
}
|
||||
|
||||
func parseConfigNotifyArgs(args []string) (configNotifyOptions, bool, error) {
|
||||
options := configNotifyOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--reset":
|
||||
options.reset = true
|
||||
case arg == "--mode":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--mode="):
|
||||
value, err := requiredInlineFlagValue(arg, "--mode")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
case arg == "--focus":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--focus="):
|
||||
value, err := requiredInlineFlagValue(arg, "--focus")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func writeConfigNotifyHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, "Usage:\n"+
|
||||
" zero config notify [flags]\n"+
|
||||
"\n"+
|
||||
"Print or update the stored global notification preference.\n"+
|
||||
"\n"+
|
||||
"The preference controls BOTH notification kinds: the turn-completion\n"+
|
||||
"(\"Zero: ready\") alert and the needs-input alert. mode off silences both;\n"+
|
||||
"the focus mode (unfocused, always, focused) applies to both.\n"+
|
||||
"\n"+
|
||||
"When run with no flag, prints the stored mode and focusMode; a field you\n"+
|
||||
"never set shows as (default) — the TUI alerts with bell + notification,\n"+
|
||||
"firing only when the terminal is unfocused, while an unconfigured\n"+
|
||||
"headless run stays silent. Omitted flags preserve the values stored in\n"+
|
||||
"YOUR config file; --reset clears both so the TUI's effective default\n"+
|
||||
"applies again. If --reset is combined with --mode/--focus, --reset wins\n"+
|
||||
"and the explicit values are ignored.\n"+
|
||||
"\n"+
|
||||
"Examples:\n"+
|
||||
" zero config notify\n"+
|
||||
" zero config notify --json\n"+
|
||||
" zero config notify --mode both --focus unfocused\n"+
|
||||
" zero config notify --mode off\n"+
|
||||
" zero config notify --reset # clear the stored preference\n"+
|
||||
"\n"+
|
||||
"Flags:\n"+
|
||||
" --mode <off|bell|notify|both> Notification mechanism (both kinds)\n"+
|
||||
" --focus <unfocused|always|focused> When the alert fires\n"+
|
||||
" --reset Clear the stored preference so the TUI effective default applies\n"+
|
||||
" --json Machine-readable output\n"+
|
||||
" -h, --help Show this help\n")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero config notify` with no flag and a fresh config: nothing is configured,
|
||||
// the resolver leaves the fields blank (defaults deliberately live in the TUI,
|
||||
// not the resolver — maintainer review, PR #1001), and the command reports
|
||||
// "(default)" for both.
|
||||
func TestRunConfigNotifyPrintsUnconfiguredAsDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
// A valid openai profile so the resolver does not error with
|
||||
// ErrNoActiveProvider. The notify defaults are applied independently of
|
||||
// the provider resolution path.
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured mode as (default), got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "focusMode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured focus as (default), got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --json` emits a machine-readable payload. A configured
|
||||
// pair round-trips; unconfigured fields are empty strings (never defaults
|
||||
// filled in), so scripts can distinguish "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSON(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" {
|
||||
t.Errorf("mode = %v, want bell", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "always" {
|
||||
t.Errorf("focusMode = %v, want always", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// The unconfigured JSON shape: fields are empty strings, never defaults
|
||||
// filled in, so scripts can tell "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSONUnconfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "" {
|
||||
t.Errorf("mode = %v, want empty (unconfigured)", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "" {
|
||||
t.Errorf("focusMode = %v, want empty (unconfigured)", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --mode off` writes the new value to disk and prints
|
||||
// confirmation. The user can read it back by running the command again.
|
||||
func TestRunConfigNotifyWritesModeChange(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "both", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
// A mode-only update must preserve the configured focusMode, not wipe it.
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want preserved %q", cfg.Notify.FocusMode, "always")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: off") {
|
||||
t.Errorf("stdout should confirm the change, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A focus-only update preserves the configured mode.
|
||||
func TestRunConfigNotifyFocusOnlyPreservesMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "unfocused"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" {
|
||||
t.Errorf("Notify.Mode = %q, want preserved %q", cfg.Notify.Mode, "bell")
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a partial update must seed from the USER'S
|
||||
// OWN file, never from the resolved view. A project .zero/config.json setting
|
||||
// mode=off resolves into the session, but `--focus always` inside that repo
|
||||
// must NOT copy the project's off into the user's global config.
|
||||
func TestRunConfigNotifyDoesNotCopyProjectNotifyIntoUserConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "user.json")
|
||||
projectPath := filepath.Join(t.TempDir(), "project.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(projectPath, []byte(`{"notify": {"mode": "off"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
// Resolve EXACTLY the way production does: user config + project config
|
||||
// merged, so resolved.Notify.Mode is the project's "off".
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{
|
||||
UserConfigPath: configPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||
})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
// The user's file must keep an unspecified mode unspecified — the
|
||||
// project's "off" stays in the project file where it belongs.
|
||||
if cfg.Notify.Mode != "" {
|
||||
t.Errorf("user Notify.Mode = %q, want blank (project's off must not leak into the user file)", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
// The project file is untouched.
|
||||
project := readFileConfig(t, projectPath)
|
||||
if project.Notify.Mode != "off" {
|
||||
t.Errorf("project Notify.Mode = %q, want untouched off", project.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): with a clean config, `--mode off` must not
|
||||
// also pin focusMode as an explicit choice — blank means "use the built-in
|
||||
// default", and a partial update keeps it that way.
|
||||
func TestRunConfigNotifyDoesNotPinDefaultsAsExplicitChoices(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify.FocusMode = %q, want blank (unspecified stays unspecified; the default must not be pinned)", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode` and `--focus` together update both fields in one call.
|
||||
func TestRunConfigNotifyWritesModeAndFocus(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "both", "--focus", "unfocused"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Errorf("Notify = %+v, want mode=both focusMode=unfocused", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode loud` is a usage error. The config must not be mutated.
|
||||
func TestRunConfigNotifyRejectsInvalidMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "loud"}, &stdout, &stderr, deps)
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected failure for invalid mode, got success; stdout=%s", stdout.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off (unchanged after failed write)", cfg.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--reset` blanks both fields so the TUI's effective default applies again
|
||||
// (an unconfigured headless run stays silent). Useful for "go back to the
|
||||
// recommended setup" after a custom value.
|
||||
func TestRunConfigNotifyResetClearsStoredValues(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify after reset = %+v, want empty (defaults apply)", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// CodeRabbit regression (PR #1001): the command manages a user preference and
|
||||
// must not require provider resolution. A brand-new user with NO provider
|
||||
// configured (the resolver would return ErrNoActiveProvider) can still read,
|
||||
// set, and reset their notification preference.
|
||||
func TestRunConfigNotifyWorksWithoutAnyProviderConfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// resolveConfig fails the way the real resolver does for a fresh user —
|
||||
// the command must never call it, so a panic-free stub is enough to prove
|
||||
// the point; use the failing resolver to catch any regression to the
|
||||
// resolve-first shape.
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}})
|
||||
}
|
||||
|
||||
// Read works and shows defaults.
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("read: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("read should show (default), got: %s", stdout.String())
|
||||
}
|
||||
|
||||
// Write works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--mode", "bell", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("write: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" || cfg.Notify.FocusMode != "always" {
|
||||
t.Fatalf("write: Notify = %+v, want bell/always", cfg.Notify)
|
||||
}
|
||||
|
||||
// JSON read reflects the stored pair.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("json: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json output invalid: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" || payload["focusMode"] != "always" {
|
||||
t.Errorf("json = mode %v focus %v, want bell/always", payload["mode"], payload["focusMode"])
|
||||
}
|
||||
|
||||
// Reset works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("reset: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg = readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("reset: Notify = %+v, want empty", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config` (no subcommand) still works after the dispatch change.
|
||||
func TestRunConfigSummaryStillWorks(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, commandCenterDeps(t))
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Config") {
|
||||
t.Errorf("stdout should show the config summary, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -39,3 +43,115 @@ func TestFireJobClaimPreventsDoubleFire(t *testing.T) {
|
||||
t.Fatalf("NextRunAt must advance past now, got %s", d.NextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobOverlappingAdjacentSlots(t *testing.T) {
|
||||
for _, olderFirst := range []bool{false, true} {
|
||||
name := "newer completes first"
|
||||
if olderFirst {
|
||||
name = "older completes first"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stores := []*cron.Store{
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
}
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := stores[0].Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now, FireCount: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var release, done [2]chan struct{}
|
||||
for i := range stores {
|
||||
release[i], done[i] = make(chan struct{}), make(chan struct{})
|
||||
// Always release blocked runners, including on assertion failures.
|
||||
defer func() {
|
||||
select {
|
||||
case <-release[i]:
|
||||
default:
|
||||
close(release[i])
|
||||
}
|
||||
<-done[i]
|
||||
}()
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done[i])
|
||||
fireJob(stores[i], func() time.Time { return now.Add(time.Duration(i) * time.Minute) }, job, io.Discard, io.Discard,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
close(started)
|
||||
<-release[i]
|
||||
return i // Include a failed execution in the accounting.
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-done[i]:
|
||||
t.Fatal("runner did not claim its slot")
|
||||
}
|
||||
}
|
||||
order := []int{1, 0}
|
||||
if olderFirst {
|
||||
order = []int{0, 1}
|
||||
}
|
||||
for n, i := range order {
|
||||
close(release[i])
|
||||
<-done[i]
|
||||
current, err := stores[0].Get(job.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.FireCount != 8+n || !current.NextRunAt.Equal(now.Add(2*time.Minute)) {
|
||||
t.Fatalf("after completion %d: FireCount=%d NextRunAt=%s; want count %d and next %s", n+1, current.FireCount, current.NextRunAt, 8+n, now.Add(2*time.Minute))
|
||||
}
|
||||
claimed, err := claimFire(stores[1], now.Add(time.Minute), job.ID)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("already claimed slot became available: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
runs, err := stores[0].Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 2 {
|
||||
t.Fatalf("runs=%v err=%v", runs, err)
|
||||
}
|
||||
for n, i := range order {
|
||||
if !runs[n].At.Equal(now.Add(time.Duration(i)*time.Minute)) || runs[n].ExitCode != i {
|
||||
t.Fatalf("unexpected run record: %+v", runs[n])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobCompletionReadErrorDoesNotOverwriteState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: root})
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := store.Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := filepath.Join(root, job.ID, "metadata.json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
fireJob(store, func() time.Time { return now }, job, &stdout, &stderr,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
// A corrupt read must not cause completion to replace unknown newer state
|
||||
// with the stale pre-execution job. This works without permission tricks.
|
||||
if err := os.WriteFile(metadata, []byte("unreadable job"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
data, err := os.ReadFile(metadata)
|
||||
if err != nil || string(data) != "unreadable job" {
|
||||
t.Fatalf("completion overwrote unreadable state: %q, err=%v", data, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "failed to persist job state") {
|
||||
t.Fatalf("missing persistence warning: %s", stderr.String())
|
||||
}
|
||||
if want := "fired " + job.ID + " -> exit 0 (next: unknown)\n"; stdout.String() != want {
|
||||
t.Fatalf("completion summary = %q; want %q", stdout.String(), want)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 1 || runs[0].ExitCode != 0 {
|
||||
t.Fatalf("execution should still be recorded: runs=%v err=%v", runs, err)
|
||||
}
|
||||
}
|
||||
@@ -129,17 +129,15 @@ func reconcileOverdue(store *cron.Store, now func() time.Time, ids []string, std
|
||||
}
|
||||
}
|
||||
|
||||
// fireJob runs one job via the exec runner, records the outcome, advances the
|
||||
// schedule, and persists. The foreground loop is single-goroutine, so the
|
||||
// previous fire has already returned before the next tick — no overlap.
|
||||
// fireJob claims one job, runs it via the exec runner, and records the outcome.
|
||||
// Separate scheduler processes can have adjacent slots executing concurrently.
|
||||
func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Writer, stderr io.Writer, exec execRunner) {
|
||||
fired := now()
|
||||
// Atomically claim this fire before running it so concurrent schedulers (or
|
||||
// --once overlapping the forever loop) can't both execute the same due job: the
|
||||
// winner advances NextRunAt under the per-job lock, and a loser sees the
|
||||
// advanced time and skips. The post-exec advance recomputes the same
|
||||
// NextRunAt (sched.Next(fired) is deterministic), so the claim serializes the
|
||||
// fire decision without changing any persisted schedule state (M9).
|
||||
// advanced time and skips. Only the claim writes schedule state; completion
|
||||
// must not overwrite a later slot's claim with this run's stale job copy.
|
||||
claimed, claimErr := claimFire(store, fired, job.ID)
|
||||
if claimErr != nil {
|
||||
if errors.Is(claimErr, cron.ErrJobNotFound) {
|
||||
@@ -178,44 +176,25 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
rec.Error = cronTruncate(detail, 500)
|
||||
}
|
||||
|
||||
job.FireCount++
|
||||
// Advance the schedule. If the expression can no longer produce a future run
|
||||
// (became invalid, or is an impossible spec whose Next is zero), pause the job
|
||||
// so it cannot re-fire on every tick.
|
||||
// Explain schedules that claimFire paused because they cannot advance.
|
||||
if sched, perr := cron.Parse(job.Expr); perr != nil {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "invalid schedule; job paused: " + perr.Error()
|
||||
}
|
||||
} else if nxt := sched.Next(fired.Truncate(time.Minute)); nxt.IsZero() {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "schedule no longer fires; job paused"
|
||||
}
|
||||
} else {
|
||||
// Minute-aligned input keeps this advance identical to claimFire's and lets
|
||||
// the DST fall-back collapse guard engage (AUDIT-M4).
|
||||
job.NextRunAt = nxt
|
||||
}
|
||||
// Re-read and persist the advanced state ATOMICALLY under the per-job lock. The
|
||||
// job may have been paused or removed while it executed, and this in-memory copy
|
||||
// is stale from tick start. Mutate closes the read-modify-write window a single
|
||||
// scheduler alone could only narrow (Store.Mutate took a cross-process lock): a
|
||||
// concurrent scheduler or an external pause/remove landing here can no longer be
|
||||
// clobbered.
|
||||
// Count this completion against current state under the cross-process lock.
|
||||
// The claim already advanced or paused the schedule. Leave that state alone:
|
||||
// another scheduler may have claimed a later slot or a user may have edited it.
|
||||
persisted, err := store.Mutate(job.ID, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
if readErr != nil {
|
||||
// A transient read failure (IO/permission) is NOT removal — persist the
|
||||
// computed next state anyway so the schedule advances and the job does not
|
||||
// re-fire next tick.
|
||||
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
|
||||
return job, nil
|
||||
}
|
||||
current.FireCount = job.FireCount
|
||||
current.NextRunAt = job.NextRunAt
|
||||
if current.Status != cron.StatusPaused {
|
||||
current.Status = job.Status
|
||||
// Never replace unknown newer state with the pre-execution snapshot.
|
||||
return current, readErr
|
||||
}
|
||||
current.FireCount++
|
||||
return current, nil
|
||||
})
|
||||
if errors.Is(err, cron.ErrJobNotFound) {
|
||||
@@ -229,19 +208,21 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
if aerr := store.AppendRun(job.ID, rec); aerr != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, aerr)
|
||||
}
|
||||
next := "unknown"
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to persist job state for %s: %v\n", job.ID, err)
|
||||
persisted = job
|
||||
} else {
|
||||
next = formatCronTime(persisted.NextRunAt)
|
||||
}
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(persisted.NextRunAt))
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, next)
|
||||
}
|
||||
|
||||
// claimFire atomically claims a due job's fire under the per-job lock and reports
|
||||
// whether THIS caller should run it. It returns false only when the job was paused
|
||||
// externally or another scheduler already advanced NextRunAt past `fired` (so a
|
||||
// valid recurring job never double-fires). A valid due job has its NextRunAt
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is left for the
|
||||
// caller to fire-once and pause (preserving existing single-scheduler behavior).
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is paused
|
||||
// here and the caller still fires once (preserving single-scheduler behavior).
|
||||
func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
claimed := true
|
||||
_, err := store.Mutate(id, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
@@ -268,8 +249,8 @@ func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
}
|
||||
// Unparseable or unadvanceable schedule (an impossible spec whose Next is
|
||||
// zero): pause the job inside this same locked claim so a concurrent scheduler
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once
|
||||
// and fireJob's post-exec keeps it paused. Without this, both callers leave
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once.
|
||||
// Without this, both callers leave
|
||||
// NextRunAt unchanged, both see the job due, and both fire the same slot
|
||||
// (AUDIT-M5).
|
||||
current.Status = cron.StatusPaused
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestCronRunOnceFiresDueJobs(t *testing.T) {
|
||||
if d.FireCount != 1 || !d.NextRunAt.After(now) {
|
||||
t.Fatalf("due job not advanced: %+v", d)
|
||||
}
|
||||
runs, _ := store.Runs(due.ID)
|
||||
runs, _ := store.Runs(due.ID, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 run record, got %d", len(runs))
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestCronRunPausesUnadvanceableJob(t *testing.T) {
|
||||
if d.Status != cron.StatusPaused {
|
||||
t.Fatalf("unadvanceable job must be paused, got status=%q", d.Status)
|
||||
}
|
||||
runs, _ := store.Runs(job.ID)
|
||||
runs, _ := store.Runs(job.ID, 0)
|
||||
if len(runs) != 1 || runs[0].Error == "" {
|
||||
t.Fatalf("expected one run record with an error, got %+v", runs)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -147,11 +146,7 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
@@ -18,14 +18,20 @@ import (
|
||||
// cliFakeDeferredTool is deferred-eligible (implements Deferred() bool), mirroring
|
||||
// an MCP registry tool, so it counts toward the deferral threshold.
|
||||
type cliFakeDeferredTool struct {
|
||||
name string
|
||||
name string
|
||||
permission tools.Permission
|
||||
advertiseInAuto bool
|
||||
}
|
||||
|
||||
func (t cliFakeDeferredTool) Name() string { return t.name }
|
||||
func (t cliFakeDeferredTool) Description() string { return "fake deferred tool" }
|
||||
func (t cliFakeDeferredTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (t cliFakeDeferredTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
|
||||
permission := t.permission
|
||||
if permission == "" {
|
||||
permission = tools.PermissionAllow
|
||||
}
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: permission, AdvertiseInAuto: t.advertiseInAuto}
|
||||
}
|
||||
func (t cliFakeDeferredTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
|
||||
@@ -49,6 +49,14 @@ const (
|
||||
exitInterrupted = 130
|
||||
)
|
||||
|
||||
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
|
||||
// An interactive run can be continued past the shared interactive default by the
|
||||
// user; an exec run has nobody to unstick it, so evals and automation would
|
||||
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
|
||||
// still finite against a genuinely runaway loop, which the loop's empty-turn
|
||||
// guard also cuts independently of this budget.
|
||||
const defaultExecMaxTurns = config.MaxTurnsCeiling
|
||||
|
||||
type execOutputFormat string
|
||||
type execInputFormat string
|
||||
|
||||
@@ -173,7 +181,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||
// front — before tool-filter validation and the --list-tools branch — so a
|
||||
@@ -320,6 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
resolved.MaxTurns = execTurnBudget(resolved)
|
||||
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...))
|
||||
@@ -338,6 +347,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
// Before any tool runs, and before the MCP servers and plugins below start
|
||||
// through the same sandbox, so a run that goes ahead with reduced isolation
|
||||
// says so where its output starts, not only when someone runs `zero doctor`.
|
||||
// A notice that cannot be written stops the run, as the image and effort
|
||||
// notices further down do: going ahead would mean reduced isolation and no
|
||||
// warning delivered.
|
||||
if notice := sandboxEngine.DegradedNotice(); notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, "[zero] "+notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
if permissionMode != agent.PermissionModePlan {
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
@@ -442,57 +462,22 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// the resolved model and is reassigned by the model switcher on a mid-run
|
||||
// escalation so post-switch turns are attributed to the escalated model.
|
||||
currentModel := resolved.Provider.Model
|
||||
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off). nil
|
||||
// when gated off or ineligible, unless a configured RPM limiter needs to
|
||||
// wrap the default adapter. The limiter is shared across model switches.
|
||||
sessionOptions := providers.Options{ModelRPM: zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)}
|
||||
turnSessions, _ := providers.ConfiguredTurnSessions(resolved.Provider, provider, sessionOptions)
|
||||
// Both switchers come from one shared builder so exec and the interactive TUI
|
||||
// cannot drift on the nil contracts the agent loop depends on. The session
|
||||
// switcher preserves the starting transport and shares the RPM limiter.
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
if options.allowEscalation {
|
||||
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
|
||||
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
|
||||
// the escalated provider is authenticated even though resolved.Provider
|
||||
// is the pure profile — no per-site key handling here.
|
||||
switchedProfile := resolved.Provider
|
||||
switchedProfile.Model = modelID
|
||||
switchedProvider, err := deps.newProvider(switchedProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Mirror the agent loop's switch guard (it only reassigns the provider
|
||||
// when newProvider != nil). Updating currentModel only on a non-nil
|
||||
// provider keeps usage attribution consistent with whether the loop
|
||||
// actually switched — a (nil, nil) return leaves both untouched.
|
||||
if switchedProvider != nil {
|
||||
currentModel = modelID
|
||||
}
|
||||
return switchedProvider, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off).
|
||||
// nil when gated off or the profile is ineligible: agent.Run then wraps the
|
||||
// provider in its default adapter — the exact code path of today. The
|
||||
// session switcher is installed only when the run START is optimized, so
|
||||
// the legacy ModelSwitcher path above stays untouched otherwise.
|
||||
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
|
||||
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
|
||||
if options.allowEscalation && turnSessions != nil {
|
||||
modelSessionSwitcher = func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
|
||||
switchedProfile := resolved.Provider
|
||||
switchedProfile.Model = modelID
|
||||
switchedProvider, err := deps.newProvider(switchedProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if switchedProvider == nil {
|
||||
// The loop treats a nil session source as "no swap" — mirror the
|
||||
// legacy closure's (nil, nil) contract.
|
||||
return nil, nil
|
||||
}
|
||||
currentModel = modelID
|
||||
if optimized, ok := providers.OptimizedTurnSessions(switchedProfile, switchedProvider, providers.Options{}); ok {
|
||||
return optimized, nil
|
||||
}
|
||||
// Ineligible switch target: default adapter, but with the switched
|
||||
// model's resolved capability projection preserved.
|
||||
return providers.DefaultTurnSessions(switchedProfile, switchedProvider, providers.Options{}), nil
|
||||
}
|
||||
if options.allowEscalation {
|
||||
modelSwitcher, modelSessionSwitcher = providers.EscalationSwitchers(
|
||||
resolved.Provider, provider, deps.newProvider,
|
||||
func(modelID string) { currentModel = modelID },
|
||||
sessionOptions,
|
||||
)
|
||||
}
|
||||
|
||||
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
|
||||
@@ -682,16 +667,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ModelSwitcher: modelSwitcher,
|
||||
TurnSessionProvider: turnSessions,
|
||||
ModelSessionSwitcher: modelSessionSwitcher,
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
Summarizer: summarizerFactory(resolved, deps.newProvider),
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
@@ -939,6 +931,9 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
|
||||
if deferThreshold <= 0 {
|
||||
return
|
||||
}
|
||||
if _, registered := registry.Get(tools.ToolSearchToolName); registered {
|
||||
return
|
||||
}
|
||||
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
|
||||
return
|
||||
}
|
||||
@@ -1250,6 +1245,20 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
|
||||
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
|
||||
}
|
||||
|
||||
// execTurnBudget applies the headless default to the resolved turn budget: an
|
||||
// interactive run can be continued past the shared default by the user, but an
|
||||
// exec run has nobody to unstick it, so an unconfigured budget starts at the
|
||||
// documented ceiling instead of truncating mid-task. Explicit sources
|
||||
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
|
||||
// MaxTurnsSet and pass through; an exec-profile budget still wins via
|
||||
// applyProfileTurnBudget afterward.
|
||||
func execTurnBudget(resolved config.ResolvedConfig) int {
|
||||
if resolved.MaxTurnsSet {
|
||||
return resolved.MaxTurns
|
||||
}
|
||||
return defaultExecMaxTurns
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -108,8 +108,8 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -176,8 +176,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
if !closed {
|
||||
t.Fatal("MCP runtime was not closed after --list-tools")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") {
|
||||
t.Fatalf("expected MCP tool in list output, got %q", stdout.String())
|
||||
@@ -323,8 +323,8 @@ func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -388,8 +388,8 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -460,7 +460,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String())
|
||||
}
|
||||
if stderr.String() != "" {
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("approved exec wrote stderr = %q", stderr.String())
|
||||
}
|
||||
approvedEvents := decodeJSONLines(t, stdout.String())
|
||||
|
||||
@@ -118,12 +118,15 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
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,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(run.modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Profile: run.profilePolicy,
|
||||
Cwd: run.workspaceRoot,
|
||||
|
||||
@@ -568,8 +568,8 @@ func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Fatalf("expected prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,8 +696,8 @@ func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
|
||||
if !strings.Contains(stdout, "inline prompt\n\nfile prompt") {
|
||||
t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,8 +715,8 @@ func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
|
||||
if !strings.Contains(stdout, "file only prompt") {
|
||||
t.Fatalf("expected file prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,8 +798,8 @@ func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -833,8 +833,8 @@ func TestRunExecResolvesCanonicalModelAlias(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr for active model, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice for active model, got %q", stderr)
|
||||
}
|
||||
events := decodeJSONLines(t, stdout)
|
||||
if got := events[0]["model"]; got != "gpt-4.1" {
|
||||
@@ -926,8 +926,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -970,10 +970,10 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
"name": "local",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "` + server.URL + `",
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,8 +988,8 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
if strings.TrimSpace(stdout.String()) != "provider ok" {
|
||||
t.Fatalf("stdout = %q, want provider response", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer sk-local" {
|
||||
t.Fatalf("Authorization = %q, want project config token", gotAuth)
|
||||
@@ -1204,8 +1204,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -1975,3 +1975,23 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
|
||||
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resolved config.ResolvedConfig
|
||||
want int
|
||||
}{
|
||||
// A headless run has no user to continue past the shared interactive
|
||||
// default, so an unconfigured budget starts at the ceiling.
|
||||
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
|
||||
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
|
||||
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
|
||||
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := execTurnBudget(tc.resolved); got != tc.want {
|
||||
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||