mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b7b8296ac |
No files matched your search
@@ -1,3 +0,0 @@
|
||||
*.go text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.sum text eol=lf
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible problem in Zero
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug. Clear, reproducible reports are the most useful way to help the
|
||||
project. Please do **not** report security vulnerabilities here — see SECURITY.md.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / branch / commit
|
||||
description: Output of `zero version`, or the branch/commit you built from.
|
||||
placeholder: "zero dev (commit abc1234) / main"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: env
|
||||
attributes:
|
||||
label: OS and environment
|
||||
description: Operating system, terminal, and any relevant environment details.
|
||||
placeholder: "macOS 14 / iTerm2 / Go 1.25.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: A minimal reproduction case when possible.
|
||||
placeholder: |
|
||||
1. Run `zero ...`
|
||||
2. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs, screenshots, or error messages
|
||||
description: Redact any secrets (API keys, tokens) before pasting.
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,11 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and setup help
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
|
||||
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
|
||||
- name: Feature ideas
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
|
||||
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Gitlawb/zero/security/advisories/new
|
||||
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Feature request (previously discussed)
|
||||
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
not here. This form is for the step after that: an idea that gained traction in Discussions,
|
||||
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
|
||||
Do not begin implementation until the issue has been reviewed and marked with the
|
||||
`issue-approved` label (see CONTRIBUTING.md).
|
||||
- type: input
|
||||
id: discussion
|
||||
attributes:
|
||||
label: Ideas discussion
|
||||
description: Link to the Discussions Ideas thread (or note which maintainer asked for this issue).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: The problem you are trying to solve.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Requested behavior
|
||||
description: The behavior or functionality you are requesting.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why it belongs in Zero
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives or workarounds considered
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Examples, screenshots, or prior discussion
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,16 +0,0 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this change do, and why? -->
|
||||
|
||||
## Linked issue
|
||||
|
||||
<!-- Per CONTRIBUTING.md, PRs need an approved parent issue with the `issue-approved` label. -->
|
||||
Fixes #
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The linked issue already has the `issue-approved` label.
|
||||
- [ ] `go build ./...`, `go vet ./...`, and `go test ./...` pass locally.
|
||||
- [ ] `gofmt` clean.
|
||||
- [ ] Tests added/updated for the change (and run under `-race` where relevant).
|
||||
- [ ] UI changes include screenshots or a short recording where possible.
|
||||
+10
-42
@@ -30,36 +30,6 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install Make on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: choco install make --no-progress -y
|
||||
|
||||
- name: Validate quality targets with stock macOS Make
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 1
|
||||
GIT_CONFIG_KEY_0: grep.patternType
|
||||
GIT_CONFIG_VALUE_0: fixed
|
||||
run: make -n vulncheck
|
||||
|
||||
- name: Check vulnerabilities through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: make vulncheck
|
||||
|
||||
- name: Check dead code through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make deadcode
|
||||
|
||||
- name: Run static lint through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make lint-static
|
||||
|
||||
- name: Check formatting
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
@@ -130,25 +100,23 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# govulncheck and deadcode resolve a toolchain from their own modules, which
|
||||
# can drop below the version go.mod requires and then fail to load our
|
||||
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
|
||||
|
||||
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||
- name: govulncheck
|
||||
run: make vulncheck
|
||||
run: go run golang.org/x/vuln/cmd/[email protected] ./...
|
||||
|
||||
# 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
|
||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||
@@ -15,20 +15,13 @@ jobs:
|
||||
package:
|
||||
name: Package (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write # create/upload the GitHub Release on a tag push
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# `zero-release package` builds + smoke-tests natively (host==target), so each
|
||||
# target platform needs its own native runner. These five cover the platforms
|
||||
# install.sh / install.ps1 / the npm wrapper resolve.
|
||||
os:
|
||||
- ubuntu-latest # linux-x64
|
||||
- ubuntu-24.04-arm # linux-arm64
|
||||
- macos-latest # macos-arm64 (Apple Silicon)
|
||||
- macos-15-intel # macos-x64 (Intel)
|
||||
- windows-latest # windows-x64
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -50,23 +43,6 @@ jobs:
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
# zero-release names assets zero-v<package.json version>-… while the release
|
||||
# is published under the pushed git tag. If they disagree, install.sh/install.ps1
|
||||
# and the npm postinstall all build a download URL that 404s. Fail fast so a
|
||||
# forgotten version bump can never publish mis-versioned, un-installable assets.
|
||||
# Matches on the ref (not event_name) because release-please dispatches this
|
||||
# workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`.
|
||||
- name: Check tag matches package.json version
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
run: |
|
||||
pkg_version="$(node -p "require('./package.json').version")"
|
||||
if [ "v$pkg_version" != "$GITHUB_REF_NAME" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match package.json version v$pkg_version — bump package.json before tagging so the release assets and the npm package agree." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag $GITHUB_REF_NAME matches package.json version v$pkg_version."
|
||||
|
||||
- name: Package release artifact
|
||||
run: go run ./cmd/zero-release package
|
||||
|
||||
@@ -79,164 +55,3 @@ jobs:
|
||||
name: zero-${{ runner.os }}-${{ runner.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
|
||||
# release-please workflow), publish the packaged archive + .sha256 as GitHub
|
||||
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
|
||||
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
|
||||
# action dependency); each matrix OS creates the release if absent
|
||||
# (best-effort — release-please normally creates it first) then uploads its
|
||||
# own assets with --clobber. Tags come from merging the release-please PR.
|
||||
- name: Publish to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
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.
|
||||
#
|
||||
# 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.
|
||||
# Because the platform payloads are versions of the same package, they reuse
|
||||
# that single trusted-publisher configuration.
|
||||
publish-npm:
|
||||
name: Publish npm package
|
||||
needs: package
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Manual approval gate: this environment carries a required-reviewer rule
|
||||
# (Settings -> Environments -> npm-publish), so the job pauses until a
|
||||
# maintainer approves it in the Actions UI before anything reaches npm.
|
||||
environment:
|
||||
name: npm-publish
|
||||
url: https://www.npmjs.com/package/@gitlawb/zero
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token exchange for npm trusted publishing + provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
# 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
|
||||
# unset env var. Trusted publishing needs no token at all.
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with
|
||||
# the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling
|
||||
# npm@latest from the registry inside the publish path — an unpinned
|
||||
# install there would be avoidable supply-chain exposure. Fail fast if a
|
||||
# future runner/toolchain change ever regresses the bundled npm.
|
||||
- name: Check npm supports trusted publishing
|
||||
run: |
|
||||
npm --version | node -e '
|
||||
const version = require("fs").readFileSync(0, "utf8").trim();
|
||||
const [major, minor, patch] = version.split(".").map(Number);
|
||||
const ok =
|
||||
major > 11 ||
|
||||
(major === 11 && (minor > 5 || (minor === 5 && patch >= 1)));
|
||||
if (!ok) {
|
||||
console.error(
|
||||
`::error::bundled npm ${version} is too old for OIDC trusted ` +
|
||||
`publishing (needs >= 11.5.1) — bump node-version in this job.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`npm ${version} supports trusted publishing.`);
|
||||
'
|
||||
|
||||
# Probe the exact URLs the wrapper's first-run downloader builds
|
||||
# (internal/release/release.go asset-name scheme) as an anonymous client
|
||||
# — install.sh/install.ps1 and `zero update` read them too. This also
|
||||
# blocks publishing while the repo is still private: private release
|
||||
# assets 404 without auth, exactly as they would for an installing user.
|
||||
- name: Verify release assets are downloadable
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}"
|
||||
for asset in \
|
||||
"zero-v${version}-linux-x64.tar.gz" \
|
||||
"zero-v${version}-linux-arm64.tar.gz" \
|
||||
"zero-v${version}-macos-x64.tar.gz" \
|
||||
"zero-v${version}-macos-arm64.tar.gz" \
|
||||
"zero-v${version}-windows-x64.zip"; do
|
||||
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
|
||||
if ! curl -fsSLI -o /dev/null "$url"; then
|
||||
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "all release assets for v${version} are publicly downloadable."
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: zero-*
|
||||
path: dist/artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- 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
|
||||
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)."
|
||||
|
||||
- name: Publish wrapper to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
(cd dist/npm/wrapper && npm publish --access public)
|
||||
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
|
||||
# a publish by a few seconds).
|
||||
for attempt in 1 2 3 4 5; do
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
if [ "$latest" = "$version" ]; then
|
||||
echo "dist-tag latest is ${version}."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
|
||||
exit 1
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write # push the release-PR branch, create the tag + GitHub Release
|
||||
pull-requests: write # open/update the release PR
|
||||
issues: write # create/apply release-tracking labels on the release PR
|
||||
actions: write # dispatch release-artifacts.yml once a release is cut
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
name: Release PR / tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
|
||||
|
||||
# Tags created with the default GITHUB_TOKEN never fire `on: push: tags`
|
||||
# workflows (GitHub suppresses workflows triggered by workflow-created
|
||||
# events), so release-artifacts.yml would not run for a release-please
|
||||
# tag. workflow_dispatch invoked through the API is exempt from that
|
||||
# suppression — dispatch the artifacts build at the new tag explicitly.
|
||||
- name: Build and publish release artifacts
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh workflow run release-artifacts.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "${{ steps.release.outputs.tag_name }}"
|
||||
@@ -4,14 +4,12 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
jobs:
|
||||
@@ -31,8 +29,7 @@ jobs:
|
||||
import sys, yaml
|
||||
|
||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||
action_raw = handle.read()
|
||||
doc = yaml.safe_load(action_raw)
|
||||
doc = yaml.safe_load(handle)
|
||||
|
||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||
assert doc.get("name"), "action must declare a name"
|
||||
@@ -68,19 +65,6 @@ jobs:
|
||||
assert "--skip-permissions-unsafe" not in serialized, \
|
||||
"action must never pass --skip-permissions-unsafe"
|
||||
|
||||
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
|
||||
assert run_zero is not None, "action.yml must contain a Run ZERO step"
|
||||
run_zero_script = run_zero.get("run") or ""
|
||||
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
|
||||
"Run ZERO must export the provider key for the current step"
|
||||
assert "later steps inherit" not in action_raw.lower(), \
|
||||
"provider keys must not be documented as inherited by later steps"
|
||||
for line in run_zero_script.splitlines():
|
||||
if "GITHUB_ENV" in line and (
|
||||
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
|
||||
):
|
||||
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
|
||||
|
||||
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
|
||||
PY
|
||||
|
||||
|
||||
-20
@@ -27,10 +27,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Go build/module caches (GOCACHE/GOMODCACHE pointed here for local runs)
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
@@ -41,20 +37,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
/zero
|
||||
/zero.exe
|
||||
/zero-windows-command-runner.exe
|
||||
/zero-windows-sandbox-setup.exe
|
||||
/zero-skills
|
||||
/.zero-binary-version
|
||||
/snake-game.html
|
||||
|
||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Termux/Android build artifacts
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
zero-seccomp
|
||||
|
||||
# Generated benchmark reports are evidence for one configuration, never tree state
|
||||
internal/perfbench/reports/*.json
|
||||
@@ -1 +0,0 @@
|
||||
{".":"0.6.0"}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Use Go-native project commands; Node is only for the npm wrapper.
|
||||
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to Go commands in this repository.
|
||||
|
||||
- Use `go run ./cmd/zero` to launch the local CLI.
|
||||
- Use `go test ./...` for the repository test suite.
|
||||
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
|
||||
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
|
||||
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
|
||||
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
|
||||
- Do not add Bun or TypeScript build/test dependencies for repository validation.
|
||||
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Repository Guidelines for Zero
|
||||
|
||||
These instructions apply to all work in this repository. For the user-facing
|
||||
guide to extending Zero with specialists, hooks, plugins, MCP, and skills, see
|
||||
[docs/EXTENDING.md](docs/EXTENDING.md).
|
||||
|
||||
## 1. Contribution and Pull Request Rules
|
||||
|
||||
- Before opening any pull request, **all contributors**—including maintainers,
|
||||
community contributors, and coding agents—must read and follow
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- Community pull requests require an existing parent issue with the
|
||||
`issue-approved` label. Team members may open pull requests through the
|
||||
internal development process described in `CONTRIBUTING.md`.
|
||||
- Keep each change focused on the approved or assigned scope. Do not include
|
||||
unrelated fixes, refactors, formatting churn, generated output, or existing
|
||||
local changes in the same commit or pull request.
|
||||
- Discuss new implementation languages, runtimes, major dependency changes,
|
||||
and broad architectural rewrites with maintainers before implementation.
|
||||
- Pull request descriptions must explain what changed and why, link the parent
|
||||
issue when required, and list the tests or verification performed. Include
|
||||
screenshots or a short recording for user-visible UI changes when practical.
|
||||
|
||||
## 2. Repository and Implementation Conventions
|
||||
|
||||
- Use the Go version declared in `go.mod`. Do not hardcode a different local
|
||||
toolchain version in scripts or documentation.
|
||||
- Use the repository build and release commands (`make` and
|
||||
`go run ./cmd/zero-release ...`) instead of inventing parallel build flows.
|
||||
- Keep tests beside their source files (`foo_test.go` next to `foo.go`). Add a
|
||||
regression test for behavior changes and run affected concurrent code under
|
||||
the race detector.
|
||||
- Never edit files under `third_party/`; they are vendored.
|
||||
- Prefer one cross-platform function with small conditional checks over
|
||||
duplicated platform-specific helpers when the behavior can remain unified.
|
||||
- Do not commit generated benchmark reports from
|
||||
`internal/perfbench/reports/*.json`; reports are configuration-specific
|
||||
evidence, not repository state.
|
||||
- Preserve the user's working tree. Do not overwrite, delete, stage, or commit
|
||||
unrelated tracked or untracked files.
|
||||
|
||||
## 3. Required Validation
|
||||
|
||||
Run validation from the repository root before committing, opening a pull
|
||||
request, or completing an implementation task:
|
||||
|
||||
1. **Formatting check**: `make fmt-check`. If it fails, format with
|
||||
`go fmt ./...` (or `make fmt`) and run the check again.
|
||||
2. **Vet**: `go vet ./...` (or `make vet`).
|
||||
3. **Tests**: `go test ./...`. Use `make test` for the full race-enabled suite,
|
||||
or run focused tests with `-race`, when concurrency is affected.
|
||||
4. **Build**: `go run ./cmd/zero-release build`.
|
||||
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
||||
6. **Advisory lint**: `make lint-static`.
|
||||
7. **Security**: `make vulncheck`.
|
||||
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
||||
tracked changes).
|
||||
|
||||
`make lint` currently runs the formatting check and `go vet`; it does **not**
|
||||
run golangci-lint. The pinned golangci-lint job is advisory in CI while the
|
||||
existing repository-wide backlog is cleaned up. Fix findings introduced by or
|
||||
related to the current change. Unrelated pre-existing advisory findings do not
|
||||
justify expanding a focused pull request; report them separately.
|
||||
|
||||
Formatting, vet, tests, build, smoke, diff hygiene, and govulncheck are hard
|
||||
requirements. If a related check fails, fix it. If a required check cannot run
|
||||
because of the environment or fails for an unrelated external reason, report
|
||||
the exact failure and obtain maintainer direction rather than silently ignoring
|
||||
it.
|
||||
-298
@@ -1,298 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.6.0](https://github.com/Gitlawb/zero/compare/v0.5.0...v0.6.0) (2026-07-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add persistent session goals ([#803](https://github.com/Gitlawb/zero/issues/803)) ([5d1869e](https://github.com/Gitlawb/zero/commit/5d1869e7bc193c97e07418b57b823905c1151d5d))
|
||||
* **providers:** add Fireworks AI provider preset ([#772](https://github.com/Gitlawb/zero/issues/772)) ([58daea7](https://github.com/Gitlawb/zero/commit/58daea7a482927e5ab35afaf97761c60bb35e113))
|
||||
* **skills:** report hash drift in skill info ([#795](https://github.com/Gitlawb/zero/issues/795)) ([9fbdc34](https://github.com/Gitlawb/zero/commit/9fbdc341551601c13434cf7b9b6f5e4ea4c483b1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** interrupt idle reads on cancellation ([#782](https://github.com/Gitlawb/zero/issues/782)) ([15b7a54](https://github.com/Gitlawb/zero/commit/15b7a5475cfeefddbc59634551451a8872b996b7))
|
||||
* **cli:** include plugins info in shell completions ([#794](https://github.com/Gitlawb/zero/issues/794)) ([fa8734f](https://github.com/Gitlawb/zero/commit/fa8734f17927de8832dfe041e043cfb86f93d12a))
|
||||
* **config:** kill provider-command process tree via job object on Windows ([#690](https://github.com/Gitlawb/zero/issues/690)) ([5c58256](https://github.com/Gitlawb/zero/commit/5c58256abc29c185b449f8ce79647eb3fae61882))
|
||||
* **config:** make the provider-command timeout an upper bound ([#811](https://github.com/Gitlawb/zero/issues/811)) ([#813](https://github.com/Gitlawb/zero/issues/813)) ([1c38e8c](https://github.com/Gitlawb/zero/commit/1c38e8cba50db255f3cda8b7cf4298712b2933e1))
|
||||
* **config:** stop billing provider-command fixtures to the 5s timeout ([#810](https://github.com/Gitlawb/zero/issues/810)) ([f5fa4ff](https://github.com/Gitlawb/zero/commit/f5fa4ffc64537897aa78a56edca36d8f1dacd021))
|
||||
* **cron:** reserve job IDs atomically ([#686](https://github.com/Gitlawb/zero/issues/686)) ([d9b882e](https://github.com/Gitlawb/zero/commit/d9b882eb0a94f38de8447cb54b57860ccd8ed053))
|
||||
* **lint:** resolve static analysis baseline ([#769](https://github.com/Gitlawb/zero/issues/769)) ([583e653](https://github.com/Gitlawb/zero/commit/583e6530bd849a8efcb2b88bbe8c297e8592e62c))
|
||||
* **providers:** retry provably pre-send transport failures ([#447](https://github.com/Gitlawb/zero/issues/447)) ([#750](https://github.com/Gitlawb/zero/issues/750)) ([8acd42a](https://github.com/Gitlawb/zero/commit/8acd42a3bd743a35807d762f23a33fb61c171839))
|
||||
* **sandbox:** add platform-specific write lock around grant state file ([#752](https://github.com/Gitlawb/zero/issues/752)) ([#755](https://github.com/Gitlawb/zero/issues/755)) ([88fb7b6](https://github.com/Gitlawb/zero/commit/88fb7b6192c1ac8173089967d5e9119f92d7a101))
|
||||
* **sandbox:** clarify scoped permission prompts ([#798](https://github.com/Gitlawb/zero/issues/798)) ([9fe02ae](https://github.com/Gitlawb/zero/commit/9fe02ae52e3234cb9dae76e0c5594a287ccbb52a))
|
||||
* **sandbox:** deny reads of git's credential stores ([#815](https://github.com/Gitlawb/zero/issues/815)) ([#816](https://github.com/Gitlawb/zero/issues/816)) ([33c94ec](https://github.com/Gitlawb/zero/commit/33c94ec7a3e7987e6e36b5fd5e037ff4e6e84c26))
|
||||
* **sandbox:** preserve user config and retry denied writes ([#801](https://github.com/Gitlawb/zero/issues/801)) ([81a5e6c](https://github.com/Gitlawb/zero/commit/81a5e6cb716ba4b7ecf7ff3ff261dfa51fd01098))
|
||||
* **sandbox:** scrub the daemon token file pointer ([#677](https://github.com/Gitlawb/zero/issues/677)) ([#818](https://github.com/Gitlawb/zero/issues/818)) ([3e7692d](https://github.com/Gitlawb/zero/commit/3e7692d8f0885d346809869935c81a9cf055d89e))
|
||||
* **swarm:** close lifecycle admission on shutdown ([#776](https://github.com/Gitlawb/zero/issues/776)) ([bdbda89](https://github.com/Gitlawb/zero/commit/bdbda89f3e42d93e8058ab3e1e9ef402659530cd))
|
||||
* **tui:** fix rendering corruption over multipass + Windows Terminal ([#709](https://github.com/Gitlawb/zero/issues/709)) ([5bb1bc1](https://github.com/Gitlawb/zero/commit/5bb1bc11c3188dcea7709ff07c8b25dda3541de8))
|
||||
* **tui:** paste clipboard images with Ctrl+V ([#534](https://github.com/Gitlawb/zero/issues/534)) ([#807](https://github.com/Gitlawb/zero/issues/807)) ([ec3b822](https://github.com/Gitlawb/zero/commit/ec3b8225050eb62f0f41a9582ed2881d2f9dcc8e))
|
||||
* **tui:** reset quiet clock for new runs ([#799](https://github.com/Gitlawb/zero/issues/799)) ([a4a805a](https://github.com/Gitlawb/zero/commit/a4a805a6c82c51cb255e909bd87ca183c8805cf2))
|
||||
* **windows:** use PowerShell for command execution ([#804](https://github.com/Gitlawb/zero/issues/804)) ([518d2e5](https://github.com/Gitlawb/zero/commit/518d2e53ba3fa073090a9b41c278face85b59b64))
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
|
||||
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
|
||||
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
|
||||
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
|
||||
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
|
||||
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
|
||||
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
|
||||
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
|
||||
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
|
||||
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
|
||||
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
|
||||
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
|
||||
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
|
||||
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
|
||||
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
|
||||
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
|
||||
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
|
||||
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
|
||||
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
|
||||
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
|
||||
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
|
||||
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
|
||||
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
|
||||
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
|
||||
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
|
||||
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
|
||||
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
|
||||
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
|
||||
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
|
||||
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
|
||||
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
|
||||
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
|
||||
* **cli:** wire MCP serve WorkspaceRoot and --add-dir scope ([#694](https://github.com/Gitlawb/zero/issues/694)) ([75a78e7](https://github.com/Gitlawb/zero/commit/75a78e715bf23154f69c8c79cb58eb4b535b2a2a))
|
||||
* **npm:** ship the native binary as platform optionalDependencies ([#626](https://github.com/Gitlawb/zero/issues/626)) ([5e1405d](https://github.com/Gitlawb/zero/commit/5e1405d0b7abff5b3ccb3cfdb66d64d6d3322922))
|
||||
* **perf:** emit prompt-prefix hash fingerprint per turn ([#704](https://github.com/Gitlawb/zero/issues/704)) ([1c5c6e7](https://github.com/Gitlawb/zero/commit/1c5c6e78a8a0e228bdf53d7be90934fbce9d98c3))
|
||||
* **providers:** add AI/ML API preset (rebased onto main) ([#621](https://github.com/Gitlawb/zero/issues/621)) ([d66a9dd](https://github.com/Gitlawb/zero/commit/d66a9dda69c32aa59f4bea903cfefe00d4b7adef))
|
||||
* **providers:** refresh MiniMax model coverage ([#665](https://github.com/Gitlawb/zero/issues/665)) ([fa3052a](https://github.com/Gitlawb/zero/commit/fa3052a1422a4ad30a3a6295829564f42dee31a8))
|
||||
* **skills:** discover shared ~/.agents/skills with multi-root skill loading ([#696](https://github.com/Gitlawb/zero/issues/696)) ([7d57999](https://github.com/Gitlawb/zero/commit/7d579996b43741a2e57e3e38fcdd8484fcfc34e9))
|
||||
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
|
||||
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
|
||||
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
|
||||
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
|
||||
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
|
||||
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
|
||||
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
|
||||
* **cron:** prevent cron job Mutate from clobbering concurrent updates ([#630](https://github.com/Gitlawb/zero/issues/630)) ([e4bd703](https://github.com/Gitlawb/zero/commit/e4bd703cfb28dab2dfa3c2ddba46237e1bb2e164))
|
||||
* **daemon:** handle os.ErrPermission as collision during O_EXCL lock creation ([#616](https://github.com/Gitlawb/zero/issues/616)) ([8ea5384](https://github.com/Gitlawb/zero/commit/8ea53841a5d54c37a153779ae86bea010659433c))
|
||||
* **exec:** stop false INCOMPLETE downgrades on conversational final messages ([#608](https://github.com/Gitlawb/zero/issues/608)) ([b6117af](https://github.com/Gitlawb/zero/commit/b6117af86d6bc87a4ee66910e99d76cb16b03fed))
|
||||
* harden MCP credential boundaries ([#597](https://github.com/Gitlawb/zero/issues/597)) ([fdddb05](https://github.com/Gitlawb/zero/commit/fdddb05ba84b1600ae6c3a20028bf83afe474c44))
|
||||
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
|
||||
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
|
||||
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
|
||||
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
|
||||
* **openai:** omit prompt_cache_key for openai-compatible providers ([#636](https://github.com/Gitlawb/zero/issues/636)) ([1af5882](https://github.com/Gitlawb/zero/commit/1af58828eb3c22567599c000736c913a290959d2)), closes [#624](https://github.com/Gitlawb/zero/issues/624)
|
||||
* **plugins:** resolve relative executable paths against plugin root ([#627](https://github.com/Gitlawb/zero/issues/627)) ([2efe6d5](https://github.com/Gitlawb/zero/commit/2efe6d539e29374b3ef39c2290bdea81f33a228b))
|
||||
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
|
||||
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
|
||||
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
|
||||
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
|
||||
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
|
||||
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
|
||||
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
|
||||
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
|
||||
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
|
||||
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
|
||||
* **tui:** resolve pending askUser callbacks to prevent runner hangs ([#620](https://github.com/Gitlawb/zero/issues/620)) ([aa73a76](https://github.com/Gitlawb/zero/commit/aa73a76f1bd1b6fe97bac2fbff2d61b7474139f2))
|
||||
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
|
||||
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **tools:** add explicit effect metadata for safe concurrency ([#705](https://github.com/Gitlawb/zero/issues/705)) ([8ef8576](https://github.com/Gitlawb/zero/commit/8ef8576df7d0775a5b815bc0776a794cadb75c34))
|
||||
|
||||
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* gate project-scoped hooks, plugins, and MCP servers behind workspace trust ([#529](https://github.com/Gitlawb/zero/issues/529)) ([a880ce8](https://github.com/Gitlawb/zero/commit/a880ce80a6ec72da511fb9bdf6dd69291c72a64b))
|
||||
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
|
||||
* **providers:** OAuth login profiles and list-first /provider manager ([#560](https://github.com/Gitlawb/zero/issues/560)) ([1655056](https://github.com/Gitlawb/zero/commit/16550569c1be615cfaf244dab25909ec37f6dee6))
|
||||
* **tui:** remember recent provider+model selections in /model picker ([#568](https://github.com/Gitlawb/zero/issues/568)) ([d0c4e62](https://github.com/Gitlawb/zero/commit/d0c4e62cd429a0614c15296934c740a08bc0e07b))
|
||||
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
|
||||
* voice dictation (speech-to-text) ([#557](https://github.com/Gitlawb/zero/issues/557)) ([87158a1](https://github.com/Gitlawb/zero/commit/87158a1c90b4f91fc5f2bb8178ebaf46d7654680))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
|
||||
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
|
||||
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
|
||||
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
|
||||
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
|
||||
* **mcp:** silence startup warning for unconfigured default servers ([#563](https://github.com/Gitlawb/zero/issues/563)) ([302f58b](https://github.com/Gitlawb/zero/commit/302f58bb5f2a03ec7230354ed4747e4e55c16c50))
|
||||
* **mcp:** skip RFC 8414 discovery when OAuth endpoints are preconfigured ([#586](https://github.com/Gitlawb/zero/issues/586)) ([8a52d98](https://github.com/Gitlawb/zero/commit/8a52d98cad7cd0086dee9aede4ce477e432bd385))
|
||||
* **modelregistry:** reject oversized models.dev cache responses ([#602](https://github.com/Gitlawb/zero/issues/602)) ([66a6396](https://github.com/Gitlawb/zero/commit/66a63964149fb2f07e646e5f1987627c5cd9ac28))
|
||||
* **provider:** stop dropping custom no-auth providers on restart ([#558](https://github.com/Gitlawb/zero/issues/558)) ([ba99fa8](https://github.com/Gitlawb/zero/commit/ba99fa8d487fb28f2700e0ff10b2a25c75303cf7))
|
||||
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
|
||||
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
|
||||
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
|
||||
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
|
||||
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
|
||||
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
|
||||
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
|
||||
* **update:** clearer error on unsupported release platform (android/termux) ([#603](https://github.com/Gitlawb/zero/issues/603)) ([1fc9b2d](https://github.com/Gitlawb/zero/commit/1fc9b2d25c79e089f34cb7b5b6a7f7c7b8233123))
|
||||
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
|
||||
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
|
||||
|
||||
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
|
||||
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
|
||||
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
|
||||
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
|
||||
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
|
||||
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
|
||||
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
|
||||
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
|
||||
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
|
||||
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
|
||||
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
|
||||
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
|
||||
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
|
||||
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
|
||||
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
|
||||
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
|
||||
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
|
||||
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
|
||||
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
|
||||
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
|
||||
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
|
||||
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
|
||||
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
|
||||
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
|
||||
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
|
||||
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
|
||||
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
|
||||
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
|
||||
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
|
||||
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
|
||||
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
|
||||
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
|
||||
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
|
||||
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
|
||||
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
|
||||
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
|
||||
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
|
||||
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
|
||||
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
|
||||
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
|
||||
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
|
||||
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
|
||||
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
|
||||
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
|
||||
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
|
||||
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
|
||||
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
|
||||
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
|
||||
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
|
||||
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Shared multi-agent skills discovery: when present, `~/.agents/skills` is searched after the primary
|
||||
Zero skills dir (and before plugin skill roots). `zero skills list` / `info` and the runtime `skill`
|
||||
tool share one multi-root discovery path; install/remove/lock still target only the Zero skills directory.
|
||||
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
|
||||
GitHub issue/PR templates.
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`,
|
||||
`solarized-light`, `dune`, and `neon` — selectable via `/theme <name>`, `--theme <name>`, or
|
||||
`ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally
|
||||
audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light
|
||||
theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
and `ZERO_NO_FADE`.
|
||||
|
||||
### Changed
|
||||
- Provider connectivity health checks now allow loopback hosts for explicitly user-configured local
|
||||
providers (Ollama / LM Studio), so the keyless local-model path verifies instead of failing with
|
||||
"localhost hosts are blocked". The SSRF guard for fetched/remote URLs is unchanged.
|
||||
- Auth (401/403) errors now show a curated, actionable message pointing at `zero auth` / setup; the
|
||||
raw upstream body is shown only under a verbose/debug flag.
|
||||
- No-provider / missing-key errors now point at `zero setup` and `zero auth`, and distinguish a
|
||||
missing key from a rejected key.
|
||||
- `zero doctor` no longer reports "Overall: pass" when no provider credential is configured, and
|
||||
formats the missing-language-server list for humans (no raw Go `map[...]`).
|
||||
- Raised the `faint`/`faintest` theme tokens (and the light-theme accent) to meet WCAG AA contrast for
|
||||
the content they carry.
|
||||
- `NO_COLOR` is now honored for any non-empty value, per the no-color.org spec.
|
||||
|
||||
### Removed
|
||||
- The inert `/input-style` slash command (it had no backend).
|
||||
|
||||
### Fixed
|
||||
- README/`go.mod` Go-version mismatch and other stale public-release docs claims.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a
|
||||
harassment-free experience for everyone, regardless of age, body size, visible or invisible
|
||||
disability, ethnicity, sex characteristics, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance, race, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and
|
||||
healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people.
|
||||
- Being respectful of differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes.
|
||||
- Focusing on what is best for the overall community.
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind.
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks.
|
||||
- Public or private harassment.
|
||||
- Publishing others' private information, such as a physical or email address, without their
|
||||
explicit permission.
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting.
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior
|
||||
and will take appropriate and fair corrective action in response to any behavior that they deem
|
||||
inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is
|
||||
officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the
|
||||
project maintainers through the repository's **Security → Report a vulnerability** channel (which
|
||||
delivers a confidential report to maintainers), or by contacting a maintainer directly. All
|
||||
complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
Maintainers are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at
|
||||
<https://www.contributor-covenant.org/version/2/1/code_of_conduct.html>.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
+5
-48
@@ -8,13 +8,6 @@ stabilizes. Community participation is still welcome, especially through bug
|
||||
reports, feature requests, reproduction details, logs, and discussion on existing
|
||||
issues.
|
||||
|
||||
Questions and setup help belong in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
|
||||
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
|
||||
an idea that gains traction there is the path to an approved issue and,
|
||||
eventually, an accepted PR.
|
||||
|
||||
Please read this policy before opening a pull request. Pull requests that do not
|
||||
follow it may be closed without review.
|
||||
|
||||
@@ -40,11 +33,7 @@ unless the issue has been explicitly approved for outside contribution.
|
||||
There are three primary ways to contribute:
|
||||
|
||||
1. Open a bug report with clear reproduction steps.
|
||||
2. Propose a feature in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
describing the problem, use case, and expected behavior. A feature-request
|
||||
issue is filed only after the idea gains traction there or a maintainer asks
|
||||
for one.
|
||||
2. Open a feature request describing the problem, use case, and expected behavior.
|
||||
3. Add useful context to an existing issue, such as logs, screenshots, examples,
|
||||
affected versions, or reproduction cases.
|
||||
|
||||
@@ -57,8 +46,7 @@ All community pull requests require an approved parent issue.
|
||||
|
||||
Before starting implementation work:
|
||||
|
||||
1. For a bug, open an issue using the bug-report template. For a feature, start
|
||||
in Discussions Ideas; the feature-request issue comes after the discussion.
|
||||
1. Open an issue using the appropriate issue template.
|
||||
2. Describe the bug, feature request, or functional change clearly.
|
||||
3. Wait for the core team to review the issue.
|
||||
4. Do not open a pull request unless the issue has the `issue-approved` label.
|
||||
@@ -67,28 +55,6 @@ An approved issue means only that the specific issue has been accepted for work.
|
||||
It does not approve unrelated future work, future pull requests, or future issues
|
||||
from the same author. Each issue is reviewed case by case.
|
||||
|
||||
## Contribution Volume and Batching
|
||||
|
||||
To keep triage and review sustainable while the project stabilizes, please keep
|
||||
your contribution flow to a size the core team can realistically work through:
|
||||
|
||||
- One issue per bug. File each defect as its own issue with its own
|
||||
reproduction. Please do not bundle several unrelated findings into a single
|
||||
"audit" issue; separate issues are far easier to triage, approve, and close
|
||||
individually.
|
||||
- Keep only a few items open at a time. If you already have several issues or
|
||||
pull requests awaiting review, let those move through the queue before opening
|
||||
more. A large simultaneous burst from one contributor slows review for
|
||||
everyone, even when each item is valid.
|
||||
- AI-assisted contributions are welcome, held to the same bar. Automated audits
|
||||
and generated patches are fine, but each issue and pull request must be
|
||||
individually verified, scoped, and reproducible before you open it. Please do
|
||||
not post bulk machine-generated findings unfiltered.
|
||||
|
||||
These apply to all contributors and are about review bandwidth, not the value of
|
||||
any single report. Maintainers may ask you to consolidate, split, or hold
|
||||
submissions to keep the queue manageable.
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
Before opening a community pull request, make sure all of the following are true:
|
||||
@@ -167,12 +133,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),
|
||||
not as an issue. The feature-request issue form exists for the step AFTER that:
|
||||
when an idea has gained traction in Discussions or a maintainer has asked for an
|
||||
issue to track it. Whether in the discussion or the eventual issue, please
|
||||
include:
|
||||
When filing a feature request, please include:
|
||||
|
||||
- The problem you are trying to solve.
|
||||
- The behavior or functionality you are requesting.
|
||||
@@ -195,9 +156,5 @@ contribution phase.
|
||||
|
||||
## Getting Help
|
||||
|
||||
General questions, setup help, and "is this a bug or my config" uncertainty all
|
||||
go to
|
||||
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
|
||||
If you are unsure whether a proposed code change is appropriate, raise it in
|
||||
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
|
||||
focused, specific, and respectful of maintainer time.
|
||||
If you are unsure whether a change is appropriate, open an issue first and ask.
|
||||
Please keep discussion focused, specific, and respectful of maintainer time.
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gitlawb
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
|
||||
# with `make` — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
|
||||
GO_TOOLCHAIN = go$(GO_VERSION)
|
||||
DEADCODE_VERSION := v0.46.0
|
||||
GOLANGCI_LINT_VERSION := v2.12.2
|
||||
GOVULNCHECK_VERSION := v1.3.0
|
||||
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
go build -o zero ./cmd/zero
|
||||
|
||||
# Build every command in cmd/.
|
||||
build-all:
|
||||
go build ./...
|
||||
|
||||
# Run the full test suite with the race detector (matches CI expectations).
|
||||
test:
|
||||
go test ./... -race -count=1
|
||||
|
||||
# Faster, no race detector.
|
||||
test-quick:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w $(shell git ls-files '*.go')
|
||||
|
||||
# Fail if any tracked Go file is not gofmt-clean.
|
||||
fmt-check:
|
||||
@out="$$(gofmt -l $$(git ls-files '*.go'))"; \
|
||||
if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
|
||||
|
||||
# Lint = formatting check + vet (no extra tooling required).
|
||||
lint: fmt-check vet
|
||||
|
||||
# Versioned tools select the toolchain from their own modules when invoked with
|
||||
# package@version. Read this module's go directive directly, without invoking
|
||||
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
||||
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
||||
# The target-specific export is shell-independent.
|
||||
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
|
||||
deadcode:
|
||||
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
|
||||
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -f zero
|
||||
go clean ./...
|
||||
|
||||
# Run the per-turn benchmark harness over the checked-in baseline manifest and
|
||||
# write the JSON result to internal/perfbench/reports/baseline.json. Requires a
|
||||
# built `zero` binary and a model; set ZERO_BENCH_MODEL (required) and
|
||||
# ZERO_BENCH_BINARY (defaults to ./zero) to configure the run. The report is
|
||||
# machine-specific and regenerated, not hand-edited.
|
||||
baseline: build
|
||||
@if [ -z "$(ZERO_BENCH_MODEL)" ]; then echo "Set ZERO_BENCH_MODEL (and optionally ZERO_BENCH_BINARY) before running 'make baseline'"; exit 2; fi
|
||||
@ZERO_BIN="$${ZERO_BENCH_BINARY:-./zero}"; \
|
||||
go run ./cmd/zero-perf-bench turn \
|
||||
--suite internal/perfbench/manifests/baseline.json \
|
||||
--model $(ZERO_BENCH_MODEL) \
|
||||
--binary "$$ZERO_BIN" \
|
||||
--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"
|
||||
@@ -1,443 +1,300 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
<p align="center"><strong>A terminal coding agent you own.</strong></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.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
### The terminal coding agent you fully own.
|
||||
|
||||
Zero is an AI coding agent for your local terminal. It can inspect a repository,
|
||||
edit files, run commands, use browser/terminal helpers, and keep durable local
|
||||
sessions while you choose the model and the permission level.
|
||||
**Any model. Any provider. Your rules.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Zero is an AI coding agent that lives in your terminal. It runs a full agentic loop — reading, searching, editing, and executing in your repo — against **whatever model you choose**: frontier APIs, fast cloud inference, or a local model on your own machine. One interface, no vendor lock-in, no telemetry phoning home.
|
||||
|
||||
```bash
|
||||
zero
|
||||
zero exec "fix the failing test in ./pkg"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
zero # interactive TUI
|
||||
zero exec "fix the failing test in ./pkg" # headless one-shot
|
||||
zero exec -o stream-json < turns.jsonl # programmatic, for scripts & CI
|
||||
```
|
||||
|
||||
> Zero treats the **model as a swappable, per-task choice** and **never mutates your system without a permission decision**.
|
||||
|
||||
## 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
|
||||
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.
|
||||
- **Works in the terminal.** The TUI has model/provider pickers, image input,
|
||||
slash commands, live plan/tool rendering, scrollback, themes, and resume/fork
|
||||
support.
|
||||
- **Works without the TUI.** `zero exec` is scriptable, supports text/JSON/
|
||||
stream-JSON I/O, isolated worktrees, spec-first runs, and meaningful exit
|
||||
codes for CI.
|
||||
- **Keeps context local.** Sessions are stored on disk, searchable, resumable,
|
||||
and never uploaded as telemetry by Zero.
|
||||
- **Extensible when you need it.** Use MCP servers, skills, plugins, hooks, and
|
||||
specialist subagents from the same CLI.
|
||||
- 🔌 **25+ providers, one interface** — OpenAI, Anthropic, Gemini, Ollama (local & cloud), LM Studio, OpenRouter, Groq, DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, and any OpenAI- or Anthropic-compatible endpoint. Switch mid-session with `/model`.
|
||||
- 🖥️ **A TUI that feels premium** — truecolor Bubble Tea interface with a first-run setup wizard, searchable live model picker, scrollback, themes, image input, and slash commands for everything.
|
||||
- 🤖 **Headless & scriptable** — `zero exec` with `text` / `json` / `stream-json` I/O, session resume & fork, isolated `--worktree` runs, and meaningful exit codes. Built for CI.
|
||||
- 🧠 **Subagents** — delegate to built-in `worker`, `explorer`, and `code-review` specialists (or generate your own) that run as real background tasks, even out-of-process.
|
||||
- 📋 **Spec mode** — have the agent draft a spec first, review and approve it, *then* let it build. No more runaway sessions.
|
||||
- 📈 **Mid-run model escalation** — start cheap, and let the agent request a stronger model only when it hits a wall (`--allow-escalation`).
|
||||
- 🗺️ **Repo intelligence** — deterministic repo maps, workspace indexing, and context-budget reports keep the agent grounded in *your* codebase, not hallucinations.
|
||||
- ⏰ **Scheduled agents** — `zero cron` runs file-backed, dependency-free agent jobs on a schedule.
|
||||
- 🛡️ **Safe by default** — permission-gated mutations, autonomy ceilings, sandbox policy (writes stay inside the workspace unless you grant extra directories with `--add-dir` / `/add-dir`), and secret redaction everywhere. Unsafe mode is an explicit, loudly-labeled opt-in.
|
||||
- 💾 **Durable sessions** — append-only local event store with full-text search, resume, fork, and rewind. Your history never leaves your disk.
|
||||
- 🧩 **Extensible** — skills, plugins, hooks, and MCP (Zero is both an MCP client *and* an MCP server).
|
||||
|
||||
## Install
|
||||
|
||||
### npm
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The npm package is a small wrapper whose platform build (Linux and macOS on
|
||||
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
|
||||
installs as an optional dependency straight from the npm registry — no install
|
||||
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
|
||||
no trust or approval steps. Installs that skip optional dependencies
|
||||
(`--omit=optional`) still work: the wrapper fetches the binary from the
|
||||
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.
|
||||
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
# run from source (requires Go 1.24+)
|
||||
go run ./cmd/zero
|
||||
|
||||
# or install a release binary
|
||||
scripts/install.sh # Linux / macOS
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 # Windows
|
||||
```
|
||||
|
||||
Release installers and the npm wrapper require published GitHub Release assets.
|
||||
If you are testing before the first public release, build from source:
|
||||
First launch opens a **guided setup wizard** — pick a provider, paste a key, choose a model, done. Or do it non-interactively:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, ...
|
||||
zero setup # guided first-run provider setup
|
||||
zero doctor # verify config, keys, and connectivity
|
||||
```
|
||||
|
||||
On Linux, build the sandbox helper too if you want native sandboxing:
|
||||
Local models need no key at all:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
|
||||
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
|
||||
Windows source builds can use the main `zero.exe` as their sandbox helper; release
|
||||
archives still ship standalone Windows helper executables.
|
||||
|
||||
More install details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
## First Run
|
||||
|
||||
Start the TUI:
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
The setup wizard helps you pick a provider and model. You can also configure
|
||||
providers from the command line:
|
||||
|
||||
```bash
|
||||
zero setup
|
||||
# Ollama or LM Studio running locally? Zero finds them.
|
||||
zero providers list
|
||||
zero models list
|
||||
zero doctor
|
||||
```
|
||||
|
||||
For API providers, set the matching environment variable before setup or enter
|
||||
the key in the wizard:
|
||||
## The TUI
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
Type to chat, **Enter** to send. `/` opens command suggestions, **Shift+Tab** cycles permission modes, **Ctrl+C** exits.
|
||||
|
||||
To configure AI/ML API directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup aimlapi --set-active
|
||||
```
|
||||
|
||||
To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
To configure Fireworks AI directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup fireworks --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
```bash
|
||||
zero providers add minimax --set-active
|
||||
zero providers add minimaxi-cn --set-active
|
||||
```
|
||||
|
||||
To use the OpenAI-compatible endpoints instead, add a custom compatible profile
|
||||
for the required region:
|
||||
|
||||
```bash
|
||||
zero providers add custom-openai-compatible \
|
||||
--name minimax-openai \
|
||||
--model MiniMax-M3 \
|
||||
--base-url https://api.minimax.io/v1 \
|
||||
--api-key-env MINIMAX_API_KEY \
|
||||
--set-active
|
||||
|
||||
zero providers add custom-openai-compatible \
|
||||
--name minimax-cn-openai \
|
||||
--model MiniMax-M3 \
|
||||
--base-url https://api.minimaxi.com/v1 \
|
||||
--api-key-env MINIMAXI_API_KEY \
|
||||
--set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
|
||||
## Daily Use
|
||||
|
||||
### Interactive TUI
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
Useful controls:
|
||||
|
||||
| Control | Action |
|
||||
| | |
|
||||
|---|---|
|
||||
| `Enter` | send the prompt |
|
||||
| `/` | open slash-command suggestions |
|
||||
| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) |
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
||||
| `/model` `/provider` | switch model or provider mid-session (searchable picker) |
|
||||
| `/spec` `/plan` | spec-mode drafting and live plan view |
|
||||
| `/image` | attach images for vision models |
|
||||
| `/resume` `/rewind` | time-travel across sessions |
|
||||
| `/compact` `/context` | manage the context window |
|
||||
| `/permissions` `/tools` | inspect what the agent can touch |
|
||||
| `/add-dir` | grant an extra write directory for the session, or list current write roots |
|
||||
| `/theme` `/style` | make it yours |
|
||||
| `/doctor` `/usage` `/config` | health, cost, and config without leaving the chat |
|
||||
|
||||
Common slash commands:
|
||||
Turn-completion notifications (terminal bell / OSC-9) ping you when the agent finishes or needs input — go make coffee.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `/model`, `/provider` | switch the active model/provider |
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
| `/theme`, `/doctor`, `/config` | adjust appearance and inspect setup |
|
||||
|
||||
### Headless `exec`
|
||||
## Headless `exec`
|
||||
|
||||
```bash
|
||||
zero exec "explain internal/agent/loop.go"
|
||||
zero exec --model claude-sonnet-4.5 "refactor the config loader"
|
||||
# one-shot
|
||||
zero exec "explain internal/agent/loop.go and suggest one improvement"
|
||||
|
||||
# pick a model and mode preset per task
|
||||
zero exec --model claude-sonnet-4.5 --mode deep "refactor the session store"
|
||||
|
||||
# spec-first: draft → review → approve → build
|
||||
zero exec --use-spec "add rate limiting to the API client"
|
||||
zero exec --worktree "try the migration in an isolated worktree"
|
||||
zero exec --resume
|
||||
zero exec --fork <session-id> "try the other approach"
|
||||
```
|
||||
|
||||
Programmatic use:
|
||||
# run in an isolated git worktree, escalate model only if needed
|
||||
zero exec -w --allow-escalation "migrate the config loader to v2"
|
||||
|
||||
```bash
|
||||
# multi-turn programmatic I/O over stdio
|
||||
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||
|
||||
# resume or fork any previous session
|
||||
zero exec --resume # latest
|
||||
zero exec --fork <session-id> "now try the other approach"
|
||||
```
|
||||
|
||||
The stream-JSON contract is documented in
|
||||
[docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md).
|
||||
Key flags: `-m/--model` · `--mode <smart|deep|fast|large|precise>` · `--image` · `--use-spec` · `--auto <low|medium|high>` · `--enabled-tools/--disabled-tools` · `-w/--worktree` · `--add-dir <path>` (repeatable) · `--resume/--fork` · `--allow-escalation` · `--notify` · `-o <text|json|stream-json>`.
|
||||
|
||||
## Safety Model
|
||||
stdout carries **only** program output; logs go to stderr. Full contract in [`docs/STREAM_JSON_PROTOCOL.md`](docs/STREAM_JSON_PROTOCOL.md).
|
||||
|
||||
Zero is designed to make side effects visible.
|
||||
## Commands
|
||||
|
||||
- Workspace reads are allowed by default.
|
||||
- File writes are limited to the workspace unless you grant another directory.
|
||||
- Shell commands, network access, destructive commands, and elevated actions are
|
||||
permission-gated.
|
||||
- `--add-dir <path>` and `/add-dir <path>` grant additional write roots without
|
||||
giving the agent the whole filesystem.
|
||||
- Unsafe/autonomous modes are explicit opt-ins.
|
||||
- Secrets are redacted from tool output and logs where Zero controls the surface.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
zero --add-dir ../docs-site
|
||||
zero exec --add-dir ../shared "update both repos"
|
||||
```
|
||||
|
||||
Sandbox behavior can be inspected with:
|
||||
|
||||
```bash
|
||||
zero sandbox policy
|
||||
zero sandbox grants list
|
||||
```
|
||||
|
||||
## Web And Local Control
|
||||
|
||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||
and MCP support for additional tools.
|
||||
|
||||
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
|
||||
TUI.
|
||||
|
||||
The npm package also includes browser and terminal helper packages used by local
|
||||
browser/terminal tools. Source builds can use the same helpers when they are on
|
||||
`PATH` or configured in Zero's local-control settings.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```text
|
||||
zero interactive TUI
|
||||
zero exec one-shot or scripted agent run
|
||||
zero setup first-run provider setup
|
||||
zero auth OAuth/login helpers for supported providers
|
||||
zero models model registry and capabilities
|
||||
zero providers provider profiles and detection
|
||||
zero doctor setup, key, and connectivity checks
|
||||
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 spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
zero plugins manage plugins
|
||||
zero hooks manage lifecycle hooks
|
||||
zero mcp manage MCP servers and tools
|
||||
zero serve --mcp expose Zero tools over MCP stdio
|
||||
zero sandbox inspect sandbox policy and grants
|
||||
zero worktrees prepare isolated git worktrees
|
||||
zero verify detect and run local verification checks
|
||||
zero changes inspect and commit local git changes
|
||||
zero exec one-shot / scripted agent runs
|
||||
zero setup guided first-run provider setup
|
||||
zero models model registry (capabilities, context, cost)
|
||||
zero providers provider profiles + 25-provider catalog
|
||||
zero doctor config, key, and connectivity health checks
|
||||
zero context workspace context-budget report
|
||||
zero repo-map deterministic repository map for agent context
|
||||
zero repo-info local (network-free) repository characterizer
|
||||
zero search | find full-text search over local session history
|
||||
zero sessions session lineage inspection
|
||||
zero spec review & approve saved spec-mode drafts
|
||||
zero specialist manage subagent profiles
|
||||
zero skills markdown instruction skills
|
||||
zero plugins plugin manifests
|
||||
zero hooks lifecycle hook configuration
|
||||
zero mcp MCP client settings
|
||||
zero serve --mcp expose Zero's tools over MCP stdio
|
||||
zero sandbox sandbox policy & persistent grants
|
||||
zero worktrees isolated git worktrees for agent runs
|
||||
zero verify detect & run local verification checks
|
||||
zero changes inspect & commit local git changes
|
||||
zero usage token usage and estimated cost
|
||||
zero cron scheduled agent jobs
|
||||
zero cron scheduled agent jobs (file-backed, dep-free)
|
||||
zero update check for newer releases
|
||||
```
|
||||
|
||||
## Extending Zero
|
||||
## Providers
|
||||
|
||||
### Project and personal instructions
|
||||
Bring your own key — or no key at all for local runtimes.
|
||||
|
||||
Zero appends project-specific guidance to the system prompt from the first
|
||||
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
|
||||
the git root down to your current working directory (checked in that order
|
||||
per directory). Files are injected general-to-specific, capped at 8 KiB per
|
||||
file and 32 KiB total.
|
||||
|
||||
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
|
||||
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
|
||||
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
|
||||
|
||||
### Plugins
|
||||
|
||||
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
|
||||
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
|
||||
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
|
||||
(project scope — resolved from the current working directory, not the repo
|
||||
root), and managed with `zero plugins`. A manifest can declare:
|
||||
|
||||
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
|
||||
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
|
||||
auto-approval is enabled)
|
||||
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
|
||||
`sessionEnd`
|
||||
- `prompts` and `skills` — additional prompt/skill files
|
||||
|
||||
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
|
||||
the same extension points and can also be wired up outside of a plugin
|
||||
manifest.
|
||||
|
||||
## Appearance And Accessibility
|
||||
|
||||
| Control | Effect |
|
||||
| Tier | Providers |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
| **Frontier APIs** | OpenAI · Anthropic · Google Gemini |
|
||||
| **Fast cloud inference** | Groq · OpenRouter · Together AI · DeepSeek · Mistral · xAI · NVIDIA NIM |
|
||||
| **Local — no key, no cloud** | Ollama · LM Studio |
|
||||
| **More clouds** | Ollama Cloud · DashScope (Qwen) · Moonshot (Kimi) · MiniMax · Z.ai · Venice · GitHub Models · and more |
|
||||
| **Enterprise (catalog)** | Amazon Bedrock · Vertex AI *(adapters in progress)* |
|
||||
| **Anything else** | any OpenAI-compatible or Anthropic-compatible endpoint |
|
||||
|
||||
Meaning does not rely on color alone; diffs, permissions, and statuses also use
|
||||
text or glyph markers.
|
||||
The model registry tracks each model's capabilities, context window, and cost — and the live model picker discovers what your provider actually serves.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose | Side effect |
|
||||
|---|---|---|
|
||||
| `read_file` · `list_directory` · `grep` · `glob` | explore & search | read |
|
||||
| `web_fetch` | fetch docs & references | network |
|
||||
| `update_plan` · `ask_user` | plan & clarify | none |
|
||||
| `write_file` · `edit_file` · `apply_patch` | create & modify | write (gated) |
|
||||
| `bash` | run shell commands | shell (gated) |
|
||||
| `Task` · `TaskOutput` · `TaskStop` | delegate to specialist subagents | per-tool gating |
|
||||
| `GenerateSpecialist` | create new subagent manifests | write (gated) |
|
||||
| `skill` | load markdown instruction skills | read |
|
||||
| `tool_search` | lazily load deferred tools (large MCP sets stay cheap) | none |
|
||||
| `escalate_model` | request a stronger model mid-run | gated by `--allow-escalation` |
|
||||
|
||||
Every mutating tool routes through the permission policy **before** any side effect.
|
||||
|
||||
### Extra write directories (`--add-dir`)
|
||||
|
||||
Zero confines writes to the workspace by default. To let the agent write somewhere
|
||||
else, pass the repeatable `--add-dir` flag — it works for both the interactive TUI
|
||||
and `zero exec`:
|
||||
|
||||
```bash
|
||||
zero --add-dir ~/Desktop/scratch # launch the TUI with an extra write root
|
||||
zero exec --add-dir ../sibling-repo "update both repos"
|
||||
```
|
||||
|
||||
In the TUI, `/add-dir <path>` grants a directory mid-session (session-only), and a
|
||||
bare `/add-dir` lists the current write roots. To persist extra roots across
|
||||
sessions, set `sandbox.additionalWriteRoots` in the **global** user config
|
||||
(`~/.config/zero/config.json`); the key is deliberately ignored in project config
|
||||
so a checked-out repo can't widen its own sandbox. Flag and config sources merge
|
||||
as a union.
|
||||
|
||||
Granted roots must already exist (the filesystem root is rejected), symlinks are
|
||||
resolved when the grant is made, and the same per-root symlink-traversal checks
|
||||
that protect the workspace apply to each extra root. Relative paths in tool calls
|
||||
still resolve against the workspace only, and network and destructive-shell policy
|
||||
are unchanged. A write denied outside all roots returns an error that suggests
|
||||
`/add-dir`.
|
||||
|
||||
Two extra hardening flags are available in the `sandbox` config block, both **off
|
||||
by default** and safe to leave unset:
|
||||
|
||||
- `sandbox.blockUnixSockets` (Linux) — on the bubblewrap backend, installs a
|
||||
seccomp filter that denies `AF_UNIX` socket creation in the sandboxed command,
|
||||
closing the Unix-socket channel that filesystem/network isolation leaves open.
|
||||
Degrades gracefully (runs without the filter) when the `zero-seccomp` helper is
|
||||
not installed alongside the binary.
|
||||
- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt
|
||||
denials and appends them to a command's stderr as a `<sandbox_violations>` block
|
||||
so blocked operations are visible. A no-op on OS versions that do not deliver
|
||||
seatbelt denials to the queryable log.
|
||||
|
||||
When the network policy is `scoped`, the `web_fetch` and `web_search` tools honor
|
||||
the same allow/deny domain list as sandboxed shell egress (host allowlisting is
|
||||
enforced on the macOS `sandbox-exec` backend; other backends collapse `scoped` to
|
||||
`deny` for these tools).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TUI (Bubble Tea) headless exec MCP server cron runner
|
||||
└──────────────────────┬──────────────────────┘
|
||||
surface-agnostic agent core
|
||||
(loop · typed event stream · tool registry)
|
||||
┌──────────┬──────────┬───────────┬───────────┬────────────┬──────────┐
|
||||
providers tools sessions specialist repo intel permissions
|
||||
+ catalog registry + search + background + workspace + sandbox
|
||||
+ registry + rewind tasks index + redaction
|
||||
```
|
||||
|
||||
- **Surface-agnostic core** — the agent loop streams text + tool calls and emits one typed event stream consumed identically by the TUI, `exec`, the MCP server, and cron.
|
||||
- **Edges are interfaces** — `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
|
||||
- **Model is data** — capabilities, cost, and routing live in the registry, never hard-coded.
|
||||
- **Pure Go** — one static binary per platform; the npm wrapper just delegates to it.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
cmd/
|
||||
zero/ production CLI entrypoint
|
||||
zero-release/ release builder + smoke tests
|
||||
zero-perf-bench/ performance benchmarks
|
||||
zero-pr-review/ deterministic PR review helper
|
||||
internal/
|
||||
agent/ zeroruntime/ agent loop & runtime orchestration
|
||||
cli/ command surface (exec, doctor, cron, ...)
|
||||
tui/ Bubble Tea terminal interface
|
||||
providers/ providercatalog/ providermodelcatalog/
|
||||
modelregistry/ capabilities, context windows, cost
|
||||
tools/ read/write/edit/bash/grep/glob/patch/...
|
||||
specialist/ background/ subagents + out-of-process tasks
|
||||
sessions/ search/ append-only store, full-text search
|
||||
repomap/ repoinfo/ workspaceindex/ contextreport/
|
||||
specmode/ cron/ skills/ plugins/ hooks/ mcp/
|
||||
sandbox/ redaction/ secrets/ safety surfaces
|
||||
doctor/ providerhealth/ verify/ selfverify/
|
||||
docs/ PRD, protocols, install/update/perf
|
||||
scripts/ installers
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-perf-bench
|
||||
```
|
||||
go test ./... # full test suite (200+ test files)
|
||||
go run ./cmd/zero-release build # compile the release binary
|
||||
go run ./cmd/zero-release smoke # smoke-test it
|
||||
go run ./cmd/zero-perf-bench # perf benchmarks (docs/PERFORMANCE.md)
|
||||
|
||||
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.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
|
||||
|
||||
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
|
||||
2. **Vetting**: Run `make vet` (or `go vet ./...`).
|
||||
3. **Linting**: Run `make lint-static`.
|
||||
4. **Vulnerability Scan**: Run `make vulncheck`.
|
||||
|
||||
Use the repository-managed targets rather than globally installed binaries:
|
||||
the targets apply the module's required Go toolchain as well as the reviewed,
|
||||
pinned tool versions.
|
||||
|
||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||
run them directly. If it isn't, add it:
|
||||
|
||||
```bash
|
||||
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
||||
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
||||
```
|
||||
|
||||
### Cross-Compile Examples
|
||||
|
||||
```bash
|
||||
# cross-compile
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Install](docs/INSTALL.md)
|
||||
- [Update flow](docs/UPDATE.md)
|
||||
- [Themes](docs/THEMES.md)
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [Specialists](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
- [Benchmarks](docs/BENCHMARK.md)
|
||||
- [Performance](docs/PERFORMANCE.md)
|
||||
- [Agent evals](docs/AGENT_EVALS.md)
|
||||
|
||||
## Community
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
| Category | Use it for |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
|
||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
|
||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
|
||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
|
||||
|
||||
For a good Q&A answer fast, include `zero --version`, your OS and install
|
||||
method, the provider/model in use, and `zero doctor` output. See
|
||||
[SUPPORT.md](SUPPORT.md). Bugs belong in
|
||||
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
|
||||
follow [SECURITY.md](SECURITY.md), never a public thread.
|
||||
- [Product Requirements (PRD)](docs/PRD.md) — vision, full feature spec, roadmap
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract
|
||||
- [Specialists](docs/SPECIALISTS.md) — subagent manifests, Task tools, background state
|
||||
- [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md) · [Performance](docs/PERFORMANCE.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
|
||||
relevant tests, and open a focused pull request.
|
||||
|
||||
Security reports should follow [SECURITY.md](SECURITY.md).
|
||||
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go test ./...` and the relevant build or smoke command before opening a PR.
|
||||
|
||||
## License
|
||||
|
||||
Zero is released under the [MIT License](LICENSE).
|
||||
License is being finalized; a `LICENSE` file will be added before the public release.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub><b>Zero</b> — one terminal · every model</sub>
|
||||
</div>
|
||||
-280
@@ -1,280 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></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.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
|
||||
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
|
||||
|
||||
```bash
|
||||
zero
|
||||
zero exec "修复 ./pkg 中失败的测试"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
|
||||
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
|
||||
|
||||
## 安装
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.5+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
|
||||
```
|
||||
|
||||
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
|
||||
|
||||
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
|
||||
|
||||
## 首次运行
|
||||
|
||||
启动 TUI:
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
|
||||
|
||||
```bash
|
||||
zero setup
|
||||
zero providers list
|
||||
zero models list
|
||||
zero doctor
|
||||
```
|
||||
|
||||
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||
|
||||
## 日常使用
|
||||
|
||||
### 交互式 TUI
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
常用控制:
|
||||
|
||||
| 控制 | 操作 |
|
||||
|---|---|
|
||||
| `Enter` | 发送提示 |
|
||||
| `/` | 打开斜杠命令建议 |
|
||||
| `Shift+Tab` | 切换权限模式 |
|
||||
| `Ctrl+B` | 显示/隐藏侧边栏 |
|
||||
| `Ctrl+C` | 取消或退出 |
|
||||
|
||||
常用斜杠命令:
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `/model`、`/provider` | 切换活动模型/提供商 |
|
||||
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||
| `/image` | 为视觉模型附加图片 |
|
||||
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||
| `/compact`、`/context` | 管理上下文使用 |
|
||||
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
|
||||
|
||||
### 无头 `exec` 模式
|
||||
|
||||
```bash
|
||||
zero exec "解释 internal/agent/loop.go"
|
||||
zero exec --model claude-sonnet-4.5 "重构配置加载器"
|
||||
zero exec --use-spec "为 API 客户端添加速率限制"
|
||||
zero exec --worktree "在隔离的工作树中尝试迁移"
|
||||
zero exec --resume
|
||||
zero exec --fork <session-id> "尝试另一种方法"
|
||||
```
|
||||
|
||||
编程使用:
|
||||
|
||||
```bash
|
||||
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
Zero 旨在使副作用可见。
|
||||
|
||||
- 工作区读取默认允许。
|
||||
- 文件写入限制在工作区内,除非你授予其他目录。
|
||||
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
|
||||
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
|
||||
- 不安全/自主模式是显式选择加入。
|
||||
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
zero --add-dir ../docs-site
|
||||
zero exec --add-dir ../shared "更新两个仓库"
|
||||
```
|
||||
|
||||
沙箱行为可以通过以下方式检查:
|
||||
|
||||
```bash
|
||||
zero sandbox policy
|
||||
zero sandbox grants list
|
||||
```
|
||||
|
||||
## Web 和本地控制
|
||||
|
||||
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
|
||||
|
||||
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
|
||||
|
||||
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```text
|
||||
zero 交互式 TUI
|
||||
zero exec 一次性或脚本化智能体运行
|
||||
zero setup 首次运行提供商设置
|
||||
zero auth 支持提供商的 OAuth/登录辅助
|
||||
zero models 模型注册表和能力
|
||||
zero providers 提供商配置和检测
|
||||
zero doctor 设置、密钥和连接检查
|
||||
zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
zero plugins 管理插件
|
||||
zero hooks 管理生命周期钩子
|
||||
zero mcp 管理 MCP 服务器和工具
|
||||
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
|
||||
zero sandbox 检查沙箱策略和授权
|
||||
zero worktrees 准备隔离的 Git 工作树
|
||||
zero verify 检测和运行本地验证检查
|
||||
zero changes 检查和提交本地 Git 变更
|
||||
zero usage Token 使用量和估算成本
|
||||
zero cron 定时智能体任务
|
||||
zero update 检查更新版本
|
||||
```
|
||||
|
||||
## 外观和无障碍
|
||||
|
||||
| 控制 | 效果 |
|
||||
|---|---|
|
||||
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
|
||||
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
|
||||
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
|
||||
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
|
||||
|
||||
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-perf-bench
|
||||
```
|
||||
|
||||
交叉编译示例:
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [安装](docs/INSTALL.md)
|
||||
- [更新流程](docs/UPDATE.md)
|
||||
- [主题](docs/THEMES.md)
|
||||
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [专家](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
- [基准测试](docs/BENCHMARK.md)
|
||||
- [性能](docs/PERFORMANCE.md)
|
||||
- [智能体评估](docs/AGENT_EVALS.md)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
|
||||
|
||||
安全报告应遵循 [SECURITY.md](SECURITY.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
Zero 基于 [MIT 许可证](LICENSE) 发布。
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
Zero is a terminal coding agent: it reads and edits files in your repository and can run shell
|
||||
commands and call external model/tool endpoints. We take security reports seriously.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's private vulnerability reporting:
|
||||
|
||||
1. Go to the repository's **Security** tab → **Report a vulnerability** (GitHub Security Advisories).
|
||||
2. Describe the issue, the affected version/commit, reproduction steps, and the impact.
|
||||
|
||||
If you cannot use GitHub Security Advisories, open a regular issue that contains **only** a request to
|
||||
be contacted for a security report (no details), and a maintainer will arrange a private channel.
|
||||
|
||||
Please include, where possible:
|
||||
|
||||
- The version, branch, or commit affected.
|
||||
- A minimal reproduction and the impact (e.g. sandbox escape, secret exposure, RCE).
|
||||
- Any logs or output, with secrets redacted.
|
||||
|
||||
## What to expect
|
||||
|
||||
- We aim to acknowledge a report within a few business days.
|
||||
- We will work with you on a fix and a coordinated disclosure timeline, and credit you in the release
|
||||
notes unless you prefer to remain anonymous.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- Findings that require a malicious local user who already controls the machine running Zero, or that
|
||||
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||
out of scope — but please report anything you are unsure about.
|
||||
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
||||
keyless web search, to the documented Firecrawl endpoint — see the README).
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
# Getting help with Zero
|
||||
|
||||
## Questions, setup help, configuration
|
||||
|
||||
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
|
||||
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
|
||||
be marked and found by the next person.
|
||||
|
||||
The fastest way to a useful answer is to include:
|
||||
|
||||
- `zero --version` output
|
||||
- your OS (Windows, macOS, Linux) and how you installed Zero (npm, install
|
||||
script, source)
|
||||
- the provider and model you are using (`zero providers list`)
|
||||
- `zero doctor` output for setup problems
|
||||
- the exact command you ran and the exact error text
|
||||
|
||||
## Bugs
|
||||
|
||||
If Zero does something wrong (crashes, wrong output, broken behavior), file a
|
||||
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
|
||||
sure whether it is a bug or a setup problem, start in Discussions and we will
|
||||
promote it to an issue if it is one.
|
||||
|
||||
## Feature ideas
|
||||
|
||||
Start a thread in
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
|
||||
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
|
||||
also the path to getting a PR accepted.
|
||||
|
||||
## Security issues
|
||||
|
||||
Never file a public issue or discussion. Report privately via
|
||||
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
|
||||
(see SECURITY.md).
|
||||
+9
-8
@@ -27,14 +27,14 @@ inputs:
|
||||
api-key:
|
||||
description: >-
|
||||
The provider API key. Pass it from a repository or organization secret;
|
||||
it is exported only for the ZERO step and never written to the log.
|
||||
it is exported into the step environment and never written to the log.
|
||||
required: false
|
||||
default: ""
|
||||
api-key-env:
|
||||
description: >-
|
||||
Name of the environment variable the chosen provider reads its key from
|
||||
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
|
||||
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||
api-key, that variable is exported for the run. Leave the action
|
||||
provider-agnostic by passing the env name your provider expects rather
|
||||
than hardcoding one here.
|
||||
required: false
|
||||
@@ -180,7 +180,6 @@ runs:
|
||||
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
|
||||
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
|
||||
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
|
||||
ACTION_PATH: ${{ github.action_path }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -193,9 +192,10 @@ runs:
|
||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||
exit 2
|
||||
fi
|
||||
# Export in-process for this Run ZERO step so zero can read it; do
|
||||
# not persist provider credentials to later job steps.
|
||||
# Export in-process so the zero invocation in this same run block sees
|
||||
# it, and append to GITHUB_ENV so any later steps inherit it too.
|
||||
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
|
||||
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
|
||||
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
|
||||
@@ -248,9 +248,10 @@ runs:
|
||||
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
||||
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Best-effort one-line summary. Structured output ends with lifecycle
|
||||
# metadata, so parse final/error events instead of tailing stdout.
|
||||
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
|
||||
# Best-effort one-line summary: last non-empty line of the captured output.
|
||||
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
|
||||
# Keep the summary single-line and bounded for the step output.
|
||||
summary="$(printf '%s' "${summary}" | cut -c1-280)"
|
||||
{
|
||||
echo "summary<<ZERO_EOF"
|
||||
printf '%s\n' "${summary}"
|
||||
|
||||
+5
-271
@@ -2,7 +2,6 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -10,283 +9,18 @@ function zeroBinaryName(platform = process.platform) {
|
||||
return platform === 'win32' ? 'zero.exe' : 'zero';
|
||||
}
|
||||
|
||||
function helperShimNames(name, platform = process.platform) {
|
||||
if (platform === 'win32') {
|
||||
return [`${name}.cmd`, `${name}.exe`, name];
|
||||
}
|
||||
return [name];
|
||||
}
|
||||
|
||||
function commandForShim(path, platform = process.platform) {
|
||||
if (platform === 'win32' && path.toLowerCase().endsWith('.cmd')) {
|
||||
return {
|
||||
command: process.env.ComSpec || 'cmd.exe',
|
||||
prefixArgs: ['/d', '/s', '/c', `"${path.replace(/"/g, '""')}"`],
|
||||
};
|
||||
}
|
||||
return { command: path, prefixArgs: [] };
|
||||
}
|
||||
|
||||
function resolveHelper(packageRoot, name) {
|
||||
const binDir = join(packageRoot, 'node_modules', '.bin');
|
||||
for (const shimName of helperShimNames(name)) {
|
||||
const candidate = join(binDir, shimName);
|
||||
if (!existsSync(candidate)) continue;
|
||||
return {
|
||||
...commandForShim(candidate),
|
||||
pathPrepend: [binDir],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
|
||||
// installs vendor the helpers inside the platform package instead (a helpers/
|
||||
// directory next to the binary, found by the Go side on its own), so this
|
||||
// manifest is usually empty — it only matters for a downloaded-binary install
|
||||
// whose wrapper still carries helper dependencies.
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
const helper = resolveHelper(packageRoot, name);
|
||||
if (helper) helpers[name] = helper;
|
||||
}
|
||||
if (Object.keys(helpers).length === 0) return '';
|
||||
return JSON.stringify({ version: 1, helpers });
|
||||
}
|
||||
|
||||
// Mirrors internal/cli/observability.go parseDoctorArgs + writeDoctorHelp so the
|
||||
// wrapper's missing-binary doctor fallback matches the Go doctor CLI surface.
|
||||
const DOCTOR_HELP = `Usage:
|
||||
zero doctor [flags]
|
||||
|
||||
Runs Go backend health checks for config and provider setup.
|
||||
|
||||
Flags:
|
||||
--json Print JSON report
|
||||
--connectivity Include provider endpoint connectivity probe when available
|
||||
-h, --help Show this help
|
||||
`;
|
||||
|
||||
const EXIT_USAGE = 2;
|
||||
|
||||
function installedByBun() {
|
||||
if (process.env.ZERO_WRAPPER_SIMULATE_BUN === '1') {
|
||||
return true;
|
||||
}
|
||||
return process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
}
|
||||
|
||||
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' +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
|
||||
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+).'
|
||||
);
|
||||
}
|
||||
|
||||
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` +
|
||||
'also download it from the GitHub Release when it is missing.'
|
||||
);
|
||||
}
|
||||
|
||||
function missingNativeRecoveryParagraphs(postinstallScript) {
|
||||
const ranByBun = installedByBun();
|
||||
return (
|
||||
missingBinaryContextParagraph() +
|
||||
'\n' +
|
||||
'\n' +
|
||||
'Things to try:\n' +
|
||||
' - reinstall without omitting optional dependencies:\n' +
|
||||
' npm install -g @gitlawb/zero\n' +
|
||||
' - run the downloader manually (needs write access to the package dir):\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
(ranByBun ? bunRecoveryParagraph() : '') +
|
||||
buildFromSourceParagraph()
|
||||
);
|
||||
}
|
||||
|
||||
function formatGenericMissingNativeBinaryMessage(postinstallScript) {
|
||||
return (
|
||||
'[zero] No native binary is available for this install.\n' +
|
||||
missingNativeRecoveryParagraphs(postinstallScript)
|
||||
);
|
||||
}
|
||||
|
||||
function parseDoctorArgs(args) {
|
||||
let json = false;
|
||||
for (const arg of args) {
|
||||
switch (arg) {
|
||||
case '-h':
|
||||
case '--help':
|
||||
case 'help':
|
||||
return { kind: 'help' };
|
||||
case '--json':
|
||||
json = true;
|
||||
break;
|
||||
case '--connectivity':
|
||||
break;
|
||||
default:
|
||||
return { kind: 'error', message: `unknown doctor flag ${JSON.stringify(arg)}` };
|
||||
}
|
||||
}
|
||||
return { kind: 'run', json };
|
||||
}
|
||||
|
||||
function missingNativeDoctorJSONReport(postinstallScript) {
|
||||
return {
|
||||
generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
ok: false,
|
||||
checks: [
|
||||
{
|
||||
id: 'runtime.go',
|
||||
label: 'Go runtime',
|
||||
status: 'fail',
|
||||
message: 'Native zero binary is missing next to the npm wrapper.',
|
||||
details: {
|
||||
remedy: `node "${postinstallScript}"`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function missingNativeDoctorTextReport(postinstallScript) {
|
||||
return (
|
||||
'Zero doctor report (' +
|
||||
new Date().toISOString() +
|
||||
')\n' +
|
||||
'Overall: fail\n' +
|
||||
'[fail] runtime.go - Native zero binary is missing next to the npm wrapper.\n' +
|
||||
' remedy: node "' +
|
||||
postinstallScript +
|
||||
'"\n' +
|
||||
'\n' +
|
||||
missingNativeRecoveryParagraphs(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
|
||||
// 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}`;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidate = join(dirname(manifestPath), zeroBinaryName());
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
// Mirrors the release matrix (internal/release/release.go): the platforms a
|
||||
// GitHub Release asset exists for. android maps to the linux asset; there is
|
||||
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
|
||||
function releaseAssetAvailable() {
|
||||
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
|
||||
process.platform
|
||||
];
|
||||
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
|
||||
if (!platform || !arch) return false;
|
||||
return !(platform === 'windows' && arch === 'arm64');
|
||||
}
|
||||
|
||||
// Fallback for installs without a platform package (--omit=optional, package
|
||||
// managers that skip optionalDependencies): fetch the binary next to the
|
||||
// wrapper with the same checksum-verified downloader that used to run as
|
||||
// postinstall. Deliberately NOT cached on failure — a transient network error
|
||||
// self-heals on the next run — so this retries on every invocation until a
|
||||
// binary is in place. Platforms with no release asset skip the attempt.
|
||||
function downloadMissingBinary(packageRoot) {
|
||||
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
|
||||
console.error(
|
||||
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
|
||||
);
|
||||
spawnSync(process.execPath, [downloadScript], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
|
||||
function resolveNativeBinary() {
|
||||
const fromPlatformPackage = platformPackageBinary();
|
||||
if (fromPlatformPackage) return fromPlatformPackage;
|
||||
const downloaded = join(packageRoot, zeroBinaryName());
|
||||
if (existsSync(downloaded)) return downloaded;
|
||||
downloadMissingBinary(packageRoot);
|
||||
return existsSync(downloaded) ? downloaded : null;
|
||||
}
|
||||
|
||||
const nativePath = resolveNativeBinary();
|
||||
|
||||
if (!nativePath) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
|
||||
// `zero doctor` is the diagnostic command: when the native binary is missing
|
||||
// it's the one invocation that must NOT bail with the generic wrapper error,
|
||||
// because that's exactly the blind alley issue #405 reports. Instead, surface
|
||||
// a doctor-shaped FAIL line for the runtime so the user's diagnostic finds the
|
||||
// real cause. We branch on a literal 'doctor' subcommand only (matching `zero
|
||||
// doctor` and `zero doctor --connectivity`), preserving the existing bail for
|
||||
// every other invocation (exec, providers list, TUI, --version, etc.).
|
||||
const argv = process.argv.slice(2);
|
||||
const isDoctor = argv.length > 0 && argv[0] === 'doctor';
|
||||
if (isDoctor) {
|
||||
const parsed = parseDoctorArgs(argv.slice(1));
|
||||
if (parsed.kind === 'help') {
|
||||
process.stdout.write(DOCTOR_HELP);
|
||||
process.exit(0);
|
||||
}
|
||||
if (parsed.kind === 'error') {
|
||||
process.stderr.write(`[zero] ${parsed.message}\n`);
|
||||
process.exit(EXIT_USAGE);
|
||||
}
|
||||
|
||||
if (parsed.json) {
|
||||
process.stdout.write(JSON.stringify(missingNativeDoctorJSONReport(postinstallScript), null, 2) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(missingNativeDoctorTextReport(postinstallScript) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(formatGenericMissingNativeBinaryMessage(postinstallScript));
|
||||
if (!existsSync(nativePath)) {
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
delete env.ZERO_LOCAL_CONTROL_HELPERS;
|
||||
}
|
||||
|
||||
const child = spawnSync(nativePath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -39,9 +39,6 @@ func run(args []string, getenv func(string) string, stdout io.Writer, stderr io.
|
||||
if len(args) > 0 && args[0] == "tasks" {
|
||||
return runTasksCommand(args[1:], getenv, stdout, stderr)
|
||||
}
|
||||
if len(args) > 0 && args[0] == "turn" {
|
||||
return runTurnCommand(args[1:], getenv, stdout, stderr)
|
||||
}
|
||||
options, err := parseArgs(args, getenv)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err.Error())
|
||||
@@ -209,7 +206,6 @@ func helpText() string {
|
||||
return strings.Join([]string{
|
||||
"Usage: zero-perf-bench [options]",
|
||||
" zero-perf-bench tasks [options] (Terminal-Bench-style task harness; see `tasks --help`)",
|
||||
" zero-perf-bench turn [options] (per-turn tracing benchmark; see `turn --help`)",
|
||||
"",
|
||||
"Options:",
|
||||
" --iterations <n> Measured samples to collect (default: 5)",
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
|
||||
@@ -94,18 +92,3 @@ func TestHelpText(t *testing.T) {
|
||||
func emptyEnv(string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTurnExitCodeFailsWhenEveryTaskErrored(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
allErrored := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 3}
|
||||
if code := turnExitCode(allErrored, &stderr); code != 1 {
|
||||
t.Fatalf("exit=%d, want 1 when every task errored", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no accepted benchmark sample") {
|
||||
t.Fatalf("stderr missing explanation: %q", stderr.String())
|
||||
}
|
||||
partial := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 2}
|
||||
if code := turnExitCode(partial, &stderr); code != 0 {
|
||||
t.Fatalf("exit=%d, want 0 for partial errors (summary surfaces them)", code)
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
|
||||
// benchmark harness that runs ZERO headlessly with --trace, parses each turn's
|
||||
// NDJSON trace, and records per-span latency plus the top controllable latency
|
||||
// sources — the Phase 0 baseline's "do not proceed until" criterion.
|
||||
type turnOptions struct {
|
||||
SuitePath string
|
||||
Model string
|
||||
Mode string
|
||||
ExecProfile string
|
||||
SelfCorrect bool
|
||||
Binary string
|
||||
Iterations int
|
||||
Version string
|
||||
Commit string
|
||||
Output string
|
||||
JSON bool
|
||||
DryRun bool
|
||||
Help bool
|
||||
}
|
||||
|
||||
func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer, stderr io.Writer) int {
|
||||
options, err := parseTurnArgs(args, getenv)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err.Error())
|
||||
return 2
|
||||
}
|
||||
if options.Help {
|
||||
_, _ = fmt.Fprint(stdout, turnHelpText())
|
||||
return 0
|
||||
}
|
||||
|
||||
set, err := perfbench.LoadTaskSet(options.SuitePath)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
// The dry-run path records a zero-iteration run without a binary, so the
|
||||
// manifest loads and the report path is exercised in CI without a model.
|
||||
if options.DryRun {
|
||||
_, _ = fmt.Fprintln(stdout, "[zero] turn benchmark: dry run (no agent invoked)")
|
||||
return 0
|
||||
}
|
||||
|
||||
binary, err := perfbench.ResolveBinary(options.Binary)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 2
|
||||
}
|
||||
|
||||
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
|
||||
Model: options.Model,
|
||||
Mode: options.Mode,
|
||||
ExecProfile: options.ExecProfile,
|
||||
SelfCorrect: options.SelfCorrect,
|
||||
Version: options.Version,
|
||||
Commit: options.Commit,
|
||||
Iterations: options.Iterations,
|
||||
Runner: perfbench.NewTurnExecRunner(binary),
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
if options.Output != "" {
|
||||
if err := writeTurnReport(options.Output, result); err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if options.JSON {
|
||||
if err := perfbench.WriteTurnBenchJSON(stdout, result); err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
|
||||
// turnExitCode fails the command when every attempted task errored (no
|
||||
// iteration produced an accepted benchmark sample): such a report measures
|
||||
// nothing, and exiting 0 would let a broken configuration (missing binary, bad
|
||||
// path, failing harness step) pass as a clean baseline. Partial errors keep
|
||||
// exit 0 — the summary surfaces them loudly and the surviving samples are
|
||||
// still valid measurements.
|
||||
func turnExitCode(result perfbench.TurnBenchResult, stderr io.Writer) int {
|
||||
if result.TasksAttempted > 0 && result.TasksErrored == result.TasksAttempted {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, error) {
|
||||
options := turnOptions{
|
||||
Iterations: 1,
|
||||
Version: strings.TrimSpace(getenv("ZERO_BENCH_VERSION")),
|
||||
Commit: strings.TrimSpace(getenv("ZERO_BENCH_COMMIT")),
|
||||
}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
flag, inlineValue := splitFlagValue(arg)
|
||||
switch flag {
|
||||
case "--suite":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.SuitePath = value
|
||||
index = next
|
||||
case "--model":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Model = value
|
||||
index = next
|
||||
case "--mode":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Mode = value
|
||||
index = next
|
||||
case "--exec-profile":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.ExecProfile = value
|
||||
index = next
|
||||
case "--binary":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Binary = value
|
||||
index = next
|
||||
case "--iterations":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
parsed, err := parsePositiveInteger(flag, value)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Iterations = parsed
|
||||
index = next
|
||||
case "--version":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Version = value
|
||||
index = next
|
||||
case "--commit":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Commit = value
|
||||
index = next
|
||||
case "--output":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Output = value
|
||||
index = next
|
||||
case "--self-correct":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.SelfCorrect = true
|
||||
case "--json":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.JSON = true
|
||||
case "--dry-run":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.DryRun = true
|
||||
case "-h", "--help":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.Help = true
|
||||
default:
|
||||
return options, fmt.Errorf("unknown option: %s", arg)
|
||||
}
|
||||
}
|
||||
if options.Help {
|
||||
return options, nil
|
||||
}
|
||||
if strings.TrimSpace(options.SuitePath) == "" {
|
||||
return options, fmt.Errorf("--suite is required")
|
||||
}
|
||||
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
|
||||
return options, fmt.Errorf("--model is required (or pass --dry-run)")
|
||||
}
|
||||
// Validate the profile here, before anything spawns: an unknown name would
|
||||
// otherwise make every child exit with a usage error in milliseconds, and
|
||||
// those near-zero walls would be recorded as valid latency samples in an
|
||||
// exit-0 report — exactly the misread-as-improvement trap the harness
|
||||
// closed for spawn failures. Normalizing to the catalog name also keeps
|
||||
// the stamped execProfile canonical (Lookup is case-insensitive), so two
|
||||
// captures of the same posture always compare equal.
|
||||
if raw := strings.TrimSpace(options.ExecProfile); raw != "" {
|
||||
profile, ok := execprofile.Lookup(raw)
|
||||
if !ok {
|
||||
return options, fmt.Errorf("unknown execution profile %q for --exec-profile. Valid profiles: %s", raw, strings.Join(execprofile.Names(), ", "))
|
||||
}
|
||||
options.ExecProfile = profile.Name
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func writeTurnReport(path string, result perfbench.TurnBenchResult) error {
|
||||
if dir := filepath.Dir(path); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := perfbench.WriteTurnBenchJSON(&buffer, result); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buffer.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func turnHelpText() string {
|
||||
return strings.Join([]string{
|
||||
"Usage: zero-perf-bench turn [options]",
|
||||
"",
|
||||
"Runs ZERO headlessly with --trace against a per-turn benchmark task set and",
|
||||
"records per-span latency plus the top controllable latency sources (the",
|
||||
"Phase 0 baseline's \"do not proceed until\" criterion). Each task is a fresh",
|
||||
"`zero exec` process, so iterations are cold-start samples; a warm path needs",
|
||||
"an in-process runner (future work).",
|
||||
"",
|
||||
"Options:",
|
||||
" --suite <path> Task set JSON file (required)",
|
||||
" --model <model> Model to run (required unless --dry-run)",
|
||||
" --mode <name> Exec mode preset to apply",
|
||||
" --exec-profile <name>",
|
||||
" Execution profile for every task (fast|balanced|thorough);",
|
||||
" forwarded to zero exec and stamped into the result",
|
||||
" --self-correct Enable the post-edit verify-and-correct loop",
|
||||
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
|
||||
" --iterations <n> Times to run each task (default: 1)",
|
||||
" --version <v> Record the ZERO version (default: $ZERO_BENCH_VERSION)",
|
||||
" --commit <sha> Record the ZERO commit (default: $ZERO_BENCH_COMMIT)",
|
||||
" --output <path> Write the JSON result to path",
|
||||
" --json Print only the JSON result",
|
||||
" --dry-run Load the manifest and exit without invoking the agent",
|
||||
" -h, --help Show this help",
|
||||
}, "\n") + "\n"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTurnArgsExecProfile(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json",
|
||||
"--model", "m",
|
||||
"--exec-profile", "fast",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
// Inline form.
|
||||
options, err = parseTurnArgs([]string{
|
||||
"--suite=suite.json",
|
||||
"--model=m",
|
||||
"--exec-profile=thorough",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs inline: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "thorough" {
|
||||
t.Fatalf("ExecProfile = %q, want thorough", options.ExecProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// The bench validates the profile BEFORE spawning anything: an unknown name
|
||||
// would otherwise make every child exit with a usage error in milliseconds
|
||||
// and record those near-zero walls as valid latency samples in an exit-0
|
||||
// report. Lookup normalization also keeps the stamped name canonical so two
|
||||
// captures of the same posture compare equal.
|
||||
func TestParseTurnArgsExecProfileValidatesAndNormalizes(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "FAST",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want the canonical fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
_, err = parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "blanced",
|
||||
}, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown profile must fail parse, before anything spawns")
|
||||
}
|
||||
for _, name := range []string{"balanced", "fast", "thorough"} {
|
||||
if !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error must list %q, got %q", name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
//go:build linux
|
||||
|
||||
// Command zero-seccomp is retained as a compatibility wrapper for existing Linux
|
||||
// installs and scripts. The main sandbox path now applies the same optional
|
||||
// Unix-socket filter inside zero-linux-sandbox when sandbox.blockUnixSockets is
|
||||
// enabled.
|
||||
// Command zero-seccomp is a tiny exec wrapper for the Linux sandbox: it installs
|
||||
// the Unix-socket-blocking seccomp filter on itself and then execs the real
|
||||
// command, so a bubblewrap-sandboxed process cannot create AF_UNIX sockets (a gap
|
||||
// bubblewrap's filesystem/network isolation leaves open). Wire it by prefixing the
|
||||
// sandboxed command with this binary, e.g. `bwrap ... -- zero-seccomp <command>`.
|
||||
//
|
||||
// It degrades gracefully: if the filter cannot be installed (e.g. the kernel lacks
|
||||
// seccomp), it warns and runs the command anyway, since bubblewrap still provides
|
||||
// the primary isolation.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "zero-seccomp is only supported on Linux")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxCommandRunner(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxSetup(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
# Extending Zero
|
||||
|
||||
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
|
||||
If you only want to *use* Zero, the [README](../README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
|
||||
2. Add new specialist sub-agents.
|
||||
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
|
||||
|
||||
## 1. Drop a project `AGENTS.md`
|
||||
|
||||
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
|
||||
|
||||
Accepted file names, in priority order at each level:
|
||||
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
```markdown
|
||||
# Project conventions for <your project>
|
||||
|
||||
- Build with `make`, not `go build` directly.
|
||||
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
|
||||
- Run `make lint` before opening a PR.
|
||||
- Never edit files under `third_party/` — those are vendored.
|
||||
```
|
||||
|
||||
Tips:
|
||||
|
||||
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
|
||||
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
|
||||
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
### Personal guidelines, across every project
|
||||
|
||||
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
|
||||
|
||||
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
|
||||
| Scope | Path | Shared? |
|
||||
| --- | --- | --- |
|
||||
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
|
||||
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
|
||||
| Project | `./.zero/specialists/*.md` | yes — the repo team |
|
||||
|
||||
Project overrides user overrides built-in when names collide.
|
||||
|
||||
A specialist is a markdown manifest with frontmatter and a system prompt:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews API changes for breaking-change risk and missing tests.
|
||||
tools: read-only,plan
|
||||
---
|
||||
|
||||
You review API changes. For every changed hunk in `internal/api/` or any file
|
||||
that ends in `_api.go`:
|
||||
|
||||
1. Confirm the public signature is backward-compatible, or note the breaking
|
||||
change explicitly with the migration path.
|
||||
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
|
||||
the new behaviour is exercised.
|
||||
3. Flag any new exported symbol without a doc comment.
|
||||
|
||||
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
|
||||
```
|
||||
|
||||
CLI management (the prompt is passed inline via `--prompt`):
|
||||
|
||||
```bash
|
||||
zero specialist list
|
||||
zero specialist show api-reviewer
|
||||
zero specialist create api-reviewer \
|
||||
--project \
|
||||
--description "Reviews API changes" \
|
||||
--tools read-only,plan \
|
||||
--prompt "$(cat api-reviewer.md)"
|
||||
zero specialist edit api-reviewer --project
|
||||
zero specialist delete api-reviewer --project
|
||||
zero specialist path # prints the resolved specialists directory
|
||||
```
|
||||
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](SPECIALISTS.md).
|
||||
|
||||
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
|
||||
|
||||
## 3. Skills
|
||||
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
|
||||
|
||||
Discovery roots (earlier wins on name collisions):
|
||||
|
||||
1. **Primary Zero dir** — `$ZERO_SKILLS_DIR` if set, else `$XDG_DATA_HOME/zero/skills`, else `~/.local/share/zero/skills/`
|
||||
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
||||
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
SKILL.md
|
||||
|
||||
~/.agents/skills/ # optional shared multi-agent root
|
||||
shared-review/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
`SKILL.md` format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Run the project's benchmark suite and summarize the deltas.
|
||||
---
|
||||
|
||||
# Run benchmarks
|
||||
|
||||
1. `make bench` — captures the wall-clock and RSS before and after.
|
||||
2. `benchstat before.txt after.txt` — diffs the two.
|
||||
3. Report any regression > 5% with the function name and the previous value.
|
||||
```
|
||||
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. Across roots, Zero loads the primary directory first, then `~/.agents/skills`, then plugin skill roots; earlier roots win name collisions. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
|
||||
|
||||
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
|
||||
- User: `~/.config/zero/hooks.json`
|
||||
- Project: `./.zero/hooks.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"id": "block-rm-rf",
|
||||
"event": "beforeTool",
|
||||
"matcher": "bash",
|
||||
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "log-session",
|
||||
"event": "sessionStart",
|
||||
"command": "/usr/local/bin/zero-hook-log.sh",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# /usr/local/bin/zero-hook-block-rmrf.sh
|
||||
set -euo pipefail
|
||||
payload="$(cat)"
|
||||
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
|
||||
echo "refusing rm -rf" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Events the agent emits (in dispatch order):
|
||||
|
||||
| Event | Fires when | Matcher allowed? |
|
||||
| --- | --- | --- |
|
||||
| `beforeTool` | A tool is about to run | yes (tool name) |
|
||||
| `afterTool` | A tool just returned | yes (tool name) |
|
||||
| `sessionStart` | A session begins | no |
|
||||
| `sessionEnd` | A session ends | no |
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
|
||||
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
|
||||
|
||||
## 5. MCP — Model Context Protocol
|
||||
|
||||
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
|
||||
|
||||
### As a client — configure MCP servers in `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"]
|
||||
},
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manage via CLI:
|
||||
|
||||
```bash
|
||||
zero mcp add docs --type stdio -- docs-mcp --port 7777
|
||||
zero mcp add github --type http --url https://api.example.com/mcp \
|
||||
--header "Authorization=Bearer YOUR_TOKEN_HERE"
|
||||
zero mcp list
|
||||
zero mcp check docs
|
||||
zero mcp remove github
|
||||
zero mcp oauth login github
|
||||
```
|
||||
|
||||
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
|
||||
|
||||
- A wrapper script that sources the secret and execs the real command.
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
Project MCP config remains supported for shared team servers, but a project layer
|
||||
that changes an existing server's target (`url`, `command`, or `args`) does not
|
||||
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
|
||||
or use a new server name. OAuth tokens are bound to the resolved server identity,
|
||||
so changing a server target can require `zero mcp oauth login <server>` again.
|
||||
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
zero serve --mcp
|
||||
zero serve --mcp -C /path/to/workspace
|
||||
zero serve --mcp --add-dir /path/to/extra
|
||||
```
|
||||
|
||||
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
|
||||
|
||||
`-C/--cwd` sets the workspace root exposed as MCP resources and used by core tools. `--add-dir` (repeatable) widens that resource/tool scope beyond the workspace without granting the sandbox temp roots used by interactive runs.
|
||||
## 6. Plugins
|
||||
|
||||
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
|
||||
|
||||
- User: `~/.config/zero/plugins/<id>/`
|
||||
- Project: `./.zero/plugins/<id>/`
|
||||
|
||||
Each plugin has a `plugin.json` manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github-pr-review",
|
||||
"name": "GitHub PR Review",
|
||||
"description": "Adds review tools for GitHub PRs.",
|
||||
"version": "1.0.0",
|
||||
"tools": [
|
||||
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
|
||||
],
|
||||
"hooks": [
|
||||
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
|
||||
],
|
||||
"skills": [
|
||||
{ "path": "./skills/review-checklist/SKILL.md" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Install and manage:
|
||||
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
Five configuration sources, in precedence order (later sources override earlier ones):
|
||||
|
||||
| Source | Path / Key | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in defaults | compiled in | Lowest priority. |
|
||||
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
|
||||
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
|
||||
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
|
||||
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
|
||||
|
||||
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
|
||||
|
||||
## 8. End-to-end example
|
||||
|
||||
A team that wants every contributor's Zero to behave the same way commits:
|
||||
|
||||
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
|
||||
- `.zero/config.json` — provider catalog, default model, allowed tools.
|
||||
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
|
||||
- `.zero/hooks.json` — block `rm -rf` on `beforeTool`.
|
||||
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
|
||||
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- [README](../README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](INSTALL.md) — install from source or release.
|
||||
+9
-10
@@ -41,9 +41,8 @@ jobs:
|
||||
> provider-neutral: `api-key-env` is the environment variable name the chosen
|
||||
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
|
||||
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
|
||||
> that variable only for the ZERO step and never prints it. If your repository
|
||||
> already commits a `.zero/config.json` with an active provider, you can omit
|
||||
> `provider`.
|
||||
> that variable for the run and never prints it. If your repository already
|
||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -52,8 +51,8 @@ jobs:
|
||||
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
|
||||
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
|
||||
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
||||
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
|
||||
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
|
||||
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
|
||||
@@ -163,10 +162,9 @@ jobs:
|
||||
## Security notes
|
||||
|
||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
|
||||
is passed only to the Slack post step. They are never echoed or persisted to
|
||||
later workflow steps, and ZERO redacts secret-shaped strings from its own
|
||||
output.
|
||||
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
|
||||
environment only. They are never echoed, and ZERO redacts secret-shaped
|
||||
strings from its own output.
|
||||
- **The sandbox is always active.** This action never passes
|
||||
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
|
||||
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
|
||||
@@ -200,4 +198,5 @@ webhook:
|
||||
- **Redaction:** the message, summary, links, and the webhook URL itself are run
|
||||
through ZERO's redaction before being sent or logged, so tokens never leak.
|
||||
- **Egress/proxy:** the notifier uses the default HTTP transport, which honors
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` when a proxy is configured.
|
||||
`HTTP_PROXY`/`HTTPS_PROXY`. In a sandboxed run the scoped-egress proxy is
|
||||
respected, so a webhook host that is not on the allowlist is denied.
|
||||
@@ -1,801 +0,0 @@
|
||||
# How Zero Works
|
||||
|
||||
This document explains Zero's architecture at a high level: the layers, module
|
||||
boundaries, data flow, and runtime concepts behind the interactive TUI and the
|
||||
headless `zero exec` mode.
|
||||
|
||||
It is written as a progressive map: each high-level section introduces the
|
||||
concepts you need for orientation, then points to the later section that explains
|
||||
the mechanics in more detail.
|
||||
|
||||
## Big Picture
|
||||
|
||||
Zero is a Go CLI application with two main user-facing surfaces:
|
||||
|
||||
- **Interactive TUI** (`zero`): a Bubble Tea application that renders chat,
|
||||
tool cards, permission prompts, model/provider pickers, and session controls.
|
||||
- **Headless exec** (`zero exec ...`): a scriptable command runner that uses the
|
||||
same agent loop and tools, but writes text, JSON, or stream-JSON events.
|
||||
|
||||
Both surfaces converge on the same core agent runtime:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User[User / automation] --> CLI[cmd/zero -> internal/cli]
|
||||
|
||||
CLI --> TUI[Interactive TUI<br/>internal/tui]
|
||||
CLI --> Exec[Headless exec<br/>internal/cli/exec.go]
|
||||
CLI --> ACP[ACP / editor bridge<br/>internal/acp]
|
||||
|
||||
TUI --> Agent[Agent loop<br/>internal/agent]
|
||||
Exec --> Agent
|
||||
ACP --> Agent
|
||||
|
||||
Agent --> Provider[Provider runtime<br/>internal/zeroruntime.Provider]
|
||||
Provider --> APIs[OpenAI / Anthropic / Gemini / compatible APIs]
|
||||
|
||||
Agent --> Registry[Tool registry<br/>internal/tools.Registry]
|
||||
Registry --> CoreTools[Core tools<br/>read/edit/shell/search/plan]
|
||||
Registry --> MCP[MCP tools<br/>internal/mcp]
|
||||
Registry --> Specialists[Specialist tools<br/>internal/specialist]
|
||||
Registry --> Plugins[Plugin tools<br/>internal/plugins]
|
||||
Registry --> LocalControl[Browser / terminal / desktop helpers]
|
||||
|
||||
Agent --> Sandbox[Permission + sandbox engine<br/>internal/sandbox]
|
||||
Agent --> Hooks[Hooks dispatcher<br/>internal/hooks]
|
||||
Agent --> Sessions[Session event store<br/>internal/sessions]
|
||||
```
|
||||
|
||||
The important split is:
|
||||
|
||||
- `internal/tui` is the **presentation layer**. For the mechanics, see
|
||||
[Presentation Layer: Bubble Tea TUI](#presentation-layer-bubble-tea-tui) and
|
||||
[How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect).
|
||||
- `internal/agent` is the **reasoning/tool loop**. For the per-turn mechanics,
|
||||
see [Agent Loop](#agent-loop), [Tool Execution Lifecycle](#tool-execution-lifecycle),
|
||||
and [Context Budget and Compaction](#context-budget-and-compaction).
|
||||
- `internal/tools`, `internal/sandbox`, and provider packages are the **runtime
|
||||
capability layer**. For the details, see [Runtime Provider Layer](#runtime-provider-layer),
|
||||
[Tools and Capabilities](#tools-and-capabilities), and
|
||||
[Permission and Sandbox Model](#permission-and-sandbox-model).
|
||||
- `internal/sessions` is the **durable event log** used for resume, fork,
|
||||
rewind, sub-agents, and history. See [Session Storage and History](#session-storage-and-history).
|
||||
|
||||
## Architecture Spine
|
||||
|
||||
The easiest way to understand Zero is not as a set of independent packages, but
|
||||
as one spine with several mechanisms attached to it:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Surface[Surface<br/>TUI / exec / ACP] --> Run[Run request<br/>prompt + runtime options]
|
||||
Run --> Agent[Agent loop<br/>turns, tools, recovery]
|
||||
Agent --> Capabilities[Capabilities<br/>providers + tools + sandbox]
|
||||
Capabilities --> Events[Runtime events<br/>text, tool calls, permissions, usage]
|
||||
Events --> Surface
|
||||
Events --> Sessions[Session log<br/>resume, fork, rewind, history]
|
||||
|
||||
Config[Config / guidelines / skills] --> Run
|
||||
Extensions[MCP / specialists / plugins / hooks] --> Capabilities
|
||||
```
|
||||
|
||||
The **TUI + agent loop** is the main story because it explains the normal user
|
||||
experience: type a prompt, watch the model stream, approve tool use, see file
|
||||
changes, and receive a final answer. Other mechanisms are still important, but
|
||||
most of them attach to the same spine:
|
||||
|
||||
| Mechanism | Where it attaches | What it changes |
|
||||
| --- | --- | --- |
|
||||
| Config, provider profiles, model registry | Before a run starts | Which provider/model is used and what defaults apply. |
|
||||
| Project/user guidelines, skills, specialists context | Prompt construction | What instructions and available capabilities the model sees. |
|
||||
| Tool registry, MCP, plugins, local-control helpers | Capability layer | Which actions the model may request. |
|
||||
| Permission policy, sandbox, command-prefix grants | Tool execution | Whether a requested action can run, needs approval, or is denied. |
|
||||
| Hooks | Tool lifecycle | Optional before/after observers or blockers around tool calls. |
|
||||
| Sessions, checkpoints, compaction metadata | Event persistence/context | What can be resumed, forked, rewound, or summarized for later turns. |
|
||||
| Stream-JSON | Surface layer | How the same runtime events are emitted for automation instead of rendered in the TUI. |
|
||||
|
||||
So this document focuses first on the TUI/agent-loop bridge, then explains the
|
||||
supporting mechanisms in terms of where they connect to that bridge.
|
||||
|
||||
Use this as the reading path:
|
||||
|
||||
| If you see this concept in a high-level flow | Read this detail section |
|
||||
| --- | --- |
|
||||
| Surface submits a run | [Startup Flow](#startup-flow), then [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect) |
|
||||
| Agent builds context and advances turns | [Agent Loop](#agent-loop) |
|
||||
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
|
||||
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
|
||||
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
|
||||
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
|
||||
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
|
||||
|
||||
## Main Packages
|
||||
|
||||
The sections above describe the architecture in concepts. If you want to connect
|
||||
those concepts to implementation details, use this table as the code map before
|
||||
reading the deeper flows below.
|
||||
|
||||
| Package | Role |
|
||||
| --- | --- |
|
||||
| `cmd/zero` | Tiny binary entrypoint; calls `internal/cli.Run`. |
|
||||
| `internal/cli` | Command parsing, config resolution, provider creation, registry setup, TUI/exec/ACP launch paths. |
|
||||
| `internal/tui` | Bubble Tea model/update/view code, transcript rendering, composer, prompts, slash commands, setup wizard. |
|
||||
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
|
||||
| `internal/zeroruntime` | Provider-neutral message, tool-call, stream, usage, and image types. Providers implement `Provider.StreamCompletion`. |
|
||||
| `internal/providers` | Factory that turns a resolved provider profile into an OpenAI, Anthropic, Gemini, Codex, or compatible provider adapter. |
|
||||
| `internal/tools` | Tool interface, registry, core read/write/shell/network tools, local-control wrappers, tool safety metadata, redaction, output ceilings. |
|
||||
| `internal/sandbox` | Permission policy, path/network checks, grant store, command-prefix approvals, and platform sandbox backends. |
|
||||
| `internal/sessions` | Local append-only session storage: `metadata.json` + `events.jsonl`. |
|
||||
| `internal/config` | User/project config resolution, active provider, preferences, sandbox/tool settings. |
|
||||
| `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. |
|
||||
| `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. |
|
||||
| `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. |
|
||||
| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. |
|
||||
| `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. |
|
||||
|
||||
## Startup Flow
|
||||
|
||||
### Interactive TUI (`zero`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Main as cmd/zero
|
||||
participant CLI as internal/cli
|
||||
participant Config as internal/config
|
||||
participant TUI as internal/tui
|
||||
participant Agent as internal/agent
|
||||
|
||||
Main->>CLI: Run(args)
|
||||
CLI->>Config: resolve config for workspace
|
||||
CLI->>CLI: build provider
|
||||
CLI->>CLI: build tool registry
|
||||
CLI->>CLI: register core, config-gated local-control, specialists, MCP, plugins
|
||||
CLI->>CLI: create sandbox engine + session store
|
||||
CLI->>TUI: run Bubble Tea with Options
|
||||
TUI->>TUI: Init / Update / View event loop
|
||||
TUI->>Agent: agent.Run on submitted prompt
|
||||
```
|
||||
|
||||
The TUI owns terminal interaction. It tracks composer state, transcript rows,
|
||||
scrolling, permission dialogs, model/provider pickers, setup wizard state, and
|
||||
visual updates. When the user submits a prompt, the TUI builds `agent.Options`
|
||||
with callbacks like `OnText`, `OnToolCall`, `OnToolResult`, `OnPermission`,
|
||||
`OnAskUser`, and `OnUsage`. Those callbacks convert runtime events into visible
|
||||
transcript rows and session events.
|
||||
|
||||
### Headless exec (`zero exec`)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Parse exec flags] --> B[Resolve config + provider]
|
||||
B --> C[Build tool registry]
|
||||
C --> D[Resolve prompt / stream-json input / images]
|
||||
D --> E[Prepare or resume session]
|
||||
E --> F[Create stream-json/text/json writer]
|
||||
F --> G[agent.Run]
|
||||
G --> H[Write final output and exit code]
|
||||
```
|
||||
|
||||
`zero exec` uses the same `agent.Run` loop as the TUI, but without interactive
|
||||
terminal state. It can persist sessions, resume/fork existing sessions, run in
|
||||
stream-JSON mode for automation, and always requires a stronger completion
|
||||
signal so a no-tool assistant response is not treated as success while work is
|
||||
still pending.
|
||||
|
||||
## Agent Loop
|
||||
|
||||
The core loop lives in `internal/agent.Run`. Conceptually, every turn follows
|
||||
this cycle:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start[Build system prompt + user prompt] --> Expose[Partition visible tools]
|
||||
Expose --> Compact[Maybe compact context]
|
||||
Compact --> Request[Send CompletionRequest to provider]
|
||||
Request --> Stream[Collect streamed text, reasoning, usage, tool calls]
|
||||
|
||||
Stream --> HasTools{Tool calls?}
|
||||
HasTools -- No --> FinalCheck{Final answer acceptable?}
|
||||
FinalCheck -- Yes --> Done[Return Result]
|
||||
FinalCheck -- No --> Nudge[Append user nudge and continue]
|
||||
|
||||
HasTools -- Yes --> Execute[Execute tool calls]
|
||||
Execute --> Permission[Permission / sandbox / hooks]
|
||||
Permission --> ToolResult[Append tool result messages]
|
||||
ToolResult --> Diagnostics[Post-edit diagnostics / self-correct]
|
||||
Diagnostics --> Expose
|
||||
```
|
||||
|
||||
Key responsibilities of the loop:
|
||||
|
||||
1. **Build prompt state** — combines the system prompt, project/user guidelines,
|
||||
loaded skills/specialist instructions, the current user prompt, and images. The
|
||||
resulting message list is the model-facing context that later tool results are
|
||||
appended to. Resumed session history is replayed by the caller before a new run,
|
||||
not passed as a separate `agent.Run` history field.
|
||||
2. **Expose tools** — starts from the registry built by the CLI, then filters
|
||||
tool definitions by permission mode, enabled or disabled filters, and
|
||||
deferred-loading rules. Tool schemas also count toward context budget, so they
|
||||
matter for compaction decisions.
|
||||
3. **Call provider** — sends a provider-neutral `zeroruntime.CompletionRequest`.
|
||||
4. **Stream events** — forwards text, reasoning, tool-call deltas, and usage to
|
||||
the caller via callbacks.
|
||||
5. **Execute tools** — decodes tool arguments, checks filters, applies
|
||||
permissions and sandbox policy, dispatches hooks, runs the registered tool,
|
||||
redacts output, and appends a tool message. See
|
||||
[Tool Execution Lifecycle](#tool-execution-lifecycle).
|
||||
6. **Recover and guard** — handles malformed tool calls, repeated failures,
|
||||
empty turns, context pressure, stream reconnect/retry, and max-turn fallback.
|
||||
Context pressure is handled by [Context Budget and Compaction](#context-budget-and-compaction).
|
||||
7. **Finalize** — returns a final answer or a structured incomplete/stop reason.
|
||||
|
||||
The model never directly mutates the workspace. It asks for tool calls; Zero
|
||||
validates and executes those calls through the registry, permission policy, and
|
||||
sandbox engine.
|
||||
|
||||
## Tool Execution Lifecycle
|
||||
|
||||
A tool call is part of the model conversation, not a side channel that bypasses
|
||||
it. The model proposes an action, Zero decides whether that action is allowed to
|
||||
run, the tool returns a structured result, and that result becomes context for the
|
||||
next provider request.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Model as LLM / provider
|
||||
participant Agent as agent.Run
|
||||
participant Policy as filters + permissions + sandbox
|
||||
participant Hooks as hooks
|
||||
participant Tool as tool implementation
|
||||
participant Surface as TUI / exec callbacks
|
||||
participant Transcript as model conversation + session events
|
||||
|
||||
Agent->>Model: request with messages + available tool schemas
|
||||
Model-->>Agent: assistant text and/or tool_call(name, args)
|
||||
Agent-->>Surface: stream text / tool-call preview
|
||||
Agent->>Agent: collect and decode tool arguments
|
||||
Agent->>Policy: check tool filters, permission mode, sandbox policy
|
||||
|
||||
alt denied before execution
|
||||
Policy-->>Agent: denied result
|
||||
Agent-->>Surface: tool failed/denied event
|
||||
Agent->>Transcript: append tool result explaining denial
|
||||
else needs user approval
|
||||
Policy-->>Surface: permission request
|
||||
Surface-->>Policy: allow / deny / remember decision
|
||||
Policy-->>Agent: decision
|
||||
end
|
||||
|
||||
alt approved to execute
|
||||
Agent->>Hooks: beforeTool hooks
|
||||
Hooks-->>Agent: allow / block / annotate
|
||||
Agent->>Tool: execute(ctx, args)
|
||||
Tool-->>Agent: output, error, metadata
|
||||
Agent->>Hooks: afterTool hooks
|
||||
Agent->>Agent: redact secrets, apply semantic output budget, enforce byte ceiling
|
||||
Agent->>Agent: classify success/failure
|
||||
Agent-->>Surface: OnToolResult callback
|
||||
Agent->>Transcript: append tool result message
|
||||
end
|
||||
|
||||
Agent->>Model: next request includes prior assistant tool_call + tool result
|
||||
Model-->>Agent: continue with more tools or final answer
|
||||
```
|
||||
|
||||
Conceptually, one requested tool goes through these phases:
|
||||
|
||||
1. **Proposal** — the provider streams assistant content and may emit one or more
|
||||
tool calls. Zero can show the partial tool arguments in the TUI while they are
|
||||
still streaming, but nothing has executed yet.
|
||||
2. **Decode and gate** — the agent validates JSON arguments, checks whether the
|
||||
tool is enabled, applies permission mode, asks the sandbox engine about path,
|
||||
network, shell, or command-prefix constraints, and may invoke permission
|
||||
callbacks.
|
||||
3. **Execute** — the selected registry entry runs locally, through MCP, as a
|
||||
specialist/sub-agent, or through a plugin/local-control helper. Hooks can run
|
||||
before or after this step.
|
||||
4. **Normalize result** — Zero turns the outcome into an agent `ToolResult`: a
|
||||
success, failure, denial, cancellation, timeout, or validation error with
|
||||
output/metadata that may be redacted or truncated.
|
||||
5. **Append to context** — the result is appended as a tool-result message paired
|
||||
with the assistant tool call. The next model request includes that result, so
|
||||
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.
|
||||
|
||||
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
|
||||
an error such as “old string not found”; a denied shell command tells the model
|
||||
that the action is not currently permitted. The model is expected to use that
|
||||
feedback to continue, choose another tool, ask the user, or explain the blocker.
|
||||
|
||||
The judgement that “information is sufficient” is made by the model, not by the
|
||||
TUI. Zero controls the loop mechanics: it keeps appending messages, enforces
|
||||
safety, retries or nudges around malformed/empty/incomplete turns, and stops on
|
||||
hard limits. But the semantic decision to call another tool versus produce a
|
||||
final answer comes from the next provider response. In other words:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Result[Tool result appended to conversation] --> Next[Next provider request]
|
||||
Next --> Decide{Model response}
|
||||
Decide -- More information/action needed --> MoreTools[Request another tool]
|
||||
MoreTools --> Result
|
||||
Decide -- User input needed --> AskUser[Call ask_user or explain question]
|
||||
AskUser --> Result
|
||||
Decide -- Enough information --> Final[Final assistant answer]
|
||||
Decide -- Invalid / empty / unsafe --> Guard[Agent guardrail, retry, denial, or stop reason]
|
||||
Guard --> Next
|
||||
```
|
||||
|
||||
This is why tool output is usually more than UI decoration. It is part of the
|
||||
working memory for the next model turn, subject to context-window pressure and
|
||||
compaction. The TUI may summarize or prettify it for the user, but the agent loop
|
||||
also stores the machine-facing result that the model sees.
|
||||
|
||||
## Context Budget and Compaction
|
||||
|
||||
The `Maybe compact context` step in the agent-loop diagram is Zero's context-window
|
||||
pressure valve. It is not a separate memory engine; it rewrites the current
|
||||
model-facing conversation so the next provider request fits within the configured
|
||||
context window.
|
||||
|
||||
Compaction is enabled only when the run knows a positive `ContextWindow`. When it
|
||||
is enabled, the loop does this near the top of each turn:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Tools[Partition visible tools] --> Estimate[Estimate messages + tool-definition tokens]
|
||||
Estimate --> Under{Under threshold?}
|
||||
Under -- Yes --> Keep[Keep context as-is]
|
||||
Under -- No --> Grown{Grown past low-water mark?}
|
||||
Grown -- No --> Keep
|
||||
Grown -- Yes --> Prune[Prune stale large tool outputs]
|
||||
Prune --> Recheck{Back under threshold?}
|
||||
Recheck -- Yes --> KeepPruned[Use pruned context]
|
||||
Recheck -- No --> Summarize[Summarize oldest middle]
|
||||
Summarize --> Replace[system messages + summary + recent suffix]
|
||||
```
|
||||
|
||||
The trigger is intentionally approximate:
|
||||
|
||||
- Zero estimates request size using message text, images, tool-call arguments, and
|
||||
the tool definitions advertised on that turn.
|
||||
- The proactive threshold is roughly **two-thirds of the context window**.
|
||||
- Provider usage from completed turns is used to calibrate the rough estimate over
|
||||
time, so the budget gauge and compaction decision track the provider more
|
||||
closely after a few turns.
|
||||
- A low-water mark prevents the loop from re-summarizing the same already-compacted
|
||||
history on every turn.
|
||||
|
||||
When compaction actually runs, it tries to preserve the parts most likely to be
|
||||
needed by the model:
|
||||
|
||||
- leading system messages stay verbatim;
|
||||
- the most recent turns stay verbatim (`CompactionPreserveLast`, defaulting to the
|
||||
agent's normal preserve count);
|
||||
- the preserved suffix is widened to a provider-valid turn boundary so tool-result
|
||||
messages are not replayed without their corresponding assistant tool call;
|
||||
- the older middle is summarized into one user-role message labeled
|
||||
`[Summary of earlier conversation]`;
|
||||
- structured state that should not be paraphrased away, such as active plan/loaded
|
||||
skill state, is preserved alongside the prose summary.
|
||||
|
||||
There are two compaction paths:
|
||||
|
||||
1. **Proactive compaction** runs before the provider call when the estimated
|
||||
request is over threshold.
|
||||
2. **Reactive compaction** runs after a provider returns a context-limit style
|
||||
error. Zero compacts once and retries that same turn; if the provider still
|
||||
rejects the request, the error is surfaced rather than looping forever.
|
||||
|
||||
Compaction failures are conservative. If the summarizer fails during proactive
|
||||
compaction, Zero keeps the original messages and tries to continue. It does not
|
||||
drop conversation history just to make room.
|
||||
|
||||
## Presentation Layer: Bubble Tea TUI
|
||||
|
||||
`internal/tui` is a Bubble Tea application. The usual Bubble Tea shape applies:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Init[model.Init] --> Update[model.Update]
|
||||
Update --> View[model.View]
|
||||
View --> Terminal[Terminal frame]
|
||||
Terminal --> Msg[tea.Msg: key, mouse, window, agent event]
|
||||
Msg --> Update
|
||||
```
|
||||
|
||||
The TUI is intentionally not the agent brain. It is responsible for:
|
||||
|
||||
- collecting user input from the composer;
|
||||
- rendering assistant text, reasoning, tool cards, permissions, plans, usage,
|
||||
files, and sidebars;
|
||||
- handling slash commands and modal surfaces;
|
||||
- converting agent callbacks into Bubble Tea messages;
|
||||
- persisting visible session events;
|
||||
- allowing live provider/model/permission changes.
|
||||
|
||||
When a prompt is submitted, the TUI starts a background command that invokes
|
||||
`agent.Run`. Streaming callbacks send messages back into the Bubble Tea update
|
||||
loop, so rendering remains responsive while the agent is working.
|
||||
|
||||
## How the TUI and Agent Loop Connect
|
||||
|
||||
A common source of confusion is that Zero has two event loops running at once:
|
||||
|
||||
1. **Bubble Tea loop** — owns terminal input, in-memory UI state, and frames.
|
||||
2. **Agent loop** — owns model turns, tool execution, permissions, retries, and
|
||||
stop conditions.
|
||||
|
||||
They are connected by `tea.Cmd` and `agent.Options` callbacks. The TUI never
|
||||
runs provider/tool logic directly inside `Update`; instead, submit handling gates
|
||||
and dispatches a prompt, the TUI starts a background command, and that command
|
||||
calls `agent.Run` with callbacks that send runtime events back to the Bubble Tea
|
||||
program. Function names in this section are illustrative code anchors, not API
|
||||
boundaries.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant TUI as Bubble Tea model<br/>internal/tui
|
||||
participant Cmd as tea.Cmd goroutine<br/>runAgentWithOptions
|
||||
participant Agent as agent.Run<br/>internal/agent
|
||||
participant Provider as Provider.StreamCompletion
|
||||
participant Tools as Tool registry + sandbox
|
||||
participant Store as Session store
|
||||
|
||||
User->>TUI: type prompt / press Enter
|
||||
TUI->>TUI: validate/gate submit and append visible user row
|
||||
TUI->>Cmd: launch tea.Cmd with runID + cancellable context
|
||||
Cmd->>Agent: agent.Run(prompt, provider, options)
|
||||
Agent->>Provider: stream completion request
|
||||
Provider-->>Agent: text / reasoning / tool-call deltas
|
||||
Agent-->>Cmd: OnText / OnReasoning / OnToolCallStart callbacks
|
||||
Cmd-->>TUI: runtimeMessageSink(tea.Msg)
|
||||
TUI->>TUI: Update appends/updates transcript rows
|
||||
TUI-->>User: View renders new frame
|
||||
|
||||
Agent->>Tools: execute collected tool call
|
||||
Tools->>Tools: permission, sandbox, hooks, redaction
|
||||
Tools-->>Agent: tool result
|
||||
Agent-->>Cmd: OnToolCall / OnToolResult / OnPermission / OnUsage
|
||||
Cmd->>Store: batch session events for resume/history
|
||||
Cmd-->>TUI: final agentResponseMsg
|
||||
TUI->>TUI: mark run complete, flush rows/events, unlock composer
|
||||
```
|
||||
|
||||
In the TUI path, the same event can have three representations:
|
||||
|
||||
| Runtime thing | Agent representation | TUI representation | Session representation |
|
||||
| --- | --- | --- | --- |
|
||||
| Assistant text | streamed provider text forwarded through `OnText` | incremental assistant row text | assistant message event |
|
||||
| Reasoning preview | `OnReasoning` callback | collapsible/preview reasoning row | usually not the same as final assistant text |
|
||||
| Tool call | `agent.ToolCall` from collected provider stream | tool card, specialist card, or plan panel update | `EventToolCall` |
|
||||
| Tool result | `agent.ToolResult` | result card/status, changed files, plan/specialist updates | `EventToolResult` |
|
||||
| Permission prompt/audit | `PermissionRequest` / `PermissionEvent` | modal prompt or permission row | permission request/decision events |
|
||||
| Usage/context | provider usage + measured context | context gauge / usage metadata | usage event |
|
||||
|
||||
### One interactive turn, end to end
|
||||
|
||||
For a normal prompt in the interactive TUI:
|
||||
|
||||
1. Submit handling validates composer state and command gates, then dispatches
|
||||
the prompt to the launch path.
|
||||
2. The launch path appends immediate UI rows, creates a cancellable run context,
|
||||
and starts `runAgent` as a `tea.Cmd`; run bookkeeping assigns the `runID` used
|
||||
to correlate later events.
|
||||
3. `runAgentWithOptions` copies the current UI-controlled runtime choices into
|
||||
`agent.Options`: provider/model labels, permission mode, cwd, session ID,
|
||||
images, registry, response style, context window, and self-correction hooks.
|
||||
4. The command wraps agent callbacks. For example:
|
||||
- `OnText` sends incremental assistant text into the TUI message sink.
|
||||
- `OnToolCallStart` / `OnToolCallDelta` show live tool-argument streaming.
|
||||
- `OnToolCall` creates visible tool rows and appends session events.
|
||||
- `OnToolResult` creates result rows, updates plan/specialist side panels, and
|
||||
records changed files.
|
||||
- `OnPermissionRequest` sends a prompt to the TUI and blocks the agent loop
|
||||
until the user answers or the run context is cancelled.
|
||||
- `OnAskUser` does the same for model-initiated clarification questions.
|
||||
5. `agent.Run` builds provider-neutral messages and tool definitions, streams a
|
||||
provider response, executes any requested tools through the registry/sandbox,
|
||||
appends tool results back into the model conversation, and repeats until it has
|
||||
a final answer or a stop condition.
|
||||
6. When `agent.Run` returns, the command emits `agentResponseMsg`. The Bubble Tea
|
||||
loop marks the run complete, flushes queued rows/session events, updates usage
|
||||
and file state, and re-enables the composer.
|
||||
|
||||
The important architectural point: **the TUI is stateful and reactive, but the
|
||||
agent loop is the source of runtime truth for model/tool progress**. Presentation
|
||||
state is derived from callback events and persisted session events; workspace
|
||||
changes happen only through tools executed by the agent runtime.
|
||||
|
||||
### Feature flows use the same bridge
|
||||
|
||||
For one interactive turn, the supporting systems do not run beside the TUI/agent
|
||||
loop; they run inside or around it:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Submit[TUI submit] --> Options[Build agent.Options]
|
||||
Options --> Context[Assemble context<br/>config, guidelines, history, skills]
|
||||
Context --> ProviderCall[Provider stream]
|
||||
ProviderCall --> StreamEvents[Text/reasoning/tool-call events]
|
||||
StreamEvents --> Render[TUI render callbacks]
|
||||
|
||||
ProviderCall --> ToolCall{Tool call?}
|
||||
ToolCall -- No --> Final[Final response]
|
||||
ToolCall -- Yes --> ToolGate[Tool gate<br/>filters, permissions, sandbox]
|
||||
ToolGate --> HooksBefore[beforeTool hooks]
|
||||
HooksBefore --> Execute[Tool / MCP / specialist / plugin]
|
||||
Execute --> HooksAfter[afterTool hooks]
|
||||
HooksAfter --> ToolResult[Tool result message]
|
||||
ToolResult --> Persist[Session events]
|
||||
ToolResult --> Context
|
||||
|
||||
Final --> Persist
|
||||
Persist --> Resume[Resume / fork / rewind / parent metadata]
|
||||
```
|
||||
|
||||
Most interactive features are variations of that same bridge rather than separate
|
||||
architectures:
|
||||
|
||||
- **Slash commands** either mutate TUI state directly (`/model`, `/permission`,
|
||||
`/help`) or start short `tea.Cmd` jobs that later return a message to `Update`
|
||||
(`/doctor`, `/search`, `/compact`, session commands).
|
||||
- **Permissions** sit between a model-requested tool call and actual execution:
|
||||
the agent blocks on `OnPermissionRequest`, the TUI surfaces a modal and sends
|
||||
the decision back through a channel, and the session log records the
|
||||
request/decision events.
|
||||
- **Plans, specialists/sub-agents, and ask-user prompts** all follow the same
|
||||
runtime pattern: the model requests a capability, the agent records the
|
||||
call/result in its conversation, and the TUI mirrors the visible state as plan
|
||||
panels, specialist cards, or question forms. Specialist session metadata can
|
||||
carry parent/root and sub-agent/spec fields, with richer linkage depending on
|
||||
the specialist or swarm path.
|
||||
- **Streaming tool writes** start as provider tool-call deltas before execution;
|
||||
the TUI shows the arguments live, then replaces/augments them with the actual
|
||||
`ToolResult` after the tool runs.
|
||||
- **Compaction** is an agent-loop context-management step; the TUI may show that
|
||||
it happened, but the purpose is to keep the next provider request within budget.
|
||||
- **Stream-JSON** is another surface over the same callbacks: `zero exec`
|
||||
serializes the events as JSON protocol events instead of Bubble Tea views.
|
||||
|
||||
So a feature-specific deep dive should usually explain: what user event starts it,
|
||||
which `tea.Msg`/callback carries it, which agent/tool event is authoritative, and
|
||||
which session events make it resumable.
|
||||
|
||||
## Runtime Provider Layer
|
||||
|
||||
Providers are hidden behind this interface in `internal/zeroruntime`:
|
||||
|
||||
```go
|
||||
type Provider interface {
|
||||
StreamCompletion(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
|
||||
}
|
||||
```
|
||||
|
||||
The agent loop only knows about provider-neutral messages, tool definitions,
|
||||
stream events, reasoning blocks, images, and token usage. Provider adapters in
|
||||
`internal/providers/*` translate those neutral types to provider-specific APIs:
|
||||
|
||||
- OpenAI and OpenAI-compatible APIs;
|
||||
- Anthropic and Anthropic-compatible APIs;
|
||||
- Gemini;
|
||||
- Codex / ChatGPT OAuth-backed runtime.
|
||||
|
||||
`internal/providers.New` resolves the configured model through the model
|
||||
registry/catalog, normalizes API model IDs and base URLs, attaches API keys or
|
||||
OAuth resolvers, and returns the concrete adapter.
|
||||
|
||||
## Tools and Capabilities
|
||||
|
||||
Tools implement a common interface and are registered by name in
|
||||
`internal/tools.Registry`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
AgentToolCall[Tool call from model] --> Decode[Decode JSON args]
|
||||
Decode --> Filter[Enabled/disabled + permission-mode filter]
|
||||
Filter --> Precheck[Pre-permission rejecters]
|
||||
Precheck --> SandboxEval[Sandbox Evaluate]
|
||||
SandboxEval --> Prompt{Needs user approval?}
|
||||
Prompt -- Yes --> Decision[Permission callback]
|
||||
Prompt -- No --> Run[Registry.RunWithOptions]
|
||||
Decision --> Run
|
||||
Run --> Redact[Redact secrets]
|
||||
Redact --> Budget[Token-aware semantic output budget]
|
||||
Budget --> Ceiling[Existing hard byte ceiling]
|
||||
Ceiling --> Message[Return one tool result to model]
|
||||
```
|
||||
|
||||
Oversized results use deterministic, provider-neutral estimated-token budgets.
|
||||
Policies retain useful structure for files, search matches, tests, process logs,
|
||||
diffs, and worker conclusions; tools without a declared category use a UTF-8-safe
|
||||
head/tail fallback. The estimate is intentionally conservative for non-ASCII
|
||||
text and is not exact provider tokenization. Existing byte ceilings remain the
|
||||
authoritative safety limit. When the existing spill mechanism can persist the
|
||||
complete redacted text received by the budgeting layer, the result includes its
|
||||
safe spill reference. This does not imply capture of subprocess bytes already
|
||||
discarded by a tool's established internal buffer.
|
||||
|
||||
Core tool groups include:
|
||||
|
||||
- **Read-only tools**: file reads, directory listing, glob, grep, LSP navigation,
|
||||
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.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
- **Extension tools**: MCP tools, specialists/sub-agents, swarm tools, plugins.
|
||||
|
||||
Tool definitions include safety metadata: side-effect class, permission level,
|
||||
reason, and whether prompt-gated tools are advertised in auto-like modes. The
|
||||
agent uses this metadata plus per-turn filters and deferred-loading state to
|
||||
choose which registered tools the model can see and whether a call can run
|
||||
immediately.
|
||||
|
||||
## Permission and Sandbox Model
|
||||
|
||||
Zero has two related but separate checks:
|
||||
|
||||
1. **Tool permission metadata** says whether a tool is allowed, denied, or needs
|
||||
approval.
|
||||
2. **Sandbox policy** evaluates the actual request: path scope, network access,
|
||||
destructive shell patterns, explicit escalation, persistent/session grants,
|
||||
and platform isolation availability.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Call[Tool call] --> Safety[Tool Safety metadata]
|
||||
Call --> Scope[Workspace/additional roots scope]
|
||||
Safety --> Engine[Sandbox Engine Evaluate]
|
||||
Scope --> Engine
|
||||
Engine --> Allow[Allow]
|
||||
Engine --> Prompt[Prompt user]
|
||||
Engine --> Deny[Deny]
|
||||
Prompt --> Grant[Turn/session/persistent grant]
|
||||
Grant --> Execute[Execute tool]
|
||||
Allow --> Execute
|
||||
Deny --> Result[Return denied tool result]
|
||||
```
|
||||
|
||||
The sandbox engine can:
|
||||
|
||||
- auto-allow workspace writes for safe file-edit tools;
|
||||
- block reads/writes outside the allowed scope;
|
||||
- prompt for recoverable outside-workspace access;
|
||||
- deny or prompt for network depending on policy;
|
||||
- detect destructive shell commands;
|
||||
- run shell commands through a platform backend when available;
|
||||
- remember session or persistent grants;
|
||||
- support narrow command-prefix approvals.
|
||||
|
||||
The user-facing TUI and `zero exec` mode both route permission requests through
|
||||
callbacks, but the TUI can show interactive modals while headless mode generally
|
||||
returns non-interactive fallback behavior unless a protocol/client handles it.
|
||||
|
||||
## Session Storage and History
|
||||
|
||||
Sessions live locally under Zero's data directory. A session directory contains:
|
||||
|
||||
- `metadata.json` — session ID, title, cwd, provider/model, parent/root IDs,
|
||||
spec/sub-agent fields, timestamps, and event count.
|
||||
- `events.jsonl` — append-only event log with messages, tool calls/results,
|
||||
permissions, usage, checkpoints, forks, compaction, and specs. TUI and exec use
|
||||
the richer event stream; ACP currently records conversational message history
|
||||
while streaming tool and permission updates to the client.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TUI[TUI callbacks] --> Store[internal/sessions.Store]
|
||||
Exec[exec recorder] --> Store
|
||||
Store --> Metadata[metadata.json]
|
||||
Store --> Events[events.jsonl]
|
||||
Events --> Resume[resume]
|
||||
Events --> Fork[fork]
|
||||
Events --> Rewind[rewind/checkpoints]
|
||||
Events --> Children[parent/root metadata]
|
||||
```
|
||||
|
||||
This is why sessions can be resumed, forked, rewound, and inspected without a
|
||||
remote service. Zero stores the local event history; providers only receive the
|
||||
messages needed for the current completion request.
|
||||
|
||||
## Stream-JSON Protocol
|
||||
|
||||
`zero exec --output-format stream-json` emits newline-delimited JSON events for
|
||||
integrations and CI. The protocol is implemented in `internal/streamjson` and is
|
||||
specified in [STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md).
|
||||
|
||||
Common output event types include:
|
||||
|
||||
- `run_start`, `text`, `reasoning`, `tool_call`, `tool_result`;
|
||||
- `permission`, `permission_request`, `permission_decision`;
|
||||
- `checkpoint`, `restore`, `usage`, `final`, `warning`, `error`, `run_end`.
|
||||
|
||||
Input stream-JSON can provide `prompt` or `message` events and image payloads.
|
||||
The CLI parses those events, resolves a single prompt, validates/decodes images,
|
||||
and passes the result into the same agent loop used everywhere else.
|
||||
|
||||
## Extensibility Flow
|
||||
|
||||
Zero's extension surfaces are loaded before the agent loop starts:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Config[Config resolution] --> Registry[Tool registry]
|
||||
Registry --> Core[Core tools]
|
||||
Registry --> MCP[MCP server tools]
|
||||
Registry --> Specialist[Specialist / Task tools]
|
||||
Registry --> Plugin[Plugin tools]
|
||||
|
||||
Config --> Prompt[System prompt context]
|
||||
Prompt --> Guidelines[AGENTS.md / ZERO.md]
|
||||
Prompt --> Skills[Skills list]
|
||||
Prompt --> SpecialistContext[Specialist descriptions]
|
||||
|
||||
ToolLifecycle[Tool lifecycle] --> Hooks[beforeTool / afterTool hooks]
|
||||
```
|
||||
|
||||
- **Project/user guidelines** become prompt context.
|
||||
- **Skills** are instruction packs the model can load on demand.
|
||||
- **Specialists** are sub-agents callable through the `Task` tool.
|
||||
- **MCP servers** contribute external tools.
|
||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
||||
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
||||
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
||||
`LoadFromRoots`; single-root `Load` remains for install/write.
|
||||
- **Hooks** can observe or block tool lifecycle events.
|
||||
|
||||
## End-to-End Data Flow
|
||||
|
||||
A typical interactive edit request looks like this:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant T as TUI
|
||||
participant A as Agent loop
|
||||
participant P as Provider
|
||||
participant R as Tool registry
|
||||
participant S as Sandbox
|
||||
participant FS as Workspace/session files
|
||||
|
||||
U->>T: Type prompt and press Enter
|
||||
T->>A: agent.Run(prompt, provider, options)
|
||||
A->>P: CompletionRequest(messages, tool definitions)
|
||||
P-->>A: Stream text + tool call edit_file
|
||||
A->>S: Evaluate edit_file args
|
||||
S-->>A: allow/prompt/deny decision
|
||||
A->>T: permission callback if prompt needed
|
||||
T-->>A: user decision
|
||||
A->>R: Run edit_file
|
||||
R->>FS: Write workspace file
|
||||
R-->>A: Tool result + changed files
|
||||
A->>P: Next CompletionRequest with tool result
|
||||
P-->>A: Final answer
|
||||
A-->>T: Result + callbacks
|
||||
T->>FS: Append session events
|
||||
T-->>U: Render final transcript
|
||||
```
|
||||
|
||||
The same request in `zero exec` follows the same provider/tool/sandbox path, but
|
||||
streaming events are written to stdout/stderr instead of rendered in Bubble Tea.
|
||||
|
||||
## Mental Model
|
||||
|
||||
When you work on Zero, start by locating the change on the four-layer spine from
|
||||
[Architecture Spine](#architecture-spine) — Surface, Agent loop, Capabilities,
|
||||
Persistence — then jump to the package that owns that boundary:
|
||||
|
||||
- UI behavior? Start in `internal/tui`.
|
||||
- Command-line behavior? Start in `internal/cli`.
|
||||
- Model/tool-turn behavior? Start in `internal/agent`.
|
||||
- A new capability? Register a tool in `internal/tools` or an extension surface.
|
||||
- Safety policy? Start in `internal/sandbox` and agent permission wiring.
|
||||
- Resume/history behavior? Start in `internal/sessions` and session recorders.
|
||||
+31
-176
@@ -1,55 +1,27 @@
|
||||
# Installing Zero
|
||||
# Zero Install Scripts
|
||||
|
||||
Zero is distributed as:
|
||||
Zero release archives are published as:
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.26.5+
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
- `zero-v<version>-macos-<arch>.tar.gz`
|
||||
- `zero-v<version>-windows-<arch>.zip`
|
||||
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
Each archive must have a matching `.sha256` file. The install scripts download both files and verify the checksum before copying the binary.
|
||||
|
||||
## npm
|
||||
Maintainers can verify the release directory before upload:
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
go run ./cmd/zero-release package
|
||||
go run ./cmd/zero-release verify
|
||||
```
|
||||
|
||||
The package supports Linux and macOS on x64 and arm64, and Windows on x64
|
||||
(Windows on ARM runs the x64 build under emulation). It installs a small
|
||||
`zero` wrapper plus, as an optional dependency, a platform payload with the
|
||||
native binary and the bundled browser/terminal control helpers. There are no
|
||||
install scripts and nothing is downloaded from outside the npm registry, so
|
||||
the install is silent and works identically under npm, Bun, pnpm, and yarn —
|
||||
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
|
||||
package architecture.
|
||||
`verify:release` requires every archive in `dist/release` to have a same-directory `.sha256` file whose contents are:
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 18+
|
||||
- network access to npm
|
||||
|
||||
If the install skipped optional dependencies (`npm install --omit=optional`,
|
||||
or a package manager configured to do so), the wrapper falls back to
|
||||
downloading the binary from the matching GitHub Release, with checksum
|
||||
verification. The fetch is retried on each run until a binary is in place (a
|
||||
transient network failure heals itself; an install directory the current user
|
||||
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"
|
||||
```text
|
||||
<sha256> <archive-name>
|
||||
```
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
## Linux And macOS
|
||||
|
||||
From a checkout:
|
||||
|
||||
@@ -61,14 +33,18 @@ Install a specific version:
|
||||
|
||||
```bash
|
||||
ZERO_VERSION=0.1.0 scripts/install.sh
|
||||
scripts/install.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Install somewhere else:
|
||||
Install to a custom directory:
|
||||
|
||||
```bash
|
||||
ZERO_INSTALL_DIR="$HOME/bin" scripts/install.sh
|
||||
scripts/install.sh --install-dir "$HOME/bin"
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```bash
|
||||
scripts/install.sh --repo owner/repo
|
||||
```
|
||||
|
||||
Defaults:
|
||||
@@ -77,15 +53,9 @@ Defaults:
|
||||
- Version: latest GitHub release
|
||||
- Install path: `~/.local/bin/zero`
|
||||
|
||||
Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
Requirements: `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
|
||||
## Windows PowerShell Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
## Windows
|
||||
|
||||
From a checkout:
|
||||
|
||||
@@ -99,145 +69,30 @@ Install a specific version:
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Version 0.1.0
|
||||
```
|
||||
|
||||
Install somewhere else:
|
||||
Install to a custom directory:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:USERPROFILE\bin"
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Repository owner/repo
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
## From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
Build a local binary:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
Release archives include the platform sandbox helpers. If you build directly
|
||||
from source, build the helpers you need:
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
|
||||
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
|
||||
helper applies the Unix-socket filter itself when that sandbox option is enabled.
|
||||
Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
### Termux (Android)
|
||||
|
||||
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
|
||||
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
|
||||
seccomp filter on Android:
|
||||
|
||||
```bash
|
||||
# Install Go in Termux
|
||||
pkg install golang
|
||||
|
||||
# Build Zero for Android
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
|
||||
|
||||
# Move into PATH
|
||||
mv zero ~/.local/bin/
|
||||
```
|
||||
|
||||
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
|
||||
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
|
||||
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
|
||||
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
|
||||
|
||||
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
|
||||
needs one. Use `proot` to bind-mount Termux's resolver config:
|
||||
|
||||
```bash
|
||||
pkg install proot
|
||||
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
|
||||
```
|
||||
|
||||
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
|
||||
|
||||
```bash
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
|
||||
```
|
||||
|
||||
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
|
||||
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
|
||||
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
|
||||
to avoid PRoot's ptrace interference with the 1003 escape sequence.
|
||||
|
||||
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
|
||||
example, to use OpenCode Zen's free tier:
|
||||
|
||||
```bash
|
||||
zero providers add opencode \
|
||||
--name opencode \
|
||||
--model deepseek-v4-flash-free \
|
||||
--base-url https://opencode.ai/zen/v1 \
|
||||
--set-active
|
||||
```
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
|
||||
```powershell
|
||||
go build -o zero.exe ./cmd/zero
|
||||
go build -o zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
|
||||
go build -o zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
|
||||
```
|
||||
|
||||
## Release Archive Format
|
||||
|
||||
Release archives are named:
|
||||
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
- `zero-v<version>-macos-<arch>.tar.gz`
|
||||
- `zero-v<version>-windows-<arch>.zip`
|
||||
|
||||
Supported targets:
|
||||
|
||||
- `linux-x64`
|
||||
- `linux-arm64`
|
||||
- `macos-x64`
|
||||
- `macos-arm64`
|
||||
- `windows-x64` (Windows on ARM runs this build under emulation)
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download
|
||||
both files, verify the checksum, and then copy the binary into the install
|
||||
directory.
|
||||
|
||||
## Updating
|
||||
|
||||
Check for a newer release:
|
||||
Check whether a newer release exists:
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
```
|
||||
|
||||
Then reinstall with npm or rerun the install script for the version you want.
|
||||
For M2, updates are check-only. Re-run the installer to replace the local binary after reviewing the release.
|
||||
@@ -0,0 +1,87 @@
|
||||
# M1 Headless Exec PRD
|
||||
|
||||
Status: Draft
|
||||
Owner: Vasanth
|
||||
Milestone: M1
|
||||
Scope: `zero exec` CLI surface
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the first production-shaped headless command for Zero:
|
||||
|
||||
```sh
|
||||
zero exec "inspect this repo and summarize the next fix"
|
||||
```
|
||||
|
||||
This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No daemon mode.
|
||||
- No JSON-RPC input protocol.
|
||||
- No MCP, plugin, marketplace, or subagent support.
|
||||
- No session resume/fork/search.
|
||||
- No new provider adapters in this slice.
|
||||
- No architecture rewrite of the agent loop.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout.
|
||||
2. As a script author, I can choose `text` or JSONL output.
|
||||
3. As a reviewer, I can see tool activity without corrupting text stdout.
|
||||
4. As a model tester, I can override the configured model for one run.
|
||||
5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag.
|
||||
|
||||
## CLI Contract
|
||||
|
||||
```sh
|
||||
zero exec [prompt...] [options]
|
||||
```
|
||||
|
||||
Options for M1:
|
||||
|
||||
- `-f, --file <path>`: read the prompt from a file.
|
||||
- `-m, --model <model>`: override the configured model for this run.
|
||||
- `-C, --cwd <path>`: run from another working directory.
|
||||
- `-o, --output-format <text|json>`: choose stdout format.
|
||||
- `--skip-permissions-unsafe`: grant prompt-gated tools for this run.
|
||||
|
||||
The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Text mode:
|
||||
|
||||
- stdout contains assistant text only.
|
||||
- stderr contains tool rows, warnings, and errors.
|
||||
|
||||
JSON mode:
|
||||
|
||||
- stdout is newline-delimited JSON events.
|
||||
- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: success
|
||||
- `1`: unexpected crash
|
||||
- `2`: usage error
|
||||
- `3`: provider/config/runtime error
|
||||
- `4`: reserved for future tool failure policy
|
||||
- `5`: reserved for future permission-denied policy
|
||||
|
||||
## Permission Behavior
|
||||
|
||||
Default headless mode advertises only tools marked `permission: "allow"`.
|
||||
|
||||
`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `go test ./...` passes.
|
||||
- `go run ./cmd/zero-release build` passes.
|
||||
- `go run ./cmd/zero-release smoke` passes.
|
||||
- `zero exec --help` documents the M1 flags.
|
||||
- Usage errors do not initialize providers.
|
||||
- Provider failures return exit code `3`.
|
||||
- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`.
|
||||
- Unsafe mode still uses the registry execution path.
|
||||
@@ -1,162 +0,0 @@
|
||||
# npm packaging
|
||||
|
||||
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
|
||||
rules the release pipeline must follow. Read this before touching
|
||||
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
|
||||
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
|
||||
`release-artifacts.yml`.
|
||||
|
||||
## Goals
|
||||
|
||||
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
|
||||
|
||||
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
|
||||
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
|
||||
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
|
||||
install zero without prompts, trust ceremonies, or broken binaries.
|
||||
- No network fetches outside the npm registry at install time. GitHub being
|
||||
down or rate-limited must not break `npm install`.
|
||||
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
|
||||
of the box.
|
||||
|
||||
## Architecture
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
|
||||
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
|
||||
`npm:` aliases in `optionalDependencies`. One package name means one npm
|
||||
trusted-publisher configuration — no new publish credentials per platform.
|
||||
The alias suffixes use Node's `process.platform`/`process.arch` names so
|
||||
`bin/zero.js` can derive its platform package directly.
|
||||
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
|
||||
them and skips the rest.
|
||||
- A platform payload is assembled from the platform's release archive by
|
||||
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
|
||||
the platform's sandbox helpers, and the vendored `helpers/` tree (see
|
||||
below). It has no `bin`, no scripts, and no dependencies — it is inert on
|
||||
its own; the wrapper execs the binary out of it.
|
||||
- The published wrapper has **no scripts and no dependencies** — the
|
||||
assembly script strips both from the repo `package.json` and injects the
|
||||
exact-version `optionalDependencies`. (The repo `package.json` keeps
|
||||
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
|
||||
the vendored helpers tree; they are never installed by consumers.)
|
||||
- There is **no windows-arm64 build** (matches the release matrix); Windows on
|
||||
ARM runs the x64 build under emulation via the first-run fallback.
|
||||
|
||||
### Binary resolution in `bin/zero.js`
|
||||
|
||||
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
|
||||
it. This wins over any previously downloaded copy — the platform version is
|
||||
pinned to the wrapper release.
|
||||
2. Fall back to a binary previously downloaded next to the wrapper.
|
||||
3. If neither exists (`--omit=optional`, package managers that skip optional
|
||||
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
|
||||
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
|
||||
verified against the release's own checksum file, no zip-slip), invoked by
|
||||
the wrapper itself. Failures are deliberately not cached — the fetch
|
||||
retries on every run until a binary is in place, so a transient network
|
||||
error self-heals. Platforms with no release asset (anything outside the
|
||||
matrix above, including windows-arm64) skip the attempt entirely rather
|
||||
than probing the network each run.
|
||||
4. If the download is impossible too, print build-from-source guidance.
|
||||
|
||||
There is deliberately **no `scripts.postinstall`** in any published
|
||||
`package.json`. The downloader exists only as a first-run fallback.
|
||||
|
||||
### Vendored helpers (`helpers/`)
|
||||
|
||||
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
|
||||
**vendored binaries/packages inside the platform payload**, not npm
|
||||
dependencies of the wrapper:
|
||||
|
||||
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
|
||||
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
|
||||
every installer. As a vendored tree it produces none, because npm never
|
||||
resolves it.
|
||||
- `zero-release package` already stages the helpers tree into every release
|
||||
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
|
||||
`package.json` pins + lockfile on the native builder). The assembly script
|
||||
reuses that staged tree, so npm installs and `install.sh` installs get
|
||||
identical helpers.
|
||||
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
|
||||
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
|
||||
wrapper involvement, no configuration.
|
||||
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
|
||||
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
|
||||
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
|
||||
other symlink, so the vendored tree survives publishing. (Verified
|
||||
empirically; nested `node_modules` under `helpers/` IS packed — only the
|
||||
package-root `node_modules` is always ignored.)
|
||||
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
|
||||
platform (7 total). The assembly script keeps only the payload's own
|
||||
platform binary (plus the musl variant on linux, which its launcher detects
|
||||
at runtime) — about 65 MB saved per platform package.
|
||||
|
||||
## Publishing rules (release pipeline)
|
||||
|
||||
These are the invariants `release-artifacts.yml` must hold. Breaking the first
|
||||
one is user-visible immediately.
|
||||
|
||||
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
|
||||
semver *prerelease* of `0.4.0`; publishing it without an explicit
|
||||
non-`latest` dist-tag would clobber `latest` and users would install a
|
||||
platform payload as the CLI. The workflow publishes platform versions with
|
||||
`--tag platform` and asserts `latest` survived before the wrapper publish,
|
||||
then asserts `latest` equals the wrapper version after it. Both lookups
|
||||
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
|
||||
bootstrap publish) — a missing value must not abort the job between the
|
||||
platform and wrapper publishes, which would strand a half-published
|
||||
release.
|
||||
2. **Platform versions publish before the wrapper.** The wrapper's
|
||||
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
|
||||
first would create a window where installs resolve aliases that 404.
|
||||
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
|
||||
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
|
||||
and its binaries can never skew.
|
||||
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.
|
||||
|
||||
`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`.
|
||||
|
||||
## Runbook: bumping the vendored helpers
|
||||
|
||||
The vendored helper versions are pinned by the repo `package.json`
|
||||
`dependencies` + `package-lock.json`; the release build vendors exactly what
|
||||
the lockfile resolves. No `^range` surprises ship: a release carries what its
|
||||
lockfile pinned at build time.
|
||||
|
||||
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
|
||||
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
|
||||
ship with the next release.
|
||||
- Security response: if a vendored helper publishes a security fix, the bump
|
||||
is release-worthy on its own — cut a patch release of zero carrying only the
|
||||
pin change.
|
||||
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
|
||||
vendored package directory (`helpers/node_modules/agent-browser/`), which
|
||||
satisfies redistribution attribution.
|
||||
|
||||
## History / rationale
|
||||
|
||||
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
|
||||
`agent-browser`/`tuistory` as regular dependencies. That produced three
|
||||
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
|
||||
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
|
||||
silently under Bun's default script blocking, and coupled `npm install` to
|
||||
GitHub Releases availability. The platform-package model removes the warnings
|
||||
structurally — there is nothing left in the tree for a package manager to warn
|
||||
about — rather than asking users to approve or suppress them.
|
||||
@@ -1,8 +1,8 @@
|
||||
# npm Wrapper Smoke Checklist
|
||||
|
||||
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.
|
||||
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
|
||||
wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -21,52 +21,11 @@ go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
|
||||
## End-To-End Assembly Smoke
|
||||
|
||||
Build a real archive for the host platform and assemble the npm payloads from
|
||||
it (this is exactly what the publish job does):
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release package
|
||||
node scripts/npm/build-platform-packages.mjs \
|
||||
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
|
||||
```
|
||||
|
||||
Then simulate an installed layout and run the wrapper through each resolution
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name (`@gitlawb/zero`), version,
|
||||
`bin.zero` entry, and NO `scripts` entries — the published package must be
|
||||
free of lifecycle scripts (see NPM_PACKAGING.md).
|
||||
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
|
||||
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
|
||||
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
|
||||
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
|
||||
scripts, no dependencies, and the full five-alias `optionalDependencies`
|
||||
matrix.
|
||||
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
|
||||
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
|
||||
plan), verifies the downloaded archive's SHA-256, and extracts only the
|
||||
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
|
||||
(exit 0) and an unsupported platform/arch is a non-fatal skip.
|
||||
- The wrapper prefers the platform package binary over a previously downloaded
|
||||
one, and `agent-browser`/`tuistory` run from the platform package's
|
||||
`helpers/node_modules/.bin` shims.
|
||||
- `package.json` has the expected package name, version, and `bin.zero` entry.
|
||||
- The wrapper binary resolves through the package `bin` entry and
|
||||
`node_modules/.bin` in a package-install smoke test.
|
||||
- The built binary exits 0 for `zero --version` or `zero --help`.
|
||||
- `zero --version` reports `zero <package.json version>`.
|
||||
- Release packaging still emits the expected archive and checksum names when
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Zero Performance Benchmarks
|
||||
|
||||
The performance harness tracks three release-facing signals:
|
||||
The M2 performance harness tracks three release-facing signals:
|
||||
|
||||
- Cold start: process startup time for `zero --version`.
|
||||
- Binary first output: time from spawning the built `zero --version` command to
|
||||
|
||||
+602
@@ -0,0 +1,602 @@
|
||||
# Zero — Product Requirements Document (v1.0)
|
||||
|
||||
**Status:** DRAFT v1.0
|
||||
**Target:** Compete with Claude Code + Cursor + OpenCode
|
||||
**Timeline:** 6-12 months
|
||||
**Stack:** Go native core + npm distribution wrapper
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**Zero is a terminal-first, editor-aware, multi-provider AI coding platform.**
|
||||
|
||||
It combines the best of:
|
||||
- **Claude Code** — terminal-native agentic coding
|
||||
- **Cursor** — IDE integration + smart editing
|
||||
- **OpenCode** — multi-provider + headless
|
||||
|
||||
**One Go-native agent core. Three surfaces. Zero lock-in.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Surfaces
|
||||
|
||||
### 2.1 Terminal (TUI) — Primary
|
||||
- Interactive coding agent in terminal
|
||||
- Streaming, append-style (like Claude Code)
|
||||
- Bubble Tea + Lip Gloss-based
|
||||
- Full keyboard control
|
||||
- Mouse support
|
||||
|
||||
### 2.2 Editor (VS Code Extension)
|
||||
- Same agent core, different UI
|
||||
- Inline suggestions + chat panel
|
||||
- File editing with diff view
|
||||
- Diagnostics integration
|
||||
- LSP-aware
|
||||
|
||||
### 2.3 Headless (CLI/CI)
|
||||
- `zero exec "prompt"` for scripts
|
||||
- `zero serve` for daemon mode
|
||||
- JSON-RPC over stdio/WebSocket
|
||||
- MCP server support
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ZERO PLATFORM │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ TUI │ │ VSCode │ │ Headless │ │
|
||||
│ │ Surface │ │ Surface │ │ Surface │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────┼─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ Agent Core │ │
|
||||
│ │ (ReAct) │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌──────┬──────┬────┴────┬──────┬──────┐ │
|
||||
│ │Tools │Perms │Sessions │Provi │Memory│ │
|
||||
│ └──────┴──────┴─────────┴──────┴──────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Layering Rules:**
|
||||
- Agent Core never imports a surface
|
||||
- Surfaces are thin shells (I/O + rendering)
|
||||
- All state persists to local files
|
||||
- No cloud dependency
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements (24 Features)
|
||||
|
||||
### F1 — Agent Core (P0)
|
||||
- ReAct loop: think → tool → result → think
|
||||
- Stream text + tool calls + thinking
|
||||
- Parallel tool execution where safe
|
||||
- Bound turns (max 100)
|
||||
- Clean interrupt (Esc/Ctrl-C)
|
||||
- Event stream: `text | tool_call | tool_result | thinking | usage | plan_update | error | turn_end`
|
||||
|
||||
### F2 — Tools (P0)
|
||||
|
||||
**Core (8):**
|
||||
- `read_file` — read with line ranges, images
|
||||
- `write_file` — create/overwrite
|
||||
- `edit_file` — exact-string edit (uniqueness guard)
|
||||
- `bash` — run shell command
|
||||
- `grep` — ripgrep content search
|
||||
- `glob` — file pattern matching
|
||||
- `apply_patch` — multi-hunk patches
|
||||
- `update_plan` — todo list
|
||||
|
||||
**Advanced (4):**
|
||||
- `web_fetch` / `web_search` — web access
|
||||
- `task` — spawn subagent
|
||||
- `structured_output` — JSON-Schema validated
|
||||
- `notebook_edit` — Jupyter notebooks
|
||||
|
||||
**Tool Requirements:**
|
||||
- Go typed parameter schemas -> JSON Schema
|
||||
- Side-effect class: `read | write | shell | network | out_of_workspace`
|
||||
- Structured results: `{ status, output, truncated?, meta? }`
|
||||
- Atomic execution
|
||||
- Permission gating
|
||||
|
||||
### F3 — Providers & Model Registry (P0)
|
||||
- OpenAI-compatible (works with OpenAI, OpenRouter, Ollama, etc.)
|
||||
- Anthropic (Claude)
|
||||
- Google Gemini
|
||||
- Azure OpenAI
|
||||
- AWS Bedrock
|
||||
- xAI (Grok)
|
||||
|
||||
**Model Registry:**
|
||||
|
||||
```go
|
||||
type ModelEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Provider string
|
||||
APIProviders []string
|
||||
ContextLimits ContextLimits
|
||||
ReasoningEffort ReasoningEffort
|
||||
Capabilities ModelCapabilities
|
||||
Cost ModelCost
|
||||
Tier string
|
||||
MatchPatterns []string
|
||||
}
|
||||
```
|
||||
|
||||
- Per-task model selection (`-m`)
|
||||
- Provider rotation/failover
|
||||
- Cost tracking
|
||||
- "Spec model" for planning
|
||||
- Fallback model on failure
|
||||
|
||||
### F4 — Terminal UI (P0)
|
||||
- **Layout:** header (cwd · git · model) → tool rows → diff → response → input → footer
|
||||
- **Streaming:** append-only transcript model with live tail updates
|
||||
- **Markdown:** full CommonMark + syntax highlighting
|
||||
- **Tool rows:** collapsible with one-line summaries
|
||||
- **Input:** `/` commands, `@` files, `!` bash, `Esc` interrupt
|
||||
- **Robustness:** resize, wide-char, ANSI, non-TTY fallback
|
||||
- **Splash:** ZERO wordmark
|
||||
- **Themes:** light/dark + custom
|
||||
|
||||
### F5 — Sessions (P0)
|
||||
- Persist to `~/.local/share/zero/sessions/<id>/`
|
||||
- `meta.json` + `events.jsonl` + `messages.jsonl` + `checkpoints/`
|
||||
- `--resume [id]` (default: latest)
|
||||
- `--fork <id>` (copy with lineage)
|
||||
- Search: `zero search <query>` over messages/tools/results
|
||||
- Atomic writes (write-temp-then-rename)
|
||||
- Schema versioning
|
||||
- Export to Markdown/JSON
|
||||
|
||||
### F6 — Configuration (P0)
|
||||
- Layered: defaults → user → project → env → CLI
|
||||
- `~/.config/zero/config.json` (user)
|
||||
- `./.zero/config.json` (project)
|
||||
- Runtime overlay (`--settings <path>`)
|
||||
- Feature flags for staged rollout
|
||||
- Validation on load
|
||||
|
||||
### F7 — MCP (Model Context Protocol) (P0)
|
||||
- Connect to stdio/HTTP/SSE servers
|
||||
- Surface MCP tools into registry
|
||||
- `zero mcp add/remove/list`
|
||||
- Per-server and per-tool permissions
|
||||
- Persistent grants with revoke/clear
|
||||
- MCP server mode (zero as MCP server)
|
||||
|
||||
### F8 — Headless / Programmatic (P0)
|
||||
- `zero exec [prompt]` — one-shot
|
||||
- `zero serve` — daemon mode
|
||||
- `--input-format stream-json` — line-delimited events
|
||||
- `--input-format stream-jsonrpc` — JSON-RPC protocol
|
||||
- `-o text|json` output formats
|
||||
- Exit codes: `0=success, 1=crash, 2=usage, 3=provider, 4=tool, 5=permission, 6=budget`
|
||||
|
||||
### F9 — Permissions & Autonomy (P0)
|
||||
- **Side-effect classes:** read, write, shell, network, out_of_workspace
|
||||
- **Autonomy levels:** low (ask), medium (smart), high (auto)
|
||||
- `--skip-permissions-unsafe` (loud warning)
|
||||
- TUI: inline prompts (allow/deny/always)
|
||||
- Headless: default-deny unless granted
|
||||
- Persistent grants with list/revoke/clear
|
||||
- Audit log in `events.jsonl`
|
||||
|
||||
**Permission Matrix:**
|
||||
|
||||
| Tool Class | low | medium | high |
|
||||
|------------|-----|--------|------|
|
||||
| Read | allow | allow | allow |
|
||||
| Workspace write | prompt | allow | allow |
|
||||
| Shell | prompt | prompt | allow |
|
||||
| Network | prompt | allow | allow |
|
||||
| Out-of-workspace | deny | prompt | prompt |
|
||||
|
||||
### F10 — Observability (P0)
|
||||
- Structured logging (stdout/stderr split)
|
||||
- Secret redaction (API keys, tokens, passwords)
|
||||
- No vendor telemetry (local only)
|
||||
- `zero doctor` — health check command
|
||||
- Crash reports (opt-in, local)
|
||||
- Performance metrics (TTFT, tokens/sec)
|
||||
|
||||
### F11 — Subagents (P1)
|
||||
- `task` tool spawns scoped subagent
|
||||
- Built-in agents: code-review, security-review, refactor, test-gen
|
||||
- Recursion depth limit
|
||||
- Parent session linkage
|
||||
- Shared/isolated budgets
|
||||
|
||||
### F12 — Plugins (P1)
|
||||
- User/project-scoped plugins
|
||||
- Commands, tools, prompts, hooks
|
||||
- Plugin manifest: `plugin.json`
|
||||
- `zero plugin install <path|url>`
|
||||
- No marketplace in v1 (manual install)
|
||||
|
||||
### F13 — Distribution (P0)
|
||||
- **Single binary** via Go cross-compilation
|
||||
- npm package as a thin installer/launcher for the platform binary
|
||||
- Cross-platform: Linux, macOS, Windows
|
||||
- `zero update [--check]` self-update
|
||||
- Checksum verification
|
||||
- Rollback on failure
|
||||
|
||||
### F14 — VS Code Extension (P1)
|
||||
- Same agent core via stream protocol
|
||||
- Inline chat panel
|
||||
- Diff view in editor
|
||||
- Diagnostics integration
|
||||
- LSP awareness
|
||||
- File watching
|
||||
|
||||
### F15 — Memory & Context (P1)
|
||||
- `AGENTS.md` hierarchical memory
|
||||
- `@import` for modular rules
|
||||
- Auto-generated from `init` command
|
||||
- Project conventions learning
|
||||
- Skills system (reusable instruction packs)
|
||||
- Context budgeting (token tracking)
|
||||
- Auto-compaction when full
|
||||
|
||||
### F16 — Hooks System (P1)
|
||||
- Lifecycle hooks: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`
|
||||
- User-defined shell commands
|
||||
- JSON output for conditional flow
|
||||
- `.zero/hooks/` directory
|
||||
|
||||
### F17 — Slash Commands (P0)
|
||||
|
||||
**Built-in (21):**
|
||||
- Session: `/clear`, `/compact`, `/context`, `/add-dir`, `/rewind`, `/resume`, `/exit`
|
||||
- Model: `/model`, `/effort`, `/style`, `/permissions`
|
||||
- Project: `/init`, `/memory`, `/skills`
|
||||
- Extensibility: `/mcp`, `/hooks`, `/agents`
|
||||
- Meta: `/help`, `/config`, `/doctor`, `/feedback`
|
||||
|
||||
**Custom:** `./.zero/commands/*.md`
|
||||
|
||||
### F18 — Git Integration (P1)
|
||||
- Worktree isolation per task
|
||||
- Auto-commit with messages
|
||||
- Branch management
|
||||
- PR creation
|
||||
- Diff review
|
||||
- Conflict resolution
|
||||
|
||||
### F19 — Testing & Verification (P1)
|
||||
- `run_tests` tool
|
||||
- Self-verification loop
|
||||
- Test runner integration
|
||||
- Coverage reporting
|
||||
- Diagnostics (typecheck, lint)
|
||||
- LSP integration
|
||||
|
||||
### F20 — Web Access (P1)
|
||||
- `web_fetch` — fetch URL content
|
||||
- `web_search` — search engine
|
||||
- Provider/permission gated
|
||||
- Rate limiting
|
||||
- Caching
|
||||
|
||||
### F21 — Sandbox (P2)
|
||||
- Linux: bubblewrap
|
||||
- macOS: sandbox-exec
|
||||
- Windows: AppContainer
|
||||
- Filesystem policies (workspace write confinement + user-granted extra roots via `--add-dir`)
|
||||
- Network policies
|
||||
- Unix socket controls
|
||||
|
||||
### F22 — Multi-modal (P2)
|
||||
- Image input (screenshots, diagrams)
|
||||
- PDF parsing
|
||||
- Voice input (optional)
|
||||
- File attachments
|
||||
- Paste support
|
||||
|
||||
### F23 — Telemetry (P2)
|
||||
- Local-only metrics
|
||||
- Token usage tracking
|
||||
- Cost reports
|
||||
- Performance profiling
|
||||
- Crash reports (opt-in)
|
||||
- NO vendor telemetry
|
||||
|
||||
### F24 — Advanced Features (P2)
|
||||
- Code review automation
|
||||
- Auto-refactor
|
||||
- Dependency updates
|
||||
- Security scanning
|
||||
- Documentation generation
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Models
|
||||
|
||||
### 5.1 Headless Event Stream
|
||||
|
||||
```json
|
||||
{ "type": "run_start", "runId": "r1", "sessionId": "s1", "cwd": "/repo", "model": "..." }
|
||||
{ "type": "text", "delta": "..." }
|
||||
{ "type": "tool_call", "id": "t1", "name": "bash", "args": {...}, "sideEffect": "shell" }
|
||||
{ "type": "permission", "id": "t1", "decision": "needs_approval", "reason": "shell" }
|
||||
{ "type": "tool_result", "id": "t1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "type": "usage", "promptTokens": 1234, "completionTokens": 567, "costUsd": 0.012 }
|
||||
{ "type": "plan_update", "plan": [{ "id": "1", "content": "...", "status": "in_progress" }] }
|
||||
{ "type": "turn_end", "stopReason": "end_turn" }
|
||||
{ "type": "error", "code": "rate_limit", "message": "...", "recoverable": true }
|
||||
{ "type": "run_end", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
### 5.2 Session On Disk
|
||||
|
||||
```
|
||||
~/.local/share/zero/sessions/<id>/
|
||||
meta.json # { schemaVersion, id, parentId?, title, cwd, model, createdAt, updatedAt, tokens, cost }
|
||||
events.jsonl # append-only AgentEvent stream
|
||||
messages.jsonl # provider-facing messages
|
||||
checkpoints/ # snapshots for restore/fork/compact
|
||||
```
|
||||
|
||||
### 5.3 Tool Contract
|
||||
|
||||
```go
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() JSONSchema
|
||||
SideEffect() SideEffect
|
||||
Execute(ctx ToolContext, args map[string]any) ToolResult
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
Status string
|
||||
Output string
|
||||
Truncated bool
|
||||
Meta map[string]any
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Config Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"providers": {
|
||||
"anthropic": { "apiKey": "env:AN..._KEY" },
|
||||
"openai": { "apiKey": "env:OP..._KEY" }
|
||||
},
|
||||
"permissions": {
|
||||
"autonomy": "medium",
|
||||
"grants": [{ "tool": "bash", "pattern": "git status", "decision": "allow" }]
|
||||
},
|
||||
"ui": { "theme": "dark", "syntaxHighlighting": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- TTFT < 500ms after provider's first byte
|
||||
- No full-transcript repaint per token
|
||||
- Cold start < 300ms (warm cache)
|
||||
- Tool execution < 100ms overhead
|
||||
- Session resume < 1s
|
||||
|
||||
### Reliability
|
||||
- Crash-free TUI under resize, wide-char, non-TTY
|
||||
- Clean interrupt (no half-written state)
|
||||
- Atomic session writes
|
||||
- Recovery from malformed JSONL
|
||||
- Provider failover
|
||||
|
||||
### Security
|
||||
- No mutation without permission decision (hard invariant)
|
||||
- API keys never logged
|
||||
- Secret redaction everywhere
|
||||
- Headless stdout free of logs
|
||||
- Workspace boundary by default
|
||||
- Sandboxing for untrusted code
|
||||
|
||||
### Portability
|
||||
- Linux, macOS, Windows
|
||||
- Any OpenAI-compatible endpoint
|
||||
- x86_64, ARM64
|
||||
- Single binary distribution
|
||||
|
||||
### Testability
|
||||
- Mock provider for unit tests
|
||||
- Fake MCP server/client
|
||||
- Fake terminal/PTY
|
||||
- Sandbox simulator
|
||||
- Deterministic event replay
|
||||
|
||||
---
|
||||
|
||||
## 7. Milestones
|
||||
|
||||
### M0 — Foundation (Weeks 1-2)
|
||||
- Go module setup
|
||||
- Agent core skeleton
|
||||
- 8 core tools (read, write, edit, bash, grep, glob, apply_patch, update_plan)
|
||||
- OpenAI-compatible provider
|
||||
- Basic Bubble Tea TUI
|
||||
- Permission gate (read vs mutate)
|
||||
- Session persistence
|
||||
|
||||
**Exit:** `zero` runs, can read/edit files, basic chat works.
|
||||
|
||||
### M1 — Multi-Provider (Weeks 3-4)
|
||||
- Anthropic provider
|
||||
- Gemini provider
|
||||
- Model registry
|
||||
- Per-task model selection
|
||||
- Provider rotation
|
||||
- Cost tracking
|
||||
- Headless `exec` mode
|
||||
|
||||
**Exit:** Switch between Claude/GPT/Gemini, see cost, run headless.
|
||||
|
||||
### M2 — Polish (Weeks 5-8)
|
||||
- Advanced TUI features
|
||||
- Full markdown + syntax highlighting
|
||||
- Tool result truncation/pagination
|
||||
- Auto-compaction
|
||||
- Context budgeting
|
||||
- 20 slash commands
|
||||
- Doctor command
|
||||
- Search command
|
||||
|
||||
**Exit:** Production-ready TUI, smooth streaming, all commands work.
|
||||
|
||||
### M3 — Distribution (Weeks 9-10)
|
||||
- Single binary build
|
||||
- Self-update
|
||||
- Cross-platform CI
|
||||
- VS Code extension MVP
|
||||
- Documentation site
|
||||
|
||||
**Exit:** `zero` ships as one binary, works on Win/Mac/Linux.
|
||||
|
||||
### M4 — Extensibility (Weeks 11-14)
|
||||
- MCP client + permissions
|
||||
- Hooks system
|
||||
- Skills system
|
||||
- Memory (AGENTS.md)
|
||||
- Plugin loading
|
||||
- Subagents (built-in: review, security)
|
||||
|
||||
**Exit:** Plugins work, MCP servers connect, memory persists.
|
||||
|
||||
### M5 — Advanced (Weeks 15-20)
|
||||
- Web fetch/search
|
||||
- Git worktrees
|
||||
- Self-verification
|
||||
- Diagnostics (LSP, typecheck)
|
||||
- Testing integration
|
||||
- Code review automation
|
||||
|
||||
**Exit:** Zero can review code, run tests, manage git.
|
||||
|
||||
### M6 — Sandbox & Security (Weeks 21-24)
|
||||
- Linux sandbox (bubblewrap)
|
||||
- macOS sandbox
|
||||
- Windows AppContainer
|
||||
- Network policies
|
||||
- Filesystem policies
|
||||
|
||||
**Exit:** Untrusted code runs safely in sandbox.
|
||||
|
||||
### Beyond v1.0
|
||||
- Multi-modal (images, PDFs)
|
||||
- Voice input
|
||||
- Cloud sync (optional)
|
||||
- Team features
|
||||
- Marketplace (if demand)
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & Mitigations
|
||||
|
||||
| # | Risk | Mitigation |
|
||||
|---|------|------------|
|
||||
| R1 | Scope creep | Stick to milestones, defer features |
|
||||
| R2 | Provider API changes | Abstract via interface, test with mocks |
|
||||
| R3 | TUI complexity | Use Bubble Tea/Lip Gloss, follow mature terminal agent UX patterns |
|
||||
| R4 | Performance | Benchmark early, optimize streaming |
|
||||
| R5 | Security vulnerabilities | Permission gate from day 1, security review |
|
||||
| R6 | Cross-platform bugs | CI on Win/Mac/Linux from M0 |
|
||||
| R7 | Breaking changes | Schema versioning, migration tools |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### v1.0 (6 months)
|
||||
- 5+ providers supported
|
||||
- 12+ tools
|
||||
- 100% test coverage on core
|
||||
- < 300ms cold start
|
||||
- Single binary < 50MB
|
||||
- Works on Win/Mac/Linux
|
||||
- 1000+ GitHub stars
|
||||
- 50+ contributors
|
||||
|
||||
### v2.0 (12 months)
|
||||
- VS Code extension published
|
||||
- MCP server mode
|
||||
- Plugin ecosystem (20+ plugins)
|
||||
- 10K+ active users
|
||||
- Compete with Claude Code on benchmarks
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of Scope (v1.0)
|
||||
|
||||
- Cloud sync / hosted backend
|
||||
- Relay / remote SSH execution
|
||||
- Multi-agent mission mode
|
||||
- Plugin marketplace
|
||||
- Auto-generated wikis
|
||||
- Git authorship notes
|
||||
- Vendor telemetry
|
||||
- Voice input
|
||||
- Mobile support
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Decisions
|
||||
|
||||
1. **TUI library:** Bubble Tea + Lip Gloss — **Decide: Go-native TUI** (single-binary friendly, mature terminal ecosystem)
|
||||
2. **Storage:** JSONL files vs SQLite — **Decide: JSONL** (inspectable, simple)
|
||||
3. **Package structure:** Single vs monorepo — **Decide: Single** until M4
|
||||
4. **Provider abstraction:** Direct API vs wrapper — **Decide: Direct** (portability)
|
||||
5. **Patch strategy:** apply_patch vs edit_file — **Decide: Both** (apply_patch for bulk, edit_file for small)
|
||||
|
||||
---
|
||||
|
||||
## 12. Team & Timeline
|
||||
|
||||
**Team (3 people):**
|
||||
- You (lead + TUI + tools)
|
||||
- Backend dev (providers + core)
|
||||
- Designer (VS Code extension + docs)
|
||||
|
||||
**Timeline:**
|
||||
- M0-M1: 4 weeks (foundation)
|
||||
- M2-M3: 6 weeks (polish + distribution)
|
||||
- M4-M5: 8 weeks (extensibility)
|
||||
- M6: 4 weeks (sandbox)
|
||||
- **Total v1.0: 6 months**
|
||||
|
||||
---
|
||||
|
||||
## 13. Why This Will Work
|
||||
|
||||
1. **Proven patterns** — Based on clean-room analysis of existing terminal coding-agent UX patterns
|
||||
2. **Multi-provider** — Unique advantage vs Claude Code/Cursor
|
||||
3. **Open source** — Community contributions
|
||||
4. **Go** — Native binary, fast startup, strong concurrency, cross-platform CLI ergonomics
|
||||
5. **npm distribution wrapper** — Keeps JavaScript ecosystem installation while the runtime stays native
|
||||
6. **Clear milestones** — Ship early, iterate
|
||||
|
||||
---
|
||||
|
||||
**This is the real PRD. 13 sections, 24 features, 6 months to v1.0.**
|
||||
@@ -1,24 +0,0 @@
|
||||
# Zero Documentation
|
||||
|
||||
This directory contains public documentation for using, extending, and releasing
|
||||
Zero.
|
||||
|
||||
## User Docs
|
||||
|
||||
- [Install](INSTALL.md)
|
||||
- [Update flow](UPDATE.md)
|
||||
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
|
||||
|
||||
## Automation And Integrations
|
||||
|
||||
- [Stream-JSON protocol](STREAM_JSON_PROTOCOL.md)
|
||||
- [GitHub Action](GITHUB_ACTION.md)
|
||||
- [Specialists](SPECIALISTS.md)
|
||||
|
||||
## Maintainer Docs
|
||||
|
||||
- [How Zero Works](HOW_ZERO_WORKS.md)
|
||||
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
|
||||
- [Benchmarks](BENCHMARK.md)
|
||||
- [Performance benchmarks](PERFORMANCE.md)
|
||||
- [Offline agent evals](AGENT_EVALS.md)
|
||||
@@ -8,12 +8,12 @@ redacted before they are written to stdout.
|
||||
|
||||
## Version
|
||||
|
||||
Current schema version: `2`
|
||||
Current schema version: `1`
|
||||
|
||||
Every input and output event must include:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "..." }
|
||||
{ "schemaVersion": 1, "type": "..." }
|
||||
```
|
||||
|
||||
Output events also include `runId`.
|
||||
@@ -24,42 +24,27 @@ Output events also include `runId`.
|
||||
`--file`.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "message", "role": "user", "content": "Inspect this repo." }
|
||||
{ "schemaVersion": 2, "type": "prompt", "content": "Return only blockers." }
|
||||
{ "schemaVersion": 1, "type": "message", "role": "user", "content": "Inspect this repo." }
|
||||
{ "schemaVersion": 1, "type": "prompt", "content": "Return only blockers." }
|
||||
```
|
||||
|
||||
Zero combines accepted input event content in order, separated by blank lines.
|
||||
Unknown fields are rejected so protocol clients catch drift early.
|
||||
|
||||
Schema version `2` renamed sandbox permission metadata from `violation` to
|
||||
`block`. Clients should read the optional `block` object on permission and tool
|
||||
events when present.
|
||||
|
||||
## Output Events
|
||||
|
||||
`zero exec --output-format stream-json` emits schema-versioned JSONL events.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
|
||||
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
{ "schemaVersion": 2, "type": "permission_decision", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "allow", "permission": "prompt", "permissionGranted": true, "decisionReason": "approved in TUI" }
|
||||
{ "schemaVersion": 2, "type": "tool_result", "runId": "run_20260603_abc123", "id": "call_1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "schemaVersion": 2, "type": "usage", "runId": "run_20260603_abc123", "promptTokens": 12, "completionTokens": 8, "totalTokens": 20 }
|
||||
{ "schemaVersion": 2, "type": "final", "runId": "run_20260603_abc123", "text": "..." }
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
`reasoning` events carry live model reasoning/status deltas for providers that
|
||||
stream them separately from answer text. They are liveness/progress events only:
|
||||
they are not folded into `text` or the final answer.
|
||||
|
||||
Permission events may include structured sandbox metadata:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_3", "name": "bash", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "shell", "reason": "network access requires approval", "risk": { "level": "critical", "categories": ["network"] }, "block": { "code": "network", "toolName": "bash", "action": "prompt", "risk": { "level": "critical", "categories": ["network"] }, "reason": "network access requires approval", "recoverable": true } }
|
||||
{ "schemaVersion": 1, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 1, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 1, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 1, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
{ "schemaVersion": 1, "type": "permission_decision", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "allow", "permission": "prompt", "permissionGranted": true, "decisionReason": "approved in TUI" }
|
||||
{ "schemaVersion": 1, "type": "tool_result", "runId": "run_20260603_abc123", "id": "call_1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "schemaVersion": 1, "type": "usage", "runId": "run_20260603_abc123", "promptTokens": 12, "completionTokens": 8, "totalTokens": 20 }
|
||||
{ "schemaVersion": 1, "type": "final", "runId": "run_20260603_abc123", "text": "..." }
|
||||
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
Errors are part of the protocol and are followed by `run_end`.
|
||||
@@ -70,6 +55,6 @@ is not pre-approved, Zero may emit `permission_request` followed by a denied
|
||||
allows, denies, or always-allows the request.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "error", "runId": "run_20260603_abc123", "code": "provider_error", "message": "...", "recoverable": false }
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "error", "exitCode": 3 }
|
||||
{ "schemaVersion": 1, "type": "error", "runId": "run_20260603_abc123", "code": "provider_error", "message": "...", "recoverable": false }
|
||||
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "error", "exitCode": 3 }
|
||||
```
|
||||
@@ -1,39 +0,0 @@
|
||||
# TUI Themes
|
||||
|
||||
Zero's TUI ships a set of built-in color themes. Pick one with `--theme <name>`,
|
||||
the `ZERO_THEME` environment variable, or the `/theme <name>` command while
|
||||
running. `auto` (the default) follows the terminal's detected background.
|
||||
|
||||
Run `/theme` with no argument to open a picker: move through the list to
|
||||
live-preview each theme, press Enter to apply the highlighted one, or Esc to
|
||||
cancel and restore the previously active theme. Run `/theme list` to print
|
||||
the active theme and the registered names without opening the picker.
|
||||
|
||||
## Dune (`dune`)
|
||||
|
||||
A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft
|
||||
amber accent.
|
||||
|
||||
## Neon (`neon`)
|
||||
|
||||
A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent.
|
||||
|
||||
## Adding a theme
|
||||
|
||||
Every theme is a `palette` (a table of color hex tokens) plus one entry in
|
||||
`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in
|
||||
`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style`
|
||||
set every renderer reads from the active `zeroTheme`. Adding a theme means
|
||||
adding a new `palette{...}` literal, a `themeRegistry` entry, and test
|
||||
coverage for the new palette (see below).
|
||||
|
||||
Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic
|
||||
WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the
|
||||
selected-row band for every entry. The rendered-surface invariants beyond
|
||||
those (permission surfaces, selected-row secondary text, diff gutters, and
|
||||
the xterm-256 downsampling checks) are asserted per palette, not against the
|
||||
whole registry: `TestExtendedThemeContrastInvariants` and
|
||||
`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new
|
||||
theme must be added to those tests (or given equivalent palette-specific
|
||||
assertions), or CI can stay green while its permission, selected-row, and
|
||||
diff surfaces ship unreadable.
|
||||
+21
-27
@@ -1,44 +1,38 @@
|
||||
# Update Flow
|
||||
# Zero Update Flow
|
||||
|
||||
`zero update --check` checks the latest GitHub release and compares it with the
|
||||
local CLI version.
|
||||
`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version.
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
zero update --check --json
|
||||
zero update --check --repo Gitlawb/zero
|
||||
zero update --check --endpoint https://api.github.com/repos/Gitlawb/zero/releases/latest
|
||||
zero update --check --timeout 3s
|
||||
zero update --check --target windows-x64
|
||||
```
|
||||
|
||||
The command is intentionally check-only:
|
||||
For M2 this command is intentionally check-only:
|
||||
|
||||
- It does not replace the running binary.
|
||||
- It exits with code `0` when the check succeeds, even when an update is
|
||||
available.
|
||||
- It exits with code `0` when the check succeeds, even when an update is available.
|
||||
- It exits with code `1` when the release check cannot be completed.
|
||||
- `--json` prints the same result in a machine-readable format for scripts and
|
||||
CI.
|
||||
- `--json` prints the same result in a machine-readable format for scripts and CI.
|
||||
- `--repo <owner/repo>` checks a different GitHub repository when no endpoint is provided.
|
||||
- `--endpoint <url|owner/repo>` checks a specific release API URL or repository slug.
|
||||
- `--timeout <duration>` overrides the default release check timeout.
|
||||
- `--target <platform-arch>` validates release metadata for another supported target from the current machine.
|
||||
- Release checks time out after 5 seconds by default.
|
||||
- It validates that the latest release includes the expected archive and matching `.sha256` asset for the selected platform target.
|
||||
|
||||
Useful flags:
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, `windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current platform.
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--repo <owner/repo>` | Check another GitHub repository. |
|
||||
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
|
||||
| `--timeout <duration>` | Override the default release check timeout. |
|
||||
| `--target <platform-arch>` | Validate release metadata for another supported target. |
|
||||
The release endpoint resolves in this order:
|
||||
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
||||
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
||||
platform.
|
||||
- `--endpoint` from the CLI, or `Options.Endpoint` when calling `update.Check` from code.
|
||||
- `ZERO_UPDATE_RELEASE_URL` from the environment.
|
||||
- `--repo` from the CLI, or `Options.Repository` when calling `update.Check` from code.
|
||||
- `https://api.github.com/repos/Gitlawb/zero/releases/latest`.
|
||||
|
||||
Endpoint resolution order:
|
||||
`--endpoint`, `Options.Endpoint`, and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug.
|
||||
|
||||
1. `--endpoint`
|
||||
2. `ZERO_UPDATE_RELEASE_URL`
|
||||
3. `--repo`
|
||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
|
||||
before reinstalling.
|
||||
Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Zero WorkSplit PRD v3: Go-Native Product Ownership Plan
|
||||
|
||||
Status: Draft v3.0
|
||||
Date: 2026-06-06
|
||||
Timeline: Active sprint plan toward v0.1, then v1.0 hardening
|
||||
|
||||
Team:
|
||||
|
||||
- Vasanth
|
||||
- Gnanam
|
||||
- Anandan
|
||||
|
||||
## 1. Product Goal
|
||||
|
||||
Zero is a terminal-first AI coding agent built in Go.
|
||||
|
||||
The goal is not just to remove the old TypeScript runtime. The goal is to make Zero feel like a mature daily-driver CLI agent:
|
||||
|
||||
- fast startup
|
||||
- strong command UX
|
||||
- safe autonomy
|
||||
- clear permission handling
|
||||
- useful local tools
|
||||
- reliable provider/runtime behavior
|
||||
- installable, updateable, and testable on real developer machines
|
||||
|
||||
This document replaces the older migration-era work split. The Go runtime is now the main product baseline, so future work should focus on product depth rather than repeating the old M0 migration plan.
|
||||
|
||||
## 2. Current Baseline On Main
|
||||
|
||||
The current main branch is Go-native for the runtime and CLI.
|
||||
|
||||
Implemented baseline:
|
||||
|
||||
- `cmd/zero` is the primary CLI entrypoint.
|
||||
- `cmd/zero-pr-review` exists for PR automation workflows.
|
||||
- Core runtime packages live under `internal/`.
|
||||
- Current CLI surface includes `exec`, `config`, `models`, `providers`, `doctor`, `search`, `sessions`, `plugins`, `hooks`, `mcp`, `sandbox`, `update`, `worktrees`, `verify`, `changes`, `serve`, `help`, and `version`.
|
||||
- Core areas exist for agent flow, provider/model registry, tools, TUI, sessions, search, sandbox, self-verification, stream-json, usage, redaction, hooks, MCP, plugins, update, worktrees, and git/change handling.
|
||||
- TypeScript is no longer part of the repository baseline. The npm package keeps a small Node wrapper that delegates to the Go binary.
|
||||
|
||||
Still needed before Zero feels production-ready:
|
||||
|
||||
- Permission and sandbox UX must become clearer and harder to misuse.
|
||||
- TUI command flows need polish beyond basic rendering.
|
||||
- Platform behavior, especially Windows sandbox/update/install paths, needs stronger product ownership.
|
||||
- Review automation should produce useful, repeatable output for maintainers.
|
||||
- Release trust needs checksums, install smoke tests, and update verification.
|
||||
|
||||
## 3. Ownership Rules
|
||||
|
||||
- Every feature has one DRI.
|
||||
- UI, runtime, and platform work should be split by contract, not by vague file ownership.
|
||||
- Every PR must expose user-runnable behavior or a concrete contract consumed by another owner.
|
||||
- Docs, CI, and website work can support a product PR, but they should not be the whole lane for any owner.
|
||||
- Anandan must own platform product work, not only docs/site/CI.
|
||||
- Shared contracts should be agreed before large UI or integration work lands.
|
||||
- Keep PRs large enough to move the product forward, but still reviewable.
|
||||
|
||||
## 4. Team Roles
|
||||
|
||||
| Owner | Primary Role | Owns | Should Not Own Alone |
|
||||
| --- | --- | --- | --- |
|
||||
| Vasanth | Product UX and TUI owner | Bubble Tea TUI, startup screen, command palette, slash command UX, tool rendering, permission prompts, sandbox violation rendering, verification/test/git output UX, themes, daily CLI feel | Provider internals, release pipeline, platform adapters |
|
||||
| Gnanam | Runtime core owner | Provider/model registry, provider factory, agent/runtime protocols, stream-json, sessions/search backends, config/doctor/usage/redaction, MCP/hooks/plugins backend, sandbox policy/grants, permission event contract | TUI composition, release packaging, platform-specific install trust |
|
||||
| Anandan | Platform product owner | Install/update/release trust, binary/package smoke tests, platform sandbox adapters, Windows behavior, PR/review automation CLI, CI as product verification, performance and release checks | TUI rendering, provider semantics, model registry ownership |
|
||||
|
||||
## 5. Anandan Platform Product Rule
|
||||
|
||||
Anandan should not be assigned only docs/site/CI forever. His lane must produce platform behavior that users and maintainers can run.
|
||||
|
||||
Accepted Anandan work:
|
||||
|
||||
- `zero sandbox policy` improvements and platform-specific sandbox behavior.
|
||||
- Windows sandbox adapter behavior and clear fallback reporting.
|
||||
- `zero update` verification, release metadata validation, and install smoke tests.
|
||||
- Package and binary checksums.
|
||||
- PR automation CLI workflows that reviewers can run locally or in CI.
|
||||
- CI jobs that prove real Zero commands work after installation.
|
||||
|
||||
Not accepted as standalone Anandan work:
|
||||
|
||||
- Docs-only updates.
|
||||
- Website-only updates.
|
||||
- Future VS Code notes with no current CLI behavior.
|
||||
- CI matrix changes that do not prove a user-facing command or release flow.
|
||||
- Release notes or checklists without executable validation.
|
||||
|
||||
Docs and CI are still useful, but they must attach to real platform product behavior.
|
||||
|
||||
## 6. Shared Contracts
|
||||
|
||||
| Contract | DRI | Consumers | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| Provider stream events | Gnanam | Vasanth, Anandan | Lets TUI and automation render model output, tool calls, and errors consistently. |
|
||||
| Permission and sandbox events | Gnanam | Vasanth, Anandan | Lets TUI ask for decisions and platform code enforce them without bypasses. |
|
||||
| Session event schema | Gnanam | Vasanth, Anandan | Lets TUI, resume, search, and automation share one session model. |
|
||||
| Command metadata registry | Vasanth | Gnanam, Anandan | Lets slash commands, help, shell completions, and docs use one command source. |
|
||||
| Platform/release contract | Anandan | Vasanth, Gnanam | Defines install, update, checksum, smoke, and platform capability results. |
|
||||
| Stream-json protocol | Gnanam | Anandan | Lets CI and PR automation consume Zero output without scraping text UI. |
|
||||
|
||||
## 7. Active Workstreams
|
||||
|
||||
### A. Safe Autonomy
|
||||
|
||||
Top priority. Zero must be able to run tools confidently while showing users what is happening and asking when risk increases.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-sandbox-permissions`
|
||||
- permission prompt UX
|
||||
- allow, deny, and always-allow flows
|
||||
- sandbox violation rendering
|
||||
- `/permissions` or equivalent permission state UI
|
||||
- Gnanam: `feat/runtime-permission-events`
|
||||
- structured permission request events
|
||||
- sandbox grant contract
|
||||
- stream-json support
|
||||
- tests for denial, approval, and violation cases
|
||||
- Anandan: `feat/windows-sandbox-platform`
|
||||
- platform sandbox capability checks
|
||||
- stronger Windows behavior or explicit safe fallback
|
||||
- `zero sandbox policy --json` output useful enough for CI and support
|
||||
- platform tests where possible
|
||||
|
||||
Done when:
|
||||
|
||||
- High-autonomy tool attempts produce a clear prompt, decision, and result in TUI and headless modes.
|
||||
- Denied commands do not run.
|
||||
- Sandbox violations are visible and actionable.
|
||||
- Platform fallback is explicit, tested, and not silently unsafe.
|
||||
|
||||
### B. Command UX And Daily CLI
|
||||
|
||||
The CLI should feel like a product, not a collection of backend commands.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-command-polish`
|
||||
- `/model`, `/provider`, `/context`, `/doctor`, `/verify`, `/resume`, and `/compact` become clear interactive flows
|
||||
- command results render consistently
|
||||
- startup screen stays minimal until the user asks for panels
|
||||
- Gnanam: `feat/runtime-command-contracts`
|
||||
- stable command data APIs
|
||||
- provider/model/session/config result structs
|
||||
- error types suitable for TUI rendering
|
||||
- Anandan: `feat/install-update-user-flow`
|
||||
- `zero update` and install flows are user-runnable
|
||||
- package smoke command verifies installed binary behavior
|
||||
- CI proves the packaged command starts and reports version/help
|
||||
|
||||
Done when:
|
||||
|
||||
- A new user can run Zero, inspect config/provider/model state, and understand the next action without reading source.
|
||||
|
||||
### C. Verification And Review Automation
|
||||
|
||||
Zero should help maintainers verify changes and review PRs with repeatable local commands.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-verification-results`
|
||||
- render verify/test/git/review results in TUI
|
||||
- show pass/fail/blocked states clearly
|
||||
- Gnanam: `feat/runtime-self-verify-contract`
|
||||
- structured verification results
|
||||
- stable test/change event model
|
||||
- redacted logs for model and automation use
|
||||
- Anandan: `feat/pr-review-cli-workflow`
|
||||
- make `cmd/zero-pr-review` or `zero review` useful for changed files, validations, and summary output
|
||||
- support local and CI invocation
|
||||
- avoid brittle text scraping where stream-json can be used
|
||||
|
||||
Done when:
|
||||
|
||||
- A maintainer can run one local command before review and get useful changed-file, validation, and risk output.
|
||||
|
||||
### D. Release Trust And Distribution
|
||||
|
||||
This is Anandan's main product lane after platform sandbox work.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Anandan: `feat/release-trust`
|
||||
- release metadata validation
|
||||
- checksums
|
||||
- install smoke tests
|
||||
- update verification
|
||||
- clear error output when release artifacts are invalid
|
||||
- Vasanth:
|
||||
- consume release/update results in CLI UX where needed
|
||||
- Gnanam:
|
||||
- ensure config, errors, and logs are structured and redacted
|
||||
|
||||
Done when:
|
||||
|
||||
- Users can install, run, verify, and update Zero with confidence.
|
||||
- Maintainers can prove release artifacts work before publishing.
|
||||
|
||||
### E. Extensibility UX
|
||||
|
||||
This comes after safe autonomy and daily CLI polish.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth:
|
||||
- `/mcp`, `/hooks`, `/plugins`, and related UI flows
|
||||
- Gnanam:
|
||||
- harden backend contracts and lifecycle behavior
|
||||
- Anandan:
|
||||
- automation and docs only after executable behavior exists
|
||||
|
||||
Done when:
|
||||
|
||||
- Extensibility feels discoverable and safe from the terminal.
|
||||
|
||||
## 8. Immediate Next PRs
|
||||
|
||||
These are the next reviewable slices. Do not use the old migration-era next steps.
|
||||
|
||||
1. Vasanth: `feat/tui-sandbox-permissions`
|
||||
2. Gnanam: `feat/runtime-permission-events`
|
||||
3. Anandan: `feat/windows-sandbox-platform`
|
||||
4. Anandan: `feat/update-release-verification`
|
||||
5. Vasanth: `feat/tui-command-polish`
|
||||
6. Gnanam: `feat/session-rewind-compaction-contract`
|
||||
|
||||
Recommended first parallel set:
|
||||
|
||||
- Vasanth starts the TUI permission prompt and violation display.
|
||||
- Gnanam defines the permission event/grant contract.
|
||||
- Anandan owns Windows/platform sandbox reporting and tests.
|
||||
|
||||
## 9. Command Ownership Matrix
|
||||
|
||||
| Command Area | Vasanth | Gnanam | Anandan |
|
||||
| --- | --- | --- | --- |
|
||||
| `exec` | TUI and interactive UX | Runtime execution contract | Package smoke and platform validation |
|
||||
| `sandbox` | Prompt and violation UX | Policy, grants, and events | Platform adapters and Windows behavior |
|
||||
| `update` | Display and user messaging | Config and redacted errors | Primary owner of update verification |
|
||||
| `verify` | Result rendering | Verification backend contract | CI and release integration |
|
||||
| `changes` | TUI rendering | Change detection backend | PR automation integration |
|
||||
| `mcp`, `hooks`, `plugins`, `serve` | Discoverability and UI | Backend lifecycle and contracts | Automation/docs after behavior exists |
|
||||
| `sessions`, `search` | Resume/search UX | Storage, query, and event model | Stream-json and automation consumers |
|
||||
|
||||
## 10. Definition Of Done
|
||||
|
||||
For every code PR:
|
||||
|
||||
- Add or update tests for the changed behavior.
|
||||
- Run `go test ./...` when Go files changed.
|
||||
- Run the relevant build command when CLI entrypoints changed.
|
||||
- Run package smoke checks when install/update/npm wrapper files changed.
|
||||
- Include manual CLI output or screenshots only when they prove a user-facing flow.
|
||||
|
||||
For docs-only PRs:
|
||||
|
||||
- State clearly that no runtime behavior changed.
|
||||
- Do not use docs-only PRs as a substitute for platform product work.
|
||||
|
||||
For Anandan's PRs specifically:
|
||||
|
||||
- Must include one of:
|
||||
- user-runnable command behavior
|
||||
- install/update/release artifact verification
|
||||
- platform adapter behavior
|
||||
- CI that executes real installed Zero commands
|
||||
- Docs/CI can support the PR, but cannot be the entire value.
|
||||
|
||||
## 11. Review Rotation
|
||||
|
||||
- Vasanth PRs:
|
||||
- Gnanam reviews runtime contracts.
|
||||
- Anandan reviews platform/release impact when touched.
|
||||
- Gnanam PRs:
|
||||
- Vasanth reviews user-facing contract impact.
|
||||
- Anandan reviews platform/protocol impact when touched.
|
||||
- Anandan PRs:
|
||||
- Vasanth reviews user-facing CLI behavior.
|
||||
- Gnanam reviews runtime contract and safety impact.
|
||||
|
||||
## 12. Retired Historical Plan
|
||||
|
||||
The old M0-M6 TypeScript-to-Go migration schedule is now historical.
|
||||
|
||||
Do not assign new work from the old migration next-steps section. The Go migration foundation has already landed on main. Future work should move Zero from "ported" to "mature terminal coding agent."
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,208 @@
|
||||
# Deep-audit status ledger (re-verified against HEAD on branch fix/audit-batch, 2026-06-13)
|
||||
|
||||
Total verified: 175 of 175 extracted findings
|
||||
|
||||
| Status | Count |
|
||||
|---|---|
|
||||
| fixed | 97 |
|
||||
| open | 66 |
|
||||
| partial | 12 |
|
||||
| obsolete | 0 |
|
||||
|
||||
<!--
|
||||
Counts are the post-re-verification view: of the 87 rows the body still marks
|
||||
open/partial (69 open + 18 partial), the re-verification found 9 already fixed on
|
||||
`main` (3 open + 6 partial; listed in 2026-06-13-reverification.md), leaving 66
|
||||
open + 12 partial = 78 genuinely pending, and fixed 88 + 9 = 97. The per-finding
|
||||
rows below retain their original verdicts pending a row-level reclassification pass.
|
||||
-->
|
||||
|
||||
|
||||
> **Authoritative re-verification (2026-06-13, branch `fix/audit-batch` off current `main`).**
|
||||
> All 87 rows the 06-11 snapshot marked `open`/`partial` were re-checked against the ACTUAL
|
||||
> current code by independent agents (the 06-11 ledger was verified on `fix/audit-critical-high`,
|
||||
> which has since diverged). Every `already_fixed` verdict was adversarially re-checked by a
|
||||
> second agent trying to prove the defect was still present — none were refuted.
|
||||
> **Result: 78 genuinely pending** (66 still_open + 12 partial); **9 rows were already fixed on
|
||||
> `main`** and are reclassified to `fixed` here. Full per-finding table:
|
||||
> [`2026-06-13-reverification.md`](2026-06-13-reverification.md).
|
||||
> Pending by severity: **high 2, medium 42, low 34**. Counts above reflect this re-verification
|
||||
> plus this branch's own fixes (scanner.go:81, compaction.go:58, worktrees.go:247).
|
||||
|
||||
| Sev | Status | Location | Evidence |
|
||||
|---|---|---|---|
|
||||
| critical | fixed | internal/search/search.go:346 | search.go:363-428 findMatch maps lowered offsets back via lowerWithOffsets + snapRuneStart clamps context; /tmp probe of audit repro (100x'Ⱥ'+' hello') returns match {201 206}, valid UTF-8, no panic |
|
||||
| critical | fixed | internal/mcp/protocol.go:69 | 82b9c8d: const maxMessageBytes=64MiB (protocol.go:18) bounds both framing paths — Content-Length values above the cap are rejected (:90-92) and the newline-delimited readLine errors past the cap (:78-80); stdio transport switched to newline-delimited JSON. Covered by protocol_test.go; go test green |
|
||||
| high | fixed | internal/cli/usage.go:121 | usage.go:123-131: diff defaults to zerogit.DiffStat{}; resolveWorkspaceRoot/inspectChanges errors are ignored (degrade to net-LOC 0) instead of returning |
|
||||
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-142 keys are run-scoped ("%d:%d:%s" with row.runID) plus effectiveToolRowID ordinal suffix; model.go:1501/1553/1572/1584 set runID on rows |
|
||||
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets exiting, and defers tea.Quit while flushRunIDs is non-empty; deferred quit fires after flush at model.go:645-647 |
|
||||
| high | fixed | internal/tui/model.go:599 | internal/tui/flush.go:11-30 settled-row flush frontier prints history to native scrollback via tea.Println; model.go transcriptView() renders only live tail from m.flushed; covered by internal/tui/flush_test.go |
|
||||
| high | fixed | internal/tui/model.go:512 | model.go:614,633-637 flush goes to flushSessionID recorded at cancel time (model.go:1334) via appendSessionEventsTo (tui/session.go:76), not the active session |
|
||||
| high | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 blocks /rewind while len(m.flushRunIDs) > 0 ('cannot rewind while a cancelled run is still flushing'); covered by tui/session_test.go |
|
||||
| high | fixed | internal/tui/model.go:584 | flush.go:71-120 settles rows into native scrollback via tea.Println; View()/transcriptView renders only rows from m.flushed (model.go:751-784) |
|
||||
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-141 bakes runID into every key; within-run repeats get ordinal suffix via effectiveToolRowID (transcript.go:148, used at model.go:1493-1496 with callSeq) |
|
||||
| high | fixed | internal/tui/rendering.go:525 | live cap 16 kept for tidy tail only; flushed scrollback cards use flushCardBodyMaxLines=400 (rendering.go:154-158) and Ctrl+O detailed view is uncapped (renderRowDetailed bodyCap:0, rendering.go:165; capCardLines treats <=0 as no cap, rendering.go:718-720) |
|
||||
| high | fixed | internal/tui/transcript.go:135 | transcript.go:121-141 transcriptRowKey bakes runID into every key ("%d:%d:%s"); model.go:1501/1553 set row.runID and effectiveToolRowID suffixes within-run repeats |
|
||||
| high | fixed | internal/tui/model.go:512 | model.go:614-637 — flushRunIDs maps cancelled run -> session id captured at cancel time (model.go:1334); flush goes via appendSessionEventsTo(flushSessionID,…) (session.go:76) when active session differs |
|
||||
| high | fixed | internal/tui/run.go:26 | flush.go:71-120 settled-row frontier prints history to native scrollback via tea.Println; model.go:769 View renders only the unflushed live tail |
|
||||
| high | fixed | internal/tui/transcript.go:139 | transcript.go:125 key is now "%d:%d:%s" (kind:runID:id) for all keyed kinds; transcript.go:148 effectiveToolRowID adds per-run ordinal suffix for within-run repeats |
|
||||
| high | fixed | internal/tui/model.go:311 | model.go:355-360 Ctrl+C sets exiting and returns nil (no Quit) while flushRunIDs non-empty; deferred tea.Quit fires at model.go:645-647 once the cancelled run's session events flush |
|
||||
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets m.exiting, and defers tea.Quit while len(m.flushRunIDs) > 0 — same protection as Ctrl+C |
|
||||
| high | fixed | internal/cli/extensions.go:186 | internal/cli/extensions.go:191 passes Servers through redactMCPServerConfigs (:205-224) which replaces every env/header value with [REDACTED]; asserted by extensions_test.go:108 (go test ok) |
|
||||
| high | fixed | internal/mcp/protocol.go:42 | 82b9c8d: read() now accepts newline-delimited JSON frames (isJSONStart→decodeMessage) and only falls back to Content-Length headers for back-compat; write() emits one JSON message per line. Covered by protocol_test.go; go test green |
|
||||
| high | fixed | internal/streamjson/streamjson.go:310 | 3142de6: secretPatterns now anchored — sk keys require a word boundary plus a 16-char body and bearer requires a 16-char token — so 'task-list' and prose like 'bearer of bad news' are no longer redacted while real keys/tokens still are. Covered by streamjson_test.go |
|
||||
| high | fixed | internal/cli/cron.go:112 | 7a47059: cronAdd now consumes an explicit positional expr (cron.go:113-118) BEFORE applying recipe defaults, so a schedule given alongside --recipe is honored; extra positionals error. Covered by cron_test.go |
|
||||
| high | fixed | internal/providerhealth/providerhealth.go:285 | 600057d: default probe now uses newConnectivityClient — CheckRedirect re-validates every hop (capped at 5) and DialContext validates+pins the resolved IP, dialing the IP literal to close the DNS-rebinding TOCTOU. Covered by connectivity_client_test.go |
|
||||
| high | fixed | internal/sessions/store.go:547 | 12a3f62 + 8c75074: ReadEvents now drops a torn final record (last non-empty line) only when the file lacks a trailing newline (tornTailPossible); a fully-flushed corrupt line, or any malformed earlier line, still fails loudly. Covered by store_test.go |
|
||||
| high | partial | internal/cli/cron_run.go:173 | 7a47059: fireJob now re-reads the job before persisting and preserves an external pause/removal mid-run (cron_run.go:170-181), closing the pause-clobber. But internal/cron/store.go still has no flock/CAS, so the concurrent-scheduler race (two schedulers both seeing NextRunAt due) remains — single-scheduler is the supported model |
|
||||
| high | fixed | internal/tools/bash.go:104 | 0dbb5e0: hardenProcessLifetime (bash_proc_unix.go) sets Setpgid, a Cancel that SIGKILLs the whole process group (negative pid), and a WaitDelay backstop so a backgrounded child cannot outlive the timeout or hang Run(); Windows sets WaitDelay only. Covered by bash_tool_test.go |
|
||||
| high | fixed | internal/sandbox/runner.go:262 | 2eeaea7: sandbox-exec profile write rule now also allows the standard device nodes (/dev/null, …) and temp trees (/tmp, /private/var/folders, …) for parity with bubblewrap's --dev/--tmpfs; the workspace stays the only writable project location. Covered by runner_test.go |
|
||||
| high | fixed | internal/tools/apply_patch.go:161 | b6182bf: patch headers are now parsed with hunk line-counting (git-apply semantics), so a hunk-body line like '-- /etc/old' is no longer mistaken for a file header and a valid patch is accepted. Covered by write_tools_test.go |
|
||||
| high | fixed | internal/tools/grep.go:224 | b6182bf: glob now matches against the path relative to the SEARCH directory (ripgrep semantics) via filepath.Rel(target,path); a single explicit file matches by base name. probe path=subdir glob=*.go now matches subdir/a.go. Covered by file_tools_test.go |
|
||||
| high | fixed | internal/providers/openai/types.go:3 | 46d495c (+7044d36/6664f71): chatCompletionRequest gained reasoning_effort; zeroruntime.CompletionRequest.ReasoningEffort is threaded through the agent loop and mapped per provider — OpenAI reasoning_effort, Anthropic extended thinking, Gemini thinking budget; CLI gates the value against the resolved model. Covered by openai/anthropic/gemini provider_test.go |
|
||||
| high | fixed | internal/specialist/exec.go:166 | 39a9c55: BuildArgs always prefixes the session title with the specialist name and falls back to the bare name when description is empty, so AgentName is non-empty and a description-less run resumes. Covered by specialist/exec_test.go |
|
||||
| high | fixed | internal/agent/loop.go:233 | 95a6a8e: Run now sets result.FinishReason from collected.FinishReason (and from the after-max-turns final answer); Result.Truncated()/TruncationNotice() drive an exec-writer warning and a TUI system row when a response was cut off. Covered by loop_test.go |
|
||||
| high | partial | internal/hooks/hooks.go:372 | 17a91d9: a Dispatcher that runs configured hooks and audits each via NewAuditStore now exists and is tested (dispatch.go + dispatch_test.go), closing the 'zero non-test callers' gap for Select/audit. But it has ZERO production callers — not wired into the beforeTool/afterTool tool lifecycle — and runHooks still supports only `list` (extensions.go:94); no dispatch/edit CLI surface |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:415 and :404 now call cutRuneBoundary (:420-431) which backs up to the last UTF-8 rune start before slicing; added in audit-fix commit 6fb0231 |
|
||||
| low | fixed | internal/mcp/server.go:129 | server.go:129-132 adds `case "ping": return server.writeResult(message.ID, map[string]any{})` (introduced in commit 6fb0231, present at HEAD) |
|
||||
| low | fixed | internal/search/search.go:309 | search.go:318-322: resolveSessions now returns fmt.Errorf("zero session not found: %s", sessionID) when store.Get returns nil instead of an empty success |
|
||||
| low | fixed | internal/doctor/doctor.go:134 | doctor.go:129-141: providerConfigCheck now reports apiKey presence ("set"/"not set") based on profile.APIKey != "", with comment explaining the change; doctor tests pass |
|
||||
| low | fixed | internal/tui/transcript.go:303 | truncateTUIOutput cuts via rune-boundary cutRunes (transcript.go:302,307-318); titles use cutRunes (session.go:112, spec_mode.go:305) and cutRuneBoundary (exec_sessions.go:90, exec_writer.go:420) |
|
||||
| low | fixed | go.mod:10 | go.mod:7-12 direct requires are now only bubbles/bubbletea/lipgloss/x-sys; glamour is gone entirely and x/ansi (line 18) and termenv (line 31) are demoted to // indirect |
|
||||
| low | fixed | internal/tui/model.go:525 | agentResponseMsg completion path now invokes m.runCancel() before nil-ing it (model.go:651-658, with explanatory comment about the per-prompt leak) |
|
||||
| low | fixed | internal/imageinput/imageinput.go:58 | imageinput.go:56-66 os.Open failure now wraps real cause ("cannot open image %s: %w") and read failure "cannot read image %s: %w"; only a stat miss reports "not found" (:38) |
|
||||
| low | fixed | internal/tui/session.go:93 | All three paths rune-safe: internal/tui/session.go:112 cutRunes(..., tuiSessionTitleLimit), internal/tui/spec_mode.go:305 cutRunes, internal/cli/exec_sessions.go:90 cutRuneBoundary(title, 80) |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | internal/cli/exec_writer.go:404 (truncateForStatus) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary defined at exec_writer.go:420-431 (backs up to utf8.RuneStart) |
|
||||
| low | fixed | internal/sessions/replay.go:237 | replay.go:245-256 cutPromptRuneBoundary (utf8.RuneStart backoff) now used at replay.go:174-177 and 238; titles rune-safe via cutRuneBoundary (cli/exec_sessions.go:90) and cutRunes (tui/session.go:112) |
|
||||
| low | fixed | internal/tui/rendering.go:68 | footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText (present at b144cf9~1 rendering.go) are absent at HEAD; dead actionAppendToolCall/ToolResult enum values also removed from transcript.go |
|
||||
| low | fixed | internal/tui/transcript.go:303 | cutRunes lands cuts on rune boundaries (transcript.go:305-318), used by truncateTUIOutput for tool-result text (transcript.go:302) and session titles (session.go:112, spec_mode.go:305) |
|
||||
| low | fixed | internal/tui/model.go:853 | commandExit cancels the run, sets exiting, and defers quit while flushRunIDs is non-empty (model.go:1034-1043); deferred tea.Quit fires when the last flush drains (model.go:645-647), and new prompts are gated while exiting (model.go:1012-1014) |
|
||||
| low | fixed | internal/tui/session.go:93 | cutRunes (transcript.go:307-318) cuts on rune boundaries; used by tuiSessionTitle (session.go:112) and truncateTUIOutput (transcript.go:302) for tool-result text |
|
||||
| low | fixed | internal/tui/rendering.go:482 | rendering.go:526 — permission card action row now renders "[esc] cancel run", matching the KeyEsc fall-through to m.cancelRun() (model.go:391-393) |
|
||||
| low | fixed | internal/cli/update.go:37 | internal/update/update.go:136-142: normalizeVersionTag failure now falls back to currentVersion="0.0.0" so the check proceeds to the network call (landed in #158) |
|
||||
| low | fixed | internal/notify/notify.go:134 | notify.go:137 now drops `(r >= 0x80 && r <= 0x9f)` with comment that C1 includes single-byte CSI/OSC/ST introducers (landed in #158) |
|
||||
| low | fixed | internal/tui/model.go:512 | model.go:616-624 the flushRunIDs (cancelled-run) branch of agentResponseMsg now records each usage event via recordUsageEvent → m.usageTracker.Record (session_controls.go:294) |
|
||||
| low | fixed | internal/tui/rendering.go:490 | rendering.go:560-564 the ask card now echoes the in-progress answer: "❯ " + fill(zeroTheme.ink).Render(input) + accent cursor, appended to the card lines |
|
||||
| low | fixed | internal/tui/view.go:227 | view.go:230-235 now matches `path == home` or strings.HasPrefix(path, home+os.PathSeparator) — a path-boundary check with a comment citing the /Users/alice2 sibling case |
|
||||
| low | fixed | internal/sandbox/risk.go:53 | defect not present: risk.go:11-50 patterns are package-level regexp.MustCompile compiled once at init (identical at audit commit b144cf9); per call only MatchString runs |
|
||||
| low | fixed | internal/modelregistry/catalog.go:36 | catalog.go:36 claude-haiku-3.5 extra capabilities now []ModelCapability{ModelCapabilityPromptCache} — ModelCapabilityVision removed (base withBaseCapabilities adds no vision), so SupportsVision is false |
|
||||
| low | fixed | internal/providers/providerio/retry.go:107 | retry.go:108-110 ShouldRetryStatus now returns true for `code == 529` alongside 429/503 (doc comment lines 99-107 updated to cover Anthropic overloaded), consistent with ClassifiedError's 529 rate-limit classification at providerio.go:224 |
|
||||
| low | fixed | internal/tui/spec_mode.go:301 | spec_mode.go:305 and session.go:112 now use cutRunes (transcript.go:307-318 backs off to utf8.RuneStart boundary); no byte-index title slicing remains |
|
||||
| low | fixed | internal/agent/system_prompt.go:89 | system_prompt.go:102-107 now walks cut back with `for cut > 0 && !utf8.RuneStart(content[cut])` before slicing, so truncation lands on a rune boundary (matches audit doc's rune-safe truncation fix claim) |
|
||||
| low | fixed | internal/config/writer.go:53 | internal/config/writer.go:55-74 — writeConfigFile now does os.CreateTemp + Chmod(0600) + write + os.Rename (atomic temp+rename, with explanatory comment at :53); go test ./internal/config -run Upsert passes |
|
||||
| low | fixed | internal/cli/exec_sessions.go:88 | exec_sessions.go:87-91: title = cutRuneBoundary(title, 80); helper (exec_writer.go:420-431) verified rune-safe by /tmp probe |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:404 (truncateForStatus/stderr) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary, which backs up to a utf8.RuneStart boundary |
|
||||
| low | open | internal/streamjson/streamjson.go:30 | repo-wide grep finds EventRestore only at its declaration (streamjson.go:30) — no emitter and not even test references; streamjson.go untouched by 6fb0231 or later merges |
|
||||
| low | open | internal/cli/cron_run.go:150 | cron_run.go:145-151: rec.Error still set only from errBuf on code!=0; outBuf (where stream-json error events land) is captured but never read |
|
||||
| low | open | internal/cli/exec_sessions.go:55 | exec_sessions.go:55 still constructs sessions.NewStore(sessions.StoreOptions{}) directly, and exec.go:375-389 PrepareExecOptions still omits the Store field; deps.newSessionStore is used by spec/usage/observability but not the exec run path |
|
||||
| low | fixed | internal/secrets/scanner.go:35 | [reverified 2026-06-13] already fixed on main: scanner.go:37 pattern is now `sk-[A-Za-z0-9_-]{20,}` (allows -/_ so sk-proj-/sk-svcacct- match), with comment at :35-36. Covered by scanner_test.go TestScanDetectsModernPrefixedOpenAIKeys |
|
||||
| low | open | internal/redaction/redaction.go:222 | redaction.go:221-235 pointer/map ptr added to context.seen and never deleted after subtree recursion, so a second (sibling) visit returns CircularReference |
|
||||
| low | open | internal/redaction/redaction.go:174 | redaction.go:174 `interface{ StackTrace() fmt.Stringer }` unchanged; grep finds no implementer in the module and go.mod has no pkg/errors or similar dep |
|
||||
| low | open | internal/zerogit/zerogit.go:417 | zerogit.go:418-420 fallback EnvRunner adapter still discards env (`_ []string`), so stagedSnapshotDiff's GIT_INDEX_FILE isolation is lost with a caller-supplied Runner |
|
||||
| low | open | internal/tui/commands_test.go:46 | internal/tui/commands_test.go:46 still defines commandTestStringSliceContains; repo-wide grep finds no callers, only the definition |
|
||||
| low | open | internal/tui/session_test.go:215 | internal/tui/session_test.go:215 'runtimeMessages := []tea.Msg{}' appended in RuntimeMessageSink at line 226 but never read in the test body (only runtimeMessageCh is consumed) |
|
||||
| low | open | internal/sessions/store.go:555 | store.go:554-556 timestamp() still time.RFC3339 (second precision); List ties broken lexically by SessionID (store.go:331-336), not actual recency, so Latest() can still pick the wrong session on same-second ties |
|
||||
| low | open | internal/tui/session_controls.go:165 | session_controls.go:165 still only increments compactRequests; sole reads are its own status display (lines 266, 280) with "Backend: state: pending integration" — no compaction is ever triggered by it |
|
||||
| low | open | internal/tui/model.go:837 | model.go:1012-1014 silently drops prompt submits while m.exiting (return m, nil — no feedback row); no exiting/flushRunIDs indicator anywhere in statusLine (view.go:113-139), composerLine, or any render path |
|
||||
| low | open | internal/tui/command_center.go:17 | tui/command_center.go:17-21 passes only Now/Runtime/Provider (no UserConfig/ProjectConfig) vs cli/observability.go:64-71; empty paths warn at doctor.go:120 and :273 |
|
||||
| low | open | internal/background/process_posix.go:38 | process_posix.go:39-47 still polls signal-0 on os.FindProcess(pid) while the Wait goroutine (specialist/exec.go:653) reaps the child; line 50 Kill() can hit a recycled pid |
|
||||
| low | open | internal/tools/registry.go:108 | repo-wide grep: OnSandboxDecision only at registry.go:19,108,116 — no caller or test ever sets it |
|
||||
| low | open | internal/sandbox/grants.go:146 | grants.go unchanged since #77: Lookup trims key (grants.go:146) but readState validates raw file keys via trimming ValidateToolName (grants.go:225-227,273) and keeps the raw map key, so a whitespace-padded key validates yet never matches |
|
||||
| low | open | internal/tools/read_file.go:89 | probe: max_lines=5 -> Truncated=true with no marker beyond '(lines 1-5 of 20)' header (read_file.go:102-111); rune-width sub-claim not reproducible (ASCII strconv.Itoa padding) |
|
||||
| low | open | internal/providers/factory.go:118 | factory.go:115 still `if !explicitProvider \|\| isImplicitOpenAI(...)`; isImplicitOpenAI (factory.go:159-164) requires TrimSpace(ProviderKind)=="" && TrimSpace(Provider)=="", which contradicts explicitProvider==true (factory.go:147-157), so it can never return true at its sole call site |
|
||||
| low | open | internal/providercatalog/catalog.go:46 | catalog.go:46 Public still declared; no constructor or descriptor ever sets it (always false); the only read is the vacuous guard in catalog_test.go:105, which already existed before the audit (added in 4accec5/#141) — no production read or write |
|
||||
| low | open | internal/verify/verify.go:215 | verify.go:215 RunLoop still defined, sole caller verify_test.go:207; production loop is selfverify.Run (selfverify.go:53-74) used by cli/workflows.go |
|
||||
| low | open | internal/specialist/output_tool.go:244 | output_tool.go:244 formatTaskOutput has zero callers (prod or tests); only formatTaskOutputSummary is used (output_tool.go:154) |
|
||||
| low | open | internal/specialist/exec.go:346 | exec.go:347-350: on SetPID error calls cleanupBackgroundPromptFile (runtime.go:73 deletes file/dir) and returns err with no recordSpecialistStop/UpdateStatus while child keeps running |
|
||||
| low | open | internal/agent/loop.go:188 | helpers.go:85 sets collected.Error = ctx.Err().Error(); loop.go:189-191 returns errors.New(collected.Error) before the ctx.Err() identity return at loop.go:193-195, so errors.Is(err, context.Canceled) still fails for that path |
|
||||
| low | open | internal/agent/types.go:183 | types.go:183 OnContext field and loop.go:102-103 emit site exist, but repo-wide grep finds no "OnContext:" assignment and no MeasureContext/ContextBreakdown use in cli/tui/cmd/contextreport (only tests) |
|
||||
| low | open | internal/agent/loop.go:1072 | loop.go:1120-1122 and loop.go:1129-1131 both still assign schema["items"] = propertyToRuntimeMap(*property.Items) |
|
||||
| low | open | internal/agent/loop.go:473 | loop.go:530-531 set requestEvent.GrantMatched/Grant but requestEvent is never read after the switch (post-run event rebuilt at loop.go:558); fallback at loop.go:510-511 unreachable since buildPermissionEvent always returns ok=true under the shouldRequestPermission gate (Prompt-permission tools, loop.go:751-758 vs 877-894) |
|
||||
| low | open | internal/agent/loop.go:183 | loop.go:184-186 retry CollectStreamWithOptions still passes only OnUsage; comment at loop.go:178-183 documents the OnText omission as deliberate anti-duplication, but streaming surfaces still receive only the aborted partial text |
|
||||
| low | open | internal/zeroruntime/helpers.go:104 | helpers.go:104 still does `collected.Text += event.Content` per StreamEventText (no strings.Builder); unchanged since audit commit b144cf9 |
|
||||
| low | open | internal/zerocommands/sandbox_snapshots.go:104 | grep for SandboxPlan/Decision/Policy/Backend/Risk/Violation/Hook/Plugin/MCPServer snapshots finds no consumers outside internal/zerocommands + tests; `zero sandbox policy --json` serializes raw sandbox.Policy/Backend/BackendPlan instead (internal/cli/sandbox.go:90-105) |
|
||||
| low | open | internal/config/contracts.go:12 | internal/config/contracts.go still defines ContractGap/DefaultContractGaps/FindContractGapsByMilestone; only reference repo-wide is internal/config/contracts_test.go |
|
||||
| low | open | internal/skills/skills.go:91 | skills.Duplicates (skills.go:91) and skills.Get (skills.go:217) have zero callers outside internal/skills; cli/skills.go uses List and tools/skill.go uses Load, so collisions are still dropped without any user-facing warning (dedup is now documented/deterministic but unsurfaced) |
|
||||
| low | open | internal/plugins/plugins.go:893 | internal/plugins/plugins.go:893 allows only beforeTool/afterTool/sessionStart/sessionEnd, while internal/hooks/hooks.go:812 also accepts specialistStart/specialistStop — sets still disagree |
|
||||
| low | open | internal/config/resolver.go:136 | internal/config/resolver.go:143, :173 and :502 all gate on `MaxTurns > 0` with no error path, so a configured maxTurns of 0 or negative is still silently ignored (falls back to default 30) |
|
||||
| low | open | internal/cli/exec.go:353 | exec.go:375 PrepareExec called without Store (defaults to NewStore in sessions/exec_session.go:58), exec_sessions.go:55 direct NewStore, exec.go:396 time.Now() instead of deps.now |
|
||||
| low | open | internal/zerocommands/contracts.go:166 | contracts.go:166 APIKeySet checks only profile.APIKey, ignoring AuthHeaderValue (valid sole credential per config/types.go:49-51); rendered "not set" at cli/command_center.go:328 |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary(p,47) at cron.go:275, but cronTruncate at cron_run.go:179-184 still returns s[:max]+"…" (byte slice, can split a rune in persisted run errors) |
|
||||
| low | partial | internal/tui/options.go:28 | app.go:413 production caller still sets none of the three, but newModel now self-defaults a usage.Tracker when nil (model.go:239-242) so usage works; effort/style are settable at runtime via /effort and /style |
|
||||
| low | partial | internal/tui/rendering.go:62 | Footer system, actionAppendToolCall/Result, and dead theme tokens (selRow/statusOk/statusErr/line2) are removed, but listCommandNames/formatCommandHelpLines remain test-only (commands.go:297,306), resumeText's args!="" branch is still unreachable (command_center.go:44-54; sole caller session.go:122 passes ""), OnContext is still wired by no production caller (agent/loop.go:102), and stale footer comments persist (commands.go:254, autocomplete.go:64) |
|
||||
| low | partial | internal/zerogit/zerogit.go:206 | truncateSubject now rune-based (zerogit.go:464-472, no invalid UTF-8), but ValidateMessage still byte-based `len(firstLine) > 72` (zerogit.go:207) so valid non-ASCII subjects are rejected |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed (cron.go:275 uses cutRuneBoundary) but error helper cronTruncate still byte-slices: internal/cli/cron_run.go:183 'return s[:max] + "…"', used for run errors at cron_run.go:150 |
|
||||
| low | partial | internal/tui/command_center.go:44 | Footer helpers now render (rendering.go:690-700, bash/grep footers 892-905/969-971), but resumeText's args!="" branch (command_center.go:43-54) is still unreachable (sole caller session.go:122 passes "") and stale footer comments persist (autocomplete.go:64, commands.go:254) |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary (cron.go:275), but cronTruncate still byte-slices `s[:max]` at cron_run.go:182, splitting runes in run-record error text |
|
||||
| low | partial | internal/tui/rendering.go:62 | footer builders (footerText/commandFooterText/etc.) and the four theme tokens (selRow/statusOk/statusErr/line2/panel2) are fully removed (zero grep hits), but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production code referenced only by tests, and stale "footer advertises" comments persist (commands.go:254, autocomplete.go:64) |
|
||||
| medium | fixed | internal/update/update.go:135 | update.go:136-142: normalizeVersionTag error now falls back to currentVersion="0.0.0" (commented for dev builds) instead of returning the error |
|
||||
| medium | fixed | internal/tui/session.go:190 | session.go:219-227 rehydrates role "ask_user"/"ask_user_answers" rows via askUserRequestFromPayload (session.go:349) and askUserAnswersText (session.go:380) |
|
||||
| medium | fixed | internal/tui/model.go:1067 | cancelRun appends rowSystem "Run cancelled." plus any partial streamed answer (model.go:1336-1343); asserted by flush_test.go:105 |
|
||||
| medium | fixed | internal/search/search.go:345 | search.go:363-407 findMatch maps lowered offsets back via lowerWithOffsets offset table; snapRuneStart (:417) guards context slicing; tests pass |
|
||||
| medium | fixed | internal/search/search.go:108 | search.go:111-118 LoadIndex error now `result.SkippedSessions++; continue` instead of returning; SkippedSessions reported in Result (:61) |
|
||||
| medium | fixed | .github/workflows/ci.yml:31 | .github/workflows/ci.yml:31-44 adds 'Check formatting' (gofmt -l, fails on output) and 'Vet' (go vet ./...) steps; gofmt -l . is clean at HEAD |
|
||||
| medium | fixed | internal/tui/transcript.go:303 | internal/tui/transcript.go:302 truncateTUIOutput now calls cutRunes (rune-boundary cut, transcript.go:307-318); non-ASCII test TestCutRunesNeverSplitsUTF8 at flush_test.go:158 |
|
||||
| medium | fixed | internal/cli/usage.go:46 | usage.go:47-51 collectUsageData now does set.skipped++ and continue on ReadEvents failure, so a corrupt session is skipped instead of aborting |
|
||||
| medium | fixed | internal/tui/model.go:1081 | cancelRun appends partial answer then rowSystem "Run cancelled." marker (model.go:1336-1343), shared by Esc (model.go:392) and Ctrl+C (model.go:355) paths |
|
||||
| medium | fixed | internal/tui/model.go:227 | WindowSizeMsg sets m.input.Width = maxInt(20, chatWidth(msg.Width)-14) so long input scrolls with cursor visible (model.go:556-558) |
|
||||
| medium | fixed | internal/tui/view.go:394 | view.go:412-431 now requires a hunk header match or BOTH '--- ' and '+++ ' file headers; a lone '---' line no longer triggers the diff renderer |
|
||||
| medium | fixed | internal/tui/rendering.go:261 | rendering.go:286-330 preserves explicit newlines and each line's leading indent (tabs→4 spaces), continuations keep the indent; only intra-line space runs collapse |
|
||||
| medium | fixed | internal/tui/model.go:456 | WindowSizeMsg resizes composer and prints title bar at real width (model.go:553-566); flush refuses to freeze history before a real width arrives (flush.go:72-77); width/tier recomputed every render (view.go:17-19) and history is native scrollback, so only the ephemeral pre-size frame uses the 96-col default |
|
||||
| medium | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 — handleRewindCommand returns "cannot rewind while a cancelled run is still flushing" when len(m.flushRunIDs) > 0 |
|
||||
| medium | fixed | internal/tui/session.go:190 | model.go:1461-1476 persists ask_user question payload AND ask_user_answers; session.go:218-227 rehydrates both via askUserRequestFromPayload/askUserAnswersText (session.go:349, 380) |
|
||||
| medium | fixed | internal/tui/model.go:853 | model.go:1034-1043 — commandExit calls cancelRun(), sets exiting, defers tea.Quit until flushRunIDs drains (model.go:645); spec_mode.go:25 gates /spec on m.pending \|\| m.exiting |
|
||||
| medium | fixed | internal/tui/spec_mode.go:22 | spec_mode.go:25 handleSpecCommand gates on `if m.pending \|\| m.exiting` and refuses to start, with comment citing the post-Ctrl+C flush window |
|
||||
| medium | fixed | internal/tui/model.go:1081 | model.go:1340-1343 cancelRun appends the partial streamed answer plus rowSystem "Run cancelled." to m.transcript (live), in addition to the session-log marker |
|
||||
| medium | fixed | internal/tui/model.go:610 | model.go:769 transcriptView loops only `for index := m.flushed` (live tail; settled rows flushed once to scrollback per flush.go), and rendering.go:184-192 memoizes stable row renders in the LRU staticRenderCache (render_cache.go:19) |
|
||||
| medium | fixed | internal/tui/session.go:93 | session.go:112 tuiSessionTitle uses cutRunes; transcript.go:302/307-318 truncateTUIOutput cuts on the last rune boundary at or before the byte limit |
|
||||
| medium | fixed | internal/tui/model.go:227 | model.go:558 WindowSizeMsg handler sets `m.input.Width = maxInt(20, chatWidth(msg.Width)-14)` so long input scrolls horizontally with the cursor visible |
|
||||
| medium | fixed | internal/providers/providerio/providerio.go:184 | providerio.go:142-154 onComment now forwards a keepAlive marker to the consumer, which calls resetIdle() at providerio.go:203 before the keepAlive continue (line 204-205); providerio tests pass |
|
||||
| medium | fixed | internal/modelregistry/catalog.go:27 | catalog.go:27 gpt-4.1 entry now reads ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, matching mini/nano |
|
||||
| medium | fixed | internal/tui/run.go:41 | run.go:42-46 now prints `zero: tui error: <err>` to os.Stderr before returning 1 |
|
||||
| medium | open | internal/mcp/client.go:302 | client.go:295-317 readLoop dispatches any id-bearing message to pending[id] without checking message.Method; grep confirms no 'ping' handling anywhere in client.go |
|
||||
| medium | open | internal/mcp/client.go:246 | client.go:246 `client.writer.write(...)` is a blocking pipe write executed while client.mu is held (:228-:255) with no ctx awareness; file unchanged since the audit (only the response wait, which predates the audit, is ctx-aware) |
|
||||
| medium | open | internal/mcp/client.go:98 | client.go:98-99 `var stderr bytes.Buffer; cmd.Stderr = &stderr` — plain unbounded buffer attached for the process lifetime, only read on initialize failure |
|
||||
| medium | open | internal/mcp/network_client.go:621 | network_client.go:603-632 decodeSSERPCMessage stops at the first non-empty 'message' event (sets found, returns false); caller at :210 errors on id mismatch instead of scanning for the matching response — unchanged since audit |
|
||||
| medium | open | internal/mcp/network_client.go:637 | network_client.go:637 `scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`; ErrTooLong propagates via readStream->failPending (:578-:592) setting a permanent streamErr with no reconnect, so request() (:234) fails forever |
|
||||
| medium | open | internal/cli/cron_run.go:115 | cron_run.go:102-126 reconcileOverdue still reschedules any active job with NextRunAt before the current minute without firing; no RunNow/RunOnce sentinel exists in internal/cron, and the file is unchanged since pre-audit commit 0f8ea50 |
|
||||
| medium | open | internal/sessions/lineage.go:140 | lineage.go:140 tree() still calls ListChildren per node, which calls store.List() (lineage.go:74) re-reading every metadata.json from disk (store.go:312-327, no cache); file unchanged since pre-audit commits |
|
||||
| medium | open | internal/cli/exec_sessions.go:114 | exec_sessions.go:117-123 still early-returns once recorder.err is set and stores AppendEvent errors there; recorder.err is never read anywhere (exec.go/exec_spec.go check only writer.err) |
|
||||
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle still only displayed (command_views.go:155); /theme //input-style render "does not have a backend setting yet" (rendering.go:47); /compact shows "state: pending integration" (session_controls.go:272); all still in commandDefinitions help/autocomplete (commands.go:189-230) |
|
||||
| medium | fixed | internal/tui/session_controls.go:42 | 46d495c/7044d36/6664f71: zeroruntime.CompletionRequest now carries ReasoningEffort, threaded from agent.Options through every request the loop builds and mapped per provider (OpenAI reasoning_effort, Anthropic extended thinking with thinking-block replay, Gemini thinkingBudget). Covered by provider tests |
|
||||
| medium | fixed | internal/secrets/scanner.go:81 | [reverified+fixed 2026-06-13, fix/audit-batch] Redact now replaces longest matches first (copies findings, sorts by len(Match) desc) so a containing PRIVATE KEY block is redacted before any nested shorter match corrupts its exact string. Covered by scanner_test.go TestRedactNestedSecretStillRemovesWholeBlock (fails pre-fix: leaks BEGIN/END header) |
|
||||
| medium | fixed | internal/redaction/redaction.go:362 | [reverified 2026-06-13] already fixed on main: urlWithCredsPattern is a package-level var (redaction.go:367) and redactURLPasswords (:369) uses it via ReplaceAllStringFunc — no per-call regexp.MustCompile remains |
|
||||
| medium | open | internal/zerogit/zerogit.go:212 | zerogit.go:223 `path := strings.TrimSpace(line[3:])` — no C-quote unescaping and no `old -> new` rename split; no -z porcelain used |
|
||||
| medium | fixed | internal/worktrees/worktrees.go:247 | [reverified+fixed 2026-06-13, fix/audit-batch] defaultRunGit now captures stdout/stderr into separate bytes.Buffers and returns CommandResult{Stdout, Stderr}; git warnings no longer pollute parsed rev-parse output and error messages get real stderr. Covered by worktrees_test.go TestDefaultRunGitSeparatesStdoutAndStderr |
|
||||
| medium | open | internal/sessions/store.go:632 | store.go:632-635 writeMetadata still os.WriteFile temp + os.Rename with no file/dir Sync; zero Sync() calls anywhere in internal/sessions; appendEventLocked (store.go:512-517) also unsynced |
|
||||
| medium | open | internal/tui/session_controls.go:235 | session_controls.go:244 returns lastCheckpoint-1 while the tool_call is persisted immediately before its checkpoint (model.go:1505 then 1527), so truncate (rewind.go:205 keeps seq<=target) retains the dangling tool_call |
|
||||
| medium | open | internal/sessions/store.go:387 | store.go:387-391 Fork re-appends every parent event (no provider_usage skip) via AppendEvent which stamps a fresh CreatedAt (store.go:495-501); BuildReport has no event-id dedupe |
|
||||
| medium | open | internal/usage/report.go:17 | report.go:17-21 usageEventPayload has only token fields and report.go:102 prices from session Metadata.ModelID, while exec.go:510-512 still persists payload["model"] under --allow-escalation — never read |
|
||||
| medium | open | internal/cli/exec_sessions.go:115 | exec_sessions.go:117-121 append still early-returns once recorder.err is set, and grep shows sessionRecorder.err is never read in exec.go (no surfacing at run end) |
|
||||
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle (session_controls.go:117) is only echoed in /style and /config text (command_views.go:155); grep shows it never reaches agent.Options/system prompt (runAgentWithOptions model.go:1366-1397 sets no style) |
|
||||
| medium | open | internal/cron/schedule.go:171 | Probe at HEAD: Next("30 1 * * *", 2026-11-01 01:30 EDT) = 01:30 EST same day (repeated hour); schedule.go:171-201 guards only spring-forward gaps |
|
||||
| medium | open | internal/cli/cron.go:112 | cron.go:112-114 sets expr=r.Expr while expr is still ""; cron.go:123 only uses positional[0] if expr=="" — user's expr silently ignored, no warning |
|
||||
| medium | open | internal/background/process_posix.go:50 | process_posix.go:29/50 signal only pid (no kill(-pid)); launchBackgroundProcess (internal/specialist/exec.go:642) sets no Setpgid; Windows still tree-kills via taskkill /T (process_windows.go:12) |
|
||||
| medium | open | internal/background/manager.go:462 | manager.go:462-471 normalizeLoadedTask rewrites Status==running to error/PID=0/ExitCode=-1; loadTasks persists it to disk at manager.go:419 |
|
||||
| medium | open | internal/tui/session_controls.go:117 | session_controls.go:117 still only stores m.responseStyle; sole readers are display strings (command_views.go:155, session_controls.go:120/132) — runAgentWithOptions (model.go:1366+) never threads it into agent.Options or the system prompt, so /style still changes nothing about responses |
|
||||
| medium | open | internal/tools/registry.go:224 | registry.go:190-236 Core* sets still omit escalate_model; only exec --allow-escalation registers it (cli/exec.go:157, predates audit), TUI path (cli/app.go:449) never does |
|
||||
| medium | open | internal/sandbox/safe_command.go:138 | file unchanged since #109; splitShellSegments (safe_command.go:431) is quote-unaware — probe: `git commit -m "use top \| less"` flagged as 'less', `echo "a; vim b"` flagged as 'vim' |
|
||||
| medium | open | internal/providers/gemini/types.go:88 | gemini/types.go:88-93 usageMetadata still has only Prompt/Candidates/Thoughts/Total token counts (no cachedContentTokenCount); grep shows CachedInputTokens is set only in openai/provider.go:253 and anthropic/provider.go:274, never in gemini |
|
||||
| medium | open | internal/providers/openai/tool_state.go:95 | tool_state.go:95-100 closeOpen still emits StreamEventToolCallDropped for calls with empty id (and applyDelta:58 never streams them); zeroruntime/helpers.go:141-207 empty-ID/synthetic-key collector support remains unreachable from the adapter |
|
||||
| medium | open | internal/specialist/exec.go:150 | exec.go:150 and :195 still hardcode "--auto","high"; cli/exec_tools.go:84-85 maps high -> agent.PermissionModeUnsafe |
|
||||
| medium | open | internal/specialist/accounting.go:72 | accounting.go:31/80 check (ReadEvents) then :47/91 append under separate store locks; concurrent callers remain at exec.go:336 (onExit) and output_tool.go:150 (TaskOutput); no serializing mutex in package |
|
||||
| medium | open | internal/specialist/streamer.go:43 | streamer.go:43 still caps scanner at 1024*1024; ErrTooLong fails ParseStream -> runChildProcess err (exec.go:624-627) aborts whole run; tool_call Args still emitted untruncated (cli/exec_writer.go:99) |
|
||||
| medium | open | internal/agent/loop.go:468 | loop.go:525-528 AlwaysAllow still returns deniedPermissionResult on persistPermissionGrant error; loop.go:778-779 still errors unconditionally when options.Sandbox == nil |
|
||||
| medium | partial | internal/agent/compaction.go:58 | [reverified+fixed 2026-06-13, fix/audit-batch] estimateTokens now adds len(message.Images)*imageTokenEstimate (flat per-image cost, ~1k; raw byte size is a poor proxy for vision tiles) — Images are no longer uncounted. Covered by compaction_test.go TestEstimateTokensCountsImages. REMAINING: maybeCompact still has no tool-definition term in its estimate |
|
||||
| medium | open | internal/agent/compaction.go:376 | compaction.go:376 summarizeMessagesOnce uses zeroruntime.CollectStream with no OnUsage; comment at compaction.go:314-315 documents OnText/OnUsage are intentionally not forwarded |
|
||||
| medium | open | internal/config/resolver.go:95 | ResolvedConfig.MCP set at resolver.go:102 has no readers (grep: zero non-test uses); MCP registration uses ResolveMCP (resolver.go:109-124) which ignores options.ProviderCommand, so ZERO_PROVIDER_COMMAND MCP servers are still dropped |
|
||||
| medium | open | internal/hooks/hooks.go:503 | internal/hooks/hooks.go:503 — append still calls store.ReadEvents() (full file read + JSON parse) on every append just to compute the next Sequence |
|
||||
| medium | open | internal/cli/app.go:149 | app.go:177-190: comment says trailing non-flag args "still are" ignored; rest scanned only for --add-dir then dropped before runInteractiveTUI |
|
||||
| medium | open | internal/cli/exec.go:500 | exec.go:524-532: interrupted branch emits errorEvent/runEnd only for execOutputStreamJSON; json path prints "Interrupted." to stderr, no terminal JSON event |
|
||||
| medium | open | internal/cli/exec.go:182 | exec.go:186-193: only stream-json is special-cased; -o json falls to writeExecToolList plain text (exec_tools.go:100-110 "Tools visible to model:") |
|
||||
| medium | partial | internal/tui/transcript.go:113 | Full-slice copy removed — in-place append, unkeyed rows skip the scan (transcript.go:92-101) — but hasTranscriptRow still linearly scans all rows per keyed tool/permission/ask append (transcript.go:103-114), so tool-heavy rehydration stays O(n²) |
|
||||
| medium | partial | internal/tui/transcript.go:113 | O(n) full-slice copy removed (in-place append, transcript.go:96-100), but hasTranscriptRow (transcript.go:103-114) still linearly scans all rows per keyed (tool/permission) row and m.transcript is never pruned, so tool-heavy rehydration remains O(n^2) |
|
||||
| medium | partial | internal/tui/model.go:569 | transcript half fixed: interim text appended as non-final assistant row on tool call/error/cancel (model.go:710-715, 681-683, 1340-1342); but session log still records only FinalAnswer (model.go:1638-1644) — interim segments never persisted, so /resume loses them |
|
||||
| medium | partial | internal/tui/transcript.go:117 | re-render gone (View renders only unflushed tail, model.go:769; render_cache.go memoizes) and copy-on-append removed (transcript.go:96-100); but hasTranscriptRow still scans the whole transcript per keyed append (transcript.go:103-114) — O(n²) cumulative for tool rows |
|
||||
| medium | partial | internal/tui/transcript.go:113 | Copy fixed: transcript.go:96-100 appends in place; but hasTranscriptRow (transcript.go:103-114) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(N²) |
|
||||
| medium | partial | internal/tui/transcript.go:117 | transcript.go:100 full-slice copy removed (in-place append; non-keyed rows now O(1) via empty-key fast path), but hasTranscriptRow transcript.go:108-113 still linear-scans all rows per tool/permission/ask append — tool-heavy appends/rehydration remain quadratic |
|
||||
| medium | partial | internal/tui/rendering.go:261 | rendering.go:286-303 now preserves explicit newlines and each line's leading indentation (code blocks survive), but rendering.go:305 still runs strings.Fields(body) per line, collapsing intra-line space runs so mid-line column alignment is still lost |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
# Audit re-verification against current `main` (2026-06-13)
|
||||
|
||||
Every pending (open/partial) row from `2026-06-10-deep-audit-status.md` was re-checked
|
||||
against the **actual current code** by an independent agent (the ledger was verified on a
|
||||
since-diverged branch). Every `already_fixed` verdict was then adversarially re-checked by a
|
||||
second agent trying to prove the defect was still present; **none were refuted** (all 9 hold).
|
||||
|
||||
| Verdict | Count |
|
||||
|---|---|
|
||||
| still_open | 66 |
|
||||
| partial | 12 |
|
||||
| already_fixed (ledger over-counted) | 9 |
|
||||
| **genuinely pending (still_open + partial)** | **78** |
|
||||
|
||||
Pending by severity: high 2, medium 42, low 34.
|
||||
|
||||
## Already fixed on `main` (reclassify to fixed) (9)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| medium | `internal/cli/cron.go:106-128` | The original defect (recipe set `expr=r.Expr` before the positional was consumed, silently ignoring a user-supplied expr) is gone: cronAdd now consumes the positional expr first at cron.go:113-114 (`if len(positional)==1 && expr=="" { expr=positional[0] }`), and the recipe only fills expr if still empty (cron.go:122-123); extra positionals error. Confirmed by TestCronAddExplicitExprOverridesRecipe (cron_test.go:95-118). |
|
||||
| low | `internal/cli/cron_run.go:198-205` | The original gap was that cronTruncate byte-sliced `s[:max]+"…"` and could split a multi-byte rune; current cronTruncate (cron_run.go:204) now returns `cutRuneBoundary(s, max) + "…"`, and cutRuneBoundary (exec_writer.go:420-431) backs up to a utf8.RuneStart boundary, so the rune-split defect is gone. promptExcerpt (cron.go:278) was already rune-safe. |
|
||||
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: the cited byte-slice `return s[:max] + "…"` for run errors (used at cron_run.go:151) no longer exists; cronTruncate now calls cutRuneBoundary(s, max) which lands on a UTF-8 rune boundary, so persisted run-error text can no longer be split mid-rune. |
|
||||
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: cronTruncate no longer byte-slices `s[:max]`; it uses the rune-safe cutRuneBoundary helper (cron_run.go:204), with a comment explaining the cut lands on a UTF-8 rune boundary, closing the run-record invalid-UTF-8 gap. |
|
||||
| low | `internal/redaction/redaction.go:217-231 (pointer), :232-252 (map)` | The pointer case now does context.seen[ptr]=struct{}{} (line 225) then delete(context.seen, ptr) after redactReflect returns (line 230); the map case mirrors this (line 240 add, line 251 delete). Comments at lines 226-228 explicitly state the pointer is dropped after recursion so a shared non-cyclic reference reached via a sibling branch is not mistaken for a cycle, exactly the originally-described defect, which is gone. |
|
||||
| low | `internal/sandbox/grants.go:392-406` | readState now rebuilds a new `normalized` map keyed by `key := strings.TrimSpace(name)` (grants.go:394, stored at :403) instead of retaining the raw padded file key, and Lookup indexes with `strings.TrimSpace(toolName)` (grants.go:172), so a whitespace-padded file key both validates AND matches. The ledger's premise ('unchanged since #77') is stale — this normalization was added in commit 3f3de1a (#170). |
|
||||
| low | `internal/tui/session_controls.go:196-241` | handleCompactCommand no longer just increments compactRequests: when sessionCompactor or hasSessionBackedCompactor() is available it sets compactInFlight and returns tea.Batch(m.runCompact(), ...), and runCompact()->compactActiveSession() actually calls sessionStore.PlanCompaction/RecordCompaction (replay.go:145/181). The 'pending integration' status string is gone (only a negative-assertion test references it), and sessionStore is always non-nil (model.go:272-274), so real compaction is triggered. |
|
||||
| low | `internal/zerocommands/contracts.go:169` | ProviderSnapshotFromProfile now sets APIKeySet via `strings.TrimSpace(profile.APIKey) != "" \|\| strings.TrimSpace(profile.AuthHeaderValue) != ""` (line 169) with a comment (lines 166-168) explaining auth-header-only profiles must not render as 'not set'; config/types.go:51 confirms AuthHeaderValue is a valid sole credential, and command_center.go:317/334 render apiKeyState(provider.APIKeySet) off this fixed boolean. |
|
||||
| low | `internal/zerogit/zerogit.go:209` | The remaining gap in the partial finding (ValidateMessage still byte-based `len(firstLine) > 72`) is gone: line 209 now uses `utf8.RuneCountInString(firstLine) > 72` with an explanatory comment, and truncateSubject (line 466) is already rune-based, so valid non-ASCII subjects are no longer rejected. |
|
||||
|
||||
## Genuinely partial (a real gap remains) (12)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| high | `internal/cli/cron_run.go:176-188 (re-read fix) + internal/cron/store.go:170-178 (Update, no lock)` | fireJob does re-read via store.Get and preserves an external pause (current.Status == StatusPaused at cron_run.go:186-187) and handles ErrJobNotFound/transient errors, closing the pause-clobber; but cron/store.go Update still just calls writeJob (temp+rename) with no flock/CAS anywhere in the package, so the concurrent-scheduler read-modify-write race remains exactly as the ledger noted. |
|
||||
| high | `internal/hooks/dispatch.go:112 (Dispatch); wired at internal/agent/loop.go:584,620 and constructed at internal/cli/hook_dispatch.go:51` | The primary gap is closed: the Dispatcher now has production callers and IS wired into the tool lifecycle - constructed in app.go/exec.go/exec_spec.go, assigned to agent Options.Hooks, and invoked at loop.go:584 (beforeTool, with veto via blockedByHookResult) and loop.go:620 (afterTool feedback). But the original finding's CLI-surface gap remains: runHooks in internal/cli/extensions.go:88-135 still supports only `list`/help with no dispatch/edit subcommand. |
|
||||
| medium | `internal/agent/compaction.go:280 (maybeCompact) / 83-96 (estimateTokens)` | The image-counting fix is present (estimateTokens line 92 adds len(message.Images)*imageTokenEstimate), but the remaining gap stands: maybeCompact's trigger uses estimateTokens(messages), which has no tool-definition term, and is called at loop.go:85 with no tools (the exposed tool defs are built afterward at loop.go:87). The tool-definition estimator estimateToolTokens exists only in the separate MeasureContext reporting path (context_measurement.go:53), not in the compaction trigger. |
|
||||
| medium | `internal/tui/rendering.go:292-336 (gap at :311)` | wrapPlainText now preserves explicit newlines (split on \n, :297) and each line's leading indentation (TrimLeft + reconstructed indent, :302-305), so code blocks survive. But the word loop at :311 still iterates strings.Fields(body), which collapses intra-line space runs, so mid-line column alignment within a line is still lost exactly as the finding describes. |
|
||||
| medium | `internal/tui/transcript.go:103-114` | The full-slice copy is genuinely gone (in-place append at transcript.go:100 with a comment about the old O(n²)), but hasTranscriptRow (transcript.go:108-112) still linear-scans every existing row computing transcriptRowKey per keyed tool/permission/ask append, and m.transcript is never pruned, so rehydration via appendTranscriptRow (session.go:145-146) remains O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the same finding: copy-on-append removed (transcript.go:96-100) but hasTranscriptRow (transcript.go:108-112) still linearly scans all rows per keyed row and the transcript is never bounded, so tool-heavy rehydration stays O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:113 finding: append is in-place (transcript.go:100), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | View re-render and copy-on-append are fixed (in-place append at transcript.go:100, empty-key fast path at transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still scans the whole transcript per keyed append — O(n²) cumulative for tool rows during rehydration. |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:117 finding: the full-slice copy is removed and non-keyed rows are O(1) via the empty-key fast path (transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per tool/permission/ask append, so those appends/rehydration remain quadratic. |
|
||||
| low | `internal/agent/loop.go:553-580, 611-616` | The main "requestEvent never read after the switch" claim is now fixed (requestEvent is passed to emitDeniedPermission/deniedPermissionResult on the denial paths at 571-572,577-578), but requestEvent.GrantMatched/Grant set at 574-575 in the AlwaysAllow success case remain dead writes (the post-run event at 611-616 is rebuilt fresh) and the fallbackPermissionEvent at 555 is still unreachable because shouldRequestPermission only fires for PermissionPrompt tools, for which buildPermissionEvent never returns ok=false (false only at 1071/1076 for non-Prompt/Allow). |
|
||||
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | The main fix landed: footer builders, theme tokens (selRow/statusOk/statusErr/line2/panel2), and actionAppendToolCall/Result are all gone (zero grep hits). But the cited gaps remain: listCommandNames (commands.go:297) and formatCommandHelpLines (commands.go:306) are referenced only by test files; resumeText's args!="" branch (command_center.go:45-55) is still unreachable since its sole non-test caller session.go:122 passes ""; OnContext (agent/loop.go:105-106) is emitted but has no production assignment; and the stale footer comment persists at commands.go:254. |
|
||||
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | Duplicate of the other rendering.go:62 row; same underlying code. Footer builders and the theme tokens are fully removed, but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production-defined yet referenced only by tests, and a stale 'footer advertises' comment persists at commands.go:254 (the autocomplete.go:64 stale comment cited in the older row is now gone, but commands.go:254 still has one). |
|
||||
|
||||
## Genuinely still open (66)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| medium | `internal/agent/compaction.go:422 (summarizeMessagesOnce)` | summarizeMessagesOnce still calls plain zeroruntime.CollectStream (compaction.go:422), which is CollectStreamWithOptions with empty CollectOptions{} (helpers.go:78), so no OnUsage callback fires; the collected Usage is discarded and the deliberate non-forwarding is now documented at summarizeClosure (compaction.go:359-361). Compaction-call token usage is still not surfaced/tracked. |
|
||||
| medium | `internal/agent/loop.go:567-573, 928-931` | The AlwaysAllow case still calls deniedPermissionResult/emitDeniedPermission when persistPermissionGrant errors (loop.go:569-573), and persistPermissionGrant (now at 928-931) still errors unconditionally when options.Sandbox == nil, so choosing AlwaysAllow without a configured sandbox denies the already-approved call; both sub-claims persist (only relocated from the stale 778-779 reference). |
|
||||
| medium | `internal/background/manager.go:462-471 (and persistTaskLocked call at :419)` | normalizeLoadedTask still unconditionally rewrites task.Status==StatusRunning to StatusError, sets PID=0 and ExitCode=-1, and marks changed=true; loadTasks then persists this to disk via persistTaskLocked at line 419, with no process-liveness check or reconciliation. The defect is present essentially as the auditor described. |
|
||||
| medium | `internal/background/process_posix.go:29,50` | SIGTERM (line 29) and Kill() (line 50) still target only the single pid with no kill(-pid)/negative-pid group signal, and launchBackgroundProcess (specialist/exec.go:650) sets no SysProcAttr{Setpgid:true} (grep finds zero process-group setup), while Windows still tree-kills via taskkill /T /F (process_windows.go:12). All three claims hold. |
|
||||
| medium | `internal/cli/app.go:200-220` | In the `--skip-permissions-unsafe` case, the code re-splits args[1:] via splitLeadingAddDirFlags and only iterates `rest` to reject a misplaced --add-dir (lines 215-219); it then calls runInteractiveTUI with merged addDirs only, never forwarding `rest`, so any trailing non-flag args are still silently dropped. The in-code comment at lines 208-209 explicitly confirms this ('were ignored on this path... and still are'), matching the original finding verbatim. |
|
||||
| medium | `internal/cli/cron_run.go:103-128` | reconcileOverdue still reschedules any strictly-overdue active job (NextRunAt before nowMin) to sched.Next without firing it (lines 116-126); grep confirms no RunNow/RunOnce/FireNow sentinel exists in internal/cron, so the only way to fire backlog is the --catch-up flag that skips reconcileOverdue entirely. |
|
||||
| medium | `internal/cli/exec.go:191-199` | The --list-tools branch special-cases only execOutputStreamJSON (exec.go:192-194); -o json (execOutputJSON) falls through to writeExecToolList, which calls formatExecToolList and prints the plain-text "Tools visible to model:" listing (exec_tools.go:100-110) rather than any JSON output. |
|
||||
| medium | `internal/cli/exec.go:531-545` | The interrupted branch still emits writer.errorEvent/runEnd only when outputFormat == execOutputStreamJSON (exec.go:535-540); for -o json it just does fmt.Fprintln(stderr, "Interrupted.") at exec.go:542 and returns exitInterrupted, producing no terminal JSON event on stdout (unlike the provider-error path which writes type:error/type:done for json). |
|
||||
| medium | `internal/cli/exec_sessions.go:116-124` | execSessionRecorder.append still early-returns when recorder.err != nil (line 117) and latches AppendEvent failures into recorder.err (line 120); a package-wide grep shows recorder.err/sessionRecorder.err is read only inside append itself, never by exec.go or exec_spec.go (which check only writer.err), so persistence errors are silently swallowed at run end. |
|
||||
| medium | `internal/cli/exec_sessions.go:116-124` | Duplicate of the same recorder.err defect: append (lines 116-124) early-returns once recorder.err is set and stores AppendEvent errors there, but sessionRecorder.err is never read in exec.go or exec_spec.go, so a failed session append is never surfaced to the user at run end. |
|
||||
| medium | `internal/config/resolver.go:102, 110-124` | ResolvedConfig.MCP is still set at resolver.go:102 but has zero readers: every `.MCP` access in internal/cli is the config.MCPConfig type or the separate resolveMCPConfig/ResolveMCP path, and no resolveConfig caller reads resolved.MCP. MCP registration runs through ResolveMCP (resolver.go:110-124), which only merges config-file paths and Overrides and never calls LoadProviderCommand/touches options.ProviderCommand (asserted intentional by TestResolveMCPDoesNotRunProviderCommand), so ZERO_PROVIDER_COMMAND MCP servers are still dropped. |
|
||||
| medium | `internal/cron/schedule.go:171-201` | Empirically confirmed: on the 2026-11-01 US fall-back day Next("30 1 * * *") returns 01:30 EDT and then 01:30 EST again, firing the daily job twice during the repeated wall-clock hour. The Next loop and its forward-progress guard (schedule.go:182-198) only handle spring-forward non-existent instants and contain no de-duplication for the ambiguous repeated fall-back hour. |
|
||||
| medium | `internal/hooks/hooks.go:503` | AuditStore.append still calls store.ReadEvents() at line 503, which os.ReadFile's the entire JSONL audit file and json.Unmarshal's every line on every single append, solely to scan for the highest existing Sequence (lines 507-513); no cached/in-memory sequence counter was introduced, so the O(n) full re-read per append is unchanged. |
|
||||
| medium | `internal/mcp/client.go:246` | client.writer.write(...) at line 246 still executes while client.mu is held (locked at :228, unlocked at :255), and writer.write (protocol.go:148-163) does a blocking Write+Flush on the stdin pipe with no ctx awareness; only the response wait at :257 is ctx-aware, unchanged from the audit. |
|
||||
| medium | `internal/mcp/client.go:295-318` | readLoop still dispatches any id-bearing message to pending[id] (lines 302-316) with no check of message.Method; grep finds no 'ping' or Method handling anywhere in client.go, so server-initiated requests are still ignored/misrouted exactly as described. |
|
||||
| medium | `internal/mcp/client.go:98-99` | Still 'var stderr bytes.Buffer; cmd.Stderr = &stderr' — a plain unbounded buffer (no LimitReader/cap), never reset after startup, attached for the whole process lifetime and only read on initialize failure at line 114. |
|
||||
| medium | `internal/mcp/network_client.go:612-642 (decodeSSERPCMessage), caller at :219` | decodeSSERPCMessage still decodes the first non-empty 'message' SSE event, sets found=true and returns false (stops scanning) without checking the message ID; the synchronous caller networkClient.request at line 219 does `if !rpcIDMatches(message.ID, id)` and errors on mismatch instead of scanning for the matching response. Defect unchanged (caller line is now :219, was the stale :210). |
|
||||
| medium | `internal/mcp/network_client.go:646 (scanSSEEvents), failPending at :587-593, request gate at :243` | scanSSEEvents still calls scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) with the 1MiB cap; a bufio.Scanner ErrTooLong flows through readStream into failPending (:593) which sets a permanent client.streamErr, and request() returns that streamErr forever (:243-246) with no reconnect path (openStream is only called once at connect, :77). |
|
||||
| medium | `internal/providers/gemini/types.go:103-108` | The usageMetadata struct still declares only PromptTokenCount, CandidatesTokenCount, ThoughtsTokenCount, and TotalTokenCount with no cachedContentTokenCount field; provider.go:225-228 reads only those into input/output/reasoning tokens and emitDone (provider.go:282-286) builds TokenUsage without CachedInputTokens, which is set only in openai/provider.go:253 and anthropic/provider.go:330. |
|
||||
| medium | `internal/providers/openai/tool_state.go:95-101 (and applyDelta:58)` | applyDelta still returns early when call.id=="" (line 58), and closeOpen still only emits StreamEventToolCallDropped for empty-id/empty-name calls (lines 95-99) instead of streaming them with a synthetic key; every Start/Delta/End the adapter emits carries call.id, so the zeroruntime collector's empty-ID/synthetic-key support (helpers.go start/delta/end at ~173-216) is never reached from the OpenAI adapter. The provider_test still asserts such calls are dropped and never started, confirming the behavior is unchanged, and tool_state.go's last commit (4d2ce6b) predates the 2026-06-10 audit. |
|
||||
| medium | `internal/sandbox/safe_command.go:431` | splitShellSegments still uses a strings.NewReplacer that blindly turns \|, ;, &&, \|\|, $(, ), backtick into segment boundaries with zero quote awareness; a live probe confirms `git commit -m "use top \| less"` is flagged as 'less' (interactive=true) and `echo "a; vim b"` is flagged as 'vim', exactly as the original finding described. The file is unchanged since commit #109 and contains no quote-state parsing. |
|
||||
| medium | `internal/sessions/lineage.go:140` | tree() (lineage.go:128-154) still calls store.ListChildren(sessionID) at lineage.go:140 once per node, and ListChildren calls store.List() at lineage.go:74; List() (store.go:312-338) re-reads the whole sessions dir via os.ReadDir+Get->readMetadata (store.go:674-684, a fresh os.ReadFile+json.Unmarshal with no cache), so an N-node tree re-reads every metadata.json N times. The defect is present exactly as described. |
|
||||
| medium | `internal/sessions/store.go:428-432 (Fork copy loop), 526-567 (appendEventLocked); internal/usage/report.go:74-119 (BuildReport)` | Fork's loop at store.go:428-431 re-appends every parent event (no provider_usage/EventUsage skip) via AppendEvent, which stamps a fresh CreatedAt/ID in appendEventLocked (537-543), and BuildReport (report.go:74-119) aggregates all EventUsage events with no event-id dedupe, so forked usage events are double-counted. |
|
||||
| medium | `internal/sessions/store.go:686-701 (writeMetadata), 549-559 (appendEventLocked write path)` | writeMetadata still does os.WriteFile to a temp file + os.Rename (store.go:693-696) with no file or directory fsync, and a repo-wide grep finds zero Sync() calls anywhere in internal/sessions/; appendEventLocked's append write (549-557) is likewise unsynced. |
|
||||
| medium | `internal/specialist/accounting.go:80-91 (appendSpecialistUsageRollup) and :31-47 (recordSpecialistStop)` | The dedup is still a non-atomic check-then-append: specialistEventExists calls store.ReadEvents (which acquires NO lock, store.go:569) and only the later AppendEvent takes the per-session lock for its own write, so concurrent callers (exec.go:343 onExit and output_tool.go:150 TaskOutput) can both pass the existence check and each append a duplicate event. No serializing mutex spans the check and append in the specialist package, and the store's lockSession/appendEventLocked machinery is not used by the accounting path. |
|
||||
| medium | `internal/specialist/exec.go:150 (and :202)` | Both BuildArgs (:150) and BuildResumeArgs (:202) still hardcode args append "--auto","high", and internal/cli/exec_tools.go:84-85 maps "high" -> agent.PermissionModeUnsafe, so specialist children always run in unsafe permission mode. |
|
||||
| medium | `internal/specialist/streamer.go:43` | ParseStream still calls scanner.Buffer(make([]byte,0,64*1024), 1024*1024), so a line over 1MB yields bufio.ErrTooLong; exec.go:631-634 returns that error from runChildProcess aborting the whole run, and the producer at cli/exec_writer.go:99 (parseToolCallArgs) still emits tool_call Args with no length cap. All three elements of the original finding are unchanged. |
|
||||
| medium | `internal/tools/registry.go:198-245` | The Core* tool sets (CoreReadOnlyTools/CoreWriteTools/CoreShellTools at :224/CoreNetworkTools/CoreTools) still omit escalate_model; it is registered only under exec --allow-escalation (cli/exec.go:156-157), while the TUI registry built by newCoreRegistryScoped (cli/app.go:436,531-537) never registers it — app.go only mentions escalate_model in help text at :733. |
|
||||
| medium | `internal/tui/model.go:2022-2028 (session log) vs model.go:854/802/1721 (transcript)` | Interim narration is appended to the live transcript on tool call (model.go:852-857), error (801-803), and cancel (1720-1722), but the session log only persists result.FinalAnswer via EventMessage (model.go:2022-2028); onText (1781-1786) and sendAgentText (2064-2069) record no session event, so the gap the ledger flagged as 'partial' is unchanged — interim segments are still never persisted and /resume loses them. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | m.responseStyle is still only stored by handleStyleCommand (session_controls.go:156) and echoed in display strings (session_controls.go:159/171, command_views.go:275); runAgentWithOptions threads ReasoningEffort/SystemPrompt/PermissionMode into options but never references responseStyle, and agent.Options has no style field (no non-display ResponseStyle reader exists), so /style still changes nothing about responses. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding: m.responseStyle is set at session_controls.go:156 but runAgentWithOptions (model.go:1746-1778) never assigns it into agent.Options or the system prompt; the only readers are display strings (command_views.go:275, session_controls.go:159/171), so the style preference is inert. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding verified once: handleStyleCommand only stores m.responseStyle and grep shows no non-display consumer; runAgentWithOptions sets ReasoningEffort/SystemPrompt/etc. on agent.Options but never the style, so /style remains cosmetic. |
|
||||
| medium | `internal/tui/session_controls.go:318 (resolveRewindTarget); internal/sessions/rewind.go:205` | resolveRewindTarget still returns lastCheckpoint-1, while OnToolCall appends EventToolCall then the EventSessionCheckpoint right after it (model.go:1886-1911) so the tool_call gets the immediately-preceding sequence; TruncateEvents keeps ev.Sequence<=target (rewind.go:205), so the tool_call (seq==target) is retained while its checkpoint (seq>target) is dropped, leaving the dangling tool_call described in the finding. |
|
||||
| medium | `internal/usage/report.go:17-21 (struct) and :102 (pricing); writer at internal/cli/exec.go:521-524` | usageEventPayload (report.go:17-21) still has only PromptTokens/CompletionTokens/TotalTokens with no model field, and BuildReport at report.go:102 still prices via modelBySession[event.SessionID] from session Metadata.ModelID; meanwhile exec.go:521-522 still persists payload["model"]=currentModel under --allow-escalation, and a repo-wide grep confirms no consumer reads that per-event model key (the only usageEventPayload reader silently drops it on unmarshal). |
|
||||
| medium | `internal/zerogit/zerogit.go:225` | parseStatus still does `path := strings.TrimSpace(line[3:])` with no C-quote unescaping and no `old -> new` rename split, and the caller at line 119 runs `status --short --untracked-files=all` without `-z`/porcelain, so quoted or renamed paths are still mis-parsed exactly as described. |
|
||||
| low | `internal/agent/loop.go:1305-1316` | propertyToRuntimeMap still assigns schema["items"] = propertyToRuntimeMap(*property.Items) twice for the same property.Items != nil guard (lines 1305-1307 and 1314-1316); the second assignment is a redundant duplicate writing an identical value. |
|
||||
| low | `internal/agent/loop.go:189-191` | The reactive-retry CollectStreamWithOptions call still passes only OnUsage and omits OnText (comment at 183-188 documents this as deliberate anti-duplication), so streaming surfaces still receive only the aborted partial text on the retried turn exactly as the finding describes. |
|
||||
| low | `internal/agent/loop.go:194-201` | When ctx is canceled, internal/zeroruntime/helpers.go:89 sets collected.Error = ctx.Err().Error(); loop.go:194-197 returns errors.New(collected.Error) before the ctx.Err() identity return at 198-201, so the returned error no longer wraps the sentinel and errors.Is(err, context.Canceled) still fails on that path. |
|
||||
| low | `internal/agent/types.go:196 (field); internal/agent/loop.go:105-106 (emit site); internal/agent/context_measurement.go:31 (MeasureContext/ContextBreakdown)` | The OnContext callback field (types.go:196) and its emit site (loop.go:105-106 calling MeasureContext) still exist, but a repo-wide grep finds ZERO 'OnContext:' assignments anywhere (test or prod) and no use of MeasureContext/ContextBreakdown outside internal/agent tests; cli/tui/cmd and the contextreport package never reference them (contextreport only calls agent.BuildSystemPromptPreview), so the callback remains permanently a no-op exactly as the original finding described. |
|
||||
| low | `internal/background/process_posix.go:38-50` | terminateProcess still does os.FindProcess(pid) (line 24) and polls liveness via signal-0 in processAlive (lines 39-47, 59-61), while launchBackgroundProcess (specialist/exec.go:659-673) still reaps the child in a concurrent command.Wait() goroutine, so the PID can be reaped and recycled before process.Kill() at line 50 fires. Defect unchanged. |
|
||||
| low | `internal/cli/cron_run.go:146-152` | rec.Error is still set only from errBuf (cronTruncate(strings.TrimSpace(errBuf.String()),500) when code!=0); outBuf is declared and passed to exec at line 146-147 but never read anywhere in fireJob, so stream-json error events on stdout are still discarded. |
|
||||
| low | `internal/cli/exec.go:383-398 (PrepareExec call), exec.go:404 (time.Now), internal/cli/exec_sessions.go:55 (direct NewStore)` | All three sub-claims hold: the sessions.PrepareExec call at exec.go:383-398 omits the Store field so PrepareExec falls back to NewStore(StoreOptions{}) at exec_session.go:58-61; preflightExecSession still does sessions.NewStore(StoreOptions{}) directly at exec_sessions.go:55; and runExec uses streamjson.CreateRunID(time.Now()) at exec.go:404 instead of the injectable deps.now (which is available and used at exec.go:142/186). |
|
||||
| low | `internal/cli/exec_sessions.go:55` | preflightExecSession still calls sessions.NewStore(sessions.StoreOptions{}) directly at line 55, and exec.go's PrepareExec call (exec.go:383-397) omits the Store field so sessions/exec_session.go:58-60 defaults to NewStore again; deps.newSessionStore is used by spec/usage/observability/sessions paths but never by the exec run path in exec.go. |
|
||||
| low | `internal/config/contracts.go:5,12,65` | ContractGap (line 5), DefaultContractGaps (line 12), and FindContractGapsByMilestone (line 65) are all still defined, and a repo-wide grep for these symbols (plus ContractOwnerRuntime) finds references ONLY in internal/config/contracts_test.go. No production caller exists, so the dead-code defect persists exactly as described. |
|
||||
| low | `internal/config/resolver.go:144,177,506` | All three MaxTurns sites (mergeConfig :144, mergeProjectConfig :177, applyOverrides :506) still gate purely on `if src/overrides.MaxTurns > 0` with no error path, and Resolve() has no MaxTurns validation, so a configured 0 or negative maxTurns is silently dropped and falls back to defaultMaxTurns=30 (resolver.go:16,22). A negative-value error path exists for tools.deferThreshold (:57-59) but none was added for MaxTurns. |
|
||||
| low | `internal/plugins/plugins.go:894` | parseHookEvent's switch (plugins.go:894) still accepts only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd and the HookEvent consts (plugins.go:39-42) omit specialist events entirely, whereas hooks.go parseEvent (hooks.go:812) accepts those four plus EventSpecialistStart/EventSpecialistStop, so the two validation sets still disagree exactly as described. |
|
||||
| low | `internal/providercatalog/catalog.go:46` | The `Public bool` field is still declared on Descriptor (catalog.go:46) but is never assigned anywhere in the codebase: there are zero `Public:` struct-literal assignments and zero `.Public =` writes. The only read remains the vacuous guard at catalog_test.go:106 (`if descriptor.Public && !descriptor.RequiresAuth`), which is dead code since Public is always false. |
|
||||
| low | `internal/providers/factory.go:115` | At the sole call site (line 115) the `\|\|` short-circuits so isImplicitOpenAI is only evaluated when explicitProvider==true, which (per explicitProviderKind at 147-157) requires ProviderKind!="" or Provider!=""; but isImplicitOpenAI (159-164) requires both ProviderKind=="" and Provider=="", so it can never return true there — the term remains dead code exactly as described. |
|
||||
| low | `internal/redaction/redaction.go:174` | Line 174 still declares `var stackTracer interface{ StackTrace() fmt.Stringer }`; a repo-wide grep finds StackTrace only at redaction.go:174 and :176 (no implementer in the module), and go.mod has no pkg/errors-style dependency, so this errors.As branch is dead code that can never match exactly as the finding described. |
|
||||
| low | `internal/sessions/store.go:615-617 (timestamp), 331-336 (List), 340-346 (Latest)` | timestamp() at store.go:616 still formats with time.RFC3339 (second precision), and List() at 332-334 breaks UpdatedAt ties lexically by SessionID, so Latest() (line 345 returns sessions[0]) can still pick the wrong session on same-second ties exactly as described. |
|
||||
| low | `internal/skills/skills.go:91 (Duplicates); consumers: internal/cli/skills.go:62, internal/plugins/activate.go:376` | skills.Duplicates and skills.Get still have zero callers outside internal/skills (Get has zero callers anywhere, even tests), and the duplicate-collision data is never surfaced: cli/skills.go runSkillsList calls skills.List with no warning, and the skill tool discards the dups at activate.go:376 with `merged, _ := MergedSkillsLoaded(...)`. Although plugins.mergeSkills now computes a parallel DuplicateName slice that MergedSkills returns, every call site drops it, so shadowed/dropped skills are still resolved silently with no user-facing warning, exactly the defect described. |
|
||||
| low | `internal/specialist/exec.go:353-358` | On manager.SetPID failure the code only calls executor.cleanupBackgroundPromptFile and returns the error; it does not call recordSpecialistStop or manager.UpdateStatus(StatusError) while the already-launched child keeps running, unlike the launch-error path at :347-352 which does record a stop. |
|
||||
| low | `internal/specialist/output_tool.go:244` | formatTaskOutput is still dead code: a codebase-wide grep finds only its own definition at :244 and no callers in prod or tests; the only used formatter is formatTaskOutputSummary, called from readOutput at :154. |
|
||||
| low | `internal/streamjson/streamjson.go:30` | Repo-wide grep for EventRestore (and the literal "restore" EventType) returns only the declaration at streamjson.go:30 — no emitter and no test reference, whereas the sibling EventCheckpoint is emitted at cli/exec_writer.go:119 and referenced in streamjson_test.go:435. The constant remains dead exactly as the original finding described. |
|
||||
| low | `internal/tools/read_file.go:98-121` | The truncation path is unchanged from main (empty git diff): when max_lines truncates, it sets Result.Truncated=true (line 120) but the only in-output signal is the '(lines 1-5 of 20)' range header (line 114) — no distinct truncation marker is appended to Output (line 119). The rune-width sub-claim remains non-reproducible because line-number padding uses ASCII digits via strconv.Itoa (lines 105-109), exactly as the original finding concluded. |
|
||||
| low | `internal/tools/registry.go:19` | Repo-wide grep finds OnSandboxDecision only at registry.go:19 (declaration), :113 (nil check), and :121 (invocation) — zero struct-literal (OnSandboxDecision:) or dot-assignment (.OnSandboxDecision =) sites anywhere in non-test or test code, so the field is still never set by any caller. |
|
||||
| low | `internal/tui/command_center.go:45-55` | resumeText's args!="" branch (command_center.go:45-55) is still dead: the only production caller, handleResumeCommand at session.go:121-122, passes "" (the non-empty case is handled separately via resolveResumeSession at session.go:125), so the branch remains unreachable; one stale 'footer advertises' comment also persists at commands.go:254 (the autocomplete.go one is gone). |
|
||||
| low | `internal/tui/command_center.go:17-24` | doctorText() still constructs doctor.Run(doctor.Options{Now, Runtime:"go", Provider}) with no UserConfig/ProjectConfig (command_center.go:18-22), unlike the CLI path at observability.go:64-71; so configFilesCheck (doctor.go:111-123) gets empty paths and emits the StatusWarn 'No explicit Zero config files were inspected.' exactly as the finding describes. |
|
||||
| low | `internal/tui/commands_test.go:46` | The function commandTestStringSliceContains is still defined at line 46, and a repo-wide grep across all *.go files returns only the definition itself with zero call sites, matching the original dead-helper finding exactly. |
|
||||
| low | `internal/tui/model.go:1363-1365` | handleSubmit still drops a prompt while m.exiting with a bare `return m, nil` and no feedback row, and no render path surfaces an exiting/flushRunIDs indicator: statusLine (view.go:137-156) shows only provider+usage, and composerLine's hint switch (model.go:1130-1134) only shows 'esc stop' when m.pending. |
|
||||
| low | `internal/tui/options.go:34,39-40 (UsageTracker/ReasoningEffort/ResponseStyle); internal/cli/app.go:484-516; internal/tui/model.go:277-279` | The exact residual gap the ledger evidence describes is still present verbatim: the production caller's tui.Options{...} block in app.go (484-516) sets none of UsageTracker, ReasoningEffort, or ResponseStyle. The mitigations cited also still hold — newModel self-defaults a usage.Tracker when options.UsageTracker is nil (model.go:277-279) and effort/style remain runtime-settable via /effort (session_controls.go:81) and /style (session_controls.go:156) — so the code matches the partial state described, with the same unset-fields gap unchanged. |
|
||||
| low | `internal/tui/session_test.go:215` | The local slice `runtimeMessages := []tea.Msg{}` (line 215) is still only appended to in the RuntimeMessageSink closure (line 226) and never read or asserted anywhere in the test body; the test exclusively consumes the channel runtimeMessageCh via receiveRuntimeMessage, leaving the slice write-only exactly as the finding described. |
|
||||
| low | `internal/verify/verify.go:215` | RunLoop is still defined at verify.go:215 and a repo-wide grep finds only two references: the definition and its sole caller verify_test.go:207. The production retry loop is selfverify.Run (selfverify.go:53), which re-implements the attempt loop calling verify.Run directly (selfverify.go:75) and is the one wired into cli/workflows.go, so RunLoop plus LoopOptions/LoopReport remain dead production code. |
|
||||
| low | `internal/zerocommands/sandbox_snapshots.go:82-104 (SandboxPlanSnapshot/SandboxDecisionSnapshot); raw serialization at internal/cli/sandbox.go:92-94,146-148` | Grep confirms SandboxPlanSnapshot/Decision/Policy/Backend/Risk/Violation snapshots are referenced only inside internal/zerocommands and its tests (no production consumer; the only new consumers in tui/command_views.go are SandboxGrantSnapshots and ProviderSnapshot, which are different types). `zero sandbox policy --json` in internal/cli/sandbox.go still builds its payload from raw zeroSandbox.Policy/Backend/BackendPlan (lines 92-94 and 146-148) instead of the snapshot constructors, exactly as the original evidence described. |
|
||||
| low | `internal/zerogit/zerogit.go:419-423` | The fallback EnvRunner adapter at lines 420-422 is still `func(ctx, dir, _ []string, args ...string)` which discards the env slice and calls `runGit(ctx, dir, args...)`, so a caller-supplied Runner with no RunGitEnv still loses stagedSnapshotDiff's GIT_INDEX_FILE isolation. |
|
||||
| low | `internal/agent/loop.go:194-201 (origin internal/zeroruntime/helpers.go:89)` | helpers.go:89 still sets collected.Error = ctx.Err().Error() (stringifying the cancellation, losing identity), and loop.go:194-196 returns errors.New(collected.Error) BEFORE the identity-preserving `return result, ctx.Err()` at loop.go:198-200, so on the stream-cancellation path the returned error is a plain string error and errors.Is(err, context.Canceled) still fails. |
|
||||
@@ -0,0 +1,281 @@
|
||||
# ZERO · Lime TUI rebuild — staged prompt
|
||||
|
||||
You are working in a fresh checkout of `github.com/Gitlawb/zero` (Go 1.24,
|
||||
Bubble Tea v1.3.10, Lip Gloss, Bubbles v1.0.0, Glamour — all in go.mod; add NO
|
||||
new dependencies).
|
||||
|
||||
Mission: **remove both existing TUI designs entirely** — the default cyan
|
||||
shell (`internal/tui/theme.go` palette + the centered splash in `startup.go`)
|
||||
AND the whole `zeroline` skin system — and replace them with ONE design:
|
||||
**Lime**, the near-black, lime-accent chat surface prototyped in
|
||||
`docs/design/zero_tui_lime.html` (commit the prototype there first; it is the
|
||||
visual source of truth). The prototype is a web mock — this spec is the
|
||||
authoritative terminal translation of it. Behavior must not regress: every
|
||||
slash command, prompt flow, overlay, and safety property that works today
|
||||
must work after.
|
||||
|
||||
This is a fresh clone: file names below were verified against a recent
|
||||
snapshot, but ALWAYS recon before editing. If a path or symbol has moved,
|
||||
adapt — do not blindly apply.
|
||||
|
||||
**Gate (run after EVERY stage, commit + tag before continuing):**
|
||||
```
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
```
|
||||
Stage 4 additionally runs `go run ./cmd/zero-release smoke`. Never tag a red
|
||||
tree. AGENTS.MD applies: Go-native commands only.
|
||||
|
||||
---
|
||||
|
||||
## Design spec — Lime
|
||||
|
||||
### Palette — `internal/tui/theme.go` becomes the ONLY place colors exist
|
||||
| Token | Hex | Use |
|
||||
|------------|-----------|-----|
|
||||
| bg | `#070708` | canvas (terminal default bg; do not paint full-bleed) |
|
||||
| panel | `#0e0e10` | card backgrounds (tool/diff/read/grep/sessions rows) |
|
||||
| panel2 | `#121215` | card header rows, picker rows |
|
||||
| panel3 | `#17171b` | selected/hovered row bg |
|
||||
| line | `#242429` | default borders, rules, status separators |
|
||||
| line2 | `#2e2e34` | emphasized borders |
|
||||
| ink | `#ececee` | primary text |
|
||||
| muted | `#8b8b93` | secondary text, assistant interim prose |
|
||||
| faint | `#5b5b63` | hints, metadata |
|
||||
| faintest | `#3a3a40` | line numbers, separators, tool args |
|
||||
| accent | `#caff3f` | brand lime: prompts, badge, spinner, focus, final rail |
|
||||
| green | `#5dd1a4` | success, diff add sign, ✓ |
|
||||
| red | `#ff7a7a` | errors, diff del sign, ✗, deny |
|
||||
| amber | `#ffc25c` | permission surfaces, warnings, auto badge |
|
||||
| blue | `#7db4ff` | grep file locations, local-model dot |
|
||||
| addBg | `#15201d` | diff added-line bg (solid stand-in for green @9% over panel) |
|
||||
| delBg | `#241819` | diff deleted-line bg (solid stand-in for red @9%) |
|
||||
| permBg | `#1c1915` | permission card bg (amber @6% over panel) |
|
||||
| selBg | `#1d2114` | selected picker/suggestion row (accent @8%) |
|
||||
| addInk | `#bdeed7` | added-line text |
|
||||
| delInk | `#f2c4c4` | deleted-line text |
|
||||
| onAccent | `#000000` | text on accent or amber fills |
|
||||
|
||||
Terminals cannot alpha-blend or glow — the four solid tints above ARE the
|
||||
translation of the prototype's rgba/glow effects; do not attempt gradients,
|
||||
shadows, or blur. Truecolor hex throughout; lipgloss downsamples on limited
|
||||
terminals (keep the existing explanatory comment style in theme.go).
|
||||
|
||||
Keep the `tuiTheme` single-value pattern but rebuild the field set around
|
||||
these tokens (style per role: badge, userPrompt, sayText, finalRail, toolName,
|
||||
toolTarget, toolArg, autoTag, statusOk/Err, diffAdd/Del/Meta, permBadge,
|
||||
permRisk, grepLoc, bashPrompt, stGroup, etc.). No hex outside theme.go.
|
||||
|
||||
### Layout — single-column chat surface (matches the existing architecture)
|
||||
```
|
||||
zero / ~/dev/zero anthropic/claude-sonnet-4-5 · 200K
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
❯ add a --version flag to the cli (user)
|
||||
I'll add a --version flag…▌ (interim, muted)
|
||||
╭ grep internal/cli flag\.|RegisterFlag ⠋ ──────────╮
|
||||
│ internal/cli/root.go:41 fs := flag.NewFlagSet("zero"… │
|
||||
╰ 3 matches ───────────────────────────────────────────────────────╯
|
||||
╭ edit internal/cli/root.go auto ✓ ────────────╮
|
||||
│ internal/cli/root.go new file +6 −1 │
|
||||
│ 41 │ fs.BoolVar(&o.Version, "version", false, … (addBg) │
|
||||
╰──────────────────────────────────────────────────────────────────╯
|
||||
│ Done — the CLI now prints its version. (final: accent rail)
|
||||
● done · 2 tools · 8.4s (done line)
|
||||
|
||||
❯ describe a task for zero… (composer)
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
● anthropic │ claude-sonnet-4-5 │ │ 12.4K tok │ interactive │ ⏵⏵ auto
|
||||
```
|
||||
Render order stays: title line → rule → transcript → composer → rule →
|
||||
status line. NO alt-screen change, NO panes, NO mouse — keep `run.go`
|
||||
exactly as-is including its no-mouse-capture comment.
|
||||
|
||||
### Title bar (replaces `headerBar`)
|
||||
Left: badge — `lipgloss` style bg=accent fg=onAccent bold, content ` 0 ` —
|
||||
then `zero` (ink, bold, lowercase), ` / ` (faintest), cwd via existing
|
||||
`shortenPath` (muted), git branch (faint) when present. Right:
|
||||
`provider/model` (ink) + ` · ctx` (faint) where ctx comes from
|
||||
`modelregistry.ContextLimits.ContextWindow` formatted like `200K`. Reuse the
|
||||
existing width-candidate fallback pattern (`startupHeaderLine`) so segments
|
||||
drop gracefully: full → drop ctx → drop cwd → badge+model only. Below it,
|
||||
one rule line in `line`.
|
||||
|
||||
### Empty state (DELETE the splash entirely)
|
||||
Remove `startupView`, `startupHeader`, `commandChips`, the figlet
|
||||
`zeroLogoLines`, gap math, and the `showSplash` field/branch. The app boots
|
||||
into the chat surface; when the transcript is empty, the stream area shows,
|
||||
vertically centered:
|
||||
- a 5–6 line block-art `0` in accent (derive the glyph from the `O` columns
|
||||
of the old figlet constant before deleting it),
|
||||
- tagline (muted): `a std-lib-first coding agent · bring your own key · no lock-in`,
|
||||
- hint (faint): `running zero against <provider/model>` with the model in ink,
|
||||
- three suggestion chips, one per line: `❯` (accent bold) + suggestion (ink)
|
||||
inside a `line`-bordered rounded box; pressing `1`–`3` while the composer
|
||||
is empty inserts that suggestion. Define suggestions as a string slice in
|
||||
the tui package (e.g. add a --version flag / explain internal/agent/loop.go /
|
||||
fix the failing test in internal/tools).
|
||||
|
||||
### Stream blocks (restyle `transcript.go` / `rendering.go` rows)
|
||||
- **user** (`rowUser`): `❯ ` accent bold + ink text. Blank line above turns.
|
||||
- **assistant interim** (`rowAssistant` while streaming): muted text, wrapped
|
||||
to `min(width-4, 74)` columns, trailing `▌` cursor in accent while the
|
||||
turn is pending (drives off existing pending state — no new ticker).
|
||||
- **final answer** (last assistant row of a turn): a `│` accent rail gutter
|
||||
on every wrapped line + ink text, same 74-col measure. If distinguishing
|
||||
interim vs final is not already possible from row data, mark the row at
|
||||
append time — do not re-parse text.
|
||||
- **done line** (end of a turn): `●` green (red on error) + faint
|
||||
`done · N tools · Xs` with faintest `·` separators, derived from data the
|
||||
model already has (tool rows this turn + existing usage/timing if present;
|
||||
omit segments that don't exist rather than inventing counters).
|
||||
- **system / error notes** (`rowSystem`/`rowError` equivalents): one-line
|
||||
bordered note — system: faint on panel with `line` border; denial/error:
|
||||
red text, red-tinted border. Keep content unchanged.
|
||||
|
||||
### Tool cards (replaces `titledCard`/tool rows for `rowToolCall`+`rowToolResult`)
|
||||
Rounded border card on panel bg. Head row on panel2 with a bottom rule:
|
||||
status glyph + tool name (ink bold) + target (muted, middle-truncated) +
|
||||
one-line arg via the existing `argHint` (faintest, right side) + optional
|
||||
`auto` tag (amber text in amber-bordered mini box) when the call was
|
||||
auto-approved by mode or a stored grant. Status glyph: running =
|
||||
`bubbles/spinner` MiniDot in accent · ok = `✓` green · error = `✗` red.
|
||||
Border tint follows state: line → accent-ish `#5a6b2e` while running →
|
||||
red-mix on error (define both as theme tokens). Bodies by result shape,
|
||||
reusing the existing detection where present:
|
||||
- **diff** (`looksLikeDiff`): head row = path (ink) + `NEW FILE` tag (green
|
||||
on addBg) when applicable + right-aligned `+N` green / `−N` red counts;
|
||||
body lines = 4-col right-aligned line number (faintest) + sign col
|
||||
(green/red) + text — added rows on addBg in addInk, deleted on delBg in
|
||||
delInk, context muted. Keep the 16-line cap + `… N more lines` footer.
|
||||
- **read**: same gutter, muted text, head shows path + `Lstart–Lend` (faint).
|
||||
- **bash**: cmd row `❯` accent bold + command (ink) above a rule; output
|
||||
muted (stderr-ish in delInk), indented 2; footer `exit 0` green /
|
||||
`exit N` red when an exit code is in the result.
|
||||
- **grep**: rows `file:line` (blue) + text (muted, truncated); footer faint
|
||||
`N matches`.
|
||||
- Anything else: current generic output rendering, restyled to muted on panel.
|
||||
|
||||
### Permission card (restyle the existing `pendingPermission` prompt — keys unchanged)
|
||||
Card with amber-mixed border on permBg. Top row: `PERMISSION` badge (onAccent
|
||||
on amber, bold) + `risk: <low|medium|high>` (amber) from the request's
|
||||
existing risk data. Body: `<tool>` (amber bold) + target (ink) + reason
|
||||
(muted). Action row of bordered key-chips: `[a] allow once` (onAccent on
|
||||
accent), `[y] always` (ink, accent border), `[d] deny` (ink, red on hover is
|
||||
web-only — just red-bordered), `[esc]` hint faint. After resolution collapse
|
||||
to one faint line: `allowed once · <tool>` green / `always · <tool>` green /
|
||||
`denied · <tool>` red. Ask-user and spec-review prompts get the same card
|
||||
language with `line` borders. Do NOT alter `handlePermissionKey`,
|
||||
`nextPermissionMode`, or grant-store semantics.
|
||||
|
||||
### Composer + status line
|
||||
Composer: `❯` accent bold + the existing `bubbles/textinput`, borderless
|
||||
(remove the bordered input block), placeholder `describe a task for zero…`,
|
||||
switching to `running… ctrl+c to interrupt` while pending; right-aligned
|
||||
faint hint `run ↵` (idle, only when input is non-empty) / `esc stop`
|
||||
(pending). Rules above composer and above status line in `line`.
|
||||
Status line groups separated by ` │ ` (line color): `●` accent + provider ·
|
||||
model id · flexible gap · `<X.XK> tok` (existing usage segment data,
|
||||
keep cost when priced) · `interactive` · permission mode (reuse
|
||||
`modeLabel()`: `⏵⏵ auto-approve` green / `ask` amber / `unsafe` red) ·
|
||||
optional green `always: <n> grants` when the sandbox grant store exposes a
|
||||
cheap count (omit otherwise — do not add plumbing).
|
||||
|
||||
### Overlays (restyle in place, identical key handling)
|
||||
- Autocomplete: rows on panel, selected row on selBg with accent `❯` marker,
|
||||
name ink / desc faint.
|
||||
- Picker (`/model`, `/provider`, etc.): bordered panel; rows = provider dot
|
||||
(accent remote, blue local) + id (ink) + right meta `ctx · KEY_ENV`
|
||||
(faint/faintest) when the registry/provider catalog already exposes them;
|
||||
selected row selBg. Title row + `↑/↓ · ⏎ · esc` hints faint.
|
||||
- Sessions: `/resume` (alias `/sessions`) keeps its existing flow; restyle
|
||||
its list as stacked cards — id (accent) + age (faint) top row, title (ink),
|
||||
meta line (faint with faintest dots). No new persistence behavior.
|
||||
|
||||
### Explicitly OUT of scope
|
||||
The prototype's `text/json` toggle visualizes the headless
|
||||
`--output-format stream-json` CLI mode (see docs/STREAM_JSON_PROTOCOL.md) —
|
||||
not a TUI feature. The TweaksPanel in the HTML is a design harness — ignore
|
||||
it. No mouse support, no model dropdown (keyboard picker is the translation).
|
||||
|
||||
---
|
||||
|
||||
## Adaptive requirements (treat as acceptance criteria)
|
||||
- Width tiers, re-evaluated on every `WindowSizeMsg`:
|
||||
**≥100** full spec · **80–99** drop tool-arg column, header ctx, status
|
||||
`interactive` group · **60–79** drop diff/read line-number gutters, badge
|
||||
renders as `0` without padding, status keeps provider+tokens+mode only ·
|
||||
**<58** (current `minStartupWidth`) single-segment header, cards lose
|
||||
side borders (keep top/bottom rules), composer + mode line only.
|
||||
- Text measure: say/final wrap at `min(width-4, 74)`; paths middle-truncate
|
||||
(`internal/…/root.go`); never emit a styled line wider than the terminal
|
||||
(extend the existing `fitStyledLine`/`truncateRunes` helpers — reuse, don't
|
||||
fork).
|
||||
- Height: when the terminal is shorter than the frame, transcript trims from
|
||||
the top (existing inline behavior) — verify no overlap of composer/status.
|
||||
- Color: nothing may rely on truecolor to be legible — check every pairing
|
||||
at 256 colors mentally; the four tint tokens must remain darker than ink.
|
||||
- Tests for the tier logic: table-driven across widths {58, 70, 80, 100, 120}
|
||||
asserting which segments render, using the existing ANSI-stripping helpers.
|
||||
|
||||
---
|
||||
|
||||
## Stages
|
||||
|
||||
### Stage 0 — recon + delete the `zeroline` design system *(tag: `tui-lime-s0`)*
|
||||
Recon first: `grep -rni zeroline --include='*.go' .` and read
|
||||
`internal/tui/options.go`, `internal/cli/app.go`. Then remove completely:
|
||||
`internal/zeroline/` (whole package + tests), `internal/tui/zeroline_view.go`
|
||||
(+ test), `internal/cli/zeroline.go`, the `case "zeroline":` dispatch and the
|
||||
zeroline help-text line in `internal/cli/app.go`, the skin parameter on
|
||||
`runInteractiveTUIWithSkin` (rename to `runInteractiveTUI`), the `Skin`,
|
||||
`ThemeVariant`, `ThemeDark` fields in `tui.Options`, the model's `skin`
|
||||
field and every `m.skin == "zeroline"` branch (View dispatch, `/theme`
|
||||
special-case, update paths, `picker.go`, `image_attach.go`). Update or
|
||||
delete affected tests; the zeroline grep must return zero hits. Gate, tag.
|
||||
|
||||
### Stage 1 — Lime palette on the existing layout *(tag: `tui-lime-s1`)*
|
||||
Rewrite `theme.go` to the token table. Restyle every current render site
|
||||
(header, transcript rows, titledCard/diffCard, overlays, status line,
|
||||
prompts) to the new tokens WITHOUT changing structure yet — this isolates
|
||||
the string-assertion churn in `model_test.go` / `session_test.go` /
|
||||
`command_polish_test.go` from the layout work. Prefer asserting plain
|
||||
content via the ANSI-stripping helpers over styled bytes. Gate, tag.
|
||||
|
||||
### Stage 2 — chat-surface components *(tag: `tui-lime-s2`)*
|
||||
Title bar + rule, empty state replacing the splash (splash code deleted),
|
||||
stream blocks (user/interim/final/done/notes), tool cards with all four
|
||||
result bodies, MiniDot spinner, composer + status line redesign. Add
|
||||
rendering tests per block type with synthetic rows. Gate, tag.
|
||||
|
||||
### Stage 3 — interactive surfaces *(tag: `tui-lime-s3`)*
|
||||
Permission card + resolved collapse + auto tag, ask-user and spec-review
|
||||
cards, autocomplete/picker restyle with model-row metadata, `/resume`
|
||||
sessions card list, suggestion-chip `1–3` insertion. Key handling and
|
||||
semantics byte-identical to before — assert that in tests where they
|
||||
already exist. Gate, tag.
|
||||
|
||||
### Stage 4 — adaptive polish + docs *(tag: `tui-lime-s4`)*
|
||||
Implement + test the width tiers, sweep remaining render sites (spec mode,
|
||||
command center, doctor output if styled) for stray old-palette styles,
|
||||
update `internal/cli/app.go` help text and README's TUI section, manual run
|
||||
`go run ./cmd/zero` at 58/80/100/120 cols (no wrapped rules, no orphan ANSI,
|
||||
prompts render correctly). Full gate + `go run ./cmd/zero-release smoke`.
|
||||
Gate, tag.
|
||||
|
||||
---
|
||||
|
||||
## Hard constraints
|
||||
- No new module dependencies; charmbracelet stack only. No mouse capture.
|
||||
- Zero behavior change to permissions, grants, ask-user, spec review,
|
||||
sessions, slash commands, autocomplete, image attach, compaction, rewind.
|
||||
- All colors in `theme.go`; renderers consume named styles only.
|
||||
- Reuse existing helpers (`fitStyledLine`, `truncateRunes`, `argHint`,
|
||||
`shortenPath`, header candidates) instead of writing parallel ones.
|
||||
- Comment style matches the repo: explain WHY at non-obvious decisions.
|
||||
|
||||
## Definition of done
|
||||
One design in the tree (no zeroline references, no splash, no cyan tokens),
|
||||
the Lime chat surface matching this spec at ≥100 cols and degrading per the
|
||||
width tiers below that, every pre-existing flow keyboard-reachable and
|
||||
visually consistent, `go test ./...` + release build + smoke fully green.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,222 +0,0 @@
|
||||
# OAuth logins & using ChatGPT / Claude subscriptions
|
||||
|
||||
Zero supports two distinct things people mean by "log in with OAuth":
|
||||
|
||||
1. **OAuth login for a provider/gateway** that issues a *standard* bearer token —
|
||||
fully built in (`zero auth login …`). Zero attaches the token to model calls
|
||||
automatically.
|
||||
2. **Using a ChatGPT or Claude *subscription*** (Plus / Pro / Max) instead of a
|
||||
pay-per-token API key — only possible through a **local proxy**, for the
|
||||
reasons documented below. Zero ships a convenience preset and this recipe.
|
||||
|
||||
---
|
||||
|
||||
## 1. OAuth login for a provider or gateway (built in)
|
||||
|
||||
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
|
||||
`Authorization: Bearer …` on its API, configure it with `ZERO_OAUTH_<NAME>_*`
|
||||
env vars and log in:
|
||||
|
||||
```sh
|
||||
export ZERO_OAUTH_ACME_CLIENT_ID=…
|
||||
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
|
||||
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
|
||||
export ZERO_OAUTH_ACME_SCOPES="openid profile"
|
||||
zero auth login acme # browser (loopback); --device for headless
|
||||
zero auth status
|
||||
```
|
||||
|
||||
When a login exists for a provider, the **OpenAI and Anthropic** providers send
|
||||
`Authorization: Bearer <fresh-token>` (auto-refreshed; one refresh-and-retry on a
|
||||
`401`) instead of the API key. With no login they use the API key exactly as
|
||||
before. Tokens are stored 0600 (or the OS keyring with
|
||||
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.
|
||||
|
||||
### In the setup wizard (`/provider`)
|
||||
|
||||
Running `/provider` opens a **"How do you want to connect?"** chooser:
|
||||
|
||||
```text
|
||||
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI, ChatGPT, Hugging Face)
|
||||
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
|
||||
```
|
||||
|
||||
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
|
||||
|
||||
```text
|
||||
❯ OpenRouter browser sign-in · creates a key
|
||||
xAI (Grok) browser or device code
|
||||
ChatGPT browser (Codex backend, ChatGPT Plus/Pro)
|
||||
Hugging Face browser or device code
|
||||
```
|
||||
|
||||
- **OpenRouter / xAI / ChatGPT / Hugging Face** are real OAuth: your browser
|
||||
opens to approve → done (no key to paste). OpenRouter mints a key; xAI /
|
||||
ChatGPT / Hugging Face store a refreshable bearer. Hugging Face requires a
|
||||
one-time OAuth-app registration (no secret needed for "public" apps); the
|
||||
preset pre-fills scopes, endpoints, and the OIDC issuer. The same chooser
|
||||
appears in first-run onboarding. (xAI uses an opt-in preset — set
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
|
||||
- **Device code (headless / SSH):** for a provider that supports it (xAI,
|
||||
Hugging Face), press **d** on the list to get a code to enter on another
|
||||
device instead of opening a browser. On an SSH session or headless Linux box
|
||||
(no `DISPLAY`) device code is used automatically; set `ZERO_OAUTH_DEVICE=1`
|
||||
to force it anywhere. The CLI equivalent is
|
||||
`zero auth login <name> --device`.
|
||||
- **ChatGPT / Claude are intentionally not in this list for the proxy path** —
|
||||
use the dedicated `chatgpt-proxy` / `custom-anthropic-compatible` preset
|
||||
(see §2) for subscription-via-proxy. ChatGPT *is* a first-class OAuth
|
||||
provider in this version (routes to the Codex backend) — see "Built-in OAuth
|
||||
providers" below.
|
||||
|
||||
### Built-in OAuth providers
|
||||
|
||||
- **OpenRouter (no env needed)** — `zero auth openrouter` opens a browser, you
|
||||
approve, and it **mints an OpenRouter API key** (public PKCE flow, no client_id).
|
||||
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
|
||||
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
|
||||
the provider profile and used normally.
|
||||
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
|
||||
ships a built-in preset for the public Grok-CLI client, but to keep third-party
|
||||
client identities out of the default credential path it is **off by default**.
|
||||
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
|
||||
(browser, or `--device` for headless) works one-click; the token is used directly
|
||||
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
|
||||
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
|
||||
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
|
||||
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
|
||||
Grok-CLI client that may change without notice.
|
||||
- **ChatGPT (Codex) — opt-in preset** — `zero auth chatgpt` opens a browser, you
|
||||
approve with your ChatGPT Plus/Pro/Business/Enterprise account, and the bearer is
|
||||
stored. The bearer routes to `https://chatgpt.com/backend-api/codex/responses`
|
||||
(the same endpoint the openai/codex CLI uses), with `originator: codex_cli_rs` and
|
||||
the `chatgpt-account-id` claim injected as headers on every request. The
|
||||
`chatgpt-account-id` is extracted from the OIDC ID token and stored alongside the
|
||||
bearer; if the claim is missing (older ChatGPT accounts, or a rotated
|
||||
authorization server), the Codex backend will 401 and `zero auth status chatgpt`
|
||||
will show the warning. Like xAI, the preset uses the publicly-shipped Codex CLI
|
||||
client identity (`app_EMoamEEZ73f0CkXaXp7hrann`) and is opt-in via
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1`. As of mid-2026 the Codex backend is
|
||||
Cloudflare-gated: requests from a non-Codex client can still be challenged, and
|
||||
the `chatgpt-proxy` route in §2 is the conservative fallback.
|
||||
- **Hugging Face — opt-in preset, BYO client_id** — `zero auth login huggingface`
|
||||
(or `--device` for headless) opens a Hugging Face OAuth flow. The bearer works on
|
||||
the OpenAI-compatible router at `https://router.huggingface.co/v1` for hundreds
|
||||
of OSS models (Llama, Qwen, DeepSeek, Mistral, etc.). HF does not ship a
|
||||
globally-known client_id, so the preset ships endpoints + scopes + the OIDC
|
||||
issuer pre-filled; you must register a "public" OAuth app (no secret) at
|
||||
<https://huggingface.co/settings/applications/new> and set the resulting
|
||||
`client_id` via `ZERO_OAUTH_HUGGINGFACE_CLIENT_ID`. Enable the preset with
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` (or omit it — the BYO client_id path uses
|
||||
`client_credentials = none` and doesn't need the opt-in). Free tier has strict
|
||||
rate limits; Pro removes them.
|
||||
|
||||
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
|
||||
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
|
||||
`zero auth login <name>`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ChatGPT / Claude subscriptions — why a proxy is required
|
||||
|
||||
We researched this carefully. As of mid-2026, a **subscription** OAuth token does
|
||||
**not** work as a drop-in bearer against the standard APIs:
|
||||
|
||||
- **OpenAI (ChatGPT):** a "Sign in with ChatGPT" token only works against
|
||||
ChatGPT's own backend (`chatgpt.com/backend-api/codex/responses`, the Responses
|
||||
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
|
||||
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
|
||||
requires mimicking the official Codex client (originator + account-id header).
|
||||
**First-class path (this version):** `zero auth chatgpt` does exactly that
|
||||
mimicking (`originator: codex_cli_rs`, `chatgpt-account-id: <claim>`) and
|
||||
routes requests to the Codex backend, no proxy required — see §1. The
|
||||
`chatgpt-proxy` route below is the conservative fallback when Cloudflare
|
||||
challenges become an issue, and is the only path that works without a
|
||||
browser-based ChatGPT OAuth login.
|
||||
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
|
||||
for third-party use unless the request spoofs the Claude Code identity
|
||||
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
|
||||
*"You are Claude Code…"* system prompt) — and **even then** tool-using requests
|
||||
on Max plans are routed to a disabled billing lane and `400`. Anthropic's policy
|
||||
**prohibits** subscription-token use outside Claude Code / claude.ai, and the
|
||||
timeline hardened through 2026: a **Feb 19 2026** docs update spelled out that
|
||||
Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK,
|
||||
then on **April 4 2026** enforcement landed and subscription OAuth tokens
|
||||
**stopped working in third-party harnesses** (starting with OpenClaw, then the
|
||||
rest). As of mid-2026 the only supported ways to drive Claude from a third-party
|
||||
tool are a standard **API key** or pay-as-you-go **"Extra Usage"** billing —
|
||||
both per-token, not the flat subscription. The request to allow subscription use
|
||||
(claude-code #37205) was closed *"not planned."*
|
||||
|
||||
So Zero does **not** call those backends directly or spoof those clients — that
|
||||
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
|
||||
The robust, supported pattern is a **local proxy** that holds your subscription
|
||||
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
|
||||
`127.0.0.1`. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
|
||||
just points at it.
|
||||
|
||||
### ChatGPT via a local proxy
|
||||
|
||||
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
|
||||
typically listen on `127.0.0.1:10531/v1`). Then use the built-in **`chatgpt-proxy`**
|
||||
preset (no API key — the proxy authenticates):
|
||||
|
||||
```jsonc
|
||||
// ~/.config/zero/config.json (or ./.zero/config.json)
|
||||
{
|
||||
"activeProvider": "chatgpt",
|
||||
"providers": [
|
||||
{
|
||||
"name": "chatgpt",
|
||||
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
|
||||
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
|
||||
"model": "gpt-5.5" // whatever model your proxy serves
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
|
||||
```
|
||||
|
||||
### Claude via a local proxy
|
||||
|
||||
There is no single canonical Claude OAuth-proxy port, so use the generic
|
||||
**`custom-anthropic-compatible`** entry pointed at your proxy's Anthropic-compatible
|
||||
endpoint:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"activeProvider": "claude",
|
||||
"providers": [
|
||||
{
|
||||
"name": "claude",
|
||||
"catalogID": "custom-anthropic-compatible",
|
||||
"baseURL": "http://localhost:<port>", // your Claude proxy
|
||||
"apiKey": "unused-by-proxy",
|
||||
"model": "claude-sonnet-4.5"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Supported alternatives (no proxy)
|
||||
|
||||
- **API key (recommended, simplest):** set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
|
||||
(or per-profile `apiKey`) and use the `openai` / `anthropic` catalog providers.
|
||||
Bills as API usage.
|
||||
- **Anthropic subscription automation, sanctioned:** spawn the real `claude` CLI
|
||||
(e.g. `claude -p …`) as a subprocess — the only path Anthropic recognizes as a
|
||||
first-class subscription session.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `chatgpt-proxy` base URL / port and model are defaults you override for your
|
||||
setup; they are not an endorsement of any specific proxy implementation.
|
||||
- Subscription-via-proxy depends on third-party tools and undocumented vendor
|
||||
backends; it can break without notice. The API-key path is the stable one.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,304 @@
|
||||
# Agent Eval Benchmark Harness Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a local, offline-testable benchmark harness that copies eval fixtures, runs an agent command per task, scores the resulting workspace, and reports aggregate results.
|
||||
|
||||
**Architecture:** Keep `internal/agenteval.Runner` as the scoring primitive. Add a `Harness` layer that materializes fixture workspaces into a work directory, initializes a clean Git baseline, invokes an injectable `AgentRunner`, then calls `Runner.Run`. The CLI exposes this as `zero eval bench`, while `zero eval run` remains the lower-level scorer for an already-mutated workspace.
|
||||
|
||||
**Tech Stack:** Go 1.24 standard library, existing `internal/agenteval`, existing `internal/cli`, Git CLI for workspace baselines.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `internal/agenteval/materialize.go`: fixture path resolution, directory copy, Git init/baseline helpers.
|
||||
- Create `internal/agenteval/materialize_test.go`: fixture copy and Git baseline tests.
|
||||
- Create `internal/agenteval/agent_command.go`: command-template based agent runner with `{prompt}`, `{workspace}`, `{task_id}` placeholders.
|
||||
- Create `internal/agenteval/agent_command_test.go`: placeholder substitution, cwd, exit/error behavior.
|
||||
- Create `internal/agenteval/benchmark.go`: benchmark orchestration and aggregate report.
|
||||
- Create `internal/agenteval/benchmark_test.go`: end-to-end harness with fake agent runner and sample fixtures.
|
||||
- Modify `internal/cli/agent_eval.go`: parse `zero eval bench` and wire `Harness`.
|
||||
- Modify `internal/cli/agent_eval_test.go`: bench parsing, JSON/text output, report writing.
|
||||
- Modify `docs/AGENT_EVALS.md`: document `bench` mode and real local command examples.
|
||||
|
||||
## Task 1: Fixture Materializer
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/materialize.go`
|
||||
- Create: `internal/agenteval/materialize_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestMaterializeTaskCopiesFixtureAndInitializesGit(t *testing.T) {
|
||||
suitePath := filepath.Join("testdata", "sample_suite.json")
|
||||
suite, err := LoadSuite(suitePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := suite.Tasks[0]
|
||||
workRoot := t.TempDir()
|
||||
|
||||
workspace, err := Materializer{}.MaterializeTask(context.Background(), suitePath, task, MaterializeInput{WorkRoot: workRoot})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeTask: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workspace.Path, "go.mod")); err != nil {
|
||||
t.Fatalf("fixture was not copied: %v", err)
|
||||
}
|
||||
if output, err := exec.Command("git", "-C", workspace.Path, "status", "--porcelain").CombinedOutput(); err != nil || strings.TrimSpace(string(output)) != "" {
|
||||
t.Fatalf("workspace baseline is dirty: err=%v output=%s", err, output)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestMaterializeTask -count=1`
|
||||
Expected: FAIL because `Materializer` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement minimal materializer**
|
||||
|
||||
Create a `Materializer` with:
|
||||
- `MaterializeTask(ctx, suitePath string, task Task, input MaterializeInput) (Workspace, error)`
|
||||
- `MaterializeInput{WorkRoot string}`
|
||||
- `Workspace{Path string, TaskID string, FixturePath string}`
|
||||
- Resolve relative `task.WorkspaceFixture` from `filepath.Dir(suitePath)`.
|
||||
- Copy directories recursively using stdlib only.
|
||||
- Skip `.git` while copying.
|
||||
- Run `git init`, `git add .`, and `git commit -m "baseline"` with local user config/env.
|
||||
- Return clear errors for missing fixture, absolute fixture escapes, and empty work root.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go test ./internal/agenteval -run 'TestMaterializeTask|TestMaterializer' -count=1
|
||||
```
|
||||
|
||||
## Task 2: Agent Command Runner
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/agent_command.go`
|
||||
- Create: `internal/agenteval/agent_command_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestCommandAgentRunnerExpandsPlaceholders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "agent.bat")
|
||||
if err := os.WriteFile(script, []byte("@echo %1|%2|%3> out.txt\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := CommandAgentRunner{Command: []string{script, "{task_id}", "{workspace}", "{prompt}"}}
|
||||
result := runner.Run(context.Background(), AgentRunInput{TaskID: "task-a", WorkspacePath: dir, Prompt: "fix bug"})
|
||||
if result.ExitCode != 0 || result.Error != "" {
|
||||
t.Fatalf("Run = %#v", result)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "out.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "task-a") || !strings.Contains(string(data), dir) || !strings.Contains(string(data), "fix bug") {
|
||||
t.Fatalf("placeholders not expanded: %q", data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
|
||||
Expected: FAIL because `CommandAgentRunner` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement runner**
|
||||
|
||||
Add:
|
||||
- `type AgentRunInput struct { TaskID, Prompt, WorkspacePath string }`
|
||||
- `type AgentRunResult struct { ExitCode int; Stdout, Stderr, Error string }`
|
||||
- `type AgentRunner interface { Run(context.Context, AgentRunInput) AgentRunResult }`
|
||||
- `type CommandAgentRunner struct { Command []string }`
|
||||
- `Run` executes without shell interpolation, with `cmd.Dir = WorkspacePath`.
|
||||
- Replace `{prompt}`, `{workspace}`, `{task_id}` in every arg.
|
||||
- Empty command returns `ExitCode:-1` and `Error:"agent command is required"`.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
|
||||
|
||||
## Task 3: Benchmark Harness
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/benchmark.go`
|
||||
- Create: `internal/agenteval/benchmark_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestHarnessRunsTaskFromFixtureAndScoresResult(t *testing.T) {
|
||||
suitePath := filepath.Join("testdata", "sample_suite.json")
|
||||
suite, err := LoadSuite(suitePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := Harness{
|
||||
Materializer: Materializer{},
|
||||
Agent: agentRunnerFunc(func(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
target := filepath.Join(input.WorkspacePath, "docs", "STREAM_JSON_PROTOCOL.md")
|
||||
err := os.WriteFile(target, []byte("updated"), 0o644)
|
||||
if err != nil {
|
||||
return AgentRunResult{ExitCode: -1, Error: err.Error()}
|
||||
}
|
||||
return AgentRunResult{ExitCode: 0}
|
||||
}),
|
||||
Runner: Runner{},
|
||||
}
|
||||
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
|
||||
TaskID: "document-stream-json-verify-events",
|
||||
WorkRoot: t.TempDir(),
|
||||
})
|
||||
if report.Summary.TotalTasks != 1 || report.Summary.PassedTasks != 1 || !report.OK {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestHarness -count=1`
|
||||
Expected: FAIL because `Harness` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement harness**
|
||||
|
||||
Add:
|
||||
- `BenchmarkInput{TaskID, WorkRoot string; KeepWorkspaces bool}`
|
||||
- `BenchmarkReport{Contract, SuiteID, OK, Summary, Tasks}`
|
||||
- `BenchmarkTaskReport{TaskID, WorkspacePath, FixturePath, Agent AgentRunResult, Report Report}`
|
||||
- `BenchmarkSummary{TotalTasks, PassedTasks, FailedTasks, BlockedTasks, ErrorTasks int}`
|
||||
- `Harness{Materializer Materializer; Agent AgentRunner; Runner Runner}`
|
||||
- If no `Agent`, produce blocked task reports with message `agent command is required`.
|
||||
- Select one task when `TaskID` set, otherwise all tasks.
|
||||
- For each task: materialize, run agent, if agent fails mark blocked; otherwise score with `Runner.Run`.
|
||||
- Remove each materialized task workspace after scoring unless `BenchmarkInput.KeepWorkspaces` is true.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/agenteval -run 'TestHarness|TestBenchmark' -count=1`
|
||||
|
||||
## Task 4: CLI `zero eval bench`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/cli/agent_eval.go`
|
||||
- Modify: `internal/cli/agent_eval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestRunEvalBenchJSONModePassesHarnessOptions(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"eval", "bench",
|
||||
"--suite", "evals/context.json",
|
||||
"--task", "edit-reader",
|
||||
"--work-root", "D:\\tmp\\zero-evals",
|
||||
"--agent-command", "zero", "exec", "{prompt}",
|
||||
"--json",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
runAgentEval: func(ctx context.Context, options agentEvalOptions) (agentEvalReport, error) {
|
||||
if options.Mode != "bench" || options.TaskID != "edit-reader" || options.WorkRoot == "" || len(options.AgentCommand) != 3 {
|
||||
t.Fatalf("unexpected bench options: %#v", options)
|
||||
}
|
||||
return agentEvalReport{Suite: "quality-context", Status: "pass", OK: true, Total: 1, Passed: 1}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/cli -run TestRunEvalBench -count=1`
|
||||
Expected: FAIL because bench mode is not parsed.
|
||||
|
||||
- [ ] **Step 3: Implement CLI**
|
||||
|
||||
Add parser support:
|
||||
- Modes: `validate`, `run`, `bench`
|
||||
- Bench flags: `--work-root <path>`, `--agent-command <argv...>`, `--keep-workspaces`
|
||||
- `--task`, `--report-dir`, `--json` work in bench mode.
|
||||
- `--workspace` remains run-only.
|
||||
- Default work root for bench: temp dir with prefix `zero-eval-`.
|
||||
- Default `AgentCommand` empty -> harness report blocked rather than usage error.
|
||||
- Convert benchmark aggregate to existing CLI report shape.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go test ./internal/cli -run 'TestRunEvalBench|TestRunEvalRun' -count=1
|
||||
```
|
||||
|
||||
## Task 5: Docs and Manual Examples
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/AGENT_EVALS.md`
|
||||
|
||||
- [ ] **Step 1: Document modes**
|
||||
|
||||
Add a `bench` subsection:
|
||||
- `validate`: schema only
|
||||
- `run`: score an already-mutated worktree
|
||||
- `bench`: copy fixture, run agent command, score result
|
||||
|
||||
- [ ] **Step 2: Include examples**
|
||||
|
||||
Document:
|
||||
```bash
|
||||
go run ./cmd/zero eval bench \
|
||||
--suite internal/agenteval/testdata/sample_suite.json \
|
||||
--task document-stream-json-verify-events \
|
||||
--work-root /tmp/zero-evals \
|
||||
--agent-command zero exec --cwd {workspace} {prompt}
|
||||
```
|
||||
|
||||
Also document a deterministic fake-agent example for local testing.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Task 6: Integration Validation
|
||||
|
||||
**Files:**
|
||||
- Modify as needed only in files above.
|
||||
|
||||
- [ ] **Step 1: Run focused validation**
|
||||
|
||||
```bash
|
||||
go test -count=1 ./internal/agenteval ./internal/cli
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run fixture validation**
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
from `internal/agenteval/testdata/fixtures/zero-mini`.
|
||||
|
||||
- [ ] **Step 3: Run full repo validation**
|
||||
|
||||
```bash
|
||||
gofmt -l internal/agenteval internal/cli
|
||||
git diff --check
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
@@ -0,0 +1,181 @@
|
||||
# Agent Eval D-Level Upgrades Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade the local agent eval harness from a smoke benchmark into a quality signal that can compare models, score traces, catch context mistakes, and preserve regression artifacts.
|
||||
|
||||
**Architecture:** Keep the existing `internal/agenteval` harness as the core. Add small schema fields to `Task`, score them through `Score` and `Harness.Run`, then expose model matrix and full benchmark report data through `zero eval bench`. Avoid provider calls or UI work.
|
||||
|
||||
**Tech Stack:** Go 1.24, standard library JSON/process/git helpers, existing `go test ./...` validation.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Suite Schema And Rubric Checks
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/suite.go`
|
||||
- Modify: `internal/agenteval/score.go`
|
||||
- Test: `internal/agenteval/agenteval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing schema/rubric tests**
|
||||
|
||||
Add tests that load and normalize `tags`, `difficulty`, and `forbiddenChangedFiles`, reject malformed forbidden paths, and fail scoring when a forbidden file is touched.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run "LoadSuite|Validate|Forbidden" -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement minimal schema and scoring**
|
||||
|
||||
Add these fields:
|
||||
|
||||
```go
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Difficulty string `json:"difficulty,omitempty"`
|
||||
ForbiddenChangedFiles []string `json:"forbiddenChangedFiles,omitempty"`
|
||||
```
|
||||
|
||||
Normalize file lists, validate duplicate/malformed entries, and add a `forbidden_changed_files` result.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 2: Agent Trace Scoring
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/trace.go`
|
||||
- Test: `internal/agenteval/trace_test.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
|
||||
- [ ] **Step 1: Write failing trace tests**
|
||||
|
||||
Cover JSONL events such as:
|
||||
|
||||
```json
|
||||
{"type":"tool","name":"read_file"}
|
||||
{"event":"verify","name":"go-test"}
|
||||
```
|
||||
|
||||
Require deterministic event keys like `tool:read_file` and `verify:go-test`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run Trace -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement parser and benchmark scoring**
|
||||
|
||||
Parse agent stdout line by line, ignore non-JSON noise, and append a `trace` result when a task declares `requiredTraceEvents`.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 3: Context Quality Checks
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/context_checks.go`
|
||||
- Test: `internal/agenteval/context_checks_test.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
|
||||
- [ ] **Step 1: Write failing context tests**
|
||||
|
||||
Cover a task that declares:
|
||||
|
||||
```json
|
||||
"contextChecks": {
|
||||
"requiredFiles": ["docs/STREAM_JSON_PROTOCOL.md"],
|
||||
"forbiddenFiles": ["node_modules/cache.txt"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run Context -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement fixture/workspace context checks**
|
||||
|
||||
Check required files exist under the materialized workspace and forbidden files do not. Append a `context` result before summary finalization.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 4: Model Matrix Benchmarking
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/agent_command.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
- Test: `internal/agenteval/agent_command_test.go`
|
||||
- Test: `internal/agenteval/benchmark_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing model-matrix tests**
|
||||
|
||||
Assert `{model}` expands in agent command argv and `BenchmarkInput{Models: []string{"a","b"}}` runs every task for each model.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run "Model|Harness" -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement model propagation**
|
||||
|
||||
Add `Model` to `AgentRunInput` and `BenchmarkTaskReport`; add `Models []string` to `BenchmarkInput`; loop task/model pairs deterministically.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 5: CLI Flags And Regression Artifacts
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/cli/agent_eval.go`
|
||||
- Test: `internal/cli/agent_eval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing CLI tests**
|
||||
|
||||
Cover repeated `--model`, comma-separated `--models`, help text, and report-dir JSON containing nested benchmark detail.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/cli -run Eval -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement CLI wiring**
|
||||
|
||||
Pass models into `agenteval.BenchmarkInput`, include benchmark detail in `agentEvalReport`, and prefix failure IDs with model when present.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/cli -count=1`
|
||||
|
||||
### Task 6: Suite Expansion And Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/testdata/sample_suite.json`
|
||||
- Modify: `docs/AGENT_EVALS.md`
|
||||
|
||||
- [ ] **Step 1: Write failing suite expectation**
|
||||
|
||||
Update the sample suite test to require richer coverage count and metadata fields.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run SampleSuite -count=1`
|
||||
|
||||
- [ ] **Step 3: Expand the fixture suite and docs**
|
||||
|
||||
Add more tasks using the existing `zero-mini` fixture, plus examples for `--model`, `{model}`, `requiredTraceEvents`, `contextChecks`, and report artifacts.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval ./internal/cli -count=1`
|
||||
|
||||
### Final Verification
|
||||
|
||||
- [ ] Run `gofmt -l internal/agenteval internal/cli`
|
||||
- [ ] Run `git diff --check`
|
||||
- [ ] Run `go vet ./...`
|
||||
- [ ] Run `go test ./...`
|
||||
- [ ] Run `go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json`
|
||||
- [ ] Run `go run ./cmd/zero eval bench --suite internal/agenteval/testdata/sample_suite.json --task document-stream-json-verify-events --model test-model --json --agent-command powershell -NoProfile -Command "& { param(`$ws) Set-Content -LiteralPath (Join-Path `$ws 'docs/STREAM_JSON_PROTOCOL.md') -Value updated; Write-Output '{\"type\":\"tool\",\"name\":\"read_file\"}' }" "{workspace}"`
|
||||
- [ ] Run `go run ./cmd/zero-release build`
|
||||
- [ ] Commit and push to `feat/agent-eval-harness`
|
||||
@@ -0,0 +1,175 @@
|
||||
# Additional write roots (`--add-dir` / `/add-dir`)
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Zero's sandbox confines all writes to the workspace root, with no escape hatch.
|
||||
`EnforceWorkspace` is hardcoded `true` in `DefaultPolicy()`
|
||||
(`internal/sandbox/types.go`) and `SandboxConfig` exposes only `maxAutonomy`
|
||||
and `network`. When a user asks the agent to save output outside the workspace
|
||||
(e.g. "save this to ~/Desktop/dev/untitled folder"), every layer denies it:
|
||||
|
||||
1. **Policy engine** — `Engine.Evaluate` → `validateWorkspacePaths`
|
||||
(`internal/sandbox/paths.go`) denies tool requests whose paths fall outside
|
||||
the workspace root.
|
||||
2. **macOS seatbelt** — `sandboxExecProfile` (`internal/sandbox/runner.go`)
|
||||
emits `(allow file-write* (subpath "<workspaceRoot>"))`, so shell commands
|
||||
fail with `Operation not permitted`.
|
||||
3. **Linux bubblewrap** — `bubblewrapCommandPlan` binds only the workspace
|
||||
read-write; system paths are read-only binds.
|
||||
4. **File tools** — `resolveWorkspacePath` / `recheckWorkspaceWriteTarget`
|
||||
(`internal/tools/workspace.go`) confine `write_file`, `edit`, `read_file`,
|
||||
`list_directory`, `glob`, `grep`, and the bash tool's `cwd`.
|
||||
|
||||
The agent's only recourse is workarounds (zip in workspace, ask the user to
|
||||
move it), and the denial surfaces as a raw OS error with no guidance.
|
||||
|
||||
## Goal
|
||||
|
||||
Let users grant zero write access to specific directories outside the
|
||||
workspace — at launch (flag/config) and mid-session (TUI command) — without
|
||||
weakening the sandbox elsewhere. Decided UX: **launch flag + runtime command**
|
||||
(prompt-on-denial may layer on later).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No interactive prompt-on-denial flow in this iteration.
|
||||
- No persistence of `/add-dir` grants across sessions (config is the durable
|
||||
surface).
|
||||
- No widening of network or destructive-shell policy.
|
||||
- No `enforceWorkspace: false` config knob.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Core type: `sandbox.Scope`
|
||||
|
||||
New concurrency-safe type in `internal/sandbox` (e.g. `scope.go`):
|
||||
|
||||
```go
|
||||
type Scope struct {
|
||||
mu sync.RWMutex
|
||||
workspaceRoot string // normalized at construction
|
||||
extraRoots []string // normalized, deduplicated
|
||||
}
|
||||
|
||||
func NewScope(workspaceRoot string, extras []string) (*Scope, error)
|
||||
func (s *Scope) Roots() []string // workspace first, then extras
|
||||
func (s *Scope) Add(path string) (string, error) // returns normalized root
|
||||
func (s *Scope) validate(path string) *pathViolation // package-internal
|
||||
```
|
||||
|
||||
`Add` normalization and guardrails:
|
||||
|
||||
- Expand a leading `~` to the user home directory.
|
||||
- Make absolute, `filepath.Clean`, then `filepath.EvalSymlinks`.
|
||||
- The path must exist and be a directory (no auto-create).
|
||||
- Reject the filesystem root `/` (and Windows volume roots) — granting it
|
||||
would disable confinement entirely.
|
||||
- Adding the workspace root or a path inside any existing root is a
|
||||
no-op (idempotent success, not an error).
|
||||
|
||||
`Validate` generalizes today's `validateWorkspacePath`: a path is allowed if
|
||||
it validates under **any** root, including the per-segment symlink-traversal
|
||||
check against that root. The existing single-root function remains as the
|
||||
per-root helper.
|
||||
|
||||
One `*Scope` is constructed at startup and shared by the engine, the command
|
||||
runner, and the tool registry, so a mid-session `Add` is immediately visible
|
||||
to all layers.
|
||||
|
||||
### 2. Policy engine
|
||||
|
||||
- `EngineOptions` gains `Scope *Scope`; `NewEngine` builds a workspace-only
|
||||
scope when nil (back-compat for tests/embedders).
|
||||
- `Evaluate`'s workspace check (engine.go) calls `scope.Validate` per
|
||||
requested path instead of `validateWorkspacePaths(root, request)`.
|
||||
- Risk classification (`risk.go`) treats paths inside any scope root as
|
||||
in-workspace, so writes to granted roots are not classified
|
||||
`out_of_workspace`/critical.
|
||||
- Denial reason becomes actionable:
|
||||
`"<path> is outside the workspace (use /add-dir or --add-dir to allow writes there)"`.
|
||||
|
||||
### 3. OS runners
|
||||
|
||||
- `sandboxExecProfile` emits one `(subpath "...")` per scope root inside the
|
||||
single `(allow file-write* ...)` rule.
|
||||
- `bubblewrapCommandPlan` adds `--bind <root> <root>` (read-write, real path)
|
||||
for each extra root. The workspace keeps its `/workspace` remount; extra
|
||||
roots keep their host paths so absolute paths in commands work unchanged.
|
||||
- `resolveCommandDir` accepts a command `cwd` inside any scope root.
|
||||
- Profiles are built per command plan from `scope.Roots()`, so commands run
|
||||
after `/add-dir` get the widened profile with no restart.
|
||||
|
||||
### 4. File tools
|
||||
|
||||
- New narrow interface in `internal/tools`:
|
||||
`type PathScope interface { Roots() []string }` (satisfied by
|
||||
`*sandbox.Scope`) to avoid a hard dependency direction problem.
|
||||
- `resolveWorkspacePath` and `recheckWorkspaceWriteTarget` gain scope-aware
|
||||
variants: absolute paths are resolved against the first root that contains
|
||||
them; **relative paths still resolve against the workspace root only**.
|
||||
- Tool constructors (`CoreReadOnlyTools`, `CoreWriteTools`, `CoreShellTools`,
|
||||
`CoreTools` in `registry.go`) accept the scope (workspace-only default kept
|
||||
via a convenience wrapper so existing call sites/tests keep compiling).
|
||||
- Result: `write_file`, `edit`, `read_file`, `list_directory`, `glob`, `grep`,
|
||||
and bash `cwd` all work consistently inside extra roots. Write access
|
||||
implies read access within a granted root.
|
||||
|
||||
### 5. Config, CLI, TUI
|
||||
|
||||
- **Config:** `SandboxConfig` gains
|
||||
`AdditionalWriteRoots []string \`json:"additionalWriteRoots,omitempty"\``.
|
||||
The key is honored from the **global user config**
|
||||
(`os.UserConfigDir()/zero/config.json`) so a grant can apply to every
|
||||
project, and from CLI flags/overrides. It is **deliberately excluded from
|
||||
the project config allowlist** (`mergeProjectConfig`): a repo-controlled
|
||||
`.zero/config.json` must not be able to widen write access outside the
|
||||
workspace. Sources are merged as a union (append + dedupe), not replace.
|
||||
- **CLI:** repeatable `--add-dir <path>` flag on `zero` (TUI) and `zero exec`.
|
||||
Effective set = union(flag, config), normalized through `Scope` at startup;
|
||||
an invalid root fails fast with a clear error.
|
||||
- **TUI:** new `/add-dir` command (`internal/tui/commands.go`):
|
||||
- `/add-dir` (bare) — lists current write roots.
|
||||
- `/add-dir <path>` — calls `scope.Add`; on success appends a system line:
|
||||
`write access added: <path> (this session only)`; on failure shows the
|
||||
normalization error.
|
||||
- **Observability:** `zero sandbox` status output and sandbox snapshots
|
||||
(`internal/zerocommands/sandbox_snapshots.go`) list extra roots.
|
||||
|
||||
### 6. Security invariants
|
||||
|
||||
- Extra roots only widen *file-write* (and file-tool read) access under the
|
||||
named directories; network and destructive-shell policy are untouched.
|
||||
- Symlink-traversal protection applies per root exactly as it does for the
|
||||
workspace today.
|
||||
- `/` and volume roots are rejected; roots are symlink-resolved before being
|
||||
trusted so a symlinked grant cannot silently point elsewhere later.
|
||||
- Runtime grants are session-only; durable grants require editing config.
|
||||
|
||||
### 7. Testing
|
||||
|
||||
- **Scope unit tests:** normalization (`~`, relative, symlink), rejection of
|
||||
`/`, missing dirs, files; idempotent adds; multi-root `Validate` including
|
||||
symlink-traversal escapes from one root into another denied area.
|
||||
- **Runner tests:** seatbelt profile string contains one `subpath` per root;
|
||||
bubblewrap args contain the extra `--bind`s; `resolveCommandDir` accepts
|
||||
extra-root cwd (extend `runner_test.go`).
|
||||
- **Engine tests:** out-of-workspace write allowed when inside an extra root;
|
||||
still denied (with the new actionable message) when outside all roots.
|
||||
- **Tool tests:** `write_file`/`edit`/`read_file` in an extra root succeed;
|
||||
relative paths never resolve against extra roots (extend
|
||||
`file_tools_test.go`).
|
||||
- **TUI tests:** `parseCommand` recognizes `/add-dir`; handler list/add/error
|
||||
paths.
|
||||
- **Config/CLI tests:** flag-config union, fail-fast on invalid root.
|
||||
- Platform-gated integration tests follow the existing sandbox test patterns.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `sandbox.Scope` + tests.
|
||||
2. Engine + risk + runner integration (flag-less, scope seeded empty).
|
||||
3. File-tool scope plumbing.
|
||||
4. Config key + CLI flags.
|
||||
5. TUI `/add-dir` command + status output.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Permission-UX Phase 1 — scope-enforced grants
|
||||
|
||||
Status: approved (2026-06-11). Branch: `feat/permission-ux` (continues Phase 0, commit `742ff48`).
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 0 derives a human-readable *scope* for a tool call (the file, directory, or
|
||||
cwd it touches) and shows it on the permission card and the persisted decision
|
||||
row. But the grant that an "always allow" writes is still **tool-wide**:
|
||||
|
||||
- `persistPermissionGrant` (agent/loop.go) stores only `ToolName`.
|
||||
- the grant file is keyed by tool name: `state.Grants[toolName]`.
|
||||
- enforcement (`engine.Decide` → `store.Lookup(toolName, autonomy)`, engine.go:100)
|
||||
matches on tool name alone.
|
||||
|
||||
So clicking "always allow" on a write to `src/main.go` silently authorizes *every*
|
||||
future write — the blind-yes the Phase-0 card was written to warn against.
|
||||
|
||||
## Goal
|
||||
|
||||
Persist and enforce the scope so an "always" grant covers exactly what the card
|
||||
showed: that file, or that directory subtree — nothing else.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Match semantics:** a `file` scope re-matches only its exact path; a `dir`
|
||||
scope matches that directory and any descendant; an empty scope (a call with no
|
||||
path-like arg, e.g. `bash` with no `cwd`) stays tool-wide. A tool-wide *request*
|
||||
is **not** covered by a narrower grant — it re-prompts (fail-safe).
|
||||
2. **Path anchoring:** scopes are normalized to an absolute, cleaned path at grant
|
||||
time, anchored to the workspace root. Matching compares absolute paths, so
|
||||
`./src/main.go` == `src/main.go` == `/proj/src/main.go`, and a grant made in
|
||||
`/proj-a` never matches `/proj-b`. The card still displays the short relative form.
|
||||
3. **Storage:** `grants` becomes `map[toolName][]Grant`, one record per
|
||||
(scope, decision). Schema bumps `1 → 2`; a v1 file is migrated by reading each
|
||||
entry as a single tool-wide grant, so existing grants keep working unchanged.
|
||||
4. **Precedence:** among grants that cover a request, a covering **deny wins**
|
||||
(regardless of autonomy); otherwise the most-specific covering **allow**
|
||||
(file > dir > tool-wide; ties broken by longer path, then newest) whose
|
||||
`MaxAutonomy` satisfies the request.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope derivation — single source of truth (`internal/sandbox/grant_scope.go`)
|
||||
|
||||
`DeriveScope(toolName, args) (raw string, kind ScopeKind)` is the one place that
|
||||
knows which args are path-like, reusing Phase 0's key priority (most specific
|
||||
first): `path`,`file` → `file`; `directory`,`dir`,`cwd` → `dir`. Empty / `.` →
|
||||
tool-wide. `resolveScopeAbs(raw, workspaceRoot)` cleans and absolutizes (relative
|
||||
anchored to the workspace root; falls back to `filepath.Abs` when the root is
|
||||
empty). `agent.permissionScope` (Phase-0 display) is refactored to call
|
||||
`DeriveScope`, so the displayed scope and the stored/matched scope can never diverge.
|
||||
|
||||
`ScopeKind`: `""` (tool-wide) | `"file"` | `"dir"`.
|
||||
|
||||
### Data model (`internal/sandbox/grants.go`)
|
||||
|
||||
`Grant` gains `Scope` (absolute path, `omitempty`) and `ScopeKind` (`omitempty`).
|
||||
`grantFile.Grants` becomes `map[string][]Grant`; `grantSchemaVersion` = 2.
|
||||
`GrantInput` gains `Scope` (raw) + `ScopeKind`.
|
||||
|
||||
### Grant creation
|
||||
|
||||
The agent derives `(raw, kind)` via `DeriveScope` and passes them in `GrantInput`.
|
||||
`engine.Grant` resolves `Scope` to absolute using its configured `workspaceRoot`
|
||||
(the engine already holds one — engine.go:33) before delegating to `store.Grant`,
|
||||
which appends to the tool's slice (replacing a same-scope entry). CLI grants
|
||||
(`zero sandbox grants allow|deny <tool>`) leave the scope empty → tool-wide, as today.
|
||||
|
||||
### Matching (`engine.Decide` → `store.Lookup`)
|
||||
|
||||
`Decide` derives the request's absolute scope from `request.Args` +
|
||||
`request.WorkspaceRoot` (already populated at engine.go:63) and passes it to a
|
||||
scope-aware `Lookup(toolName, reqScopeAbs, autonomy)`. `grantCovers` implements the
|
||||
match semantics above; `Lookup` applies the deny-wins / most-specific-allow
|
||||
precedence and the per-grant autonomy check. The surrounding allow/deny/ceiling
|
||||
flow (engine.go:99-126) is unchanged.
|
||||
|
||||
### Migration & a fixed bug
|
||||
|
||||
`readState` peeks at `schemaVersion`: v1 (`map[string]Grant`) entries become single
|
||||
tool-wide grants; v2 reads `map[string][]Grant`; anything else still errors with
|
||||
"unsupported schemaVersion". Keys are canonicalized (trimmed) on read so a
|
||||
whitespace-padded tool name matches at lookup — closing audit finding #90
|
||||
(grants.go:146). `writeState` always emits v2.
|
||||
|
||||
### CLI surface
|
||||
|
||||
`List()` flattens to `[]Grant` (sorted by tool, then scope). `FormatGrantList`
|
||||
shows the scope (`*` for tool-wide). `Revoke(toolName)` removes all of a tool's
|
||||
grants and returns the count. Per-scope revoke is out of scope.
|
||||
|
||||
## Testing
|
||||
|
||||
- `grant_scope_test` / `grants_test`: `DeriveScope` kinds; `resolveScopeAbs` (relative
|
||||
anchored, `./` equivalence, absolute passthrough, cross-project non-match);
|
||||
`grantCovers` matrix (tool-wide/file/dir × equal/descendant/sibling/parent/
|
||||
empty-request); deny-precedence; per-grant + policy autonomy; v1→v2 migration
|
||||
round-trip; whitespace-key canonicalization; `FormatGrantList` scope rendering.
|
||||
- `engine_test`: covered scoped request auto-allows; uncovered sibling re-prompts;
|
||||
dir deny blocks a subtree request; legacy tool-wide grant still allows.
|
||||
- `agent`: `persistPermissionGrant` forwards scope+kind; `TestPermissionScope`
|
||||
stays green (display unchanged).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Card UI changes (Phase 0), per-scope revoke, glob/pattern scopes.
|
||||
@@ -1,38 +1,37 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.5
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.47.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
golang.org/x/sys v0.38.0
|
||||
mvdan.cc/sh/v3 v3.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.0 // indirect
|
||||
github.com/go-quicktest/qt v1.102.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
)
|
||||
@@ -1,70 +1,66 @@
|
||||
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/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/dlclark/regexp2/v2 v2.5.0 h1:liNiWIPCvCh5HBcYfsjd+P16AG79fwd6T1Toy2gOtEA=
|
||||
github.com/dlclark/regexp2/v2 v2.5.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
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/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=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
|
||||
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
|
||||
@@ -1,745 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Deps are the ZERO capabilities the ACP Agent drives. The CLI fills these with
|
||||
// real implementations; tests inject fakes (e.g. a canned provider) to drive the
|
||||
// full ACP flow without a live model. Keeping auth/model/keys behind these deps
|
||||
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
|
||||
// operation.
|
||||
type Deps struct {
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
|
||||
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
|
||||
// exactly like the exec surface — never run unconfined on the host.
|
||||
BuildWorkspace func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error)
|
||||
// ResolveWorkspaceRoot validates + normalizes a client-supplied cwd (must be an
|
||||
// existing directory; never the bare root). It is the file-tool confinement root.
|
||||
ResolveWorkspaceRoot func(cwd string) (string, error)
|
||||
Store *sessions.Store
|
||||
AgentInfo Implementation
|
||||
}
|
||||
|
||||
// Agent is the ACP agent server bound to one JSON-RPC connection (one editor).
|
||||
type Agent struct {
|
||||
conn *Conn
|
||||
deps Deps
|
||||
|
||||
mu sync.Mutex
|
||||
clientCaps ClientCapabilities
|
||||
initialized bool
|
||||
sessions map[string]*acpSession
|
||||
}
|
||||
|
||||
type turnRecord struct {
|
||||
user string
|
||||
assistant string
|
||||
}
|
||||
|
||||
type acpSession struct {
|
||||
id string
|
||||
cwd string
|
||||
|
||||
// turnMu serializes prompt turns for one session: concurrent session/prompt
|
||||
// calls run one at a time so they can't interleave history or clobber the
|
||||
// single cancel slot.
|
||||
turnMu sync.Mutex
|
||||
modelMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string
|
||||
models []SessionConfigOptionValue
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
a := &Agent{conn: conn, deps: deps, sessions: make(map[string]*acpSession)}
|
||||
conn.Handle(MethodInitialize, a.handleInitialize)
|
||||
conn.Handle(MethodSessionNew, a.handleSessionNew)
|
||||
conn.Handle(MethodSessionLoad, a.handleSessionLoad)
|
||||
conn.Handle(MethodSessionPrompt, a.handleSessionPrompt)
|
||||
conn.Handle(MethodSessionSetMode, a.handleSetMode)
|
||||
conn.Handle(MethodSessionSetConfigOption, a.handleSetConfigOption)
|
||||
conn.Handle(MethodZeroSetModel, a.handleZeroSetModel)
|
||||
conn.HandleNotify(MethodSessionCancel, a.handleCancel)
|
||||
return a
|
||||
}
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p InitializeParams
|
||||
if len(params) > 0 {
|
||||
_ = json.Unmarshal(params, &p)
|
||||
}
|
||||
negotiated := ProtocolVersion
|
||||
if p.ProtocolVersion > 0 && p.ProtocolVersion < ProtocolVersion {
|
||||
negotiated = p.ProtocolVersion
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.clientCaps = p.ClientCapabilities
|
||||
a.initialized = true
|
||||
a.mu.Unlock()
|
||||
|
||||
info := a.deps.AgentInfo
|
||||
return InitializeResult{
|
||||
ProtocolVersion: negotiated,
|
||||
AgentCapabilities: AgentCapabilities{
|
||||
// Only advertise what ZERO actually implements: session/load (loadSession)
|
||||
// and image prompts. session/resume + the session-capability sub-object
|
||||
// are intentionally omitted since there is no resume handler yet.
|
||||
LoadSession: true,
|
||||
PromptCapabilities: PromptCapabilities{Image: true},
|
||||
},
|
||||
AgentInfo: &info,
|
||||
// ZERO owns credentials (BYOK) and does not delegate auth to the editor.
|
||||
AuthMethods: []AuthMethod{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p NewSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p LoadSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||
}
|
||||
meta, err := a.deps.Store.Get(p.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
return nil, RPCError(codeInvalidParams, "session not found: "+p.SessionID)
|
||||
}
|
||||
cwdInput := p.Cwd
|
||||
if strings.TrimSpace(cwdInput) == "" {
|
||||
cwdInput = meta.Cwd
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwdInput)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
|
||||
model = persistedModel
|
||||
if !modelChoiceExists(models, persistedModel) {
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
|
||||
historyErr,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p PromptParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/prompt params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
|
||||
// Serialize turns for this session so two prompts can't interleave history or
|
||||
// fight over the single cancel slot. session/cancel still works concurrently
|
||||
// (it doesn't take turnMu).
|
||||
sess.turnMu.Lock()
|
||||
defer sess.turnMu.Unlock()
|
||||
|
||||
userText := promptText(p.Prompt)
|
||||
images := promptImages(p.Prompt)
|
||||
|
||||
turnCtx, cancel := context.WithCancel(ctx)
|
||||
sess.setCancel(cancel)
|
||||
defer func() {
|
||||
cancel()
|
||||
sess.setCancel(nil)
|
||||
}()
|
||||
|
||||
reason, err := a.runTurn(turnCtx, sess, userText, images)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return PromptResult{StopReason: reason}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, images []zeroruntime.ImageBlock) (string, error) {
|
||||
overrides := config.Overrides{}
|
||||
if model := sess.currentModel(); model != "" {
|
||||
overrides.Provider.Model = model
|
||||
}
|
||||
resolved, err := a.deps.ResolveConfig(sess.cwd, overrides)
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
provider, err := a.deps.NewProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "provider: "+err.Error())
|
||||
}
|
||||
// Build the SCOPED registry + sandbox engine for this session's workspace so
|
||||
// shell/file tools are confined to the workspace exactly like the exec surface.
|
||||
registry, sandboxEngine, err := a.deps.BuildWorkspace(sess.cwd, resolved)
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "workspace: "+err.Error())
|
||||
}
|
||||
note := ¬ifier{conn: a.conn, sessionID: sess.id}
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
SessionID: sess.id,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
Model: resolved.Provider.Model,
|
||||
Registry: registry,
|
||||
Sandbox: sandboxEngine,
|
||||
PermissionMode: sess.currentMode(),
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Images: images,
|
||||
OnText: note.text,
|
||||
OnReasoning: note.thought,
|
||||
OnToolCall: note.toolCall,
|
||||
OnToolResult: func(result agent.ToolResult) {
|
||||
note.toolResult(result)
|
||||
if result.Name == "update_plan" {
|
||||
a.emitPlan(registry, note)
|
||||
}
|
||||
},
|
||||
OnPermissionRequest: func(ctx context.Context, req agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
return a.requestPermission(ctx, sess.id, req)
|
||||
},
|
||||
}
|
||||
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
|
||||
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
|
||||
|
||||
reason, stopErr := stopReasonFor(result, runErr)
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
func stopReasonFor(result agent.Result, err error) (string, error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return StopCancelled, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if result.FinishReason == "length" {
|
||||
return StopMaxTokens, nil
|
||||
}
|
||||
if result.FinishReason == "content_filter" {
|
||||
return StopRefusal, nil
|
||||
}
|
||||
return StopEndTurn, nil
|
||||
}
|
||||
|
||||
// requestPermission forwards a ZERO permission prompt to the client as an ACP
|
||||
// session/request_permission request and maps the outcome back. Failure to reach
|
||||
// the client fails closed to deny.
|
||||
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
params := RequestPermissionParams{
|
||||
SessionID: sessionID,
|
||||
ToolCall: permissionToolCall(req),
|
||||
Options: buildPermissionOptions(req),
|
||||
}
|
||||
var result RequestPermissionResult
|
||||
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "cancelled"}, nil
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission request failed: " + err.Error()}, nil
|
||||
}
|
||||
return decisionFromOutcome(result.Outcome, req.AvailableDecisions), nil
|
||||
}
|
||||
|
||||
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
|
||||
t, ok := registry.Get("update_plan")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
planner, ok := t.(interface{ CurrentPlan() []tools.PlanItem })
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
note.plan(planner.CurrentPlan())
|
||||
}
|
||||
|
||||
// ---- mode + model selection ----
|
||||
|
||||
func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p SetSessionModeParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid set_mode params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
mode := agent.PermissionMode(p.ModeID)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
return SetSessionModeResult{}, nil
|
||||
case agent.PermissionModeUnsafe:
|
||||
// Unsafe = run every tool with no prompt. The TUI gates this behind an
|
||||
// explicit --skip-permissions-unsafe operator flag; an editor client must
|
||||
// not be able to grant itself unconfined, no-prompt access over the wire.
|
||||
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.ModeID)
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.ModeID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p SetSessionConfigOptionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid set_config_option params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
switch p.ConfigID {
|
||||
case configIDModel:
|
||||
model := strings.TrimSpace(p.Value)
|
||||
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case configIDMode:
|
||||
mode := agent.PermissionMode(p.Value)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
case agent.PermissionModeUnsafe:
|
||||
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
|
||||
}
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
||||
}
|
||||
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p ZeroSetModelParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid _zero/set_model params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
model := strings.TrimSpace(p.Model)
|
||||
if err := a.updateModel(sess, model, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ZeroSetModelResult{Model: model}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
|
||||
sess.modelMu.Lock()
|
||||
defer sess.modelMu.Unlock()
|
||||
if restrictModels && !sess.hasModel(model) {
|
||||
return RPCError(codeInvalidParams, "unknown model: "+model)
|
||||
}
|
||||
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
|
||||
return RPCError(codeInternalError, "save model selection: "+err.Error())
|
||||
}
|
||||
sess.setModel(model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
var p CancelParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return
|
||||
}
|
||||
if sess := a.session(p.SessionID); sess != nil {
|
||||
sess.invokeCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- advertising helpers ----
|
||||
|
||||
func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
|
||||
// handleSetMode) so a client can't grant itself no-prompt host access.
|
||||
return &SessionModeState{
|
||||
CurrentModeID: string(s.currentMode()),
|
||||
AvailableModes: []SessionMode{
|
||||
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveModelChoices advertises authenticated live models when discovery is
|
||||
// available. The configured model is always retained as the sole fallback.
|
||||
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
|
||||
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
selected := strings.TrimSpace(resolved.Provider.Model)
|
||||
options := make([]SessionConfigOptionValue, 0, 8)
|
||||
seen := make(map[string]bool)
|
||||
add := func(id, description string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
|
||||
}
|
||||
add(selected, "")
|
||||
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
|
||||
restrictModels := knownProvider && !descriptor.Custom
|
||||
if a.deps.DiscoverModels != nil {
|
||||
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
|
||||
if ctx.Err() != nil {
|
||||
return "", nil, false, ctx.Err()
|
||||
}
|
||||
if discoverErr == nil && len(discovered) > 0 {
|
||||
for _, model := range discovered {
|
||||
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
|
||||
add(model.ID, model.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected, options, restrictModels, nil
|
||||
}
|
||||
|
||||
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
|
||||
for _, option := range models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||
model, models, mode := s.configState()
|
||||
return []SessionConfigOption{{
|
||||
ID: configIDModel,
|
||||
Name: "Model",
|
||||
Description: "Model used for this session.",
|
||||
Category: configCategoryModel,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: model,
|
||||
Options: models,
|
||||
}, {
|
||||
ID: configIDMode,
|
||||
Name: "Mode",
|
||||
Description: "Permission mode used for this session.",
|
||||
Category: configCategoryMode,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: string(mode),
|
||||
Options: []SessionConfigOptionValue{
|
||||
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events := []sessions.AppendEventInput{
|
||||
{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
},
|
||||
}
|
||||
if assistant != "" {
|
||||
events = append(events, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
_, err := a.deps.Store.AppendEvents(sess.id, events)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []turnRecord
|
||||
var pendingUser string
|
||||
havePending := false
|
||||
for _, e := range events {
|
||||
if e.Type != sessions.EventMessage {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(e.Payload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var msg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(raw, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
pendingUser = msg.Content
|
||||
havePending = true
|
||||
case "assistant":
|
||||
records = append(records, turnRecord{user: pendingUser, assistant: msg.Content})
|
||||
pendingUser = ""
|
||||
havePending = false
|
||||
}
|
||||
}
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
sessionID := ""
|
||||
if note != nil {
|
||||
sessionID = note.sessionID
|
||||
}
|
||||
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
|
||||
if note != nil {
|
||||
note.text("\n\n[zero warning] " + message + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// buildPrompt prepends prior conversation as context, since agent.Run drives a
|
||||
// single seeded turn. Mirrors how headless resume folds history into the prompt.
|
||||
func buildPrompt(history []turnRecord, userText string) string {
|
||||
if len(history) == 0 {
|
||||
return userText
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Conversation so far:\n")
|
||||
for _, t := range history {
|
||||
b.WriteString("User: ")
|
||||
b.WriteString(t.user)
|
||||
b.WriteString("\n")
|
||||
if t.assistant != "" {
|
||||
b.WriteString("Assistant: ")
|
||||
b.WriteString(t.assistant)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n---\nContinue with this request:\n")
|
||||
b.WriteString(userText)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
var images []zeroruntime.ImageBlock
|
||||
for _, blk := range blocks {
|
||||
if blk.Type != "image" || blk.Data == "" {
|
||||
continue
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(blk.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
images = append(images, zeroruntime.ImageBlock{MediaType: blk.MimeType, Data: data})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// ---- session registry + accessors ----
|
||||
|
||||
// registerSession publishes a session under the agent's lock. If one is already
|
||||
// registered for id (e.g. a re-load of an in-flight session) the existing live
|
||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||
// half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
}
|
||||
|
||||
func (a *Agent) session(id string) *acpSession {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.sessions[id]
|
||||
}
|
||||
|
||||
func (s *acpSession) setCancel(cancel context.CancelFunc) {
|
||||
s.mu.Lock()
|
||||
s.cancel = cancel
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) invokeCancel() {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *acpSession) setMode(mode agent.PermissionMode) {
|
||||
s.mu.Lock()
|
||||
s.mode = mode
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) currentMode() agent.PermissionMode {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.mode
|
||||
}
|
||||
|
||||
func (s *acpSession) setModel(model string) {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
found := false
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && model != "" {
|
||||
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
|
||||
}
|
||||
s.model = model
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) currentModel() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.model
|
||||
}
|
||||
|
||||
func (s *acpSession) hasModel(model string) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
|
||||
}
|
||||
|
||||
func (s *acpSession) appendHistory(rec turnRecord) {
|
||||
s.mu.Lock()
|
||||
s.history = append(s.history, rec)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) snapshotHistory() []turnRecord {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]turnRecord(nil), s.history...)
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// fakeProvider streams a canned assistant message and ends the turn — enough to
|
||||
// drive the real agent.Run loop without a live model.
|
||||
type fakeProvider struct{ text string }
|
||||
|
||||
func (f fakeProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: f.text}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func testDeps(t *testing.T) Deps {
|
||||
t.Helper()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
return Deps{
|
||||
ResolveConfig: func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "fake-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
Provider: config.ProviderProfile{Name: "fake", Model: model},
|
||||
MaxTurns: 4,
|
||||
}, nil
|
||||
},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return fakeProvider{text: "Hello from ZERO"}, nil
|
||||
},
|
||||
RunAgent: agent.Run,
|
||||
BuildWorkspace: func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
r := tools.NewRegistry()
|
||||
r.Register(tools.NewUpdatePlanTool())
|
||||
return r, nil, nil
|
||||
},
|
||||
ResolveWorkspaceRoot: func(cwd string) (string, error) { return cwd, nil },
|
||||
Store: store,
|
||||
AgentInfo: Implementation{Name: "zero", Version: "test"},
|
||||
}
|
||||
}
|
||||
|
||||
// clientHarness wires a client Conn to an Agent over in-memory pipes and collects
|
||||
// session/update text chunks.
|
||||
type clientHarness struct {
|
||||
client *Conn
|
||||
updates chan string
|
||||
stop func()
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, deps Deps) *clientHarness {
|
||||
t.Helper()
|
||||
ar, bw := io.Pipe() // agent -> client
|
||||
br, aw := io.Pipe() // client -> agent
|
||||
agentConn := NewConn(ar, aw)
|
||||
client := NewConn(br, bw)
|
||||
a := NewAgent(agentConn, deps)
|
||||
|
||||
h := &clientHarness{client: client, updates: make(chan string, 128)}
|
||||
client.HandleNotify(MethodSessionUpdate, func(_ context.Context, params json.RawMessage) {
|
||||
var probe struct {
|
||||
Update struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
Content struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"update"`
|
||||
}
|
||||
if json.Unmarshal(params, &probe) != nil {
|
||||
return
|
||||
}
|
||||
if probe.Update.SessionUpdate == UpdateAgentMessageChunk {
|
||||
h.updates <- probe.Update.Content.Text
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { _ = a.Serve(ctx) }()
|
||||
go func() { _ = client.Serve(ctx) }()
|
||||
h.stop = func() {
|
||||
cancel()
|
||||
_ = aw.Close()
|
||||
_ = bw.Close()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestACPEndToEndPrompt(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// initialize
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if initRes.ProtocolVersion != ProtocolVersion {
|
||||
t.Fatalf("protocol version = %d", initRes.ProtocolVersion)
|
||||
}
|
||||
if !initRes.AgentCapabilities.LoadSession || !initRes.AgentCapabilities.PromptCapabilities.Image {
|
||||
t.Fatalf("unexpected capabilities: %+v", initRes.AgentCapabilities)
|
||||
}
|
||||
|
||||
// session/new
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
if newRes.SessionID == "" {
|
||||
t.Fatal("session/new returned empty sessionId")
|
||||
}
|
||||
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
|
||||
}
|
||||
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
|
||||
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
|
||||
}
|
||||
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
|
||||
}
|
||||
|
||||
// session/prompt
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("hi")},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
// The streamed agent_message_chunk(s) should carry the assistant text.
|
||||
if got := drainText(t, h.updates); !strings.Contains(got, "Hello from ZERO") {
|
||||
t.Fatalf("streamed text = %q, want it to contain the assistant message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "gpt-5.5"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: " gpt-5.4-mini ", Description: "Fast"},
|
||||
{ID: "gpt-5.5", Description: "duplicate configured model"},
|
||||
}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
|
||||
t.Fatalf("new model option = %+v", option)
|
||||
}
|
||||
for _, choice := range option.Options {
|
||||
if choice.Name != choice.Value {
|
||||
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
|
||||
}
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set_config_option: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
|
||||
t.Fatalf("selected model = %q", got)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
|
||||
}, &SetSessionConfigOptionResult{}); err == nil {
|
||||
t.Fatal("unknown standard model selection was accepted")
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
|
||||
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
|
||||
}
|
||||
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, fmt.Errorf("discovery unavailable")
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
|
||||
t.Fatalf("fallback model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "configured-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set custom model: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
|
||||
t.Fatalf("custom model = %q", got)
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
option := loaded.ConfigOptions[0]
|
||||
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
|
||||
t.Fatalf("loaded custom model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
|
||||
}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
|
||||
},
|
||||
}}
|
||||
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
|
||||
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, context.Canceled
|
||||
},
|
||||
}}
|
||||
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("resolve error = %v, want context canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPConfigOptionWireSchema(t *testing.T) {
|
||||
b, err := json.Marshal(SessionConfigOption{
|
||||
ID: "model", Name: "Model", Description: "desc", Category: "model",
|
||||
Type: configOptionTypeSelect, CurrentValue: "m1",
|
||||
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(b, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"type", "category", "currentValue", "options"} {
|
||||
if _, ok := wire[key]; !ok {
|
||||
t.Errorf("wire field %q absent: %s", key, b)
|
||||
}
|
||||
}
|
||||
if _, ok := wire["value"]; ok {
|
||||
t.Errorf("obsolete option value field present: %s", b)
|
||||
}
|
||||
if _, ok := wire["values"]; ok {
|
||||
t.Errorf("obsolete values field present: %s", b)
|
||||
}
|
||||
choice := wire["options"].([]any)[0].(map[string]any)
|
||||
if choice["value"] != "m1" {
|
||||
t.Errorf("options[].value = %#v", choice["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPUnknownSessionPromptErrors(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: "nope", Prompt: []ContentBlock{TextBlock("x")}}, &PromptResult{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPSetModeUpdatesSession(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
// auto/ask are accepted.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
|
||||
t.Fatalf("set_mode ask: %v", err)
|
||||
}
|
||||
var configured SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
|
||||
}, &configured); err != nil {
|
||||
t.Fatalf("set_config_option mode: %v", err)
|
||||
}
|
||||
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("configured mode = %q", got)
|
||||
}
|
||||
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected Unsafe mode to be rejected over ACP")
|
||||
}
|
||||
// An unknown mode must be rejected.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected error for unknown mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
|
||||
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
|
||||
// shell tools run confined, not unconfined on the host.
|
||||
func TestACPRunTurnWiresSandboxAndScopedRegistry(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
reg := tools.NewRegistry()
|
||||
reg.Register(tools.NewUpdatePlanTool())
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
deps.BuildWorkspace = func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
return reg, engine, nil
|
||||
}
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
captured = opts
|
||||
return agent.Result{FinalAnswer: "ok"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("hi")}}, &PromptResult{}); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if captured.Sandbox != engine {
|
||||
t.Fatal("sandbox engine was not wired into agent.Options (shell tools would run unconfined)")
|
||||
}
|
||||
if captured.Registry != reg {
|
||||
t.Fatal("scoped registry was not wired into agent.Options")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
|
||||
// resolver rejects the client cwd (e.g. filesystem root).
|
||||
func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveWorkspaceRoot = func(string) (string, error) {
|
||||
return "", fmt.Errorf("cwd must not be the filesystem root")
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: "/", McpServers: []McpServer{}}, &NewSessionResult{}); err == nil {
|
||||
t.Fatal("expected session/new to reject an invalid cwd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
|
||||
if err := os.Remove(metadataPath); err != nil {
|
||||
t.Fatalf("remove metadata: %v", err)
|
||||
}
|
||||
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("hi")},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO") &&
|
||||
strings.Contains(text, "Could not save session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not save session history") {
|
||||
t.Fatalf("streamed text = %q, want persistence warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
cwd := t.TempDir()
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
|
||||
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt events: %v", err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Could not load session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not load session history") {
|
||||
t.Fatalf("streamed text = %q, want load warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// drainText collects streamed chunks for a short window and concatenates them.
|
||||
func drainText(t *testing.T, ch <-chan string) string {
|
||||
t.Helper()
|
||||
return drainTextUntil(t, ch, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO")
|
||||
})
|
||||
}
|
||||
|
||||
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case s := <-ch:
|
||||
b.WriteString(s)
|
||||
if done(b.String()) {
|
||||
return b.String()
|
||||
}
|
||||
case <-deadline:
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package acp
|
||||
|
||||
import "context"
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
// Package acp implements the Agent Client Protocol (ACP) surface for ZERO: a
|
||||
// JSON-RPC 2.0 peer spoken over stdio (newline-delimited JSON) so editors such
|
||||
// as Zed, JetBrains, and Neovim can drive ZERO's agent core as a backend.
|
||||
//
|
||||
// The protocol shapes (method names, message fields) are derived solely from the
|
||||
// public, Apache-licensed ACP specification at agentclientprotocol.com and the
|
||||
// agentclientprotocol/agent-client-protocol repository (schema/v1). All logic
|
||||
// here is written originally against ZERO's own interfaces.
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// JSON-RPC 2.0 standard error codes.
|
||||
const (
|
||||
codeParseError = -32700
|
||||
codeInvalidRequest = -32600
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
codeInternalError = -32603
|
||||
)
|
||||
|
||||
// rpcError is a JSON-RPC 2.0 error object.
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil rpc error>"
|
||||
}
|
||||
return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// RPCError builds a protocol error to return from a handler. Use it when a
|
||||
// handler needs to control the wire-level error code; any other error a handler
|
||||
// returns is reported as a generic internal error.
|
||||
func RPCError(code int, message string) error {
|
||||
return &rpcError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// rpcMessage is the union of request, response, and notification frames. The
|
||||
// shape is disambiguated by which fields are present (per JSON-RPC 2.0):
|
||||
// - request: method + id
|
||||
// - notification: method, no id
|
||||
// - response: id + (result | error), no method
|
||||
type rpcMessage struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (m rpcMessage) isResponse() bool { return m.Method == "" && len(m.ID) > 0 }
|
||||
func (m rpcMessage) isRequest() bool { return m.Method != "" && len(m.ID) > 0 }
|
||||
func (m rpcMessage) isNotify() bool { return m.Method != "" && len(m.ID) == 0 }
|
||||
|
||||
// HandlerFunc handles an inbound request and returns the result to encode, or an
|
||||
// error. Returning an *rpcError controls the wire code; any other error becomes
|
||||
// a generic internal error.
|
||||
type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error)
|
||||
|
||||
// NotifyFunc handles an inbound notification (no response is sent).
|
||||
type NotifyFunc func(ctx context.Context, params json.RawMessage)
|
||||
|
||||
// Conn is a JSON-RPC 2.0 peer over a single ndjson stream pair. It both serves
|
||||
// inbound requests/notifications (via registered handlers) and issues outbound
|
||||
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
||||
// the client for session/request_permission, fs/*, terminal/*).
|
||||
type Conn struct {
|
||||
rawReader io.Reader // wrapped lazily in Serve, once ctx is known — see interruptibleReader
|
||||
readerCloser io.Closer
|
||||
w io.Writer
|
||||
|
||||
writeMu sync.Mutex // serializes all writes to w
|
||||
|
||||
handlers map[string]HandlerFunc
|
||||
notifiers map[string]NotifyFunc
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
pending map[int64]chan rpcMessage
|
||||
closed bool
|
||||
|
||||
wg sync.WaitGroup // tracks in-flight inbound handlers
|
||||
}
|
||||
|
||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w. This
|
||||
// Conn does not own r: cancelling Serve's context cannot forcibly close it, so
|
||||
// a Read on r that's genuinely idle (never returns on its own) leaves Serve
|
||||
// blocked until r itself produces something — e.g. a test that closes r's own
|
||||
// write side to unblock it. Use NewOwnedConn when this Conn should own r's
|
||||
// lifetime.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
}
|
||||
}
|
||||
|
||||
// NewOwnedConn is like NewConn but declares that this Conn exclusively owns r:
|
||||
// if r implements io.Closer, Serve closes it on context cancellation to
|
||||
// interrupt an otherwise-idle blocking Read. Only pass a reader whose lifetime
|
||||
// this Conn should control — e.g. the process's own stdin — never a reader a
|
||||
// caller retains or shares elsewhere, since cancellation tears it down
|
||||
// permanently for every consumer, not just this Conn.
|
||||
func NewOwnedConn(r io.Reader, w io.Writer) *Conn {
|
||||
c := NewConn(r, w)
|
||||
c.readerCloser, _ = r.(io.Closer)
|
||||
return c
|
||||
}
|
||||
|
||||
// Handle registers a request handler for method.
|
||||
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
||||
|
||||
// HandleNotify registers a notification handler for method.
|
||||
func (c *Conn) HandleNotify(method string, fn NotifyFunc) { c.notifiers[method] = fn }
|
||||
|
||||
// Serve runs the read loop until the stream ends, ctx is cancelled, or a fatal
|
||||
// decode error occurs. Inbound requests and notifications are dispatched on
|
||||
// their own goroutines so a long-running handler (e.g. session/prompt) never
|
||||
// blocks the loop from delivering session/cancel or a permission response.
|
||||
func (c *Conn) Serve(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
// On exit, cancel in-flight handlers (so a blocked outbound Call unblocks via
|
||||
// ctx) and then wait for them to finish writing their responses. Without this,
|
||||
// a finite input stream (e.g. piped ndjson that EOFs right after a request)
|
||||
// would race the dispatch goroutine and drop the response.
|
||||
defer func() {
|
||||
cancel()
|
||||
c.wg.Wait()
|
||||
}()
|
||||
|
||||
interruptible := newInterruptibleReader(ctx, c.rawReader, c.readerCloser)
|
||||
reader := bufio.NewReader(interruptible)
|
||||
|
||||
for {
|
||||
// One ndjson value per line. Framing per line (rather than streaming the
|
||||
// decoder) keeps a single malformed line from making the whole connection
|
||||
// unrecoverable — we report -32700 and continue.
|
||||
//
|
||||
// generation brackets this SPECIFIC ReadBytes call: bufio may invoke
|
||||
// interruptible.Read zero or more times underneath it (zero when the
|
||||
// answer is already sitting in bufio's own buffer — including a
|
||||
// previously-buffered, not-yet-surfaced error: bufio can return a
|
||||
// complete line with a nil error while quietly caching an error it
|
||||
// received ALONGSIDE that line's bytes, which then surfaces on the VERY
|
||||
// NEXT call with no further contact with interruptible.Read at all). The
|
||||
// generation only advances inside interruptible.Read's own cancellation
|
||||
// branch, so "unchanged across this call" is proof this call's outcome
|
||||
// didn't pass through that branch — whether because it was answered from
|
||||
// the buffer or because it raced ctx.Done() and won. Only that proof
|
||||
// makes it safe to call the outcome genuine.
|
||||
before := interruptible.generation()
|
||||
line, err := reader.ReadBytes('\n')
|
||||
interrupted := interruptible.generation() != before
|
||||
|
||||
if len(bytes.TrimSpace(line)) > 0 {
|
||||
c.handleLine(ctx, line)
|
||||
}
|
||||
if err != nil {
|
||||
c.failAllPending(err)
|
||||
if errors.Is(err, io.EOF) || interrupted {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// interruptibleReader wraps a reader so a context cancellation can unblock a
|
||||
// currently in-flight Read without ever discarding — or misattributing — the
|
||||
// real outcome of a call that wasn't actually blocked.
|
||||
//
|
||||
// Each Read runs the underlying call on its own goroutine and races it against
|
||||
// ctx.Done(). If the underlying call finishes first, its result is returned
|
||||
// exactly as received; ctx being cancelled a moment later has no bearing on a
|
||||
// call that already had its answer. If ctx.Done() fires first, closer (when
|
||||
// non-nil) is closed to nudge a genuinely blocked read, the generation counter
|
||||
// is bumped, and the call STILL waits for and returns the underlying call's own
|
||||
// result — never a synthesized one — since closing a reader's outcome is
|
||||
// whatever the reader itself reports for it, not ours to invent.
|
||||
//
|
||||
// The counter is what lets Serve tell a call that was actually raced against
|
||||
// cancellation apart from one merely answered while cancellation happened to be
|
||||
// pending — the distinction the read loop's own doc comment explains.
|
||||
type interruptibleReader struct {
|
||||
inner io.Reader
|
||||
closer io.Closer
|
||||
ctx context.Context
|
||||
|
||||
closeOnce sync.Once
|
||||
gen atomic.Int64
|
||||
}
|
||||
|
||||
func newInterruptibleReader(ctx context.Context, inner io.Reader, closer io.Closer) *interruptibleReader {
|
||||
return &interruptibleReader{inner: inner, closer: closer, ctx: ctx}
|
||||
}
|
||||
|
||||
func (r *interruptibleReader) generation() int64 { return r.gen.Load() }
|
||||
|
||||
type interruptibleReadResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
|
||||
// interruptibleReadDecision arbitrates whether an underlying Read completed or
|
||||
// cancellation claimed it first. Completion is recorded before the result is
|
||||
// published so cancellation cannot misclassify a finished read merely because
|
||||
// its goroutine has not sent the result yet.
|
||||
type interruptibleReadDecision struct {
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (d *interruptibleReadDecision) complete() { d.once.Do(func() {}) }
|
||||
|
||||
func (d *interruptibleReadDecision) interrupt() (claimed bool) {
|
||||
d.once.Do(func() { claimed = true })
|
||||
return claimed
|
||||
}
|
||||
|
||||
// readInner records completion in the Read call's deferred epilogue. It also
|
||||
// converts a reader panic into a terminal error so the helper goroutine always
|
||||
// publishes a result for Read to join.
|
||||
func (r *interruptibleReader) readInner(p []byte, decision *interruptibleReadDecision) (res interruptibleReadResult) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
res = interruptibleReadResult{err: fmt.Errorf("acp: reader panicked: %v", recovered)}
|
||||
}
|
||||
decision.complete()
|
||||
}()
|
||||
res.n, res.err = r.inner.Read(p)
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *interruptibleReader) Read(p []byte) (int, error) {
|
||||
resultCh := make(chan interruptibleReadResult, 1)
|
||||
var decision interruptibleReadDecision
|
||||
go func() {
|
||||
resultCh <- r.readInner(p, &decision)
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.n, res.err
|
||||
case <-r.ctx.Done():
|
||||
// resultCh may not be readable yet even when inner.Read has returned: its
|
||||
// goroutine can be descheduled between recording completion and publishing
|
||||
// the result. Only interrupt a call that cancellation actually claims.
|
||||
if decision.interrupt() {
|
||||
r.gen.Add(1)
|
||||
if r.closer != nil {
|
||||
r.closeOnce.Do(func() { _ = r.closer.Close() })
|
||||
}
|
||||
}
|
||||
// Wait for the real result rather than returning synthetically: p is
|
||||
// still being written by the goroutine above until it does, so returning
|
||||
// early would race that write, and the actual error (whatever closing
|
||||
// the reader produced, or whatever the reader was already about to
|
||||
// report) is what the caller should see.
|
||||
res := <-resultCh
|
||||
return res.n, res.err
|
||||
}
|
||||
}
|
||||
|
||||
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
||||
// with -32700 (id null) and keeps the connection alive.
|
||||
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
var msg rpcMessage
|
||||
if err := json.Unmarshal(bytes.TrimSpace(line), &msg); err != nil {
|
||||
c.writeError(json.RawMessage("null"), &rpcError{Code: codeParseError, Message: "parse error"})
|
||||
return
|
||||
}
|
||||
if msg.JSONRPC != "" && msg.JSONRPC != "2.0" {
|
||||
if len(msg.ID) > 0 {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeInvalidRequest, Message: "unsupported jsonrpc version"})
|
||||
}
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case msg.isResponse():
|
||||
c.deliver(msg)
|
||||
case msg.isRequest():
|
||||
c.wg.Add(1)
|
||||
go func(m rpcMessage) {
|
||||
defer c.wg.Done()
|
||||
c.dispatchRequest(ctx, m)
|
||||
}(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)
|
||||
}
|
||||
default:
|
||||
// Malformed frame; reply only if we can identify a request id.
|
||||
if len(msg.ID) > 0 {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeInvalidRequest, Message: "invalid request"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
||||
fn := c.handlers[msg.Method]
|
||||
if fn == nil {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeMethodNotFound, Message: "method not found: " + msg.Method})
|
||||
return
|
||||
}
|
||||
result, err := fn(ctx, msg.Params)
|
||||
if err != nil {
|
||||
var re *rpcError
|
||||
if errors.As(err, &re) {
|
||||
c.writeError(msg.ID, re)
|
||||
} else {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeInternalError, Message: err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.writeResult(msg.ID, result)
|
||||
}
|
||||
|
||||
// Call issues an outbound request and blocks until the response arrives, ctx is
|
||||
// cancelled, or the connection closes.
|
||||
func (c *Conn) Call(ctx context.Context, method string, params any, result any) error {
|
||||
raw, err := marshalParams(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return errors.New("acp: connection closed")
|
||||
}
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ch := make(chan rpcMessage, 1)
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
idRaw, _ := json.Marshal(id)
|
||||
if err := c.write(rpcMessage{JSONRPC: "2.0", ID: idRaw, Method: method, Params: raw}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case resp := <-ch:
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
if result != nil && len(resp.Result) > 0 {
|
||||
return json.Unmarshal(resp.Result, result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Notify issues an outbound notification (no response expected).
|
||||
func (c *Conn) Notify(method string, params any) error {
|
||||
raw, err := marshalParams(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.write(rpcMessage{JSONRPC: "2.0", Method: method, Params: raw})
|
||||
}
|
||||
|
||||
func (c *Conn) deliver(msg rpcMessage) {
|
||||
var id int64
|
||||
if err := json.Unmarshal(msg.ID, &id); err != nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
ch := c.pending[id]
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
// Non-blocking: the pending channel is cap-1 and may have been abandoned (e.g.
|
||||
// a Call cancelled by session/cancel). A duplicate or late response frame must
|
||||
// never block the read-loop goroutine.
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) failAllPending(err error) {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
pending := c.pending
|
||||
c.pending = make(map[int64]chan rpcMessage)
|
||||
c.mu.Unlock()
|
||||
for _, ch := range pending {
|
||||
select {
|
||||
case ch <- rpcMessage{Error: &rpcError{Code: codeInternalError, Message: err.Error()}}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) writeResult(id json.RawMessage, result any) {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
c.writeError(id, &rpcError{Code: codeInternalError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = c.write(rpcMessage{JSONRPC: "2.0", ID: id, Result: raw})
|
||||
}
|
||||
|
||||
func (c *Conn) writeError(id json.RawMessage, e *rpcError) {
|
||||
_ = c.write(rpcMessage{JSONRPC: "2.0", ID: id, Error: e})
|
||||
}
|
||||
|
||||
func (c *Conn) write(msg rpcMessage) error {
|
||||
msg.JSONRPC = "2.0"
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if _, err := c.w.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// marshalParams encodes params, treating nil as an absent params field.
|
||||
func marshalParams(params any) (json.RawMessage, error) {
|
||||
if params == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if raw, ok := params.(json.RawMessage); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return json.Marshal(params)
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testReadCloser struct {
|
||||
read func([]byte) (int, error)
|
||||
close func() error
|
||||
}
|
||||
|
||||
func (r testReadCloser) Read(p []byte) (int, error) { return r.read(p) }
|
||||
func (r testReadCloser) Close() error { return r.close() }
|
||||
|
||||
type testReader func([]byte) (int, error)
|
||||
|
||||
func (r testReader) Read(p []byte) (int, error) { return r(p) }
|
||||
|
||||
type testWriter func([]byte) (int, error)
|
||||
|
||||
func (w testWriter) Write(p []byte) (int, error) { return w(p) }
|
||||
|
||||
// connPair wires two Conns together over in-memory pipes and serves both.
|
||||
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||
t.Helper()
|
||||
ar, bw := io.Pipe() // b -> a
|
||||
br, aw := io.Pipe() // a -> b
|
||||
a = NewConn(ar, aw)
|
||||
b = NewConn(br, bw)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { _ = a.Serve(ctx) }()
|
||||
go func() { _ = b.Serve(ctx) }()
|
||||
return a, b, func() {
|
||||
cancel()
|
||||
_ = aw.Close()
|
||||
_ = bw.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServeCancellationInterruptsIdleRead(t *testing.T) {
|
||||
pipeReader, writer := io.Pipe()
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
// Buffered + non-blocking send, not close: interruptibleReader retries each
|
||||
// underlying Read on its own goroutine, so if bufio ever calls this more than
|
||||
// once, a close() here would panic on the second call.
|
||||
readStarted := make(chan struct{}, 1)
|
||||
closeCalls := make(chan struct{}, 2)
|
||||
reader := testReadCloser{
|
||||
read: func(p []byte) (int, error) {
|
||||
select {
|
||||
case readStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return pipeReader.Read(p)
|
||||
},
|
||||
close: func() error {
|
||||
closeCalls <- struct{}{}
|
||||
return pipeReader.Close()
|
||||
},
|
||||
}
|
||||
conn := NewOwnedConn(reader, io.Discard)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
<-readStarted
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned %v after cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after cancelling an idle connection")
|
||||
}
|
||||
if got := len(closeCalls); got != 1 {
|
||||
t.Fatalf("reader Close calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServePreservesTerminalReadErrorDuringCancellation(t *testing.T) {
|
||||
for _, closable := range []bool{false, true} {
|
||||
name := "non-closable"
|
||||
if closable {
|
||||
name = "closable"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
return copy(p, "not json"), wantErr
|
||||
})
|
||||
closeCalls := make(chan struct{}, 1)
|
||||
var reader io.Reader = read
|
||||
if closable {
|
||||
reader = testReadCloser{
|
||||
read: read,
|
||||
close: func() error {
|
||||
closeCalls <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
// Buffered + non-blocking send, not close: a later change adding a
|
||||
// second write (e.g. an additional error frame) must not panic on a
|
||||
// repeated close of an already-closed channel.
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
writer := testWriter(func(p []byte) (int, error) {
|
||||
select {
|
||||
case writeStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-releaseWrite
|
||||
return len(p), nil
|
||||
})
|
||||
newConn := NewConn
|
||||
if closable {
|
||||
// Ownership matters here: this case exists to prove that even a
|
||||
// Conn entitled to close its reader on cancellation still
|
||||
// preserves a terminal error that was already decided.
|
||||
newConn = NewOwnedConn
|
||||
}
|
||||
conn := newConn(reader, writer)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
<-writeStarted
|
||||
cancel()
|
||||
close(releaseWrite)
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Serve returned %v, want terminal read error %v", err, wantErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after terminal read error")
|
||||
}
|
||||
if got := len(closeCalls); got != 0 {
|
||||
t.Fatalf("reader Close calls = %d, want 0 after terminal read", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnServePreservesReadErrorBufferedAlongsideAPriorLine is the deterministic
|
||||
// regression test for jatmn's #782 finding: a real, non-EOF ReadBytes failure can
|
||||
// be DECIDED before cancellation ever happens, yet only SURFACE afterward, and
|
||||
// must still be reported rather than swallowed as a clean shutdown.
|
||||
//
|
||||
// bufio.Reader can obtain data AND a terminal error from ONE underlying Read call
|
||||
// (io.Reader explicitly permits returning (n>0, err) together). If a delimiter is
|
||||
// found within that data, ReadBytes returns the line with a nil error for THIS
|
||||
// call, but bufio caches the error internally and returns it — WITHOUT calling the
|
||||
// underlying reader again — on the very next ReadBytes call. This test forces that
|
||||
// exact sequence: the underlying reader hands back one complete, validly-framed
|
||||
// line together with wantErr in a SINGLE call (asserted below to be its ONLY
|
||||
// call), the loop dispatches that line through a synchronous path that blocks the
|
||||
// read loop, cancellation is asserted to have happened before the loop attempts
|
||||
// its next read, and only then is the block released. By the time the read loop
|
||||
// asks for more input, cancellation is already in effect and the underlying
|
||||
// reader is never touched again — so if Serve attributed the resulting error to
|
||||
// cancellation merely because ctx was already done, it would incorrectly return
|
||||
// nil. It must instead recognize that this read was answered entirely from
|
||||
// bufio's own cache, never raced against ctx.Done(), and report wantErr.
|
||||
func TestConnServePreservesReadErrorBufferedAlongsideAPriorLine(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
var readCalls atomic.Int32
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
readCalls.Add(1)
|
||||
// An unsupported jsonrpc version with an id takes the SYNCHRONOUS
|
||||
// writeError path in handleLine (not a dispatched goroutine), giving this
|
||||
// test a reliable blocking point between processing this line and the
|
||||
// loop's next ReadBytes call.
|
||||
return copy(p, `{"jsonrpc":"1.0","id":1}`+"\n"), wantErr
|
||||
})
|
||||
writeStarted := make(chan struct{}, 1)
|
||||
releaseWrite := make(chan struct{})
|
||||
var releaseWriteOnce sync.Once
|
||||
release := func() { releaseWriteOnce.Do(func() { close(releaseWrite) }) }
|
||||
t.Cleanup(release)
|
||||
writer := testWriter(func(p []byte) (int, error) {
|
||||
select {
|
||||
case writeStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-releaseWrite
|
||||
return len(p), nil
|
||||
})
|
||||
conn := NewConn(read, writer)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(ctx) }()
|
||||
|
||||
// The synchronous write confirms the first (and, per the panic guard above,
|
||||
// only) read has already returned and is being processed — cancelling now
|
||||
// lands squarely in the gap between that read and the loop's next one, never
|
||||
// racing an in-flight interruptibleReader.Read call.
|
||||
select {
|
||||
case <-writeStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not reach the synchronous write")
|
||||
}
|
||||
cancel()
|
||||
release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Serve returned %v, want the buffered terminal read error %v", err, wantErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after the buffered terminal read error")
|
||||
}
|
||||
if got := readCalls.Load(); got != 1 {
|
||||
t.Fatalf("underlying Read calls = %d, want exactly 1 (bufio must have served the second ReadBytes from its cache)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptibleReaderPrefersDecidedResultOverCancellation is the regression
|
||||
// test for jatmn's second #782 finding: interruptibleReader.Read's select
|
||||
// between resultCh and ctx.Done() chooses pseudo-randomly when both are ready,
|
||||
// so a real, already-decided outcome (e.g. a terminal transport error) could be
|
||||
// misattributed to cancellation purely because ctx also happened to be done by
|
||||
// the time the select ran. Each iteration lets the underlying Read return (so
|
||||
// resultCh already holds the real outcome) before cancelling, maximizing the
|
||||
// odds of landing exactly in that ambiguous window; the generation counter must
|
||||
// never advance when the real, already-decided result is what gets returned.
|
||||
func TestInterruptibleReaderPrefersDecidedResultOverCancellation(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
for i := 0; i < 100; i++ {
|
||||
resultReady := make(chan struct{})
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
defer close(resultReady)
|
||||
return copy(p, "x"), wantErr
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := newInterruptibleReader(ctx, read, nil)
|
||||
|
||||
readDone := make(chan interruptibleReadResult, 1)
|
||||
go func() {
|
||||
n, err := r.Read(make([]byte, 8))
|
||||
readDone <- interruptibleReadResult{n, err}
|
||||
}()
|
||||
|
||||
<-resultReady
|
||||
time.Sleep(time.Millisecond) // bias resultCh's send ahead of cancel
|
||||
cancel()
|
||||
|
||||
res := <-readDone
|
||||
if !errors.Is(res.err, wantErr) {
|
||||
t.Fatalf("iteration %d: Read returned %v, want %v", i, res.err, wantErr)
|
||||
}
|
||||
if got := r.generation(); got != 0 {
|
||||
t.Fatalf("iteration %d: generation = %d, want 0 (a decided result must never be attributed to cancellation)", i, got)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptibleReadCompletionWinsBeforeResultPublication exercises the
|
||||
// actual inner-read wrapper at the handoff between the underlying Read and the
|
||||
// helper goroutine's result publication. Cancellation must not reclassify a
|
||||
// result returned by this wrapper as interrupted.
|
||||
func TestInterruptibleReadCompletionWinsBeforeResultPublication(t *testing.T) {
|
||||
wantErr := errors.New("read failed")
|
||||
read := testReader(func(p []byte) (int, error) {
|
||||
return copy(p, "x"), wantErr
|
||||
})
|
||||
r := newInterruptibleReader(context.Background(), read, nil)
|
||||
var decision interruptibleReadDecision
|
||||
readComplete := make(chan struct{})
|
||||
resultCh := make(chan interruptibleReadResult)
|
||||
go func() {
|
||||
res := r.readInner(make([]byte, 8), &decision)
|
||||
close(readComplete)
|
||||
resultCh <- res
|
||||
}()
|
||||
|
||||
<-readComplete
|
||||
if decision.interrupt() {
|
||||
t.Fatal("cancellation claimed a read that completed before result publication")
|
||||
}
|
||||
res := <-resultCh
|
||||
if res.n != 1 || !errors.Is(res.err, wantErr) {
|
||||
t.Fatalf("inner read result = (%d, %v), want (1, %v)", res.n, res.err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnServeReportsReaderPanicAsTerminalError(t *testing.T) {
|
||||
read := testReader(func([]byte) (int, error) {
|
||||
panic("boom")
|
||||
})
|
||||
conn := NewConn(read, io.Discard)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- conn.Serve(context.Background()) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
const want = "acp: reader panicked: boom"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("Serve returned %v, want %q", err, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve did not return after reader panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnRequestResponse(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
b.Handle("add", func(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var in struct{ X, Y int }
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "bad params")
|
||||
}
|
||||
return map[string]int{"sum": in.X + in.Y}, nil
|
||||
})
|
||||
|
||||
var out struct{ Sum int }
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Call(ctx, "add", map[string]int{"X": 2, "Y": 3}, &out); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out.Sum != 5 {
|
||||
t.Fatalf("sum = %d, want 5", out.Sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnNotification(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
got := make(chan string, 1)
|
||||
b.HandleNotify("ping", func(_ context.Context, params json.RawMessage) {
|
||||
var in struct{ Msg string }
|
||||
_ = json.Unmarshal(params, &in)
|
||||
got <- in.Msg
|
||||
})
|
||||
|
||||
if err := a.Notify("ping", map[string]string{"Msg": "hello"}); err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case msg := <-got:
|
||||
if msg != "hello" {
|
||||
t.Fatalf("got %q, want hello", msg)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("notification not delivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnMethodNotFound(t *testing.T) {
|
||||
a, _, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err := a.Call(ctx, "does_not_exist", nil, nil)
|
||||
var re *rpcError
|
||||
if !asRPCError(err, &re) {
|
||||
t.Fatalf("expected rpcError, got %v", err)
|
||||
}
|
||||
if re.Code != codeMethodNotFound {
|
||||
t.Fatalf("code = %d, want %d", re.Code, codeMethodNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnHandlerError(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
b.Handle("boom", func(_ context.Context, _ json.RawMessage) (any, error) {
|
||||
return nil, RPCError(codeInvalidParams, "nope")
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err := a.Call(ctx, "boom", nil, nil)
|
||||
var re *rpcError
|
||||
if !asRPCError(err, &re) || re.Code != codeInvalidParams {
|
||||
t.Fatalf("expected invalid-params rpcError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnBidirectionalDuringHandler proves that while one peer is inside a
|
||||
// request handler it can issue an outbound request back to the caller and the
|
||||
// caller answers it — exactly the session/prompt -> session/request_permission
|
||||
// pattern. If the read loop blocked on the handler, this would deadlock.
|
||||
func TestConnBidirectionalDuringHandler(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
// a answers an "approve?" callback.
|
||||
a.Handle("approve", func(_ context.Context, _ json.RawMessage) (any, error) {
|
||||
return map[string]bool{"ok": true}, nil
|
||||
})
|
||||
|
||||
// b's "run" handler calls back to a mid-flight.
|
||||
b.Handle("run", func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
var approval struct{ OK bool }
|
||||
if err := b.Call(ctx, "approve", nil, &approval); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]bool{"ran": approval.OK}, nil
|
||||
})
|
||||
|
||||
var out struct{ Ran bool }
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Call(ctx, "run", nil, &out); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !out.Ran {
|
||||
t.Fatal("expected ran=true via mid-handler callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnSurvivesMalformedLine proves a single bad ndjson line yields a -32700
|
||||
// and does NOT tear down the connection — a following valid request still works.
|
||||
func TestConnSurvivesMalformedLine(t *testing.T) {
|
||||
clientR, serverW := io.Pipe() // server -> client
|
||||
serverR, clientW := io.Pipe() // client -> server
|
||||
server := NewConn(serverR, serverW)
|
||||
server.Handle("ping", func(_ context.Context, _ json.RawMessage) (any, error) { return "pong", nil })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
_ = serverW.Close()
|
||||
_ = clientW.Close()
|
||||
}()
|
||||
go func() { _ = server.Serve(ctx) }()
|
||||
|
||||
go func() {
|
||||
_, _ = clientW.Write([]byte("this is not json\n"))
|
||||
_, _ = clientW.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}` + "\n"))
|
||||
}()
|
||||
|
||||
dec := json.NewDecoder(clientR)
|
||||
var sawParseError, sawPong bool
|
||||
for i := 0; i < 2; i++ {
|
||||
var msg struct {
|
||||
Result any `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- dec.Decode(&msg) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("decode response %d: %v", i, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for a response")
|
||||
}
|
||||
if msg.Error != nil && msg.Error.Code == codeParseError {
|
||||
sawParseError = true
|
||||
}
|
||||
if r, ok := msg.Result.(string); ok && r == "pong" {
|
||||
sawPong = true
|
||||
}
|
||||
}
|
||||
if !sawParseError {
|
||||
t.Error("expected a -32700 parse error for the malformed line")
|
||||
}
|
||||
if !sawPong {
|
||||
t.Error("expected the valid request to still be answered (connection survived the bad line)")
|
||||
}
|
||||
}
|
||||
|
||||
func asRPCError(err error, target **rpcError) bool {
|
||||
re, ok := err.(*rpcError)
|
||||
if ok {
|
||||
*target = re
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
// permission.go maps ZERO's permission prompt model onto ACP's
|
||||
// session/request_permission request/option/outcome model. The option id on the
|
||||
// wire carries the ZERO decision action verbatim, so mapping the client's
|
||||
// selection back to a ZERO decision is exact and lossless.
|
||||
|
||||
// buildPermissionOptions turns the decisions ZERO offers for a tool call into ACP
|
||||
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
||||
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
||||
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
actions := req.AvailableDecisions
|
||||
if len(actions) == 0 {
|
||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
||||
actions = []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
}
|
||||
options := make([]PermissionOption, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
kind, name := optionKindFor(action)
|
||||
if kind == "" {
|
||||
continue // skip actions that have no clean ACP option (e.g. cancel)
|
||||
}
|
||||
options = append(options, PermissionOption{
|
||||
OptionID: string(action),
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
|
||||
// human label. Returns an empty kind for actions that ACP expresses through the
|
||||
// outcome rather than an option (cancel).
|
||||
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||
switch action {
|
||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||
return PermAllowOnce, "Allow"
|
||||
case agent.PermissionDecisionAllowForSession:
|
||||
return PermAllowAlways, "Allow for this session"
|
||||
case agent.PermissionDecisionAllowPrefix:
|
||||
return PermAllowAlways, "Allow this command for the session"
|
||||
case agent.PermissionDecisionAlwaysAllow:
|
||||
return PermAllowAlways, "Always allow"
|
||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||
return PermAllowAlways, "Always allow this command"
|
||||
case agent.PermissionDecisionDeny:
|
||||
return PermRejectOnce, "Reject"
|
||||
case agent.PermissionDecisionCancel:
|
||||
return "", "" // expressed as outcome=cancelled, not an option
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
// decisionFromOutcome maps the client's permission outcome back to a ZERO
|
||||
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
|
||||
// action verbatim (validated against what was offered); anything unrecognized
|
||||
// fails closed to deny.
|
||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
|
||||
switch outcome.Outcome {
|
||||
case OutcomeCancelled:
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
||||
case OutcomeSelected:
|
||||
action := agent.PermissionDecisionAction(outcome.OptionID)
|
||||
// Bind to what was actually offered for THIS call: a client must not be able
|
||||
// to return a broader grant (always_allow / allow_for_session) that wasn't
|
||||
// presented. Anything not offered fails closed to deny.
|
||||
if actionOffered(action, offered) {
|
||||
return agent.PermissionDecision{Action: action}
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
||||
default:
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "no permission outcome"}
|
||||
}
|
||||
}
|
||||
|
||||
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
|
||||
for _, a := range offered {
|
||||
if a == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// permissionToolCall builds the ToolCall descriptor embedded in a
|
||||
// session/request_permission request from a ZERO permission request.
|
||||
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
||||
args := marshalArgs(req.Args)
|
||||
return ToolCallUpdate{
|
||||
ToolCallID: req.ToolCallID,
|
||||
Title: toolTitle(req.ToolName, string(args)),
|
||||
Kind: toolKindFor(req.ToolName),
|
||||
Status: ToolStatusPending,
|
||||
RawInput: rawInputBytes(args),
|
||||
}
|
||||
}
|
||||
|
||||
func marshalArgs(args map[string]any) []byte {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func rawInputBytes(data []byte) json.RawMessage {
|
||||
if len(data) == 0 || !json.Valid(data) {
|
||||
return nil
|
||||
}
|
||||
return json.RawMessage(data)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func TestBuildPermissionOptions(t *testing.T) {
|
||||
req := agent.PermissionRequest{
|
||||
ToolCallID: "tc1",
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowForSession,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
agent.PermissionDecisionCancel, // must be dropped (expressed as outcome)
|
||||
},
|
||||
}
|
||||
opts := buildPermissionOptions(req)
|
||||
if len(opts) != 4 {
|
||||
t.Fatalf("expected 4 options (cancel dropped), got %d: %+v", len(opts), opts)
|
||||
}
|
||||
// optionId must carry the ZERO action verbatim for a clean round trip.
|
||||
if opts[0].OptionID != string(agent.PermissionDecisionAllow) || opts[0].Kind != PermAllowOnce {
|
||||
t.Errorf("allow option = %+v", opts[0])
|
||||
}
|
||||
if opts[1].Kind != PermAllowAlways || opts[3].Kind != PermRejectOnce {
|
||||
t.Errorf("kinds = %q, %q", opts[1].Kind, opts[3].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionOptionsDefault(t *testing.T) {
|
||||
opts := buildPermissionOptions(agent.PermissionRequest{ToolName: "x"})
|
||||
if len(opts) != 2 {
|
||||
t.Fatalf("expected default allow+deny, got %d", len(opts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecisionFromOutcome(t *testing.T) {
|
||||
offered := []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
||||
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
||||
}
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: "allow"}, offered); d.Action != agent.PermissionDecisionAllow {
|
||||
t.Errorf("selected allow -> %q", d.Action)
|
||||
}
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: "always_allow"}, offered); d.Action != agent.PermissionDecisionAlwaysAllow {
|
||||
t.Errorf("selected always_allow -> %q", d.Action)
|
||||
}
|
||||
// Unknown option fails closed to deny.
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: "bogus"}, offered); d.Action != agent.PermissionDecisionDeny {
|
||||
t.Errorf("unknown option -> %q, want deny", d.Action)
|
||||
}
|
||||
// Missing/empty outcome fails closed to deny.
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{}, offered); d.Action != agent.PermissionDecisionDeny {
|
||||
t.Errorf("empty outcome -> %q, want deny", d.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionToolCall(t *testing.T) {
|
||||
tc := permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "tc9",
|
||||
ToolName: "read_file",
|
||||
Args: map[string]any{"path": "a.go"},
|
||||
})
|
||||
if tc.ToolCallID != "tc9" || tc.Kind != ToolKindRead || tc.Status != ToolStatusPending {
|
||||
t.Fatalf("unexpected toolCall: %+v", tc)
|
||||
}
|
||||
if tc.Title != "read_file a.go" {
|
||||
t.Errorf("title = %q", tc.Title)
|
||||
}
|
||||
if len(tc.RawInput) == 0 {
|
||||
t.Error("expected rawInput from args")
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
|
||||
// mapping functions are pure (no I/O) so they can be unit-tested directly; the
|
||||
// notifier wires them to a live JSON-RPC connection.
|
||||
|
||||
func agentMessageChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
func agentThoughtChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
// toolKindFor maps a ZERO tool name to the closest ACP ToolKind so editors can
|
||||
// pick an icon/affordance. Unknown tools fall back to "other".
|
||||
func toolKindFor(name string) string {
|
||||
switch name {
|
||||
case "read_file", "read_minified_file", "list_directory":
|
||||
return ToolKindRead
|
||||
case "glob", "grep":
|
||||
return ToolKindSearch
|
||||
case "write_file", "edit_file", "apply_patch":
|
||||
return ToolKindEdit
|
||||
case "bash", "exec_command", "write_stdin":
|
||||
return ToolKindExecute
|
||||
case "web_fetch", "web_search":
|
||||
return ToolKindFetch
|
||||
case "update_plan":
|
||||
return ToolKindThink
|
||||
default:
|
||||
return ToolKindOther
|
||||
}
|
||||
}
|
||||
|
||||
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
||||
func toolTitle(name, rawArgs string) string {
|
||||
if hint := primaryArgHint(rawArgs); hint != "" {
|
||||
return name + " " + hint
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if strings.TrimSpace(rawArgs) == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(rawArgs), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"path", "file_path", "pattern", "query", "command", "url", "cwd"} {
|
||||
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return truncateHint(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateHint(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
const max = 60
|
||||
if utf8.RuneCountInString(s) > max {
|
||||
return string([]rune(s)[:max]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rawInput returns the tool arguments as a raw JSON object when they parse, else
|
||||
// nil (so a malformed/empty arg string never produces invalid JSON on the wire).
|
||||
func rawInput(args string) json.RawMessage {
|
||||
if strings.TrimSpace(args) == "" || !json.Valid([]byte(args)) {
|
||||
return nil
|
||||
}
|
||||
return json.RawMessage(args)
|
||||
}
|
||||
|
||||
// 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{
|
||||
SessionUpdate: UpdateToolCall,
|
||||
ToolCallID: call.ID,
|
||||
Title: toolTitle(call.Name, call.Arguments),
|
||||
Kind: toolKindFor(call.Name),
|
||||
Status: ToolStatusInProgress,
|
||||
RawInput: rawInput(call.Arguments),
|
||||
}
|
||||
}
|
||||
|
||||
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
||||
func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
||||
status := ToolStatusCompleted
|
||||
if result.Status == tools.StatusError {
|
||||
status = ToolStatusFailed
|
||||
}
|
||||
upd := ToolCallUpdate{
|
||||
SessionUpdate: UpdateToolCallUpdate,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Status: status,
|
||||
}
|
||||
if content := toolResultContent(result); len(content) > 0 {
|
||||
upd.Content = content
|
||||
}
|
||||
if locs := toolResultLocations(result); len(locs) > 0 {
|
||||
upd.Locations = locs
|
||||
}
|
||||
return upd
|
||||
}
|
||||
|
||||
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
||||
text := strings.TrimRight(result.Output, "\n")
|
||||
if text == "" {
|
||||
text = result.Display.Summary
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []ToolCallContent{ToolContent(TextBlock(text))}
|
||||
}
|
||||
|
||||
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
||||
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
|
||||
for _, f := range result.ChangedFiles {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
continue
|
||||
}
|
||||
locs = append(locs, ToolCallLocation{Path: f})
|
||||
}
|
||||
return locs
|
||||
}
|
||||
|
||||
// planUpdate maps ZERO's plan items to an ACP "plan" update.
|
||||
func planUpdate(items []tools.PlanItem) PlanUpdate {
|
||||
entries := make([]PlanEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
entries = append(entries, PlanEntry{
|
||||
Content: it.Content,
|
||||
Priority: PlanPriorityMedium,
|
||||
Status: planStatusToACP(it.Status),
|
||||
})
|
||||
}
|
||||
return PlanUpdate{SessionUpdate: UpdatePlan, Entries: entries}
|
||||
}
|
||||
|
||||
// planStatusToACP maps ZERO's plan status (pending/in_progress/completed/failed)
|
||||
// to ACP's PlanEntryStatus (which has no "failed"; a failed step is terminal, so
|
||||
// it maps to completed).
|
||||
func planStatusToACP(s string) string {
|
||||
switch s {
|
||||
case "in_progress":
|
||||
return PlanStatusInProgress
|
||||
case "completed", "failed":
|
||||
return PlanStatusCompleted
|
||||
default:
|
||||
return PlanStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
// promptText concatenates the text content blocks of an inbound prompt.
|
||||
func promptText(blocks []ContentBlock) string {
|
||||
var b strings.Builder
|
||||
for _, blk := range blocks {
|
||||
if blk.Type == "text" {
|
||||
b.WriteString(blk.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// notifier sends translated updates over a connection for one session.
|
||||
type notifier struct {
|
||||
conn *Conn
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (n *notifier) send(update any) {
|
||||
_ = n.conn.Notify(MethodSessionUpdate, SessionNotification{SessionID: n.sessionID, Update: update})
|
||||
}
|
||||
|
||||
func (n *notifier) text(delta string) {
|
||||
if delta != "" {
|
||||
n.send(agentMessageChunk(delta))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) thought(delta string) {
|
||||
if delta != "" {
|
||||
n.send(agentThoughtChunk(delta))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) toolCall(call agent.ToolCall) { n.send(toolCallStart(call)) }
|
||||
func (n *notifier) toolResult(result agent.ToolResult) { n.send(toolCallResult(result)) }
|
||||
|
||||
func (n *notifier) plan(items []tools.PlanItem) {
|
||||
if len(items) > 0 {
|
||||
n.send(planUpdate(items))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) currentMode(modeID string) {
|
||||
n.send(CurrentModeUpdate{SessionUpdate: UpdateCurrentMode, CurrentModeID: modeID})
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
||||
m := agentMessageChunk("hello")
|
||||
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
||||
t.Fatalf("unexpected message chunk: %+v", m)
|
||||
}
|
||||
th := agentThoughtChunk("thinking")
|
||||
if th.SessionUpdate != UpdateAgentThoughtChunk || th.Content.Text != "thinking" {
|
||||
t.Fatalf("unexpected thought chunk: %+v", th)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolKindFor(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"read_file": ToolKindRead,
|
||||
"list_directory": ToolKindRead,
|
||||
"grep": ToolKindSearch,
|
||||
"glob": ToolKindSearch,
|
||||
"edit_file": ToolKindEdit,
|
||||
"apply_patch": ToolKindEdit,
|
||||
"bash": ToolKindExecute,
|
||||
"exec_command": ToolKindExecute,
|
||||
"web_fetch": ToolKindFetch,
|
||||
"update_plan": ToolKindThink,
|
||||
"some_mcp_tool": ToolKindOther,
|
||||
}
|
||||
for name, want := range cases {
|
||||
if got := toolKindFor(name); got != want {
|
||||
t.Errorf("toolKindFor(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolTitleAndHint(t *testing.T) {
|
||||
if got := toolTitle("read_file", `{"path":"src/main.go"}`); got != "read_file src/main.go" {
|
||||
t.Errorf("title = %q", got)
|
||||
}
|
||||
if got := toolTitle("bash", `{"command":"go test ./..."}`); got != "bash go test ./..." {
|
||||
t.Errorf("title = %q", got)
|
||||
}
|
||||
if got := toolTitle("mystery", `not json`); got != "mystery" {
|
||||
t.Errorf("malformed args should yield bare name, got %q", got)
|
||||
}
|
||||
if got := toolTitle("noargs", ``); got != "noargs" {
|
||||
t.Errorf("empty args should yield bare name, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStart(t *testing.T) {
|
||||
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
||||
if upd.SessionUpdate != UpdateToolCall {
|
||||
t.Fatalf("sessionUpdate = %q", upd.SessionUpdate)
|
||||
}
|
||||
if upd.ToolCallID != "tc1" || upd.Status != ToolStatusInProgress || upd.Kind != ToolKindRead {
|
||||
t.Fatalf("unexpected start: %+v", upd)
|
||||
}
|
||||
if string(upd.RawInput) != `{"path":"a.go"}` {
|
||||
t.Fatalf("rawInput = %s", upd.RawInput)
|
||||
}
|
||||
// Malformed args must not produce invalid JSON on the wire.
|
||||
if got := toolCallStart(agent.ToolCall{ID: "x", Name: "bash", Arguments: "broken"}); got.RawInput != nil {
|
||||
t.Fatalf("malformed args should drop rawInput, got %s", got.RawInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResult(t *testing.T) {
|
||||
ok := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "tc1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "applied\n",
|
||||
ChangedFiles: []string{"a.go", ""},
|
||||
})
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
|
||||
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
||||
if failed.Status != ToolStatusFailed {
|
||||
t.Fatalf("error result should be failed, got %q", failed.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanUpdateAndStatus(t *testing.T) {
|
||||
upd := planUpdate([]tools.PlanItem{
|
||||
{Content: "step a", Status: "completed"},
|
||||
{Content: "step b", Status: "in_progress"},
|
||||
{Content: "step c", Status: "failed"},
|
||||
{Content: "step d", Status: "weird"},
|
||||
})
|
||||
if upd.SessionUpdate != UpdatePlan || len(upd.Entries) != 4 {
|
||||
t.Fatalf("unexpected plan: %+v", upd)
|
||||
}
|
||||
want := []string{PlanStatusCompleted, PlanStatusInProgress, PlanStatusCompleted, PlanStatusPending}
|
||||
for i, w := range want {
|
||||
if upd.Entries[i].Status != w {
|
||||
t.Errorf("entry %d status = %q, want %q", i, upd.Entries[i].Status, w)
|
||||
}
|
||||
if upd.Entries[i].Priority != PlanPriorityMedium {
|
||||
t.Errorf("entry %d priority = %q", i, upd.Entries[i].Priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptText(t *testing.T) {
|
||||
got := promptText([]ContentBlock{
|
||||
TextBlock("hello "),
|
||||
ImageBlock("base64", "image/png"),
|
||||
TextBlock("world"),
|
||||
})
|
||||
if got != "hello world" {
|
||||
t.Fatalf("promptText = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolTitleTruncateHintRuneSafe(t *testing.T) {
|
||||
// A 61-character string containing multi-byte UTF-8 runes (emojis / CJK characters).
|
||||
// We want to verify that it is truncated without cutting any runes or producing invalid UTF-8.
|
||||
longPath := "📁/项目/非常长的路径名称/测试/🚀/emoji-and-cjk-characters-which-are-very-long-and-exceed-sixty-characters"
|
||||
|
||||
// Create JSON args for read_file
|
||||
rawArgs := `{"path":"` + longPath + `"}`
|
||||
got := toolTitle("read_file", rawArgs)
|
||||
|
||||
expectedPrefix := "read_file "
|
||||
if !strings.HasPrefix(got, expectedPrefix) {
|
||||
t.Fatalf("expected title to start with %q, got %q", expectedPrefix, got)
|
||||
}
|
||||
|
||||
hint := strings.TrimPrefix(got, expectedPrefix)
|
||||
// Hint should end with the ellipsis character
|
||||
if !strings.HasSuffix(hint, "…") {
|
||||
t.Fatalf("expected truncated hint to end with ellipsis, got %q", hint)
|
||||
}
|
||||
|
||||
// Check that we don't have invalid UTF-8 runes
|
||||
if !utf8.ValidString(hint) {
|
||||
t.Fatalf("truncated hint is not a valid UTF-8 string: %q", hint)
|
||||
}
|
||||
|
||||
// The rune count of the hint (excluding ellipsis) should be exactly 60
|
||||
runes := []rune(strings.TrimSuffix(hint, "…"))
|
||||
if len(runes) != 60 {
|
||||
t.Fatalf("expected exactly 60 runes before ellipsis, got %d (hint: %q)", len(runes), hint)
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
package acp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ProtocolVersion is the ACP protocol version ZERO speaks. Wire compatibility is
|
||||
// negotiated during initialize; v1 is the current stable version.
|
||||
const ProtocolVersion = 1
|
||||
|
||||
// Method names exactly as they appear on the wire (public ACP spec).
|
||||
const (
|
||||
MethodInitialize = "initialize"
|
||||
MethodAuthenticate = "authenticate"
|
||||
MethodSessionNew = "session/new"
|
||||
MethodSessionLoad = "session/load"
|
||||
MethodSessionPrompt = "session/prompt"
|
||||
MethodSessionCancel = "session/cancel" // notification
|
||||
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
||||
MethodSessionSetMode = "session/set_mode"
|
||||
MethodSessionSetConfigOption = "session/set_config_option"
|
||||
MethodSessionRequestPerm = "session/request_permission" // agent -> client
|
||||
MethodFSReadTextFile = "fs/read_text_file" // agent -> client
|
||||
MethodFSWriteTextFile = "fs/write_text_file" // agent -> client
|
||||
|
||||
// Vendor-prefixed ZERO extensions (clients that don't support them ignore the
|
||||
// method and degrade cleanly, per the spec's _-prefixed convention).
|
||||
MethodZeroSetModel = "_zero/set_model"
|
||||
)
|
||||
|
||||
// SessionUpdate discriminator values (the "sessionUpdate" field).
|
||||
const (
|
||||
UpdateAgentMessageChunk = "agent_message_chunk"
|
||||
UpdateAgentThoughtChunk = "agent_thought_chunk"
|
||||
UpdateUserMessageChunk = "user_message_chunk"
|
||||
UpdateToolCall = "tool_call"
|
||||
UpdateToolCallUpdate = "tool_call_update"
|
||||
UpdatePlan = "plan"
|
||||
UpdateAvailableCommands = "available_commands_update"
|
||||
UpdateCurrentMode = "current_mode_update"
|
||||
)
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
type Implementation struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
type FileSystemCapabilities struct {
|
||||
ReadTextFile bool `json:"readTextFile"`
|
||||
WriteTextFile bool `json:"writeTextFile"`
|
||||
}
|
||||
|
||||
type ClientCapabilities struct {
|
||||
FS FileSystemCapabilities `json:"fs"`
|
||||
Terminal bool `json:"terminal"`
|
||||
}
|
||||
|
||||
type PromptCapabilities struct {
|
||||
Image bool `json:"image"`
|
||||
Audio bool `json:"audio"`
|
||||
EmbeddedContext bool `json:"embeddedContext"`
|
||||
}
|
||||
|
||||
type AgentCapabilities struct {
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
}
|
||||
|
||||
type AuthMethod struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type InitializeParams struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
ClientCapabilities ClientCapabilities `json:"clientCapabilities"`
|
||||
ClientInfo *Implementation `json:"clientInfo,omitempty"`
|
||||
}
|
||||
|
||||
type InitializeResult struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
AgentCapabilities AgentCapabilities `json:"agentCapabilities"`
|
||||
AgentInfo *Implementation `json:"agentInfo,omitempty"`
|
||||
AuthMethods []AuthMethod `json:"authMethods"`
|
||||
}
|
||||
|
||||
// ---- content blocks ----
|
||||
|
||||
// ContentBlock is the polymorphic content type. ZERO emits "text" (and "image"
|
||||
// on tool content); it parses "text", "image", and "resource"/"resource_link"
|
||||
// from inbound prompts. A single struct with omitempty fields covers both
|
||||
// directions since the field names do not collide across the variants ZERO uses.
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"` // image/audio: base64
|
||||
MimeType string `json:"mimeType,omitempty"` // image/audio
|
||||
URI string `json:"uri,omitempty"` // resource_link
|
||||
Name string `json:"name,omitempty"` // resource_link
|
||||
Resource json.RawMessage `json:"resource,omitempty"` // embedded resource
|
||||
}
|
||||
|
||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||
// configuration (BYOK), so these are accepted for spec compliance; ZERO's
|
||||
// configured servers remain authoritative.
|
||||
type McpServer struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env json.RawMessage `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionParams struct {
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionResult struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions,omitempty"`
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
type LoadSessionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type LoadSessionResult struct {
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions,omitempty"`
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
type PromptParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Prompt []ContentBlock `json:"prompt"`
|
||||
}
|
||||
|
||||
// StopReason values (why a prompt turn ended).
|
||||
const (
|
||||
StopEndTurn = "end_turn"
|
||||
StopMaxTokens = "max_tokens"
|
||||
StopRefusal = "refusal"
|
||||
StopCancelled = "cancelled"
|
||||
)
|
||||
|
||||
type PromptResult struct {
|
||||
StopReason string `json:"stopReason"`
|
||||
}
|
||||
|
||||
type CancelParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// ---- session/update notification ----
|
||||
|
||||
type SessionNotification struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Update any `json:"update"`
|
||||
}
|
||||
|
||||
// AgentMessageChunk / AgentThoughtChunk / UserMessageChunk all carry a single
|
||||
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
||||
type ContentChunk struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
Content ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
// ToolKind classifies a tool call for client rendering.
|
||||
const (
|
||||
ToolKindRead = "read"
|
||||
ToolKindEdit = "edit"
|
||||
ToolKindDelete = "delete"
|
||||
ToolKindMove = "move"
|
||||
ToolKindSearch = "search"
|
||||
ToolKindExecute = "execute"
|
||||
ToolKindThink = "think"
|
||||
ToolKindFetch = "fetch"
|
||||
ToolKindOther = "other"
|
||||
)
|
||||
|
||||
// ToolCallStatus values.
|
||||
const (
|
||||
ToolStatusPending = "pending"
|
||||
ToolStatusInProgress = "in_progress"
|
||||
ToolStatusCompleted = "completed"
|
||||
ToolStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// ToolCallUpdate is used for both the initial "tool_call" and subsequent
|
||||
// "tool_call_update" notifications (distinguished by SessionUpdate). It also
|
||||
// appears inside session/request_permission.
|
||||
type ToolCallUpdate 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"`
|
||||
}
|
||||
|
||||
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
||||
// text/image block) and "diff" (a file change); "terminal" is part of the spec
|
||||
// but unused because ZERO executes locally.
|
||||
type ToolCallContent struct {
|
||||
Type string `json:"type"`
|
||||
// type == "content"
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
// type == "diff"
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText string `json:"oldText,omitempty"`
|
||||
NewText string `json:"newText,omitempty"`
|
||||
}
|
||||
|
||||
func ToolContent(block ContentBlock) ToolCallContent {
|
||||
return ToolCallContent{Type: "content", Content: &block}
|
||||
}
|
||||
|
||||
type ToolCallLocation struct {
|
||||
Path string `json:"path"`
|
||||
Line *int `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// ---- plan ----
|
||||
|
||||
const (
|
||||
PlanStatusPending = "pending"
|
||||
PlanStatusInProgress = "in_progress"
|
||||
PlanStatusCompleted = "completed"
|
||||
|
||||
PlanPriorityHigh = "high"
|
||||
PlanPriorityMedium = "medium"
|
||||
PlanPriorityLow = "low"
|
||||
)
|
||||
|
||||
type PlanEntry struct {
|
||||
Content string `json:"content"`
|
||||
Priority string `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type PlanUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
Entries []PlanEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type AvailableCommand struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type AvailableCommandsUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
AvailableCommands []AvailableCommand `json:"availableCommands"`
|
||||
}
|
||||
|
||||
type CurrentModeUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
CurrentModeID string `json:"currentModeId"`
|
||||
}
|
||||
|
||||
// ---- permissions ----
|
||||
|
||||
const (
|
||||
PermAllowOnce = "allow_once"
|
||||
PermAllowAlways = "allow_always"
|
||||
PermRejectOnce = "reject_once"
|
||||
PermRejectAlways = "reject_always"
|
||||
)
|
||||
|
||||
type PermissionOption struct {
|
||||
OptionID string `json:"optionId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type RequestPermissionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ToolCall ToolCallUpdate `json:"toolCall"`
|
||||
Options []PermissionOption `json:"options"`
|
||||
}
|
||||
|
||||
// RequestPermissionOutcome is a tagged union: {"outcome":"cancelled"} or
|
||||
// {"outcome":"selected","optionId":"..."}.
|
||||
type RequestPermissionOutcome struct {
|
||||
Outcome string `json:"outcome"`
|
||||
OptionID string `json:"optionId,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
OutcomeSelected = "selected"
|
||||
OutcomeCancelled = "cancelled"
|
||||
)
|
||||
|
||||
type RequestPermissionResult struct {
|
||||
Outcome RequestPermissionOutcome `json:"outcome"`
|
||||
}
|
||||
|
||||
// ---- session modes ----
|
||||
|
||||
type SessionMode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type SessionModeState struct {
|
||||
CurrentModeID string `json:"currentModeId"`
|
||||
AvailableModes []SessionMode `json:"availableModes"`
|
||||
}
|
||||
|
||||
type SetSessionModeParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ModeID string `json:"modeId"`
|
||||
}
|
||||
|
||||
type SetSessionModeResult struct{}
|
||||
|
||||
// ---- session config options ----
|
||||
|
||||
type SessionConfigOptionValue struct {
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type SessionConfigOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Type string `json:"type"`
|
||||
CurrentValue string `json:"currentValue"`
|
||||
Options []SessionConfigOptionValue `json:"options"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ConfigID string `json:"configId"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionResult struct {
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions"`
|
||||
}
|
||||
|
||||
// ---- vendor: _zero/set_model ----
|
||||
|
||||
type ZeroSetModelParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type ZeroSetModelResult struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
const (
|
||||
configIDModel = "model"
|
||||
configIDMode = "mode"
|
||||
|
||||
configOptionTypeSelect = "select"
|
||||
configCategoryModel = "model"
|
||||
configCategoryMode = "mode"
|
||||
)
|
||||
@@ -1,248 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
|
||||
t.Helper()
|
||||
// checker/verifier nil → no LSP / project-test pass runs; we only need a
|
||||
// non-nil SelfCorrector to enable the task-grounded acceptance gate.
|
||||
return NewSelfCorrector(t.TempDir(), nil, nil, SelfCorrectConfig{Enabled: true})
|
||||
}
|
||||
|
||||
// BUG #2(a): a final message that admits the model guessed / could not meet the
|
||||
// objective must be reported as INCOMPLETE, never success — and immediately
|
||||
// (an admitted guess is not worth re-prompting).
|
||||
func TestAcceptanceSelfReportAdmissionIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I couldn't analyze the board image, so I wrote the common opening move e2e4 as my best guess."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best chess move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("admitted guess must be Incomplete, got success; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "objective was not met") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite the admission", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("an admission should downgrade immediately (1 turn), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): the inability detector must generalize over the verb the model uses
|
||||
// (so re-phrasing can't defeat it) while NOT misreading success-y negations
|
||||
// ("could not find any issues") as admissions.
|
||||
func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
admit bool
|
||||
}{
|
||||
// real chess re-run wording that slipped a fixed phrase list:
|
||||
{"…which I cannot do without proper image analysis capabilities.", true},
|
||||
{"I cannot visually analyze the chess board image.", true},
|
||||
{"I could not recover the original values, so this is my best guess.", true},
|
||||
{"I was unable to complete the optimization.", true},
|
||||
{"The result may not be correct.", true},
|
||||
// multi-occurrence: an early guarded use ("could not find any") must NOT mask a
|
||||
// later genuine admission with the same stem (review #5):
|
||||
{"I could not find any examples in the codebase, so I could not implement the feature.", true},
|
||||
// genuine successes that must NOT be flagged:
|
||||
{"I implemented the function and all tests pass.", false},
|
||||
{"I verified the fix; I could not find any remaining issues.", false},
|
||||
{"I cannot reproduce the bug, so the fix holds.", false},
|
||||
{"Done — the output matches the required format and values.", false},
|
||||
// behavior descriptions that must NOT be flagged — these phrases describe
|
||||
// implemented behavior, not an admission (review #1, removed from the list):
|
||||
{"The parser will fall back to UTF-8 when no encoding is set.", false},
|
||||
{"I replaced the placeholder value with the computed key.", false},
|
||||
{"The retry uses exponential backoff as a fallback.", false},
|
||||
{"Without proper error handling the function would crash, so I added it.", false},
|
||||
// real false positive (conversational recap): retelling a PAST exchange is
|
||||
// not an admission about the current objective — the sentence carries its
|
||||
// own narrative context ("You asked …", "at the time"):
|
||||
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
|
||||
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
|
||||
{"Last session I was unable to build the project; that is what we should retry now.", false},
|
||||
// quoted admissions (the model citing its own earlier message or a log
|
||||
// line) must NOT be flagged:
|
||||
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
|
||||
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
|
||||
// current-objective admissions must STILL be flagged, including past tense
|
||||
// and multi-sentence finals whose other sentences are narrative:
|
||||
{"I couldn't complete the refactor.", true},
|
||||
{"I hit an error I could not resolve, so the migration is unfinished.", true},
|
||||
{"You asked me to port the parser. I couldn't finish the second half.", true},
|
||||
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
|
||||
// spans are stripped), so an admission after a stray quote still fires:
|
||||
{"The log ends with a stray \" and I couldn't finish the migration.", true},
|
||||
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
|
||||
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
if got != c.admit {
|
||||
t.Errorf("selfReportedIncompletion(%q) admitted=%v, want %v", c.text, got, c.admit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): a "cannot analyze / cannot determine" admission (present tense) must
|
||||
// be caught even when the model also stamps a bogus "PASS" based on a shape check.
|
||||
// Mirrors the chess-best-move final ("I cannot analyze the position… PASS: I wrote
|
||||
// the move and verified by reading it back").
|
||||
func TestAcceptanceCannotPhrasingIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I cannot analyze the specific position from the image, so I cannot determine the best move. PASS: I wrote e2e4 to the file and verified it by reading it back."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a 'cannot analyze/determine' admission must be Incomplete despite the bogus PASS; final=%q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending/in_progress update_plan item is an AMBIGUOUS signal (the model may have
|
||||
// finished without re-marking the step), so it must NOT by itself force INCOMPLETE.
|
||||
// The gate nudges, but if the model keeps claiming completion with no continuation
|
||||
// cue and no admission, the run finalizes as SUCCESS — trusting the completion claim
|
||||
// over stale plan bookkeeping. (Only a continuation cue or self-report admission
|
||||
// forces INCOMPLETE; see TestCompletionGate* and TestAcceptance*Admission*.)
|
||||
func TestPendingPlanAloneDoesNotForceIncomplete(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
done := "All set." // confident; no cue, no admission — only the plan is stale
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "in_progress"),
|
||||
textTurn(done), textTurn(done), textTurn(done), textTurn(done), textTurn(done),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do the multi-step task", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("pending-plan alone must NOT force Incomplete (stale bookkeeping != unfinished); reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != done {
|
||||
t.Fatalf("final answer = %q, want the completion claim %q", result.FinalAnswer, done)
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected at least one continue-nudge before accepting completion")
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(b): when self-correct is on, the run must pass a one-time task-grounded
|
||||
// acceptance check before success. A model that, when challenged, confirms its
|
||||
// work meets the criterion (no admission) then finalizes as success — no regression.
|
||||
func TestAcceptanceGroundedCompletionSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I implemented the function."),
|
||||
textTurn("PASS. I ran the verification and it passes; the output meets the task requirement."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement the function", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("a verified completion must succeed, got Incomplete (%q)", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != "PASS. I ran the verification and it passes; the output meets the task requirement." {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
// Exactly one acceptance pass: the first "done" turn was challenged, the second confirmed.
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 acceptance challenge + 1 confirmation), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("expected the task-grounded acceptance check to be demanded")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (interactive/TUI default), even an admission is accepted as the
|
||||
// final answer exactly as before — proving no behavior change for non-headless callers.
|
||||
func TestAcceptanceGateOffLeavesAdmissionAsSuccess(t *testing.T) {
|
||||
admission := "I couldn't determine the value, so this is a guess."
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(admission),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal false, SelfCorrect nil — the legacy path.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != admission {
|
||||
t.Fatalf("final answer = %q, want the text verbatim (legacy behavior)", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// The acceptance check is scoped to runs with self-correct on: a headless run
|
||||
// WITHOUT a SelfCorrector accepts a confident completion immediately (the (b) gate
|
||||
// is inert), so enabling RequireCompletionSignal alone adds no acceptance turn.
|
||||
func TestAcceptanceGateRequiresSelfCorrect(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Done. Implemented and it works."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement it", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
// SelfCorrect deliberately nil → acceptance gate (b) must not fire.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("confident completion without self-correct must succeed; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("acceptance gate must not fire without self-correct; got %d turns", len(provider.requests))
|
||||
}
|
||||
if someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("acceptance nudge must not be injected when self-correct is off")
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
// must be rejected as a plain tool error BEFORE any permission prompt, not
|
||||
// surfaced as a confusing "approved but denied" prompt outcome.
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The mismatched-flag case (additional_permissions present without the
|
||||
// sandbox_permissions flag) must be rejected the same way.
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection message must show a concrete, correctly-shaped example: a
|
||||
// model that gets this wrong once (attaching sandbox_permissions out of habit
|
||||
// from an earlier call, without a valid payload) needs enough in the error to
|
||||
// self-correct on retry instead of repeating the identical mistake.
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
// permission prompt: this fix only rejects malformed payloads.
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionDeny}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if promptCalls != 1 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
@@ -141,11 +141,9 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
|
||||
registry := registryWithAskUser()
|
||||
args := `{"questions":[{"question":"Which framework?"}]}`
|
||||
provider := providerCallingAskUserThenAnswer(args, "done")
|
||||
var toolResults []ToolResult
|
||||
|
||||
result, err := Run(context.Background(), "clarify", provider, Options{
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
|
||||
Registry: registry,
|
||||
OnAskUser: func(_ context.Context, _ AskUserRequest) (AskUserResponse, error) {
|
||||
return AskUserResponse{}, context.Canceled
|
||||
},
|
||||
@@ -160,9 +158,6 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected the run to stop after the canceled ask_user (1 turn), got %d", len(provider.requests))
|
||||
}
|
||||
if len(toolResults) != 1 || toolResults[0].ToolCallID == "" {
|
||||
t.Fatalf("cancellation must emit exactly one result for the call, got %#v", toolResults)
|
||||
}
|
||||
// The recorded tool result must reflect cancellation, not a synthetic answer.
|
||||
var toolMsg string
|
||||
for _, m := range result.Messages {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
|
||||
// write_file: every edit blocked its tool result on a fresh-publish wait with
|
||||
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
|
||||
// the largest recurring latency tax in an edit-heavy session. Collection is
|
||||
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
|
||||
// single worker checks it while the rest of the tool batch (and any
|
||||
// self-correct verification) runs, and the loop drains completed results just
|
||||
// before building the NEXT provider request, appending errors as a user-role
|
||||
// nudge. The model always issues another request after an edit turn — it has
|
||||
// to read its tool results — so it still sees introduced errors at the same
|
||||
// decision point as before, without any tool call stalling on the language
|
||||
// server.
|
||||
//
|
||||
// A file re-edited before its check runs is checked once in its final state
|
||||
// (the checker re-reads the file); a file re-edited after its check completed
|
||||
// is re-enqueued and the newer result wins. When the worker is still busy at
|
||||
// drain time, the drain gives up after a short wait and the results are
|
||||
// delivered on the following turn instead — they stay accurate because any
|
||||
// further edit re-enqueues the file.
|
||||
|
||||
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
|
||||
// check before deferring delivery to the next turn. Most checks finish well
|
||||
// under it (debounce floor + analysis); a cold language server on a large
|
||||
// repo does not get to stall the loop the way the old inline 10s cap could.
|
||||
// A var so tests can shorten the wait.
|
||||
var asyncDiagnosticsDrainTimeout = 3 * time.Second
|
||||
|
||||
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
|
||||
// FINALIZE (natural completion or the max-turns summary): there is no later
|
||||
// turn to defer to, so the gate waits out the old inline-era budget rather
|
||||
// than silently dropping an error the last edit introduced. A var so tests
|
||||
// can shorten the wait.
|
||||
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
|
||||
|
||||
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
|
||||
// the old inline block so the model reacts the same way.
|
||||
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
|
||||
|
||||
type asyncDiagnostics struct {
|
||||
check func(context.Context, string) string
|
||||
workspaceRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
queue []string
|
||||
queued map[string]bool
|
||||
results map[string]string
|
||||
// working is non-nil while the worker goroutine runs and is closed when it
|
||||
// exits, so drain can wait for quiescence without polling.
|
||||
working chan struct{}
|
||||
}
|
||||
|
||||
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
|
||||
// receiver is a no-op — callers wire it unconditionally.
|
||||
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
|
||||
if check == nil {
|
||||
return nil
|
||||
}
|
||||
return &asyncDiagnostics{
|
||||
check: check,
|
||||
workspaceRoot: workspaceRoot,
|
||||
queued: map[string]bool{},
|
||||
results: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue schedules changed files for a background check. Paths are
|
||||
// workspace-relative from Result.ChangedFiles (absolute when under a granted
|
||||
// extra write root), mirroring SelfCorrector's resolution.
|
||||
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
|
||||
if diagnostics == nil || len(files) == 0 {
|
||||
return
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
for _, file := range files {
|
||||
path := diagnostics.absPath(file)
|
||||
if diagnostics.queued[path] {
|
||||
continue
|
||||
}
|
||||
diagnostics.queued[path] = true
|
||||
// A completed result for this file describes a pre-edit state now.
|
||||
delete(diagnostics.results, path)
|
||||
diagnostics.queue = append(diagnostics.queue, path)
|
||||
}
|
||||
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
|
||||
done := make(chan struct{})
|
||||
diagnostics.working = done
|
||||
go diagnostics.work(ctx, done)
|
||||
}
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
|
||||
diagnostics.working = nil
|
||||
diagnostics.mu.Unlock()
|
||||
return
|
||||
}
|
||||
path := diagnostics.queue[0]
|
||||
diagnostics.queue = diagnostics.queue[1:]
|
||||
// Un-mark before checking so an edit that lands DURING the check
|
||||
// re-enqueues the file and it is checked again in its final state.
|
||||
delete(diagnostics.queued, path)
|
||||
diagnostics.mu.Unlock()
|
||||
|
||||
block := diagnostics.check(ctx, path)
|
||||
|
||||
diagnostics.mu.Lock()
|
||||
if block != "" {
|
||||
diagnostics.results[path] = block
|
||||
} else {
|
||||
delete(diagnostics.results, path)
|
||||
}
|
||||
diagnostics.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// drain waits briefly for the worker to go quiet, then formats and consumes
|
||||
// all completed error blocks as one nudge. Returns "" when there is nothing
|
||||
// to report or the worker is still busy (results then surface next turn).
|
||||
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
|
||||
}
|
||||
|
||||
// drainFinal is drain with the finalization budget: called before the run's
|
||||
// last model request, where an undelivered result would otherwise be lost.
|
||||
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
|
||||
if diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy != nil {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-busy:
|
||||
case <-timer.C:
|
||||
return ""
|
||||
case <-ctx.Done():
|
||||
return ""
|
||||
}
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
if len(diagnostics.results) == 0 {
|
||||
return ""
|
||||
}
|
||||
paths := make([]string, 0, len(diagnostics.results))
|
||||
for path := range diagnostics.results {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
blocks := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
blocks = append(blocks, diagnostics.results[path])
|
||||
}
|
||||
diagnostics.results = map[string]string{}
|
||||
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) absPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(diagnostics.workspaceRoot, path)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
||||
var diagnostics *asyncDiagnostics
|
||||
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
|
||||
t.Fatal("nil check must yield a nil collector")
|
||||
}
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("nil collector drain = %q, want empty", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
|
||||
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
|
||||
// (no drive letter), which is exactly what the assertions below check.
|
||||
root := t.TempDir()
|
||||
var mu sync.Mutex
|
||||
var checked []string
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checked = append(checked, absPath)
|
||||
mu.Unlock()
|
||||
if filepath.Base(absPath) == "clean.go" {
|
||||
return ""
|
||||
}
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, root)
|
||||
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
|
||||
t.Fatalf("nudge missing prefix: %q", nudge)
|
||||
}
|
||||
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
|
||||
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
|
||||
}
|
||||
mu.Lock()
|
||||
for _, path := range checked {
|
||||
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
||||
t.Fatalf("check received %q, want path under workspace root %q", path, root)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if second := diagnostics.drain(context.Background()); second != "" {
|
||||
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
|
||||
}
|
||||
}
|
||||
|
||||
// A check still running at drain time defers delivery to the next drain
|
||||
// instead of blocking the turn.
|
||||
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
release := make(chan struct{})
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
<-release
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
diagnostics.enqueue(context.Background(), []string{"slow.go"})
|
||||
|
||||
start := time.Now()
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
if !strings.Contains(nudge, "ERR slow.go") {
|
||||
t.Fatalf("deferred nudge = %q", nudge)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("deferred diagnostics never delivered")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enqueueing a file after its check completed replaces the stale result.
|
||||
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
response := "ERR old"
|
||||
check := func(context.Context, string) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return response
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
|
||||
t.Fatalf("nudge = %q, want only the re-check result", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
func (changedFilesTool) Name() string { return "fake_edit" }
|
||||
func (changedFilesTool) Description() string { return "test mutating tool" }
|
||||
func (changedFilesTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (changedFilesTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
|
||||
}
|
||||
|
||||
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
|
||||
// result (the old inline path), but the next request contains the background
|
||||
// diagnostics nudge, before the model finalizes.
|
||||
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
root := t.TempDir()
|
||||
var checkedPath string
|
||||
var mu sync.Mutex
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: root,
|
||||
FileDiagnostics: func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checkedPath = absPath
|
||||
mu.Unlock()
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// The tool result itself must NOT block on / embed diagnostics.
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
|
||||
}
|
||||
}
|
||||
// The nudge must be present in the second request as a user message.
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no async diagnostics nudge in the follow-up request")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if want := filepath.Join(root, "main.go"); checkedPath != want {
|
||||
t.Fatalf("checked path = %q, want %q", checkedPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
// When the run hits the maxTurns ceiling right after an edit turn, the
|
||||
// final-answer request must still carry the diagnostics nudge — otherwise an
|
||||
// error introduced by the last edit would go unreported in the summary.
|
||||
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "summary"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
MaxTurns: 1,
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "summary" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
|
||||
}
|
||||
}
|
||||
|
||||
// When the model finalizes while a slow check is still in flight (the
|
||||
// per-turn drain missed it), the finalization gate must wait it out and give
|
||||
// the model one more turn with the nudge instead of dropping the errors.
|
||||
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
// Slower than the (shortened) per-turn drain, well under the
|
||||
// finalization budget.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done after fix" {
|
||||
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[2].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
|
||||
}
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return nil
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if requested, ok := requestedPrefixRule(args); ok {
|
||||
if safeRequestedPrefixForSegments(requested, segments) {
|
||||
return requested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Only propose approving a prefix of one segment when every other segment
|
||||
// in the command is independently known-safe. Once a prefix is approved,
|
||||
// shellExecutionArgsForApproval escalates the whole command (every
|
||||
// segment, not just the approved one) to bypass the sandbox, so offering a
|
||||
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
|
||||
// would let that segment run unsandboxed without ever being reviewed.
|
||||
for index, tokens := range segments {
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), segments[0]...)
|
||||
}
|
||||
|
||||
// otherSegmentsKnownSafe reports whether every segment other than the one at
|
||||
// skip is known-safe on its own.
|
||||
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
|
||||
for index, tokens := range segments {
|
||||
if index == skip {
|
||||
continue
|
||||
}
|
||||
if !knownSafeCommandSegment(tokens) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||
if !isShellCommandTool(toolName) || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
var matched sandbox.CommandPrefixGrant
|
||||
matchedAny := false
|
||||
matchedSession := false
|
||||
for _, tokens := range segments {
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
continue
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
matchedSession = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if matchedAny {
|
||||
return matched, true, matchedSession
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
|
||||
func shellExecutionArgsForApproval(toolName string, args map[string]any, action PermissionDecisionAction, options Options) map[string]any {
|
||||
if !isShellCommandTool(toolName) || !shellPrefixApprovalBypassesSandbox(action) {
|
||||
return args
|
||||
}
|
||||
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return args
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
|
||||
return args
|
||||
}
|
||||
planned := cloneArgs(args)
|
||||
if planned == nil {
|
||||
planned = map[string]any{}
|
||||
}
|
||||
planned["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
|
||||
return planned
|
||||
}
|
||||
|
||||
func shellPrefixApprovalBypassesSandbox(action PermissionDecisionAction) bool {
|
||||
return action == PermissionDecisionAllowPrefix || action == PermissionDecisionAlwaysAllowPrefix
|
||||
}
|
||||
|
||||
func shellCommandRequiresEscalated(args map[string]any) bool {
|
||||
raw, ok := args["sandbox_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
value = fmt.Sprint(raw)
|
||||
}
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated)
|
||||
}
|
||||
|
||||
func isShellCommandTool(toolName string) bool {
|
||||
return toolName == "bash" || toolName == "exec_command"
|
||||
}
|
||||
|
||||
func firstStringArg(args map[string]any, names ...string) (string, bool) {
|
||||
for _, name := range names {
|
||||
if raw, ok := args[name].(string); ok {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value != "" {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func requestedPrefixRule(args map[string]any) ([]string, bool) {
|
||||
raw, ok := args["prefix_rule"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case []string:
|
||||
return cleanPrefixRule(value), true
|
||||
case []any:
|
||||
out := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
part, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return cleanPrefixRule(out), true
|
||||
default:
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
|
||||
func cleanPrefixRule(prefix []string) []string {
|
||||
cleaned := make([]string, 0, len(prefix))
|
||||
for _, part := range prefix {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil
|
||||
}
|
||||
cleaned = append(cleaned, part)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for _, command := range segments {
|
||||
if len(prefix) > len(command) {
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
if hasStringPrefix(command, prefix) {
|
||||
matched = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func safeShellCommandSegments(command string) ([][]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return nil, false
|
||||
}
|
||||
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
|
||||
if err != nil || len(file.Stmts) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
segments := make([][]string, 0, len(file.Stmts))
|
||||
for _, stmt := range file.Stmts {
|
||||
if !collectSafeShellStatement(stmt, &segments) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return segments, true
|
||||
}
|
||||
|
||||
func collectSafeShellStatement(stmt *syntax.Stmt, segments *[][]string) bool {
|
||||
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 {
|
||||
return false
|
||||
}
|
||||
return collectSafeShellCommand(stmt.Cmd, segments)
|
||||
}
|
||||
|
||||
func collectSafeShellCommand(cmd syntax.Command, segments *[][]string) bool {
|
||||
switch node := cmd.(type) {
|
||||
case *syntax.CallExpr:
|
||||
tokens, ok := literalCallTokens(node)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
*segments = append(*segments, tokens)
|
||||
return true
|
||||
case *syntax.BinaryCmd:
|
||||
switch node.Op {
|
||||
case syntax.AndStmt, syntax.OrStmt, syntax.Pipe:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return collectSafeShellStatement(node.X, segments) && collectSafeShellStatement(node.Y, segments)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
|
||||
if call == nil || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tokens := make([]string, 0, len(call.Args))
|
||||
for _, word := range call.Args {
|
||||
if len(word.Parts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
lit, ok := word.Parts[0].(*syntax.Lit)
|
||||
if !ok || strings.ContainsAny(lit.Value, "*?[]{}") {
|
||||
return nil, false
|
||||
}
|
||||
tokens = append(tokens, lit.Value)
|
||||
}
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
name := commandName(command[0])
|
||||
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
||||
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
||||
"true", "uname", "uniq", "wc", "which", "whoami":
|
||||
return true
|
||||
case "base64":
|
||||
return safeBase64Command(command[1:])
|
||||
case "find":
|
||||
return safeFindCommand(command[1:])
|
||||
case "rg":
|
||||
return safeRipgrepCommand(command[1:])
|
||||
case "sed":
|
||||
return safeSedCommand(command[1:])
|
||||
case "git":
|
||||
return safeGitCommand(command)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func safeBase64Command(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "-o" || arg == "--output" || strings.HasPrefix(arg, "--output=") || (strings.HasPrefix(arg, "-o") && arg != "-o") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeFindCommand(args []string) bool {
|
||||
unsafe := map[string]bool{
|
||||
"-exec": true, "-execdir": true, "-ok": true, "-okdir": true,
|
||||
"-delete": true,
|
||||
"-fls": true, "-fprint": true, "-fprint0": true, "-fprintf": true,
|
||||
}
|
||||
for _, arg := range args {
|
||||
if unsafe[arg] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeRipgrepCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--search-zip" || arg == "-z" || arg == "--pre" || arg == "--hostname-bin" ||
|
||||
strings.HasPrefix(arg, "--pre=") || strings.HasPrefix(arg, "--hostname-bin=") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeSedCommand(args []string) bool {
|
||||
if len(args) < 2 || len(args) > 3 || args[0] != "-n" {
|
||||
return false
|
||||
}
|
||||
return validSedPrintArg(args[1])
|
||||
}
|
||||
|
||||
func validSedPrintArg(arg string) bool {
|
||||
if !strings.HasSuffix(arg, "p") {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSuffix(arg, "p")
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(body, ",") {
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeGitCommand(command []string) bool {
|
||||
subIndex, subcommand, ok := gitSubcommand(command)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if gitHasUnsafeGlobalOption(command[1:subIndex]) {
|
||||
return false
|
||||
}
|
||||
args := command[subIndex+1:]
|
||||
switch subcommand {
|
||||
case "status", "log", "diff", "show":
|
||||
return gitArgsReadOnly(args)
|
||||
case "branch":
|
||||
return gitArgsReadOnly(args) && gitBranchReadOnly(args)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitSubcommand(command []string) (int, string, bool) {
|
||||
for index := 1; index < len(command); index++ {
|
||||
arg := command[index]
|
||||
if gitOptionConsumesValue(arg) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if gitOptionHasInlineValue(arg) || arg == "--" || strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "status", "log", "diff", "show", "branch":
|
||||
return index, arg, true
|
||||
default:
|
||||
return 0, "", false
|
||||
}
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
func gitOptionConsumesValue(arg string) bool {
|
||||
switch arg {
|
||||
case "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitOptionHasInlineValue(arg string) bool {
|
||||
return strings.HasPrefix(arg, "--config-env=") ||
|
||||
strings.HasPrefix(arg, "--exec-path=") ||
|
||||
strings.HasPrefix(arg, "--git-dir=") ||
|
||||
strings.HasPrefix(arg, "--namespace=") ||
|
||||
strings.HasPrefix(arg, "--super-prefix=") ||
|
||||
strings.HasPrefix(arg, "--work-tree=") ||
|
||||
((strings.HasPrefix(arg, "-C") || strings.HasPrefix(arg, "-c")) && len(arg) > 2)
|
||||
}
|
||||
|
||||
func gitHasUnsafeGlobalOption(args []string) bool {
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--upload-pack"):
|
||||
return true
|
||||
case arg == "-C" || strings.HasPrefix(arg, "-C"):
|
||||
return true
|
||||
case gitOptionConsumesValue(arg):
|
||||
index++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gitArgsReadOnly(args []string) bool {
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--exec="):
|
||||
return false
|
||||
case arg == "--output", arg == "--exec":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gitBranchReadOnly(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "--format=") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose":
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func commandName(raw string) string {
|
||||
name := strings.TrimSpace(raw)
|
||||
if index := strings.LastIndexAny(name, `/\`); index >= 0 {
|
||||
name = name[index+1:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func hasStringPrefix(values []string, prefix []string) bool {
|
||||
if len(prefix) == 0 || len(prefix) > len(values) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if values[index] != prefix[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
||||
want := []string{"go", "test", "./..."}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixSupportsExecCommand(t *testing.T) {
|
||||
got := proposedCommandPrefix("exec_command", map[string]any{"cmd": "go test ./..."})
|
||||
want := []string{"go", "test", "./..."}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||
if runtime.GOOS == "windows" {
|
||||
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
|
||||
// not offer "ps aux" as a reusable prefix here: approving it would
|
||||
// escalate the whole command, including the uncovered head segment,
|
||||
// to bypass the sandbox unreviewed. See
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
|
||||
// the platform-independent regression coverage of this behavior.
|
||||
if got != nil {
|
||||
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
want := []string{"ps", "aux"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
|
||||
// against proposedCommandPrefix offering to approve one segment of a
|
||||
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
|
||||
// install") is not known-safe. shellExecutionArgsForApproval escalates the
|
||||
// entire command once any prefix is approved, so an uncovered unsafe segment
|
||||
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
|
||||
// any platform, so the assertion does not depend on runtime.GOOS.
|
||||
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
|
||||
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short && git status --branch",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedPrefixThatDoesNotCoverSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "ps aux && npm install",
|
||||
"prefix_rule": []any{"ps", "aux"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("partial requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("broad requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"cat < in > out",
|
||||
"FOO=bar go test",
|
||||
"echo $(whoami)",
|
||||
"cat *.go",
|
||||
"bash -lc go test",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe command got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
cases := []string{
|
||||
"find . -type f",
|
||||
"xargs rm -rf /tmp/x",
|
||||
"timeout 5 go test ./...",
|
||||
"nice go test ./...",
|
||||
"nohup go test ./...",
|
||||
"watch ls",
|
||||
"ssh host ls",
|
||||
"make test",
|
||||
"npm run dev",
|
||||
"command git status",
|
||||
"eval echo hi",
|
||||
"exec echo hi",
|
||||
"python script.py",
|
||||
"node script.js",
|
||||
"./script.sh --flag",
|
||||
"/tmp/script.sh --flag",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe launcher got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
|
||||
command := "ps aux | head -5"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "ps aux | echo ok"
|
||||
}
|
||||
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
|
||||
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only known-safe MSYS guard")
|
||||
}
|
||||
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
|
||||
if knownSafeCommandSegment(command) {
|
||||
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
|
||||
}
|
||||
}
|
||||
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
|
||||
t.Fatal("expected echo to remain known-safe on Windows")
|
||||
}
|
||||
if !tools.MsysProneCommandName("head") {
|
||||
t.Fatal("expected head to be MSYS-prone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
if grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}, Options{Sandbox: engine}); ok {
|
||||
t.Fatalf("match = %#v session=%v, want no match because npm segment is uncovered", grant, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "find . -type f",
|
||||
"prefix_rule": []any{"find", "."},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("unsafe requested launcher prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
+18
-133
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -22,17 +21,12 @@ import (
|
||||
// the agent loop owns the provider wiring.
|
||||
|
||||
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
||||
// when the caller does not specify a preserve count. Kept in sync with the replay
|
||||
// reconstruction default in internal/sessions/replay.go.
|
||||
const defaultCompactionPreserveLast = 6
|
||||
// when the caller does not specify a preserve count.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
||||
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
||||
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
||||
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
||||
// summarization, raise it back toward 0.8.
|
||||
const compactionTriggerRatio = 0.7
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
@@ -56,10 +50,6 @@ type CompactionOptions struct {
|
||||
// injected so Compact stays pure and testable; the agent loop wires it to a
|
||||
// real provider call.
|
||||
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
||||
// taskState is a snapshot supplied by the running agent. Its immutable
|
||||
// objective is always preserved; mutable fields are admitted only when its
|
||||
// plan projection still matches the transcript.
|
||||
taskState *taskStateSnapshot
|
||||
}
|
||||
|
||||
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
||||
@@ -87,38 +77,17 @@ type CompactionResult struct {
|
||||
// ~1k tokens is a representative mid-range cost across providers.
|
||||
const imageTokenEstimate = 1000
|
||||
|
||||
// ApproxTextTokens estimates the token count of text WITHOUT a real tokenizer.
|
||||
// The BPE tokenizers zero targets fold a run's leading space into the following
|
||||
// token rather than emitting whitespace as its own token, so naive len/4
|
||||
// overcounts real text by ~15-20% (measured: a 24.8k-char prompt counted 5.2k
|
||||
// real tokens where len/4 said 6.2k). Counting NON-whitespace bytes / 4 tracks
|
||||
// the provider's actual count closely (validated against live usage) while
|
||||
// staying allocation- and dependency-free. zero still receives the exact count
|
||||
// back as usage on every request; this estimate is only for the pre-request
|
||||
// context budget preview and the compaction threshold.
|
||||
func ApproxTextTokens(value string) int {
|
||||
nonSpace := 0
|
||||
for i := 0; i < len(value); i++ {
|
||||
switch value[i] {
|
||||
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||
default:
|
||||
nonSpace++
|
||||
}
|
||||
}
|
||||
return nonSpace / 4
|
||||
}
|
||||
|
||||
// estimateTokens is a cheap, dependency-free token estimate (see ApproxTextTokens)
|
||||
// estimateTokens is a cheap, dependency-free token estimate (~4 chars/token)
|
||||
// across message content plus tool call names/arguments and a flat per-image
|
||||
// cost. It deliberately uses no real tokenizer; it only needs to be monotonic
|
||||
// and roughly proportional so the loop can decide when to compact.
|
||||
func estimateTokens(messages []zeroruntime.Message) int {
|
||||
total := 0
|
||||
for _, message := range messages {
|
||||
total += ApproxTextTokens(message.Content)
|
||||
total += len(message.Content) / 4
|
||||
for _, call := range message.ToolCalls {
|
||||
total += ApproxTextTokens(call.Name)
|
||||
total += ApproxTextTokens(call.Arguments)
|
||||
total += len(call.Name) / 4
|
||||
total += len(call.Arguments) / 4
|
||||
total += 4 // small per-call overhead
|
||||
}
|
||||
total += len(message.Images) * imageTokenEstimate
|
||||
@@ -135,11 +104,11 @@ func estimateTokens(messages []zeroruntime.Message) int {
|
||||
func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
total += len(encoded) / 4
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
@@ -224,7 +193,7 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
|
||||
|
||||
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
||||
compacted = append(compacted, messages[:systemEnd]...)
|
||||
@@ -290,25 +259,6 @@ func isContextLimitError(message string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isStreamTimeoutError reports whether a streamed error is a provider stream
|
||||
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
|
||||
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
|
||||
// no content was forwarded yet it can be safely re-issued on a fresh connection
|
||||
// (see the stall-retry in the agent loop). Substring + case-insensitive to
|
||||
// tolerate the prefixed "provider stream error: …" wrapping.
|
||||
func isStreamTimeoutError(message string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowered == "" {
|
||||
return false
|
||||
}
|
||||
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compactionState carries the per-run state the agent loop needs to compact a
|
||||
// conversation safely. It is created once per Run and is a no-op whenever
|
||||
// options.ContextWindow <= 0.
|
||||
@@ -330,55 +280,15 @@ type compactionState struct {
|
||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
|
||||
// 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
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
// running calibration ratio. A single sample is clamped to a sane band so an
|
||||
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
|
||||
// estimate enough to disable or thrash compaction.
|
||||
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
|
||||
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
|
||||
return
|
||||
}
|
||||
sample := float64(actualPromptTokens) / float64(rawEstimate)
|
||||
if sample < 0.5 {
|
||||
sample = 0.5
|
||||
} else if sample > 2.0 {
|
||||
sample = 2.0
|
||||
}
|
||||
if state.calibrationRatio <= 0 {
|
||||
state.calibrationRatio = 1.0
|
||||
}
|
||||
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
|
||||
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
|
||||
}
|
||||
|
||||
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
|
||||
// arrives (ratio unset) it returns the raw estimate unchanged.
|
||||
func (state *compactionState) calibratedTokens(raw int) int {
|
||||
if state.calibrationRatio <= 0 {
|
||||
return raw
|
||||
}
|
||||
return int(float64(raw) * state.calibrationRatio)
|
||||
}
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
func newCompactionState(options Options) *compactionState {
|
||||
return &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
@@ -398,7 +308,7 @@ func (state *compactionState) maybeCompact(
|
||||
// the messages; both the threshold check and the shrink check below use the
|
||||
// same term so they stay consistent.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
size := estimateTokens(messages) + toolTokens
|
||||
if size <= state.threshold {
|
||||
return messages
|
||||
}
|
||||
@@ -409,42 +319,22 @@ func (state *compactionState) maybeCompact(
|
||||
return messages
|
||||
}
|
||||
|
||||
// CHEAP FIRST STAGE: reclaim context at zero token/latency cost by pruning
|
||||
// the bodies of old, large tool results (the model has already acted on
|
||||
// them). If that brings us back under threshold, skip the paid summarizer
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
// Summarizer failed: keep the original history. The reactive path (or a
|
||||
// later turn) can try again; we never drop messages on failure here.
|
||||
return messages
|
||||
}
|
||||
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
|
||||
newSize := estimateTokens(compacted) + toolTokens
|
||||
if newSize >= size {
|
||||
// Compaction did not actually shrink anything (e.g. nothing to
|
||||
// summarize). Leave the history untouched and don't churn next turn.
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
}
|
||||
// Only count a compaction when it actually shrank the history, so the
|
||||
// compaction counter reflects real context reductions rather than paid
|
||||
// no-ops that left the token budget untouched.
|
||||
if r := trace.FromContext(ctx); r != nil {
|
||||
r.Counter(trace.CounterCompactionCount, 1)
|
||||
}
|
||||
state.lowWaterMark = newSize
|
||||
return compacted
|
||||
}
|
||||
@@ -476,7 +366,6 @@ func (state *compactionState) recover(
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
// A genuine compaction attempt was made (and failed): the budget is spent
|
||||
@@ -495,13 +384,9 @@ func (state *compactionState) recover(
|
||||
// one-shot budget now so a provider that keeps returning context-limit errors
|
||||
// after a successful compaction can't loop forever. Store the low-water mark in
|
||||
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
|
||||
// proactive shrink-guard compares like with like. Count it now that the shrink
|
||||
// is confirmed, so the counter mirrors maybeCompact's real-reduction policy.
|
||||
if r := trace.FromContext(ctx); r != nil {
|
||||
r.Counter(trace.CounterCompactionCount, 1)
|
||||
}
|
||||
// proactive shrink-guard compares like with like.
|
||||
state.reactiveAttempted = true
|
||||
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
|
||||
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCalibrationConvergesTowardActual(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
|
||||
// Before any sample, the estimate is unchanged.
|
||||
if got := state.calibratedTokens(1000); got != 1000 {
|
||||
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
|
||||
}
|
||||
|
||||
// The estimator over-counts: our raw estimate is 1000 but the provider reports
|
||||
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
|
||||
// toward 0.85, so a 1000-token raw estimate calibrates downward.
|
||||
for range 20 {
|
||||
state.calibrate(1000, 850)
|
||||
}
|
||||
got := state.calibratedTokens(1000)
|
||||
if got >= 1000 || got < 820 || got > 880 {
|
||||
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
state.calibrate(0, 500) // zero estimate
|
||||
state.calibrate(500, 0) // zero actual
|
||||
if state.calibrationRatio != 0 {
|
||||
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
|
||||
}
|
||||
// Disabled state never calibrates.
|
||||
disabled := &compactionState{enabled: false}
|
||||
disabled.calibrate(1000, 850)
|
||||
if disabled.calibrationRatio != 0 {
|
||||
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateClampsOutliers(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
|
||||
// ratio and disable compaction.
|
||||
for range 50 {
|
||||
state.calibrate(1000, 10000)
|
||||
}
|
||||
if state.calibrationRatio > 2.0 {
|
||||
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
|
||||
}
|
||||
}
|
||||
@@ -20,20 +20,6 @@ const (
|
||||
toolNameUpdatePlan = "update_plan"
|
||||
toolNameToolSearch = "tool_search"
|
||||
toolNameSkill = "skill"
|
||||
toolNameWriteFile = "write_file"
|
||||
toolNameEditFile = "edit_file"
|
||||
)
|
||||
|
||||
// maxRecentEdits caps how many edited files are carried across a compaction, and
|
||||
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
|
||||
// bloat the preserved-state block.
|
||||
const (
|
||||
maxRecentEdits = 20
|
||||
maxEditNoteBytes = 160
|
||||
// maxTaskObjectiveBytes keeps the original objective recognizable after
|
||||
// compaction without allowing an unusually large prompt to recreate the
|
||||
// context pressure the compaction just removed.
|
||||
maxTaskObjectiveBytes = 512
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -80,14 +66,32 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
|
||||
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
||||
// "" on malformed arguments or an empty plan.
|
||||
func formatPlanArguments(arguments string) string {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Content string `json:"content"`
|
||||
Step string `json:"step"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
line := "- [" + item.Status + "] " + item.Content
|
||||
if notes := item.Notes; notes != "" {
|
||||
lines := make([]string, 0, len(parsed.Plan))
|
||||
for _, item := range parsed.Plan {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(item.Step)
|
||||
}
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
status := strings.TrimSpace(item.Status)
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
line := "- [" + status + "] " + content
|
||||
if notes := strings.TrimSpace(item.Notes); notes != "" {
|
||||
line += "\n Notes: " + notes
|
||||
}
|
||||
lines = append(lines, line)
|
||||
@@ -96,123 +100,12 @@ func formatPlanArguments(arguments string) string {
|
||||
}
|
||||
|
||||
// skillEntry is a named preserved body. It began as loaded-skill state and is
|
||||
// reused for loaded tools, project instruction blocks, and recent file edits.
|
||||
// reused for loaded tools and project instruction blocks.
|
||||
type skillEntry struct {
|
||||
name string
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
// rediscover its own footprint. Capped at maxRecentEdits paths.
|
||||
//
|
||||
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
|
||||
// position so the tail cap below keeps the file the model most recently touched
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
|
||||
continue
|
||||
}
|
||||
path := editPathFromArguments(call.Arguments)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if call.ID != "" {
|
||||
pathByID[call.ID] = path
|
||||
}
|
||||
sequence = append(sequence, path)
|
||||
}
|
||||
}
|
||||
order := lastSeenOrder(sequence)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
}
|
||||
if path, ok := pathByID[message.ToolCallID]; ok {
|
||||
noteByPath[path] = editNote(message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
|
||||
if len(order) > maxRecentEdits {
|
||||
order = order[len(order)-maxRecentEdits:]
|
||||
}
|
||||
entries := make([]skillEntry, 0, len(order))
|
||||
for _, path := range order {
|
||||
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
|
||||
// chronological order otherwise. A re-edited path therefore lands at the newest
|
||||
// position rather than staying pinned to where it first appeared.
|
||||
func lastSeenOrder(paths []string) []string {
|
||||
lastIdx := make(map[string]int, len(paths))
|
||||
for i, p := range paths {
|
||||
lastIdx[p] = i
|
||||
}
|
||||
order := make([]string, 0, len(lastIdx))
|
||||
for i, p := range paths {
|
||||
if lastIdx[p] == i {
|
||||
order = append(order, p)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// editPathFromArguments pulls the target file path from a write_file/edit_file
|
||||
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
|
||||
// malformed arguments or no path.
|
||||
func editPathFromArguments(arguments string) string {
|
||||
var parsed struct {
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
FilePath string `json:"file_path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
|
||||
if s := strings.TrimSpace(candidate); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// editNote reduces an edit tool's result to a single short line (its first
|
||||
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
|
||||
func editNote(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if len(s) > maxEditNoteBytes {
|
||||
limit := maxEditNoteBytes
|
||||
for limit > 0 && !utf8.RuneStart(s[limit]) {
|
||||
limit--
|
||||
}
|
||||
s = s[:limit] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadedSkills returns the skills loaded via the skill tool in messages — the
|
||||
// latest body per name, in first-seen order — matching each skill tool call to
|
||||
// its tool result by id.
|
||||
@@ -417,30 +310,11 @@ func capBody(body string) string {
|
||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||
type preservedState struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Task *preservedTaskState `json:"task,omitempty"`
|
||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||
Tools []preservedTool `json:"tools,omitempty"`
|
||||
Skills []preservedSkill `json:"skills,omitempty"`
|
||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
Path string `json:"path"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTool struct {
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
@@ -463,27 +337,8 @@ type preservedInstruction struct {
|
||||
// may live only inside the injected summary message, which on a later compaction
|
||||
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
||||
// instruction blocks override the carried-forward copy by name/source.
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
// even after compaction removes the plan tool call needed for comparison.
|
||||
if taskSnapshot.PlanParity == taskPlanParityMatch {
|
||||
task.Status = taskSnapshot.Status
|
||||
task.Pending = taskSnapshot.Plan.Pending
|
||||
task.InProgress = taskSnapshot.Plan.InProgress
|
||||
task.Completed = taskSnapshot.Plan.Completed
|
||||
task.Failed = taskSnapshot.Plan.Failed
|
||||
task.VerificationPassed = taskSnapshot.Verification.Passed
|
||||
task.VerificationFailed = taskSnapshot.Verification.Failed
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -492,12 +347,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
plan = priorState.Plan
|
||||
}
|
||||
|
||||
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
|
||||
// results in middle (newer note per path wins AND moves to the newest
|
||||
// position), so the tail cap keeps the files the model most recently touched
|
||||
// across repeated compactions rather than dropping a just-re-edited file.
|
||||
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
|
||||
|
||||
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
|
||||
// loads override older carried copies by name.
|
||||
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
|
||||
@@ -511,89 +360,19 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
|
||||
summary += "\n\n" + block
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
return objective
|
||||
}
|
||||
const suffix = "…"
|
||||
limit := maxTaskObjectiveBytes - len(suffix)
|
||||
for limit > 0 && !utf8.RuneStart(objective[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(objective[:limit]) + suffix
|
||||
}
|
||||
|
||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||
// position and takes the fresh note, so capRecentEdits — which keeps only the
|
||||
// most-recent tail — never drops a file the model just re-edited. Paths present
|
||||
// only in the older set keep their relative order, ahead of the fresh ones.
|
||||
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
|
||||
if len(newer) == 0 {
|
||||
return older
|
||||
}
|
||||
freshBody := make(map[string]string, len(newer))
|
||||
freshOrder := make([]string, 0, len(newer))
|
||||
for _, e := range newer {
|
||||
if _, ok := freshBody[e.name]; !ok {
|
||||
freshOrder = append(freshOrder, e.name)
|
||||
}
|
||||
freshBody[e.name] = e.body
|
||||
}
|
||||
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
|
||||
// Older entries that were NOT re-edited keep their position, oldest first.
|
||||
for _, e := range older {
|
||||
if _, refreshed := freshBody[e.name]; refreshed {
|
||||
continue // re-appended at its newest position below
|
||||
}
|
||||
merged = append(merged, e)
|
||||
}
|
||||
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
|
||||
// the most recently touched files sit at the tail the cap preserves.
|
||||
for _, name := range freshOrder {
|
||||
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
|
||||
// entries after a merge, so repeated compactions can't grow it without limit.
|
||||
func capRecentEdits(entries []skillEntry) []skillEntry {
|
||||
if len(entries) > maxRecentEdits {
|
||||
return entries[len(entries)-maxRecentEdits:]
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||
entries := make([]skillEntry, 0, len(edits))
|
||||
for _, e := range edits {
|
||||
if e.Path == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// formatPreservedState renders state as the labelled, single-line
|
||||
// JSON block. Returns "" when there is nothing to preserve.
|
||||
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
return ""
|
||||
}
|
||||
state := preservedState{Plan: plan, Task: task}
|
||||
for _, e := range edits {
|
||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||
}
|
||||
state := preservedState{Plan: plan}
|
||||
for _, t := range tools {
|
||||
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
|
||||
}
|
||||
@@ -610,6 +389,15 @@ func formatPreservedState(plan string, task *preservedTaskState, edits, tools, s
|
||||
return preservedStateLabel + "\n" + string(encoded)
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
func parsePreservedStateBlock(summaryContent string) preservedState {
|
||||
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
@@ -60,103 +59,6 @@ func TestCompactPreservesActivePlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
|
||||
t.Fatalf("unexpected compact task state: %#v", state.Task)
|
||||
}
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
|
||||
encoded, err := json.Marshal(prior)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
}
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: &taskStateSnapshot{
|
||||
Objective: "current objective",
|
||||
PlanParity: taskPlanParityMismatch,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "current objective" {
|
||||
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.Pending != 0 {
|
||||
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
|
||||
task := newTaskState("keep this objective", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Compact: %v", err)
|
||||
}
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
)
|
||||
snapshot := task.snapshotForCompaction(secondInput)
|
||||
if snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
|
||||
}
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "keep this objective" {
|
||||
t.Fatalf("objective lost after second compaction: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.InProgress != 0 {
|
||||
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
||||
summary := compactStateConversation(t, stateConversation())
|
||||
if !strings.Contains(summary, preservedStateLabel) {
|
||||
@@ -381,10 +283,12 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
|
||||
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
|
||||
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
||||
if got != "" {
|
||||
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
|
||||
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q in formatted plan, got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// recentEdits extracts each mutated file's path and a one-line note from the
|
||||
// matching tool result, latest note per path in last-seen order.
|
||||
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||
}
|
||||
|
||||
edits := recentEdits(messages)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
}
|
||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||
}
|
||||
}
|
||||
|
||||
// After compaction elides the editing turns, the preserved-state block still
|
||||
// names the edited files and what changed, so the model needn't re-read them.
|
||||
func TestCompactionPreservesRecentEdits(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
}
|
||||
summary := compactStateConversation(t, messages)
|
||||
|
||||
state := parsePreservedStateBlock(summary)
|
||||
if len(state.RecentEdits) != 1 {
|
||||
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
|
||||
}
|
||||
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
|
||||
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh edit note for a path overrides the one carried from an earlier
|
||||
// compaction (newer wins) and moves the path to the newest position rather than
|
||||
// duplicating it or leaving it pinned to its old slot.
|
||||
func TestRecentEditsMergeNewerWins(t *testing.T) {
|
||||
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
|
||||
older := preservedEditsToEntries(prior.RecentEdits)
|
||||
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
|
||||
|
||||
merged := mergeRecentEdits(older, newer)
|
||||
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
|
||||
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
|
||||
}
|
||||
if merged[0].name != "z.go" {
|
||||
t.Fatalf("untouched z.go should stay first, got %#v", merged)
|
||||
}
|
||||
if merged[1].name != "a.go" || merged[1].body != "new note" {
|
||||
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
|
||||
}
|
||||
if merged[2].name != "b.go" {
|
||||
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
|
||||
// an early file must move it into the capped tail rather than leaving it to be
|
||||
// dropped — otherwise the next compaction omits exactly the file the model most
|
||||
// recently touched.
|
||||
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
|
||||
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
|
||||
older := make([]skillEntry, 0, maxRecentEdits)
|
||||
for i := 0; i < maxRecentEdits; i++ {
|
||||
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
|
||||
}
|
||||
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
|
||||
// merged list exceeds the cap and something must be dropped.
|
||||
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
|
||||
|
||||
merged := capRecentEdits(mergeRecentEdits(older, newer))
|
||||
if len(merged) != maxRecentEdits {
|
||||
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
|
||||
}
|
||||
|
||||
var f0 *skillEntry
|
||||
for i := range merged {
|
||||
if merged[i].name == "f0.go" {
|
||||
f0 = &merged[i]
|
||||
}
|
||||
if merged[i].name == "f1.go" {
|
||||
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
|
||||
}
|
||||
}
|
||||
if f0 == nil {
|
||||
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
|
||||
}
|
||||
if f0.body != "re-edited" {
|
||||
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -364,7 +364,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
},
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -427,7 +427,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// the compaction closure. read_file also returns a sizeable result that
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -491,7 +491,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
var deltas []string
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
@@ -581,7 +581,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -628,7 +628,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverDisabledIsNoop(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 0}, nil)
|
||||
st := newCompactionState(Options{ContextWindow: 0})
|
||||
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
||||
called := false
|
||||
// recover must not invoke the provider/summarize when disabled, even on a
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// someRequestContains reports whether any message sent to the provider across all
|
||||
// turns contains substr — used to assert a continue nudge was actually injected.
|
||||
func someRequestContains(requests []zeroruntime.CompletionRequest, substr string) bool {
|
||||
for _, req := range requests {
|
||||
for _, msg := range req.Messages {
|
||||
if strings.Contains(msg.Content, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planTurn is a turn that calls update_plan with the given item statuses (reusing
|
||||
// the package's shared toolTurn helper).
|
||||
func planTurn(statuses ...string) []zeroruntime.StreamEvent {
|
||||
items := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
items[i] = `{"content":"step ` + s + `","status":"` + s + `"}`
|
||||
}
|
||||
return toolTurn("plan", "update_plan", `{"plan":[`+strings.Join(items, ",")+`]}`)
|
||||
}
|
||||
|
||||
// BUG #1 regression: a no-tool-call turn that ends mid-step while plan items are
|
||||
// still pending must NOT be accepted as success. The loop must re-prompt (bounded)
|
||||
// and, if the model keeps stalling, finalize as INCOMPLETE — never success.
|
||||
func TestCompletionGatePendingPlanContinuesThenIncomplete(t *testing.T) {
|
||||
// Mirrors the git-multibranch failure: plan with pending steps, then the model
|
||||
// keeps emitting "…Let me check the SSH configuration:" without acting.
|
||||
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "pending", "pending"),
|
||||
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "set up a git server", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("expected Incomplete=true (model stalled with pending plan), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
// 1 plan turn + maxContinueNudges(3) nudged turns + 1 final stalling turn = 5.
|
||||
// Critically it did NOT stop at the first text turn (request 2) as success.
|
||||
if len(provider.requests) != 1+maxContinueNudges+1 {
|
||||
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
|
||||
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge (%q) to be injected into the conversation", continueNudgeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuinely-complete single-turn answer (no plan, no continuation cue) must
|
||||
// still finalize as success — the gate must not break short/read-only tasks.
|
||||
func TestCompletionGateAcceptsGenuineCompletion(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("The file contains 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("genuine completion wrongly marked Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "The file contains 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected exactly 1 turn (no spurious re-prompt), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation-cue turn triggers a re-prompt, but once the model actually
|
||||
// finishes (clean answer, no cue, no pending plan) the run exits as success — the
|
||||
// nudge gives the model a path to a legitimate completion.
|
||||
func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Let me read the file:"),
|
||||
textTurn("Done. The file has 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("run wrongly marked Incomplete after the model completed; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "Done. The file has 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 cue re-prompted + 1 completion), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge after the continuation-cue turn")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
|
||||
// accepted as the final answer exactly as before — guaranteeing no behavior
|
||||
// change for non-headless callers.
|
||||
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
|
||||
cue := "Let me check the config:"
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal deliberately left false.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != cue {
|
||||
t.Fatalf("final answer = %q, want %q (legacy: text-only turn is the answer)", result.FinalAnswer, cue)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// review #6: the continuation-cue detector must catch a mid-line action announcement
|
||||
// that stops on a colon (the git-multibranch case), without flagging genuine closers
|
||||
// — a recommendation, a plain summary colon, or a sign-off.
|
||||
func TestContinuationCueMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
cue bool
|
||||
}{
|
||||
{"Now I need to configure the SSH server. Let me check the current SSH configuration:", true},
|
||||
{"Let me read the file:", true},
|
||||
{"Now I'll run the tests:", true},
|
||||
{"Next, I suggest reviewing the changes.", false}, // recommendation, no colon
|
||||
{"Here is the summary:", false}, // summary colon, no action lead-in
|
||||
{"Let me know if you need anything:", false}, // sign-off
|
||||
{"The function is implemented and all tests pass.", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := endsWithContinuationCue(c.text); got != c.cue {
|
||||
t.Errorf("endsWithContinuationCue(%q) = %v, want %v", c.text, got, c.cue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
|
||||
// never reaches the no-tool-call gate) was reported as success. Under the headless
|
||||
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
|
||||
func TestMaxTurnsCutoffIsIncompleteUnderGate(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
toolEvery := toolTurn("c", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolEvery, toolEvery, toolEvery, toolEvery,
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "keep working", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a max-turns cutoff under the gate must be Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "max-turns") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite max-turns", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package agent
|
||||
|
||||
// CompletionDecision is the deterministic outcome of evaluating a text-only
|
||||
// assistant turn. Uncertain means the loop needs one bounded follow-up action
|
||||
// before it can finalize the run.
|
||||
type CompletionDecision string
|
||||
|
||||
const (
|
||||
CompletionUncertain CompletionDecision = "uncertain"
|
||||
CompletionComplete CompletionDecision = "complete"
|
||||
CompletionIncomplete CompletionDecision = "incomplete"
|
||||
)
|
||||
|
||||
type completionAction string
|
||||
|
||||
const (
|
||||
completionActionNone completionAction = ""
|
||||
completionActionContinue completionAction = "continue"
|
||||
completionActionSemanticCheck completionAction = "semantic_check"
|
||||
)
|
||||
|
||||
type completionEvaluation struct {
|
||||
Decision CompletionDecision
|
||||
Action completionAction
|
||||
Reason string
|
||||
}
|
||||
|
||||
// completionPolicy owns the small amount of state needed to keep uncertain
|
||||
// decisions bounded. requireSemanticCheck is set only for run profiles that
|
||||
// already opted into self-correction; ordinary completion remains entirely
|
||||
// local and adds no model call.
|
||||
type completionPolicy struct {
|
||||
continueNudges int
|
||||
requireSemanticCheck bool
|
||||
semanticCheckRequested bool
|
||||
}
|
||||
|
||||
func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
|
||||
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
||||
}
|
||||
|
||||
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
|
||||
// A direct admission is strong local evidence and takes precedence over all
|
||||
// weaker signals, avoiding both continuation nudges and semantic checks.
|
||||
if reason := selfReportedIncompletion(text); reason != "" {
|
||||
return completionEvaluation{Decision: CompletionIncomplete, Reason: reason}
|
||||
}
|
||||
|
||||
// A continuation cue is strong unfinished evidence; a pending plan is only
|
||||
// weak evidence because bookkeeping can be stale. Both get a bounded chance
|
||||
// to continue, but only a persisted cue is ultimately incomplete.
|
||||
cue := endsWithContinuationCue(text)
|
||||
if cue || context.PlanPending {
|
||||
if policy.continueNudges < maxContinueNudges {
|
||||
policy.continueNudges++
|
||||
reason := "your message ended mid-step"
|
||||
if !cue {
|
||||
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
|
||||
}
|
||||
return completionEvaluation{
|
||||
Decision: CompletionUncertain,
|
||||
Action: completionActionContinue,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
if cue {
|
||||
return completionEvaluation{
|
||||
Decision: CompletionIncomplete,
|
||||
Reason: "your message ended mid-step",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles with self-correction enabled require one task-grounded semantic
|
||||
// check. It is requested at most once per run; the next locally complete turn
|
||||
// is accepted without another model call.
|
||||
if policy.requireSemanticCheck && !policy.semanticCheckRequested {
|
||||
policy.semanticCheckRequested = true
|
||||
return completionEvaluation{
|
||||
Decision: CompletionUncertain,
|
||||
Action: completionActionSemanticCheck,
|
||||
}
|
||||
}
|
||||
|
||||
return completionEvaluation{Decision: CompletionComplete, Action: completionActionNone}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
|
||||
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
|
||||
if complete.Decision != CompletionComplete {
|
||||
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
|
||||
}
|
||||
|
||||
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
|
||||
if incomplete.Decision != CompletionIncomplete {
|
||||
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionIncomplete {
|
||||
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
for attempt := 0; attempt < maxContinueNudges; attempt++ {
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
|
||||
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
got := policy.evaluate("All set.", completionContext{PlanPending: true})
|
||||
if got.Decision != CompletionComplete {
|
||||
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(true)
|
||||
|
||||
first := policy.evaluate("Implemented and tested.", completionContext{})
|
||||
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
|
||||
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
|
||||
}
|
||||
|
||||
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
|
||||
if second.Decision != CompletionComplete {
|
||||
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
|
||||
}
|
||||
if second.Action == completionActionSemanticCheck {
|
||||
t.Fatal("semantic check was requested more than once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
|
||||
objective := "make the requested behavior observable"
|
||||
nudge := acceptanceVerificationNudge(objective)
|
||||
if !strings.Contains(nudge, "Task objective: "+objective) {
|
||||
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
|
||||
}
|
||||
}
|
||||
@@ -81,11 +81,3 @@ Otherwise, confirm before executing.
|
||||
6. Avoid redundant confirmations if you already confirmed and there is no material new risk.
|
||||
7. Write/shell operations inside the workspace do not need the same scrutiny as operations outside it.
|
||||
8. Interactive programs (editors, pagers, REPLs, `ssh`/`psql`/`mysql` without a command, `top`/`htop`, `git rebase -i`) will be blocked because they hang the agent; always use the non-interactive alternative.
|
||||
|
||||
## Sandbox Permission Requests
|
||||
|
||||
- Prefer sandboxed additional permissions over full escalation: use `sandbox_permissions: "with_additional_permissions"` with the required file-system or network permission when that can satisfy the command.
|
||||
- Use `sandbox_permissions: "require_escalated"` only when the command must run outside the sandbox, such as inspecting or controlling host/global state that the sandbox intentionally hides.
|
||||
- If a sandboxed shell command returns only sandbox-local state when the user asked for host/global state, do not answer from that misleading output. Rerun the needed command with `sandbox_permissions: "require_escalated"`, a short user-facing `justification`, and a narrow `prefix_rule` when appropriate.
|
||||
- For `require_escalated`, include a short user-facing `justification`. When a reusable approval is appropriate, include a narrow `prefix_rule`; do not suggest broad shell, interpreter, or launcher prefixes.
|
||||
- Shell commands are evaluated as independent command segments across `|`, `&&`, `||`, and `;`. A reusable prefix must apply safely to the command segment it is meant to authorize.
|
||||
@@ -48,16 +48,16 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
}
|
||||
|
||||
// estimateToolTokens approximates the token footprint of advertised tool
|
||||
// definitions (name + description + JSON schema), using the same ApproxTextTokens
|
||||
// heuristic as estimateTokens so all categories share one scale.
|
||||
// definitions (name + description + JSON schema), matching estimateTokens'
|
||||
// ~4-chars/token heuristic so all categories use the same scale.
|
||||
func estimateToolTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
total += len(encoded) / 4
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// decodeToolArguments must (a) decode a normal single-object payload unchanged,
|
||||
// (b) tolerate a weak model packing multiple concatenated top-level JSON objects
|
||||
// into one arguments string by decoding the FIRST object and ignoring the rest
|
||||
// (the minimax-m3 "invalid character '{' after top-level value" failure), and
|
||||
// (c) still reject a genuinely malformed/truncated first object.
|
||||
func TestDecodeToolArguments(t *testing.T) {
|
||||
t.Run("single object decodes normally", func(t *testing.T) {
|
||||
var a map[string]any
|
||||
if err := decodeToolArguments(`{"path":"x"}`, &a); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if a["path"] != "x" {
|
||||
t.Fatalf("decoded %v, want path=x", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concatenated objects decode the first, ignore trailing", func(t *testing.T) {
|
||||
var b map[string]any
|
||||
if err := decodeToolArguments(`{"path":"first"}{"path":"second"}`, &b); err != nil {
|
||||
t.Fatalf("multi-object should decode the first, got err = %v", err)
|
||||
}
|
||||
if b["path"] != "first" {
|
||||
t.Fatalf("decoded %v, want the first object (path=first)", b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("distinct tools' args concatenated (real minimax case): first object used", func(t *testing.T) {
|
||||
var c map[string]any
|
||||
if err := decodeToolArguments(`{"plan":[1,2]}{"cmd":"go version"}`, &c); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if _, ok := c["plan"]; !ok || c["cmd"] != nil {
|
||||
t.Fatalf("decoded %v, want only the first object's keys", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated first object still errors", func(t *testing.T) {
|
||||
var d map[string]any
|
||||
if err := decodeToolArguments(`{"path":`, &d); err == nil {
|
||||
t.Fatal("truncated JSON must still error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-JSON trailing garbage still errors (not masked)", func(t *testing.T) {
|
||||
var g map[string]any
|
||||
if err := decodeToolArguments(`{"x":1}xyz`, &g); err == nil {
|
||||
t.Fatal("a valid first object followed by non-JSON garbage must still error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("recoverableToolArguments: only whole-JSON trailing is tolerated", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
first string
|
||||
}{
|
||||
{`{"a":1}`, true, `{"a":1}`},
|
||||
{`{"a":1}{"b":2}`, true, `{"a":1}`},
|
||||
{`{"a":1} {"b":2} `, true, `{"a":1}`},
|
||||
{`{"a":1}xyz`, false, ""},
|
||||
{`{"a":`, false, ""},
|
||||
{` `, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
first, ok := recoverableToolArguments(c.in)
|
||||
if ok != c.ok || (ok && first != c.first) {
|
||||
t.Fatalf("recoverableToolArguments(%q) = (%q,%v), want (%q,%v)", c.in, first, ok, c.first, c.ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("historySafeToolCalls recovers the first object, not {}", func(t *testing.T) {
|
||||
out := historySafeToolCalls([]ToolCall{
|
||||
{ID: "c1", Name: "read_file", Arguments: `{"path":"README.md"}{"path":"AGENTS.md"}`}, // concatenated -> first
|
||||
{ID: "c2", Name: "read_file", Arguments: `{"path":"x"}xyz`}, // garbage -> {}
|
||||
{ID: "c3", Name: "read_file", Arguments: `{"path":"ok"}`}, // valid -> unchanged
|
||||
})
|
||||
if out[0].Arguments != `{"path":"README.md"}` {
|
||||
t.Fatalf("concatenated args should record the first object, got %q", out[0].Arguments)
|
||||
}
|
||||
if out[1].Arguments != "{}" {
|
||||
t.Fatalf("garbage-trailing args should record {}, got %q", out[1].Arguments)
|
||||
}
|
||||
if out[2].Arguments != `{"path":"ok"}` {
|
||||
t.Fatalf("valid args should be unchanged, got %q", out[2].Arguments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace-only errors (caller guards empty)", func(t *testing.T) {
|
||||
var e map[string]any
|
||||
if err := decodeToolArguments(" ", &e); err == nil {
|
||||
t.Fatal("whitespace-only must error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
|
||||
// object with several typed, described properties. Real MCP tools are this size
|
||||
// or larger, which is why shipping their full schemas every turn is expensive.
|
||||
type richMCPTool struct{ name string }
|
||||
|
||||
func (t richMCPTool) Name() string { return t.name }
|
||||
func (t richMCPTool) Description() string {
|
||||
return "Search the web and return ranked, structured results with titles, URLs, snippets, and optional cleaned full-page content. Supports natural-language and keyword queries with domain and date filtering."
|
||||
}
|
||||
func (t richMCPTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
AdditionalProperties: false,
|
||||
Required: []string{"query"},
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"query": {Type: "string", Description: "The search query to run against the web index. Supports natural-language and keyword queries."},
|
||||
"num_results": {Type: "integer", Description: "Maximum number of results to return (1-100). Defaults to 10."},
|
||||
"include_domains": {Type: "array", Description: "Only return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"exclude_domains": {Type: "array", Description: "Never return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"start_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or after this date are returned."},
|
||||
"end_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or before this date are returned."},
|
||||
"category": {Type: "string", Description: "Restrict results to a content category.", Enum: []string{"news", "research paper", "company", "tweet", "pdf"}},
|
||||
"include_text": {Type: "boolean", Description: "Include cleaned full-page text for each result (slower, larger payload)."},
|
||||
},
|
||||
}
|
||||
}
|
||||
func (t richMCPTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t richMCPTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
func (t richMCPTool) Deferred() bool { return true }
|
||||
|
||||
// TestDeferralTokenSavingsMeasurement quantifies the win from the lowered
|
||||
// deferThreshold default: with a typical small MCP server (6 tools), threshold 10
|
||||
// (the OLD default) kept deferral OFF so all six full schemas shipped every turn,
|
||||
// while threshold 3 (the NEW default) collapses them to compact reminder lines.
|
||||
func TestDeferralTokenSavingsMeasurement(t *testing.T) {
|
||||
const mcpToolCount = 6
|
||||
registry := tools.NewRegistry()
|
||||
for i := 0; i < mcpToolCount; i++ {
|
||||
registry.Register(richMCPTool{name: fmt.Sprintf("mcp__websearch__search_v%d", i)})
|
||||
}
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
measure := func(threshold int) (int, int) {
|
||||
defs, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: threshold}, map[string]bool{})
|
||||
encoded, err := json.Marshal(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool defs: %v", err)
|
||||
}
|
||||
bytes := len(encoded) + len(reminder)
|
||||
return bytes, bytes / 4 // ~tokens (approx 4 chars/token)
|
||||
}
|
||||
|
||||
eagerBytes, eagerTok := measure(10) // 6 < 10 -> inactive -> all schemas eager
|
||||
deferBytes, deferTok := measure(3) // 6 >= 3 -> active -> compact reminder
|
||||
savedPct := 100 * float64(eagerBytes-deferBytes) / float64(eagerBytes)
|
||||
|
||||
t.Logf("MCP tools in set: %d", mcpToolCount)
|
||||
t.Logf("threshold 10 (OLD, eager schemas): %d bytes (~%d tokens) per turn", eagerBytes, eagerTok)
|
||||
t.Logf("threshold 3 (NEW, deferred lines): %d bytes (~%d tokens) per turn", deferBytes, deferTok)
|
||||
t.Logf("SAVED: ~%d tokens per turn (%.0f%% smaller tool payload)", eagerTok-deferTok, savedPct)
|
||||
|
||||
if deferBytes >= eagerBytes {
|
||||
t.Fatalf("deferral should shrink the tool payload: eager=%d defer=%d", eagerBytes, deferBytes)
|
||||
}
|
||||
if savedPct < 50 {
|
||||
t.Fatalf("expected a large reduction, got only %.0f%%", savedPct)
|
||||
}
|
||||
}
|
||||
@@ -129,28 +129,10 @@ func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func toolDefinitionByName(definitions []zeroruntime.ToolDefinition, name string) *zeroruntime.ToolDefinition {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == name {
|
||||
return &definitions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.CompletionRequest) {
|
||||
t.Helper()
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, "Deferred tools:") {
|
||||
t.Fatalf("deferred discovery must not be appended as a user message: %q", message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
@@ -237,8 +219,8 @@ func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
|
||||
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
|
||||
// eligible total. With N deferred registered and threshold == N, disabling one
|
||||
// drops the surviving eligible count to N-1 < threshold, so the partition takes
|
||||
// the INACTIVE path (empty discovery text, all VISIBLE tools exposed with full
|
||||
// schemas, the disabled one filtered out entirely).
|
||||
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
|
||||
// the disabled one filtered out entirely).
|
||||
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
@@ -264,9 +246,9 @@ func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T)
|
||||
}
|
||||
|
||||
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
|
||||
// influence discovery NOR be exposed — it is filtered out before the partition
|
||||
// appear in the reminder NOR be exposed — it is filtered out before the partition
|
||||
// even considers it.
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *testing.T) {
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -274,46 +256,40 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
||||
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
|
||||
|
||||
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
|
||||
DeferThreshold: 2,
|
||||
DisabledTools: []string{"mcp__srv__beta"},
|
||||
}, map[string]bool{})
|
||||
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected active path with discovery text for the unloaded tools")
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
|
||||
}
|
||||
if strings.Contains(discovery, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in discovery text, got %q", discovery)
|
||||
if strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
for _, def := range exposed {
|
||||
if def.Name == "mcp__srv__beta" {
|
||||
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("tool_search must be exposed on active path, got %#v", exposed)
|
||||
}
|
||||
if search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text\n got: %q\nwant: %q", search.Description, discovery)
|
||||
}
|
||||
if !strings.Contains(discovery, "mcp") {
|
||||
t.Fatalf("discovery text must list the surviving deferred source, got %q", discovery)
|
||||
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
|
||||
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
|
||||
loaded := map[string]bool{"mcp__srv__alpha": true}
|
||||
|
||||
// 2 eligible deferred tools, threshold 2 => active.
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
|
||||
exposedNames := map[string]bool{}
|
||||
for _, def := range exposed {
|
||||
@@ -331,21 +307,14 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
if exposedNames["mcp__srv__beta"] {
|
||||
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
|
||||
}
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected non-empty discovery text for the hidden tool")
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected a non-empty reminder for the hidden tool")
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
if !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
|
||||
}
|
||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||
}
|
||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||
}
|
||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||
if strings.Contains(reminder, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,9 +337,9 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// No hidden tools means no dynamic discovery text is needed.
|
||||
// BuildDeferredReminder returns "" for no hidden lines.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +376,7 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("expected two turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// Turn 1: alpha is hidden (not advertised), and discovery lives on the
|
||||
// tool_search definition rather than a trailing user message.
|
||||
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
|
||||
turn1Tools := map[string]bool{}
|
||||
for _, def := range provider.requests[0].Tools {
|
||||
turn1Tools[def.Name] = true
|
||||
@@ -416,13 +384,9 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
if turn1Tools["mcp__srv__alpha"] {
|
||||
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
|
||||
}
|
||||
assertNoDeferredDiscoveryMessage(t, provider.requests[0])
|
||||
search1 := toolDefinitionByName(provider.requests[0].Tools, tools.ToolSearchToolName)
|
||||
if search1 == nil {
|
||||
t.Fatalf("turn 1 must expose tool_search, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
if !strings.Contains(search1.Description, "Tool discovery") || !strings.Contains(search1.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry deferred discovery, got %q", search1.Description)
|
||||
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
|
||||
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
|
||||
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
|
||||
}
|
||||
|
||||
// Turn 2: alpha is now loaded and advertised with a full schema.
|
||||
@@ -437,20 +401,20 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery drives a mid-run
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
|
||||
// context-limit error that triggers reactive compaction+retry while deferral is
|
||||
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
|
||||
// degraded to the empty-loaded/no-discovery state: it must still advertise the
|
||||
// loaded tool's FULL schema and keep tool_search discovery for hidden tools.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
// degraded to the empty-loaded/no-reminder state: it must still advertise the
|
||||
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
// load_signal asks the loop to load mcp__srv__alpha next turn.
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
@@ -528,20 +492,24 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
// The retry must carry the deferred-tools reminder as its trailing user
|
||||
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history, even on
|
||||
// The reminder must NOT persist into the returned message history, even on
|
||||
// the reactive-retry path.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,15 +544,15 @@ func (provider *connectErrorProvider) StreamCompletion(_ context.Context, reques
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery but drives the
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
|
||||
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
|
||||
// context-limit error (rather than a mid-stream StreamEventError). With deferral
|
||||
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
|
||||
// advertise the loaded tool's FULL schema AND carry tool_search discovery —
|
||||
// i.e. the first reactive block reuses exposed, not the empty-loaded
|
||||
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
|
||||
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
|
||||
// partition.
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
@@ -657,19 +625,22 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
// The retry must carry the deferred-tools reminder as its trailing user message.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,7 +649,8 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
|
||||
// regression: N deferred tools are registered alongside tool_search, the operator
|
||||
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
|
||||
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED.
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
|
||||
// the reminder never points the model at an unreachable tool.
|
||||
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
|
||||
@@ -695,14 +667,14 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
EnabledTools: append([]string{}, deferredNames...),
|
||||
}
|
||||
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
|
||||
// Deferral active => non-empty tool_search discovery for hidden deferred tools.
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected deferral active (non-empty discovery) at threshold N, got empty")
|
||||
// Deferral active => non-empty reminder advertising the hidden deferred tools.
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
|
||||
}
|
||||
if !strings.Contains(discovery, "tool_search") {
|
||||
t.Fatalf("discovery must instruct the model to call tool_search, got %q", discovery)
|
||||
if !strings.Contains(reminder, "tool_search") {
|
||||
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
|
||||
}
|
||||
|
||||
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
|
||||
@@ -713,10 +685,6 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil || search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text, got %#v discovery=%q", search, discovery)
|
||||
}
|
||||
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
|
||||
for _, name := range deferredNames {
|
||||
if exposedNames[name] {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||
DisabledTools: []string{"read_file"},
|
||||
@@ -30,12 +30,12 @@ func TestDeniedPermissionResultCategories(t *testing.T) {
|
||||
if plain.DenialReason != DenialPermissionDenied {
|
||||
t.Fatalf("plain denial = %q, want %q", plain.DenialReason, DenialPermissionDenied)
|
||||
}
|
||||
// Sandbox-block-driven denial.
|
||||
// Sandbox-violation-driven denial.
|
||||
sandboxDenied := deniedPermissionResult(ToolCall{ID: "c2", Name: "bash"}, "outside workspace", PermissionEvent{
|
||||
ToolName: "bash",
|
||||
Block: &sandbox.Block{Code: "outside_workspace"},
|
||||
ToolName: "bash",
|
||||
Violation: &sandbox.Violation{Code: "outside_workspace"},
|
||||
})
|
||||
if sandboxDenied.DenialReason != DenialSandboxBlock {
|
||||
t.Fatalf("sandbox denial = %q, want %q", sandboxDenied.DenialReason, DenialSandboxBlock)
|
||||
if sandboxDenied.DenialReason != DenialSandboxViolation {
|
||||
t.Fatalf("sandbox denial = %q, want %q", sandboxDenied.DenialReason, DenialSandboxViolation)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
|
||||
type argsPermTool struct{ perm tools.Permission }
|
||||
|
||||
func (argsPermTool) Name() string { return "args" }
|
||||
func (argsPermTool) Description() string { return "" }
|
||||
func (argsPermTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (argsPermTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
|
||||
func (argsPermTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
func (t argsPermTool) PermissionForArgs(map[string]any) tools.Permission { return t.perm }
|
||||
|
||||
type plainTool struct{ perm tools.Permission }
|
||||
|
||||
func (plainTool) Name() string { return "plain" }
|
||||
func (plainTool) Description() string { return "" }
|
||||
func (plainTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (t plainTool) Safety() tools.Safety { return tools.Safety{Permission: t.perm} }
|
||||
func (plainTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
|
||||
func TestEffectivePermission(t *testing.T) {
|
||||
// A tool implementing ArgsPermissioner refines its permission per call.
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("args-aware tool: got %q, want allow", got)
|
||||
}
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("args-aware tool: got %q, want prompt", got)
|
||||
}
|
||||
// A tool WITHOUT the interface falls back to its static Safety().Permission —
|
||||
// the args-aware path must never change a normal tool's behavior.
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("plain tool: got %q, want its static allow", got)
|
||||
}
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("plain tool: got %q, want its static prompt", got)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
)
|
||||
|
||||
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
|
||||
// slow or wedged language server can never hang a tool call; on timeout the
|
||||
// edit simply reports without a diagnostics block.
|
||||
const fileDiagnosticsTimeout = 10 * time.Second
|
||||
|
||||
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
|
||||
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
|
||||
// checks it against the file's language server, and formats error-severity
|
||||
// diagnostics for the model. Warnings and hints are excluded — nagging about
|
||||
// style on every edit is noise, while a type error the edit just introduced is
|
||||
// exactly what the model should see before its next step. Diagnostics are
|
||||
// rendered with workspace-relative paths: the absolute path would put the
|
||||
// local username/home directory into the model prompt and session transcript
|
||||
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
|
||||
// entirely.
|
||||
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, absPath string) string {
|
||||
text, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
|
||||
defer cancel()
|
||||
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
|
||||
if len(errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
|
||||
}
|
||||
}
|
||||
|
||||
// diagnosticsDisplayPath renders absPath relative to the workspace root for
|
||||
// model-facing output, falling back to the file's base name when the path is
|
||||
// outside the workspace (a bare name still identifies the file without
|
||||
// exposing the directory layout).
|
||||
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
|
||||
if workspaceRoot != "" {
|
||||
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return filepath.Base(absPath)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Diagnostics are model-facing: absolute paths would leak the local username
|
||||
// and directory layout into the prompt and session transcript on every edit.
|
||||
func TestDiagnosticsDisplayPath(t *testing.T) {
|
||||
root := filepath.Join("/Users", "someone", "project")
|
||||
cases := []struct {
|
||||
root, abs, want string
|
||||
}{
|
||||
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
|
||||
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
|
||||
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
|
||||
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-365
@@ -21,12 +21,6 @@ const (
|
||||
// calls have executed since the last update_plan call.
|
||||
staleToolCallThreshold = 10
|
||||
|
||||
// stalePlanTurnThreshold injects the same one-shot reminder once this many
|
||||
// turns have passed since the last update_plan while plan items are still
|
||||
// pending — the turn-based complement to staleToolCallThreshold, catching a
|
||||
// plan that drifts stale across many turns that each make few tool calls.
|
||||
stalePlanTurnThreshold = 8
|
||||
|
||||
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
|
||||
// many consecutive turns contain tool calls but no visible assistant text.
|
||||
// It does not stop the run; it tells the model to synthesize what it already
|
||||
@@ -48,278 +42,10 @@ const (
|
||||
toolFailureHintAt = 2
|
||||
// toolFailureStopAt halts the run after a tool fails this many times in a row
|
||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
|
||||
// and a model iterating on a genuinely tricky edit can legitimately fail a few
|
||||
// times after the hint while converging — stopping at 4 cut those runs short.
|
||||
// The streak still resets the moment the tool succeeds or hits a different
|
||||
// error, so this only affects true same-error loops.
|
||||
toolFailureStopAt = 6
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
// tool call while work clearly remained. Once spent, the run finalizes as
|
||||
// INCOMPLETE rather than nudging forever (and it is still bounded by maxTurns
|
||||
// and the run deadline).
|
||||
maxContinueNudges = 3
|
||||
// bad call.
|
||||
toolFailureStopAt = 4
|
||||
)
|
||||
|
||||
// continueNudgeMarker is a stable substring for tests.
|
||||
const continueNudgeMarker = "the task is not finished"
|
||||
|
||||
// continueNudge tells a model that stopped without a tool call — while work
|
||||
// clearly remained — to keep going, or to mark the plan complete and summarize if
|
||||
// it is genuinely done. The second path gives it a clean route to a legitimate
|
||||
// completion (a finished plan + no continuation cue then exits as success).
|
||||
func continueNudge(reason string) string {
|
||||
return "You stopped without calling a tool, but " + continueNudgeMarker + " (" + reason + "). " +
|
||||
"Do not stop here: take the next concrete action with a tool now. " +
|
||||
"If you are genuinely finished, first mark the plan complete with update_plan, then give your final summary."
|
||||
}
|
||||
|
||||
// endsWithContinuationCue reports whether an assistant message ends mid-thought —
|
||||
// the model announcing its OWN next action and then stopping on a colon
|
||||
// ("…Let me check the SSH configuration:") rather than concluding. It requires
|
||||
// BOTH a trailing colon AND an action lead-in on the last line, so genuine closers
|
||||
// are NOT flagged: a forward-looking recommendation ("Next, I suggest reviewing
|
||||
// the changes."), a sign-off ("Let me know if you need anything"), or a summary
|
||||
// that merely ends in a colon ("Here is the summary:"). A bare trailing colon alone
|
||||
// is too common in legitimate final answers to use as a signal.
|
||||
func endsWithContinuationCue(text string) bool {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
last := ""
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if s := strings.TrimSpace(lines[i]); s != "" {
|
||||
last = strings.ToLower(s)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(last, ":") {
|
||||
return false
|
||||
}
|
||||
// Inspect the final clause (after the last sentence break) so a mid-line action
|
||||
// announcement is caught ("…configure the server. Let me check the config:")
|
||||
// while a plain summary colon ("Here is the summary:") or a recommendation is not.
|
||||
clause := last
|
||||
if idx := strings.LastIndex(last, ". "); idx >= 0 {
|
||||
clause = strings.TrimSpace(last[idx+2:])
|
||||
}
|
||||
if strings.HasPrefix(clause, "let me know") { // sign-off, not a mid-step action
|
||||
return false
|
||||
}
|
||||
for _, cue := range []string{
|
||||
"let me ", "let's ", "now i'll ", "now i will ", "now let me ", "let me now ",
|
||||
"i'll now ", "i will now ", "next i'll ", "next, i'll ", "first, i'll ", "first let me ",
|
||||
} {
|
||||
if strings.HasPrefix(clause, cue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planStatusRemaining reports whether a raw update_plan status string represents
|
||||
// unfinished work. Anything not clearly completed/failed (incl. empty/unknown,
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch normalizeTaskPlanStatus(status) {
|
||||
case "completed", "failed":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// acceptanceNudgeMarker is a stable substring for tests; it is embedded verbatim
|
||||
// in acceptanceVerificationNudge.
|
||||
const acceptanceNudgeMarker = "verify your work against the task's stated success criterion"
|
||||
|
||||
// selfReportPhrases are high-signal admissions of guessing/fabrication or stated
|
||||
// uncertainty about the produced result. They are matched by plain substring with
|
||||
// NO context guard, so every entry must be FIRST-PERSON or unambiguously about the
|
||||
// model's own output — NOT a phrase that also describes implemented behavior.
|
||||
// (Earlier versions included "fall back to" / "placeholder value" / "as a
|
||||
// fallback" / bare "best guess" / "without proper", which match legitimate final
|
||||
// answers like "the parser will fall back to UTF-8" or "I replaced the placeholder
|
||||
// value", wrongly downgrading completed runs — so those are removed. Admissions of
|
||||
// INABILITY are handled separately by inabilityStems, which carry a guard.)
|
||||
var selfReportPhrases = []string{
|
||||
// first-person admissions of guessing / fabrication / assumption
|
||||
"i guessed", "my best guess", "i'm guessing", "this is a guess", "just a guess",
|
||||
"i made it up", "i fabricated", "i assumed a", "i had to assume",
|
||||
// first-person lack of capability (inability stems also cover cannot/could-not)
|
||||
"i do not have the ability", "i don't have the ability", "i lack the ability",
|
||||
"no way for me to", "not possible for me to",
|
||||
// stated uncertainty about the correctness of the produced result
|
||||
"may not be correct", "might not be correct", "may be incorrect", "might be incorrect",
|
||||
"this may not work", "this might not work", "not fully functional", "not fully working",
|
||||
}
|
||||
|
||||
// inabilityStems are first-person "I cannot / can't / could not / am unable to /
|
||||
// do not have" stems. Matching the STEM (not a fixed verb) generalizes over
|
||||
// whatever action the model claims it could not perform — "analyze", "determine",
|
||||
// "do", "complete", "verify", "see", etc. — so the detector is not defeated by
|
||||
// re-phrasing (the chess case slipped a fixed "cannot analyze" list by writing
|
||||
// "…which I cannot do without proper image analysis capabilities").
|
||||
var inabilityStems = []string{
|
||||
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
|
||||
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
|
||||
"i was not able to", "i do not have", "i don't have", "unable to ",
|
||||
"without being able to",
|
||||
}
|
||||
|
||||
// successNegationTails are negated phrasings that indicate SUCCESS, not an
|
||||
// admission ("I could not find any remaining issues", "I cannot reproduce the
|
||||
// bug"). When an inability stem is immediately followed by one of these, it is not
|
||||
// treated as an admission, so a clean result is not misreported as INCOMPLETE.
|
||||
var successNegationTails = []string{
|
||||
"find any", "found any", "find a ", "see any", "detect any", "identify any",
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
|
||||
// reporting the outcome of the current objective, so an inability admission in
|
||||
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
|
||||
// conversational recap ("You asked if I could work autonomously … I was honest
|
||||
// that my sandbox had no repo … so I couldn't actually do it at the time") was
|
||||
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
|
||||
// current turn's objective (summarize where we left off) was fully met. Every
|
||||
// marker is second-person or explicitly past-referential, so a first-person
|
||||
// admission about the current task ("I couldn't complete the refactor") is
|
||||
// never masked.
|
||||
var narrativeMarkers = []string{
|
||||
"you asked", "you said", "you wanted", "you mentioned",
|
||||
"we discussed", "we talked", "we covered",
|
||||
"when we last", "last time", "last session", "previous session",
|
||||
"previous conversation", "earlier session", "earlier conversation",
|
||||
"at the time", "back then",
|
||||
}
|
||||
|
||||
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
|
||||
// backticks, so an admission the model merely QUOTES — its own earlier message,
|
||||
// a log line, an error string — cannot fire the detector. Only BALANCED spans
|
||||
// are removed: an opening delimiter that never closes is kept as literal text,
|
||||
// so a stray quote cannot swallow the rest of the message (and with it a
|
||||
// genuine admission the detector must see). Single quotes are left alone: they
|
||||
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
|
||||
// delimiters would swallow the text between two contractions.
|
||||
func stripQuoted(s string) string {
|
||||
var b strings.Builder
|
||||
var span strings.Builder // pending text since the open delimiter, kept if it never closes
|
||||
open := rune(0)
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case open != 0:
|
||||
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
|
||||
open = 0
|
||||
span.Reset()
|
||||
continue
|
||||
}
|
||||
span.WriteRune(r)
|
||||
case r == '"' || r == '“' || r == '`':
|
||||
open = r
|
||||
span.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// admissionSentences splits lowered text into sentence-ish fragments so the
|
||||
// detector judges each claim in its own context. Newlines split too (markdown
|
||||
// bullets are separate claims); the exact boundaries only need to keep an
|
||||
// admission next to its own narrative/negation context, not be grammatical.
|
||||
func admissionSentences(lower string) []string {
|
||||
return strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return r == '.' || r == '!' || r == '?' || r == '\n'
|
||||
})
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
// Matching is per sentence, after dropping quoted spans, and a sentence that
|
||||
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
|
||||
// must be the model's own report about the CURRENT objective, not general
|
||||
// language that merely resembles one.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
|
||||
if containsAny(sentence, narrativeMarkers) {
|
||||
continue
|
||||
}
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(sentence, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(sentence[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(sentence[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAny(s string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
|
||||
func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptanceVerificationNudge forces a TASK-GROUNDED acceptance check before a run
|
||||
// may finalize as success. It explicitly rejects the three false-success patterns
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge(objective string) string {
|
||||
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
"execute the program or tests that demonstrate the required behavior, or directly probe the specific " +
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
if objective = capTaskObjective(objective); objective != "" {
|
||||
nudge += "\n\nTask objective: " + objective
|
||||
}
|
||||
return nudge
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
const toolFailureHintMarker = "kept failing with the same error"
|
||||
|
||||
@@ -364,48 +90,11 @@ func toolFailureStopAnswer(toolName string, count int) string {
|
||||
"Please check the request or adjust the tool arguments."
|
||||
}
|
||||
|
||||
// The no-output stop answer is assembled from these fixed parts (only the turn
|
||||
// count varies). IsNoProgressStop matches all three so a legitimate message that
|
||||
// merely quotes the marker substring is not misclassified as a failed empty run.
|
||||
const (
|
||||
noOutputStopPrefix = "Agent stopped after "
|
||||
noOutputStopMarker = "with no output (no visible text and no tool calls)"
|
||||
noOutputStopSuffix = "to avoid consuming tokens without making progress."
|
||||
)
|
||||
|
||||
// noOutputStopAnswer is the final answer returned when the no-output guard
|
||||
// stops the run. The turn count is interpolated at the call site.
|
||||
func noOutputStopAnswer(turns int) string {
|
||||
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
|
||||
}
|
||||
|
||||
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
|
||||
// (a run that produced no visible text and no tool calls). It matches the EXACT
|
||||
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
|
||||
// suffix, where only the integer turn count varies — rather than just looking for
|
||||
// the three parts in order. A loose check (prefix && contains-marker && suffix)
|
||||
// would misclassify a genuine assistant/tool message that merely quotes the
|
||||
// marker amid other prose, which would wrongly hide a real session from /resume
|
||||
// and skip its title generation.
|
||||
func IsNoProgressStop(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
|
||||
return false
|
||||
}
|
||||
rest := trimmed[len(noOutputStopPrefix):]
|
||||
const turnsSep = " turns "
|
||||
sep := strings.Index(rest, turnsSep)
|
||||
if sep < 0 {
|
||||
return false
|
||||
}
|
||||
// The text between the prefix and " turns " must be exactly the bare integer
|
||||
// count; anything else means this isn't the guard's own answer.
|
||||
if _, err := strconv.Atoi(rest[:sep]); err != nil {
|
||||
return false
|
||||
}
|
||||
// The marker must be immediately followed (one space) by the suffix and then
|
||||
// end — no arbitrary text wedged in between.
|
||||
return rest[sep+len(turnsSep):] == noOutputStopMarker+" "+noOutputStopSuffix
|
||||
return "Agent stopped after " + strconv.Itoa(turns) +
|
||||
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
|
||||
}
|
||||
|
||||
// Reminder markers are stable substrings used both to build the reminder text
|
||||
@@ -443,23 +132,14 @@ type guardState struct {
|
||||
emptyTurns int
|
||||
totalToolCalls int
|
||||
toolCallsSincePlanUpdate int
|
||||
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
|
||||
// update_plan, so a plan that goes stale across many low-tool-call turns is
|
||||
// still caught — the tool-call counter alone can take many turns to trip when
|
||||
// the model makes only one call per turn.
|
||||
turnsSincePlanUpdate int
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
// staleReminderSent records whether the stale reminder has already fired for
|
||||
// the current stale interval. It is cleared when a plan update opens a new
|
||||
// interval, making the reminder one-shot per interval rather than per turn.
|
||||
staleReminderSent bool
|
||||
toolOnlyTurns int
|
||||
toolOnlyReminderSent bool
|
||||
// planItemsPending is the number of remaining (pending/in_progress) items in
|
||||
// the most recent update_plan call, so the headless completion gate can tell
|
||||
// whether work is unfinished when the model stops without a tool call.
|
||||
planItemsPending int
|
||||
// toolFailures tracks consecutive same-error failures per tool, keyed by tool
|
||||
// name, so the loop can hint then halt instead of looping forever.
|
||||
toolFailures map[string]*toolFailureRecord
|
||||
@@ -508,9 +188,8 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
|
||||
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
|
||||
hasToolCalls := len(collected.ToolCalls) > 0
|
||||
hasVisibleText := strings.TrimSpace(collected.Text) != ""
|
||||
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
|
||||
|
||||
if hasToolCalls || hasVisibleText || hasReasoning {
|
||||
if hasToolCalls || hasVisibleText {
|
||||
state.emptyTurns = 0
|
||||
} else {
|
||||
state.emptyTurns++
|
||||
@@ -522,21 +201,13 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
state.toolOnlyReminderSent = false
|
||||
}
|
||||
|
||||
// One turn has passed; the plan-update below resets this to 0 when the model
|
||||
// refreshes the plan this turn.
|
||||
state.turnsSincePlanUpdate++
|
||||
|
||||
for _, call := range collected.ToolCalls {
|
||||
state.totalToolCalls++
|
||||
if call.Name == planToolName {
|
||||
state.planEverCalled = true
|
||||
state.toolCallsSincePlanUpdate = 0
|
||||
state.turnsSincePlanUpdate = 0
|
||||
// A fresh plan update opens a new stale interval.
|
||||
state.staleReminderSent = false
|
||||
// Record how many items remain so the completion gate knows whether
|
||||
// work is unfinished if the model later stops without a tool call.
|
||||
state.observePlanUpdate(call.Arguments)
|
||||
} else {
|
||||
state.toolCallsSincePlanUpdate++
|
||||
}
|
||||
@@ -545,29 +216,6 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
return state.emptyTurns >= maxEmptyTurns
|
||||
}
|
||||
|
||||
// pendingPlanItems reports whether the most recent update_plan call still has
|
||||
// unfinished (pending/in_progress) items. False when no plan was ever recorded.
|
||||
func (state *guardState) pendingPlanItems() bool {
|
||||
return state.planItemsPending > 0
|
||||
}
|
||||
|
||||
// observePlanUpdate parses an update_plan call's raw arguments and records how
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
state.planItemsPending = pending
|
||||
}
|
||||
|
||||
func (state *guardState) progressReminder() string {
|
||||
if state.toolOnlyReminderSent || state.toolOnlyTurns < toolOnlyProgressReminderAt {
|
||||
return ""
|
||||
@@ -581,12 +229,10 @@ func (state *guardState) progressReminder() string {
|
||||
// number of turns completed so far).
|
||||
func (state *guardState) planReminder(turn int) string {
|
||||
// STALE reminder takes priority: a long run without a plan update is the
|
||||
// stronger signal. Fires on either the tool-call streak OR a turn streak with
|
||||
// pending items (so a plan drifting stale across many low-call turns is caught,
|
||||
// while a fully-completed plan is left alone). One-shot per stale interval.
|
||||
if state.planEverCalled && !state.staleReminderSent &&
|
||||
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
|
||||
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
|
||||
// stronger signal. One-shot per stale interval.
|
||||
if state.planEverCalled &&
|
||||
!state.staleReminderSent &&
|
||||
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
state.staleReminderSent = true
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
@@ -22,14 +22,6 @@ func textTurn(content string) []zeroruntime.StreamEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTurn produces live reasoning without visible assistant text.
|
||||
func reasoningTurn(content string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventReasoning, Content: content},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
}
|
||||
|
||||
// toolTurn produces a turn that calls a named tool with the given args JSON.
|
||||
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
@@ -109,36 +101,11 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningTurn("thinking 1"),
|
||||
reasoningTurn("thinking 2"),
|
||||
reasoningTurn("thinking 3"),
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -169,37 +136,6 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of #702's fix: the unknown-session error's normalized
|
||||
// signature must not vary with the session id, so a model probing ids 1, 2,
|
||||
// 3, … produces one repeated signature the failure guard can count. If the id
|
||||
// leaked into the first 80 normalized chars, each probe would reset the streak
|
||||
// and the halt would never fire.
|
||||
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
|
||||
a := errorSignature(tools.UnknownExecSessionError(1))
|
||||
b := errorSignature(tools.UnknownExecSessionError(999999))
|
||||
if a != b {
|
||||
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: with an id-invariant signature, probing a different unknown id
|
||||
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
|
||||
// before the fix it never would.
|
||||
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
|
||||
var state guardState
|
||||
var stoppedAt int
|
||||
for i := 1; i <= toolFailureStopAt; i++ {
|
||||
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
|
||||
if out.Stop {
|
||||
stoppedAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if stoppedAt != toolFailureStopAt {
|
||||
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||
var state guardState
|
||||
toolOnly := zeroruntime.CollectedStream{
|
||||
@@ -260,7 +196,7 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -291,7 +227,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -319,7 +255,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{
|
||||
@@ -349,7 +285,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
||||
@@ -383,7 +319,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
turns := [][]zeroruntime.StreamEvent{
|
||||
@@ -418,7 +354,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsImageRejectionError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "400 with image mention",
|
||||
err: errors.New("HTTP 400: image content is not supported by this model"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "400 with multimodal mention",
|
||||
err: errors.New("400 Bad Request: model does not support multimodal input"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "400 with vision mention",
|
||||
err: errors.New("error 400: this model does not have vision capabilities"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "400 with unsupported content type",
|
||||
err: errors.New("400: unsupported content type"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "generic 400 no image keywords",
|
||||
err: errors.New("400 Bad Request: invalid model id"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "500 server error",
|
||||
err: errors.New("500 Internal Server Error"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "context limit error",
|
||||
err: errors.New("context length exceeded"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isImageRejectionError(tt.err); got != tt.want {
|
||||
t.Errorf("isImageRejectionError(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user